Cut the interactive TUI over to OpenTUI - #333
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
createCliRendererreplacesrender(<App />). Layout is a zone-based geometry resolver with min/max/idle rows per zone and an explicit collapse order, rather than React reconciliation.theme.tsbehind aThemetype and registry.TextareaRenderable, wrapping and growing to 40vh before it scrolls.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:binpassed--external '@opentui/core-*'to a--compilestandalone binary, so the native module had nonode_modulesto 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
uncaughtExceptionhandler 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 itself —
inference.retryreached 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,planandtasksblocks (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+Ccopy mode had never written to the system clipboard.Verification
bun run typecheck,bun run buildclean.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
streamLogis absolute, so head-trimming needs an offset base threaded through the shell.####painting as literal text — is fixed and guarded; a residual flicker remains from the async highlighter.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.