Back to Cline

Keyboard Input Handling

sdk/apps/cli/.agents/skills/opentui/references/keyboard/REFERENCE.md

3.83.013.8 KB
Original Source

Keyboard Input Handling

How to handle keyboard input in OpenTUI applications.

Overview

OpenTUI provides keyboard input handling through:

  • Core: renderer.keyInput EventEmitter
  • 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:

typescript
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

typescript
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

tsx
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

tsx
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 NameDescription
escapeEscape key
enterEnter/Return
returnEnter/Return (alias)
tabTab key
backspaceBackspace
deleteDelete key
spaceSpacebar

Arrow Keys

Key NameDescription
upUp arrow
downDown arrow
leftLeft arrow
rightRight arrow
Key NameDescription
homeHome key
endEnd key
pageupPage Up
pagedownPage Down
insertInsert key

Modifier Keys

Check modifier properties on KeyEvent:

typescript
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

typescript
// 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:

typescript
renderer.keyInput.on("keypress", (key) => {
  if (key.eventType === "press") {
    // Initial key press
  }
})

Repeat Events

Key held down:

typescript
renderer.keyInput.on("keypress", (key) => {
  if (key.eventType === "repeat" || key.repeated) {
    // Key is being held
  }
})

Release Events

Key released (opt-in):

tsx
// 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

tsx
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>
  )
}
tsx
function Modal({ onClose, children }) {
  useKeyboard((key) => {
    if (key.name === "escape") {
      onClose()
    }
  })

  return (
    <box border padding={2}>
      {children}
    </box>
  )
}

Vim-style Modes

tsx
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

tsx
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

tsx
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

typescript
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:

typescript
import { decodePasteBytes, stripAnsiSequences } from "@opentui/core"

const text = decodePasteBytes(event.bytes)          // Decode UTF-8
const clean = stripAnsiSequences(decodePasteBytes(event.bytes))  // Decode + strip ANSI

Core

typescript
import { type PasteEvent, decodePasteBytes } from "@opentui/core"

renderer.keyInput.on("paste", (event: PasteEvent) => {
  const text = decodePasteBytes(event.bytes)
  console.log("Pasted:", text)
})

Solid

Solid provides a dedicated usePaste hook:

tsx
import { usePaste } from "@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>
}

Note: usePaste is Solid-only. React does not have this hook - handle paste via the Core event emitter or input component's onChange.

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.

Making Renderables Selectable

A renderable must have selectable set to true to participate in selection. Text-based renderables (TextRenderable, TextareaRenderable, ASCIIFontRenderable, TextTableRenderable) support this:

tsx
// 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:

typescript
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 the Selection object from the event -- not renderer.root.getSelectedText(). Individual renderables only return their own selected text. The Selection object aggregates across the tree.

Copy-on-Selection (Solid)

tsx
import { useSelectionHandler } from "@opentui/solid"

function App() {
  useSelectionHandler((selection) => {
    const text = selection.getSelectedText()
    if (text) {
      renderer.copyToClipboardOSC52(text)
    }
  })

  return <text selectable>Select this text</text>
}

Note: useSelectionHandler is Solid-only. React does not have this hook -- use the Core renderer.on("selection", ...) event.

Selection Object

The Selection object passed to the event callback:

typescript
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

Individual renderables also expose:

typescript
renderable.hasSelection()         // Does this renderable have selected text?
renderable.getSelectedText()      // Selected text in this renderable only

How Selection Traversal Works

When the user drags to select, the renderer:

  1. Identifies the selection container (common ancestor of start and end points)
  2. Walks all selectable descendants within the selection bounds
  3. Calls onSelectionChanged(selection) on each, which computes local selection
  4. 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 API (OSC 52)

Copy text to the system clipboard using OSC 52 escape sequences. Works over SSH and in most modern terminal emulators.

typescript
// 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:

tsx
<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:

tsx
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+C often sends SIGINT (use exitOnCtrlC: false to handle)
  • Ctrl+Z suspends 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