From 06bd720f8d26012c74560197299eb92dfbb57ed0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 04:26:48 -0700 Subject: [PATCH] Show what a lane tool is doing, not just which tool A fleet of shell calls all painted as "run_shell", so an operator could not tell a test suite from a build. Derive a bounded, secret-scrubbed subject from the same arguments the transcript holds, stamp it on the outstanding call, and paint it in place of the bare tool name on the agents board and dispatch trailer. --- src/subagent/fleet-report.test.ts | 1 + src/subagent/fleet-report.ts | 1 + src/subagent/session-store.test.ts | 42 ++++++++++- src/subagent/session-store.ts | 79 ++++++++++++++++++--- src/subagent/tool-preview.test.ts | 68 ++++++++++++++++++ src/subagent/tool-preview.ts | 108 +++++++++++++++++++++++++++++ src/tui/agent-progress.test.ts | 35 ++++++++-- src/tui/agent-progress.ts | 20 +++++- src/tui/chrome-state.test.ts | 17 +++++ src/tui/chrome-state.ts | 20 +++++- src/tui/demo.ts | 27 +++++++- src/tui/runner-host.test.ts | 1 + src/tui/runner-host.ts | 1 + src/tui/runner.ts | 1 + src/tui/runtime-bridge.test.ts | 1 + 15 files changed, 400 insertions(+), 22 deletions(-) create mode 100644 src/subagent/tool-preview.test.ts create mode 100644 src/subagent/tool-preview.ts diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 3e3605ff3..2be646abc 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -15,6 +15,7 @@ function lane(overrides: Partial & { id: string }): FleetLane { startedAt: T0, lastActivityAt: T0, currentToolName: null, + currentToolPreview: null, currentToolStartedAt: null, ...overrides, }; diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index a6f457b17..e95210882 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -23,6 +23,7 @@ export type FleetLane = { readonly startedAt: number; readonly lastActivityAt: number; readonly currentToolName: string | null; + readonly currentToolPreview: string | null; readonly currentToolStartedAt: number | null; readonly report?: string; readonly error?: string; diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index 3b5356612..d450c7b81 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -120,9 +120,16 @@ describe("outstanding tool clock", () => { store.appendEvent(session.id, { type: "tool.start", seq: 1, - data: { call: { id: "call-1", name: "run_shell", arguments: {} } }, + data: { + call: { + id: "call-1", + name: "run_shell", + arguments: { command: "bun test" }, + }, + }, } as unknown as ReactorEmittedEvent); expect(store.get(session.id)?.currentToolName).toBe("run_shell"); + expect(store.get(session.id)?.currentToolPreview).toBe("bun test"); expect(store.get(session.id)?.currentToolStartedAt).toBe(5_000); clock = 95_000; @@ -132,6 +139,7 @@ describe("outstanding tool clock", () => { data: { result: { callId: "call-1", content: "ok", isError: false } }, } as unknown as ReactorEmittedEvent); expect(store.get(session.id)?.currentToolName).toBeNull(); + expect(store.get(session.id)?.currentToolPreview).toBeNull(); expect(store.get(session.id)?.currentToolStartedAt).toBeNull(); }); @@ -143,6 +151,38 @@ describe("outstanding tool clock", () => { store.complete(session.id, "report"); expect(store.get(session.id)?.currentToolStartedAt).toBeNull(); + expect(store.get(session.id)?.currentToolPreview).toBeNull(); + }); + + // CL-5765: argument streaming must refresh the preview so a partial command + // does not stick on the lane after the rest of the args arrive. + test("streaming arguments refresh the lane preview from the same payload the transcript holds", () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b" }); + + store.appendEvent(session.id, { + type: "inference.tool_call.start", + seq: 1, + data: { name: "run_shell", callId: "call-1" }, + } as unknown as ReactorEmittedEvent); + store.appendEvent(session.id, { + type: "inference.tool_call.delta", + seq: 2, + data: { callId: "call-1", argumentFragment: '{"command":"bun te' }, + } as unknown as ReactorEmittedEvent); + // Incomplete JSON — no preview yet. + expect(store.get(session.id)?.currentToolPreview).toBeNull(); + + store.appendEvent(session.id, { + type: "inference.tool_call.delta", + seq: 3, + data: { callId: "call-1", argumentFragment: 'st"}' }, + } as unknown as ReactorEmittedEvent); + expect(store.get(session.id)?.currentToolPreview).toBe("bun test"); + expect(store.get(session.id)?.entries[0]).toMatchObject({ + kind: "tool", + arguments: '{"command":"bun test"}', + }); }); }); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index 0ffad525a..a0be3f75b 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -5,6 +5,7 @@ // this store is the dedicated child record the enter-session UI reads. import type { ReactorEmittedEvent } from "@intx/inference"; +import { toolCallPreview } from "./tool-preview.js"; export type SubAgentSessionStatus = "running" | "done" | "failed" | "cancelled"; @@ -21,6 +22,13 @@ export type OutstandingToolCall = { callId: string; name: string; startedAt: number; + /** + * Bounded one-line subject of the call (command, path, pattern…), or null + * when the args have nothing useful to show. Derived from the same raw + * arguments the transcript stores so the lane and the body cannot disagree + * about what is running (CL-5765). + */ + preview: string | null; }; export type SubAgentSession = { @@ -30,14 +38,17 @@ export type SubAgentSession = { brief: string; status: SubAgentSessionStatus; toolNames: string[]; - // Name and start clock of the OLDEST outstanding call — the one that - // explains the longest silence. Both are derived from `outstandingTools`; - // never assign them directly. Null when nothing is in flight. + // Name, preview, and start clock of the OLDEST outstanding call — the one + // that explains the longest silence. All three are derived from + // `outstandingTools`; never assign them directly. Null when nothing is in + // flight. // // A worker inside one long tool emits no events for the whole execution, so // silence alone cannot tell "wedged" from "running a ten-minute test suite". - // The start clock is the fact that separates them. + // The start clock is the fact that separates them. The preview is what lets + // an operator tell six shell commands apart on a fleet board. currentToolName: string | null; + currentToolPreview: string | null; currentToolStartedAt: number | null; // Calls the reactor has started and not yet reported a result for. The // reactor runs parallel calls concurrently, so this cannot collapse to one @@ -116,8 +127,9 @@ function defaultCreateId(): string { } /** - * The one place the displayed pair is produced, so a name can never be shown - * beside another call's clock. Called after every change to `outstandingTools`. + * The one place the displayed triple is produced, so a name / preview can never + * be shown beside another call's clock. Called after every change to + * `outstandingTools`. */ function syncCurrentTool(session: SubAgentSession): void { let oldest: OutstandingToolCall | undefined; @@ -125,12 +137,15 @@ function syncCurrentTool(session: SubAgentSession): void { if (oldest === undefined || call.startedAt < oldest.startedAt) oldest = call; } session.currentToolName = oldest?.name ?? null; + session.currentToolPreview = oldest?.preview ?? null; session.currentToolStartedAt = oldest?.startedAt ?? null; } /** * `restartClock` marks the execution boundary: argument streaming already * registered the call, and the figure worth showing is time spent running it. + * `rawArgs`, when known, refreshes the lane preview from the same payload the + * transcript stores. */ function beginToolCall( session: SubAgentSession, @@ -138,17 +153,39 @@ function beginToolCall( name: string, nowMs: number, restartClock = false, + rawArgs?: string, ): void { const existing = session.outstandingTools.find((c) => c.callId === callId); + const preview = + rawArgs !== undefined ? toolCallPreview(name, rawArgs) : (existing?.preview ?? null); if (existing !== undefined) { existing.name = name; if (restartClock) existing.startedAt = nowMs; + if (rawArgs !== undefined) existing.preview = preview; } else { - session.outstandingTools.push({ callId, name, startedAt: nowMs }); + session.outstandingTools.push({ + callId, + name, + startedAt: nowMs, + preview, + }); } syncCurrentTool(session); } +/** Refresh the outstanding call's preview once more of its arguments stream in. */ +function refreshToolPreview( + session: SubAgentSession, + callId: string, + name: string, + rawArgs: string, +): void { + const existing = session.outstandingTools.find((c) => c.callId === callId); + if (existing === undefined) return; + existing.preview = toolCallPreview(name, rawArgs); + syncCurrentTool(session); +} + /** * Retires exactly the call that finished. A result carrying an id we never saw * start retires nothing, rather than silently clearing a live sibling's clock. @@ -338,6 +375,7 @@ export function createSubAgentSessionStore( status: "running", toolNames: [], currentToolName: null, + currentToolPreview: null, currentToolStartedAt: null, outstandingTools: [], entries: [], @@ -398,6 +436,9 @@ export function createSubAgentSessionStore( if (entry?.kind !== "tool") continue; if (callId !== null && entry.callId !== callId) continue; entry.arguments = appendCapped(entry.arguments, fragment, maxEntryChars); + // Preview tracks the same args the transcript holds so the lane + // and the body never disagree about what is running. + refreshToolPreview(session, entry.callId, entry.name, entry.arguments); return; } return; @@ -418,14 +459,21 @@ export function createSubAgentSessionStore( if (args !== null && args.length > 0) entry.arguments = args; // Arguments finished streaming; the call itself is still in // flight, so this renames it rather than restarting its clock. - beginToolCall(session, entry.callId, entry.name, now()); + beginToolCall( + session, + entry.callId, + entry.name, + now(), + false, + entry.arguments, + ); return; } // No matching start — record a complete tool entry. if (name !== null) { const idForEntry = callId ?? `${name}-${session.entries.length}`; if (!session.toolNames.includes(name)) session.toolNames.push(name); - beginToolCall(session, idForEntry, name, now()); + beginToolCall(session, idForEntry, name, now(), false, args ?? ""); pushEntry(session, { kind: "tool", callId: idForEntry, @@ -438,14 +486,22 @@ export function createSubAgentSessionStore( case "tool.start": { // tool.start is the execution-time counterpart of inference.tool_call. // Prefer inference events for the transcript; only fill gaps. - const call = (event as { data?: { call?: { name?: unknown; id?: unknown } } }).data?.call; + const call = (event as { + data?: { call?: { name?: unknown; id?: unknown; arguments?: unknown } }; + }).data?.call; const name = typeof call?.name === "string" ? call.name : null; if (name === null) return; const callId = typeof call?.id === "string" ? call.id : null; + const rawArgs = + call?.arguments !== undefined + ? capText(stringifyUnknown(call.arguments), maxEntryChars) + : undefined; // Without an id there is no way to tell which of several parallel // calls this starts, and guessing would retime the wrong one. The // inference-side start already registered it, so leave it alone. - if (callId !== null) beginToolCall(session, callId, name, now(), true); + if (callId !== null) { + beginToolCall(session, callId, name, now(), true, rawArgs); + } if (!session.toolNames.includes(name)) session.toolNames.push(name); return; } @@ -553,6 +609,7 @@ function cloneSession(session: SubAgentSession): SubAgentSession { status: session.status, toolNames: [...session.toolNames], currentToolName: session.currentToolName, + currentToolPreview: session.currentToolPreview, currentToolStartedAt: session.currentToolStartedAt, outstandingTools: session.outstandingTools.map((c) => ({ ...c })), entries: session.entries.map(cloneEntry), diff --git a/src/subagent/tool-preview.test.ts b/src/subagent/tool-preview.test.ts new file mode 100644 index 000000000..77c764525 --- /dev/null +++ b/src/subagent/tool-preview.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { TOOL_PREVIEW_MAX, toolCallPreview } from "./tool-preview"; + +describe("toolCallPreview", () => { + test("a shell call's subject is the command, not the tool name", () => { + expect( + toolCallPreview("run_shell", JSON.stringify({ command: "bun test ./src" })), + ).toBe("bun test ./src"); + }); + + test("a file tool's subject is the path", () => { + expect( + toolCallPreview("read_file", JSON.stringify({ path: "src/subagent/session-store.ts" })), + ).toBe("src/subagent/session-store.ts"); + }); + + test("grep shows the pattern", () => { + expect( + toolCallPreview("grep", JSON.stringify({ pattern: "currentToolPreview", path: "src" })), + ).toBe("currentToolPreview"); + }); + + test("task prefers description over prompt", () => { + expect( + toolCallPreview( + "task", + JSON.stringify({ + description: "map callers", + prompt: "Find every call site of leaveObserve.", + }), + ), + ).toBe("map callers"); + }); + + test("empty or unknown args degrade to null so the lane falls back to the tool name", () => { + expect(toolCallPreview("run_shell", "")).toBeNull(); + expect(toolCallPreview("run_shell", "{}")).toBeNull(); + expect(toolCallPreview("unknown_tool", JSON.stringify({ foo: 1 }))).toBeNull(); + }); + + test("long subjects are hard-capped so they cannot shove other columns off the row", () => { + // Avoid hex-like blobs (a-f0-9) — secret scrub would redact them first. + const command = "z".repeat(TOOL_PREVIEW_MAX + 20); + const preview = toolCallPreview("run_shell", JSON.stringify({ command })); + expect(preview).not.toBeNull(); + expect(preview!.length).toBe(TOOL_PREVIEW_MAX); + expect(preview!.endsWith("…")).toBe(true); + }); + + test("newlines collapse to a single-line subject", () => { + expect( + toolCallPreview( + "run_shell", + JSON.stringify({ command: "bun test\n --filter agent" }), + ), + ).toBe("bun test --filter agent"); + }); + + test("secret-shaped fragments are scrubbed before the subject leaves the helper", () => { + const preview = toolCallPreview( + "run_shell", + JSON.stringify({ command: "curl https://api.example.com/?api_key=supersecretvalue" }), + ); + expect(preview).not.toBeNull(); + expect(preview).not.toContain("supersecretvalue"); + expect(preview).toContain("[REDACTED]"); + }); +}); diff --git a/src/subagent/tool-preview.ts b/src/subagent/tool-preview.ts new file mode 100644 index 000000000..ab3ff1a74 --- /dev/null +++ b/src/subagent/tool-preview.ts @@ -0,0 +1,108 @@ +/** + * One-line previews of what a live tool call is doing — the subject of a lane + * row, not a serialisation of its arguments. + * + * CL-5765: operators watching a fleet need to tell six shell commands apart; + * the bare tool name cannot. Previews are bounded, single-line, and secret- + * scrubbed so the agents strip never becomes a new leak path for credentials + * that happen to sit in a command string. + */ + +import { scrubSecrets } from "../web/secret-scrub.js"; + +/** Hard cap so a long command cannot shove the row's other columns off-screen. */ +export const TOOL_PREVIEW_MAX = 48; + +/** + * Subject of a running tool call for lane/chrome paint, or null when the args + * have nothing meaningful to show — the surface then falls back to the tool name. + */ +export function toolCallPreview(name: string, rawArgs: string): string | null { + const extracted = extractSubject(name, rawArgs); + if (extracted === null) return null; + const scrubbed = scrubSecrets(extracted); + const oneLine = scrubbed.replace(/\s+/g, " ").trim(); + if (oneLine.length === 0) return null; + if (oneLine.length <= TOOL_PREVIEW_MAX) return oneLine; + return `${oneLine.slice(0, TOOL_PREVIEW_MAX - 1)}…`; +} + +function extractSubject(name: string, rawArgs: string): string | null { + if (rawArgs.length === 0) return null; + const args = parseObject(rawArgs); + if (args === null) { + // Incomplete JSON streams through here mid-delta. Do not surface the raw + // fragment as a subject — wait for a parseable object. + const trimmed = rawArgs.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return null; + // Non-JSON payload — only useful when short enough to be the whole subject. + const oneLine = trimmed.replace(/\s+/g, " "); + return oneLine.length > 0 && oneLine.length <= TOOL_PREVIEW_MAX ? oneLine : null; + } + + const tool = name.toLowerCase(); + + // Shell: the command is the whole story. Old surface replaced the tool name + // with it entirely; we produce the same subject here. + if ( + tool === "run_shell" || + tool === "shell" || + tool === "bash" || + tool.endsWith("__run_shell") || + tool.endsWith("__shell") + ) { + return stringField(args, "command") ?? stringField(args, "cmd"); + } + + if ( + tool === "read_file" || + tool === "write_file" || + tool === "edit_file" || + tool === "delete_file" || + tool.endsWith("__read_file") || + tool.endsWith("__write_file") || + tool.endsWith("__edit_file") + ) { + return stringField(args, "path") ?? stringField(args, "file_path"); + } + + if (tool === "grep" || tool.endsWith("__grep")) { + return stringField(args, "pattern") ?? stringField(args, "query"); + } + + if (tool === "search_files" || tool.endsWith("__search_files")) { + return stringField(args, "pattern") ?? stringField(args, "glob"); + } + + if (tool === "web_search" || tool === "web_fetch") { + return stringField(args, "query") ?? stringField(args, "url"); + } + + if (tool === "task") { + return stringField(args, "description") ?? stringField(args, "prompt"); + } + + // Generic fallback: first short scalar among common subject keys. + for (const key of ["path", "command", "query", "pattern", "url", "description", "prompt"]) { + const value = stringField(args, key); + if (value !== null) return value; + } + return null; +} + +function parseObject(raw: string): Record | null { + try { + const value: unknown = JSON.parse(raw); + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; + } catch { + return null; + } +} + +function stringField(args: Record, key: string): string | null { + const value = args[key]; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index 30f53e554..35175648f 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -20,7 +20,8 @@ describe("agentProgress", () => { const base = { status: "running" as const, currentToolName: "grep", - currentToolStartedAt: null, + currentToolPreview: null as string | null, + currentToolStartedAt: null as number | null, startedAt: 0, lastActivityAt: 0, } @@ -41,6 +42,27 @@ describe("agentProgress", () => { }) }) + test("a tool preview replaces the bare tool name in the trailer (CL-5765)", () => { + const progress = agentProgress( + { + ...base, + currentToolName: "run_shell", + currentToolPreview: "bun test ./src", + currentToolStartedAt: 1_000, + lastActivityAt: 1_000, + }, + 91_000, + 30_000, + ) + expect(progress?.stat).toBe("1:31 · bun test ./src 1:30") + expect(progress?.stat).not.toContain("run_shell") + }) + + test("without a preview the trailer still names the tool", () => { + const progress = agentProgress({ ...base, lastActivityAt: 42_000 }, 42_000) + expect(progress?.stat).toContain("grep") + }) + test("a running session with no current tool reports elapsed time alone", () => { const progress = agentProgress( { ...base, currentToolName: null, lastActivityAt: 42_000 }, @@ -97,8 +119,9 @@ describe("agentProgress", () => { describe("laneState", () => { const running = { status: "running" as const, - currentToolName: null, - currentToolStartedAt: null, + currentToolName: null as string | null, + currentToolPreview: null as string | null, + currentToolStartedAt: null as number | null, startedAt: 0, lastActivityAt: 0, } @@ -119,8 +142,9 @@ describe("laneState", () => { describe("fleetProgress", () => { const lane = (over: Partial[0]>) => ({ status: "running" as const, - currentToolName: null, - currentToolStartedAt: null, + currentToolName: null as string | null, + currentToolPreview: null as string | null, + currentToolStartedAt: null as number | null, startedAt: 0, lastActivityAt: 0, ...over, @@ -173,6 +197,7 @@ describe("the in-tool bound", () => { const wedged = { status: "running" as const, currentToolName: "run_shell", + currentToolPreview: null as string | null, currentToolStartedAt: 0, startedAt: 0, lastActivityAt: 0, diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index 42359dce8..ba4928abd 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -17,6 +17,13 @@ export type AgentProgressSession = { readonly status: "running" | "done" | "failed" | "cancelled"; readonly currentToolName: string | null; + /** + * Bounded subject of the oldest outstanding call (command, path, pattern…), + * or null when the args have nothing meaningful to show. When set, this + * replaces the bare tool name in the row trailer so a fleet of shell + * commands is distinguishable (CL-5765). + */ + readonly currentToolPreview: string | null; /** * When the oldest outstanding tool call began, or null when none is in * flight. Required, not optional: every hop from the store to a surface is a @@ -118,11 +125,20 @@ export function agentProgress( ): AgentProgress | null { if (session.status !== "running") return null; const elapsed = clockLabel(nowMs - session.startedAt); + // Prefer the argument subject over the bare tool name — six shell commands + // on a fleet board are six different situations, not six identical labels. + const preview = session.currentToolPreview; const tool = session.currentToolName; - const hasTool = tool !== null && tool.length > 0; + const subject = + preview !== null && preview.length > 0 + ? preview + : tool !== null && tool.length > 0 + ? tool + : null; + const hasSubject = subject !== null; const state = laneState(session, nowMs, stallMs); - const base = hasTool ? `${elapsed} · ${tool}` : elapsed; + const base = hasSubject ? `${elapsed} · ${subject}` : elapsed; const stat = state === "in_tool" && session.currentToolStartedAt !== null ? `${base} ${clockLabel(nowMs - session.currentToolStartedAt)}` diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index 6bf95d80d..da862e19b 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -435,6 +435,7 @@ describe("lane state survives the mapping hops", () => { description: "sleep 150", status: "running" as const, currentToolName: "run_shell", + currentToolPreview: null as string | null, currentToolStartedAt: NOW - 90_000, startedAt: NOW - 100_000, lastActivityAt: NOW - 90_000, @@ -462,6 +463,22 @@ describe("lane state survives the mapping hops", () => { expect(agentProgress(inTool, NOW)?.stat).toContain("run_shell 1:30") }) + test("a shell preview replaces the tool name on both panel and trailer (CL-5765)", () => { + const withPreview = { + ...inTool, + currentToolPreview: "bun test ./src", + } + const rows = formatAgentsPanel( + chromeFromSession({ agents: [withPreview] }).agents, + undefined, + NOW, + ) + expect(rows?.[1]?.tail).toContain("bun test ./src") + expect(rows?.[1]?.tail).not.toContain("run_shell") + expect(agentProgress(withPreview, NOW)?.stat).toContain("bun test ./src") + expect(agentProgress(withPreview, NOW)?.stat).not.toContain("run_shell") + }) + test("a genuinely silent lane still reads stalled through the same hops", () => { const silent = { ...inTool, currentToolName: null, currentToolStartedAt: null } expect(laneState(silent, NOW)).toBe("stalled") diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 46952d6cb..415c4e250 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -41,6 +41,11 @@ export type ChromeAgentSession = { readonly status: "running" | "done" | "failed" | "cancelled" /** Current tool while running (optional detail). */ readonly currentToolName?: string | null + /** + * Bounded subject of the outstanding call (command / path / pattern). When + * set, the agents panel paints this instead of the bare tool name (CL-5765). + */ + readonly currentToolPreview?: string | null /** Clock the worker started; feeds the panel row's elapsed time. */ readonly startedAt?: number /** Clock of the worker's last reported activity; feeds stalled detection. */ @@ -257,6 +262,7 @@ function toProgressSession(session: ChromeAgentSession): AgentProgressSession | return { status: session.status, currentToolName: session.currentToolName ?? null, + currentToolPreview: session.currentToolPreview ?? null, currentToolStartedAt: session.currentToolStartedAt, startedAt: session.startedAt, lastActivityAt: session.lastActivityAt ?? session.startedAt, @@ -400,8 +406,16 @@ function formatAgentRow( ): AgentPanelRow { const label = `${session.agentId}: ${session.description}`.trim() const stalled = state === "stalled" + // Prefer the argument subject (command / path) over the bare tool name so a + // fleet of shell calls is distinguishable at a glance (CL-5765). + const preview = session.currentToolPreview const tool = session.currentToolName - const doing = tool !== undefined && tool !== null && tool.length > 0 ? tool : null + const doing = + preview !== undefined && preview !== null && preview.length > 0 + ? preview + : tool !== undefined && tool !== null && tool.length > 0 + ? tool + : null const progressSession = toProgressSession(session) if (progressSession === null) { @@ -480,6 +494,7 @@ export type ChromeSessionAgent = { readonly description: string readonly status: "running" | "done" | "failed" | "cancelled" readonly currentToolName?: string | null + readonly currentToolPreview?: string | null readonly currentToolStartedAt: number | null readonly startedAt?: number readonly lastActivityAt?: number @@ -541,6 +556,9 @@ function mapSessionAgents( ...(a.currentToolName !== undefined ? { currentToolName: a.currentToolName } : {}), + ...(a.currentToolPreview !== undefined + ? { currentToolPreview: a.currentToolPreview } + : {}), currentToolStartedAt: a.currentToolStartedAt, ...(a.startedAt !== undefined ? { startedAt: a.startedAt } : {}), ...(a.lastActivityAt !== undefined diff --git a/src/tui/demo.ts b/src/tui/demo.ts index 08114ecfd..495280baf 100644 --- a/src/tui/demo.ts +++ b/src/tui/demo.ts @@ -108,15 +108,38 @@ const DEMO_FLEET = [ ["a15", "mcp view polish", 90, 5, "edit mcp-view.ts"], ].map(([agentId, description, ranSec, idleSec, tool]) => { const lastActivityAt = Date.now() - (idleSec as number) * 1000 + const subject = tool as string | null + // Demo subjects are already human phrases ("bash npm test", "edit zones.ts"). + // Put the phrase in the preview so the board paints what the worker is doing + // rather than a bare tool identifier (CL-5765). + let currentToolName: string | null = null + let currentToolPreview: string | null = null + if (subject !== null) { + currentToolPreview = subject + if (subject.startsWith("bash ") || subject.startsWith("approve ")) { + currentToolName = "run_shell" + } else if (subject.startsWith("edit ")) { + currentToolName = "edit_file" + } else if (subject.startsWith("write ")) { + currentToolName = "write_file" + } else if (subject.startsWith("read ")) { + currentToolName = "read_file" + } else if (subject.startsWith("grep ")) { + currentToolName = "grep" + } else { + currentToolName = "run_shell" + } + } return { agentId: agentId as string, description: description as string, status: "running" as const, - currentToolName: tool as string | null, + currentToolName, + currentToolPreview, // Hybrid chrome requires the tool clock; without it a long-running tool // would be reclassified as stalled. Align with last activity when a tool // is named so demo lanes still exercise working / in_tool / stalled. - currentToolStartedAt: tool === null ? null : lastActivityAt, + currentToolStartedAt: subject === null ? null : lastActivityAt, startedAt: Date.now() - (ranSec as number) * 1000, lastActivityAt, } diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 957e90ea4..cc70d3a5b 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -47,6 +47,7 @@ function session(over: Partial): SubAgentSession { status: "running", toolNames: [], currentToolName: null, + currentToolPreview: null, currentToolStartedAt: null, outstandingTools: [], entries: [], diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index be0301b30..265496657 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -272,6 +272,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise id: s.id, status: s.status, currentToolName: s.currentToolName, + currentToolPreview: s.currentToolPreview, currentToolStartedAt: s.currentToolStartedAt, startedAt: s.startedAt, lastActivityAt: s.lastActivityAt, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index e8b9062c3..06b2468cb 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2088,6 +2088,7 @@ export async function runTUI(initialConfig: Config): Promise { description: s.description, status: s.status, currentToolName: s.currentToolName, + currentToolPreview: s.currentToolPreview, currentToolStartedAt: s.currentToolStartedAt, startedAt: s.startedAt, lastActivityAt: s.lastActivityAt, diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index f72674686..065827783 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -659,6 +659,7 @@ describe("syncAgentProgress", () => { id: "task-1", status: "running", currentToolName: "grep", + currentToolPreview: null, currentToolStartedAt: null, startedAt: 0, lastActivityAt: 0,