From f1095be163e8a87cf626c7db878836fcfcd29407 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Wed, 19 Aug 2026 08:18:53 -0700 Subject: [PATCH] fix(tools): say how much a truncated result dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A result that overran the row's safety cap ended in a bare `…`, which reads as "a bit more" whether three lines went or three hundred. Upstream counts what it dropped, and so does this codebase's own verbose block; the compact row now matches both: ⏺ Bash(seq 1 40 >&2; exit 1) ⎿ Error: Exit code 1 [stderr] 1 … 10 … +30 lines Deliberately not "(ctrl+o to expand)": a result the gateway retained no raw copy of expands to the same truncated text, and pointing at a key that changes nothing is worse than silence. The gallery grows the last tools that had never been driven through it — `subagent_fork`, `job_kill`, `interrupt_agent`, `update_goal`, and a failure noisy enough to hit the cap — which completes coverage of all 26 tools the base bundle registers. It also learns two things about its own driving: a slash prompt needs Esc to dismiss the completion menu before Enter submits, and the scenarios that deliberately PARK on a prompt (the approval box, the plan review) are skipped by a no-argument sweep, which otherwise sat through a 90s idle timeout for each of them. README records what the trail actually does now. Co-Authored-By: Claude Opus 5 --- README.md | 14 ++++++++++---- package-lock.json | 4 ++-- package.json | 2 +- scripts/tool-gallery.py | 24 +++++++++++++++++++++++- src/__tests__/text.test.ts | 13 +++++++++++-- src/domain/toolArgs.ts | 12 ++++++++---- src/lib/text.ts | 11 +++++++++-- 7 files changed, 64 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index cd5ba31..2736ae9 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,17 @@ an `- id: cctui` config override in the profile's `cordis.patch.yml`: `provider` - **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; 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. +- **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 + 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 · + 1.2k tokens · 11s)`, and its children stream into `/agents` — goal, live tool calls, + tokens, spawn tree. - **Sessions**: `/sessions` (Ctrl+X) lists live and persisted sessions; `/resume ` - replays a persisted transcript; `/new`, `/title`, `/rename`. + replays a persisted transcript, tool results and all; `/new`, `/title`, `/rename`. - **Commands**: every harness `ctx.commands` entry (e.g. `/plan`, `/goal`) appears in the completion menu and dispatches through the harness; `/model` opens the picker backed by the llm catalog; `/effort`, `/context`, `/usage`, `/help`, `/status`. diff --git a/package-lock.json b/package-lock.json index f2cdf58..fe22028 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-cctui", - "version": "0.3.15", + "version": "0.3.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-cctui", - "version": "0.3.15", + "version": "0.3.16", "license": "MIT", "dependencies": { "@dsh-cctui/ink": "file:./packages/dsh-cctui-ink", diff --git a/package.json b/package.json index b0eb988..b8d1184 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-cctui", - "version": "0.3.15", + "version": "0.3.16", "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 dfacd52..adc2d64 100755 --- a/scripts/tool-gallery.py +++ b/scripts/tool-gallery.py @@ -67,6 +67,10 @@ 'PROBE bash {"command": "echo escalated", "description": "an escalating echo",' ' "sandbox_permissions": "danger-full-access", "justification": "the gallery needs the approval box"}' ), + # a failure whose output overruns the row's safety cap + "bash_fail_long": ( + 'PROBE bash {"command": "seq 1 40 >&2; exit 1", "description": "a noisy failure"}' + ), "bash_fail": 'PROBE bash {"command": "ls /no/such/dir", "description": "failing ls"}', "write": 'PROBE write {"file_path": "e2e-scratch/gallery-write.txt", "content": "alpha\\nbeta\\ngamma\\n"}', # the harness refuses to edit a file this session has not observed, so the @@ -98,6 +102,12 @@ 'PROBE workflow {"script": "log(\\"hello from the workflow\\")\\nreturn { ok: true }",' ' "meta": {"name": "gap-probe", "description": "a one-line workflow that returns immediately"}}' ), + "subagent_fork": ( + 'PROBE subagent_fork {"description": "Check the fork path", "prompt": "Say ok and stop."}' + ), + "job_kill": 'PROBE job_kill {"job_id": "no-such-job", "reason": "gallery probe"}', + "interrupt_agent": 'PROBE interrupt_agent {}', + "update_goal": 'PROBE update_goal {"goal_id": "no-such-goal", "revision": 1, "action": "complete"}', "skill": 'PROBE skill {"name": "nonexistent-skill"}', "read_image": 'PROBE read_image {"file_path": "e2e-scratch/gallery.png"}', # this one insists on an absolute path @@ -128,6 +138,12 @@ } +# Scenarios that deliberately park on a prompt — the approval box, the plan +# review — never settle on their own, so a no-argument sweep would sit through +# the idle timeout for each. Name them explicitly (with --live) to capture them. +LIVE_ONLY = {"bash_escalate", "exit_plan_mode"} + + def bootstrap() -> None: # the mutation scenarios must CREATE their file, not find yesterday's for leftover in (ROOT / "e2e-scratch").glob("gallery-*.txt"): @@ -299,6 +315,12 @@ def capture(scenario: str, expand: bool, live: float | None = None) -> str | Non for index, prompt in enumerate(prompts): session.send(prompt.encode()) session.pump(0.8) + if prompt.startswith("/"): + # a slash word opens the completion menu, whose Enter ACCEPTS + # rather than submits; Esc dismisses it first (nothing is + # running yet, so Esc cannot interrupt a turn here) + session.send(b"\x1b") + session.pump(0.4) session.send(b"\r") session.wait_for(TOOL_GLYPH, 60) if live is not None and index == len(prompts) - 1: @@ -334,7 +356,7 @@ def main() -> int: outdir.mkdir(parents=True, exist_ok=True) bootstrap() - for scenario in args.scenarios or list(SCENARIOS): + for scenario in args.scenarios or [name for name in SCENARIOS if name not in LIVE_ONLY]: shot = capture(scenario, args.expand, args.live) if shot is None: continue diff --git a/src/__tests__/text.test.ts b/src/__tests__/text.test.ts index 0bed99a..ceb2fa9 100644 --- a/src/__tests__/text.test.ts +++ b/src/__tests__/text.test.ts @@ -50,8 +50,17 @@ describe('buildToolTrailLine', () => { const line = buildToolTrailLine('Bash', 'x', false, big) const detail = parseToolTrailResultLine(line)!.detail - expect(detail.split('\n')).toHaveLength(13) // 12 + trailing … - expect(detail.endsWith('…')).toBe(true) + // 12 kept + a marker that says how much went, not a bare … that reads as + // "a bit more" whether three lines were dropped or three hundred + expect(detail.split('\n')).toHaveLength(13) + expect(detail.endsWith('… +28 lines')).toBe(true) + }) + + it('reads the truncation marker in the singular when one line went', () => { + const big = Array.from({ length: 13 }, (_, i) => `l${i}`).join('\n') + const detail = parseToolTrailResultLine(buildToolTrailLine('Bash', 'x', false, big))!.detail + + expect(detail.endsWith('… +1 line')).toBe(true) }) it('anchors the call/detail split so args containing :: cannot mis-split', () => { diff --git a/src/domain/toolArgs.ts b/src/domain/toolArgs.ts index 285b56b..aa9f127 100644 --- a/src/domain/toolArgs.ts +++ b/src/domain/toolArgs.ts @@ -9,7 +9,7 @@ * ⏺ Search(pattern: "presentResult", path: "src") * ⏺ Write(notes.txt) * - * Two rules produce all four shapes. Render the arguments as `key: value` + * One rule produces all four shapes: render the arguments as `key: value` * pairs, and collapse to the bare value when only one pair survives. What * "survives" is the interesting part: an argument carrying BULK (a file's new * content, an edit's replacement text, a todo list) or POLICY (a sandbox @@ -20,6 +20,10 @@ * `bash(command, description, sandbox_permissions, justification)` reduces to * `Bash(ls -1 src)` — no per-tool table, no tool names hard-coded here. * + * Two refinements keep that rule honest against real argument shapes: a + * `description` is held back until nothing sharper survives (see LABEL_KEYS), + * and an object argument is identified by its own `name` (see identify). + * * Deliberately derived from the raw arguments rather than the harness's own * `presentCall` title: the title restates the tool's verb ("Read src/a.ts"), * which the row already prints, and a shell command that happens to start with @@ -29,9 +33,9 @@ /** * Arguments that never identify a call. Bulk payloads (a file body, an edit's * before/after, a checklist, a delegation's brief, a plan under review, a - * workflow's script) 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. + * workflow's script) 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. */ const NOISE_KEYS: ReadonlySet = new Set([ // bulk payloads diff --git a/src/lib/text.ts b/src/lib/text.ts index ff8d54c..fa632d1 100644 --- a/src/lib/text.ts +++ b/src/lib/text.ts @@ -220,8 +220,15 @@ export const buildToolTrailLine = ( const raw = (note ?? '').trim() const lines = raw.split('\n') - const detail = (lines.length > TRAIL_DETAIL_MAX_LINES - ? [...lines.slice(0, TRAIL_DETAIL_MAX_LINES), '…'] + // A bare `…` says something was cut without saying how much, which reads as + // "a bit more" whether three lines went or three hundred. Upstream's own + // truncation marker counts what it dropped, and so does the verbose block + // below; this now matches them. Deliberately NOT "(ctrl+o to expand)": a + // result the gateway retained no raw copy of expands to the same truncated + // text, and pointing at a key that changes nothing is worse than silence. + const dropped = lines.length - TRAIL_DETAIL_MAX_LINES + const detail = (dropped > 0 + ? [...lines.slice(0, TRAIL_DETAIL_MAX_LINES), `… +${fmtK(dropped)} ${dropped === 1 ? 'line' : 'lines'}`] : lines ) .join('\n')