---
url: /guide/input-architecture.md
---
# Input Architecture

> This page documents the internal input event pipeline -- from raw terminal bytes to React component handlers. For the public API (hooks, props, commands), see [Event Handling](event-handling.md). For terminal protocol details, see [Kitty Protocol](kitty-protocol.md).

## Overview

Every keypress travels through five stages before reaching application code:

```
stdin bytes
    |
    v
 Terminal Provider          splitRawInput() -- chunk splitting, CSI buffering
    |
    v
 Parser                    parseKeypress() / parseKey() -- structured Key objects
    |
    v
 Event Loop                processEventBatch() -- batching, filtering, bridging
    |
    v
 Focus Dispatch            dispatchKeyEvent() -- capture / target / bubble phases
    |
    v
 Hooks & Handlers          useInput(), onKeyDown, withCommands()
```

Each stage has a single responsibility and passes structured data to the next. Release events, modifier-only events, and paste sequences are filtered at specific stages so downstream consumers see only what they need.

## Stage 1: Terminal Provider

`createTermProvider()` in `@silvery/ag-term` wraps stdin/stdout as a `Provider` with a typed async event stream.

**Chunk handling.** When the OS buffers key repeat events, stdin delivers multiple keystrokes in a single read (e.g., `"jjjjj"` for a held `j` key). The `onChunk()` handler splits these into individual sequences before parsing:

```
stdin data event: "jjj\x1b[A\x1b[A"
                      |
                splitRawInput()
                      |
        ["j", "j", "j", "\x1b[A", "\x1b[A"]
```

`splitRawInput()` recognizes three sequence types:

* **Single bytes** -- printable characters and ctrl codes
* **CSI sequences** -- `ESC [` ... terminator (letter or `~`)
* **SS3 sequences** -- `ESC O` + letter (function keys on some terminals)
* **Meta sequences** -- `ESC` + char or `ESC ESC` + CSI

**Cross-chunk buffering.** When a CSI sequence splits across two stdin data events (common with SGR mouse sequences), the incomplete prefix is buffered and prepended to the next chunk.

**Bracketed paste.** Paste content is detected before splitting -- the entire paste arrives as a single `{ type: "paste" }` event, not as individual keystrokes.

Each parsed sequence becomes a typed `ProviderEvent`:

* `{ type: "key", data: { input, key } }` -- keyboard input
* `{ type: "mouse", data: ParsedMouse }` -- mouse sequences
* `{ type: "paste", data: { text } }` -- bracketed paste
* `{ type: "resize", data: { cols, rows } }` -- terminal resize
* `{ type: "focus", data: { focused } }` -- terminal focus in/out

## Stage 2: Parser

`parseKeypress()` and `parseKey()` in `@silvery/ag` convert raw terminal sequences into structured `Key` objects.

### Two-layer output

`parseKey()` returns `[input, key]` where these serve different purposes:

* **`input`** is normalized for physical keybinding matching. Shifted punctuation is decomposed: `#` becomes `input="3"` with `key.shift=true`, so `shift+3` matches. Uppercase letters retain their casing and set `key.shift=true`.
* **`key.text`** is the actual typed character. Use it for literal-character hotkeys, and use `key.text ?? input` for text insertion -- this ensures Shift+3 inserts `#` and option+e inserts the accent.

Rule: physical keybindings use `input` plus modifier fields. Literal-character hotkeys and text insertion use `key.text`; `matchHotkey()` handles both representations for reusable shortcuts.

### Kitty protocol parsing

When the Kitty keyboard protocol is active, the parser handles the enhanced `CSI u` format:

```
CSI codepoint ; modifiers : eventType u
CSI codepoint : shifted : base ; modifiers : eventType u
```

This extracts:

* **Modifier flags** -- ctrl, shift, alt, super, hyper, capsLock, numLock (bitmask)
* **Event type** -- `"press"` (1), `"repeat"` (2), `"release"` (3)
* **Shifted codepoint** -- for correct shifted punctuation on non-US layouts
* **Text codepoints** -- with `REPORT_TEXT` flag (16)

Legacy CSI sequences (arrows, function keys) are also enhanced with the `:eventType` field when Kitty is active: `CSI number ; modifiers : eventType {letter|~}`.

### Default Kitty flags

Silvery enables flags 1 + 2 + 8 = **11** by default:

* **DISAMBIGUATE** (1) -- unambiguous `CSI u` encoding for all keys
* **REPORT\_EVENTS** (2) -- press, repeat, and release events
* **REPORT\_ALL\_KEYS** (8) -- even plain letters get `CSI u` encoding, enabling modifier-only detection

## Stage 3: Event Loop

`processEventBatch()` in `create-app.tsx` processes all queued provider events in a single batch before rendering. For a burst of 3 `j` presses: handler1 -> handler2 -> handler3 -> one render.

Each event's handlers still run as a React discrete event (`dispatchDiscreteEvent` from `@silvery/ag-react/reconciler`): React commits what they scheduled — effects and the updates those effects schedule included — before the next event's handlers run. A handler that reads React state therefore sees the previous key's update, exactly as if the keys had arrived separately: `\x1b[B\r` in one chunk opens the row the cursor moved to. The commit is reconciliation only; layout and paint still run once per batch. Pointer motion and wheel are continuous input and keep coalescing into the batch's render.

### Bridge to RuntimeContext

All key events are bridged to `RuntimeContext` listeners first, before any filtering:

```typescript
for (const event of events) {
  if (event.type === "term:key") {
    for (const listener of runtimeInputListeners) {
      listener(input, parsedKey)
    }
  }
}
```

This ensures `useModifierKeys()` sees every event -- including modifier-only and release events that are filtered out for app handlers.

### Event filtering

After the bridge, the event loop filters for app handlers using `isModifierOnlyEvent()` from `@silvery/ag/keys` (single source of truth — shared by useInput and create-app):

1. **Release events** (`key.eventType === "release"`) -- skipped. App handlers expect press-only semantics.
2. **Modifier-only events** (`isModifierOnlyEvent(input, key)`) -- skipped. Only `useModifierKeys()` consumes these.
3. **Press and repeat events** -- continue to focus dispatch and app handlers.

### Apply-chain substrate (Phase 2 — staged)

The ad-hoc `runtimeInputListeners` arrays and hardcoded `handleFocusNavigation` branch are being replaced with a typed apply chain. The substrate ships in `@silvery/create/runtime/`:

* **`base-app.ts`** — `createBaseApp()`. The apply chain: plugins capture `app.apply` and replace it with a wrapper that delegates to the captured function. `apply(op) -> false | Effect[]`. `dispatch(op)` runs the chain inside a reentry guard and drains effects via a queue (so `{type:"dispatch", op}` effects re-enter safely).
* **`with-terminal-chain.ts`** — observer lane (modifier state) + `term:resize` + `term:focus` (clears sticky modifiers on blur).
* **`with-input-chain.ts`** — the fallback `useInput` store, running AFTER focused dispatch. Handlers invoked in registration order; `"exit"` short-circuits.
* **`with-paste-chain.ts`** — paste routing: focused `onPaste` (via `routeToFocused`) wins; otherwise global handlers fire.
* **`with-focus-chain.ts`** — focused-element key dispatch via injected `dispatchKey`/`hasActiveFocus`. Sits outermost so focused consumes before `useInput`.
* **`event-loop.ts`** — `runEventBatch(app, events, hooks, options)`. Pure function: intercepts Ctrl+C/Ctrl+Z via `lifecycle-effects.ts`, dispatches each event to the chain, drains effects through the runner's `onRender`/`onBarrier`/`onExit`/`onSuspend`/`afterDispatch` hooks. Its one React touchpoint is the discrete-event commit: the chain dispatch and `afterDispatch` run through the same `dispatchDiscreteEvent` that `processEventBatch` uses.
* **`lifecycle-effects.ts`** — Ctrl+C / Ctrl+Z / exit / suspend / render-barrier as typed `Effect` data with constructors and detectors.

Every substrate module ships with unit tests (90 across 7 files). See `packages/create/tests/` for the authoritative contract.

The wiring — replacing `processEventBatch`'s `runtimeInputListeners + handleFocusNavigation` with `runEventBatch` on a piped chain — is staged in a follow-up commit so every step keeps behavioural equivalence tests green.

## Stage 4: Focus Dispatch

`dispatchKeyEvent()` in `@silvery/ag` routes key events through the render tree using DOM-style phases.

### Press and repeat events

Three phases, matching React DOM behavior:

1. **Capture phase** (root -> target): walks ancestors root-first, calling `onKeyDownCapture` handlers
2. **Target phase**: calls the focused node's `onKeyDown`
3. **Bubble phase** (target -> root): walks ancestors target-first, calling `onKeyDown` handlers

Any handler can call `event.stopPropagation()` to halt traversal, or `event.preventDefault()` to suppress default behavior (like focus navigation).

### Release events

Release events are currently filtered at Stage 3 (processEventBatch) before reaching focus dispatch. The `dispatchKeyEvent()` function supports `onKeyUp` routing (target + bubble, no capture phase), but processEventBatch skips release events so they never reach Stage 4. Release events are consumed by `useModifierKeys()` and `useInput({onRelease})` via RuntimeContext listeners, which are bridged before the Stage 3 filter.

> **Note:** React DOM does have both `onKeyUp` and `onKeyUpCapture`. Silvery's choice to skip the capture phase for release is a deliberate simplification, not matching React DOM.

### Focus navigation defaults

After dispatch, if no handler consumed the event, the event loop handles default focus navigation:

* **Tab** -- `focusManager.focusNext(root)`
* **Shift+Tab** -- `focusManager.focusPrev(root)`
* **Enter** on a `focusScope` node -- enter that scope
* **Escape** -- exit scope or blur

These defaults only fire when `dispatchKeyEvent()` did not set `propagationStopped` or `defaultPrevented`.

## Stage 5: Hooks and Handlers

### Hook hierarchy

All hooks are defined in `@silvery/ag-react/hooks/` and re-exported from `silvery` and `silvery/runtime`. There is ONE implementation per hook — no duplicates across packages.

| Hook                 | Purpose                           | Sees releases?         | Sees modifier-only? |
| -------------------- | --------------------------------- | ---------------------- | ------------------- |
| `useHotkey()`        | One semantic command binding      | No                     | No                  |
| `useHotkeyMap()`     | Several semantic command bindings | No                     | No                  |
| `useTextInput()`     | Typed text and bracketed paste    | No                     | No                  |
| `useInput()`         | Low-level Ink-compatible input    | Via `onRelease` option | No (filtered)       |
| `useRawKeyEvent()`   | Protocol-level key observation    | Yes                    | Yes                 |
| `useModifierKeys()`  | Track held modifier state         | Yes (all events)       | Yes                 |
| `useInputLayer()`    | Layered input with bubbling       | No                     | No                  |
| `useExit()`          | Programmatic exit                 | N/A                    | N/A                 |
| `usePasteCallback()` | Simple paste text callback        | N/A                    | N/A                 |
| `usePaste()`         | Context-based rich paste events   | N/A                    | N/A                 |

**`useHotkey(binding, handler, options?)` / `useHotkeyMap(bindings, options?)`** -- the semantic command boundary. Both compile public binding names through `parseHotkey()`/`matchHotkey()`, filter release and modifier-only events, and distinguish literal produced characters from physical chords. See [`useHotkey`](/api/use-hotkey).

**`useTextInput(options)`** -- the semantic text boundary. It emits printable text and bracketed paste, segments Unicode graphemes, and filters command, navigation, release, and modifier-only events. See [`useTextInput`](/api/use-text-input).

**`useInput(handler, options?)`** -- the low-level Ink-compatible boundary. It exposes normalized `input` plus `Key`, filters modifier-only events, and routes release events to `onRelease` when provided. Return `"exit"` to quit the app. See [`useInput`](/api/use-input).

**`useRawKeyEvent(handler, options?)`** -- observes every raw key event that reaches the chain, including release and modifier-only events. See [`useRawKeyEvent`](/api/use-raw-key-event).

**`useModifierKeys(options?)`** -- tracks which modifier keys (Cmd, Ctrl, Alt, Shift) are currently held. Uses `useSyncExternalStore` backed by a per-runtime singleton store. The `enabled` option controls subscription -- set to `false` to avoid re-renders when the component doesn't need modifier state.

**`useInputLayer(name, handler)`** -- registers a handler in a layered stack. Layers receive input in child-first order (like DOM bubbling). Return `true` to consume the event, `false` to let it bubble.

## Command System Integration

`withCommands()` from `@silvery/commands` layers on top of the input pipeline. It does not replace it -- it wraps the app's `update` method to intercept events after component handlers:

```
Key event
  |-> withDomEvents() -- component onKeyDown/onKeyUp handlers
  |     (stopPropagation? done)
  |-> withCommands() -- resolve key to named command, execute, dispatch action
```

The pipeline order in `pipe()` determines priority:

```typescript
const app = pipe(
  createApp(store),
  withReact(<Board />),
  withDomEvents(),   // fires first -- component handlers can consume events
  withCommands(opts), // unhandled events resolve to commands
)
```

Commands are serializable `(key) -> commandId -> action` -- enabling replay, undo, and AI automation. See [Event Handling -- withCommands()](event-handling.md#withcommands-named-serializable-actions) for the API.

## Testing Input

### Termless (full pipeline)

`createTermless()` runs the complete input pipeline through a real xterm.js emulator. Use `handle.press()` to send Playwright-style key names:

```typescript
import { createTermless } from "@silvery/test"
import { run } from "silvery/runtime"

using term = createTermless({ cols: 80, rows: 24 })
const handle = await run(<App />, term)

await handle.press("j")              // single key
await handle.press("Control+c")      // modifier combo
await handle.press("ArrowDown")      // named key
```

`press()` converts Playwright key names to ANSI sequences via `keyToAnsi()`, writes them to the emulator's input, and waits for the render to settle.

### Headless renderer (unit tests)

`createRenderer()` from `@silvery/test` provides a lighter-weight test environment. Use `press()` for individual keys:

```typescript
import { createRenderer } from "@silvery/test"

const { press, lastFrame } = createRenderer(<Counter />)
await press("j")
expect(lastFrame()).toContainText("1")
```

### Synthetic events in component tests

For testing `onKeyDown`/`onKeyUp` handlers directly, create synthetic events with `createKeyEvent()`:

```typescript
import { createKeyEvent } from "@silvery/ag/focus-events"
import { parseKey } from "@silvery/ag/keys"

const [input, key] = parseKey("j")
const event = createKeyEvent(input, key, targetNode)
dispatchKeyEvent(event)
expect(event.propagationStopped).toBe(true)
```

## Common Patterns

### Dialog key handling

Dialogs capture Escape in the bubble phase to close themselves, preventing it from reaching parent handlers:

```tsx
<Box
  onKeyDown={(e) => {
    if (e.nativeEvent.key.escape) {
      closeDialog()
      e.stopPropagation()
    }
  }}
>
  <TextInput value={query} onChange={setQuery} />
</Box>
```

### Text input vs discrete commands

Custom text surfaces separate commands from text. `useHotkey()` matches commands through normalized key identity, while `useTextInput()` emits the text the user produced. For example, ? arrives at the low-level boundary as `input === "/"`, `key.shift === true`, and `key.text === "?"`, but the semantic text hook emits `"?"` directly:

```tsx
useHotkey("Enter", submit)
useHotkey("Escape", cancel)
useTextInput({ onText: insertText })
```

Use the built-in [`TextInput`](/api/text-input) or [`TextArea`](/api/text-area) unless you are implementing a custom editor primitive.

### Mode-based routing

Applications with modal interfaces (vim-style normal/insert/visual) route input based on the current mode. The command system supports this via context-dependent keybindings:

```typescript
withCommands({
  registry,
  getContext: () => ({ mode: store.getState().mode, cursor: store.getState().cursor }),
  bindings: {
    key: {
      i: "enter_insert", // only active in normal mode (registry checks context)
      Escape: "exit_insert",
    },
  },
})
```

### Release events for interaction feedback

Track key-down/key-up pairs for hold-to-preview or scroll acceleration:

```tsx
useInput(
  (input, key) => {
    if (key.downArrow) startScrolling()
  },
  {
    onRelease: (input, key) => {
      if (key.downArrow) stopScrolling()
    },
  },
)
```

This requires the Kitty protocol with `REPORT_EVENTS` (enabled by default).

## See Also

* [Event Handling](event-handling.md) -- public API reference (hooks, props, commands, plugins)
* [Kitty Protocol](kitty-protocol.md) -- terminal protocol details, flag configuration, terminal support matrix
* [Input Limitations](input-limitations.md) -- what traditional terminals cannot report
* [Headless Machines](headless-machines.md) -- pure state machines for input processing (Readline, SelectList)
