Architecture

The whole runtime is one idea taken seriously: UI is data, and the data lives in one store. Everything below follows from that, including the parts that look like ceremony.

A conventional component tree is a tree of imports. That tree cannot be persisted, sent over a wire, edited by someone who is not a programmer, or generated by something that is not a compiler - because half of it is module identity. TextUI expresses the same screen as a graph of nodes that name their components, and resolves those names at mount time.

   a node graph                the registries              the shell
   ─────────────               ──────────────              ─────────
   { component: 'Column',  →   components.get('Column')  →  mounted into
     children: [...] }         commands.get('save')         sidebar
          │                    themes.resolve('dark')       by a layout
          │
          └── props read through binding paths → the reactive store

JSX is not a second thing

h('Row', { gap: 1 }) and { component: 'Row', gap: 1 } produce the same object. A function component is given a name and the node references that name, with the function carried in $meta.fn so an in-process tree needs no registry lookup. toSerializable strips the closures - which is exactly the set of things that only mean something in this process - and what is left is JSON.

The consequence worth stating: a prop that is a closure works, and a prop that is { functionCall: { call: 'service.restart' } } also works, and a component cannot tell them apart. That is what lets the same button be written in JSX by one team and loaded from a config file by another.

The render pipeline

Six passes, in order, once per frame:

  1. Reconcile. The node graph becomes a retained instance tree, matched by component name and key. An instance is re-rendered only if it is dirty, its props changed, or a store path it read changed.
  2. Effects. Queued during render, flushed after the frame - so a subscription set up in useEffect is torn down exactly once.
  3. Layout. Host instances (box, text, canvas, spacer) become layout boxes; function components are transparent. A flexbox subset sized in whole cells assigns rectangles.
  4. Measure. Components that asked for their rect with useMeasure are given it. If any changed, steps 1-3 run again before anything is painted - which is how a viewer can render exactly the rows that fit without a frame of the wrong size ever reaching the terminal. Bounded at three passes.
  5. Paint. The tree is walked again, painting background, border, content and children into a cell buffer, clipped by each box’s content rect.
  6. Diff. Changed cells become runs of identical style. The writer holds the terminal’s current SGR state and emits only the difference.

Two economies matter, and only here. Only touched rows are walked. And within a row, adjacent changed cells sharing a style become one run - so a redraw costs one cursor move and one SGR change per run rather than per cell.

Dirtiness propagates upward

A component deep inside a mounted surface can mark itself dirty. If the render pass stopped at the first clean ancestor it would never reach it, so marking an instance dirty also sets childDirty on every ancestor, and a clean instance with a dirty descendant reconciles its previous children rather than re-running itself.

Input settles between events

A terminal delivers several keystrokes in one read, and a handler closes over the props from its last render. Without re-rendering between events, typing “ab” quickly makes the handler for “b” see the state from before “a” and the character is lost. So input is processed one event at a time with a render in between - which is what every terminal application does, and what the frame diff makes cheap.

The store

One tree, addressed by paths, and the only place state lives.

$/services/list          the records a table shows
$/summary/services/down  a derived count
$/active/id              what is selected, application-wide
$/modus/capabilities     what this terminal can do
$/layout/surfaces/main   which layout that surface is using

The first segment is a scope, and scopes are lifetimes rather than folders. clearScope is what makes sign-out, or tearing down a screen, a single call.

Three things layer on top: computed(path, def) for a derived path, registerDataProvider(def) for a namespace and the code that fills it - lazy by default, so nothing fetches until something reads - and registerPersistence(adapter) for paths that survive a restart.

A relative path (/name) resolves against the surrounding data context, which is what lets one templated node render a hundred rows. .. is forbidden on purpose: escape to the root with $/ instead, so a node’s meaning does not depend on where it was pasted.

Events are not state

@/agent/restart looks like a store path and is deliberately a different mechanism. An event has no value to read back; that difference is the whole reason there are two.

The registries

Late binding is the mechanism the design rests on.

Registry Maps Contributed by
components 'Table' → a renderer plus metadata the catalog, or an app
commands 'service.restart' → a function, args, a when clause anything
keybindings a chord → a command id, scoped to a focus scope anything
themes 'console' → tokens, glyphs, borders, density anything
layouts 'tabs' → a surface render strategy built in, or an app
shells 'workbench' → the outermost frame built in, or an app
surfaces a surface name → its mounts the app, at runtime
resources a kind → its provider, viewers, editors, actions anything

Two consequences are worth stating plainly. A name that was never registered is a runtime miss rendered visibly, not a compile error - which is the price of the graph being data. And a command is the only way an action should be spelled: a button that calls an API directly and a palette entry that calls the same API are two implementations that will drift.

Surfaces, shells and the acceptance test

A surface name is the application’s word, not the library’s. SurfaceName lists the ones the shipped shells use so an editor can complete them, but the type stays open and the runtime never validates one - app.open({ surface: 'lateral1', ... }) works with no registration, and gets default state the first time it is used. Which surfaces exist is therefore a property of the shell you wrote, not of this package.

A shell decides where surfaces go, and that is all it decides. The three shipped shells are the same application in three house styles:

  console paper workbench
surfaces header, sidebar, main, panel, status header, main, status header, rail, sidebar, aside, main, panel, status
border single none round
density compact airy normal

Nothing in the catalog knows which is mounted. That is the test the whole architecture exists to pass, and it is asserted in playground/test/playgrounds.test.tsx.

Capabilities

What a terminal can do is detected once, published to $/modus/capabilities, and consulted by the theme rather than by components. A theme resolves glyphs and border sets against the Unicode level; the writer reduces colour against the depth. A component names a role - bulletFilled, progressFull - and never picks a fallback itself.

What is deliberately not here

  • No router. Screens and a stack, plus surfaces and mounts. An application that wants URLs maps them on itself.
  • No CSS engine. Style objects, theme tokens and convenience props.
  • No dependency injection. A typed lookup table with a parent chain.
  • No job queue. Tasks with a lifecycle, and cancellation.
  • No dependencies. Not one, in any published package.

Back to top

MIT licensed. Pre-1.0 - the surface is still moving.