Skip to article
Browse chapters

UI as data

One view vocabulary, three lanes chosen by durability, and a degrade rule so no surface loses information.

The problem it solves

A plugin should be able to put something rich, live and interactive on a screen — a diff, a board, a progress bar, a form — without the terminal surface knowing that plugin exists. Three rules bound the answer: only the terminal surface crate may depend on ratatui, no surface defines a private mirror of kernel types, and the kernel knows no plugin by name.

The answer is that a plugin describes what to show as data, and every surface decides how to draw it (ADR-0013).

The vocabulary

bingo_sdk::View is a small declarative tree.

Leaves

nodefields
Texttext
Markdowntext
Codelang?, text
Diffunified
Listitems
Tableheaders, rows
KeyValuerows
Progressvalue, total?, label?
Badgetext, tone
Treenodes

Containers — Stack, Columns, Panel { title, child }.

Interactive — Actions { items }, where an item is a label, an Action { name, args } and at most a single-key hint.

Progress with no total is unbounded: the surface shows activity, not a fraction. Badge’s tone is the one styling hook a plugin has — neutral, good, bad or attention, where attention means “wants a person” and a surface makes it move. The surface owns the colour.

Nothing in the vocabulary names a plugin or a feature. A plugin composes it out of data it owns.

The degrade

impl View {
    /// The degrade: what `--print`, an IM channel and a surface that cannot
    /// draw a node show instead.
    pub fn fold(&self) -> String
}

Every node has exactly one text fold, and that is the whole of the degrade rule. --print prints it, an IM channel sends it, a graphical surface ignores it. A node added later ships with its fold or it does not ship.

What each kind loses, and what it keeps:

kinddrawn asdegrades to
markdownheadings bold, lists, quotes, ruled tables, underlined linksthe text
codefenced, highlighted, line numbers past eight linesthe text
diffunified, coloured by column, word-level emphasisthe unified text
table / key-valuehairline rules, right-aligned numbers, – for a missing cellrows joined by ·
progressa gradient fill, a sheen when unboundedlabel 80 %
badge[ text ] in the tone’s colour[text]
tree├─ └─ with glyphs and badgesindented lines
imagekitty, iTerm2 or sixel, else half-blocks[image: name]

Three lanes, told apart by durability

lanecalllifetimedrawn as
blockToolOutput.display = Some(view)with the item, in the transcriptunder the tool row, folded like any output
panelhost.extend(session, plugin, kind, view)journaled; back after --continuea rail card, or the panel sheet
livehost.signal(session, plugin, kind, view)until replaced or set to Null; gone on resumea rail card that updates in place

Block is what a person sees beside the parts the model reads. One tool call answers with both, and neither is a summary of the other.

Panel is a durable Event::Extension whose payload is the whole of that kind’s state — so a client renders it, a plugin reads it back from a snapshot, and nothing keeps a file or a map beside it.

Live is an ephemeral Event::Signal. It is never journaled, is folded by the reducer into SessionState.signals as the latest payload per (plugin, kind), is removed by a Null payload, and is absent after a resume. A progress bar updated ten times a second costs the journal nothing.

Rate is the publisher’s discipline. A signal is coalesced by the reducer, not throttled by the kernel; a plugin that publishes at 1 kHz makes its own subscribers lag, which is what Lagged exists for.

Interaction

Two mechanisms, both of which already existed.

An Actions row carries an Action { name, args }. A surface that fires it submits Input::Action, which runs a plugin’s command — so a button is a command with a name, and nothing new reaches the kernel’s dispatch.

Anything that must stop a turn and wait for a person is an Interaction opened through ToolHost::ask, exactly as before.

What the surface decides, and a plugin never asks about

Placement, focus and keys. Where a panel sits, how it gets focus, which key fires which action, when a live signal is folded away — the terminal surface decides that once for everyone, and a graphical surface decides differently.

In the terminal that means: a panel is pinned into the rail with enter on its row in the ctrl+t sheet, and the pin is remembered per session; a signal is a rail card the moment it arrives and needs no pinning; tab walks the cards and ❯ marks the one the keyboard is talking to; a key on that card fires the button it names — the plugin’s key hint, else the button’s position — and the button wears … until the session’s stream answers. One plugin gets eight rows in the rail before the rest folds. Below 120 columns the same cards draw in the transcript under the running rows.

A plugin’s UI is therefore portable and testable without a terminal: a View value is asserted with assert_eq!, and a snapshot of the terminal surface proves the drawing once per node, not once per plugin.

The escape hatch, unused

A native widget the vocabulary provably cannot express would be a surface crate — tier = "surface", allowed to depend on ratatui, assembled by the binary — and never a plugin. None is planned, and the discipline rule widens only the day the first one is written.

The worked example

crates/bingo-demo-ui implements all three lanes in one small crate, off unless --demo-ui turns it on. Read it whole; it is the shape a plugin has when it wants a screen it knows nothing about. See plugins in Rust for its middle.