Skip to content

feat(tui): collapse tool runs into Claude Code's brief line - #834

Merged
ericleepi314 merged 6 commits into
mainfrom
feat/tui-tool-brief
Aug 12, 2026
Merged

feat(tui): collapse tool runs into Claude Code's brief line#834
ericleepi314 merged 6 commits into
mainfrom
feat/tui-tool-brief

Conversation

@ericleepi314

@ericleepi314 ericleepi314 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

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

⏺ I'll read the three files first.

  Read 3 files, listed 1 directory, ran 1 shell command

⏺ DONE

Claude Code, ctrl+o

⏺ Read(/…/src/alpha.py)
  ⎿  Read 8 lines

⏺ Read(/…/src/beta.py)
  ⎿  Read 8 lines

clawcodex before

 ⏺ Read(src/alpha.py)
   ⎿  Read 7 lines
 ⏺ Read(src/beta.py)
   ⎿  Read 7 lines
 ⏺ Read(src/gamma.py)
   ⎿  Read 3 lines
 ⏺ Bash(ls src)
   ⎿  alpha.py
      beta.py
      gamma.py
 ⏺ Bash(echo hello)
   ⎿  hello
 ⏺ Bash(wc -l docs/numbers.txt)
   ⎿  12 docs/numbers.txt

clawcodex after (same prompt, same fixture)

   Read 3 files, listed 1 directory, ran 2 shell commands

clawcodex after, ctrl+o — every call back, blank-line separated as upstream
renders them:

 ⏺ Read(src/alpha.py)
   ⎿  Read 7 lines

 ⏺ Read(src/beta.py)
   ⎿  Read 7 lines

 ⏺ Bash(echo hello)
   ⎿  hello

While the turn runs, gerund + ellipsis, tally bold — byte-identical in
shape to upstream's Reading \e[1m1\e[22m file…:

 ⏺ Listing 1 directory, running 2 shell commands…

What the brief says

ui-tui/src/domain/toolBrief.ts owns 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 — ls reads as a listing,
grep as a search, cat as a read — which is what upstream does and what
keeps 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, a
pipeline) falls through to "shell command"; under-claiming reads fine, a
cat > file.py heredoc 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 the
captures agree: a lone Bash folds to Ran 1 shell command, but a lone
WebSearch keeps its row and its result, and so does an Agent:

⏺ Web Search("Rust 1.90 release date")
  ⎿  Did 1 search in 2s

⏺ Agent(Count .py files under src)
  ⎿  Done (2 tool uses · 48.0k tokens · 11s)

⏺ Write(src/delta.py)
  ⎿  Wrote 2 lines to src/delta.py

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 reads
brief / 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 cat of a missing file renders as
a plain Read 1 file with the error nowhere on screen. clawcodex runs against
models that do not reliably narrate a failure, so the failing call keeps its
block while the successes around it still fold:

   Read 1 file

 ⏺ Bash(cat missing.txt)
   ⎿  Error: cat: missing.txt: No such file or directory
      Command failed with exit code 1

   Ran 1 shell command

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 dim parent rewrites each
count's \e[22m into the parent's \e[2m — faint reopened, bold never
cleared. Every column after the first tally rendered bold: the opposite of
what the line is for. muted alone carries the quiet now. stripAnsi cannot
see 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 estimatedMsgHeight before Yoga measures
anything, 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 no
role 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 twelve
lines, 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 been
paying for by accident; wrapped the reasoning body three columns short of its
└─ rail; and missed the marginBottom on every prose row's details
wrapper, which predates this branch.

The fix that matters is the test: it renders thirteen message shapes at two
widths through both ToolTrail and MessageLine and asserts the estimate
equals 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 expanded collided in the height cache key, so pinning it
changed 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 quietly
collapsed the bare JSX whitespace to two — it is a string literal now, with a
test that can see the difference.

Testing

  • ui-tui suite: 1819 passing, 8 failures all pre-existing on origin/main.
  • 75 new tests in toolBrief.test.ts — classification, vocabulary, run
    splitting, render shape, byte-level styling, and estimate-vs-paint agreement
    across trail and prose rows at 60 and 100 columns.
  • Verified end to end against a live backend (deepseek-v4-pro) through the
    same pty harness: collapsed, ctrl+o, live, and failure cases all captured
    above.
  • ui-tui vitest is not wired into CI (the Node job covers ui-desktop), so
    the suite above was run locally against origin/main for the baseline.

🤖 Generated with Claude Code

ericleepi314 and others added 6 commits August 12, 2026 12:29
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
ericleepi314 merged commit acda316 into main Aug 12, 2026
3 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant