diff --git a/CHANGELOG.md b/CHANGELOG.md index 512c88d3d..0bb2103ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,18 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Added +### Added + - Plan and counsel workers require substance in Findings (files/paths, acceptance criteria, non-goals, risks, ordered steps). Four headings with stub Findings salvage as `incomplete-report`, not an attachable plan. Implement and review envelope completeness is unchanged. +- Consecutive same-tool transcript calls collapse into one row with a count + chip (`· ×N`). Settled lanes use a past-tense head (`Grepped ×3 · "corbits"`). + `spawn_agent` stays one row per dispatch; `manage_tasks` paints no row. +- Pending `run_shell` rows stream up to three live output lines from a + bounded 8 KiB feed, then a last-three preview and a non-zero `exit N` at + settle. ### Fixed @@ -31,7 +39,6 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ask is dropped. Do not poll `list_agents`. - ## [0.3.21] - 2026-09-11 ### Added diff --git a/docs/TUI.md b/docs/TUI.md index 59aaad56b..39e1596a4 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -63,7 +63,7 @@ down the left edge (`userBubbleLines` in `src/tui/stream.ts`). Each bubble keeps one empty bar row above and below its text so the operator's voice stays easy to find while scrolling through denser assistant and tool rows — the pad is part of the bubble itself, not an extra turn-boundary gap, and -assistant/tool rows are unchanged. +assistant rows are unchanged; tool rows are lanes (see Tool lanes below). Parent live reasoning paints through the existing thinking row — never a third mid-turn stream lane. While `inference.thinking.delta` arrives, @@ -75,6 +75,63 @@ same one row per turn (`reasoning-fold`); `inference.text.delta` grows the open assistant streaming row in place. Worker spawn_agent-row thinking is a separate path and is unchanged by this preview. +## Tool lanes + +Transcript tool rows group by tool, not by sentence. A call for a tool the +previous row already represents folds onto that row instead of opening a new +one (`src/tui/tool-rows.ts`); the row narrates the newest call's subject and, +while calls are in flight, carries a dim count chip (`· ×N`) after the +subject. `spawn_agent` never folds — each dispatch is its own live anchor — +and `manage_tasks` paints no transcript row at all. + +When the lane settles (its last outstanding answer landed), the head rewrites +to a past-tense count over the latest subject — `Grepped ×3 · "corbits"` — +with the count taken over calls, never over payload items (a lane of three +greps says `×3` even if the payloads returned forty matches in total; nothing +here can substantiate a payload total). Each call's own answer stays behind +the expand arrow. For an edit lane the per-call `+n/-n path` addenda remain +in the expanded body, so folding never buries which file each call touched. +Single-call rows (count <= 1) render exactly as they did before lanes. + +The past tense is a map keyed by raw tool name (`src/tui/tool-formatter.ts`: +`grep` → `Grepped`, `read_file` → `Read`, `write_file` → `Wrote`, +`edit_file` → `Edited`, `run_shell` → `Ran`, `list_dir` → `Listed`, +`search_files` → `Searched`, …); an unknown tool falls back to its display +name. + +Because a lane's row identity moves to the newest call, a lane also carries +the call ids it absorbed (`memberIds`, newest appended). A +result resolves its lane when its call id is the row's own id **or** one of +its members — this is what pairs a resumed transcript's parallel batch +(call, call, result, result) correctly. An id matching nothing still answers +nothing: it is appended as its own row, never folded onto the newest +same-name lane. + +Alt+C on a lane copies the most recent call's full output (see the Alt+C +bullet under Clipboard and mouse). + +### Shell output lanes + +A pending `run_shell` row shows the command head and elapsed clock as today, +plus up to three dim tail lines of the command's live output and — when the +feed window holds more than three lines — the same dim `⋯ +N lines` elision +marker as the settle preview, `N` counting lines within the live window (the +feed keeps only the most recent 8 KiB). The output +travels through a polled bounded feed (`src/session/shell-output-feed.ts`), +not a reactor event: the plugin appends chunks (capped at an 8 KiB tail per +call, emitting at most once per 100 ms plus a final flush at settle), and +the product host's sticky poll reads the feed snapshot every 200 ms and +repaints the pending row frame-coalesced. Nothing from the feed is +persisted. When the feed is not wired (tests, the demo shell), the row +renders exactly as before — silent degradation. + +At settle the live tail is replaced by a preview of the full output: its +last three lines plus a dim `⋯ +N lines` elision marker when more were +produced (the marker carries the count; the "N lines" stat is not painted +on shell rows). A non-zero exit adds an `exit N` stat; a zero exit adds +none. The existing expand idiom (Alt+E / click / the row arrow) reveals the +full output, hiding the preview; the idiom and the arrow are unchanged. + The prompt box's border carries the metadata that would otherwise cost a titlebar row: the model label sits right-aligned in the top rule as `profile · model · effort` (empty segments omitted), and a @@ -249,7 +306,10 @@ clocks. `runtime-bridge` paints each `spawn_agent` call as a transcript stream row for **spawn / final / fail anchors**. While the agents strip is sticky, sticky-poll `syncAgentProgress` rewrites are gated off so the transcript is not a dual live -rail. Ordinary in-flight tool rows keep their own elapsed clock +rail. A `spawn_agent` call never folds into a tool lane: each dispatch keeps +its own transcript row for its whole lifetime, because the row is the live +progress anchor, not just a call record. Ordinary in-flight tool rows keep +their own elapsed clock (`syncToolElapsed`) without the current-tool suffix. ### Unprompted fleet reports @@ -725,7 +785,9 @@ running its own selection. Two chords cover remaining copy needs: (`enterCopyMode`) that resolves through the system clipboard port (`src/tui/system-clipboard.ts` — a native helper binary per platform, `pbcopy`/`clip`/`wl-copy`/`xclip`/`xsel`, falling back to an OSC - 52 escape sequence when no helper is available, e.g. over SSH). + 52 escape sequence when no helper is available, e.g. over SSH). On a + coalesced tool lane the copy resolves to the most recent call's full + output; a single-call row copies its own output, exactly as before. Arrow keys never scroll anything — inside the prompt they are caret motion or, at the buffer's edges, prompt-history recall; inside an open overlay's @@ -787,6 +849,10 @@ terminal. It cannot observe: - **Real paint.** Tests assert on the shell's in-memory row/rect state, not on what a terminal emulator actually draws to a screen buffer. +- **The live shell tail's wall clock.** The feed itself is pure and the + cadence is injectable, so the live tail is headless-testable by driving a + fake feed and a fake clock through the same sync path the sticky poll + uses; what the harness cannot see is real-time emission timing. - **Modifier reporting.** Whether a real terminal can report Shift+Enter, Alt+letter, or similar modifier combinations depends on the terminal negotiating the kitty keyboard protocol (or an equivalent) with the actual diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index ddb1dc64c..bf657804d 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -30,6 +30,7 @@ import { import type { PermissionGate } from "../permission/gate.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; import type { CompactionArchive } from "../session/compaction-archive.js"; +import type { ShellOutputFeedMap } from "../session/shell-output-feed.js"; export interface CorePosixToolPluginsArgs { cwd: string; @@ -47,6 +48,9 @@ export interface CorePosixToolPluginsArgs { // Live getter for the background-shell registry (run_shell background:true). // Omitted makes background runs fail closed in shell-guard. getBackgroundShellRegistry?: () => BackgroundShellRegistry | undefined; + // Live getter for the per-call bounded shell-output feeds the transcript + // polls for a running command's live tail. Omitted leaves the tail unwired. + getShellOutputFeeds?: () => ShellOutputFeedMap | undefined; /** Primary-only evidence archive; workers omit this getter. */ getEvidenceArchive?: () => CompactionArchive | undefined; } @@ -86,6 +90,7 @@ export function buildCorePosixToolPlugins( getContextDir, shellEnv, getBackgroundShellRegistry, + getShellOutputFeeds, getEvidenceArchive, } = args; // Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell @@ -119,6 +124,7 @@ export function buildCorePosixToolPlugins( ...(getBackgroundShellRegistry !== undefined ? { getBackgroundShellRegistry } : {}), + ...(getShellOutputFeeds !== undefined ? { getShellOutputFeeds } : {}), }), ...(getEvidenceArchive !== undefined ? [evidenceArchiveSearchPlugin(getEvidenceArchive)] diff --git a/src/agent/tools.ts b/src/agent/tools.ts index dc6a6a0fa..a22a700d5 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -90,6 +90,7 @@ import { createBackgroundShellRegistry, type BackgroundShellExit, } from "../shell/background-shell.js"; +import { createShellOutputFeedMap } from "../session/shell-output-feed.js"; import { createListDirTool } from "../util/list-dir.js"; import { createExaMCPWebFetchTool, @@ -289,6 +290,9 @@ export interface AgentToolset { callbacks: MCPConnectCallbacks, signal?: AbortSignal, ) => Promise; + // Per-call bounded live-output tails of foreground shells, polled by the + // transcript for each pending run_shell row's live lines. + shellOutputFeed: ReturnType; // Connect one newly persisted server through the same lifecycle as startup MCP. connectMCPServer: ( config: MCPServerConfig, @@ -360,6 +364,10 @@ export async function createAgentToolset( : {}), }); const shellCollect = createShellCollectTool(backgroundShells); + // Per-call bounded live-output tails of foreground shells, polled by the TUI + // for each pending run_shell row's live lines. Workers get a map too; nothing + // reads it unless a transcript polls it (silent degradation). + const shellOutputFeed = createShellOutputFeedMap(); const sessionBlobReader = getBlobReader !== undefined ? createLazyBlobReader(getBlobReader) @@ -447,6 +455,7 @@ export async function createAgentToolset( ...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}), ...(shellEnv !== undefined ? { shellEnv } : {}), getBackgroundShellRegistry: () => backgroundShells, + getShellOutputFeeds: () => shellOutputFeed, }), }); @@ -1179,6 +1188,7 @@ export async function createAgentToolset( return { dynamicRunner, connectMCP, + shellOutputFeed, connectMCPServer: publicConnectMCPServer, disconnectMCPServer: publicDisconnectMCPServer, hasMCPServer: (name) => diff --git a/src/plugins/shell-guard-plugin.test.ts b/src/plugins/shell-guard-plugin.test.ts index 7666dc217..47736f8cf 100644 --- a/src/plugins/shell-guard-plugin.test.ts +++ b/src/plugins/shell-guard-plugin.test.ts @@ -10,9 +10,12 @@ import { spawnSync, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createBackgroundShellRegistry } from "../shell/background-shell.js"; +import { createShellOutputFeed } from "../session/shell-output-feed.js"; + import { BoundedShellOutput, MAX_SHELL_OUTPUT_BYTES, + SHELL_FEED_EMIT_MS, advertiseShellGuardTimeout, resolveShellTimeoutMs, reapLiveChildren, @@ -37,6 +40,35 @@ describe("runGuardedShell", () => { expect(output).toContain("hello"); }); + test("a rate-limited second write reaches the feed before the process exits", async () => { + const feed = createShellOutputFeed(); + let finished = false; + const running = runGuardedShell( + { command: "echo first; sleep 0.02; echo second; sleep 0.4" }, + neverAbort(), + undefined, + undefined, + (text) => { + feed.append(text); + }, + ).then((result) => { + finished = true; + return result; + }); + const deadline = Date.now() + SHELL_FEED_EMIT_MS + 80; + while ( + !feed.snapshot().includes("second") && + Date.now() < deadline && + !finished + ) { + await Bun.sleep(10); + } + expect(finished).toBe(false); + expect(feed.snapshot()).toContain("second"); + const result = await running; + expect(result.exitCode).toBe(0); + }); + test("omitted timeout does not arm a timer", async () => { const start = Date.now(); const { exitCode, timedOut, output } = await runGuardedShell( @@ -309,6 +341,66 @@ describe("background run_shell (shellGuardPlugin)", () => { expect(registry.runningCount()).toBe(0); registry.disposeAll("test done"); }); + + test("an unwired shell-output feed spawns fine and paints no tail", async () => { + const handler = defined( + shellGuardPlugin(process.cwd(), undefined, undefined, {}).middleware, + )(fallback); + const result = await handler( + { id: "fg2", name: "run_shell", arguments: { command: "echo hi" } }, + neverAbort(), + ); + expect(result.isError).toBeUndefined(); + expect(String(result.content)).toContain("hi"); + }); + + test("a wired feed receives the output tail at cadence with a final flush", async () => { + const feed = createShellOutputFeed(); + let emits = 0; + const handler = defined( + shellGuardPlugin(process.cwd(), undefined, undefined, { + getShellOutputFeeds: () => { + const wrapped = { + append: (text: string) => { + emits += 1; + feed.append(text); + }, + snapshot: () => feed.snapshot(), + clear: () => feed.clear(), + }; + return { + forCall: () => wrapped, + get: () => wrapped, + drop: () => undefined, + }; + }, + }).middleware, + )(fallback); + const result = await handler( + { + id: "fg3", + name: "run_shell", + arguments: { + // Fifteen lines ~10 ms apart: far more chunk arrivals than one + // cadence window per 100 ms can allow. Without the Date.now() gate + // in emitPendingOutput every arrival emits (~16 emissions) and this + // ceiling fails — the assertion is what pins the cadence. + command: + "i=1; while [ $i -le 15 ]; do echo line$i; sleep 0.01; i=$((i+1)); done", + }, + }, + neverAbort(), + ); + expect(result.isError).toBeUndefined(); + // The final flush lands the tail (including the last line) in the feed. + expect(feed.snapshot()).toContain("line1"); + expect(feed.snapshot()).toContain("line15"); + // At most one emit per 100 ms of wall time (~300 ms with the pwd probe + // trailer), plus the final flush. Still far below the ~16 arrivals, so a + // broken cadence gate cannot pass. + const elapsedMs = 350; + expect(emits).toBeLessThanOrEqual(Math.ceil(elapsedMs / 100) + 1); + }); }); describe("advertiseShellGuardTimeout", () => { diff --git a/src/plugins/shell-guard-plugin.ts b/src/plugins/shell-guard-plugin.ts index 23449f303..584420a51 100644 --- a/src/plugins/shell-guard-plugin.ts +++ b/src/plugins/shell-guard-plugin.ts @@ -1,6 +1,8 @@ import { spawn, type ChildProcess } from "node:child_process"; import { realpathSync } from "node:fs"; +import { StringDecoder } from "node:string_decoder"; import type { ToolPlugin } from "@intx/tools-posix"; +import type { ShellOutputFeedMap } from "../session/shell-output-feed.js"; import { killProcessTree, type BackgroundShellRegistry, @@ -25,6 +27,10 @@ import { } from "../shell/persistent-shell-cwd.js"; // Corbits Code-side replacement for stock `@intx/tools-posix` run_shell. + +/** Minimum interval between live shell-tail emits to the transcript feed. */ +export const SHELL_FEED_EMIT_MS = 100; + // We do not patch interchange: this middleware short-circuits run_shell and // enforces an optional timeout (no built-in default — match Pi), an // output-byte cap, and process-group kill so open-ended walks cannot OOM the host. @@ -277,6 +283,7 @@ export async function runGuardedShell( signal: AbortSignal, liveChildren?: Set, isDisposed?: () => boolean, + onOutput?: (text: string) => void, ): Promise { signal.throwIfAborted(); if (isDisposed?.()) { @@ -314,9 +321,43 @@ export async function runGuardedShell( let settled = false; let timer: ReturnType | undefined; - + let emitTimer: ReturnType | undefined; + + // Live-output cadence for the transcript's shell tail (when wired). + const decoder = new StringDecoder("utf8"); + let pendingOutput = ""; + let lastEmitAt = 0; + const clearEmitTimer = (): void => { + if (emitTimer !== undefined) { + clearTimeout(emitTimer); + emitTimer = undefined; + } + }; + const emitPendingOutput = (final: boolean): void => { + if (onOutput === undefined || pendingOutput.length === 0) { + if (final) clearEmitTimer(); + return; + } + if (!final) { + const wait = SHELL_FEED_EMIT_MS - (Date.now() - lastEmitAt); + if (wait > 0) { + if (emitTimer === undefined) { + emitTimer = setTimeout(() => { + emitTimer = undefined; + emitPendingOutput(false); + }, wait); + } + return; + } + } + clearEmitTimer(); + lastEmitAt = Date.now(); + onOutput(pendingOutput); + pendingOutput = ""; + }; const clearTimer = () => { if (timer !== undefined) clearTimeout(timer); + clearEmitTimer(); }; const settle = (err?: Error) => { @@ -334,6 +375,8 @@ export async function runGuardedShell( settled = true; clearTimer(); abortCleanup(); + pendingOutput += decoder.end(); + emitPendingOutput(true); const { output, truncated } = collector.build(); resolve({ output, @@ -348,6 +391,12 @@ export async function runGuardedShell( // Switch to head+tail collection when over cap; keep the process running so // the command can finish and its true exit code is preserved. collector.append(chunk); + // Live tail for the transcript: buffered between emits so the feed sees + // at most one update per SHELL_FEED_EMIT_MS (plus a final flush at + // settle). Lossless — dropped cadence windows accumulate, they do not + // skip text. + pendingOutput += decoder.write(chunk); + emitPendingOutput(false); }; // Interleave stdout and stderr in arrival order into one collector. @@ -410,6 +459,9 @@ export interface ShellGuardPluginOptions { // Live getter for the background-shell registry. Unwired (undefined result) // makes `background: true` fail closed: nothing spawns, no handle returns. getBackgroundShellRegistry?: () => BackgroundShellRegistry | undefined; + // Live getter for the per-call bounded shell-output feeds the transcript + // polls for a running command's live tail. Unwired, the tail is simply not painted. + getShellOutputFeeds?: () => ShellOutputFeedMap | undefined; } function resolveAllowOutsideCwd( @@ -540,6 +592,8 @@ export function shellGuardPlugin( }; } const wrappedCommand = wrapCommandWithPwdProbe(command); + const feeds = options.getShellOutputFeeds?.(); + const feed = feeds?.forCall(call.id); try { const { output, exitCode, timedOut, outputTruncated } = await runGuardedShell( @@ -555,6 +609,11 @@ export function shellGuardPlugin( signal, liveChildren, () => disposed, + feed !== undefined + ? (text) => { + feed.append(text); + } + : undefined, ); const parsed = parsePwdProbeOutput(output); if (perCallCwdRaw === undefined && parsed.finalCwd !== undefined) { @@ -591,6 +650,8 @@ export function shellGuardPlugin( content: err instanceof Error ? err.message : String(err), isError: true, }; + } finally { + feeds?.drop(call.id); } }).catch((err: unknown) => ({ callId: call.id, diff --git a/src/session/shell-output-feed.test.ts b/src/session/shell-output-feed.test.ts new file mode 100644 index 000000000..672ae301a --- /dev/null +++ b/src/session/shell-output-feed.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; + +import { + createShellOutputFeed, + createShellOutputFeedMap, + SHELL_FEED_LIMIT_BYTES, +} from "./shell-output-feed.js"; + +describe("shell output feed", () => { + test("snapshots appended chunks in order", () => { + const feed = createShellOutputFeed(); + feed.append("hello "); + feed.append("world\n"); + expect(feed.snapshot()).toBe("hello world\n"); + feed.clear(); + expect(feed.snapshot()).toBe(""); + }); + + test("keeps the tail under a burst larger than the bound", () => { + const feed = createShellOutputFeed(1024); + const burst = Array.from( + { length: 128 }, + (_, i) => `line ${i} padding\n`, + ).join(""); + expect(burst.length).toBeGreaterThan(1024); + feed.append(burst); + const snapshot = feed.snapshot(); + expect(new TextEncoder().encode(snapshot).length).toBeLessThanOrEqual(1024); + // The oldest lines are the ones dropped: the snapshot ends at the newest. + expect(snapshot.endsWith("line 127 padding\n")).toBe(true); + }); + + test("the default bound is 8 KiB", () => { + const feed = createShellOutputFeed(); + feed.append("x".repeat(SHELL_FEED_LIMIT_BYTES + 1)); + expect( + new TextEncoder().encode(feed.snapshot()).length, + ).toBeLessThanOrEqual(SHELL_FEED_LIMIT_BYTES); + }); + + test("empty appends change nothing", () => { + const feed = createShellOutputFeed(); + feed.append(""); + expect(feed.snapshot()).toBe(""); + }); +}); + +describe("shell output feed map", () => { + test("isolates tails per call and keeps the 8 KiB bound on each", () => { + const feeds = createShellOutputFeedMap(); + const a = feeds.forCall("a"); + const b = feeds.forCall("b"); + a.append("alpha\n"); + b.append("beta\n"); + expect(feeds.get("a")?.snapshot()).toBe("alpha\n"); + expect(feeds.get("b")?.snapshot()).toBe("beta\n"); + a.append("x".repeat(SHELL_FEED_LIMIT_BYTES + 1)); + expect(new TextEncoder().encode(a.snapshot()).length).toBeLessThanOrEqual( + SHELL_FEED_LIMIT_BYTES, + ); + expect(feeds.get("b")?.snapshot()).toBe("beta\n"); + feeds.drop("a"); + expect(feeds.get("a")).toBeUndefined(); + expect(feeds.get("b")?.snapshot()).toBe("beta\n"); + }); +}); diff --git a/src/session/shell-output-feed.ts b/src/session/shell-output-feed.ts new file mode 100644 index 000000000..7dfd86e8f --- /dev/null +++ b/src/session/shell-output-feed.ts @@ -0,0 +1,89 @@ +/** + * Bounded live-output tail of a running shell command. + * + * Pure and synchronous: the shell-guard plugin appends output chunks, the TUI + * polls `snapshot()` on its sticky tick and paints the tail onto the pending + * `run_shell` row that owns that call. Nothing here is persisted; the whole + * feed is a display affordance for the wait. + */ + +/** Tail of output a feed keeps before the oldest chunk is dropped. */ +export const SHELL_FEED_LIMIT_BYTES = 8 * 1024; + +export interface ShellOutputFeed { + append(chunk: string): void; + /** The retained tail, oldest first. Empty string when nothing has landed. */ + snapshot(): string; + /** Drop everything retained in this feed. */ + clear(): void; +} + +/** Per-call live tails so parallel run_shells cannot share or wipe a sibling. */ +export interface ShellOutputFeedMap { + forCall(callId: string): ShellOutputFeed; + get(callId: string): ShellOutputFeed | undefined; + drop(callId: string): void; +} + +function byteLength(text: string): number { + return new TextEncoder().encode(text).length; +} + +export function createShellOutputFeed( + limitBytes: number = SHELL_FEED_LIMIT_BYTES, +): ShellOutputFeed { + let chunks: string[] = []; + let totalBytes = 0; + const recompute = (): void => { + while (totalBytes > limitBytes && chunks.length > 1) { + const dropped = chunks.shift(); + totalBytes -= dropped === undefined ? 0 : byteLength(dropped); + } + // A single chunk over the limit is truncated to its tail so the bound + // holds even when one write exceeds it. + if (totalBytes > limitBytes && chunks.length === 1) { + const only = chunks[0] ?? ""; + let cut = only; + while (cut.length > 0 && byteLength(cut) > limitBytes) { + cut = cut.slice(Math.ceil(cut.length / 2)); + } + chunks = [cut]; + totalBytes = byteLength(cut); + } + }; + return { + append(chunk: string): void { + if (chunk.length === 0) return; + chunks.push(chunk); + totalBytes += byteLength(chunk); + recompute(); + }, + snapshot(): string { + return chunks.join(""); + }, + clear(): void { + chunks = []; + totalBytes = 0; + }, + }; +} + +export function createShellOutputFeedMap(): ShellOutputFeedMap { + const feeds = new Map(); + return { + forCall(callId: string): ShellOutputFeed { + let feed = feeds.get(callId); + if (feed === undefined) { + feed = createShellOutputFeed(); + feeds.set(callId, feed); + } + return feed; + }, + get(callId: string): ShellOutputFeed | undefined { + return feeds.get(callId); + }, + drop(callId: string): void { + feeds.delete(callId); + }, + }; +} diff --git a/src/subagent/run.ts b/src/subagent/run.ts index d4de7e155..69a861bf4 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -22,6 +22,7 @@ import { workerPermissionGate, } from "../permission/reactor-authorize.js"; import { createSessionStores } from "../session/optimized-context-store.js"; +import { createShellOutputFeedMap } from "../session/shell-output-feed.js"; import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js"; import { type } from "arktype"; import { createPosixTools } from "@intx/tools-posix"; @@ -597,6 +598,9 @@ async function runSubAgentInner( let childBlobReader: BlobReader | undefined; let childBlobWriter: SpillBlobWriter | undefined; let childContextDir: string | undefined; + // Per-call live shell tails for this worker's transcript; nothing polls them + // unless a transcript host does (silent degradation). + const childShellOutputFeed = createShellOutputFeedMap(); const sessionBlobReader = createCompositeBlobReader( () => childBlobReader, params.getBlobReader, @@ -613,6 +617,7 @@ async function runSubAgentInner( ...(params.shellEnv !== undefined ? { shellEnv: params.shellEnv } : {}), readFileGuard: { blobReader: sessionBlobReader }, getBackgroundShellRegistry: () => backgroundShells, + getShellOutputFeeds: () => childShellOutputFeed, getBlobWriter: () => childBlobWriter, getContextDir: () => childContextDir, extraToolPlugins: [ diff --git a/src/tui/collapse.test.ts b/src/tui/collapse.test.ts index cc202ca46..90eac1e9d 100644 --- a/src/tui/collapse.test.ts +++ b/src/tui/collapse.test.ts @@ -4,9 +4,11 @@ * shared gutter on its way there. */ import { describe, expect, test } from "bun:test"; +import { defined } from "../../tests/helpers/defined.js"; import { toolCallRow } from "./diff"; import { resolveSideMargin } from "./geometry/margins"; import { withTestRenderer } from "./harness"; +import { pushToolCall, pushToolResult } from "./tool-rows"; import { appendStreamRow, toggleCollapsedRow, @@ -18,6 +20,7 @@ import { EXPAND_HINT_LABEL, isCollapsibleRow, paintStreamRow, + toolRowLines, type RowLayout, type StreamRow, } from "./stream"; @@ -177,6 +180,39 @@ describe("tool arguments collapse to a human summary", () => { }, ); }); + + test("a settled shell preview row toggles: expanded hides the preview, collapsed restores it", async () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "run_shell", + arguments: JSON.stringify({ command: "make test" }), + }); + pushToolResult(rows, { + name: "run_shell", + content: "ok\nline2\nline3\nline4\nline5", + }); + const row = defined(rows[0]); + expect(row.previewLines?.length).toBe(4); + await paint([row], 80, (frame, shell) => { + // Collapsed: the preview tail and its elision marker paint. + expect(frame).toContain("line3"); + expect(frame).toContain("⋯ +2 lines"); + shellFocusTranscript(shell); + expect(toggleCollapsedRow(shell)).toBe(true); + expect(shell.streamLog[0]?.expanded).toBe(true); + // Expanded: the full output replaces the preview tail and marker. + const expandedLines = toolRowLines({ ...row, expanded: true }) + .map((line) => line.map((segment) => segment.text).join("")) + .join("\n"); + expect(expandedLines).toContain("ok"); + expect(expandedLines).toContain("line5"); + }); + await paint([{ ...row, expanded: true }], 80, (_frame, shell) => { + shellFocusTranscript(shell); + expect(toggleCollapsedRow(shell)).toBe(true); + expect(shell.streamLog[0]?.expanded).not.toBe(true); + }); + }); }); describe("reasoning collapses to a short wrapped preview", () => { diff --git a/src/tui/copy-path.test.ts b/src/tui/copy-path.test.ts index ffc8bcfc5..c013ac2a1 100644 --- a/src/tui/copy-path.test.ts +++ b/src/tui/copy-path.test.ts @@ -122,6 +122,22 @@ describe("formatCopyText / copyStreamRow", () => { expect(payload.kind).toBe("tool"); }); + test("a lane copies the most recent call's full output", () => { + const port = createRecordingClipboard(); + const payload = defined( + copyStreamRow( + { + role: "tool", + text: '{"pattern":"a"}', + meta: "grep", + resultText: "newest output\nmore", + }, + port, + ), + ); + expect(payload.text).toBe("[grep] newest output\nmore"); + }); + test("null when no row", () => { const port = createRecordingClipboard(); expect(copyStreamRow(null, port)).toBeNull(); diff --git a/src/tui/copy-path.ts b/src/tui/copy-path.ts index 795e99643..7a9471b03 100644 --- a/src/tui/copy-path.ts +++ b/src/tui/copy-path.ts @@ -118,7 +118,9 @@ export function formatCopyText(row: StreamRow): CopyPayload { let text: string; switch (kind) { case "tool": - text = meta + row.text; + // A coalesced lane copies the most recent call's full output; single + // rows carry that in `text` already. + text = meta + (row.resultText ?? row.text); break; case "diff": // Rendered edit rows copy the diff itself, not the raw JSON arguments. diff --git a/src/tui/diff.ts b/src/tui/diff.ts index 6777de106..7335d5052 100644 --- a/src/tui/diff.ts +++ b/src/tui/diff.ts @@ -535,6 +535,7 @@ export function toolCallRow(input: ToolCallRowInput): StreamRow { role: "tool", text, meta, + toolName: input.name, pending: true, callKey, ...(input.callId !== undefined ? { callId: input.callId } : {}), diff --git a/src/tui/history-hydrate.test.ts b/src/tui/history-hydrate.test.ts index 980be8176..2990e34c6 100644 --- a/src/tui/history-hydrate.test.ts +++ b/src/tui/history-hydrate.test.ts @@ -41,6 +41,7 @@ describe("rowFromHistoryBlock", () => { role: "tool", text: "pattern x", meta: "grep", + toolName: "grep", verb: "Grep", summary: "pattern x", pending: true, @@ -62,6 +63,7 @@ describe("rowFromHistoryBlock", () => { role: "tool", text: "…", meta: "tool", + toolName: "tool", pending: true, callKey: "tool ", }); @@ -75,14 +77,20 @@ describe("rowFromHistoryBlock", () => { content: "ok", isError: false, }), - ).toEqual({ role: "tool", text: "ok", meta: "bash" }); + ).toEqual({ role: "tool", text: "ok", meta: "bash", toolName: "bash" }); expect( rowFromHistoryBlock({ type: "tool_result", name: "bash", isError: true, }), - ).toEqual({ role: "tool", text: "error", meta: "bash", failed: true }); + ).toEqual({ + role: "tool", + text: "error", + meta: "bash", + toolName: "bash", + failed: true, + }); }); test("error and unknown", () => { @@ -254,6 +262,38 @@ describe("hydrateHistoryRows", () => { expect(rows.map((r) => r.text)).toEqual(["done c1", "done c2", "done c3"]); }); + test("reconstructs a lane of consecutive greps with memberIds result pairing", () => { + const rows = hydrateHistoryRows([ + { + type: "tool_call", + name: "grep", + arguments: '{"pattern":"a"}', + callId: "g1", + }, + { + type: "tool_call", + name: "grep", + arguments: '{"pattern":"b"}', + callId: "g2", + }, + { + type: "tool_call", + name: "grep", + arguments: '{"pattern":"c"}', + callId: "g3", + }, + { type: "tool_result", name: "grep", content: "3 lines", callId: "g1" }, + { type: "tool_result", name: "grep", content: "5 lines", callId: "g2" }, + { type: "tool_result", name: "grep", content: "7 lines", callId: "g3" }, + ]); + expect(rows.length).toBe(1); + expect(rows[0]?.coalesced).toBe(true); + expect(rows[0]?.callCount).toBe(3); + expect(rows[0]?.memberIds).toEqual(["g1", "g2", "g3"]); + expect(rows[0]?.pending).toBeUndefined(); + expect(rows[0]?.detail?.length).toBe(3); + }); + test("non-array returns empty", () => { expect(hydrateHistoryRows(undefined)).toEqual([]); expect(hydrateHistoryRows(null)).toEqual([]); diff --git a/src/tui/mcp-view.ts b/src/tui/mcp-view.ts index 476efe0a9..6d19013b9 100644 --- a/src/tui/mcp-view.ts +++ b/src/tui/mcp-view.ts @@ -517,6 +517,7 @@ export function toolResultRow(input: ToolResultRowInput): StreamRow { role: "tool" as const, text: input.content, meta: input.name, + toolName: input.name, ...(input.callId !== undefined ? { callId: input.callId } : {}), }; if (failed) return { ...base, failed: true }; diff --git a/src/tui/observe-live.test.ts b/src/tui/observe-live.test.ts index 93bcb0243..19e75ec96 100644 --- a/src/tui/observe-live.test.ts +++ b/src/tui/observe-live.test.ts @@ -419,6 +419,7 @@ describe("observe pure mappers", () => { role: "tool", text: "ls", meta: "bash", + toolName: "bash", verb: "Bash", summary: "ls", pending: true, @@ -431,7 +432,13 @@ describe("observe pure mappers", () => { detail: "out", isError: true, }), - ).toEqual({ role: "tool", text: "out", meta: "bash", failed: true }); + ).toEqual({ + role: "tool", + text: "out", + meta: "bash", + toolName: "bash", + failed: true, + }); expect(rowFromBridgeEvent({ type: "system", text: "s" })).toEqual({ role: "system", text: "s", @@ -478,6 +485,7 @@ describe("observe pure mappers", () => { role: "tool", text: "a.ts", meta: "read_file", + toolName: "read_file", verb: "Read", summary: "a.ts", pending: true, diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index a54a6be6c..f75350331 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -15,6 +15,7 @@ import { type TaskProgressSession, type TurnMonitorOptions, } from "./runtime-bridge.js"; +import type { ShellOutputFeed } from "../session/shell-output-feed.js"; import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js"; import { wireGates } from "./gate-wire.js"; import { createSystemClipboard } from "./system-clipboard.js"; @@ -177,6 +178,13 @@ export interface ProductHostConfig { * Omitted hosts (tests, the demo shell) simply paint bare pending rows. */ readonly subAgentSessions?: () => readonly TaskProgressSession[]; + /** + * Per-call bounded shell-output feeds, polled on the same sticky tick to + * paint a running command's live output tail onto the pending row that owns + * that call. Omitted hosts (tests, the demo shell) paint pending shell rows + * without a tail. + */ + readonly shellOutputFeed?: (callId: string) => ShellOutputFeed | undefined; /** * Renderer factory override for headless mounting in tests. * Defaults to the real `createCliRenderer`; tests inject a @@ -382,6 +390,9 @@ export async function mountProductHost( if (config.subAgentSessions !== undefined && !stickyNeeded) { bridge.syncAgentProgress(config.subAgentSessions()); } + // Live shell tail: deduped in the bridge, so an unchanged snapshot is a + // no-op and this poll cadence (200 ms) is the paint cadence. + bridge.syncShellOutputs(config.shellOutputFeed); // Elapsed clock, stall flip, and post-finish linger are wall-time — repaint // the strip on this tick while sticky is needed. paintChromeZones re-enters // setChromeZones (which may paintChrome again on an unchanged-zone path), diff --git a/src/tui/row-update-perf.test.ts b/src/tui/row-update-perf.test.ts index b796fb346..e8b6cc073 100644 --- a/src/tui/row-update-perf.test.ts +++ b/src/tui/row-update-perf.test.ts @@ -130,6 +130,7 @@ describe("row update perf gates (J3)", () => { const row = streamRowAt(shell, 0); expect(row?.coalesced).toBe(true); expect(row?.outstanding).toBe(5); + expect(row?.callCount).toBe(5); // An idle frame applies nothing further. await h.renderOnce(); expect(work.replaces).toBe(1); diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index f9f73afb2..ff483d651 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -92,6 +92,7 @@ describe("rowFromTranscriptEntry", () => { role: "tool", text: "{}", meta: "grep", + toolName: "grep", verb: "Grep", // Empty summary is intentional: without it the paint layer falls through // to raw argument JSON (CL-5762). Verb alone names the call. @@ -112,6 +113,7 @@ describe("rowFromTranscriptEntry", () => { role: "tool", text: "boom", meta: "grep", + toolName: "grep", failed: true, callId: "c", }); diff --git a/src/tui/runner/host.ts b/src/tui/runner/host.ts index 034e36a3b..9b084d017 100644 --- a/src/tui/runner/host.ts +++ b/src/tui/runner/host.ts @@ -59,6 +59,7 @@ import { toolResultRow } from "../mcp-view.js"; import { pushToolCall, pushToolResult } from "../tool-rows.js"; import type { StreamRow } from "../stream.js"; import type { QueueKind } from "../session-queue.js"; +import type { ShellOutputFeed } from "../../session/shell-output-feed.js"; export interface RunnerHostDeps { readonly title: string; @@ -142,6 +143,8 @@ export interface RunnerHostDeps { readonly subscribeChrome: (notify: () => void) => () => void; /** Live subagent sessions for the palette observe action. */ readonly subAgentSessions: () => readonly SubAgentSession[]; + /** Per-call bounded live shell-output feeds for the transcript tail. */ + readonly shellOutputFeed?: (callId: string) => ShellOutputFeed | undefined; /** * Live data behind the command surfaces (settings, permissions, plugins). * `notify` is supplied by the host itself. @@ -332,6 +335,9 @@ export async function mountRunnerHost( lastActivityAt: s.lastActivityAt, ...(s.runInFlight !== undefined ? { runInFlight: s.runInFlight } : {}), })), + ...(deps.shellOutputFeed !== undefined + ? { shellOutputFeed: deps.shellOutputFeed } + : {}), ...(deps.createRenderer !== undefined ? { createRenderer: deps.createRenderer } : {}), diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index 01d55b8d4..380e1dba1 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -199,6 +199,7 @@ export async function runTUI(initialConfig: Config): Promise { }; }, subAgentSessions: () => services.subAgentSessions.list(), + shellOutputFeed: (callId) => services.toolset.shellOutputFeed.get(callId), surfaces: { permissions: settings.surfaces.permissions, plugins: settings.surfaces.plugins, diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 71a1612e0..5a9cd0ff5 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -78,8 +78,10 @@ import { canCoalesceCall, coalesceCallRows, mergeToolRows, + shellPreviewLines, } from "./tool-rows.js"; import * as rowUpdates from "./row-update-queue.js"; +import type { ShellOutputFeed } from "../session/shell-output-feed.js"; import type { StreamRow } from "./stream.js"; import { advanceRevealChars, @@ -240,6 +242,14 @@ export interface SessionBridge { * or similar) on whatever cadence it already polls at. */ syncAgentProgress: (sessions: readonly TaskProgressSession[]) => void; + /** + * Paint the live output tail of each in-flight `run_shell` from that call's + * bounded feed, frame-coalesced. Undefined lookup (or no wired feed at the + * host) leaves pending rows untouched. + */ + syncShellOutputs: ( + feedFor: ((callId: string) => ShellOutputFeed | undefined) | undefined, + ) => void; /** * Stamp the live catalog provider id onto the stream map context so * `inference.error` transcript lines can identify known-xAI short 429s @@ -507,6 +517,11 @@ export interface BridgeBag { * length of a slow call — the one case a healthy turn reads as dead. */ toolCallStartedAt: Map; + /** + * Last live shell tail painted per in-flight call, so an unchanged feed + * snapshot applies no row update. + */ + shellSnapshots: Map; /** Row of the newest in-flight call, for results that carry no call id. */ lastToolRow: number; /** @@ -889,8 +904,8 @@ export function flushStreamRowUpdates(shell: AppShell): void { } /** - * Paint a tool call. A repeat of the call the previous row already painted - * collapses onto that row instead of opening a new one. + * Paint a tool call. A consecutive call to the same raw toolName collapses + * onto the previous row instead of opening a new one. */ function applyToolCall( shell: AppShell, @@ -907,6 +922,7 @@ function applyToolCall( const row = toolCallRow({ name: event.name, ...(event.detail !== undefined ? { arguments: event.detail } : {}), + ...(event.callId !== undefined ? { callId: event.callId } : {}), }); const count = streamRowCount(shell); const tail = streamRowAt(shell, count - 1); @@ -962,6 +978,7 @@ function applyToolResult( if (event.callId !== undefined) { bag.toolRows.delete(event.callId); bag.toolCallStartedAt.delete(event.callId); + bag.shellSnapshots.delete(event.callId); // spawn_agent's immediate running JSON is not the end of the worker — // keep the row in taskCallIds / spawnProgressRows until the session // leaves the running set (see syncAgentProgress). @@ -971,6 +988,14 @@ function applyToolResult( } } if (bag.toolRows.size === 0) shell.inFlightTool = null; + // An id that matches nothing on the log answers nothing: it is appended as + // its own row rather than folding onto whichever row happens to be last + // (the spec's never-misattribute rule). Only id-less results — saved + // history from before ids existed — keep the newest-row fallback. + if (tracked === undefined && event.callId !== undefined) { + appendStreamRow(shell, result); + return; + } const index = tracked ?? bag.lastToolRow; // A close seam: apply any coalesced update first so the merge reads it. const rawCall = @@ -1037,6 +1062,41 @@ function omitStat(row: StreamRow): StreamRow { return rest; } +/** + * Paint the live tail of each running `run_shell` onto the pending row that + * owns that call, frame-coalesced. `feedFor` looks up the call's bounded + * feed; when it is not wired the row renders exactly as before. + * `shellSnapshots` dedupes so an unchanged snapshot applies nothing. + */ +function syncShellOutputs( + shell: AppShell, + bag: BridgeBag, + feedFor: ((callId: string) => ShellOutputFeed | undefined) | undefined, +): void { + if (bag.disposed || feedFor === undefined || bag.toolRows.size === 0) return; + for (const [callId, index] of bag.toolRows) { + const row = bag.pendingRowUpdates.get(index) ?? streamRowAt(shell, index); + if (row === undefined || row.pending !== true) continue; + if (row.toolName !== "run_shell") continue; + const feed = feedFor(callId); + if (feed === undefined) continue; + const preview = shellPreviewLines(feed.snapshot()) ?? []; + const key = preview.join("\n"); + if (bag.shellSnapshots.get(callId) === key) continue; + bag.shellSnapshots.set(callId, key); + // Consecutive in-flight shells share a lane. An empty sibling snapshot + // must not clear a tail another member already painted. + if ( + preview.length === 0 && + row.previewLines !== undefined && + row.previewLines.length > 0 + ) { + continue; + } + rowUpdates.scheduleRowUpdate(bag, index, { ...row, previewLines: preview }); + } +} + /** * Refresh every plain in-flight tool call's row with how long it has been * running, frame-coalesced. `spawn_agent` dispatches already get this (and @@ -1098,6 +1158,7 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { bag.toolCallStartedAt.delete(callId); bag.taskCallIds.delete(callId); bag.spawnProgressRows.delete(callId); + bag.shellSnapshots.delete(callId); } } if (bag.lastToolRow >= boundary) bag.lastToolRow = -1; @@ -1338,6 +1399,7 @@ export function attachSessionBridge( now, toolRows: new Map(), toolCallStartedAt: new Map(), + shellSnapshots: new Map(), lastToolRow: -1, taskCallIds: new Set(), spawnProgressRows: new Map(), @@ -1869,6 +1931,9 @@ export function attachSessionBridge( bag.agentSessions = sessions; syncAgentProgress(shell, bag, sessions, now()); }, + syncShellOutputs: (feedFor) => { + syncShellOutputs(shell, bag, feedFor); + }, setInferenceProviderId: (id, displayLabel) => { if (bag.disposed) return; if (id === undefined) { diff --git a/src/tui/stream.ts b/src/tui/stream.ts index 307ef26cb..d01c2ba5c 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -14,6 +14,7 @@ import { type Thought, } from "./thinking.js"; import { UI } from "./theme.js"; +import { pastTenseToolLabel } from "./tool-formatter.js"; /** * One line of a pre-coloured body (an expanded tool call's structured @@ -146,6 +147,33 @@ export interface StreamRow { readonly verb?: string; /** Diff stat or line range painted dim after the subject, e.g. "+1/-0". */ readonly stat?: string; + /** + * Raw tool identity of a tool row — the lane grouping key. Unlike `meta` + * (composed with path/stat on some rows), this is always the bare name. + */ + readonly toolName?: string; + /** + * Calls folded into a tool lane. Absent or 1 paints the single-call row + * exactly as before lanes. + */ + readonly callCount?: number; + /** + * Call ids a lane absorbed (newest appended). Lets a result + * resolve its lane by id even though the lane's own `callId` moved to the + * newest call. + */ + readonly memberIds?: readonly string[]; + /** + * Most recent result's full text on a coalesced lane — the Alt+C copy + * source. Single rows copy `text` as before. + */ + readonly resultText?: string; + /** + * Shell output preview painted collapsed between head and expand hint: + * up to three tail lines plus the elision marker. Hidden when expanded. + * Never persisted. + */ + readonly previewLines?: readonly string[]; /** * A dispatched sub-agent's row while its worker is still running: true * once it has reported activity within the stall window, false once the @@ -618,15 +646,39 @@ export function toolSentenceLines( const subject = row.summary ?? row.text; // A verb that already names the whole call ("Linear: list issues") has no // subject to pair with, and a lone subject (a result sentence) has no verb. + const laneCount = row.callCount; + const laneSettled = + laneCount !== undefined && laneCount > 1 && row.pending !== true; + // A settled lane rewrites its head to past tense over the latest subject + // ("Grepped ×3 · pattern"); a pending lane keeps narrating the newest call. const head = - verb.length === 0 ? "" : subject.length === 0 ? verb : `${verb} `; + laneSettled && row.toolName !== undefined + ? `${pastTenseToolLabel(row.toolName)} ×${laneCount} · ` + : verb.length === 0 + ? "" + : subject.length === 0 + ? verb + : `${verb} `; + const chip = + laneCount !== undefined && laneCount > 1 && row.pending === true + ? ` · ×${laneCount}` + : ""; const stat = row.stat !== undefined && row.stat.length > 0 ? ` ${row.stat}` : ""; - const arrow = toolArrow(row); + // A collapsed row with a shell preview paints the arrow after the preview + // lines (toolRowLines), not on the head. + const suppressArrow = + row.previewLines !== undefined && + row.previewLines.length > 0 && + row.expanded !== true; + const arrow = suppressArrow ? "" : toolArrow(row); // Columns, not code units: the arrow is itself an ambiguous-width glyph and // this number is subtracted from the same budget `stringWidth(head)` is. const arrowWidth = stringWidth(arrow); - const trailer = stringWidth(stat) + (arrowWidth > 0 ? arrowWidth + 1 : 0); + const trailer = + stringWidth(stat) + + stringWidth(chip) + + (arrowWidth > 0 ? arrowWidth + 1 : 0); const segments = shellChainSegments( columns === undefined || subject.includes(" && ") ? subject @@ -640,7 +692,9 @@ export function toolSentenceLines( const chain: StyledBodyLine = isLast ? [] : [{ text: " && \\", fg: UI.textDim }]; - return [...lead, ...body, ...chain]; + const chipSegment: StyledBodyLine = + isLast && chip.length > 0 ? [{ text: chip, fg: UI.textDim }] : []; + return [...lead, ...body, ...chipSegment, ...chain]; }); const last = lines[lines.length - 1] ?? []; const statSegment = stat.length > 0 ? [{ text: stat, fg: UI.textDim }] : []; @@ -668,6 +722,29 @@ export function toolRowLines( columns?: number, ): StyledBodyLine[] { const head = toolSentenceLines(row, columns); + if (row.expanded !== true && row.previewLines !== undefined) { + // Shell settle preview: dim tail lines between the head and the expand + // arrow, width-truncated to the columns left beside the indent. + const preview = row.previewLines; + const arrow = toolArrow(row); + return [ + ...head, + ...preview.map((line, i) => { + const text = + columns !== undefined + ? truncateLine(line, columns - TOOL_DETAIL_INDENT) + : line; + const segments: StyledBodyLine = + i === preview.length - 1 && arrow.length > 0 + ? [ + { text, fg: UI.textDim }, + { text: ` ${arrow}`, fg: UI.textDim }, + ] + : [{ text, fg: UI.textDim }]; + return indentStyledLine(segments, TOOL_DETAIL_INDENT); + }), + ]; + } if (row.expanded !== true) return head; const tail = row.diff !== undefined diff --git a/src/tui/tool-formatter.test.ts b/src/tui/tool-formatter.test.ts index 930d36257..4c2c1e7a1 100644 --- a/src/tui/tool-formatter.test.ts +++ b/src/tui/tool-formatter.test.ts @@ -6,6 +6,7 @@ import { isUserFacingJSON, describeToolCall, humanizeToolName, + pastTenseToolLabel, } from "./tool-formatter.js"; describe("humanizeToolName", () => { @@ -548,3 +549,22 @@ describe("spawn_agent activity transcript lines", () => { expect(line).not.toContain("## Summary"); }); }); + +describe("pastTenseToolLabel", () => { + test("maps known raw tool names", () => { + expect(pastTenseToolLabel("grep")).toBe("Grepped"); + expect(pastTenseToolLabel("read_file")).toBe("Read"); + expect(pastTenseToolLabel("write_file")).toBe("Wrote"); + expect(pastTenseToolLabel("edit_file")).toBe("Edited"); + expect(pastTenseToolLabel("run_shell")).toBe("Ran"); + expect(pastTenseToolLabel("list_dir")).toBe("Listed"); + expect(pastTenseToolLabel("search_files")).toBe("Searched"); + expect(pastTenseToolLabel("delete_file")).toBe("Deleted"); + }); + + test("falls back to the display name for unknown tools", () => { + expect(pastTenseToolLabel("totally_unknown_tool")).toBe( + "Totally Unknown Tool", + ); + }); +}); diff --git a/src/tui/tool-formatter.ts b/src/tui/tool-formatter.ts index b074de574..468b12277 100644 --- a/src/tui/tool-formatter.ts +++ b/src/tui/tool-formatter.ts @@ -59,6 +59,31 @@ export function setActiveWebProviderBrand(brand: string | undefined): void { brand !== undefined && brand.length > 0 ? brand : undefined; } +/** + * Settled head of a tool lane: the raw tool name in past tense. Keyed by the + * raw identifier (the lane grouping key), falling back to the display name + * for anything unmapped. + */ +const TOOL_PAST_TENSE: Record = { + grep: "Grepped", + read_file: "Read", + write_file: "Wrote", + edit_file: "Edited", + run_shell: "Ran", + list_dir: "Listed", + search_files: "Searched", + web_search: "Searched", + web_fetch: "Fetched", + delete_file: "Deleted", + submit_output: "Submitted", + ask_operator: "Asked operator", + use_skill: "Loaded skill", +}; + +export function pastTenseToolLabel(toolName: string): string { + return TOOL_PAST_TENSE[toolName] ?? humanizeToolName(toolName); +} + export function humanizeToolName(toolName: string): string { if (activeWebProviderBrand !== undefined) { if (toolName === "web_search") return `${activeWebProviderBrand} Search`; diff --git a/src/tui/tool-rows.test.ts b/src/tui/tool-rows.test.ts index dc5c38bbb..11debda4c 100644 --- a/src/tui/tool-rows.test.ts +++ b/src/tui/tool-rows.test.ts @@ -11,14 +11,25 @@ import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; import { createAppShell } from "./shell/index"; import { paintStreamRow, + ROW_ARROW, + toolRowLines, toolSentenceLines, type RowLayout, type StreamRow, } from "./stream"; import { pendingCallIndex, pushToolCall, pushToolResult } from "./tool-rows"; +import type { ShellOutputFeed } from "../session/shell-output-feed.js"; const LAYOUT: RowLayout = { width: 72, multiAgent: false }; +function liveFeed(snapshot: () => string): ShellOutputFeed { + return { + append: () => undefined, + clear: () => undefined, + snapshot, + }; +} + const painted = (row: StreamRow): string => paintStreamRow(row, LAYOUT).content; const collapsed = (row: StreamRow): string => @@ -199,20 +210,129 @@ describe("a run of identical calls", () => { expect(rows[0]?.stat).toBeUndefined(); }); - test("does not swallow a different call by the same tool", () => { + test("folds a different call by the same tool onto one lane with a count", () => { const rows: StreamRow[] = []; pushToolCall(rows, { name: "read_file", arguments: JSON.stringify({ path: "a.ts" }), + callId: "c1", + }); + pushToolResult(rows, { + name: "read_file", + content: "a", + callId: "c1", }); - pushToolResult(rows, { name: "read_file", content: "a" }); pushToolCall(rows, { name: "read_file", arguments: JSON.stringify({ path: "b.ts" }), + callId: "c2", + }); + expect(rows.length).toBe(1); + expect(rows[0]?.coalesced).toBe(true); + expect(rows[0]?.callCount).toBe(2); + expect(rows[0]?.summary).toBe("b.ts"); + expect(rows[0]?.memberIds).toEqual(["c1", "c2"]); + pushToolResult(rows, { + name: "read_file", + content: "b", + callId: "c2", + }); + expect(rows.length).toBe(1); + expect(rows[0]?.pending).toBeUndefined(); + expect(rows[0]?.outstanding).toBe(0); + }); + + test("a lane keeps every member id while the count climbs", () => { + const rows: StreamRow[] = []; + for (let i = 1; i <= 33; i++) { + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: `p${i}` }), + callId: `c${i}`, + }); + } + expect(rows.length).toBe(1); + expect(rows[0]?.callCount).toBe(33); + expect(rows[0]?.memberIds?.length).toBe(33); + expect(rows[0]?.memberIds?.[0]).toBe("c1"); + expect(pendingCallIndex(rows, "grep", "c33")).toBe(0); + expect(pendingCallIndex(rows, "grep", "c1")).toBe(0); + }); + + test("hydrate of 33 consecutive same-tool calls still settles the oldest result", () => { + const rows: StreamRow[] = []; + for (let i = 1; i <= 33; i++) { + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: `p${i}` }), + callId: `c${i}`, + }); + } + expect(rows.length).toBe(1); + expect(pendingCallIndex(rows, "grep", "c1")).toBe(0); + pushToolResult(rows, { name: "grep", content: "oldest", callId: "c1" }); + expect(rows.length).toBe(1); + expect(rows[0]?.pending).toBe(true); + expect(rows[0]?.outstanding).toBe(32); + for (let i = 2; i <= 33; i++) { + pushToolResult(rows, { + name: "grep", + content: `r${i}`, + callId: `c${i}`, + }); + } + expect(rows.length).toBe(1); + expect(rows[0]?.pending).toBeUndefined(); + expect(rows[0]?.outstanding).toBe(0); + }); + + test("a different tool breaks the lane", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: "x" }), + }); + pushToolCall(rows, { + name: "read_file", + arguments: JSON.stringify({ path: "a.ts" }), + }); + expect(rows.length).toBe(2); + }); + + test("spawn_agent never folds, even back to back", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "spawn_agent", + arguments: JSON.stringify({ description: "one" }), + }); + pushToolCall(rows, { + name: "spawn_agent", + arguments: JSON.stringify({ description: "two" }), }); - pushToolResult(rows, { name: "read_file", content: "b" }); expect(rows.length).toBe(2); }); + + test("a resumed parallel batch pairs results through memberIds", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: "a" }), + callId: "A", + }); + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: "b" }), + callId: "B", + }); + expect(rows.length).toBe(1); + // The lane's own callId moved to the newest call; the older member is + // still resolvable for its result. + expect(pendingCallIndex(rows, "grep", "A")).toBe(0); + pushToolResult(rows, { name: "grep", content: "a", callId: "A" }); + expect(rows.length).toBe(1); + expect(rows[0]?.outstanding).toBe(1); + expect(rows[0]?.pending).toBe(true); + }); }); describe("parallel calls to the same tool", () => { @@ -449,4 +569,436 @@ describe("a live turn", () => { { width: 80, height: 24 }, ); }); + + test("paints a live shell tail from a polled feed, unwired renders bare", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.play([ + { + type: "inference.tool_call.end", + data: { + name: "run_shell", + callId: "sh1", + arguments: { command: "make test" }, + }, + }, + ]); + // Unwired feed: sync is a no-op, the pending row stays bare. + bridge.syncShellOutputs(undefined); + expect(shell.streamLog[0]?.previewLines).toBeUndefined(); + + let text = ""; + bridge.syncShellOutputs(() => liveFeed(() => text)); + expect(shell.streamLog[0]?.previewLines).toBeUndefined(); + + text = "compiling src/a.ts\ncompiling src/b.ts\ndone\n"; + bridge.syncShellOutputs(() => liveFeed(() => text)); + // Tail repaints are frame-coalesced: the update lands on flush. + await h.renderOnce(); + const row = shell.streamLog[0]; + expect(row?.pending).toBe(true); + expect(row?.previewLines).toEqual([ + "compiling src/a.ts", + "compiling src/b.ts", + "done", + ]); + await h.renderOnce(); + const frame = h.captureCharFrame(); + expect(frame).toContain("compiling src/b.ts"); + + // A later settle replaces the live tail with the settle preview. + bridge.play([ + { + type: "tool.done", + data: { + result: { + callId: "sh1", + content: "ok\nline2\nline3\nline4\nline5", + }, + }, + }, + ]); + expect(shell.streamLog[0]?.pending).toBeUndefined(); + expect(shell.streamLog[0]?.previewLines).toEqual([ + "line3", + "line4", + "line5", + "⋯ +2 lines", + ]); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("parallel run_shell live tails do not cross-attribute", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.play([ + { + type: "inference.tool_call.end", + data: { + name: "run_shell", + callId: "sh1", + arguments: { command: "echo alpha" }, + }, + }, + { + type: "inference.tool_call.end", + data: { + name: "grep", + callId: "g1", + arguments: { pattern: "x" }, + }, + }, + { + type: "inference.tool_call.end", + data: { + name: "run_shell", + callId: "sh2", + arguments: { command: "echo beta" }, + }, + }, + ]); + expect(shell.streamLog.length).toBe(3); + bridge.syncShellOutputs((callId) => { + if (callId === "sh1") return liveFeed(() => "alpha-only\n"); + if (callId === "sh2") return liveFeed(() => "beta-only\n"); + return undefined; + }); + await h.renderOnce(); + const first = shell.streamLog[0]; + const second = shell.streamLog[2]; + expect(first?.toolName).toBe("run_shell"); + expect(second?.toolName).toBe("run_shell"); + expect(first?.previewLines).toEqual(["alpha-only"]); + expect(second?.previewLines).toEqual(["beta-only"]); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("a silent sibling does not clear a coalesced pending shell tail", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.play([ + { + type: "inference.tool_call.end", + data: { + name: "run_shell", + callId: "sh1", + arguments: { command: "echo alpha" }, + }, + }, + { + type: "inference.tool_call.end", + data: { + name: "run_shell", + callId: "sh2", + arguments: { command: "sleep 5; echo done" }, + }, + }, + ]); + await h.renderOnce(); + expect(shell.streamLog.length).toBe(1); + expect(shell.streamLog[0]?.coalesced).toBe(true); + expect(shell.streamLog[0]?.pending).toBe(true); + + bridge.syncShellOutputs((callId) => { + if (callId === "sh1") return liveFeed(() => "alpha-only\n"); + if (callId === "sh2") return liveFeed(() => ""); + return undefined; + }); + await h.renderOnce(); + expect(shell.streamLog[0]?.previewLines).toEqual(["alpha-only"]); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("rollbackAttempt drops shellSnapshots for truncated run_shell calls", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.play([ + { type: "inference.start", data: {} }, + { + type: "inference.tool_call.end", + data: { + name: "run_shell", + callId: "sh1", + arguments: { command: "sleep 5" }, + }, + }, + ]); + bridge.syncShellOutputs((callId) => + callId === "sh1" ? liveFeed(() => "live-tail\n") : undefined, + ); + await h.renderOnce(); + expect(shell.streamLog[0]?.previewLines).toEqual(["live-tail"]); + + bridge.play([{ type: "inference.retry", data: { attempt: 1 } }]); + expect( + shell.streamLog.some( + (row) => row.toolName === "run_shell" && row.callId === "sh1", + ), + ).toBe(false); + + bridge.play([ + { type: "inference.start", data: {} }, + { + type: "inference.tool_call.end", + data: { + name: "run_shell", + callId: "sh1", + arguments: { command: "sleep 5" }, + }, + }, + ]); + bridge.syncShellOutputs((callId) => + callId === "sh1" ? liveFeed(() => "live-tail\n") : undefined, + ); + await h.renderOnce(); + expect(shell.streamLog[0]?.previewLines).toEqual(["live-tail"]); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("a result id matching nothing on the log never folds onto the last row", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.play([ + { + type: "inference.tool_call.end", + data: { + name: "read_file", + callId: "c1", + arguments: { path: "a.ts" }, + }, + }, + ]); + // Attach-mid-turn / duplicate-event shape: an answer arrives whose + // id belongs to nothing this bridge saw. + bridge.play([ + { + type: "tool.done", + data: { result: { callId: "zzz-unknown", content: "orphan" } }, + }, + ]); + expect(shell.streamLog.length).toBe(2); + expect(shell.streamLog[0]?.pending).toBe(true); + expect(shell.streamLog[1]?.text).toBe("orphan"); + + // The real answer still resolves its own row in place. + bridge.play([ + { + type: "tool.done", + data: { result: { callId: "c1", content: "body" } }, + }, + ]); + expect(shell.streamLog.length).toBe(2); + expect(shell.streamLog[0]?.pending).toBeUndefined(); + expect(shell.streamLog[0]?.text).toBe("body"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); +}); + +describe("lane paint", () => { + test("a pending lane narrates the newest call with a dim count chip", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: "a" }), + }); + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: "b" }), + }); + expect(collapsed(defined(rows[0]))).toContain("Grep b"); + expect(collapsed(defined(rows[0]))).toContain("· ×2"); + }); + + test("a settled lane reads past tense times calls over the latest subject", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: "a" }), + }); + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: '"corbits"' }), + }); + pushToolResult(rows, { name: "grep", content: "no matches" }); + expect(rows[0]?.outstanding).toBe(1); + pushToolResult(rows, { name: "grep", content: "2 lines" }); + expect(collapsed(defined(rows[0]))).toContain('Grepped ×2 · "corbits"'); + }); + + test("a single-call row renders without lane wording", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "grep", + arguments: JSON.stringify({ pattern: "legacy_token" }), + }); + pushToolResult(rows, { name: "grep", content: "no matches" }); + const paintedLine = collapsed(defined(rows[0])); + expect(paintedLine).toContain("Grep legacy_token"); + expect(paintedLine).not.toContain("×"); + expect(paintedLine).not.toContain("Grepped"); + }); + + test("a settled shell row paints its preview lines and hides them expanded", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "run_shell", + arguments: JSON.stringify({ command: "make test" }), + }); + pushToolResult(rows, { + name: "run_shell", + content: "line1\nline2\nline3\nline4\nline5", + }); + const row = defined(rows[0]); + const collapsedLines = toolRowLines(row).map((line) => + line.map((segment) => segment.text).join(""), + ); + expect(collapsedLines.length).toBe(5); + expect(collapsedLines[1]).toContain("line3"); + expect(collapsedLines[4]).toContain("⋯ +2 lines"); + expect(collapsedLines[4]).toContain(ROW_ARROW.collapsed); + const expandedLines = toolRowLines({ ...row, expanded: true }); + expect(expandedLines.length).toBe(1 + 5); + expect( + expandedLines + .slice(1) + .some((line) => + line.some((segment) => segment.text.includes("+2 lines")), + ), + ).toBe(false); + }); + + test("a non-zero shell exit carries an exit stat, a zero exit none", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "run_shell", + arguments: JSON.stringify({ command: "false" }), + }); + pushToolResult(rows, { name: "run_shell", content: "exit code 1\nboom" }); + expect(rows[0]?.stat).toBe("exit 1"); + // The exit envelope line is the stat; the preview repeats only the output. + expect(rows[0]?.previewLines).toEqual(["boom"]); + + const ok: StreamRow[] = []; + pushToolCall(ok, { + name: "run_shell", + arguments: JSON.stringify({ command: "true" }), + }); + pushToolResult(ok, { name: "run_shell", content: "all good" }); + expect(ok[0]?.stat).toBeUndefined(); + }); + + test("a coalesced shell lane keeps each call's answer behind the arrow", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "run_shell", + arguments: JSON.stringify({ command: "echo a" }), + callId: "s1", + }); + pushToolCall(rows, { + name: "run_shell", + arguments: JSON.stringify({ command: "echo b" }), + callId: "s2", + }); + pushToolResult(rows, { name: "run_shell", content: "a", callId: "s1" }); + pushToolResult(rows, { name: "run_shell", content: "b", callId: "s2" }); + const answers = (rows[0]?.detail ?? []).map((line) => + line.map((segment) => segment.text).join(""), + ); + expect(answers).not.toEqual(["answered", "answered"]); + expect(answers).toContain("a"); + expect(rows[0]?.resultText).toBe("b"); + expect(rows[0]?.previewLines).toEqual(["b"]); + }); + + test("a later zero-exit coalesced shell does not keep a leftover exit stat", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "run_shell", + arguments: JSON.stringify({ command: "false" }), + callId: "s1", + }); + pushToolCall(rows, { + name: "run_shell", + arguments: JSON.stringify({ command: "true" }), + callId: "s2", + }); + pushToolResult(rows, { + name: "run_shell", + content: "exit code 1\nboom", + callId: "s1", + }); + pushToolResult(rows, { + name: "run_shell", + content: "all good", + callId: "s2", + }); + expect(rows[0]?.pending).toBeUndefined(); + expect(collapsed(defined(rows[0]))).toContain("true"); + expect(rows[0]?.stat).not.toBe("exit 1"); + }); }); diff --git a/src/tui/tool-rows.ts b/src/tui/tool-rows.ts index 5972454b5..69d54d6be 100644 --- a/src/tui/tool-rows.ts +++ b/src/tui/tool-rows.ts @@ -6,8 +6,8 @@ * marker, subject and expandable body — instead of appending a second, visually * orphaned line beneath it. * - * A run of consecutive calls that paint the same sentence collapses onto one - * row too. The row keeps saying what the call was rather than totalling the + * A run of consecutive calls to the same raw tool collapses onto one row + * too. The row keeps saying what the call was rather than totalling the * answers: totals across separate calls (overlapping queries, partial failures) * are claims the payloads do not support, and a summary nobody can trust is * worse than a plainer one. The answers themselves sit behind the arrow. @@ -90,7 +90,27 @@ function countNoun(count: number, noun: string): string { } /** - * Fold a tool result into the call row it answers. + * Collapsed shell preview painted between the head and the expand hint: the + * output's last three lines plus a dim elision marker that carries the count. + * The full output stays behind the arrow. + */ +const SHELL_PREVIEW_LINES = 3; + +export function shellPreviewLines(content: string): string[] | undefined { + const lines = content.replace(/\n+$/, "").split("\n"); + if (lines.length === 0 || (lines.length === 1 && lines[0] === "")) + return undefined; + if (lines.length <= SHELL_PREVIEW_LINES) return lines; + return [ + ...lines.slice(-SHELL_PREVIEW_LINES), + `⋯ +${lines.length - SHELL_PREVIEW_LINES} lines`, + ]; +} + +const SHELL_EXIT_ENVELOPE = /^exit code (\d+)\n/; + +/** + * Fold a tool result into the lane/call row it answers. * * The row keeps saying what the call was — the URL fetched, the path read, the * query searched. That is the stable identifier, and it is the one thing the @@ -100,13 +120,31 @@ function countNoun(count: number, noun: string): string { */ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow { const failed = result.failed === true; + const isShell = call.toolName === "run_shell"; + // A shell answer carries its exit in the content envelope the guard wraps + // non-zero exits in. The envelope's code becomes the row's only stat — + // today's "N lines" stat is dropped for shell rows because the preview's + // elision marker already carries the count. + const exitMatch = isShell ? SHELL_EXIT_ENVELOPE.exec(result.text) : null; + const exitCode = exitMatch !== null ? Number(exitMatch[1]) : undefined; + const shellStat = + exitCode !== undefined && exitCode !== 0 ? `exit ${exitCode}` : undefined; + // The exit envelope line is already the row's stat; do not repeat it in the + // collapsed preview. + const previewSource = + shellStat !== undefined + ? result.text.replace(/^exit code \d+\n/, "") + : result.text; const { pending: _pending, agentWorking: _agentWorking, stat: _stat, + previewLines: _previewLines, ...answered } = call; const addendum = resultAddendum(result); + const effAddendum = + isShell && (shellStat !== undefined || !failed) ? undefined : addendum; // A live sub-agent's elapsed-time trailer is scaffolding for the wait, not a // fact about the call the way a diff's own +/- count is — the answer's stat // must win over it rather than being shadowed by whatever it last read. @@ -114,16 +152,17 @@ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow { // the reason, not a diff that did not land. const callStat = failed || call.agentWorking !== undefined ? undefined : call.stat; + const settledStat = shellStat ?? callStat; const base: StreamRow = { ...answered, text: result.text, summary: call.summary ?? "", ...(failed || call.failed === true ? { failed: true } : {}), // A diff already states its own +/- counts; nothing the answer says beats it. - ...(callStat === undefined && addendum !== undefined - ? { stat: addendum } + ...(settledStat === undefined && effAddendum !== undefined + ? { stat: effAddendum } : {}), - ...(callStat !== undefined ? { stat: callStat } : {}), + ...(settledStat !== undefined ? { stat: settledStat } : {}), }; if (call.coalesced === true) { @@ -136,7 +175,14 @@ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow { // The run's subject stays the call it repeats, so the row keeps the // call's own text rather than taking on this one answer's payload. text: call.text, - ...(call.stat !== undefined ? { stat: call.stat } : {}), + // The most recent answer is the lane's copy source (Alt+C) and, for a + // shell lane, the settle preview's source. + resultText: result.text, + ...(isShell && shellStat !== undefined ? { stat: shellStat } : {}), + ...(!isShell && call.stat !== undefined ? { stat: call.stat } : {}), + ...(isShell + ? { previewLines: shellPreviewLines(previewSource) ?? [] } + : {}), outstanding: remaining, ...(remaining > 0 ? { pending: true } : {}), detail: appendRunLine( @@ -153,6 +199,9 @@ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow { ...(result.structured !== undefined ? { structured: result.structured } : {}), + ...(isShell + ? { previewLines: shellPreviewLines(previewSource) ?? [] } + : {}), ...(showsPayload ? { detail: result.detail ?? resultBodyLines(result.text) } : call.detail !== undefined @@ -161,7 +210,14 @@ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow { }; } -/** Whether `next` is a repeat of the call the row before it already painted. */ +/** Whether `next` folds onto the lane the tail row already represents. + * + * The lane groups by raw tool identity, not by the sentence a call paints: + * two reads of different files are still two reads, and a lane of them is + * easier to read than a stack of near-identical rows. `spawn_agent` is + * excluded — each dispatch is its own live progress anchor for its whole + * lifetime, so it must never merge. + */ export function canCoalesceCall( tail: StreamRow | undefined, next: StreamRow, @@ -169,13 +225,26 @@ export function canCoalesceCall( if (tail === undefined || tail.role !== "tool" || next.role !== "tool") { return false; } - return tail.callKey !== undefined && tail.callKey === next.callKey; + const toolName = tail.toolName; + if (toolName === undefined || toolName !== next.toolName) return false; + return toolName !== "spawn_agent"; +} + +/** Calls a lane remembers so a later result can still find this row. */ + +function laneMembers(tail: StreamRow, next: StreamRow): string[] | undefined { + const members = [ + ...(tail.memberIds ?? (tail.callId !== undefined ? [tail.callId] : [])), + ...(next.callId !== undefined ? [next.callId] : []), + ]; + return members.length > 0 ? members : undefined; } /** - * Collapse a repeated call onto the row its predecessor already occupies. The - * predecessor's own answer becomes the first line of the run's body, so nothing - * it had said is lost to the collapse. + * Fold a repeated call onto the lane its predecessor already occupies. The + * lane narrates the newest call; the predecessor's own answer (when the lane + * had already settled) becomes the first line of the body it keeps behind + * the arrow. */ export function coalesceCallRows(tail: StreamRow, next: StreamRow): StreamRow { const answered = @@ -193,8 +262,11 @@ export function coalesceCallRows(tail: StreamRow, next: StreamRow): StreamRow { ...call } = next; const inFlight = tail.outstanding ?? (tail.pending === true ? 1 : 0); + const memberIds = laneMembers(tail, next); return { ...call, + callCount: (tail.callCount ?? 1) + 1, + ...(memberIds !== undefined ? { memberIds } : {}), coalesced: true, outstanding: inFlight + 1, ...(tail.failed === true ? { failed: true } : {}), @@ -229,6 +301,11 @@ export function pendingCallIndex( for (let i = rows.length - 1; i >= 0; i--) { if (rows[i]?.callId === callId) return i; } + // A lane's own callId has already moved to its newest member, so the id + // this result carries may be one the lane absorbed earlier. + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i]?.memberIds?.includes(callId)) return i; + } return -1; } let fallback = -1;