From 4e01789cdab86453b6142ddc955e054274c26cae Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Wed, 19 Aug 2026 02:37:35 -0700 Subject: [PATCH] fix(tools): let a row be a bare label when its arguments are all payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `⏺ Exit Plan Mode(# Close the tool-result gaps - read cards - search cards)` — the row squeezed the whole plan into 64 columns of parens, and the review box directly below it then rendered the same plan in full, properly formatted. The plan is payload, exactly like a write's content or a delegation's brief, so it joins the bulk set. That exposed a rule that was wrong for the same reason. When every argument turned out to be bulk, the preview fell back to projecting them all anyway, on the theory that a row should say something. It should not: `formatToolCall` already renders a preview-less call as a bare label, and a bare label is what the original shows for the tools whose real content draws BELOW the row — `⏺ TodoWrite` over its checklist, `⏺ TaskCreate`, `⏺ ExitPlanMode` over the plan under review. Dropping the fallback makes those read right and takes a rule out. ⏺ Exit Plan Mode ⎿ Running… ╭──────────────────────────────────────────╮ │ Ready to code? │ │ Here is Claude's plan: │ … Also teaches the probe LLM to emit several calls in one assistant message (` ;; ` between them), because the collapsed brief only reads right across a RUN of calls. The five-call run it produces renders Searched for 1 pattern, read 2 files, listed 1 directory, ran 1 shell command which is byte-identical to what Claude Code 2.1.235 renders for the same five calls. Co-Authored-By: Claude Opus 5 --- package-lock.json | 4 ++-- package.json | 2 +- scripts/tool-gallery.py | 19 +++++++++++++++++++ src/__tests__/toolArgs.test.ts | 16 ++++++++++++---- src/domain/toolArgs.ts | 16 +++++++++------- test/e2e/probe-llm.mjs | 26 ++++++++++++++++++-------- 6 files changed, 61 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b62689..85d1123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-cctui", - "version": "0.3.8", + "version": "0.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-cctui", - "version": "0.3.8", + "version": "0.3.9", "license": "MIT", "dependencies": { "@dsh-cctui/ink": "file:./packages/dsh-cctui-ink", diff --git a/package.json b/package.json index 700ee56..55630cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-cctui", - "version": "0.3.8", + "version": "0.3.9", "description": "Claude-Code-style terminal UI for deepseek-harness, ported from clawcodex ui-tui", "license": "MIT", "repository": { diff --git a/scripts/tool-gallery.py b/scripts/tool-gallery.py index e7216b3..47b1c3b 100755 --- a/scripts/tool-gallery.py +++ b/scripts/tool-gallery.py @@ -82,8 +82,27 @@ 'PROBE subagent {"description": "Audit the tests", "prompt": "Summarise the test layout and stop."}' ), "skill": 'PROBE skill {"name": "nonexistent-skill"}', + "read_image": 'PROBE read_image {"file_path": "e2e-scratch/gallery.png"}', + "str_replace_editor": 'PROBE str_replace_editor {"command": "view", "path": "src/domain/usage.ts"}', + "job_output": 'PROBE job_output {"job_id": "no-such-job"}', + "send_message": 'PROBE send_message {"subagent_id": "no-such-agent", "message": "hello"}', + "get_goal": 'PROBE get_goal {}', + # plan mode has to be on before the tool will run; pair with --live to catch + # the review prompt while it is up + "exit_plan_mode": [ + '/plan', + 'PROBE exit_plan_mode {"plan": "# Close the tool-result gaps\\n\\n- read cards\\n- search cards"}' + ], "job_list": 'PROBE job_list {}', "list_agents": 'PROBE list_agents {}', + # a RUN of calls, which is the only way the collapsed brief reads right + "mixed_run": ( + 'PROBE read {"file_path": "src/domain/usage.ts"}' + ' ;; PROBE read {"file_path": "src/domain/roles.ts"}' + ' ;; PROBE glob {"pattern": "src/domain/*.ts"}' + ' ;; PROBE bash {"command": "ls -1 src | head -3", "description": "peek"}' + ' ;; PROBE bash {"command": "echo hi", "description": "echo"}' + ), "todo": ( 'PROBE todo_write {"todos": [{"content": "first task", "status": "in_progress"},' ' {"content": "second task", "status": "pending"}]}' diff --git a/src/__tests__/toolArgs.test.ts b/src/__tests__/toolArgs.test.ts index 63c7f16..d9dd547 100644 --- a/src/__tests__/toolArgs.test.ts +++ b/src/__tests__/toolArgs.test.ts @@ -48,10 +48,18 @@ describe('toolArgsPreview', () => { expect(toolArgsPreview(raw)).toBe('Review the diff') }) - it('falls back to the unfiltered projection when every argument is bulk', () => { - expect(toolArgsPreview('{"todos":[{"content":"a","status":"pending"}]}')).toBe( - 'todos: [{"content":"a","status":"pending"}]' - ) + it('does not restate a plan the review box is already showing', () => { + const raw = '{"plan":"# Close the gaps\\n\\n- read cards\\n- search cards"}' + + // the plan renders in full right below the row; squeezing it into 64 + // columns of parens shows it twice, once badly + expect(toolArgsPreview(raw)).toBe('') + }) + + it('renders nothing when every argument is bulk, leaving a bare label', () => { + // `⏺ Todo Write` with the checklist below it, the way upstream renders it — + // formatToolCall drops the parens entirely for an empty preview + expect(toolArgsPreview('{"todos":[{"content":"a","status":"pending"}]}')).toBe('') }) it('renders non-string values as JSON', () => { diff --git a/src/domain/toolArgs.ts b/src/domain/toolArgs.ts index 2938f93..9e75eb9 100644 --- a/src/domain/toolArgs.ts +++ b/src/domain/toolArgs.ts @@ -28,7 +28,8 @@ /** * Arguments that never identify a call. Bulk payloads (a file body, an edit's - * before/after, a checklist, a delegation's brief) and policy/qualifier fields + * before/after, a checklist, a delegation's brief, a plan under review) and + * policy/qualifier fields * (why a command may escalate, which window of a file to read) both make the * row longer and less distinguishable, which is the opposite of the point. */ @@ -41,6 +42,7 @@ const NOISE_KEYS: ReadonlySet = new Set([ 'new_string', 'old_str', 'old_string', + 'plan', 'prompt', 'todos', // policy / qualifiers @@ -117,10 +119,10 @@ export const toolArgsPreview = (raw: string): string => { return project(salient) } - const labels = entries.filter(([key]) => LABEL_KEYS.has(key)) - - // A call whose every argument is bulk (a bare `todo_write`) still deserves a - // row that says something; falling back to the unfiltered projection beats - // rendering `⏺ Todo Write()`. - return project(labels.length ? labels : entries) + // A call whose every argument is bulk or policy has nothing to put in the + // parens, and that is the right answer, not a failure: `formatToolCall` + // renders a preview-less call as a bare label, which is exactly what the + // original shows for `⏺ TodoWrite` and `⏺ ExitPlanMode` — both of which draw + // their real content (the checklist, the plan under review) below the row. + return project(entries.filter(([key]) => LABEL_KEYS.has(key))) } diff --git a/test/e2e/probe-llm.mjs b/test/e2e/probe-llm.mjs index 10410af..8794617 100644 --- a/test/e2e/probe-llm.mjs +++ b/test/e2e/probe-llm.mjs @@ -32,17 +32,27 @@ class ProbeAdapter extends LlmAdapter { .slice(lastUserIndex(msgs) + 1) .some(m => Array.isArray(m.content) && m.content.some(b => b.type === 'tool-result')) - const m = /^PROBE\s+(\S+)\s+([\s\S]*)$/.exec(user.trim()) + // `PROBE `, or several separated by ` ;; ` to exercise the + // collapsed brief, which only reads right across a RUN of calls. + const calls = user + .trim() + .split(' ;; ') + .map(part => /^PROBE\s+(\S+)\s*([\s\S]*)$/.exec(part.trim())) + .filter(Boolean) + + if (calls.length && !hasToolResult) { + for (const [index, m] of calls.entries()) { + const name = m[1] + const args = m[2].trim() || '{}' + const id = `probe-${++seq}` + yield { blockType: 'tool-call', index, type: 'block-start' } + yield { argumentsDelta: args, id, name, type: 'tool-call-delta', index } + yield { block: { arguments: args, id, name, type: 'tool-call' }, index, type: 'block-end' } + } - if (m && !hasToolResult) { - const name = m[1] - const args = m[2].trim() || '{}' - const id = `probe-${++seq}` - yield { blockType: 'tool-call', index: 0, type: 'block-start' } - yield { argumentsDelta: args, id, name, type: 'tool-call-delta', index: 0 } - yield { block: { arguments: args, id, name, type: 'tool-call' }, index: 0, type: 'block-end' } yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } yield { reason: { kind: 'tool-calls' }, type: 'finish' } + return }