feat(tui): collapse tool runs into Claude Code's brief line - #834
Merged
Conversation
The transcript listed every tool call on its own `⏺ Tool(args)` row, so a
turn that read three files and ran three commands cost six rows plus their
results. The original Claude Code transcript folds a run of calls into one
dim summary line instead — "Read 3 files, listed 1 directory, ran 1 shell
command" — and puts the full list back under ctrl+o.
Ported that. domain/toolBrief.ts owns the vocabulary: which bucket a call
falls into (shell calls bucket on the *command*, so `ls` reads as a listing
and `grep` as a search), the clause order, and the two rules that carry the
look — only the first clause is capitalized, and a live run uses the gerund
with a trailing ellipsis ("Reading 3 files…").
Edits, delegations and questions stay on their own row: upstream only folds
tools that declare a search/read shape, and here their detail rows carry a
patch, the subagent tree anchor, and the user's own answers respectively —
things a tally would destroy. briefRuns() preserves order, so a trail that
interleaves them reads brief / row / brief.
Two more parity fixes fall out: expanded blocks are now blank-line separated
the way upstream renders them, and the ⎿ result gutter moved into a string
literal — it is three columns wide, and a formatter had quietly collapsed
the bare JSX whitespace to two.
Verified against Claude Code 2.1.228 driven through a pty: same clause text,
same ordering, same bold tallies, same gutter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of the brief-line port turned up one rendering defect and a cluster of height-estimate divergences. The brief painted bold from its first tally onward. Bold and faint share SGR close code 22, so wrapping bold counts in a `dim` parent rewrites each count's `\e[22m` into the parent's `\e[2m` — faint reopened, bold never cleared. `muted` alone carries the quiet now, and a test asserts on the bytes, which is the only place this is visible: stripAnsi cannot see it. The estimator was counting raw trail lines where the renderer counts painted blocks: it charged a blank-line gap for meta notes that render in the activity panel instead, never counted the `⏺` call row, wrapped the brief at the prose width (a trail has no role gutter, so it is three columns wider), and gave every trail the one-row text floor that only prose rows pay. A `trail` block's estimate could be double its paint. All four are fixed against a new test that renders five trail shapes in both modes and asserts the estimate equals the painted row count — the two implementations can no longer drift silently. Failures now break out of a run. Upstream tallies a failed call away like any other (a `cat` of a missing file still reads "Read 1 file"), but the error text lives only in that call's ⎿ row, so folding it put the one thing worth reading behind ctrl+o. The successes around it still fold. That also retires the red-bullet marker the previous commit used as a half-measure. Also: advisor / vision_analyze / ExitPlanMode join AskUserQuestion as standalone — their detail rows ARE the result; `/details tools expanded` selects the full rows again instead of silently meaning "brief"; blank lines now separate collapsed blocks the way they separate expanded ones; and the dead tool names (NotebookRead, MultiEdit, Update) and their untrue comment are gone, since this backend registers none of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second review pass. Removing the one-row text floor from `trail` blocks in
the last commit was right for tool-only trails but wrong everywhere else:
that floor had been silently paying for the `∴ Thinking…` header the
reasoning panel paints and the estimator never counted. Since turnController
emits a reasoning trail for every turn that thinks, that traded a doubling
on one shape for a −1 on the common one. The header is charged explicitly
now, at the rail-indented width the body actually wraps at.
Trail rows were also counted as lines rather than wrapped rows. An Edit's
detail is `Updated <full path> with N additions…` and buildToolTrailLine caps
it at twelve LINES, never at columns, so it wraps on any ordinary terminal —
est 2 / paint 4 at eighty columns. Call rows and detail rows now go through
wrappedLines at their real gutters (2 and 5).
The parity test could not have caught either: it rendered at the terminal
width while the estimator modelled the trail width, which is four narrower,
so it agreed only on shapes that never wrapped — and would have failed
against correct code on any shape that did. It renders at
transcriptTrailWidth() now, runs at 60 and 100 columns, and covers wrapping
briefs, wrapping details, and reasoning trails.
Measuring the prose path to settle whether the header row generalized turned
up a third gap, older than this branch: a prose row wraps its details in a
Box with marginBottom={1} that nothing counted, so every assistant message
with reasoning or tools was one row short. Fixed, and the parity suite now
renders MessageLine too, so prose chrome is covered by measurement instead of
by a hand-written constant. That is what moves virtualHeights' own
response-separator expectation from +3 to +5.
Last: `/details tools expanded` collided in the height cache key. The key
re-derived `detailsMode === 'expanded'` while the layout now also flips on an
explicit tools pin, so pinning changed the paint without changing the key and
served the brief's heights to the expanded layout. It keys on the same flag
the renderer reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three cases the parity suite could not distinguish before: reasoning long enough to wrap (pins the `└─ ` rail's 3-column offset), an assistant row with details but no text (details margin without the Response separator), and a system row carrying details (neither separator nor assistant handling). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captured three more tool shapes from Claude Code 2.1.228 and one of them
contradicted the classifier. A lone WebSearch does NOT fold — it keeps
`⏺ Web Search("…")` with `⎿ Did 1 search in 2s` under it, exactly as a lone
Agent keeps `⏺ Agent(…)` with `⎿ Done (2 tool uses · 48.0k tokens · 11s)`,
while a lone Bash still folds to "Ran 1 shell command". So the catch-all
bucket is standalone too, and "called N tools" is gone with it.
That is the rule upstream's message type has been stating all along by name
(`collapsed_read_search`): reading, searching, listing and shelling out are
what bury a transcript, and they are all that folds. Everything else keeps
its row and its result.
Two estimator fixes, both measured rather than argued. The reasoning body
sits under ` └─ ` — content column 5, not 3 — so it wraps at DETAIL_GUTTER
like every other rail-indented row; the old `- 3` missed by a row for any
body landing in the two-column band the wrong gutter opened. The parity
suite could not see it because both reasoning cases used bodies that never
wrapped, so there is now one sized to wrap at 91 and not at 93, built from a
single unbreakable token so Ink's word wrap and the estimator's character
wrap coincide and the gutter is the only variable.
And the structured-diff branch brings its own wrapper with ToolTrail as a
direct child, so charging it the prose details margin added a row it never
paints.
Documented, not fixed, in the same pass: wrappedLines is character math while
Ink word-wraps, so a detail carrying one token longer than the content width
— a deep file path — can paint one row more than we count. Post-mount
measurement corrects it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…honestly A lone WebSearch keeping its `⏺`/`⎿` block rested on one unit assertion about the bucket; it has a render case now, since that behaviour came from a capture and a fresh reading of upstream rather than from anything structural. The brief-wrap case stopped wrapping at cols=100 when `other` left the vocabulary — four clauses top out around 84 columns. Counts raised so it still wraps at 60, and the name says which arm actually exercises it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericleepi314
force-pushed
the
feat/tui-tool-brief
branch
from
August 12, 2026 19:31
16ad8f3 to
34cab78
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The transcript listed every tool call on its own row. A turn that read three
files and ran three commands cost six
⏺rows plus their results, and the"grouping" of same-type calls was just adjacency — nothing actually grouped.
Real Claude Code folds a run of calls into one muted summary line and puts
the full list back under ctrl+o. This ports that.
Ground truth
Claude Code 2.1.228 was installed and driven through a pty (
pty.fork+pyte) on a fixture project, and its rendering captured for each tool shape.Everything below is from those captures.
Claude Code, default view
Claude Code, ctrl+o
clawcodex before
clawcodex after (same prompt, same fixture)
clawcodex after, ctrl+o — every call back, blank-line separated as upstream
renders them:
While the turn runs, gerund + ellipsis, tally bold — byte-identical in
shape to upstream's
Reading \e[1m1\e[22m file…:What the brief says
ui-tui/src/domain/toolBrief.tsowns the vocabulary. Two rules carry the look:only the first clause is capitalized, and a live run uses the gerund with a
trailing ellipsis. Clause order and wording follow upstream:
searched for N patterns,read N files,listed N directories,ran N shell commands.Shell calls bucket on the command, not the tool —
lsreads as a listing,grepas a search,catas a read — which is what upstream does and whatkeeps a shell-driven session reading as exploration. The match is deliberately
a prefix on the bare command, so anything wrapped (
sudo ls,cd x && ls, apipeline) falls through to "shell command"; under-claiming reads fine, a
cat > file.pyheredoc counted as a read would not.What does not fold
Only the read/search band and shell commands. That is what upstream's message
type has been saying by name all along —
collapsed_read_search— and thecaptures agree: a lone
Bashfolds toRan 1 shell command, but a loneWebSearchkeeps its row and its result, and so does anAgent:So edits, delegations, answer-bearing tools (AskUserQuestion, advisor,
vision_analyze, ExitPlanMode) and the catch-all bucket all keep their own
block.
briefRuns()preserves order, so a trail that interleaves them readsbrief / row / brief. Reading, searching, listing and shelling out are what
bury a transcript; they are what folds.
Failures also break out — a deliberate divergence. Upstream tallies a
failed call away like any other; a captured
catof a missing file renders asa plain
Read 1 filewith the error nowhere on screen. clawcodex runs againstmodels that do not reliably narrate a failure, so the failing call keeps its
block while the successes around it still fold:
Defects found in review
Three rounds of adversarial review, each of which found something real.
The brief painted bold from its first tally onward. Bold and faint share
SGR close code 22, so wrapping bold counts in a
dimparent rewrites eachcount's
\e[22minto the parent's\e[2m— faint reopened, bold nevercleared. Every column after the first tally rendered bold: the opposite of
what the line is for.
mutedalone carries the quiet now.stripAnsicannotsee this class of bug, so the test asserts on the bytes.
The height estimator disagreed with the paint, five ways. The virtualized
transcript positions rows from
estimatedMsgHeightbefore Yoga measuresanything, so a wrong estimate shows up as scrollbar drift and blank gaps.
It counted raw trail lines where the renderer counts painted blocks; never
counted the
⏺call row; wrapped the brief at the prose width (a trail has norole gutter, so it is three columns wider); counted lines rather than wrapped
rows, so an Edit's
Updated <full path> with N additions…— capped at twelvelines, never at columns — was short by one or two on any normal terminal;
missed the
∴ Thinking…header row, which a one-row text floor had beenpaying for by accident; wrapped the reasoning body three columns short of its
└─rail; and missed themarginBottomon every prose row's detailswrapper, which predates this branch.
The fix that matters is the test: it renders thirteen message shapes at two
widths through both
ToolTrailandMessageLineand asserts the estimateequals the painted row count. The numbers in it are measured, not asserted
by hand — which is how the prose-row margin surfaced at all, and why
virtualHeights.test.ts's response-separator expectation moves from +3 to +5./details tools expandedcollided in the height cache key, so pinning itchanged the paint without changing the key and served the brief's heights to
the expanded layout.
Also fixed: the
⎿gutter is three columns wide and a formatter had quietlycollapsed the bare JSX whitespace to two — it is a string literal now, with a
test that can see the difference.
Testing
ui-tuisuite: 1819 passing, 8 failures all pre-existing onorigin/main.toolBrief.test.ts— classification, vocabulary, runsplitting, render shape, byte-level styling, and estimate-vs-paint agreement
across trail and prose rows at 60 and 100 columns.
same pty harness: collapsed, ctrl+o, live, and failure cases all captured
above.
ui-desktop), sothe suite above was run locally against
origin/mainfor the baseline.🤖 Generated with Claude Code