From 546ba94b9bb8e7c3f6bd74a9215a3d4a66fb27bc Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Sun, 23 Aug 2026 00:21:23 -0700 Subject: [PATCH] fix(tools): show what each call opened and what came back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run of calls collapsed into a tally — `Read 1 file`, `Ran 1 shell command` — which took away the two things the row exists to say: the path a read opened and the command a shell ran. And the summaries added on top of that (`Read 3 lines`, `Found 37 files`) said a tool ran without saying what it found. Every call now keeps its own `⏺ Tool(args)` row over a few lines of its actual output: ⏺ Read(src/domain/usage.ts) ⎿ 1 import type { Usage } from '../types.js' 2 3 export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 } ⏺ Glob(src/domain/*.ts) ⎿ Found 12 files src/domain/blockLayout.ts src/domain/details.ts src/domain/modelSwitch.ts … +9 lines (ctrl+o to expand) ⏺ Bash(echo hi) ⎿ hi Four lines is enough to recognise a result and short enough that a turn of ten reads still fits on a screen. The body is bounded by the CARD, not by the trail's safety cap: only the card knows the real total, so the count is honest — and handing a 2000-line file to a trail line would keep the whole thing alive in the transcript for the rest of the session. `result_raw` carries the full text, which is what ctrl+o swaps in, and the row names that key only when there is something behind it. ctrl+o now changes how much of each RESULT shows, not whether the calls show at all. The height estimator follows the same rule — including the row the expand hint can wrap onto — so the two stay in step. The brief itself is left in domain/toolBrief.ts, tested and intact, with a note saying nothing calls it: putting it back is one line in ToolTrail, and deleting a subsystem is not this change's job. Co-Authored-By: Claude Opus 5 --- README.md | 13 +-- package-lock.json | 4 +- package.json | 2 +- src/__tests__/expandResults.test.ts | 14 +-- src/__tests__/harnessClient.test.ts | 22 +++- src/__tests__/toolBrief.test.ts | 144 ++++++++++++--------------- src/__tests__/toolTranscript.test.ts | 11 +- src/components/thinking.tsx | 102 +++---------------- src/domain/toolBrief.ts | 8 ++ src/harness/client.ts | 49 +++++++-- src/lib/text.ts | 4 + src/lib/virtualHeights.ts | 64 ++++-------- 12 files changed, 184 insertions(+), 253 deletions(-) diff --git a/README.md b/README.md index f723811..7061e50 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,13 @@ as you and the shipped policy confines writes rather than reads. ## Highlights -- **Conversation loop**: streamed deltas render live; reasoning behind Ctrl+O/Ctrl+R density - toggles; busy verbs and spinners; Esc interrupts (Ctrl+C never kills the app). -- **Tools**: Claude-style `⏺ Tool(args)` / `⎿ result` trail, each result summarised the way - the original does it — `Read 3 lines`, `Found 37 files`, `Received 1.2KB (200 OK)`, - `Did 1 search in 2s` — with a failed call carrying the tool's own message and a running one - its own clock; write/edit diffs render as structured diff cards; sandbox-escalation +- **Conversation loop**: streamed deltas render live; reasoning shows a line or two with the + whole chain of thought behind Ctrl+O; busy verbs and spinners; Esc interrupts (Ctrl+C never + kills the app). +- **Tools**: Claude-style `⏺ Tool(args)` / `⎿ result` trail — every call keeps its own row + with the path it opened or the command it ran, over a few lines of what came back and + `… +N lines (ctrl+o to expand)`; a failed call carries the tool's own message and a running + one its own clock; write/edit diffs render as structured diff cards; sandbox-escalation approvals pop the approval box (`1` approve / `2`+Enter deny); todo lists pin under the busy line. - **Subagents**: a delegation reads `⏺ Subagent(Review the diff)` / `⎿ Done (2 tool uses · diff --git a/package-lock.json b/package-lock.json index 860a4b2..611f4f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-cctui", - "version": "0.3.17", + "version": "0.3.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-cctui", - "version": "0.3.17", + "version": "0.3.18", "license": "MIT", "dependencies": { "@dsh-cctui/ink": "file:./packages/dsh-cctui-ink", diff --git a/package.json b/package.json index 2e0025a..0e04a61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-cctui", - "version": "0.3.17", + "version": "0.3.18", "description": "Claude-Code-style terminal UI for deepseek-harness, ported from clawcodex ui-tui", "license": "MIT", "repository": { diff --git a/src/__tests__/expandResults.test.ts b/src/__tests__/expandResults.test.ts index 688afa5..7748381 100644 --- a/src/__tests__/expandResults.test.ts +++ b/src/__tests__/expandResults.test.ts @@ -230,21 +230,21 @@ describe('mergeToolShelfInto lockstep', () => { // ── render + heights ───────────────────────────────────────────────────────── describe('expanded rendering', () => { - const trail = ['Bash(seq 6) :: 1\n2\n3\n… +3 lines (ctrl+o to expand) ✓'] + const trail = ['Bash(seq 6) :: 1\n2\n3\n… +3 lines ✓'] const verboseTrail = ['Bash(seq 6) (0.2s) :: Result:\n1\n2\n3\n4\n5\n6 ✓'] - it('collapsed folds the call into the brief; expanded swaps in the full result', () => { + it('collapsed shows the head of the result; expanded swaps in all of it', () => { const collapsed = stripAnsi( renderToString( React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail, verboseTrail }) ) ) - // 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)') + // The call keeps its row and the head of what it returned, and names the + // key that shows the rest — but none of the verbose-only content. + expect(collapsed).toContain('Bash(seq 6)') + expect(collapsed).toContain('… +3 lines (ctrl+o to expand)') + expect(collapsed).not.toContain('Result:') expect(collapsed).not.toContain('4') const expanded = stripAnsi( diff --git a/src/__tests__/harnessClient.test.ts b/src/__tests__/harnessClient.test.ts index 0f41ab9..143b91a 100644 --- a/src/__tests__/harnessClient.test.ts +++ b/src/__tests__/harnessClient.test.ts @@ -40,6 +40,13 @@ const TOOL_CARDS: Record = { ], truncated: false }, + read_long: { + card: 'read', + lines: Array.from({ length: 10 }, (_, i) => ({ number: i + 1, text: `l${i + 1}` })), + offset: 1, + path: 'src/big.ts', + totalLines: 10 + }, read_window: { card: 'read', lines: [ @@ -436,15 +443,22 @@ describe('HarnessGatewayClient', () => { return w.events.at(-1) as Extract } - it('summarises a read as its line count, keeping the text for ctrl+o', async () => { + it('shows the head of the file it read, not a tally', async () => { const done = await runTool('read_window', '{"file_path":"src/y.ts","offset":8}') - // upstream renders `⎿ Read 3 lines` and stops — the file's text is what the - // MODEL was handed, and repeating it inline would bury the turn. - expect(done.payload.result_text).toBe('Read 3 lines') + // a tally says a read happened without saying what it found; the row exists + // so a reader can follow along without opening the file themselves + expect(done.payload.result_text).toBe(' 8 import x\n 9 \n10 export const y = 1') expect(done.payload.result_raw).toBe(' 8 import x\n 9 \n10 export const y = 1') }) + it('holds back a read longer than the row, and says how much', async () => { + const done = await runTool('read_long', '{"file_path":"src/big.ts"}') + + expect(done.payload.result_text).toBe(' 1 l1\n 2 l2\n 3 l3\n 4 l4\n… +6 lines') + expect(done.payload.result_raw?.split('\n')).toHaveLength(10) + }) + it('counts a path search in files', async () => { const done = await runTool('glob', '{"pattern":"src/*.ts"}') diff --git a/src/__tests__/toolBrief.test.ts b/src/__tests__/toolBrief.test.ts index b6058f8..e9ec55a 100644 --- a/src/__tests__/toolBrief.test.ts +++ b/src/__tests__/toolBrief.test.ts @@ -210,22 +210,27 @@ const renderToString = (element: React.ReactElement, columns = 100): string => { return lastFrame(output) } -describe('ToolTrail brief render', () => { +describe('ToolTrail flat render', () => { const trail = [ - buildToolTrailLine('Read', 'src/alpha.py', false, 'Read 8 lines'), - buildToolTrailLine('Read', 'src/beta.py', false, 'Read 8 lines'), + buildToolTrailLine('Read', 'src/alpha.py', false, ' 1 import os'), + buildToolTrailLine('Read', 'src/beta.py', false, ' 1 import sys'), buildToolTrailLine('Bash', 'ls src', false, 'alpha.py\nbeta.py'), buildToolTrailLine('Bash', 'echo hello', false, 'hello') ] - it('collapses the whole run to one summary line', () => { + it('keeps every call on its own row, with what it opened and what came back', () => { + // the path a read opened and the command a shell ran are the transcript's + // whole point; a tally in their place takes the one thing worth reading 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') + expect(out).toContain('Read(src/alpha.py)') + expect(out).toContain('Read(src/beta.py)') + expect(out).toContain('Bash(ls src)') + expect(out).toContain('Bash(echo hello)') + expect(out).toContain('import os') + expect(out).toContain('hello') }) it('runs a clock on a tool that is still going', () => { @@ -265,29 +270,61 @@ describe('ToolTrail brief render', () => { expect(out).not.toContain('(0s)') }) - it('puts every call back under ctrl+o', () => { + it('swaps in the fuller result under ctrl+o', () => { + const verboseTrail = ['', '', 'Bash(ls src) :: Result:\nalpha.py\nbeta.py\ngamma.py ✓', ''] const out = stripAnsi( - renderToString(React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail })) + renderToString( + React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail, verboseTrail }) + ) ) - 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,') + expect(out).toContain('gamma.py') }) - it('keeps a standalone edit visible while collapsed', () => { - const withEdit = [trail[0]!, buildToolTrailLine('Edit', 'src/gamma.py', false, 'Updated src/gamma.py')] + it('names the expand key on a body its card held part of back', () => { + const held = [buildToolTrailLine('Bash', 'ls src', false, 'alpha.py\nbeta.py\n… +8 lines')] + const verboseTrail = ['Bash(ls src) :: Result:\nalpha.py ✓'] const out = stripAnsi( - renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: withEdit })) + renderToString( + React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: held, verboseTrail }) + ) ) - expect(out).toContain('Read 1 file') + expect(out).toContain('… +8 lines (ctrl+o to expand)') + }) + + it('offers no expand key when there is nothing more to show', () => { + // a result the gateway kept no raw copy of expands to the same text, and + // pointing at a key that changes nothing is worse than silence + const held = [buildToolTrailLine('Bash', 'ls src', false, 'alpha.py\nbeta.py\n… +8 lines')] + + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: held })) + ) + + expect(out).toContain('… +8 lines') + expect(out).not.toContain('(ctrl+o to expand)') + }) + + it('shows an edit and a failed call alongside the reads', () => { + const mixed = [ + trail[0]!, + buildToolTrailLine('Edit', 'src/gamma.py', false, 'Updated src/gamma.py'), + buildToolTrailLine('Bash', 'cat missing.txt', true, 'Error: No such file or directory') + ] + + const out = stripAnsi( + renderToString(React.createElement(ToolTrail, { detailsMode: 'collapsed', t: DEFAULT_THEME, trail: mixed })) + ) + + expect(out).toContain('Read(src/alpha.py)') expect(out).toContain('Edit(src/gamma.py)') + expect(out).toContain('Bash(cat missing.txt)') + expect(out).toContain('Error: No such file or directory') }) - it('separates expanded blocks with a blank line', () => { + it('separates blocks with a blank line', () => { const out = stripAnsi( renderToString(React.createElement(ToolTrail, { detailsMode: 'expanded', t: DEFAULT_THEME, trail })) ) @@ -311,66 +348,6 @@ describe('ToolTrail brief render', () => { 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 ─────────────────────────────────────────────────────── @@ -557,11 +534,12 @@ describe('estimatedMsgHeight matches the painted trail', () => { 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) + // Turning details on costs the call's two rows (`⏺ Read(a.py)` over its + // `⎿` result), plus the 2 the estimator charges for a `Response` + // separator this branch never paints (older divergence, left alone). + // The point is that it is 4 and not 5 — the details wrapper's + // marginBottom belongs to prose rows only. + expect(withDetails - withoutDetails).toBe(4) }) }) diff --git a/src/__tests__/toolTranscript.test.ts b/src/__tests__/toolTranscript.test.ts index d977dff..c2f597d 100644 --- a/src/__tests__/toolTranscript.test.ts +++ b/src/__tests__/toolTranscript.test.ts @@ -191,11 +191,12 @@ describe('estimatedMsgHeight with multi-line tool details', () => { 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)) + it('counts the same detail rows collapsed, since the call keeps its block', () => { + // Every call renders its own `⏺ …` + `⎿ …` block in both views; ctrl+o + // changes how much of the RESULT shows (the verbose sibling), not whether + // the call does. The estimate has to agree or the scrollbar and topSpacer + // math drift against the paint. + expect(estimatedMsgHeight(base, 80, opts) - estimatedMsgHeight(single, 80, opts)).toBe(2) }) }) diff --git a/src/components/thinking.tsx b/src/components/thinking.tsx index b05211a..772d02d 100644 --- a/src/components/thinking.tsx +++ b/src/components/thinking.tsx @@ -4,7 +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, isDelegationCall } from '../domain/toolBrief.js' +import { isDelegationCall } from '../domain/toolBrief.js' import { buildSubagentTree, fmtTokens, @@ -19,6 +19,7 @@ import { boundedLiveRenderText, cleanThinkingText, compactPreview, + ELIDED_TAIL, estimateTokensRough, fmtK, formatToolCall, @@ -267,58 +268,6 @@ 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) @@ -899,9 +848,15 @@ export const ToolTrail = memo(function ToolTrail({ }) if (parsed.detail) { + // The card bounded its own body and said how much it held back. Name + // the key that shows the rest — but only when there IS a rest to show: + // a result the gateway kept no raw copy of expands to the same text, + // and pointing at a key that changes nothing is worse than silence. + const expandable = !toolsExpanded && Boolean(verboseTrail[i]) && ELIDED_TAIL.test(parsed.detail) + pushDetail({ color: parsed.mark === '✗' ? t.color.error : t.color.muted, - content: parsed.detail, + content: expandable ? `${parsed.detail} (ctrl+o to expand)` : parsed.detail, dimColor: parsed.mark !== '✗', key: `tr-${i}-d` }) @@ -1219,41 +1174,14 @@ export const ToolTrail = memo(function ToolTrail({ ) } - // 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. + // Every call keeps its own `⏺ Tool(args)` block, in both views: the path a + // read opened and the command a shell ran are the transcript's whole point, + // and a tally that replaced them ("Ran 1 shell command") took the one thing + // worth reading. ctrl+o changes how much of each RESULT shows, not whether + // the calls do. A blank line separates consecutive blocks. 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} - /> - ) - )} - + {groups.map((group, index) => renderGroup(group, index > 0))} ) : null if (hasSubagents && !inlineDelegateKey && visible.subagents !== 'hidden') { diff --git a/src/domain/toolBrief.ts b/src/domain/toolBrief.ts index 4637ca1..888e9d2 100644 --- a/src/domain/toolBrief.ts +++ b/src/domain/toolBrief.ts @@ -20,6 +20,14 @@ import { parseToolTrailResultLine, splitToolDuration, toolTrailLabel } from '../ * 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. + * + * STATUS: the transcript no longer folds runs. Every call keeps its own + * `⏺ Tool(args)` row with a few lines of what it returned, because the path a + * read opened and the command a shell ran are the transcript's whole point and + * a tally took exactly those away. `isDelegationCall` (the tool vocabulary) is + * what the renderer still uses from here; the clause and run helpers below have + * no caller. They are kept, tested and intact, because reinstating the brief is + * one line in ToolTrail — not because anything calls them today. */ export type BriefBucket = 'agent' | 'answer' | 'bash' | 'edit' | 'list' | 'other' | 'read' | 'search' diff --git a/src/harness/client.ts b/src/harness/client.ts index 2407fe2..31f25e7 100644 --- a/src/harness/client.ts +++ b/src/harness/client.ts @@ -163,14 +163,40 @@ const diffSummary = (diff: StructuredDiffPayload): string => { return clauses.filter(Boolean).join(', ') } +/** + * How many lines of a tool's own output the compact `⎿` body carries. + * + * The row's job is to show WHAT came back, not to summarise it away: a few + * lines of the file, the head of the command's output, the first matches. Four + * is enough to recognise the result and short enough that a turn of ten reads + * still fits on a screen; the rest is one ctrl+o away. + */ +const PREVIEW_LINES = 4 + +/** + * The compact body, plus an honest count of what did not fit. Bounded HERE + * rather than by the trail's own safety cap, because only the card knows the + * real total — and because handing a 2000-line file to every trail line would + * keep the whole thing alive in the transcript for the rest of the session. + * `result_raw` carries the full text for the expanded view. + */ +const previewBody = (body: string): string => { + const lines = body.split('\n') + const dropped = lines.length - PREVIEW_LINES + + return dropped > 0 + ? [...lines.slice(0, PREVIEW_LINES), `… +${fmtK(dropped)} ${dropped === 1 ? 'line' : 'lines'}`].join('\n') + : body +} + /** `1 file` / `2 files` — the count the ⎿ summary lines are built from. */ const plural = (count: number, one: string, many = `${one}s`) => `${count} ${count === 1 ? one : many}` /** - * A read's ⎿ line. The original says `Read 4 lines` and stops — the file's text - * is what the MODEL was given, and repeating it inline would bury the turn. The - * lines still ride along as the expanded (ctrl+o) body, where a reader who - * wants to see what the model saw can ask for it. + * A read's ⎿ body: the head of the file itself, line-numbered, over + * `… +N lines`. A tally alone (`Read 29 lines`) says a read happened without + * saying what it found, and the whole point of the row is that a reader can + * follow along without opening the file themselves. */ const readCard = (view: { lines?: { number: number; text: string }[] @@ -178,11 +204,9 @@ const readCard = (view: { }): { resultRaw: string; resultText: string } => { const lines = view.lines ?? [] const width = String(lines.at(-1)?.number ?? 0).length + const body = lines.map(line => `${String(line.number).padStart(width)} ${line.text}`).join('\n') - return { - resultRaw: lines.map(line => `${String(line.number).padStart(width)} ${line.text}`).join('\n'), - resultText: `Read ${plural(lines.length, 'line')}` - } + return { resultRaw: body, resultText: previewBody(body) } } /** `559 bytes` / `1.2KB` — the size the original reports a fetch by. */ @@ -292,7 +316,7 @@ const searchCard = (view: { const summary = view.truncated && rows.length < total ? `${found} (showing ${rows.length})` : found const body = [summary, ...rows].join('\n') - return { resultRaw: body, resultText: body } + return { resultRaw: body, resultText: previewBody(body) } } /** Strip a lone ```lang fence so a fenced body reads as plain output. */ @@ -1273,12 +1297,15 @@ export class HarnessGatewayClient extends GatewayClient { const failed = signal !== undefined || (typeof exitCode === 'number' && exitCode !== 0) if (!failed) { - return { resultText: body || fallback } + const output = body || fallback + + return { resultRaw: output, resultText: previewBody(output) } } const status = signal === undefined ? `Exit code ${exitCode}` : `Killed by ${signal}` + const reported = [status, body].filter(Boolean).join(String.fromCharCode(10)) - return { failed: true, resultText: [status, body].filter(Boolean).join(String.fromCharCode(10)) } + return { failed: true, resultRaw: reported, resultText: previewBody(reported) } } if (view.card === 'generic') { diff --git a/src/lib/text.ts b/src/lib/text.ts index fa632d1..a587e0e 100644 --- a/src/lib/text.ts +++ b/src/lib/text.ts @@ -297,6 +297,10 @@ export const buildVerboseToolTrailLine = ( return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}` } +/** The `… +N lines` tail a card appends when it held part of its body back. + * Matching it is how a row knows there is something for ctrl+o to show. */ +export const ELIDED_TAIL = /… \+\S+ lines?$/ + export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') export const parseToolTrailResultLine = (line: string) => { diff --git a/src/lib/virtualHeights.ts b/src/lib/virtualHeights.ts index a6615b3..b6c1b08 100644 --- a/src/lib/virtualHeights.ts +++ b/src/lib/virtualHeights.ts @@ -1,10 +1,9 @@ import { TERMUX_TUI_MODE } from '../config/env.js' import { THINKING_COT_MAX } from '../config/limits.js' -import { briefCallOfTrailLine, briefRuns, briefText, countBriefTools } from '../domain/toolBrief.js' import type { Msg } from '../types.js' import { transcriptBodyWidth, transcriptTrailWidth } from './inputMetrics.js' -import { parseToolTrailResultLine, thinkingPreview } from './text.js' +import { ELIDED_TAIL, parseToolTrailResultLine, thinkingPreview } from './text.js' const hashText = (text: string) => { let h = 5381 @@ -85,11 +84,6 @@ export const wrappedLines = (text: string, width: number, maxLines: number = MAX * 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. @@ -102,8 +96,8 @@ interface TrailEntry { const CALL_GUTTER = 2 const DETAIL_GUTTER = 5 -const trailEntries = (msg: Msg, trailWidth: number, toolsExpanded: boolean): TrailEntry[] => { - const entries: TrailEntry[] = [] +const trailEntries = (msg: Msg, trailWidth: number, toolsExpanded: boolean): number[] => { + const entries: number[] = [] const detailRows = (detail: string) => detail ? detail.split('\n').reduce((sum, row) => sum + wrappedLines(row, trailWidth - DETAIL_GUTTER), 0) : 0 @@ -115,16 +109,17 @@ const trailEntries = (msg: Msg, trailWidth: number, toolsExpanded: boolean): Tra 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) - }) + // The renderer names the expand key on a body its card held part of + // back, which can wrap the last detail row — measure what it will paint. + const expandable = !toolsExpanded && Boolean(msg.toolsVerbose?.[i]) && ELIDED_TAIL.test(parsed.detail) + const detail = expandable ? `${parsed.detail} (ctrl+o to expand)` : parsed.detail + + // The `⏺` call row, then the `⎿` detail — both of which wrap: an Edit + // detail carries a full path and is capped in lines, never columns. + entries.push(wrappedLines(parsed.call, trailWidth - CALL_GUTTER) + detailRows(detail)) } else if (rendered.startsWith('drafting ')) { // Call row + the static "drafting..." detail row. - entries.push({ call: briefCallOfTrailLine(rendered), error: false, rows: 2 }) + entries.push(2) } // Anything else is a gateway meta note. ToolTrail routes those to the @@ -138,40 +133,15 @@ const trailEntries = (msg: Msg, trailWidth: number, toolsExpanded: boolean): Tra } /** - * 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. + * Rows a message's tool trail paints. Every call keeps its `⏺ …` + `⎿ …` block + * in both views — ctrl+o changes how much of each RESULT shows (the verbose + * sibling), not whether the calls do — plus the blank line the renderer 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) + return entries.reduce((sum, rows) => sum + rows, 0) + Math.max(0, entries.length - 1) } export const estimatedMsgHeight = (