Skip to content

tui review - #5188

Open
olafura wants to merge 181 commits into
pingdotgg:mainfrom
olafura:olafura/tui-review
Open

tui review#5188
olafura wants to merge 181 commits into
pingdotgg:mainfrom
olafura:olafura/tui-review

Conversation

@olafura

@olafura olafura commented Aug 1, 2026

Copy link
Copy Markdown

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

Screenshot 2026-08-01 at 12 43 36 Screenshot 2026-08-01 at 12 43 02 Screenshot 2026-08-01 at 12 42 53 Screenshot 2026-08-01 at 12 41 03 Screenshot 2026-08-01 at 12 40 56 Screenshot 2026-08-01 at 12 40 49 Screenshot 2026-08-01 at 12 40 09

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

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 tui subcommand (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 bundle apps/tui into dist/tui with 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.list plus listing/closing persisted terminal tabs after restart; projects.readFile optional base64 encoding limited to image paths and size caps; PTY spawn env sets COLORTERM=truecolor; shared runningServer probing 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

  • Introduces apps/tui as 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.
  • Adds a new opentui-image package implementing Kitty graphics protocol support (image transmission, Unicode placeholders, clipboard paste, tmux passthrough) and a React <Image> component.
  • Extends the server with a tui CLI command (apps/server/src/bin.ts), a terminal.list WebSocket RPC, and TerminalManager.list to enumerate live and persisted terminal IDs per thread.
  • Moves shared logic (terminalLinks, chatMessages, resolveBranchSelectionTarget) from web-only modules into shared packages so the TUI can consume them without duplicating code.
  • Adds build pipeline changes: the server build now stages apps/tui/dist/index.js into dist/tui/index.js, validates it for unresolved imports, and unpacks it from the Electron asar on macOS/Linux.
  • Risk: the TUI spawns a Bun subprocess via Node IPC; if Bun is not installed, users see a hint but the TUI is unavailable.
📊 Macroscope summarized 748d9c9. 86 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

olafura and others added 30 commits June 19, 2026 19:32
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>
olafura and others added 23 commits July 30, 2026 13:46
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>
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3b19391e-b78f-4ff3-aef4-8deaa7a73f26

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@olafura olafura changed the title Olafura/tui review tui review Aug 1, 2026
@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 1, 2026

// Expose the viewport text for ^O copy while this is the visible terminal.
React.useEffect(() => {
if (!visible) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread apps/tui/src/fileTree.ts
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread apps/tui/src/models.ts
Comment on lines +32 to +33
for (const provider of providers) {
const providerLabel = provider.displayName ?? provider.driver ?? provider.instanceId;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment on lines +90 to +98
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Suggested change
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment on lines +154 to +163
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 };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment on lines +12 to +13
React.useEffect(() => {
const onCapabilities = () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Fix All in Cursor

❌ 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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 748d9c9. Configure here.

next.set(threadId, ids);
}
return next;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 748d9c9. Configure here.

setTerminalOpen(false);
setTerminalFocused(false);
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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");
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 748d9c9. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant