Terminal adapters — adapters/tty and adapters/ink
Two adapters render a served tree in a terminal. The TTY adapter is a pure
text renderer for line hosts (a REPL, a pipe, an agent); the Ink adapter is
a full-screen TUI kit riding the React adapter's walker. They share one
idea: every interactive carries a numbered [n] marker, and the numbering
is computed once (by the TTY walker) so [7] is the same interactive in
both. The shared adapter contract is ADAPTER.md; the host
that binds these to a live server is @niscorp/moss (terminal/tty,
terminal/ink).
@niscorp/nova/adapters/tty
Pure functions — no framework, no I/O, no node builtins. Importable anywhere.
createTtyView(registry, api, options?): TtyView
registry: ComponentRegistry<TtyComponent>— the kit.api: TtyRenderApi— core'sRenderApi(frame(),canvasTree(id),dispatch(id, event),publish(channel, payload?)).options.fallback?: TtyComponent— renderer for unregistered names; omit for strictCOMPONENT_NOT_FOUNDmarkers.view.render(): TtyFrame— walk the current snapshot. Call on every host update; the view holds no state.
TtyFrame
{ text: string; interactives: TtyInteractive[] }
text is the whole screen: one rule line per non-empty canvas
(── main ───), component lines below, blank runs collapsed. Empty
canvases collapse entirely.
TtyInteractive
{ index, kind, ref, canvas, origin?, label, value?, path? }
index— the printed[n], 1-based, registration order.kind—'click' | 'row' | 'model' | 'toggle'. The host maps these to events: click/row →ui:click(payloadvalue), toggle →ui:modelwith the negated boolean, model → the host's own input flow.canvas— whose dispatch this belongs to;''is frame chrome (dispatches nothing).origin— the action instance it sits in (the enclosingActionSlot'sinstanceId; aCanvasSlotstarts outside any). A list canvas renders several live instances that share refs — a tab bar's every button isopen— so the host stamps this as the event'sorigin, as the DOM adapter does, or the server's card-deck fallback picks one of them.label— a name to list it by: the first line aref'd ormodel'd node printed, cut at 48 characters (the ref when that line is empty); a component that registers its own passes one.value— click/row: the dispatch payload; model/toggle: the current value.path— the model path, display only.
The walker applies the conventions itself: any ref'd node gets a click
marker (payload = its value prop), any model'd node gets a model
marker (toggle when its current value is boolean). Components never
handle events; data-driven internals (a table's rows, a panel's ✕)
register through ctx.register.
TtyComponent
(ctx: { props; children: TtyBlock[]; register }) => TtyBlock // TtyBlock = { lines: string[] }
register({ kind, ref, label, value?, path? }) adds an interactive and
returns its index; the component prints that [n] itself, where it wants
it.
Kit (/adapters/tty/components): defaultRegistry() with Box, Row, Stack, Grid, Text, Button, Input, Select, Textarea, Checkbox, Switch, Table, Badge, Panel, JsonTree, ActionSlot (Select is Input, Switch
is Checkbox; the components are also exported by name), plus fallback
(unknown names
render their children; childless ones surface label/title + count)
and the helpers (stack, inline, inlineText, pad, truncate,
indent, str, num, bool, iconGlyph).
Box and Stack stack their children. Row and Grid call inline: the
children join on one line, two spaces apart, while each is a single line,
and stack as soon as one is not.
@niscorp/nova/adapters/ink
An Ink component kit for the React adapter's walker — ESM-only, ink and
ink-text-input are optional peers. There is no separate walker: mount
NovaRenderProvider + RenderTree and register this kit.
Host obligations
- Pass the three host leaf renderers to
NovaRenderProvider:fallback(this kit's),textWrapper(TextWrap— ink forbids bare strings outside<Text>),errorMarker(ErrorMarker— the DOM default<span>crashes ink). - For
[n]markers, provideCanvasMarkersContextper canvas with a resolver(ref, { value?, occurrence? }) => number | undefinedand provideFrameControlsContextat the root (setTyping— a focused input claims typed digits as text, so the host's number handling must stand down). Without providers the kit renders markerless and Tab-only. - Marker identity: a pressable passes its
value(a list's rows share one ref and differ by payload), aTablerow passes itsvalueand itsoccurrence(its place among the rows), and anInputor aCheckboxpasses neither and resolves by its ref alone.markerFocusId(n)is the ink focus id a marked component adopts —useFocusManager().focus(markerFocusId(n))jumps to it.
Interaction conventions
Tab/Shift+Tab cycle ink's focus ring; Enter activates (Space too on a
Checkbox and on a Panel's header buttons; useActionable answers Enter
only); a focused Input holds a local draft, honours debounce, submits ui:key Enter, and forwards ↑/↓ to the server as ui:key ArrowDown/ArrowUp (a
served palette moves its own highlight). Typed-digit handling is the
host's job — the kit only displays markers and adopts their focus ids.
A ref on a container (Box, Stack, Row, Grid) makes it one focus
stop with a marker: Enter on it dispatches ui:click with its value prop
as payload. Enter in an Input sends a value debounce is still holding
before the ui:key.
Exports
defaultRegistry() — Box, Row, Stack, Grid, Text, Button, Input, Select, Textarea, Checkbox, Switch, Table, Badge, Panel, JsonTree, ActionSlot
(Select and Textarea are Input, Switch is Checkbox; the
components are also exported by name);
fallback (ref'd unknowns are focusable and click on Enter; childless
ones surface label/title + count); TextWrap,
ErrorMarker; useActionable(novaRef, value) — the full interactive
convention (marker + focus + Enter-click) for an app's own components;
useMarker, Mark, markerFocusId, CanvasMarkersContext,
FrameControlsContext; types MarkerResolve, InkFrameControls.
Layout mapping: Grid honours weights (flex ratios) and columns
(wrapping tracks); Row honours justify/align; containers honour
border (incl. sided 'r'/'l'/'t'/'b'), padding, w/width
(px→cells at ~8px/cell, percent passthrough), grow. What has no
terminal analog (bg, radius, glow) is absent, never faked. JsonTree
prints the TTY kit's lines, which do not fold: four levels are open, and
each object or array lists its first 20 entries.