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.
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
onKeyDownoronKeyDownCapturereceive aKeyEventwithstopPropagation()andpreventDefault(). - Mouse events: hit-tested against the render tree using
screenRect. The deepest node at(x, y)receives the event, which bubbles up through ancestors.
<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
| Prop | Event Type | Bubbles |
|---|---|---|
onClick | SilveryMouseEvent | Yes |
onDoubleClick | SilveryMouseEvent | Yes |
onMouseDown | SilveryMouseEvent | Yes |
onMouseUp | SilveryMouseEvent | Yes |
onMouseMove | SilveryMouseEvent | Yes |
onMouseEnter | SilveryMouseEvent | No |
onMouseLeave | SilveryMouseEvent | No |
onWheel | SilveryWheelEvent | Yes |
onKeyDown | SilveryKeyEvent | Yes |
onKeyDownCapture | SilveryKeyEvent | Yes (capture phase) |
withCommands() — Named Serializable Actions
Turns input into named, serializable commands. Keys and clicks resolve to commands; commands produce actions.
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.
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.
const app = pipe(
createApp(store),
withReact(<Board />),
withDomEvents(), // component handlers fire first
withCommands(opts), // unhandled events resolve to commands
)The driver pattern (testing + AI)
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 screenApp Plugin Anatomy
Every extension — withDomEvents, withCommands, withKeybindings, withDiagnostics — is an app plugin: a function that takes an app and returns an enhanced app.
import type { AppPlugin } from "@silvery/tea/plugins"
type AppPlugin<A, B> = (app: A) => BA 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:
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 automaticallyPlugins 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:
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
| Mechanism | Lifecycle | Use when... |
|---|---|---|
| App plugins | Static — created once at app setup | Always-on sources: stdin, resize, timers |
| React components | Reactive — mount/unmount with state | Conditional sources: file watchers, network polls |
| Effects | One-shot — triggered by update | Request/response: fetch, save, notifications |
React components are the most natural way to add reactive sources:
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:
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:
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] } : neverSources are typed against the map:
function terminalInput(stdin): EventStream<AppEvent<"term:key" | "term:mouse" | "term:paste">>Extend the map for custom events:
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:
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:
// 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
| Role | What it does | How |
|---|---|---|
| Source | Produces events | Overrides app.events |
| Processor | Transforms/consumes events | Wraps app.update |
| Reactor | Responds to model changes | app.subscribe (I/O or dispatch) |
| Driver | Enhances external API | app.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
| Plugin | Role | What it does |
|---|---|---|
withReact(<View />) | Rendering | React reconciler + virtual buffer |
withTerminal(process) | Source + Reactor | stdin→events, stdout→output, lifecycle, protocols |
withFocus() | Processor | Tab/Shift+Tab navigation, focus scopes |
withDomEvents() | Processor | React-style event dispatch to component tree |
withCommands(opts) | Processor + Driver | Key/mouse → named commands, .cmd API |
withKeybindings(bindings) | Driver | press() → keybinding resolution |
withDiagnostics() | Driver | Render invariant checks |
For the full API, see Plugins Reference.
See Also
- Building an App — guided progression from callbacks to composable plugins
- State Management — createApp, createSlice, tea() middleware, createStore
- Runtime Layers — createApp, createRuntime, createStore API reference
- Input Features — keyboard, mouse, hotkeys, modifier symbols
- Plugins Reference — withCommands, withKeybindings, withDiagnostics API