Phosphene

A terminal is a grid of cells, and every client that shows one has to emulate a VT. Phosphene puts a 1.26M-parameter model in front of the grid that names what each cell is for. A deterministic compiler then turns those roles into A2UI, so a web or native client draws real lists, inputs and buttons. Once a screen is recognised, its template locks and only the changed content is streamed.

github.com/drksci/phosphene
The idea
Stream the interface, not the terminal.

Terminal programs describe their interface as escape codes that move a cursor and paint characters. A client that wants to show one, in a browser, on a phone or to an agent, has to run a VT emulator and then guess at what it is looking at. phosphene does the guessing once, on the server side. A small model labels every cell with a role: border, title, menu item, selected row, table, input, status bar. A compiler turns the regions into A2UI components bound to a data model. From then on, a screen that has been seen before is a template: new frames fill its slots, and only the content that changed goes over the wire.

Model
1.26M
parameters · 5.0 MB
Roles
15
per terminal cell
Real frames
mIoU 0.51
macro-F1 0.65, held out
Templates
40%
of 13,967 screens served without the model
Demos
8
160 settled screens each

Watch it work

Eight public asciinema recordings, each from the first screen of the app, replayed from pre-generated traces (160 settled screens each; time is compressed). Top row: the cells written this frame, the role of every cell, and the A2UI messages sent. Left: the terminal drawn from cells. Right: the A2UI client, each component placed by its style area and outlined by type. Below: every top-level element, rendered or as source. User names, host names and home paths are masked at the same width.
Model
Each cell gets a role.

A 1.26M-parameter axial transformer reads the grid (characters, colours, attributes, cursor) and labels each cell with one of 15 roles.

Compiler
Roles become A2UI.

Deterministic code groups the roles into regions and emits A2UI v0.9: Column, List, Text, TextField, Button and Progress, bound to a data model.

Gateway
Locked screens send data only.
  • raw cells until a screen settles
  • model match, then lock the template
  • slots filled from the grid, no model
  • drift falls back and re-matches
  • actions come back as keystrokes

In the replay, a cell that has just been written lights on the grid and travels across the pipeline row: into the cell lattice, through its role, and out as a message. Grey diagonal hatching marks cells the gateway has not matched yet; those go out as a Terminal component carrying styled runs, so nothing is lost while the model decides. When the timeline turns black the screen is locked and frames are filled from the template without calling the model.

…but why?

Every terminal client today does the same work. It parses an escape-code stream, keeps a cell grid, and draws characters. The result is faithful and opaque. A screen reader sees a wall of characters, a phone gets a grid it cannot reflow, and an agent has to read box-drawing characters to find out which row is selected.

The structure is there. People see it at a glance: a title, a menu, a highlighted row, a status bar with key hints. It is simply never written down. phosphene writes it down once, close to the program, and sends it in a form any client can render natively.

Native clients
A2UI is a declarative interface stream: components bound to a data model, sent as small JSON messages. A web, mobile or desktop renderer draws its own widgets for it. Nothing on the client parses VT.
Agents
An agent reads a typed surface instead of characters. The selected row is a data value. F10 is a Button with an action, and pressing it sends the keystroke back to the program.
Faithful when wanted
Every component also carries a style binding with its cell rectangle and ANSI colours. A client that wants the terminal look can lay the components out on the grid, as the right half of the player does.

Anatomy

Pipeline
The gateway, per screen layout
Keyframes
Programs repaint in bursts. A keyframe is a screen that has stopped changing, with a damage mask of the cells written since the last one. The model only ever sees keyframes.
Roles
Fifteen: blank, text, prompt, input, border, title, status bar, menu item, selected, table, progress, code, log, error, key hint. They are chosen so a compiler can act on them: a border means a box, a selected cell means a list with a cursor, a key hint means a button.
Templates
A template is the compiled layout plus a map of slots: which cell ranges feed which data paths. It is cached by grid size and a layout signature that ignores letters and digits, so the next htop screen, or the next htop session, matches without the model. Regions can lock separately: a stable title bar and key hints lock while a log pane is still moving.
Reconciler
The client keeps the last UI. When a frame changes only content, the gateway sends updateDataModel patches by JSON pointer: one table row, one percentage, one keystroke. updateComponents goes out only when the structure changes.
Actions
Buttons carry their key, lists carry a select event, inputs an edit event. The gateway turns them into bytes for the program: F10, the arrow presses that move a selection to row 7, or backspaces and the new text.
Structure, sent when the layout changes (dialog demo, frame 13, abridged)
{
"updateComponents": {
"surfaceId": "term",
"components": [
{
"id": "box0.text0",
"component": "List",
"children": {
"path": "/r/box0.text0/rows",
"componentId": "box0.text0:row"
},
"style": { "path": "/s/box0.text0" }
},
{
"id": "box0.text0:row",
"component": "Text",
"text": { "path": "text" },
"highlight": { "path": "selected" }
},
{
"id": "box0.key_hint1",
"component": "Row",
"children": {
"path": "/r/box0.key_hint1/items",
"componentId": "box0.key_hint1:btn"
},
"style": { "path": "/s/box0.key_hint1" }
},
{
"id": "box0.key_hint1:btn",
"component": "Button",
"child": "box0.key_hint1:lbl",
"shortcut": { "path": "key" },
"action": {
"event": {
"name": "key",
"context": { "key": { "path": "key" } }
}
}
},
{
"id": "box0.key_hint1:lbl",
"component": "Text",
"text": { "path": "label" },
"variant": "caption"
}
]
}
}
Content, once the screen is locked: two pointer patches (dialog demo, frame 34)
{
"updateDataModel": {
"surfaceId": "term",
"path": "/r/text0/rows/9",
"value": {
"text": "Indicate the device # to use [0-9]:",
"selected": false
}
}
}
{
"updateDataModel": {
"surfaceId": "term",
"path": "/s/text0/area/2",
"value": 10
}
}

The model

The network is deliberately small: a convolutional stem over per-cell features (character class, colours, attributes, cursor), then four blocks of axial attention that alternate between rows and columns, then a role head per cell and an app head per screen. 1.26M parameters, 5.0 MB in float32, exportable to ONNX for WebGPU. It is a pure function of one screen; all the state lives in the gateway.

Training data comes from three places. A synthetic TUI generator renders boxes, menus, tables and logs through the real emulator, so its 14,223 frames have exact labels. Heuristic labelling functions cover every real frame for free and badly: they agree with reviewed labels at mIoU 0.20. And 600 real frames, chosen from 44,168 de-duplicated keyframes for disagreement and diversity, were labelled as region rectangles by Claude Sonnet subagents. 90 of the hardest were reviewed by Claude Opus, which put the Sonnet labeller at mIoU 0.74.

Training runs

T4 GPU on Colab · real frames held out from training
RunDataReal mIoUReal macro-F1
v1synthetic + 16k heuristic + 460 agent ×30.35—
v2synthetic + 4k heuristic + 460 agent ×100.510.65
v1 learned to copy the heuristics: there were too many weak labels. v2 cut them and oversampled the agent labels. Five epochs, about 9 minutes each; non-blank cell accuracy 73%.

An mIoU of 0.51 sounds low and is less bad than it looks. The macro average weighs rare roles such as progress and error the same as text, and most of the errors are at region edges: a table that starts one column late, an input that runs past the typed text. The compiler absorbs a lot of this, because it acts on regions rather than single cells, and the gateway only needs the model to recognise a layout once before the template takes over.

Measured

The gateway on the eight demo recordings

Up to 160 settled screens each, from the app’s first screen · model v2
AppLockedModel calls / screenVT KBA2UI KBFull re-send KB
lesspager92%0.1114.9227.4905.5
dialogmenus89%0.1269.2287.4839.4
vimeditor77%0.3122.2338.02,009.2
emacseditor68%0.3455.3611.31,882.0
tiggit browser52%0.5133.61,704.13,244.3
topprocess monitor26%0.8434.01,582.84,074.1
htopprocess monitor24%0.7841.91,456.62,936.1
nanoeditor14%0.9416.53,697.28,595.1
Locked: share of settled screens served from a template with no model call. Full re-send: the same interface sent whole on every screen, which is what a stateless client would need.

The template cache does what it is for. On less, dialog and vim, whose screens keep one layout while the content moves, three to nine screens in ten never reach the model. On top, htop and nano, where meters, scroll positions and the edited text keep changing what the layout signature sees, most screens go back to it. Across 193 random recordings from the corpus (13,967 settled screens, one shared cache), 40% of screens were served from a locked template and the model ran 0.70 times per screen.

The bytes are the honest surprise. The A2UI stream is larger than the VT stream it replaces: 25× over the same 193 recordings. VT is a very compact format for a grid: “move here, write these six characters”. JSON components and pointer patches are not, and the unmatched regions still go out as styled runs. Incremental updates bring it down to 43% of re-sending everything, which is the reconciler working. The claim this prototype supports is about the client, not the wire: the receiving side draws widgets and never runs a terminal emulator.

Limits

  • The model is a first round: 600 agent-labelled real frames. Its errors are visible in the replay as regions that flicker between roles on screens the template cache has not locked.
  • The wire format is not yet compact. A binary or delta-coded encoding of the data patches, and fewer styled runs for unmatched regions, are the obvious next steps.
  • The demos are recordings, so the action path (A2UI events back to keystrokes) is tested in the repository but not shown here.
  • The demos were found by searching the recordings for each app’s signature and start at its first screen; the shell session before it is skipped, and the gateway starts cold there.

Evidence boundaryMeasured: the label-quality and training figures (docs/RESULTS.md) and the per-demo gateway figures, all generated on a Colab T4 on 8 October 2026 from public asciinema recordings (4,000 of a 79k-recording archive). Not measured: rendering cost on a client, latency over a network, or accessibility outcomes. The replay is pre-generated; no model runs in this page.

  1. context · public
  2. measured · public
  3. measured · public · docs/RESULTS.md · packages/vtm/src/vtm/demos.py
  4. context · public

Phosphene is a d/rksci labs prototype. The model, compiler, gateway and labels are in the public repository; there is no hosted service.