Skip to content

feat(tui): rebuild on @opentui/solid + xterm-headless (opencode pattern) - #79

Merged
Exelord merged 2 commits into
mainfrom
claude/tui-solid-rebuild
May 13, 2026
Merged

feat(tui): rebuild on @opentui/solid + xterm-headless (opencode pattern)#79
Exelord merged 2 commits into
mainfrom
claude/tui-solid-rebuild

Conversation

@Exelord

@Exelord Exelord commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

You said the TUI was trash and asked if we could mirror opencode's
approach. Done. Wholesale rewrite of the TUI on the stack opencode
actually uses, plus xterm-headless for per-task log panes (which
opencode doesn't need but we do).

The full Turbo + opencode analysis lives in
docs/design/tui-rebuild.md.

Stack swap

Concern Was Now (matches opencode)
React binding @opentui/react @opentui/solid
Reactivity React reconciler + VDOM Solid fine-grained signals
Keys hand-rolled useKeyboard (opencode's) @opentui/keymap available
Overlays popup box at coords (bled through) full-viewport absolute backdrop + zIndex={3000}
Log output newline-split string array (mangled \r/ANSI) xterm-headless vt100 emulator per task
State one big reducer small Solid contexts (theme, run-state, pty-store, dialog)

What's in src/tui/

context/
  helper.tsx        createSimpleContext (port of opencode's helper)
  theme.tsx         single dark theme + dialogBackdrop RGBA
  run-state.tsx     Solid createStore: byId / running / planned /
                    finished — Turbo's TasksByStatus shape
  pty-store.tsx     per-task xterm-headless registry (lazy + async-safe)
component/
  pty-output.ts     xterm-headless wrapper (with window/self shims)
  task-list.tsx     sidebar; spinner for running, ✓/⊙/⨯/⊝ glyphs
  log-pane.tsx      reads selected task's pty buffer; correct vt100
  status-bar.tsx    one padded line, no ghosting
ui/
  dialog.tsx        Dialog primitive — opencode's exact pattern
overlay/
  help-dialog.tsx   keymap legend (bound to `m` per Turbo convention)
app.tsx             shell + useKeyboard + layout
tui.tsx             createCliRenderer + render(<App/>) inside providers
should-use-tui.ts   decision predicate (preserved)

The Dialog primitive is the key thing I was missing before:

<box                                              {/* full-viewport */}
  position="absolute" zIndex={3000}
  width={dim.width} height={dim.height}
  alignItems="center" paddingTop={dim.height/4}
  backgroundColor={RGBA.fromInts(0,0,0,150)}      {/* dim backdrop */}
>
  <box backgroundColor={theme.backgroundPanel}    {/* centered popup */}
       width={60}>
    {children}
  </box>
</box>

This is exactly opencode's pattern. No more bleed-through cells.

Tooling

  • tsconfig: "jsx": "preserve", "jsxImportSource": "@opentui/solid"
  • bunfig.toml (new): preload = ["@opentui/solid/preload"] for
    bun run and bun test
  • cli/run.ts does an explicit await import('@opentui/solid/preload')
    before lazy-loading the TUI so installed-binary users (their own
    cwd, no bunfig) also get the babel-preset-solid Bun plugin
    registered

Deps

- @opentui/react      -> + @opentui/solid
- react                  + @opentui/keymap
- @types/react           + solid-js
                         + xterm-headless
                         + @types/babel__core (devDep, for tsc)

What was deleted

~3,300 LOC of React-binding TUI code:

  • src/tui/{components,overlays,primitives,state,views}/*
  • src/tui/{App.tsx, tui-shim.ts, tui.ts}
  • 6 test files for React-binding-internal state/reducer/selectors

What survived: the orchestrator-side Observer + scheduler-slot
allocation + cache history (Phase 1) — that work was clean and
binding-agnostic.

Test plan

  • bun src/bin.ts run format — clean
  • bun src/bin.ts run lint — 0/0
  • bun src/bin.ts run test — 436/436 pass
  • New tests/tui-smoke.test.ts mounts the Solid TUI in OpenTUI
    testing: true mode and dispatches the full event sequence
    (runStart → taskStart → taskStdout → taskComplete → runEnd)
  • Visual smoke in a real TTY — haven't been able to drive a TTY
    from inside the bash tool; needs your eyes when you're back

What's NOT in this PR (deliberately cut scope)

  • Multi-view tabs (Graph/Workers/Bottlenecks/Queue). Turbo and
    opencode both ship one screen.
  • Sparkline panels. Same.
  • Critical-path widget. Same.
  • Auto-exit countdown. q / Ctrl-C exits explicitly.
  • @opentui/keymap declarative bindings. We use plain useKeyboard
    for now; can migrate once we want priorities/contexts.
  • Filter input (/). Easy to add as a follow-up dialog.

https://claude.ai/code/session_016HXj6HW6bxSn8EYuKcxTD9


Generated by Claude Code

claude added 2 commits May 13, 2026 17:25
Wholesale rewrite of the TUI. The prior @opentui/react implementation
was rejected as unusable ("the tui is trash :/ nothing works"). After
studying both Turbo's TUI (Rust, ratatui + vt100) and opencode's TUI
(the OpenTUI maintainers' own TUI), the path forward is clear:
opencode uses @opentui/solid, not @opentui/react. Solid's fine-grained
reactivity avoids the React-reconciler-vs-painter races that produced
our ghosting and overlay-bleed bugs.

What changed:

Deps:
  - removed: @opentui/react, react, @types/react
  - added:   @opentui/solid, @opentui/keymap, solid-js,
             xterm-headless, @types/babel__core

tsconfig:
  - "jsx": "preserve", "jsxImportSource": "@opentui/solid"

bunfig.toml (new):
  - preload = ["@opentui/solid/preload"]    # for `bun run` / `bun test`

cli/run.ts:
  - lazy `await import('@opentui/solid/preload')` before the dynamic
    TUI import so installed-binary users (different cwd, no bunfig)
    also get the babel-preset-solid Bun plugin registered

New src/tui/ layout (mirrors opencode):

  context/helper.tsx           createSimpleContext factory (port)
  context/theme.tsx            single dark theme + dialogBackdrop RGBA
  context/run-state.tsx        Solid createStore: byId / running /
                               planned / finished buckets, Turbo-style
                               finished-rank ordering (failures first)
  context/pty-store.tsx        per-task pty registry; lazy-creates an
                               xterm-headless Terminal on first chunk,
                               buffers chunks during async load
  component/pty-output.ts      xterm-headless wrapper with window/self
                               shims + allowProposedApi
  component/task-list.tsx      left sidebar; spinner for running,
                               ✓/⊙/⨯/⊝ glyphs by terminal status
  component/log-pane.tsx       right pane; reads
                               buffer.active.getLine() for the
                               selected task — vt100-parsed output
                               renders correctly (progress bars,
                               cursor escapes, \r overwrites)
  component/status-bar.tsx     single padded line, q / ↑↓ / m hints
  ui/dialog.tsx                Dialog primitive — opencode's exact
                               pattern: position="absolute" full
                               viewport + zIndex={3000} + translucent
                               RGBA backdrop + popup centered inside
                               via alignItems="center"+paddingTop
  overlay/help-dialog.tsx      keymap legend dialog (m toggles, esc
                               closes), bound through DialogProvider
  app.tsx                      App shell with useKeyboard, layout
                               TaskList + LogPane + StatusBar
  tui.tsx                      tui() entry: createCliRenderer +
                               render(<ThemeProvider><RunStateProvider>
                               ...<App /></DialogProvider>...)
  should-use-tui.ts            decision-table predicate (preserved)

Deleted ~3,300 lines of React-binding code:
  src/tui/{components,overlays,primitives,state,views}/*
  src/tui/{App.tsx, tui-shim.ts, tui.ts}
  tests/{should-use-tui, sparkline, timeline-layout, critical-path,
         tui-store, tui-selectors}.test.ts

Tests: 506 → 436 (deleted React-binding-specific tests + 5 internal
state/selector tests; kept all 414 orchestrator/cli/cache tests +
the Solid TUI smoke test). Smoke test mounts the new TUI under
OpenTUI's testing=true mode and dispatches the full event sequence
(runStart → taskStart → taskStdout → taskComplete → runEnd) — passes
cleanly.

See docs/design/tui-rebuild.md for the full Turbo + opencode
analysis and four-option comparison that led here.

https://claude.ai/code/session_016HXj6HW6bxSn8EYuKcxTD9
The TUI rebuild commit's decision-log entry hit an oxfmt issue —
a stray list-marker indentation rewrites cleanly with `oxfmt .`.
No content change.
@Exelord
Exelord merged commit 2028b25 into main May 13, 2026
1 check 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.

2 participants