tui review - #5188
Conversation
Expose applyShellStreamEvent and applyThreadDetailEvent via package exports (state/shell-reducer, state/thread-reducer) so other clients (e.g. a terminal UI) can fold orchestration events without pulling in the atom-based state modules. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A TUI that runs on the same machine as the server and talks to it over loopback, so you can monitor and drive threads after SSHing in without port forwarding. Built with Ink, reusing @t3tools/client-runtime for the RPC client and the orchestration/terminal contracts. - Discovers the running server via server-runtime.json and mints a loopback bearer + ws ticket in-process (EnvironmentAuth), connecting a minimal supervisor over the existing WebSocket RPC. - Collapsible projects with top-N thread previews and "show more", line-accurate conversation pagination, and a composer that is always ready (Ctrl shortcuts for new/terminal/interrupt/approve/mode). - Mouse support: wheel scrolling (SGR + X10), click to select threads / toggle projects, and a draggable terminal drawer. - Embedded terminal rendered with @xterm/headless (same engine as the web) as a resizable bottom drawer, replacing a screen takeover. - Fullscreen alt-screen with a file logger so stdout stays clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `pnpm tui` and `pnpm tui:dev` (the latter targets the dev server's state dir via --dev-url) and a README section covering SSH usage and the key bindings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shared visual vocabulary for the TUI, mirroring the web sidebar's status pills (Sidebar.logic.ts `resolveThreadStatusPill`/`resolveProjectStatusIndicator`) and relative-time labels (timestampFormat.ts). Maps the web's semantic colours to Ink's named ANSI colours (amber→yellow, indigo/violet→magenta, sky→cyan, emerald→green) and exposes resolveThreadStatus / resolveProjectStatus / sessionStatusColor / relativeTime as pure helpers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the thread list in line with the web sidebar: - Ellipsis-truncate project/thread titles to one line (manual clip + pad) instead of letting long names wrap and clip — the title is right-padded so trailing metadata aligns to the pane edge. - Status dots from the shared theme (working/awaiting-input/plan-ready/…) replacing the ad-hoc running/approval flags; project headers show an aggregate status dot + thread count. - Right-aligned relative timestamps (now/2m/3h/5d) on thread rows, and a colour-coded status + relative time in the conversation header. - Drag-resizable list/conversation divider (listWidth state + a vertical divider drag reusing the terminal-drawer divider plumbing). Manual truncation is used because Ink's flex `truncate-end` either hard-clips without an ellipsis or wraps to a second line in this layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the terminal UI into its own workspace package built with a real
JSX pipeline (esbuild, react-jsx) instead of hand-written
React.createElement calls — Node's type stripping handles .ts but not
JSX, which is why the in-server version avoided it.
The package is pure UI + connection: it talks to the running server over
the existing client-runtime RPC layer and takes a TuiOptions
({ origin, bearerToken, mintSocketUrl, logPath }) so it never depends on
the server's auth internals. The host performs the loopback bootstrap and
hands those in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Delete apps/server/src/tui and rewire the `t3 tui` command to do only the loopback auth bootstrap: issue a long-lived bearer session and build a mintSocketUrl closure (server-side, where EnvironmentAuth lives), then hand them to runTui() from @t3tools/tui via a lazy import. Drop the now-unused ink/react/@xterm/headless/client-runtime deps from the server and add @t3tools/tui. Enable jsx in the server tsconfig so tsgo can parse the dependency's .tsx source types (the server has no JSX of its own). The tui/tui:dev scripts now build the package first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Point @t3tools/tui's `types` export at a hand-written public.d.ts that re-exports TuiOptions (from connection.ts, plain .ts) and declares runTui, instead of at the JSX `src/index.ts`. Consumers now resolve a declared API surface and never parse the package's `.tsx`, so the `jsx` compiler flag added to the server tsconfig is no longer needed — the server has no reason to care that the TUI happens to use JSX. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace Ink with OpenTUI for a native (Zig) renderer, streaming Markdown conversations, and a built-in scrollbox. OpenTUI's renderer requires Bun (FFI), which the Node `t3` binary can't load in-process, so the UI now runs as a Bun subprocess that `t3 tui` spawns after the auth bootstrap. - apps/tui: Bun + OpenTUI package (bun build, jsxImportSource @opentui/react). app.tsx rewritten to <box>/<text>/<scrollbox>/<input> + useKeyboard, deleting ~250 lines of SGR/X10 mouse parsing plus the scrollbar/wrap/pagination and alt-screen/raw-mode plumbing (the renderer owns that). Conversation is a stickyScroll scrollbox of per-message <markdown streaming>. Pure logic (store, buildRows, theme, approvals, terminalView) carried over unchanged. - New Bun entry index.tsx: createCliRenderer + createRoot; reads origin and bearer token from env; mints fresh ws URLs over the Node IPC channel; renderer.destroy() on exit. - Server cli/tui.ts spawns `bun <entry>` with stdio inherit + an ipc fd, answers the child's websocket-ticket requests via issueWebSocketTicket, and prints an install hint when Bun is absent. - vite.config: keep @t3tools/tui external (not inlined into the Node bin) so the Bun child ships as a resolvable file with @OpenTui native deps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The TUI now runs on Bun/OpenTUI, so the README points users at bun.sh and notes the Markdown conversation + PgUp/PgDn scrolling. The `tui`/`tui:dev` scripts build the Bun package (`bun build`) before launching `t3 tui`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OpenTUI rewrite hardcoded hex/named colors, but OpenTUI is a truecolor renderer — named colors became fixed RGB (e.g. "blue" → #0000FF, unreadable on black) instead of mapping to the user's ANSI palette like Ink did, so it fought every terminal theme. Use OpenTUI's indexed/default colour intents instead, which the terminal renders with ITS OWN palette: - text → RGBA.defaultForeground(); terminal cells inherit the terminal's fg/bg - dim → ANSI slot 8, accent → slot 6 (cyan) - status/role/border colours map names → ANSI slots 0–15 via ansi() - no hardcoded hex anywhere; selection is an accent bar + themed highlight Also render with a transparent background so the terminal's backdrop shows through, and call renderer.getPalette() at startup so indexed/default intents resolve to the user's real theme even on truecolor terminals. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Bun TUI child restored the terminal and disposed the client on Ctrl+C, but the live WebSocket and the IPC channel to the parent kept Bun's event loop alive, so the process lingered and a second press (a real SIGINT, once raw mode was off) was needed to actually quit. Force-exit after teardown (process.exit(0)), racing dispose against a short timeout so a slow disconnect can't wedge it, and add idempotent SIGINT/SIGTERM handlers for terminals that deliver a signal rather than the raw Ctrl+C byte. One press now closes the child, the parent sees the close, disposes its auth runtime, and returns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OpenTUI's <input> emits `input` on every keystroke and `change` only on
commit/blur (like the DOM). The composer was wired to onChange, so the
controlled `value={reply}` never updated while typing — the field ran
uncontrolled and Enter read an empty `reply`, taking the "activate row"
path instead of sending.
Wire both the reply and new-thread inputs to onInput so state tracks each
keystroke and Enter sends the typed text. Also lay the reply prompt out
in a row so the `›` marker and field share one line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
app.tsx (~1100 lines) becomes focused modules under apps/tui/src, named after their apps/web counterparts (even though they don't share code): - components/ChatView.tsx (was App: layout + state wiring) - components/Sidebar.tsx + Sidebar.logic.ts (rows, windowing, row model) - components/ThreadStatusIndicators.tsx (themed status dot) - components/MessagesTimeline.tsx (was ConversationView) - components/ChatComposer.tsx (reply + new-thread inputs) - components/ThreadTerminalDrawer.tsx (was TerminalPane) - hooks/useKeyBindings.ts (key→action routing) - store.ts (the external store), format.ts (text helpers), and the colour palette folded into theme.ts Re-render hygiene (vercel-react-best-practices): the sidebar, its rows, the timeline, and the terminal drawer are React.memo'd, rows take the stable `store` instead of per-render onClick closures, and listRows is memoized — so streaming a reply no longer re-renders the list. Behaviour unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The TUI tore down and re-opened the per-thread WebSocket subscription on every selection, refetching each thread's whole snapshot on re-select. Reuse the web's caching engine — makeEnvironmentThreadState / makeEnvironmentShellState from @t3tools/client-runtime (what the web's atom family wraps): each builds a SubscriptionRef that loads a cache, streams referentially-stable deltas, persists on close, and re-syncs on reconnect. A bounded warm LRU registry (8 threads) keeps recently-viewed threads' refs live, so re-selecting one needs zero refetch; an in-memory EnvironmentCacheStore gives within-session persistence for evicted threads. A synchronous peekThread seeds the detail on select so it paints instantly (no blank). This is transparent behind the TuiClient surface, so the components are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
readTerminalFrame mapped xterm palette indices through a hardcoded 256-colour table (the VGA-ish #800000 red, #008000 green, …), so the embedded terminal ignored the user's theme. Setting xterm's `theme` is a no-op here — we read the raw buffer, and getFgColor() returns the palette index regardless. Emit those indices as OpenTUI indexed colours (RGBA.fromIndex) instead, so the host terminal renders them with ITS OWN palette — consistent with the rest of the UI. Truecolor cells pass through as hex; default cells stay default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…resize The embedded terminal was a screen-takeover mode that grabbed every keystroke. Make it a drawer that coexists with the prompt: - ^E shows/hides the drawer (opening focuses it) - ^P toggles focus between the prompt and the terminal - ^↑/^↓ resize the drawer (resizes the xterm emulator and the PTY) ^E/^P are always intercepted (never forwarded to the shell) so they behave the same from either pane. The drawer's border/title show which pane is active. Fix double-input: OpenTUI doesn't blur an <input> when nothing else takes focus, so a mounted prompt kept consuming keystrokes that were also going to the PTY. The composer now only mounts the <input> when the prompt actually has focus (and renders static text otherwise), guaranteeing a single consumer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code-review fixes in the warm-subscription caching layer: - The cache-key separator was a literal NUL byte (an editor/paste artifact) that corrupted the source into a "binary" file. Replace it with a visible escape sequence — same collision-proof separator, clean ASCII source. - A deleted (or never-existent) thread kept its warm SubscriptionRef alive, and makeEnvironmentThreadState retries an expected failure every 250ms — so a deleted-but-warm thread hammered the server forever. An internal watcher now evicts the entry when the thread's status becomes "deleted". - The watcher keeps latestThreads fresh for warm threads and prunes it on eviction/deletion (and disposeWarm clears it), so it no longer grows unbounded and peekThread stops returning a deleted thread's stale snapshot. - Cleanups: use Option.fromUndefinedOr instead of a hand-rolled helper; drop the unused ThreadListEntry export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- mintSocketUrl's pending request never settled if the parent IPC channel broke mid-request, hanging the reconnect loop forever. Reject pending on 'disconnect', add a per-request timeout, and catch a closed-channel send. - A throw after createCliRenderer left the terminal in raw/alt-screen mode with a garbled message; wrap the body so the renderer is destroyed before rethrow. - The server CLI leaked its auth ManagedRuntime if issueSession failed (the dispose ensuring only covered runBunTui). Wrap the whole bootstrap in the ensuring, and revoke the 30-day session on exit so it can't accumulate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The new-thread dialog discarded the typed message when the chosen project had no default model — it set a hint then cleared the draft and closed. Keep the dialog open (and the draft) in that case. - projectIndex could dangle past a shrinking project list, making the composer show "(none)" and Enter a silent no-op; clamp it on use. - buildRows hid projects with no threads, so they were invisible/unselectable and the list disagreed with the "N project(s)" count; include all catalogue projects. - Clear the terminal drawer's pending render timer on unmount; correct the key-bindings doc to not overclaim the new-thread modal; simplify selectionFromRow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire setInteractionMode, renameThread, archiveThread, unarchiveThread, deleteThread, and stopSession into TuiClient/makeTuiClient as one-liners over the existing client-runtime operations, mirroring setRuntimeMode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the low-effort web features whose ops + data already exist on the subscribed shell/thread: - ^B toggles plan/build (interactionMode); the conversation header shows it. - ^K opens a thread-actions overlay: rename (r), archive/unarchive (a), delete with y/n confirm (d), stop session (s). - ^F filters the sidebar by title; buildRows gains a filter param and the store re-validates the selection when a match disappears. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Switch the package test script to `bun test` (Bun-native, so OpenTUI's headless renderer and @xterm/headless run for real) and add @types/bun. Specs in Given/When/Then style across three layers: - pure logic: format, theme, approvals, Sidebar.logic (incl. filter), store (incl. filter re-validation), connection warm-registry, terminalView. - components via @opentui/react test-utils: ChatComposer (the double-input fix + onInput), ThreadActionsMenu, MessagesTimeline plan/build header. - features.backlog: describe.skip blocks for every un-ported web feature, so the skipped count tracks the remaining work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the single-line reply <input> with OpenTUI's <textarea> so the prompt behaves like the web composer: - Multiline: Enter sends, Shift+Enter (or Ctrl+J where the terminal can't report Shift+Enter) inserts a newline. The box auto-grows with line count. - Paste inserts the full clipboard verbatim — multi-line and unbounded, fixing the single-line cap. - Enter handling moves from the global keymap to the textarea's onSubmit so newline vs send is unambiguous; the editor is cleared by remount after send. - ↑/↓ yield to the editor for cursor movement once the reply spans multiple lines, otherwise they still navigate the sidebar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Render the conversation like the web UI by deriving from data the thread detail already carries (no new fetches): - worklog.ts ports the web's deriveWorkLogEntries: tool-call / thinking rows from thread.activities (icon + label + muted command/detail preview + success/fail/progress glyph), with lifecycle updates collapsed per toolCallId. - timeline.ts interleaves messages and the work log chronologically, exposes the working state (running session/turn + elapsed seconds), and maps each turn's checkpoint to its assistant message for a changed-files summary. - MessagesTimeline renders the interleaved timeline, a "changed files (N) +A -D" block with per-file stats under the producing message, and a live "Working… Ns" indicator that ticks while a turn runs. BDD: real specs for worklog/timeline logic and a MessagesTimeline component spec (tool row, changed-files, working indicator) through the headless renderer; drop the now-shipped changed-files backlog entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After a plan-mode turn the assistant emits a proposed plan; surface it in the conversation like the web ProposedPlanCard. Derived from data already on the thread (proposedPlans + latestTurn), no fetch: - proposedPlan.ts ports findLatestProposedPlan + the title/body helpers and exposes latestActionableProposedPlan (latest plan with implementedAt === null). - MessagesTimeline renders a bordered card at the end of the timeline: plan title, the plan markdown (title/Summary heading stripped), and a hint. BDD: real specs for the selection/strip logic and a MessagesTimeline component spec through the headless renderer; drop the now-shipped proposed-plan-card backlog entry. Implementing a plan in a new thread remains the next backlog item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the proposed-plan card actionable: ^Y starts a turn that executes the plan. TuiClient.implementPlan starts a turn on the thread in build mode (interactionMode "default") referencing the plan via sourceProposedPlan — mirroring the web's same-thread implement path. The card hint and footer advertise the key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface token usage like the web ContextWindowMeter. contextWindow.ts ports deriveLatestContextWindowSnapshot (the latest context-window.updated activity's usedTokens/maxTokens) plus compact token + bar formatting. MessagesTimeline renders a one-line meter under the header — "context ▓▓▓░░ 72% · 144k/200k", coloured green/yellow/red by fill — when usage is known. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
^A/^R previously acted only on the oldest approval. Add an approval cursor: ↑/↓ move it while a pending approval is up (and the reply is empty), and ^A/^R act on the selected request. The panel shows "(i of N)", marks the active row with ▸, and advertises ↑/↓ when more than one is pending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a revert flow off the ^K actions menu: pressing v opens a picker listing
the thread's checkpoints newest-first (turn count · file count · age); ↑/↓
choose one and Enter reverts. TuiClient.revertCheckpoint sends
revertThreadCheckpoint({turnCount}); the picker warns that it discards changes
made after the chosen point.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Components were reaching for raw ansi("red")/("yellow")/("green")
literals for statuses, so the intent (error vs. danger vs. diff-removed)
lived at each call site. Add `error`/`success`/`warning` roles to the
palette — ANSI slots 1/2/3, which the theme author has already tuned
against their own background, so they stay theme-relative with good
contrast on dark and light schemes — and use them everywhere a colour
means something: Stop/approval/danger/error states, diff +/- stats,
the context meter, busy/disabled/setup-required hints, and the
unfocused terminal header.
The only remaining literals are deliberate hue choices, not statuses:
markdown inline code (yellow) and the merged-PR badge (magenta,
mirroring GitHub's purple) — still theme-relative ANSI slots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Colour bugs are environment-dependent (SSH drops COLORTERM, multiplexers rewrite TERM) and invisible in the rendered output, so record TERM, COLORTERM, and the renderer's detected capabilities in the TUI log — once at startup and once after the capability handshake settles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Over SSH the renderer's native layer flags the session as remote and the JS side then forwards no environment to its capability detection (the default forward list only applies when remote is explicitly false). With neither COLORTERM nor a "256color" TERM visible — and no capability handshake that advertises truecolor — rgb/ansi256 stay false and every themed ANSI slot flattens to the baked legacy palette, no matter what the user sets. Verified against a PTY harness with SSH_CONNECTION set: indexed red emits 38;2;128;0;0 before, 38;5;1 after. ssh forwards TERM and we default COLORTERM in-process, and both describe the viewer's terminal, so forward them (plus TERM_PROGRAM) explicitly. This also makes the in-process ensureColorCapabilityEnv effective for SSH sessions: forwarding reads process.env from JS, bypassing Bun's non-propagation of env writes to the native environ. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TUI already classified threads into Active/Snoozed/Settled sidebar sections but offered no way to actually settle or un-settle one — the commands existed only on the web. Wire the client-runtime operations into TuiClient and add a palette command that mirrors the web context menu: one mutually-exclusive Settle/Un-settle entry driven by the thread's current classification, gated on the server's threadSettlement capability so the command never reaches a server that would reject it. Settle runs the same canSettle pre-flight as the web (pending approvals/input, active session, queued turn start) and reports the block in the status line; un-settle sends reason "user", pinning the thread active until real activity clears the pin server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sidebar window was recomputed around the selection on every move, so the whole list shifted under the cursor. Persist a scroll offset and only move it when the selection reaches a viewport edge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the hand-rolled row windowing and its "↑/↓ more" markers with the same OpenTUI scrollbox the other panes use: the mouse wheel scrolls, a scrollbar shows position, and keyboard selection only shifts the viewport when it reaches an edge. The list height must match the sidebar chrome exactly — an oversized scrollbox makes yoga shrink the heading above it and rows paint over it — so resolveSidebarListViewport now accounts for every chrome line and the scrollbox refuses to shrink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…web size Image attachments on user messages rendered below and outside the right-aligned accent bubble — plain, left-aligned, borderless — and at up to 40 columns with no height cap. The web puts previews inside the bubble above the text and bounds them to a ~206x220px grid cell; do the same here: attachments move into the bubble (which widens to fit the preview and one unwrapped metadata line) and previews scale down to the web's pixel box, never up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "[ image paused while scrolling ]" text clipped awkwardly at web-parity preview widths, and the message bubble already frames the reserved area — leave it blank while placements are paused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Web-parity 206x220px thumbnails come out only ~11-23 terminal columns wide, which is too small to read. Give the TUI a 420x440px box instead — still a bounded thumbnail, scaled down into it, never up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The base64 read path skipped the binary-file guard and raised the cap to 10MB for any path, turning projectsReadFile into an arbitrary-binary read primitive. Share the attachment image-extension map via contracts, reject non-image base64 reads server-side, and reuse the map in the TUI so the two lists cannot drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
extractPastedImagePath searched forward for an image extension from an anchored path start, so prose like 'screenshots are in /home/user/Pictures and latest.png is missing' spanned whitespace into later words and misfired as one path. Reject candidate spans with unescaped whitespace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pathForSection scanned every line of a section, so added/removed content rendered as '+++ '/'--- ' (markdown '++ item', SQL '-- comment') overrode the real header path. Stop scanning at the first hunk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
clip() compared text.length against a column budget while every caller measures layout with Bun.stringWidth, so CJK/emoji labels overflowed fixed-width rows and truncation could split a surrogate pair. Measure and truncate by display columns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The events atom held only the latest stream value, so a closed event superseded by any other terminal event (an output chunk, the next close in a multi-terminal archive) before React committed was silently dropped, leaving ghost tabs that respawn dead terminals. Accumulate closed events in the stream and apply each unprocessed one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thread-level close iterated only live sessions, so terminals that existed purely as persisted history (advertised by terminal.list) never published 'closed' before their history files were deleted — clients kept stale tabs that respawned fresh shells for archived threads. Close persisted-only ids too, after the live sessions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mime-mismatch and invalid-base64 branches re-reported on every DATA packet of a chunked payload and left the pending timeout to fire a second, misleading error. Guard on read.discarded like the overflow branch and keep the timeout armed while draining. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every streamed delta replaced the detail object, re-deriving the whole timeline, re-sorting checkpoints, and re-linkifying every message body. Memoize linkify with a bounded LRU keyed by input and depend on the exact detail slices each derivation reads so unrelated deltas skip the work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
|
||
| // Expose the viewport text for ^O copy while this is the visible terminal. | ||
| React.useEffect(() => { | ||
| if (!visible) return; |
There was a problem hiding this comment.
🟡 Medium components/ThreadTerminalDrawer.tsx:207
When the user scrolls up in a terminal pane and presses ^O to copy, the clipboard receives the live tail instead of the scrollback text currently shown on screen. The copyRef getter calls readTerminalViewport(term), which always reads from buffer.viewportY (the live tail), but the pane renders from baseY - scrollOffsetRef.current when scrolled. Consider passing the current scrollOffsetRef.current into readTerminalViewport so the copied text matches the visible viewport.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/tui/src/components/ThreadTerminalDrawer.tsx around line 207:
When the user scrolls up in a terminal pane and presses `^O` to copy, the clipboard receives the live tail instead of the scrollback text currently shown on screen. The `copyRef` getter calls `readTerminalViewport(term)`, which always reads from `buffer.viewportY` (the live tail), but the pane renders from `baseY - scrollOffsetRef.current` when scrolled. Consider passing the current `scrollOffsetRef.current` into `readTerminalViewport` so the copied text matches the visible viewport.
| export function buildFileTree(files: ReadonlyArray<TreeFileInput>): ReadonlyArray<TreeNode> { | ||
| const root = emptyDir("", ""); | ||
| for (const file of files) { | ||
| const segments = file.path.split("/").filter((s) => s.length > 0); |
There was a problem hiding this comment.
🟡 Medium src/fileTree.ts:46
buildFileTree splits file.path only on /, so a path with backslashes like apps\web\src\index.ts is treated as a single filename instead of a nested directory tree. On Windows, this loses directory grouping, chain compaction, and collapse/expand behavior for changed files. The web tree normalizes \ to / before splitting; consider doing the same here.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/tui/src/fileTree.ts around line 46:
`buildFileTree` splits `file.path` only on `/`, so a path with backslashes like `apps\web\src\index.ts` is treated as a single filename instead of a nested directory tree. On Windows, this loses directory grouping, chain compaction, and collapse/expand behavior for changed files. The web tree normalizes `\` to `/` before splitting; consider doing the same here.
| if (!response.ok) return null; | ||
| const contentLength = Number(response.headers.get("content-length")); | ||
| if (Number.isFinite(contentLength) && contentLength > maxEncodedBytes) return null; | ||
| const encoded = new Uint8Array(await response.arrayBuffer()); |
There was a problem hiding this comment.
🟠 High src/attachmentImages.ts:54
load calls response.arrayBuffer() and allocates the full response body into memory before checking encoded.byteLength > maxEncodedBytes. When Content-Length is absent or understated (e.g. chunked transfer encoding), there is no early cap and the entire body is buffered into a Uint8Array regardless of maxEncodedBytes, so an oversized response can exhaust the process's memory — the size check only runs after the allocation has already happened. Consider reading the body incrementally with a running byte cap and aborting once it is exceeded, rather than buffering the complete response first.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/tui/src/attachmentImages.ts around line 54:
`load` calls `response.arrayBuffer()` and allocates the full response body into memory before checking `encoded.byteLength > maxEncodedBytes`. When `Content-Length` is absent or understated (e.g. chunked transfer encoding), there is no early cap and the entire body is buffered into a `Uint8Array` regardless of `maxEncodedBytes`, so an oversized response can exhaust the process's memory — the size check only runs after the allocation has already happened. Consider reading the body incrementally with a running byte cap and aborting once it is exceeded, rather than buffering the complete response first.
| for (const provider of providers) { | ||
| const providerLabel = provider.displayName ?? provider.driver ?? provider.instanceId; |
There was a problem hiding this comment.
🟠 High src/models.ts:32
flattenModelOptions includes models from every provider regardless of its enabled/availability state, so disabled or unavailable providers appear in the picker. Selecting one causes thread creation or reply submission to fail because the runtime refuses to start turns against unavailable providers. Consider filtering providers to only enabled/available entries before iterating their models.
for (const provider of providers) {
+ if (provider.enabled === false || provider.availability === "unavailable") continue;
const providerLabel = provider.displayName ?? provider.driver ?? provider.instanceId;🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/tui/src/models.ts around lines 32-33:
`flattenModelOptions` includes models from every provider regardless of its `enabled`/`availability` state, so disabled or unavailable providers appear in the picker. Selecting one causes thread creation or reply submission to fail because the runtime refuses to start turns against unavailable providers. Consider filtering `providers` to only enabled/available entries before iterating their models.
| const editorBudget = Math.max( | ||
| 1, | ||
| height - STATUS_ROWS - MIN_TIMELINE_ROWS - terminalReserve - chromeRows, | ||
| ); | ||
| const editorRows = Math.max( | ||
| 1, | ||
| Math.min(Math.floor(input.desiredEditorRows), COMPOSER_MAX_EDITOR_ROWS, editorBudget), | ||
| ); | ||
| const composerRows = chromeRows + editorRows; |
There was a problem hiding this comment.
🟡 Medium components/ChatView.layout.ts:90
composerRows can exceed the terminal height because composerChromeRows is added to editorRows without any cap. When composerChromeRows is large (e.g., a question with many options), composerRows grows beyond height, and the returned panesRows + composerRows + terminalRows + popoverRows + STATUS_ROWS exceeds the terminal height, breaking the allocation invariant and causing composer content to overlap. Consider clamping composerRows (or editorRows with chrome included) to the available rows so totals never exceed height.
const terminalReserve = input.terminalOpen ? MIN_TERMINAL_DRAWER_ROWS : 0;
- const editorBudget = Math.max(
- 1,
- height - STATUS_ROWS - MIN_TIMELINE_ROWS - terminalReserve - chromeRows,
- );
- const editorRows = Math.max(
- 1,
- Math.min(Math.floor(input.desiredEditorRows), COMPOSER_MAX_EDITOR_ROWS, editorBudget),
- );
- const composerRows = chromeRows + editorRows;
+ const composerBudget = Math.max(
+ 0,
+ height - STATUS_ROWS - MIN_TIMELINE_ROWS - terminalReserve,
+ );
+ const maxEditorRows = Math.max(0, composerBudget - chromeRows);
+ const editorRows = Math.max(
+ 1,
+ Math.min(Math.floor(input.desiredEditorRows), COMPOSER_MAX_EDITOR_ROWS, maxEditorRows),
+ );
+ const composerRows = Math.min(chromeRows + editorRows, composerBudget);🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/tui/src/components/ChatView.layout.ts around lines 90-98:
`composerRows` can exceed the terminal height because `composerChromeRows` is added to `editorRows` without any cap. When `composerChromeRows` is large (e.g., a question with many options), `composerRows` grows beyond `height`, and the returned `panesRows + composerRows + terminalRows + popoverRows + STATUS_ROWS` exceeds the terminal height, breaking the allocation invariant and causing composer content to overlap. Consider clamping `composerRows` (or `editorRows` with chrome included) to the available rows so totals never exceed `height`.
| // Base64 reads exist for the image-attach flow only. Gate them to | ||
| // image extensions so the raised 10MB cap and the skipped binary | ||
| // guard below cannot become an arbitrary-binary read primitive. | ||
| if (encoding === "base64" && chatImageMimeTypeForPath(input.relativePath) === null) { |
There was a problem hiding this comment.
🟠 High workspace/WorkspaceFileSystem.ts:221
The base64 image-only gate checks input.relativePath (the user-supplied alias), so a workspace symlink like preview.png -> database.sqlite passes the chatImageMimeTypeForPath check while the resolved realTargetPath is opened and up to 10 MB of the non-image target is returned as base64. This defeats the restriction intended to prevent arbitrary binary workspace reads. Validate the resolved path (realTargetPath) or the actual file content instead of the requested alias.
| if (encoding === "base64" && chatImageMimeTypeForPath(input.relativePath) === null) { | |
| if (encoding === "base64" && chatImageMimeTypeForPath(realTargetPath) === null) { |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/workspace/WorkspaceFileSystem.ts around line 221:
The base64 image-only gate checks `input.relativePath` (the user-supplied alias), so a workspace symlink like `preview.png -> database.sqlite` passes the `chatImageMimeTypeForPath` check while the resolved `realTargetPath` is opened and up to 10 MB of the non-image target is returned as base64. This defeats the restriction intended to prevent arbitrary binary workspace reads. Validate the resolved path (`realTargetPath`) or the actual file content instead of the requested alias.
| @@ -0,0 +1,9 @@ | |||
| const PRIVATE_WORKSPACE_IMPORT = /(?:from\s+|import\(|require\()\s*["'](@t3tools\/[^"']+)["']/u; | |||
There was a problem hiding this comment.
🟠 High scripts/tuiBundle.ts:1
OPAQUE_PACKAGE_REQUIRE requires ( immediately after the closing ) of createRequire(...), so lookups with whitespace between them (e.g. createRequire(import.meta.url) ("@xterm/headless")) are not detected — findUnresolvedTuiBundleImport returns null and the build passes while shipping a runtime dependency on unavailable node_modules. Additionally, PRIVATE_WORKSPACE_IMPORT only matches from, import(, and require( forms, so bare import "@t3tools/foo" and export { x } from "@t3tools/foo" also return null, letting unresolved private workspace packages through validation. Consider allowing optional whitespace/newlines between createRequire(...) and (, and adding import\s+ and export\s+.*from\s+ as recognized import forms.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/scripts/tuiBundle.ts around line 1:
`OPAQUE_PACKAGE_REQUIRE` requires `(` immediately after the closing `)` of `createRequire(...)`, so lookups with whitespace between them (e.g. `createRequire(import.meta.url) ("@xterm/headless")`) are not detected — `findUnresolvedTuiBundleImport` returns `null` and the build passes while shipping a runtime dependency on unavailable `node_modules`. Additionally, `PRIVATE_WORKSPACE_IMPORT` only matches `from`, `import(`, and `require(` forms, so bare `import "@t3tools/foo"` and `export { x } from "@t3tools/foo"` also return `null`, letting unresolved private workspace packages through validation. Consider allowing optional whitespace/newlines between `createRequire(...)` and `(`, and adding `import\s+` and `export\s+.*from\s+` as recognized import forms.
| ) : null} | ||
| </text> | ||
| <text fg={palette.text}>{clip(question.question, labelRoom)}</text> | ||
| {question.options.map((option, index) => { |
There was a problem hiding this comment.
🟡 Medium components/ComposerPendingUserInputPanel.tsx:40
When a question has more options than the terminal has rows, the panel overflows the composer height and the later options plus the submit controls are clipped off-screen — including the option currently highlighted by optionIndex. The option list is rendered fully inside a flexShrink={0} column with no scroll window, and there is no length cap on the options array upstream, so a long list grows the panel beyond the available rows. Consider windowing the options around optionIndex (like a command palette) instead of mapping the entire array.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/tui/src/components/ComposerPendingUserInputPanel.tsx around line 40:
When a question has more options than the terminal has rows, the panel overflows the composer height and the later options plus the submit controls are clipped off-screen — including the option currently highlighted by `optionIndex`. The option list is rendered fully inside a `flexShrink={0}` column with no scroll window, and there is no length cap on the options array upstream, so a long list grows the panel beyond the available rows. Consider windowing the options around `optionIndex` (like a command palette) instead of mapping the entire array.
| if (hasOpenPr || isDefaultRef) { | ||
| return { | ||
| kind: "run_action", | ||
| label: "Push", | ||
| action: isDefaultRef ? "commit_push" : "push", | ||
| disabled: false, | ||
| }; | ||
| } | ||
| return { kind: "run_action", label: "Push & create PR", action: "create_pr", disabled: false }; | ||
| } |
There was a problem hiding this comment.
🟠 High src/gitActions.logic.ts:154
resolveGitQuickAction returns action: "commit_push" while labeling it "Push" on clean branches (no working-tree changes) that are ahead — both with an upstream (line 184) and without (line 158). gitActionNeedsCommitMessage("commit_push") is true, so the user is prompted for a commit message on a push-only operation, and the server runs a commit step with no changes to commit. These clean-ahead paths should return action: "push" instead.
if (hasOpenPr || isDefaultRef) {
return {
kind: "run_action",
label: "Push",
- action: isDefaultRef ? "commit_push" : "push",
+ action: "push",
disabled: false,
};
}
return { kind: "run_action", label: "Push & create PR", action: "create_pr", disabled: false };🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/tui/src/gitActions.logic.ts around lines 154-163:
`resolveGitQuickAction` returns `action: "commit_push"` while labeling it `"Push"` on clean branches (no working-tree changes) that are ahead — both with an upstream (line 184) and without (line 158). `gitActionNeedsCommitMessage("commit_push")` is `true`, so the user is prompted for a commit message on a push-only operation, and the server runs a commit step with no changes to commit. These clean-ahead paths should return `action: "push"` instead.
| React.useEffect(() => { | ||
| const onCapabilities = () => { |
There was a problem hiding this comment.
🟡 Medium hooks/useKittyGraphicsSupport.ts:12
useKittyGraphicsSupport can permanently report false even after Kitty support is detected. If the CAPABILITIES event fires between the initial render (where manager.isSupported is read as false) and the useEffect that subscribes to it, that event is missed: supported stays false and no subsequent capability event is guaranteed. Inline images then stay disabled for the entire session. Re-read manager.isSupported right after registering the listener inside the effect (or use useSyncExternalStore) so the state always reflects the current value.
React.useEffect(() => {
+ setSupported(manager.isSupported);
const onCapabilities = () => {
setSupported(manager.isSupported);
};🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/tui/src/hooks/useKittyGraphicsSupport.ts around lines 12-13:
`useKittyGraphicsSupport` can permanently report `false` even after Kitty support is detected. If the `CAPABILITIES` event fires between the initial render (where `manager.isSupported` is read as `false`) and the `useEffect` that subscribes to it, that event is missed: `supported` stays `false` and no subsequent capability event is guaranteed. Inline images then stay disabled for the entire session. Re-read `manager.isSupported` right after registering the listener inside the effect (or use `useSyncExternalStore`) so the state always reflects the current value.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 748d9c9. Configure here.
| const discovered = knownTerminals.get(detailIdForTabs) ?? []; | ||
| if (discovered.length === 0) return; | ||
| updateThreadTabs(detailIdForTabs, (tabs) => tabsWithDiscovered(tabs, discovered)); | ||
| }, [terminalOpen, detailIdForTabs, knownTerminals]); |
There was a problem hiding this comment.
Stale list resurrects closed tabs
Medium Severity
A race condition can cause closed terminals to reappear in the tab strip. If a terminal is removed via metadata subscription, a concurrently resolving listTerminalIds request (which only adds IDs) can reintroduce it into knownTerminals.
Reviewed by Cursor Bugbot for commit 748d9c9. Configure here.
| next.set(threadId, ids); | ||
| } | ||
| return next; | ||
| } |
There was a problem hiding this comment.
Snapshot wipes persisted terminals
Medium Severity
A metadata snapshot replaces all of knownTerminals with live sessions only, dropping ids previously learned from terminal.list. That list fetch runs only when the selected thread changes, so after a websocket resubscribe persisted-only terminals disappear from discovery until the user switches threads.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 748d9c9. Configure here.
| setTerminalOpen(false); | ||
| setTerminalFocused(false); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Stale tab count leaves drawer open
Medium Severity
closeTerminal decides whether to close the drawer from render-time detailTabs length, while tab removal uses a functional update. Rapid closes of the last two tabs can both see a length greater than one, clear every tab, and leave the terminal drawer open with an empty tab strip.
Reviewed by Cursor Bugbot for commit 748d9c9. Configure here.
| if (!detail || !approval) return; | ||
| void client.approve(detail.id, approval.requestId, "decline").catch(() => {}); | ||
| store.setStatus("Declined.", "success"); | ||
| }, |
There was a problem hiding this comment.
Approvals report success on failure
Medium Severity
onApprove and onDecline always set a success status immediately and swallow RPC errors with an empty .catch. When the approval request fails, the UI still claims the prompt was approved or declined even though the server request remains open.
Reviewed by Cursor Bugbot for commit 748d9c9. Configure here.
ApprovabilityVerdict: Needs human review 15 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR. You can customize Macroscope's approvability policy. Learn more. |


What Changed
Added a complete version of a tui. This meant to demonstrate the tui in it's more completed state before I will split it up into smaller pieces following the guidelines of the contribution documentation. It's hard to know the worth if it's in smaller pieces because it can just seem like a lost cause.
Why
I have a linux box running all the time and I do use the port forwarding capabilites but sometimes when there is a poor connection or when I need to do things quickly it's way faster going to a terminal tab. I am probably using the tui 30% of the time now sometimes fluxuating to 50%.
UI Changes
Checklist
Note
High Risk
Large new surface area touching authentication, WebSocket RPC, terminal lifecycle, and workspace file reads, though image base64 reads are gated and the change set includes substantial tests.
Overview
Adds a terminal UI for the already-running local T3 Code server so you can drive threads over SSH without port forwarding. The new
t3 tuisubcommand (Node) validates the persisted server, issues a short-lived bearer session, spawns a Bun subprocess running@t3tools/tui(OpenTUI + React), and mints WebSocket tickets over Node IPC. Release builds bundleapps/tuiintodist/tuiwith a check that blocks unresolved runtime imports.The TUI mirrors much of the web shell: responsive sidebar, multiline composer (images from clipboard/paths), command palette, approvals, terminal drawer, source-control panel, new-thread/project flows, and settlement actions—with extensive layout and interaction tests.
Server changes that support the TUI (and other clients):
terminal.listplus listing/closing persisted terminal tabs after restart;projects.readFileoptional base64 encoding limited to image paths and size caps; PTY spawn env setsCOLORTERM=truecolor; sharedrunningServerprobing for live server detection (also used from pairing).Reviewed by Cursor Bugbot for commit 748d9c9. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add a full terminal UI (TUI) for SSH access to the chat interface
apps/tuias a new Bun-based TUI application built on OpenTUI, providing a terminal-rendered chat interface with sidebar, conversation timeline, composer, command palette, git panel, and terminal drawer.opentui-imagepackage implementing Kitty graphics protocol support (image transmission, Unicode placeholders, clipboard paste, tmux passthrough) and a React<Image>component.tuiCLI command (apps/server/src/bin.ts), aterminal.listWebSocket RPC, andTerminalManager.listto enumerate live and persisted terminal IDs per thread.terminalLinks,chatMessages,resolveBranchSelectionTarget) from web-only modules into shared packages so the TUI can consume them without duplicating code.apps/tui/dist/index.jsintodist/tui/index.js, validates it for unresolved imports, and unpacks it from the Electron asar on macOS/Linux.📊 Macroscope summarized 748d9c9. 86 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.