All skills
msmps avatar

/opentui

@0d61d6d
by Matt Simpsonmsmps/opentui-skill225 stars
9

OpenTUI skill for building terminal user interfaces with the Core, React, or Solid APIs. Use for any TUI task including components, layout, keyboard and keymap handling, animations, and testing.

Use this Skill: https://skilld.dev/gh/msmps/opentui-skill/opentui

This session only. Nothing lands on disk.

referenceskeyboardREFERENCE.md

≈4k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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:

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 ANSI

Core

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 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 (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 cell

For 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 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 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 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+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

Source: SKILL.md on GitHub

No alerts17d5 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    This skill contains documentation and usage patterns for the OpenTUI framework, designed for building terminal user interfaces across Core, React, and SolidJS. The analysis confirmed that the skill provides legitimate development guidance; no malicious code, persistence mechanisms, or obfuscation were detected. Findings are limited to standard platform capabilities and recommended scaffolding tools.

  • Socket17d

    No alerts

  • Snyk17d

    Risk: LOW · No issues

  • Runlayer7mo

    1/26 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 0d61d6d. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Steadyupdated last month
Other metadata
metadata
{
  "references": "core, react, solid, components, layout, keyboard, keymap, animation, testing"
}
  • React
  • Testing
  • terminal-ui
  • tui
  • solid
  • components
  • keyboard-input
  • animations
  • layout

README badge

README badge for msmps/opentui-skill

Builds terminal user interfaces with OpenTUI using an imperative core API or React/Solid reconcilers. Covers layout, keyboard handling, animations, components, and testing for full-featured TUI applications.

Generated from the current SKILL.md.

Does this skill cover all three OpenTUI frameworks?
Yes. The skill consolidates the core imperative API, React reconciler, and Solid reconciler. Use the decision tree in the skill to pick the right framework for your task.
How do I start a new OpenTUI project?
Use `create-tui` with options before arguments: `bunx create-tui -t react my-app` (not `bunx create-tui my-app -t react`). Then read the REFERENCE.md for your chosen framework.
What runtime does OpenTUI require?
OpenTUI runs on Bun and uses Zig for native builds. See `./references/core/gotchas.md` for runtime requirements and build guidance.
Can I use `process.exit()` to shut down my TUI?
No. Always use `renderer.destroy()` instead. Calling `process.exit()` directly is a common pitfall covered in `core/gotchas.md`.
How do I apply text styling in React or Solid?
Use nested modifier elements, not props. See `components/text-display.md` for the correct pattern.

Generated from the current SKILL.md. These answers refresh after source changes.