8.5 KiB
Keyboard Input Handling
How to handle keyboard input in OpenTUI applications.
Overview
OpenTUI provides keyboard input handling through:
- Core:
renderer.keyInputEventEmitter - React:
useKeyboard()hook - Solid:
useKeyboard()hook
When to Use
Use this reference when you need keyboard shortcuts, focus-aware input handling, or custom keybindings.
KeyEvent Object
All keyboard handlers receive a KeyEvent object:
interface KeyEvent {
name: string // Key name: "a", "escape", "f1", etc.
sequence: string // Raw escape sequence
ctrl: boolean // Ctrl modifier held
shift: boolean // Shift modifier held
meta: boolean // Alt modifier held
option: boolean // Option modifier held (macOS)
eventType: "press" | "release" | "repeat"
repeated: boolean // Key is being held (repeat event)
}
Basic Usage
Core
import { createCliRenderer, type KeyEvent } from "@opentui/core"
const renderer = await createCliRenderer()
renderer.keyInput.on("keypress", (key: KeyEvent) => {
if (key.name === "escape") {
renderer.destroy()
return
}
if (key.ctrl && key.name === "s") {
saveDocument()
}
})
React
import { useKeyboard, useRenderer } from "@opentui/react"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to exit</text>
}
Solid
import { useKeyboard, useRenderer } from "@opentui/solid"
function App() {
const renderer = useRenderer()
useKeyboard((key) => {
if (key.name === "escape") {
renderer.destroy()
}
})
return <text>Press ESC to exit</text>
}
Key Names
Alphabetic Keys
Lowercase: a, b, c, ... z
With Shift: Check key.shift && key.name === "a" for uppercase
Numeric Keys
0, 1, 2, ... 9
Function Keys
f1, f2, f3, ... f12
Special Keys
| Key Name | Description |
|---|---|
escape |
Escape key |
enter |
Enter/Return |
return |
Enter/Return (alias) |
tab |
Tab key |
backspace |
Backspace |
delete |
Delete key |
space |
Spacebar |
Arrow Keys
| Key Name | Description |
|---|---|
up |
Up arrow |
down |
Down arrow |
left |
Left arrow |
right |
Right arrow |
Navigation Keys
| Key Name | Description |
|---|---|
home |
Home key |
end |
End key |
pageup |
Page Up |
pagedown |
Page Down |
insert |
Insert key |
Modifier Keys
Check modifier properties on KeyEvent:
renderer.keyInput.on("keypress", (key) => {
if (key.ctrl && key.name === "c") {
// Ctrl+C
}
if (key.shift && key.name === "tab") {
// Shift+Tab
}
if (key.meta && key.name === "s") {
// Alt+S (meta = Alt on most systems)
}
if (key.option && key.name === "a") {
// Option+A (macOS)
}
})
Modifier Combinations
// Ctrl+Shift+S
if (key.ctrl && key.shift && key.name === "s") {
saveAs()
}
// Ctrl+Alt+Delete (careful with system shortcuts!)
if (key.ctrl && key.meta && key.name === "delete") {
// ...
}
Event Types
Press Events (Default)
Normal key press:
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "press") {
// Initial key press
}
})
Repeat Events
Key held down:
renderer.keyInput.on("keypress", (key) => {
if (key.eventType === "repeat" || key.repeated) {
// Key is being held
}
})
Release Events
Key released (opt-in):
// React
useKeyboard(
(key) => {
if (key.eventType === "release") {
// Key released
}
},
{ release: true } // Enable release events
)
// Solid
useKeyboard(
(key) => {
if (key.eventType === "release") {
// Key released
}
},
{ release: true }
)
Patterns
Navigation Menu
function Menu() {
const [selectedIndex, setSelectedIndex] = useState(0)
const items = ["Home", "Settings", "Help", "Quit"]
useKeyboard((key) => {
switch (key.name) {
case "up":
case "k":
setSelectedIndex(i => Math.max(0, i - 1))
break
case "down":
case "j":
setSelectedIndex(i => Math.min(items.length - 1, i + 1))
break
case "enter":
handleSelect(items[selectedIndex])
break
}
})
return (
<box flexDirection="column">
{items.map((item, i) => (
<text
key={item}
fg={i === selectedIndex ? "#00FF00" : "#FFFFFF"}
>
{i === selectedIndex ? "> " : " "}{item}
</text>
))}
</box>
)
}
Modal Escape
function Modal({ onClose, children }) {
useKeyboard((key) => {
if (key.name === "escape") {
onClose()
}
})
return (
<box border padding={2}>
{children}
</box>
)
}
Vim-style Modes
function Editor() {
const [mode, setMode] = useState<"normal" | "insert">("normal")
const [content, setContent] = useState("")
useKeyboard((key) => {
if (mode === "normal") {
switch (key.name) {
case "i":
setMode("insert")
break
case "escape":
// Already in normal mode
break
case "j":
moveCursorDown()
break
case "k":
moveCursorUp()
break
}
} else if (mode === "insert") {
if (key.name === "escape") {
setMode("normal")
}
// Input component handles text in insert mode
}
})
return (
<box flexDirection="column">
<text>Mode: {mode}</text>
<textarea
value={content}
onChange={setContent}
focused={mode === "insert"}
/>
</box>
)
}
Game Controls
function Game() {
const [pressed, setPressed] = useState(new Set<string>())
useKeyboard(
(key) => {
setPressed(keys => {
const newKeys = new Set(keys)
if (key.eventType === "release") {
newKeys.delete(key.name)
} else {
newKeys.add(key.name)
}
return newKeys
})
},
{ release: true }
)
// Game logic uses pressed set
useEffect(() => {
if (pressed.has("up") || pressed.has("w")) {
moveUp()
}
if (pressed.has("down") || pressed.has("s")) {
moveDown()
}
}, [pressed])
return <text>WASD or arrows to move</text>
}
Keyboard Shortcuts Help
function ShortcutsHelp() {
const shortcuts = [
{ keys: "Ctrl+S", action: "Save" },
{ keys: "Ctrl+Q", action: "Quit" },
{ keys: "Ctrl+F", action: "Find" },
{ keys: "Tab", action: "Next field" },
{ keys: "Shift+Tab", action: "Previous field" },
]
return (
<box border title="Keyboard Shortcuts" padding={1}>
{shortcuts.map(({ keys, action }) => (
<box key={keys} flexDirection="row">
<text width={15} fg="#00FFFF">{keys}</text>
<text>{action}</text>
</box>
))}
</box>
)
}
Paste Events
Handle pasted text:
Core
renderer.keyInput.on("paste", (text: string) => {
console.log("Pasted:", text)
})
React/Solid
// Currently handled through the global keyboard listener
// or via input component's onChange
Focus and Input Components
Input components (<input>, <textarea>, <select>) capture keyboard events when focused:
<input focused /> // Receives keyboard input
// Global useKeyboard still fires, but input consumes characters
To prevent conflicts, check if an input is focused before handling global shortcuts:
function App() {
const renderer = useRenderer()
const [inputFocused, setInputFocused] = useState(false)
useKeyboard((key) => {
if (inputFocused) return // Let input handle it
// Global shortcuts
if (key.name === "escape") {
renderer.destroy()
}
})
return (
<input
focused={inputFocused}
onFocus={() => setInputFocused(true)}
onBlur={() => setInputFocused(false)}
/>
)
}
Gotchas
Terminal Limitations
Some key combinations are captured by the terminal or OS:
Ctrl+Coften sends SIGINT (useexitOnCtrlC: falseto handle)Ctrl+Zsuspends the process- Some function keys may be intercepted
SSH and Remote Sessions
Key detection may vary over SSH. Test on target environments.
Multiple Handlers
Multiple useKeyboard calls all receive events. Coordinate handlers to prevent conflicts.