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
| node | fields |
|---|---|
Text | text |
Markdown | text |
Code | lang?, text |
Diff | unified |
List | items |
Table | headers, rows |
KeyValue | rows |
Progress | value, total?, label? |
Badge | text, tone |
Tree | nodes |
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:
| kind | drawn as | degrades to |
|---|---|---|
| markdown | headings bold, lists, quotes, ruled tables, underlined links | the text |
| code | fenced, highlighted, line numbers past eight lines | the text |
| diff | unified, coloured by column, word-level emphasis | the unified text |
| table / key-value | hairline rules, right-aligned numbers, – for a missing cell | rows joined by · |
| progress | a gradient fill, a sheen when unbounded | label 80 % |
| badge | [ text ] in the tone’s colour | [text] |
| tree | ├─ └─ with glyphs and badges | indented lines |
| image | kitty, iTerm2 or sixel, else half-blocks | [image: name] |
Three lanes, told apart by durability
| lane | call | lifetime | drawn as |
|---|---|---|---|
| block | ToolOutput.display = Some(view) | with the item, in the transcript | under the tool row, folded like any output |
| panel | host.extend(session, plugin, kind, view) | journaled; back after --continue | a rail card, or the panel sheet |
| live | host.signal(session, plugin, kind, view) | until replaced or set to Null; gone on resume | a 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.