Skip to content

Cut the interactive TUI over to OpenTUI - #333

Merged
TheGreatAxios merged 71 commits into
mainfrom
migration/opentui-tui
Aug 7, 2026
Merged

Cut the interactive TUI over to OpenTUI#333
TheGreatAxios merged 71 commits into
mainfrom
migration/opentui-tui

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Hard cutover of the interactive TUI from Ink/React to OpenTUI. The Ink path is deleted, not feature-flagged — there is no fallback renderer, so rollback is checking out the prior tag rather than flipping a flag.

What changed

  • Renderer. createCliRenderer replaces render(<App />). Layout is a zone-based geometry resolver with min/max/idle rows per zone and an explicit collapse order, rather than React reconciliation.
  • Look. A warm bronze/sand/ember ramp replaces Summit Blue across chrome, centralized in theme.ts behind a Theme type and registry.
  • Composer. Multi-line prompt on TextareaRenderable, wrapping and growing to 40vh before it scrolls.
  • Transcript. Streaming rows repaint in place via a 1:1 log-index-to-child mapping; markdown renders through tree-sitter with async highlighting.
  • Permissions. The gate overlay is sized to its content, and the decision is recorded in the transcript after the operator chooses.

Defects found and fixed during review

CI had been dark since July over an Actions billing limit. It is re-enabled in this branch, and re-enabling it immediately paid for itself.

Would not have started on a user's machine. build:bin passed --external '@opentui/core-*' to a --compile standalone binary, so the native module had no node_modules to resolve from. Verified by building and running from an empty directory.

A production memory leak. Renderable.destroy() frees only its own native buffer and detaches children without destroying them. Six product sites dropped whole subtrees, so every transcript repaint, landing clear and shell dispose stranded its descendants. Instrumented FFI tracking showed 16,061 of 26,481 buffers live; now zero. Seventeen of the eighteen "known flakes" were this.

A crash ate the terminal. An uncaught throw left the alternate screen, mouse reporting and raw mode all on, and the process survived — OpenTUI registers an uncaughtException handler that only logs, which suppresses Bun's default exit, while raw-mode stdin holds the loop open. Verified under a real PTY against a baseline.

Trojan Source in the approval overlay. The control-character stripper let bidirectional overrides through, so text could read as one thing and run as another at the exact moment the operator approves it. Model output also reached the screen unsanitized, including sequences split across streaming deltas.

Ctrl+D quit mid-edit. Unconditional, so deleting a character — the shell default for that chord — ended the session and lost the draft.

Batched tool calls dropped their results. Eight identical calls produced one folded row and seven orphans. The existing test passed because it drove a different code path than the app.

Grep never honored its byte cap. On a breach it settled with everything accumulated — 153 KB against a 200 byte limit in one measured run — so far more than the cap could reach the model's context.

The approval modal wrapped by code units, not columns, so CJK text overflowed the subject the operator is asked to approve.

Four event channels had no listeners, so lifecycle hook status, subagent progress, MCP status and grant confirmations were silently absent.

A retried turn duplicated itselfinference.retry reached the bridge and was dropped, leaving the failed attempt painted and its tool row stranded.

Onboarding truncated a pasted API key at 1000 characters without saying so, which is the first thing a new user does.

Five help rows described behavior that did not exist, including a quit key documented as delete-character. The catalog now has a guard that presses the bytes each row claims.

Also: resumed sessions silently dropped view, plan and tasks blocks (verified against all 49 sessions on one machine); mouse reporting is off by default so the terminal owns selection, with Alt+M to take it back; Alt+C copy mode had never written to the system clipboard.

Verification

bun run typecheck, bun run build clean. bun test ./src ./tests ./evals: 3871 pass, 0 fail.

Most of the above was found by running the app or capturing the pty byte stream, not by the suite. The headless test renderer cannot see paint, real modifier reporting, the system clipboard, or terminal-owned selection.

Open

  • CL-5550 — one CI assertion on the grep byte cap fails on Linux only. The unbounded-payload defect it exposed is fixed; the CI symptom itself is not yet explained, and is being worked rather than skipped.
  • CL-5551 — the 600-block transcript retention cap died with the deleted Ink stream state and was not ported. Every index into streamLog is absolute, so head-trimming needs an offset base threaded through the shell.
  • Shift+Enter does not insert a newline on terminals that do not report the modifier. Ctrl+Enter and Ctrl+J do. The kitty path was verified working end to end, so this is terminal reporting, not a decode defect.
  • Markdown flickers mildly while streaming. The deterministic cause — a bare #### painting as literal text — is fixed and guarded; a residual flicker remains from the async highlighter.
  • Real-terminal sign-off is unsigned. docs/tui-tty-signoff.md, 29 checks at 80x24 and 120x40.

Rejected during this work

A measured double-frame commit per update was diagnosed as the cause of horizontal jitter. Four fix variants were built and every one produced an identical capture, including with the frame hook removed entirely — the second commit originates inside OpenTUI's paint path, below our layout code. All of it was reverted rather than shipped as an unverified refactor of the frame path. The actual cause was a partial heading marker re-classifying mid-stream.

Capture the OpenTUI foundation plan and product brief with locked
interaction contract, branch hard cutover, and constitution scope so
dispatch and Linear can share one source of truth.
Lock geometry ownership, residual transcript floor, zone budgets, and
the kill list so Platform work has one implementer contract before any
OpenTUI shell lands.
Document locked Enter queue, Alt+Enter tool-boundary steer, Ctrl+C
interrupt, palette chord, focus tree, and scroll lease so migration
implements one input model.
Restrict Ink to true P0 daily-use patches and route layout or scroll
classes into the OpenTUI platform instead of new chrome geometry.
Require a single migration branch, full acceptance before merge, and
scrap-on-fail so Ink and OpenTUI never ship as dual paint paths.
Prove isolated core and Solid install, sticky ScrollBox, focus, and
Enter Alt+Enter Ctrl+C key shapes so binding and packaging can proceed.
Choose Solid with core class APIs and keymap for the migration branch,
grounded in the Bun spike go evidence and OpenCode peer alignment.
Describe macOS and Linux contributor install, optional native packages,
and CI matrix gates so packaging is clear before root dependency cutover.
Point operators and implementers from PRODUCT and ARCHITECTURE into the
layout constitution, interaction contract, freeze policy, and cutover docs.
Link the go report to the Solid plus core decision so binding evidence
and the ADR stay one hop apart for implementers.
Platform kit root for migration/opentui-tui: core, solid, keymap 0.5.1.
Ink CLI entry unchanged.
Pure resolveGeometry for OpenTUI shell: zone budgets, collapse order,
transcript floors (idle 12 / inset 8), prompt and overlay caps.
Immutable focus stack with overlay > observe > shell priority and a
single scroll lease owner for wheel/page routing.
Windowing helpers for keep-active-visible lists (permissions, models,
settings consumers).
createHarness / withTestRenderer wrap createTestRenderer with cleanup and
named chords for Enter, Alt+Enter, and Ctrl+C.
Header, sticky transcript ScrollBox, prompt Input, and status bar owned by
createAppShell. Geometry resolver and focus lease drive layout and Tab focus.
Integration tests cover follow/pin, focus lease, Tab, Enter/Alt+Enter/Ctrl+C.
Wave 2 platform status note and dispatch tree recorded.
Pure queue/steer/interrupt state machine and role-styled transcript
formatters for the Wave 3 product skin.
Wire transcript roles, bordered prompt with binding hint, local
queue/steer/interrupt keys, and an inset permission overlay on the
Wave 2 platform. Demo and headless product-skin tests included.
Map reactor-like events into stream rows, route queue/steer/interrupt
through a SessionPort, and cover the path with headless fixture tests.
Ink entry stays production.
Permissions, operator question, and model/provider picker open on the
focus stack + list viewport + geometry host, with headless open/navigate/Esc
and resize-floor coverage. Ink entry stays production.
Command palette on Ctrl+O (reclaimed from tool-expand), windowed
transcript paint past collapse threshold, measured goal/task/agents
chrome via the zone registry, and Alt+C keyboard copy for stream rows.
Settings/help/plugins/resume/mentions share the list kit; subagent observe
swaps stream + lease with Esc restore. Acceptance §10 scored and blockers
listed without authorizing Ink delete or main merge.
Wire injectable SessionPort, production stream event map, overlay
onAccept callbacks, registry-backed palette catalog, and chrome state
helpers so the product host can bind real runner/session APIs without
fixtures.
Settings/help/plugins/resume/mentions openers take optional items, itemIds,
and onAccept; fixtures remain defaults when the host omits data. Shell hooks
gain residual kind callbacks so product-host can bind toggles and resume.
appendObserveStreamRow for live child stream while parent appends
route to the parent snapshot; leave/Esc restores parent lease.
OpenTUI Alt+C now freezes selectable stream targets in the inset
overlay (Ink parity), defaults to the last non-system row, and
reports via status flash so copy never mutates the transcript.

Fixes CL-5415.
Land mountProductHost plus gate, catalog, history, observe, and
chrome helpers with unit tests so production can leave Ink. Runner
still mounts Ink; wire next.
Tool calls show a human summary of their arguments instead of raw JSON —
a view tree reads as its shape, a shell call as its command, an edit as
its file. Expanding renders the real structure rather than pretty-printed
JSON, reusing the view renderer with a palette resolver so it paints in
the product's own tones.

Reasoning streams as a single windowed line while a turn thinks, then
settles to an elapsed-time phrase chosen from a band. Phrases report only
how long reasoning took, never what it concluded.

Skills, summarized calls and settled reasoning all collapse behind the
same expand key, and every collapsed row says it can be opened.

Fixes wrapped tool bodies escaping to column 0: continuation lines now
wrap to the body column and stay inside the shell gutter.

Also drops the mountain glyph from the prompt border. One row has no
vertical range for a silhouette — the wide ridge read as a lump and the
three-cell token as an anonymous peak — so the slot is the word alone and
the mark keeps its full expression on the landing. Adds a bottom margin
row, and makes the brand yield rather than starve the workspace path.
Exa ships tool names like web_search_exa that already carry the server as a
suffix, so namespacing them produced 'Exa: web search exa'. Strip a leading or
trailing word matching the server before title-casing both halves.
No callers since the OpenTUI cutover, and it would fight the renderer's own
alternate-screen handling if it were ever wired up.
Shift+Enter is unreportable unless the terminal negotiates the kitty keyboard
protocol, so a terminal that does not send a bare CR and the newline is lost.
Ctrl+Enter arrives everywhere. Alt+Enter is deliberately not bound: the shell
owns it for steering.

Nothing reads hover, so any-motion tracking was one report per cell traversed
for no consumer. Press and drag reporting stay, which click-to-expand and the
transcript drag-scroll both need.
… raw

The overlay asked for a share of the terminal rather than a row per option, so
a three-choice gate filled the screen and a nine-choice gate was no taller. It
now asks for its item count and lets the resolver cap and scroll it. Choices
lost their blank spacer row so the list reads as one list.

The gate wrote its transcript row at open time, directly above an overlay
saying the same thing. That row is now written once the operator decides, and
carries the choice.

A row ending in a bare '####' is not yet a heading, so the marker painted as
literal text until the title arrived and the line re-laid out underneath text
already on screen. Streaming rows now withhold a trailing heading-marker-only
line.

Observe mode focused the prompt while the focus tree said observe owned keys,
so keystrokes landed silently in the parent prompt where a stray Enter would
have sent them.
1.2.19 has no test.serial, so whole integration files failed to load and took
their tests with them.
OpenTUI releases a renderable's native TextBuffer in its own destroy(),
which detaches children without destroying them. Clearing the landing,
repainting the transcript window or disposing a shell therefore stranded
every descendant's buffer, and a full test run exhausted the native
allocator: 32 'Failed to create TextBuffer' errors on a dev machine and
76 renderer-dependent failures on a 2-core CI runner.
Ctrl+D quit unconditionally, so deleting a character mid-edit ended the
session. It now needs a focused, empty prompt. Esc from transcript browse did
nothing; it returns to the prompt.

Batched identical tool calls dropped every result after the first, because the
coalesced run row cleared its pending flag on the first answer. A run row now
tracks what it is still owed. The existing test passed because it drove a
different path than the app.

The control-character stripper let bidirectional overrides through, so text in
the approval overlay could read as one thing and run as another. Model output
reached the screen unsanitized, including sequences split across deltas.

Width math used UTF-16 code units where columns were required, most visibly in
the wrap that lays out what the operator is approving. The Ambiguous-width
contract with the renderer is now stated and checked at startup.

Onboarding truncated a pasted API key at a thousand characters without saying
so. Provider failures rendered as raw SDK strings; they now name the recovery.
The stall watchdog said nothing for fifteen minutes.

Four emitter channels had no listeners, so lifecycle hooks, subagent progress,
MCP status and grant confirmations were silently absent.

The standalone binary excluded its own native module and could not start away
from a node_modules tree.

Paint entry points now stand down once the shell is disposed. A poll or a
resolved continuation that outlives the renderer would otherwise write into
freed buffers.
…ocks

The grep byte cap never bounded anything: on a breach the handler settled with
everything it had accumulated, 153 KB against a 200 byte limit in one measured
run. The notice fired, so it read as enforced. Collection now owns the bytes
behind three explicit settle points where exactly one wins, and truncates on a
line boundary.

The perf span test was not order-dependent. Every perf file cleared the store
after itself and assumed it began empty, but the store is process-global and
one process runs them all.

Resumed sessions dropped view, plan and tasks blocks with no marker. The loose
history-block shape discarded their payloads before anything could paint them.
Replaying all 49 sessions on this machine recovers 6 blocks that previously
vanished.
…grep

A gate arriving while another overlay was open was dropped: the host refuses a
second non-palette open, so the handler returned without resolving and the run
blocked forever with nothing on screen. Gates now queue and take the host when
it frees. An approval and a question in the same turn is enough to hit it.

The overlay had no way to type an answer, though the tool has always promised
one and OperatorResult already carried it. There is now an answer field, and a
question with no options opens straight into it rather than offering a chooser
with nothing to choose.

The GitHub runner has no ripgrep, so CI exercised the fallback walker for every
search test — and the fallback never capped its output. An unbounded grep could
reach the model on any host without rg. The cap now belongs to the plugin, and
CI installs ripgrep so the real path is covered.
Tests were reading the developer's machine and passing there while failing on a
runner. The launch tests resolved configuration from the real global settings —
the provider env vars they set are read by nothing — and the theme tests assert
hex, which only appears when COLORTERM says truecolor.

One test wrote into the real home directory: session state lands under the home
root, not the sandboxed cwd, so goal-state left project directories behind on
every run. Its home is now an override, matching its sibling in session/.

A preload clears the terminal and CORBITS variables so a test that needs one
must set it, forces telemetry off so no path can write an installation id into
a real settings file, and fails loudly when ripgrep is missing — without it the
search tools quietly run their fallback and the suite stays green.

Bun snapshots os.homedir() at process start, so assigning process.env.HOME in a
test does nothing. Sandboxing goes through the config and cwd flags instead.
@TheGreatAxios
TheGreatAxios merged commit f62211d into main Aug 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant