diff --git a/ui-tui/src/__tests__/expandResults.test.ts b/ui-tui/src/__tests__/expandResults.test.ts index 256130b64..8d1fc43cb 100644 --- a/ui-tui/src/__tests__/expandResults.test.ts +++ b/ui-tui/src/__tests__/expandResults.test.ts @@ -233,20 +233,22 @@ describe('expanded rendering', () => { const trail = ['Bash(seq 6) :: 1\n2\n3\n… +3 lines (ctrl+o to expand) ✓'] const verboseTrail = ['Bash(seq 6) (0.2s) :: Result:\n1\n2\n3\n4\n5\n6 ✓'] - it('collapsed shows the summary; expanded swaps in the full result', () => { + it('collapsed folds the call into the brief; expanded swaps in the full result', () => { const collapsed = stripAnsi( renderToString( React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail, verboseTrail }) ) ) - expect(collapsed).toContain('+3 lines (ctrl+o to expand)') - expect(collapsed).not.toContain('4') // verbose-only content stays hidden + // Collapsed is the brief line: the tally only — no call row, no result + // summary, and none of the verbose-only content. + expect(collapsed).toContain('Ran 1 shell command') + expect(collapsed).not.toContain('Bash(seq 6)') + expect(collapsed).not.toContain('+3 lines (ctrl+o to expand)') + expect(collapsed).not.toContain('4') const expanded = stripAnsi( - renderToString( - React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail, verboseTrail }) - ) + renderToString(React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail, verboseTrail })) ) expect(expanded).toContain('Result:') diff --git a/ui-tui/src/__tests__/toolBrief.test.ts b/ui-tui/src/__tests__/toolBrief.test.ts new file mode 100644 index 000000000..523efd289 --- /dev/null +++ b/ui-tui/src/__tests__/toolBrief.test.ts @@ -0,0 +1,532 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' + +import { renderSync } from '@clawcodex/ink' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('node:child_process', () => ({ spawn: () => new EventEmitter() })) + +vi.hoisted(() => { + process.env.FORCE_COLOR = '3' + process.env.COLORTERM = 'truecolor' + delete process.env.NO_COLOR +}) + +import { MessageLine } from '../components/messageLine.js' +import { ToolTrail } from '../components/thinking.js' +import { + briefCallOfTrailLine, + briefClauses, + type BriefCounts, + briefRuns, + briefText, + classifyBriefTool, + emptyBriefCounts, + isCollapsibleBucket +} from '../domain/toolBrief.js' +import { transcriptTrailWidth } from '../lib/inputMetrics.js' +import { buildToolTrailLine, stripAnsi } from '../lib/text.js' +import { estimatedMsgHeight } from '../lib/virtualHeights.js' +import { DEFAULT_THEME } from '../theme.js' +import type { Msg } from '../types.js' + +// ── classification ────────────────────────────────────────────────────────── + +describe('classifyBriefTool', () => { + it('buckets the file/search tools', () => { + expect(classifyBriefTool('Read(src/a.py)')).toBe('read') + expect(classifyBriefTool('Grep(TODO)')).toBe('search') + expect(classifyBriefTool('Glob(src/*.py)')).toBe('search') + }) + + it('buckets a shell call on its command, the way upstream does', () => { + expect(classifyBriefTool('Bash(ls src)')).toBe('list') + expect(classifyBriefTool('Bash(grep -rn def src)')).toBe('search') + expect(classifyBriefTool('Bash(cat docs/readme.md)')).toBe('read') + expect(classifyBriefTool('Bash(echo hello)')).toBe('bash') + // A command that merely *starts* with those letters is still a command. + expect(classifyBriefTool('Bash(lsof -i)')).toBe('bash') + expect(classifyBriefTool('Bash(catalog --build)')).toBe('bash') + }) + + it('keeps edits, delegations and answer-bearing tools out of the tally', () => { + expect(classifyBriefTool('Edit(a.py)')).toBe('edit') + expect(classifyBriefTool('Write(b.py)')).toBe('edit') + expect(classifyBriefTool('Delegate Task(audit)')).toBe('agent') + expect(classifyBriefTool('AskUserQuestion(pick one)')).toBe('answer') + // Labels, not wire names: toolTrailLabel() title-cases and de-snakes. + expect(classifyBriefTool('Advisor(is this sound?)')).toBe('answer') + expect(classifyBriefTool('Vision Analyze(shot.png)')).toBe('answer') + expect(classifyBriefTool('ExitPlanMode(plan)')).toBe('answer') + }) + + it('falls back to the catch-all bucket, which also stands alone', () => { + expect(classifyBriefTool('WebSearch(rust release)')).toBe('other') + expect(classifyBriefTool('Mcp Github List Prs(open)')).toBe('other') + expect(isCollapsibleBucket('other')).toBe(false) + }) + + it('ignores a legacy duration suffix on resumed trail lines', () => { + expect(classifyBriefTool('Read(a.py) (1.2s)')).toBe('read') + }) +}) + +// ── vocabulary ────────────────────────────────────────────────────────────── + +describe('briefText', () => { + const counts = (over: Partial): BriefCounts => ({ ...emptyBriefCounts(), ...over }) + + it('capitalizes only the first clause and joins the rest with commas', () => { + expect(briefText(counts({ bash: 1, list: 1, read: 3 }))).toBe( + 'Read 3 files, listed 1 directory, ran 1 shell command' + ) + }) + + it('orders clauses search → read → list → shell', () => { + expect(briefText(counts({ bash: 1, list: 1, read: 1, search: 1 }))).toBe( + 'Searched for 1 pattern, read 1 file, listed 1 directory, ran 1 shell command' + ) + }) + + it('uses the gerund and an ellipsis while the run is live', () => { + expect(briefText(counts({ read: 1 }), true)).toBe('Reading 1 file…') + expect(briefText(counts({ search: 2 }), true)).toBe('Searching for 2 patterns…') + }) + + it('pluralizes each noun independently', () => { + expect(briefText(counts({ list: 2 }))).toBe('Listed 2 directories') + expect(briefText(counts({ read: 1 }))).toBe('Read 1 file') + }) + + it('is empty when nothing collapsible ran', () => { + expect(briefText(counts({ edit: 3 }))).toBe('') + expect(briefClauses(counts({ agent: 1 }))).toEqual([]) + // WebSearch and friends keep their own row, so they never tally either. + expect(briefText(counts({ other: 2 }))).toBe('') + }) +}) + +// ── runs ──────────────────────────────────────────────────────────────────── + +describe('briefRuns', () => { + const id = (s: string) => s + + it('folds a consecutive stretch into one brief run', () => { + const runs = briefRuns(['Read(a)', 'Read(b)', 'Bash(echo hi)'], id) + + expect(runs).toHaveLength(1) + expect(runs[0]!.kind).toBe('brief') + expect(runs[0]!.items).toHaveLength(3) + }) + + it('breaks the run at a standalone call and keeps source order', () => { + const runs = briefRuns(['Read(a)', 'Edit(b)', 'Bash(echo hi)'], id) + + expect(runs.map(r => r.kind)).toEqual(['brief', 'flat', 'brief']) + expect(runs[1]!.items).toEqual(['Edit(b)']) + }) + + it('never merges two standalone calls into one block', () => { + const runs = briefRuns(['Edit(a)', 'Edit(b)'], id) + + expect(runs.map(r => r.kind)).toEqual(['flat', 'flat']) + }) + + it('breaks a failing call out so its message survives the fold', () => { + const runs = briefRuns(['Read(a)', 'Bash(boom)', 'Read(c)'], id, item => item === 'Bash(boom)') + + expect(runs.map(r => r.kind)).toEqual(['brief', 'flat', 'brief']) + expect(runs[1]!.items).toEqual(['Bash(boom)']) + }) +}) + +describe('briefCallOfTrailLine', () => { + it('recovers the call from a completed trail line', () => { + expect(briefCallOfTrailLine(buildToolTrailLine('Read', 'src/a.py', false, 'Read 8 lines'))).toBe('Read(src/a.py)') + }) + + it('recovers the label from a drafting line', () => { + expect(briefCallOfTrailLine('drafting Write…')).toBe('Write') + }) +}) + +// ── render ────────────────────────────────────────────────────────────────── + +// Ink brackets every frame in a synchronized-update pair (BSU … ESU). Into a +// PassThrough — which is not a TTY, so nothing gets overwritten — it flushes +// the same frame more than once and they concatenate, which turns any row +// count into 2n-1. Keep the last frame that painted something. +const BSU = '[?2026h' +const ESU = '[?2026l' + +const lastFrame = (output: string): string => { + const frames = output + .split(BSU) + .map(chunk => chunk.split(ESU)[0] ?? '') + .filter(frame => stripAnsi(frame).trim() !== '') + + return frames.at(-1) ?? '' +} + +const renderToString = (element: React.ReactElement, columns = 100): string => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns, isTTY: false, rows: 40 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', (chunk: Buffer) => { + output += chunk.toString() + }) + + const instance = renderSync(element, { + patchConsole: false, + stderr: stderr as never, + stdin: stdin as never, + stdout: stdout as never + }) + + instance.unmount() + instance.cleanup() + + return lastFrame(output) +} + +describe('ToolTrail brief render', () => { + const trail = [ + buildToolTrailLine('Read', 'src/alpha.py', false, 'Read 8 lines'), + buildToolTrailLine('Read', 'src/beta.py', false, 'Read 8 lines'), + buildToolTrailLine('Bash', 'ls src', false, 'alpha.py\nbeta.py'), + buildToolTrailLine('Bash', 'echo hello', false, 'hello') + ] + + it('collapses the whole run to one summary line', () => { + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail })) + ) + + expect(out).toContain('Read 2 files, listed 1 directory, ran 1 shell command') + expect(out).not.toContain('Read(src/alpha.py)') + expect(out).not.toContain('hello') + }) + + it('puts every call back under ctrl+o', () => { + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail })) + ) + + expect(out).toContain('Read(src/alpha.py)') + expect(out).toContain('Read(src/beta.py)') + expect(out).toContain('Bash(echo hello)') + expect(out).not.toContain('Read 2 files,') + }) + + it('keeps a standalone edit visible while collapsed', () => { + const withEdit = [trail[0]!, buildToolTrailLine('Edit', 'src/gamma.py', false, 'Updated src/gamma.py')] + + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: withEdit })) + ) + + expect(out).toContain('Read 1 file') + expect(out).toContain('Edit(src/gamma.py)') + }) + + it('separates expanded blocks with a blank line', () => { + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail })) + ) + + const rows = out.split('\n') + const first = rows.findIndex(r => r.includes('Read(src/alpha.py)')) + const second = rows.findIndex(r => r.includes('Read(src/beta.py)')) + + // ⏺ call / ⎿ result / blank / ⏺ next call + expect(second - first).toBe(3) + expect(rows[second - 1]!.trim()).toBe('') + }) + + // The ⎿ result gutter is three columns wide (" ⎿ x"). It lives in a + // string literal because a formatter collapses bare JSX whitespace, and the + // looser /⎿\s+/ assertions elsewhere cannot see the difference. + it('keeps the result gutter three columns wide', () => { + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail })) + ) + + expect(out.split('\n').find(row => row.includes('⎿'))).toMatch(/^ {2}⎿ {2}\S/) + }) + + it('renders the brief under a two-column gutter, like the ⏺ rows', () => { + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail })) + ) + + const row = out.split('\n').find(line => line.includes('Read 2 files')) + + expect(row).toMatch(/^ {2}Read 2 files/) + }) + + // WebSearch and friends are NOT part of the read/search band upstream folds: + // captured from Claude Code 2.1.228, a lone Web Search keeps its row and its + // `⎿ Did 1 search in 2s`. A tally would delete the only interesting part. + it('keeps a catch-all tool standalone with its result', () => { + const withSearch = [trail[0]!, buildToolTrailLine('WebSearch', 'rust 1.90', false, 'Did 1 search in 2s')] + + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: withSearch })) + ) + + expect(out).toContain('WebSearch(rust 1.90)') + expect(out).toContain('Did 1 search in 2s') + expect(out).toContain('Read 1 file') + expect(out).not.toContain('called 1 tool') + }) + + it('breaks a failed call out of the brief so its error stays readable', () => { + const withError = [ + trail[0]!, + buildToolTrailLine('Bash', 'cat missing.txt', true, 'Error: No such file or directory'), + trail[3]! + ] + + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: withError })) + ) + + expect(out).toContain('Bash(cat missing.txt)') + expect(out).toContain('Error: No such file or directory') + // The successes around it still fold. + expect(out).toContain('Read 1 file') + expect(out).toContain('Ran 1 shell command') + }) + + // Bold and faint share SGR close code 22, so a `dim` wrapper rewrites each + // bold tally's reset into `\e[2m` — faint re-opened, bold never cleared — + // and every column after the first tally paints bold. stripAnsi cannot see + // this, so assert on the bytes. + it('closes each bold tally without leaving the rest of the line bold', () => { + const out = renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail })) + const BOLD = '\u001b[1m' + const RESET_INTENSITY = '\u001b[22m' + const FAINT = '\u001b[2m' + + expect(out).toContain(`${BOLD}2${RESET_INTENSITY}`) + // No faint is opened anywhere, so no tally's reset can be rewritten into + // one — bold cannot survive past the tally that opened it. + expect(out).not.toContain(FAINT) + }) +}) + +// ── estimate vs paint ─────────────────────────────────────────────────────── + +// The virtualized transcript positions rows from estimatedMsgHeight before +// Yoga has measured anything, so an estimate that disagrees with the paint +// shows up as scrollbar drift and blank gaps. Assert the two agree on real +// trails rather than trusting the two implementations to stay in step. +const READ_LINE = buildToolTrailLine('Read', 'a.py', false, 'Read 8 lines') + +describe('estimatedMsgHeight matches the painted trail', () => { + // A `kind: 'trail'` block gets the transcript interior with no role gutter, + // which is narrower than the terminal. Render at that same width — pulled + // from the helper the estimator uses — or the comparison is against a paint + // the app never produces, and every wrapping case reads backwards. + const paintedRows = (msg: Msg, detailsMode: 'collapsed' | 'expanded', cols: number) => { + const rows = stripAnsi( + renderToString( + React.createElement(ToolTrail, { + detailsMode, + reasoning: msg.thinking ?? '', + t: DEFAULT_THEME, + trail: msg.tools ?? [], + verboseTrail: msg.toolsVerbose ?? [] + }), + transcriptTrailWidth(cols) + ) + ).split('\n') + + while (rows.length && rows[rows.length - 1]!.trim() === '') { + rows.pop() + } + + return rows.length + } + + const trailMsg = (tools: string[], toolsVerbose?: string[]): Msg => ({ + kind: 'trail', + role: 'system', + text: '', + tools, + ...(toolsVerbose ? { toolsVerbose } : {}) + }) + + const cases: [string, Msg][] = [ + ['a plain run', trailMsg([buildToolTrailLine('Read', 'a.py', false, 'Read 8 lines')])], + [ + 'a mixed run', + trailMsg([ + buildToolTrailLine('Read', 'a.py', false, 'Read 8 lines'), + buildToolTrailLine('Bash', 'ls src', false, 'a.py\nb.py'), + buildToolTrailLine('Bash', 'echo hi', false, 'hi') + ]) + ], + [ + 'a run split by a standalone edit', + trailMsg([ + buildToolTrailLine('Read', 'a.py', false, 'Read 8 lines'), + buildToolTrailLine('Edit', 'b.py', false, 'Updated b.py'), + buildToolTrailLine('Bash', 'echo hi', false, 'hi') + ]) + ], + [ + 'a run split by a failure', + trailMsg([ + buildToolTrailLine('Read', 'a.py', false, 'Read 8 lines'), + buildToolTrailLine('Bash', 'cat nope', true, 'Error: No such file'), + buildToolTrailLine('Bash', 'echo hi', false, 'hi') + ]) + ], + [ + 'a call with no result row', + trailMsg([ + buildToolTrailLine('Bash', 'true', false, ''), + buildToolTrailLine('Read', 'a.py', false, 'Read 1 line') + ]) + ], + [ + // Edit details carry a full path and are capped in LINES, never columns, + // so this is the ordinary case on a normal-width terminal, not a corner. + 'a detail long enough to wrap', + trailMsg([ + buildToolTrailLine( + 'Edit', + 'src/components/transcript/toolTrail.tsx', + false, + 'Updated src/components/transcript/toolTrail.tsx with 3 additions and 1 removal' + ) + ]) + ], + [ + // Four clauses with multi-digit tallies is the widest a brief can get + // now that `other` stands alone: ~84 columns, so it wraps at cols=60 and + // fits at cols=100. The cols=60 arm is the one exercising brief wrap. + 'a brief long enough to wrap at 60 columns', + trailMsg([ + ...Array.from({ length: 12 }, (_, i) => buildToolTrailLine('Grep', `p${i}`, false, 'Found 2 lines')), + ...Array.from({ length: 13 }, (_, i) => buildToolTrailLine('Read', `f${i}.py`, false, 'Read 8 lines')), + ...Array.from({ length: 24 }, (_, i) => buildToolTrailLine('Bash', `ls d${i}`, false, 'a.py')), + ...Array.from({ length: 18 }, (_, i) => buildToolTrailLine('Bash', `echo ${i}`, false, 'hi')) + ]) + ], + ['a reasoning trail', { kind: 'trail', role: 'system', text: '', thinking: 'I should check the parser first.' }], + [ + 'reasoning plus tools', + { + kind: 'trail', + role: 'system', + text: '', + thinking: 'First line.\nSecond line.\nThird line.', + tools: [buildToolTrailLine('Read', 'a.py', false, 'Read 8 lines')] + } + ], + [ + 'reasoning long enough to wrap', + { + kind: 'trail', + role: 'system', + text: '', + thinking: + 'The parser reads the header first, then walks each block until it hits a boundary, ' + + 'and only then does it decide whether the trailing bytes belong to the previous frame.' + } + ], + [ + // The reasoning body sits under a ` └─ ` rail — content column 5, not + // 3. One unbreakable token so Ink's word wrap and the estimator's + // character wrap coincide and the gutter is the only variable, sized to + // land between the two candidate widths at cols=100: it wraps at 91 and + // does not at 93. A body that merely "is long" cannot tell them apart. + 'reasoning that wraps only at the rail column', + { kind: 'trail', role: 'system', text: '', thinking: 'x'.repeat(92) } + ] + ] + + // Prose rows carry the trail through MessageLine, which adds chrome the + // estimator has to model too: the details wrapper's margin, the `└─ + // Response` separator, and the reasoning panel's own header row. + describe('through MessageLine', () => { + const proseRows = (msg: Msg, cols: number) => { + const rows = stripAnsi( + renderToString(React.createElement(MessageLine, { cols, msg, t: DEFAULT_THEME }), cols) + ).split('\n') + + while (rows.length && rows[rows.length - 1]!.trim() === '') { + rows.pop() + } + + return rows.length + } + + const proseCases: [string, Msg][] = [ + ['bare assistant prose', { role: 'assistant', text: 'ok' }], + ['prose with reasoning', { role: 'assistant', text: 'ok', thinking: 'plan' }], + ['prose with a tool brief', { role: 'assistant', text: 'ok', tools: [READ_LINE] }], + ['prose with both', { role: 'assistant', text: 'ok', thinking: 'plan', tools: [READ_LINE] }], + // No text means no `└─ Response` separator, but the details wrapper's + // margin is still paid — the two must not be conflated. + ['details with no prose', { role: 'assistant', text: '', tools: [READ_LINE] }], + // Details on a non-assistant row: no separator either, same margin. + ['a system row carrying details', { role: 'system', text: 'note', tools: [READ_LINE] }] + ] + + for (const [name, msg] of proseCases) { + it(`agrees on ${name}`, () => { + expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe(proseRows(msg, 80)) + }) + } + + // The structured-diff branch brings its own wrapper with ToolTrail as a + // direct child, so it must NOT be charged the details wrapper's margin. + // (Its absolute estimate is off for older reasons — it still counts + // msg.text for a markdown fallback the structured path never renders — so + // pin the delta rather than the number.) + it('does not charge the details margin to a structured diff', () => { + const diff: Msg = { + diffData: { filePath: 'a.py', hunks: [], kind: 'update' }, + kind: 'diff', + role: 'assistant', + text: 'patch', + tools: [READ_LINE] + } + + const withDetails = estimatedMsgHeight(diff, 80, { compact: false, details: true }) + const withoutDetails = estimatedMsgHeight(diff, 80, { compact: false, details: false }) + + // Turning details on costs the one brief row, plus the 2 the estimator + // charges for a `Response` separator this branch never paints (older + // divergence, left alone). The point is that it is 3 and not 4 — the + // details wrapper's marginBottom belongs to prose rows only. + expect(withDetails - withoutDetails).toBe(3) + }) + }) + + // Narrow widths are where wrapping bites; 100 is the everyday case. + for (const cols of [60, 100]) { + for (const [name, msg] of cases) { + it(`agrees on ${name} (collapsed, cols=${cols})`, () => { + expect(estimatedMsgHeight(msg, cols, { compact: false, details: true })).toBe( + paintedRows(msg, 'collapsed', cols) + ) + }) + + it(`agrees on ${name} (expanded, cols=${cols})`, () => { + expect(estimatedMsgHeight(msg, cols, { compact: false, details: true, toolsExpanded: true })).toBe( + paintedRows(msg, 'expanded', cols) + ) + }) + } + } +}) diff --git a/ui-tui/src/__tests__/toolTranscript.test.ts b/ui-tui/src/__tests__/toolTranscript.test.ts index b03adc1b3..e1192a7f0 100644 --- a/ui-tui/src/__tests__/toolTranscript.test.ts +++ b/ui-tui/src/__tests__/toolTranscript.test.ts @@ -169,25 +169,34 @@ describe('formatToolResult', () => { // ── virtualHeights: multi-line tool entries count rendered rows ────────────── describe('estimatedMsgHeight with multi-line tool details', () => { - it('counts one row per rendered detail line, not per entry', () => { - const base = { - kind: 'trail' as const, - role: 'system' as const, - text: '', - tools: [buildToolTrailLine('Bash', 'ls', false, 'a\nb\nc')] - } - - const single = { - ...base, - tools: [buildToolTrailLine('Bash', 'ls', false, 'a')] - } - - const opts = { compact: false, details: true, leadGap: false } - const tall = estimatedMsgHeight(base, 80, opts) - const short = estimatedMsgHeight(single, 80, opts) + const base = { + kind: 'trail' as const, + role: 'system' as const, + text: '', + tools: [buildToolTrailLine('Bash', 'seq 3', false, 'a\nb\nc')] + } + + const single = { + ...base, + tools: [buildToolTrailLine('Bash', 'seq 1', false, 'a')] + } + + const opts = { compact: false, details: true, leadGap: false } + + it('counts one row per rendered detail line, not per entry, when expanded', () => { + const expanded = { ...opts, toolsExpanded: true } + const tall = estimatedMsgHeight(base, 80, expanded) + const short = estimatedMsgHeight(single, 80, expanded) expect(tall - short).toBe(2) // two extra detail rows }) + + it('collapses to the one-row brief regardless of detail length', () => { + // The brief renders the tally alone, so a 3-line result costs no more + // rows than a 1-line one — the estimate has to agree or the scrollbar + // and topSpacer math drift against the paint. + expect(estimatedMsgHeight(base, 80, opts)).toBe(estimatedMsgHeight(single, 80, opts)) + }) }) // ── ToolTrail render: CC anatomy ───────────────────────────────────────────── diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index 011206608..d6ae23096 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -43,8 +43,12 @@ describe('virtual height estimates', () => { it('accounts for the response separator when assistant details are visible', () => { const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'plan' } + // Measured against the real paint (see toolBrief.test.ts's parity suite): + // `∴ Thinking…` header, the reasoning body, the details wrapper's + // marginBottom, the `└─ Response` row, and its marginBottom — 5 rows above + // the bare `⏺ ok`. The header and the wrapper margin used to be missing. expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe( - estimatedMsgHeight(msg, 80, { compact: false, details: false }) + 3 + estimatedMsgHeight(msg, 80, { compact: false, details: false }) + 5 ) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 9e1b8a6f8..5ff2dbb6b 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -10,6 +10,7 @@ import { import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { QuestionAnswers } from '../components/questionPrompt.js' import { STARTUP_RESUME_ID, TRANSCRIPT_COLOR } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' import { hasLeadGap, prevRenderedMsg, showsInterTurnSeparator } from '../domain/blockLayout.js' @@ -26,7 +27,6 @@ import type { SessionCloseResponse, TerminalResizeResponse } from '../gatewayTypes.js' -import type { QuestionAnswers } from '../components/questionPrompt.js' import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { composerPromptWidth } from '../lib/inputMetrics.js' @@ -340,20 +340,27 @@ export function useMainApp(gw: GatewayClient) { [cols, historyItems, messageId] ) + // Mirrors ToolTrail's `toolsExpanded` exactly (ctrl+o, or an explicit + // `/details tools expanded` pin) — it picks the flat per-call rows over the + // collapsed brief, so the estimate and the paint must derive it the same way. + const toolsDetailsExpanded = ui.detailsMode === 'expanded' || ui.sections?.tools === 'expanded' + const detailsLayoutKey = useMemo(() => { const thinking = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) const tools = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) - // The global expanded toggle (ctrl+o) swaps which tool variant renders, - // so it must bucket the height cache too — section modes alone default - // to 'expanded' and would serve stale collapsed heights across toggles. - return `${thinking}:${tools}:${ui.detailsMode === 'expanded' ? 'x' : '-'}` - }, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections]) + // The expanded toggle swaps which tool variant renders (flat per-call rows + // vs the collapsed brief), so it must bucket the height cache too. Keyed on + // `toolsDetailsExpanded` itself, not on `detailsMode` a second time: a + // `/details tools expanded` pin flips the layout without moving the global + // mode, and the resolved section mode already reads 'expanded' by default — + // so re-deriving it here would hand the pinned layout the brief's heights. + return `${thinking}:${tools}:${toolsDetailsExpanded ? 'x' : '-'}` + }, [toolsDetailsExpanded, ui.detailsMode, ui.detailsModeCommandOverride, ui.sections]) const [thinkingDetailsMode, toolsDetailsMode] = detailsLayoutKey.split(':') const thinkingDetailsVisible = thinkingDetailsMode !== 'hidden' const toolsDetailsVisible = toolsDetailsMode !== 'hidden' - const toolsDetailsExpanded = ui.detailsMode === 'expanded' const detailsVisible = thinkingDetailsVisible || toolsDetailsVisible const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt) const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}` diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index f4d4667eb..7c1f03490 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -4,6 +4,7 @@ import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' import { sectionMode } from '../domain/details.js' +import { briefClauses, type BriefCounts, briefRuns, countBriefTools } from '../domain/toolBrief.js' import { buildSubagentTree, fmtTokens, @@ -264,6 +265,58 @@ function Chevron({ ) } +/** + * The collapsed tool row — the original's brief line. A run of tool calls + * reads as one quiet sentence ("Read 3 files, listed 1 directory") with the + * tallies bold, under a 2-column gutter so it lines up with the `⏺ ` bullets + * the expanded view (ctrl+o) puts back. A blinking bullet fills the gutter + * while the run is still executing (the original animates a dot there). + * + * The quiet comes from `muted`, NOT from `dim`: bold and faint share SGR + * close code 22, so wrapping bold tallies in a dim parent rewrites each + * tally's `\e[22m` into the parent's `\e[2m` — which re-opens faint without + * ever clearing bold, and every column after the first tally paints bold. + * (The ink fork's own Text props encode this as `dim?: never` alongside + * `bold`; nesting the two in separate elements evades the type, not the + * terminal.) Keep them apart. + */ +function BriefLine({ + blinkOn, + counts, + gap, + live, + t +}: { + blinkOn: boolean + counts: BriefCounts + gap: boolean + live: boolean + t: Theme +}) { + const clauses = briefClauses(counts, live) + + if (!clauses.length) { + return null + } + + return ( + + + {live ? {blinkOn ? '⏺ ' : ' '} : {' '}} + + + {clauses.map((clause, index) => ( + + {index > 0 ? ', ' : ''} + {clause.verb} {clause.count} {clause.noun} + + ))} + {live ? '…' : ''} + + + ) +} + function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined { const palette = [theme.color.border, theme.color.accent, theme.color.primary, theme.color.warn, theme.color.error] const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length) @@ -789,10 +842,15 @@ export const ToolTrail = memo(function ToolTrail({ const meta: DetailRow[] = [] const pushDetail = (row: DetailRow) => (groups.at(-1)?.details ?? meta).push(row) - // Verbose swap keys on the GLOBAL details mode (ctrl+o / /details) — the - // tools *section* mode defaults to 'expanded' merely to make the flat - // trail visible, and must not force verbose output. - const toolsExpanded = detailsMode === 'expanded' + // "Show every call, with whatever raw output the gateway kept" — the + // opposite of the collapsed brief. Driven by ctrl+o (the global details + // mode) or by an EXPLICIT `/details tools expanded` pin. Reading the raw + // `sections` override, not the resolved mode, matters: the tools section + // defaults to 'expanded' just to keep the trail visible, and treating that + // default as a pin would disable the brief for everyone. + // useMainApp derives the height estimator's flag the same way — one signal + // for both, or the estimate drifts from the paint. + const toolsExpanded = detailsMode === 'expanded' || sections?.tools === 'expanded' for (const [i, compactLine] of trail.entries()) { // Expanded details render the verbose sibling (full Args/Result blocks) @@ -1060,63 +1118,105 @@ export const ToolTrail = memo(function ToolTrail({ // alternates glyph/space (original useBlink is 600ms — same read). const blinkOn = Math.floor(now / 500) % 2 === 0 - const toolsFlat = - hasTools && visible.tools !== 'hidden' ? ( - - {groups.map(group => { - const isDelegateGroup = group.label.startsWith('Delegate Task') - const bulletColor = group.error ? t.color.error : t.color.ok + // One full `⏺ Tool(args)` + `⎿ result` block. This is every row in the + // expanded (ctrl+o) view, and the shape STANDALONE tools — edits, + // delegations, questions — keep even while collapsed. `gap` opens the blank + // line upstream leaves between consecutive blocks. + const renderGroup = (group: Group, gap: boolean) => { + const isDelegateGroup = group.label.startsWith('Delegate Task') + const bulletColor = group.error ? t.color.error : t.color.ok - return ( - - - {group.live ? ( - // Running: dim blinking ⏺ — the off-frame renders two - // spaces matching the glyph+space width so the row never - // reflows mid-blink. - {blinkOn ? '⏺ ' : ' '} + return ( + + + {group.live ? ( + // Running: dim blinking ⏺ — the off-frame renders two + // spaces matching the glyph+space width so the row never + // reflows mid-blink. + {blinkOn ? '⏺ ' : ' '} + ) : ( + + )} + {toolLabel(group)} + {isDelegateGroup ? ( + + {' (/agents to monitor)'} + + ) : null} + + {group.details.map(detail => { + // Multi-line details (Bash 3-line summaries, error caps): + // first row carries the ⎿ connector, continuations align + // under the content column. + if (typeof detail.content === 'string' && detail.content.includes('\n')) { + return detail.content.split('\n').map((row, rowIdx) => ( + + {rowIdx === 0 ? ( + <> + {' '} + {/* String literal, not JSX text: the two trailing spaces are load-bearing + (the original's ⎿ gutter is 3 columns) and a formatter collapses + bare JSX whitespace. */} + {'⎿ '} + ) : ( - + ' ' )} - {toolLabel(group)} - {isDelegateGroup ? ( - - {' (/agents to monitor)'} - - ) : null} + {row || ' '} - {group.details.map(detail => { - // Multi-line details (Bash 3-line summaries, error caps): - // first row carries the ⎿ connector, continuations align - // under the content column. - if (typeof detail.content === 'string' && detail.content.includes('\n')) { - return detail.content.split('\n').map((row, rowIdx) => ( - - {rowIdx === 0 ? ( - <> - {' '} - - - ) : ( - ' ' - )} - {row || ' '} - - )) - } - - return ( - - {' '} - - {detail.content} - - ) - })} - {inlineDelegateKey === group.key ? renderSubagentList([]) : null} - + )) + } + + return ( + + {' '} + {/* String literal, not JSX text: the two trailing spaces are load-bearing + (the original's ⎿ gutter is 3 columns) and a formatter collapses + bare JSX whitespace. */} + {'⎿ '} + {detail.content} + ) })} + {inlineDelegateKey === group.key ? renderSubagentList([]) : null} + + ) + } + + // Collapsed (default) view: consecutive collapsible calls fold into one + // brief line, standalone calls keep their block, and order is preserved. + // Expanded (ctrl+o) view: every call keeps its block. Either way a blank + // line separates consecutive blocks, as upstream renders them. + const toolsFlat = + hasTools && visible.tools !== 'hidden' ? ( + + {toolsExpanded + ? groups.map((group, index) => renderGroup(group, index > 0)) + : briefRuns( + groups, + group => group.label, + group => Boolean(group.error) + ).map((run, index) => + run.kind === 'flat' ? ( + + {/* One gap per RUN, matching how the height estimator counts + them. briefRuns never merges two standalone calls, so a + flat run holds exactly one item today — keeping the gap + keyed on the run means the two stay in step even if that + ever changes. */} + {run.items.map(group => renderGroup(group, index > 0))} + + ) : ( + group.label))} + gap={index > 0} + key={`run-${index}`} + live={run.items.some(group => group.live)} + t={t} + /> + ) + )} ) : null diff --git a/ui-tui/src/domain/toolBrief.ts b/ui-tui/src/domain/toolBrief.ts new file mode 100644 index 000000000..e13ab4d92 --- /dev/null +++ b/ui-tui/src/domain/toolBrief.ts @@ -0,0 +1,271 @@ +import { parseToolTrailResultLine, splitToolDuration, toolTrailLabel } from '../lib/text.js' + +/** + * The original Claude Code transcript does not list every tool call on its own + * row. A run of calls collapses into a single muted summary line — the + * "brief": + * + * Read 3 files, listed 1 directory, ran 1 shell command + * + * and ctrl+o (details `expanded`) swaps it back for the full `⏺ Tool(args)` + * list. This module owns the brief's vocabulary: which bucket a call falls + * into, whether that bucket collapses at all, and which clauses it produces. + * + * Three rules carry most of the look: + * - only the FIRST clause is capitalized; the rest stay lowercase, joined + * by ", " ("Read 3 files, listed 1 directory"). + * - a still-running group uses the gerund and trails an ellipsis + * ("Reading 1 file…"); a settled one uses the past tense. + * - upstream only folds tools that describe themselves as a search/read + * shaped call (plus shell commands and MCP bridges). Everything else — + * edits, delegations, answers — keeps its own row, because its detail + * rows carry information a tally would destroy. See STANDALONE below. + */ +export type BriefBucket = 'agent' | 'answer' | 'bash' | 'edit' | 'list' | 'other' | 'read' | 'search' + +export type BriefCounts = Record + +/** + * Only the read/search band and shell commands fold. Everything else renders + * as its own `⏺ Tool(args)` block even in the collapsed view — which is what + * upstream does, and the reason its collapsed message type is literally named + * `collapsed_read_search`. Captured from Claude Code 2.1.228: a lone `Bash` + * folds to "Ran 1 shell command", while a lone `WebSearch` keeps + * `⏺ Web Search("…")` + `⎿ Did 1 search in 2s`, and a lone `Agent` keeps + * `⏺ Agent(…)` + `⎿ Done (2 tool uses · 48.0k tokens · 11s)`. + * + * edit — the patch itself is the point (upstream renders `⏺ Write(f)` + * with the content under it, and never tallies it away). + * agent — the row anchors the inline subagent tree. + * answer — AskUserQuestion, clarify, advisor, vision_analyze, ExitPlanMode: + * the detail rows ARE the result (the user's own choices, the + * advisor's opinion, the plan). + * other — WebSearch, WebFetch, Skill, MCP bridges: their `⎿` rows carry a + * result no tally can stand in for. + * + * The point of the brief is the high-volume, low-information calls — reading, + * searching, listing, shelling out. Those are what bury a transcript. + */ +const STANDALONE: ReadonlySet = new Set(['agent', 'answer', 'edit', 'other']) + +export const isCollapsibleBucket = (bucket: BriefBucket): boolean => !STANDALONE.has(bucket) + +export const emptyBriefCounts = (): BriefCounts => ({ + agent: 0, + answer: 0, + bash: 0, + edit: 0, + list: 0, + other: 0, + read: 0, + search: 0 +}) + +// Labels, not wire names: the trail carries what toolTrailLabel() produced, so +// `vision_analyze` arrives as `Vision Analyze`. +const READ_TOOLS = new Set(['Read']) +const SEARCH_TOOLS = new Set(['Glob', 'Grep']) +const EDIT_TOOLS = new Set(['Edit', 'NotebookEdit', 'Write']) +const AGENT_TOOLS = new Set(['Agent', 'Delegate Task', 'Task']) +const ANSWER_TOOLS = new Set(['Advisor', 'AskUserQuestion', 'Clarify', 'ExitPlanMode', 'Vision Analyze']) + +/** `Read(src/a.py)` → `Read`. Legacy trail lines may still carry a `(1.2s)` + * duration suffix, so strip that before splitting on the arg paren. */ +export const briefToolName = (call: string): string => { + const { label } = splitToolDuration(call) + const paren = label.indexOf('(') + + return (paren > 0 ? label.slice(0, paren) : label).trim() +} + +/** `Bash(ls src)` → `ls src`. Empty when the call carries no args. */ +export const briefToolArgs = (call: string): string => { + const { label } = splitToolDuration(call) + const paren = label.indexOf('(') + + return paren > 0 ? label.slice(paren + 1).replace(/\)$/, '') : '' +} + +// Upstream buckets a shell call on the *command*, not the tool: `ls` reads as +// "listed 1 directory" and `grep`/`rg` as "searched for 1 pattern", so a +// transcript full of shell-driven exploration still reads as exploration. +// Anything else is a shell command. +// +// Deliberately a prefix match on the bare command, so anything wrapped — +// `sudo ls`, `FOO=1 ls`, `cd x && ls`, a pipeline that greps midway — falls +// through to "shell command". Under-claiming reads fine; a `cat > file.py` +// heredoc counted as a read would not. The args this matches against are the +// compactPreview head, but every pattern is anchored, so truncation can never +// change a verdict. +const LIST_COMMAND = /^\s*(?:\$\s*)?(?:ls|tree)(?:\s|$)/ +const SEARCH_COMMAND = /^\s*(?:\$\s*)?(?:ag|ack|grep|egrep|fgrep|rg)(?:\s|$)/ +const READ_COMMAND = /^\s*(?:\$\s*)?(?:bat|cat|head|less|tail)(?:\s|$)/ + +export const classifyBriefTool = (call: string): BriefBucket => { + const name = briefToolName(call) + + if (READ_TOOLS.has(name)) { + return 'read' + } + + if (SEARCH_TOOLS.has(name)) { + return 'search' + } + + if (EDIT_TOOLS.has(name)) { + return 'edit' + } + + if (AGENT_TOOLS.has(name)) { + return 'agent' + } + + if (ANSWER_TOOLS.has(name)) { + return 'answer' + } + + if (name === 'Bash') { + const args = briefToolArgs(call) + + if (LIST_COMMAND.test(args)) { + return 'list' + } + + if (SEARCH_COMMAND.test(args)) { + return 'search' + } + + return READ_COMMAND.test(args) ? 'read' : 'bash' + } + + // Everything else — WebSearch/WebFetch, Skill, MCP bridges, task tools. + // STANDALONE, so each keeps its own row and its own result. + return 'other' +} + +/** + * Classification key for a RAW trail line: the `Tool(args)` call when the line + * is a completed tool result, the drafting label while one is still being + * written, and the line itself otherwise. Mirrors how ToolTrail derives + * `group.label`, so the renderer and the height estimator agree on where runs + * begin and end. + */ +export const briefCallOfTrailLine = (line: string): string => { + const parsed = parseToolTrailResultLine(line) + + if (parsed) { + return parsed.call + } + + if (line.startsWith('drafting ')) { + return toolTrailLabel(line.slice(9).replace(/…$/, '').trim()) + } + + return line +} + +export const countBriefTools = (calls: readonly string[]): BriefCounts => { + const counts = emptyBriefCounts() + + for (const call of calls) { + counts[classifyBriefTool(call)]++ + } + + return counts +} + +export interface BriefClause { + /** The tally — rendered bold, between `verb` and `noun`. */ + count: number + key: BriefBucket + noun: string + /** Capitalized on the first clause only. */ + verb: string +} + +const plural = (n: number, one: string, many: string) => (n === 1 ? one : many) + +// Clause order is upstream's: the read/search band first, shell commands last. +// STANDALONE buckets never reach here. +const ORDER: { bucket: BriefBucket; live: string; noun: [string, string]; past: string }[] = [ + { bucket: 'search', live: 'searching for', noun: ['pattern', 'patterns'], past: 'searched for' }, + { bucket: 'read', live: 'reading', noun: ['file', 'files'], past: 'read' }, + { bucket: 'list', live: 'listing', noun: ['directory', 'directories'], past: 'listed' }, + { bucket: 'bash', live: 'running', noun: ['shell command', 'shell commands'], past: 'ran' } +] + +export const briefClauses = (counts: BriefCounts, live = false): BriefClause[] => { + const out: BriefClause[] = [] + + for (const spec of ORDER) { + const count = counts[spec.bucket] + + if (count <= 0) { + continue + } + + const verb = live ? spec.live : spec.past + + out.push({ + count, + key: spec.bucket, + noun: plural(count, spec.noun[0], spec.noun[1]), + // Only the opening clause is capitalized — the rest read as a list. + verb: out.length === 0 ? verb[0]!.toUpperCase() + verb.slice(1) : verb + }) + } + + return out +} + +/** Plain-text form of the brief. The renderer bolds the counts; this is the + * same string without styling, for height estimation and tests. */ +export const briefText = (counts: BriefCounts, live = false): string => { + const clauses = briefClauses(counts, live) + + if (!clauses.length) { + return '' + } + + return `${clauses.map(c => `${c.verb} ${c.count} ${c.noun}`).join(', ')}${live ? '…' : ''}` +} + +/** + * Split an ordered tool list into render runs: each consecutive stretch of + * collapsible calls becomes one `brief` run, and each standalone call keeps + * its own `flat` run. Preserves order, so a Read → Delegate Task → Bash trail + * reads brief / delegate row / brief rather than reordering the turn. + * + * `standalone` forces an item out of the brief regardless of its bucket. The + * renderer passes failures through it: upstream tallies a failed call away + * like any other (a `cat` of a missing file still reads "Read 1 file"), but + * the error message lives only in that call's `⎿` row, so folding it would + * make the one thing worth reading unreachable without ctrl+o. + */ +export interface BriefRun { + items: T[] + kind: 'brief' | 'flat' +} + +export const briefRuns = ( + items: readonly T[], + callOf: (item: T) => string, + standalone?: (item: T) => boolean +): BriefRun[] => { + const runs: BriefRun[] = [] + + for (const item of items) { + const collapsible = !standalone?.(item) && isCollapsibleBucket(classifyBriefTool(callOf(item))) + const kind = collapsible ? 'brief' : 'flat' + const last = runs.at(-1) + + // Standalone rows never merge with each other — each keeps its own block. + if (last && last.kind === 'brief' && kind === 'brief') { + last.items.push(item) + } else { + runs.push({ items: [item], kind }) + } + } + + return runs +} diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index ae3395405..30cdb53b7 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -191,6 +191,15 @@ export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: s return Math.max(20, available) } +/** Width a tool trail renders at. Unlike prose rows, a `kind: 'trail'` block + * has no role gutter — MessageLine hands ToolTrail the whole transcript + * interior — so it is `transcriptBodyWidth` WITHOUT the 3-column gutter. */ +export function transcriptTrailWidth(totalCols: number, termuxMode = false) { + const horizontalReserve = termuxMode ? 2 : 4 + + return Math.max(1, totalCols - horizontalReserve) +} + /** Scrollbar gutter beside the transcript: `marginLeft={1}` + `width={1}` on * the `TranscriptScrollbar` wrapper in appLayout. Always reserved — the * scrollbar renders a `width={1}` spacer even when it has nothing to show. */ diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index 1c5c914bd..666b9d73e 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -1,7 +1,9 @@ import { TERMUX_TUI_MODE } from '../config/env.js' +import { briefCallOfTrailLine, briefRuns, briefText, countBriefTools } from '../domain/toolBrief.js' import type { Msg } from '../types.js' -import { transcriptBodyWidth } from './inputMetrics.js' +import { transcriptBodyWidth, transcriptTrailWidth } from './inputMetrics.js' +import { parseToolTrailResultLine } from './text.js' const hashText = (text: string) => { let h = 5381 @@ -76,6 +78,101 @@ export const wrappedLines = (text: string, width: number, maxLines: number = MAX return n } +/** + * One entry per trail line that actually paints a `⏺ Tool(args)` block, with + * the rows it costs. Lines that produce no group — gateway meta notes, which + * ToolTrail routes to the (hidden-by-default) activity panel — drop out here, + * so both layouts below count blocks, never raw lines. + */ +interface TrailEntry { + call: string + error: boolean + rows: number +} + +// Gutters a `⏺ …` block reserves: 2 columns for the bullet on the call row, +// 5 for the ` ⎿ ` connector (and the matching pad on continuations) under it. +// The reasoning body's ` └─ ` rail lands on the same column 5. +// +// Known residual: 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 this counts. Post-mount measurement corrects it; +// it is a ±1 on long paths, not a structural divergence. +const CALL_GUTTER = 2 +const DETAIL_GUTTER = 5 + +const trailEntries = (msg: Msg, trailWidth: number, toolsExpanded: boolean): TrailEntry[] => { + const entries: TrailEntry[] = [] + + const detailRows = (detail: string) => + detail ? detail.split('\n').reduce((sum, row) => sum + wrappedLines(row, trailWidth - DETAIL_GUTTER), 0) : 0 + + for (const [i, line] of (msg.tools ?? []).entries()) { + // Read both the call and the drafting marker off the SAME line the + // renderer will draw — the verbose sibling when one is being shown. + const rendered = (toolsExpanded && msg.toolsVerbose?.[i]) || line + const parsed = parseToolTrailResultLine(rendered) + + if (parsed) { + entries.push({ + call: parsed.call, + error: parsed.mark === '✗', + // The `⏺` call row, then the `⎿` detail — both of which wrap: an Edit + // detail carries a full path and is capped in lines, never columns. + rows: wrappedLines(parsed.call, trailWidth - CALL_GUTTER) + detailRows(parsed.detail) + }) + } else if (rendered.startsWith('drafting ')) { + // Call row + the static "drafting..." detail row. + entries.push({ call: briefCallOfTrailLine(rendered), error: false, rows: 2 }) + } + + // Anything else is a gateway meta note. ToolTrail routes those to the + // activity panel (hidden by default), so they paint no row here. The one + // transient it does fold onto the previous group, "analyzing tool + // output…", is filtered by isTransientTrailLine before a line ever + // reaches msg.tools, so it cannot appear in a settled trail. + } + + return entries +} + +/** + * Rows a message's tool trail paints, matching ToolTrail's two layouts: + * + * expanded (ctrl+o) — every call keeps its `⏺ …` + `⎿ …` block, verbose + * sibling when one exists. + * collapsed (default) — consecutive collapsible calls fold to one brief + * line; standalone calls (edits, delegations, questions) and failures + * keep their block. + * + * Both walk the same briefRuns() split the renderer uses, and both add the + * blank line it opens between consecutive blocks. + */ +const trailRows = (msg: Msg, trailWidth: number, toolsExpanded: boolean) => { + const entries = trailEntries(msg, trailWidth, toolsExpanded) + + if (toolsExpanded) { + return entries.reduce((sum, entry) => sum + entry.rows, 0) + Math.max(0, entries.length - 1) + } + + return briefRuns( + entries, + entry => entry.call, + entry => entry.error + ).reduce((sum, run, index) => { + const gap = index > 0 ? 1 : 0 + + if (run.kind === 'flat') { + return sum + gap + run.items.reduce((rows, entry) => rows + entry.rows, 0) + } + + // The brief sits under a 2-column gutter, so it wraps 2 narrower. + const text = briefText(countBriefTools(run.items.map(entry => entry.call))) + + return sum + gap + (text ? wrappedLines(text, trailWidth - 2) : 0) + }, 0) +} + export const estimatedMsgHeight = ( msg: Msg, cols: number, @@ -116,7 +213,10 @@ export const estimatedMsgHeight = ( const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt, TERMUX_TUI_MODE) const text = msg.text - let h = wrappedLines(text || ' ', bodyWidth) + // A `trail` block paints no text row at all (MessageLine hands it straight + // to ToolTrail), so it must not be charged the one-row floor every prose + // block gets — that alone doubled the estimate for a one-row brief. + let h = msg.kind === 'trail' ? 0 : wrappedLines(text || ' ', bodyWidth) if (!compact && msg.role === 'assistant') { // Paragraph gaps add up to 6 extra rows of breathing room. Slice @@ -133,20 +233,36 @@ export const estimatedMsgHeight = ( const hasVisibleDetails = hasVisibleTools || hasVisibleThinking if (hasVisibleDetails) { + const trailWidth = transcriptTrailWidth(cols, TERMUX_TUI_MODE) + // Tool entries can carry multi-line details (Bash 3-line summaries, // 10-line error caps) — count rendered rows, not entries, or off-screen // estimates under-count and the scrollbar/topSpacer math jumps. - const toolRows = hasVisibleTools - ? (msg.tools ?? []).reduce((sum, line, i) => { - // Expanded details render the verbose sibling when present. - const rendered = toolsExpanded && msg.toolsVerbose?.[i] ? msg.toolsVerbose[i]! : line + const toolRows = hasVisibleTools ? trailRows(msg, trailWidth, toolsExpanded) : 0 + + // The reasoning panel is its `∴ Thinking…` header row plus the body, + // which sits under a ` └─ ` rail — content starts at column 5, so + // DETAIL_GUTTER is the right offset here too. The header used to be paid + // for by the one-row text floor above; trail blocks no longer get that + // floor, so charge it here where it is actually true. + // (Known gap, pre-dating the brief: `/details thinking collapsed` closes + // the body while this still counts it. The panel is expanded by default, + // so the common path is exact.) + const thinkingRows = hasVisibleThinking ? 1 + wrappedLines(msg.thinking ?? '', trailWidth - DETAIL_GUTTER) : 0 - return sum + rendered.split('\n').length - }, 0) - : 0 + h += toolRows + thinkingRows - h += toolRows + (hasVisibleThinking ? wrappedLines(msg.thinking ?? '', bodyWidth) : 0) + // A prose row wraps its details in a Box with marginBottom={1}. Trail + // blocks hand ToolTrail straight through, and the structured-diff branch + // brings its own wrapper with ToolTrail as a direct child — neither pays + // this. (The diff branch's estimate is off for older reasons too: it + // still counts msg.text for a markdown fallback the structured path + // never renders. Out of scope here; just don't add to it.) + if (msg.kind !== 'trail' && msg.kind !== 'diff') { + h++ + } + // "Response" separator row + its own marginBottom. if (msg.role === 'assistant' && /\S/.test(msg.text)) { h += 2 }