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 content. Paste events deliver raw bytes, not decoded text.
PasteEvent Object
import { type PasteEvent } from "@opentui/core"
interface PasteEvent {
type: "paste" // Always "paste"
bytes: Uint8Array // Raw pasted bytes
metadata?: PasteMetadata // Optional metadata
preventDefault(): void // Prevent default paste handling
defaultPrevented: boolean // Whether preventDefault was called
}
interface PasteMetadata {
mimeType?: string // MIME type if available
kind?: PasteKind // Paste kind
}Decoding Paste Bytes
Use decodePasteBytes to convert raw bytes to a string, and stripAnsiSequences to remove ANSI escape codes:
import { decodePasteBytes, stripAnsiSequences } from "@opentui/core"
const text = decodePasteBytes(event.bytes) // Decode UTF-8
const clean = stripAnsiSequences(decodePasteBytes(event.bytes)) // Decode + strip ANSICore
import { type PasteEvent, decodePasteBytes } from "@opentui/core"
renderer.keyInput.on("paste", (event: PasteEvent) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})React and Solid
Both reconciler packages provide a dedicated usePaste hook:
import { usePaste } from "@opentui/react" // or @opentui/solid
import { decodePasteBytes } from "@opentui/core"
function App() {
usePaste((event) => {
const text = decodePasteBytes(event.bytes)
console.log("Pasted:", text)
})
return <text>Paste something</text>
}Text Selection
Text selection is renderer-managed. The renderer owns a single Selection object, walks the renderable tree to find selectable children, and emits a "selection" event when the user finishes selecting (mouse-up). The Selection object aggregates text from all selected renderables automatically.
Text-buffer renderables use repeated left-button presses: a first press/drag selects cells, a second press selects a word, and a third press selects a logical line (including soft wraps). Repeated presses must hit the same renderable within 500 ms and within one cell. Dragging after the second or third press extends by words or lines. ASCII Font, TextTable, and Embedded Terminal keep component-specific cell selection.
Making Renderables Selectable
A renderable must have selectable set to true to participate in selection. Text-based renderables (TextRenderable, TextareaRenderable, ASCIIFontRenderable, TextTableRenderable) support this:
// React / Solid
<text selectable>This text can be selected</text>
// Core
const text = new TextRenderable(renderer, {
id: "label",
content: "This text can be selected",
selectable: true,
})Copy-on-Selection (Core)
Listen to the renderer's "selection" event. The Selection object's getSelectedText() returns text aggregated from all selected renderables in reading order:
import type { Selection } from "@opentui/core"
renderer.on("selection", (selection: Selection) => {
const text = selection.getSelectedText()
if (text) {
renderer.copyToClipboardOSC52(text)
}
})Important: Call
selection.getSelectedText()on theSelectionobject from the event -- notrenderer.root.getSelectedText(). Individual renderables only return their own selected text. TheSelectionobject aggregates across the tree.
Copy-on-Selection (React or Solid)
import { useSelectionHandler } from "@opentui/react" // or @opentui/solid
function App() {
useSelectionHandler((selection) => {
const text = selection.getSelectedText()
if (text) {
renderer.copyToClipboardOSC52(text)
}
})
return <text selectable>Select this text</text>
}Selection Object
The Selection object passed to the event callback:
selection.getSelectedText() // Aggregated text from all selected renderables
selection.bounds // { startX, startY, endX, endY } bounding rect
selection.selectedRenderables // Renderable[] with active selections
selection.isActive // Whether selection is still active
selection.behavior // "cell" | "word" | "line"
selection.anchor // Global anchor cell
selection.focus // Global focus cellFor text-buffer renderables, getSelection() returns a half-open
{ start, end } range measured in terminal display columns; line breaks add
one unit. These offsets are not JavaScript UTF-16 string indexes.
Individual renderables also expose:
renderable.hasSelection() // Does this renderable have selected text?
renderable.getSelectedText() // Selected text in this renderable onlyHow Selection Traversal Works
When the user drags to select, the renderer:
- Identifies the selection container (common ancestor of start and end points)
- Walks all
selectabledescendants within the selection bounds - Calls
onSelectionChanged(selection)on each, which computes local selection - Tracks which renderables have active selections in
selection.selectedRenderables
This means selection works across multiple renderables. Dragging across two <text selectable> elements selects text in both, and selection.getSelectedText() joins them with newlines.
Clipboard Services
Use the composed clipboard service when an application needs host reads/writes or needs to choose safely between the host and terminal clipboard:
import {
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
} from "@opentui/core"
const clipboard = createClipboard({
host: createHostClipboard(),
terminal: createRendererClipboardAdapter(renderer),
})
await clipboard.writeText("Hello", { destination: "best-available" })
const result = await clipboard.read({ preferredTypes: ["text/plain"] })
if (result.status === "read") {
console.log(new TextDecoder().decode(result.representation.bytes))
}
await clipboard.dispose()Write/clear destinations are terminal-only, host-only, best-available,
and all-available. In a remote session, the host clipboard belongs to the
server; host operations are skipped unless allowRemoteHost: true, while the
terminal adapter sends OSC 52 to the remote client. Reads always use the process
host. ClipboardService owns its host service, and renderer.destroy() does
not dispose a clipboard service created by the application.
Use createHostClipboard() by itself when no renderer is available. Host reads
accept ordered MIME preferences and can return text or platform-supported image
data. Operations support AbortSignal, timeouts, size limits, and clipboard
or Linux primary selection.
Low-level OSC 52
Copy text to the system clipboard using OSC 52 escape sequences. Works over SSH and in most modern terminal emulators.
// Copy to clipboard
const success = renderer.copyToClipboardOSC52("text to copy")
// Check if OSC 52 is supported
if (renderer.isOsc52Supported()) {
renderer.copyToClipboardOSC52("Hello!")
}
// Clear clipboard
renderer.clearClipboardOSC52()
// Target specific clipboard (X11)
import { ClipboardTarget } from "@opentui/core"
renderer.copyToClipboardOSC52("text", ClipboardTarget.Primary) // X11 primary
renderer.copyToClipboardOSC52("text", ClipboardTarget.Clipboard) // System clipboard (default)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 charactersTo 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.
See Also
- React API -
useKeyboardhook reference - Solid API -
useKeyboardhook reference - Input Components - Focus management with input, textarea, select
- Testing - Simulating key presses in tests