Skip to content

Event Handling

This page documents Silvery's event handling APIs. For the guided progression from callbacks to composable plugins, see Building an App.

withDomEvents() — Component Event Handlers

Adds React-style event handlers to Silvery components. Events bubble up the tree, components can stop propagation, and hit testing maps mouse coordinates to nodes.

tsx
import { pipe, withDomEvents, withReact } from "@silvery/tea/plugins"

const app = pipe(createApp(store), withReact(<Board />), withDomEvents())

How it works

withDomEvents() overrides app.update to intercept events before the base handler:

  • Keyboard events: dispatched through the focus tree (capture phase → target → bubble phase). Components with onKeyDown or onKeyDownCapture receive a KeyEvent with stopPropagation() and preventDefault().
  • Mouse events: hit-tested against the render tree using screenRect. The deepest node at (x, y) receives the event, which bubbles up through ancestors.
tsx
<Box
  onKeyDown={(e) => {
    if (e.key.escape) {
      closeDialog()
      e.stopPropagation() // don't let Escape reach the parent
    }
  }}
>
  <TextInput value={query} onChange={setQuery} />
</Box>

Available event handler props

PropEvent TypeBubbles
onClickSilveryMouseEventYes
onDoubleClickSilveryMouseEventYes
onMouseDownSilveryMouseEventYes
onMouseUpSilveryMouseEventYes
onMouseMoveSilveryMouseEventYes
onMouseEnterSilveryMouseEventNo
onMouseLeaveSilveryMouseEventNo
onWheelSilveryWheelEventYes
onKeyDownSilveryKeyEventYes
onKeyDownCaptureSilveryKeyEventYes (capture phase)

withCommands() — Named Serializable Actions

Turns input into named, serializable commands. Keys and clicks resolve to commands; commands produce actions.

tsx
import { pipe, withDomEvents, withCommands, withReact, createCommandRegistry } from "@silvery/tea/plugins"

const registry = createCommandRegistry({
  cursor_down: {
    name: "Move Down",
    execute: (ctx) => ({ op: "moveCursor", delta: 1 }),
  },
  cursor_up: {
    name: "Move Up",
    execute: (ctx) => ({ op: "moveCursor", delta: -1 }),
  },
  toggle_done: {
    name: "Toggle Done",
    execute: (ctx) => ({ op: "toggleDone", index: ctx.cursor }),
  },
  select_node: {
    name: "Select",
    execute: (ctx) => ({ op: "select", nodeId: ctx.clickedNodeId }),
  },
})

const app = pipe(
  createApp(store),
  withReact(<Board />),
  withDomEvents(),
  withCommands({
    registry,
    getContext: () => buildContext(store),
    handleAction: (action) => store.apply(action),
    bindings: {
      key: { j: "cursor_down", k: "cursor_up", x: "toggle_done" },
      mouse: {
        click: (node) => "select_node",
        doubleClick: () => "enter_edit",
      },
    },
  }),
)

Mouse commands

Mouse events resolve to commands through the same registry. Click on a node → hit test finds the target → mouse binding resolves to command → command executes → action dispatched. Same path as keyboard, same serialization, same replay.

tsx
mouse: {
  click: (node, mods) => {
    if (mods.ctrl) return "toggle_select"
    return "select_node"
  },
  doubleClick: () => "enter_edit",
  wheel: (delta) => delta < 0 ? "scroll_up" : "scroll_down",
}

Hybrid: components + commands

Component handlers and commands coexist. withDomEvents() fires first; if a component handles an event (stopPropagation), commands never see it. Unhandled events fall through to command resolution.

tsx
const app = pipe(
  createApp(store),
  withReact(<Board />),
  withDomEvents(), // component handlers fire first
  withCommands(opts), // unhandled events resolve to commands
)

The driver pattern (testing + AI)

tsx
const driver = pipe(app, withKeybindings(bindings), withDiagnostics())

driver.cmd.all() // list available commands
await driver.cmd.cursor_down() // execute by name
driver.getState() // inspect state
await driver.screenshot() // capture screen

App Plugin Anatomy

Every extension — withDomEvents, withCommands, withKeybindings, withDiagnostics — is an app plugin: a function that takes an app and returns an enhanced app.

tsx
import type { AppPlugin } from "@silvery/tea/plugins"

type AppPlugin<A, B> = (app: A) => B

A plugin is a function that takes an app and returns an enhanced version. It can wrap existing methods (like press() or run()), add new properties, or store configuration for the runtime:

tsx
import { withTerminal } from "@silvery/tea/plugins"

// withTerminal captures process streams and terminal options,
// then wraps run() to inject them:
const app = pipe(createApp(store), withReact(<Board />), withTerminal(process, { mouse: true, kitty: true }))
// app.terminalOptions is now available
// app.run() will configure stdin/stdout automatically

Plugins compose cleanly because each one wraps or extends the app object without mutating the original. The pipe() chain flows left-to-right, with each plugin seeing the result of the previous one.

Subscriptions and cleanup

Plugins react to model changes via app.subscribe. Cleanup is automatic via using:

tsx
using app = pipe(createApp(store), withTerminal(process), withFocus())
await app.run()
// all subscriptions cleaned up via [Symbol.dispose]

The rule: subscribers never mutate the model. They either do I/O or dispatch.

Event Sources

Three mechanisms

MechanismLifecycleUse when...
App pluginsStatic — created once at app setupAlways-on sources: stdin, resize, timers
React componentsReactive — mount/unmount with stateConditional sources: file watchers, network polls
EffectsOne-shot — triggered by updateRequest/response: fetch, save, notifications

React components are the most natural way to add reactive sources:

tsx
function FileWatcher({ path }: { path: string }) {
  const dispatch = useDispatch()

  useEffect(() => {
    const watcher = watch(path, (ev) => dispatch({ type: "fs:change", data: ev }))
    return () => watcher.close()
  }, [path])

  return null // renderless
}

App plugin event sources

Plugins can add event sources by overriding app.events:

tsx
function withFileWatcher(path: string) {
  return {
    slice: (msg: AppEvent, fs: FsState): FsState => {
      if (msg.type === "fs:change") return { ...fs, lastChange: msg.data }
      return fs
    },
    plugin: (app) => {
      const { events } = app
      app.events = () => [...events(), fileWatch(path)]
      return app
    },
  }
}

EventMap Type Safety

All event types flow through a single EventMap:

tsx
interface EventMap {
  "term:key": { input: string; key: Key }
  "term:mouse": ParsedMouse
  "term:paste": { text: string }
  "term:resize": { cols: number; rows: number }
}

type AppEvent<K extends keyof EventMap = keyof EventMap> = K extends K ? { type: K; data: EventMap[K] } : never

Sources are typed against the map:

tsx
function terminalInput(stdin): EventStream<AppEvent<"term:key" | "term:mouse" | "term:paste">>

Extend the map for custom events:

tsx
interface MyEventMap extends EventMap {
  "fs:change": { path: string; kind: string }
  "timer:tick": { now: number }
}

Typed dispatch

app.dispatch is both callable and a typed proxy:

tsx
app.dispatch.focus.revalidate()
app.dispatch.focus.changed({ from: "a", to: "b" })
app.dispatch.term.resize({ cols: 80, rows: 24 })

// Raw — when you already have a message object
app.dispatch({ type: "focus:revalidate" })

Plugin Catalog

The kernel and defaults

run() is sugar over pipe() with sensible defaults:

tsx
// Simple: batteries included
await run(store, <App />)

// Equivalent to:
const app = pipe(
  createApp(store), // kernel: event loop + state
  withReact(<App />), // rendering: React reconciler + virtual buffer
  withTerminal(process), // ALL terminal I/O: stdin→events, stdout→output, lifecycle
  withFocus(), // processing: Tab/Shift+Tab, focus scopes
  withDomEvents(), // processing: dispatch to component tree
)
await app.run()

// Power user: pick exactly what you need
const app = pipe(
  createApp(store),
  withReact(<Board />),
  withTerminal(process),
  withDomEvents(),
  withCommands(registry),
  withKeybindings(bindings),
  withDiagnostics(),
)

Plugin roles

RoleWhat it doesHow
SourceProduces eventsOverrides app.events
ProcessorTransforms/consumes eventsWraps app.update
ReactorResponds to model changesapp.subscribe (I/O or dispatch)
DriverEnhances external APIapp.press, app.cmd, etc.

A single plugin can fill multiple roles — withCommands wraps update AND adds .cmd. withTerminal adds sources AND subscribes to render buffer.

Built-in plugins

PluginRoleWhat it does
withReact(<View />)RenderingReact reconciler + virtual buffer
withTerminal(process)Source + Reactorstdin→events, stdout→output, lifecycle, protocols
withFocus()ProcessorTab/Shift+Tab navigation, focus scopes
withDomEvents()ProcessorReact-style event dispatch to component tree
withCommands(opts)Processor + DriverKey/mouse → named commands, .cmd API
withKeybindings(bindings)Driverpress() → keybinding resolution
withDiagnostics()DriverRender invariant checks

For the full API, see Plugins Reference.

See Also

Released under the MIT License.