Nova · TERMINAL_DOCS.md

This is TERMINAL_DOCS.md as @niscorp/nova 0.5.1 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/nova/TERMINAL_DOCS.md

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's RenderApi (frame(), canvasTree(id), dispatch(id, event), publish(channel, payload?)).
  • options.fallback?: TtyComponent — renderer for unregistered names; omit for strict COMPONENT_NOT_FOUND markers.
  • 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 (payload value), toggle → ui:model with 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 enclosing ActionSlot's instanceId; a CanvasSlot starts outside any). A list canvas renders several live instances that share refs — a tab bar's every button is open — so the host stamps this as the event's origin, 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 a ref'd or model'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, provide CanvasMarkersContext per canvas with a resolver (ref, { value?, occurrence? }) => number | undefined and provide FrameControlsContext at 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), a Table row passes its value and its occurrence (its place among the rows), and an Input or a Checkbox passes 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.