diff --git a/components/MessageView.test.mjs b/components/MessageView.test.mjs index 406ccd13ac..6ba7150c89 100644 --- a/components/MessageView.test.mjs +++ b/components/MessageView.test.mjs @@ -243,6 +243,81 @@ test("input that is still streaming keeps its raw text", () => { } }); +test("a bash call shows its command with real line breaks instead of JSON", () => { + const block = { + type: "toolCall", + toolCallId: "call-bash-multiline", + toolName: "bash", + input: { command: "cd /tmp && ls .env*\ngrep -c \"\" .env" }, + }; + clearExpandedToolCalls(); + setToolCallExpanded(block.toolCallId, true); + try { + const html = renderMessage({ role: "assistant", provider: "anthropic", model: "claude-test", content: [block] }); + const text = textOf(html); + assert.ok(text.includes("cd /tmp && ls .env*\ngrep -c \"\" .env")); + assert.ok(!html.includes(" { + const timed = { + type: "toolCall", + toolCallId: "call-bash-timeout", + toolName: "bash", + input: { command: "sleep 5", timeout: 30 }, + }; + setToolCallExpanded(timed.toolCallId, true); + t.after(() => setToolCallExpanded(timed.toolCallId, false)); + const timedHtml = renderMessage({ role: "assistant", provider: "anthropic", model: "claude-test", content: [timed] }); + assert.ok(timedHtml.includes("(timeout 30s)")); + assert.ok(!timedHtml.includes(" setToolCallExpanded(extra.toolCallId, false)); + const extraHtml = renderMessage({ role: "assistant", provider: "anthropic", model: "claude-test", content: [extra] }); + assert.ok(extraHtml.includes(" { + const complete = { + type: "toolCall", + toolCallId: "call-bash-streaming-complete", + toolName: "bash", + input: {}, + rawInput: JSON.stringify({ command: "cd /tmp\nls -la" }), + }; + const partial = { + type: "toolCall", + toolCallId: "call-bash-streaming-partial", + toolName: "bash", + input: {}, + rawInput: "{\"command\":\"cd /tmp\\nls -l", + }; + for (const block of [complete, partial]) { + clearExpandedToolCalls(); + setToolCallExpanded(block.toolCallId, true); + try { + const html = renderMessage({ role: "assistant", provider: "anthropic", model: "claude-test", content: [block] }); + const text = textOf(html); + assert.ok(text.includes("cd /tmp\nls -l"), block.toolCallId); + assert.doesNotMatch(text, /"command":/); + assert.ok(!html.includes(" { const block = { type: "toolCall", diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 78ff10777b..a92a52d2fc 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -13,7 +13,7 @@ import { getAssistantErrorMessage, getThinkingPreview, hasAssistantAnswer, isAss import { formatTurnDuration } from "@/lib/turn-duration"; import { diffGapLabel, parseUnifiedPatch, unifiedDiffLines, type SplitDiffCell, type SplitDiffFile, type SplitDiffGap, type UnifiedDiffLine } from "@/lib/patch"; import { applyPatchPreviewToFiles, applyPatchResultHasFailures, extractApplyPatchPaths, getApplyPatchInputText, parseApplyPatchInput } from "@/lib/apply-patch"; -import { isApplyPatchToolName, isEditToolName, isWriteToolName } from "@/lib/tool-names"; +import { isApplyPatchToolName, isBashToolName, isEditToolName, isWriteToolName } from "@/lib/tool-names"; import { isToolCallExpanded, setToolCallExpanded } from "@/lib/tool-call-expansion"; import { isThinkingExpandedByDefault, THINKING_EXPANDED_EVENT } from "@/lib/thinking-expansion-preference"; import { TurnWrittenFiles } from "./TurnWrittenFiles"; @@ -1184,7 +1184,8 @@ function ToolCallBlock({ block, result, duration, onOpenSubagent }: { block: Too setExpanded(next); }; const writtenFileText = getWrittenFileText(block); - const inputStr = writtenFileText ?? getToolCallInputText(block); + const bashView = getBashCommandView(block); + const inputStr = writtenFileText ?? bashView?.command ?? getToolCallInputText(block); const isStreamingInput = block.rawInput !== undefined; const isEditTool = isEditToolName(block.toolName); const resultDiff = result && !result.isError ? getResultDiff(result) : null; @@ -1200,14 +1201,18 @@ function ToolCallBlock({ block, result, duration, onOpenSubagent }: { block: Too const codemodeRunning = codemode !== null && result !== undefined && result.content.length === 0; // Flat arguments as rows instead of a JSON blob, unless the card has a view of - // its own (a write's file text, codemode, an applied patch). See `lib/tool-fields.ts`. + // its own (a write's file text, a shell command's text, codemode, an applied + // patch). See `lib/tool-fields.ts`. const toolFields = - !isStreamingInput && writtenFileText === null && codemode === null && patchFiles === null + !isStreamingInput && writtenFileText === null && bashView === null && codemode === null && patchFiles === null ? toolFieldsOfInput(block.input) : null; // `server/tool` instead of the registered `mcp__server__tool`, as pi's TUI shows it. const mcpLabel = mcpToolLabel(block.toolName, result?.details); + // A shell command's first line (+ timeout, as the TUI's `$ cmd (timeout Ns)`), + // live while it streams instead of raw JSON. Falls back when unparseable. + const bashPreview = bashView !== null ? bashHeaderPreview(bashView) : null; // Result display const resultContent = result ? (codemode ? stripCodemodeHeader(result.content) : result.content) : []; @@ -1264,8 +1269,8 @@ function ToolCallBlock({ block, result, duration, onOpenSubagent }: { block: Too {isStreamingInput - ? t("chat.generatingToolInput") - : (patchLabel ?? (codemode ? codemodeScriptPreview(codemode.code) : getToolPreview(block)))} + ? (bashPreview ?? t("chat.generatingToolInput")) + : (patchLabel ?? (codemode ? codemodeScriptPreview(codemode.code) : (bashPreview ?? getToolPreview(block))))} {codemodeCallCount > 0 && ( @@ -2150,6 +2155,74 @@ function getWrittenFileText(block: ToolCallContent): string | null { return Object.keys(block.input).every((key) => WRITE_VIEW_KEYS.has(key)) ? content : null; } +const BASH_VIEW_KEYS = new Set(["command", "timeout"]); + +interface BashCommandView { + command: string; + timeout?: number; +} + +// A shell command's text in place of its JSON: `JSON.stringify` escapes newlines +// as `\n`, so a multi-line command reads as one escaped blob (and streamed +// input is raw JSON still). A call with any other argument keeps the generic +// view, so nothing is hidden, as with `getWrittenFileText`. +function getBashCommandView(block: ToolCallContent): BashCommandView | null { + if (!isBashToolName(block.toolName)) return null; + if (block.rawInput !== undefined) return extractStreamingBashCommand(block.rawInput); + const input = block.input; + if (input === null || typeof input !== "object" || Array.isArray(input)) return null; + if (!Object.keys(input).every((key) => BASH_VIEW_KEYS.has(key))) return null; + const { command, timeout } = input as Record; + if (typeof command !== "string" || command === "") return null; + if (timeout !== undefined && typeof timeout !== "number") return null; + return timeout === undefined ? { command } : { command, timeout }; +} + +// The shell tool's `{ command, timeout? }` out of streamed raw input, which is +// still incomplete JSON: complete input parses whole, otherwise the command's +// string value is decoded up to the last chunk (a cut `\u` escape included). +function extractStreamingBashCommand(rawInput: string): BashCommandView | null { + try { + const parsed: unknown = JSON.parse(rawInput); + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + const record = parsed as Record; + if (typeof record.command === "string" && record.command !== "") { + return typeof record.timeout === "number" + ? { command: record.command, timeout: record.timeout } + : { command: record.command }; + } + return null; + } + } catch { + // Incomplete JSON: fall through to the partial-string decode below. + } + const match = /"command"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(rawInput); + if (!match) return null; + const command = decodePartialJsonString(match[1]); + return command === null || command === "" ? null : { command }; +} + +// A JSON string value that may end mid-escape: a cut escape is at most 6 chars +// (`\uXXXX`), so trimming a few trailing chars either parses or never will. +function decodePartialJsonString(fragment: string): string | null { + for (let trim = 0; trim <= 6 && trim <= fragment.length; trim++) { + try { + return JSON.parse(`"${fragment.slice(0, fragment.length - trim)}"`); + } catch { + // Try a shorter prefix. + } + } + return null; +} + +// The header's one-line preview: the command's first non-blank line, as pi's +// TUI `$ command (timeout Ns)` suffixes a timeout. +function bashHeaderPreview(view: BashCommandView): string { + const firstLine = view.command.replace(/\r\n?/g, "\n").split("\n").find((line) => line.trim() !== "") ?? ""; + const preview = firstLine.slice(0, 120); + return view.timeout === undefined ? preview : `${preview} (timeout ${view.timeout}s)`; +} + const TOOL_FIELD_LABEL: CSSProperties = { padding: "4px 12px 4px 0", color: "var(--text-dim)", diff --git a/demo/components/MessageView.tsx b/demo/components/MessageView.tsx index 4205a650ff..a2b91bae67 100644 --- a/demo/components/MessageView.tsx +++ b/demo/components/MessageView.tsx @@ -12,7 +12,7 @@ import { parseCompactionSummary } from "@/lib/compaction-summary"; import { getAssistantErrorMessage, getThinkingPreview, hasAssistantAnswer, isAssistantTruncated, isEmptyThinkingBlock, splitThinkingPieces } from "@/lib/message-display"; import { diffGapLabel, parseUnifiedPatch, unifiedDiffLines, type SplitDiffCell, type SplitDiffFile, type SplitDiffGap, type UnifiedDiffLine } from "@/lib/patch"; import { applyPatchPreviewToFiles, applyPatchResultHasFailures, extractApplyPatchPaths, getApplyPatchInputText, parseApplyPatchInput } from "@/lib/apply-patch"; -import { isApplyPatchToolName, isEditToolName, isWriteToolName } from "@/lib/tool-names"; +import { isApplyPatchToolName, isBashToolName, isEditToolName, isWriteToolName } from "@/lib/tool-names"; import { toolFieldsOfInput, type ToolField } from "@/lib/tool-fields"; import { isToolCallExpanded, setToolCallExpanded } from "@/lib/tool-call-expansion"; import { isThinkingExpandedByDefault, THINKING_EXPANDED_EVENT } from "@/lib/thinking-expansion-preference"; @@ -1103,15 +1103,20 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool setExpanded(next); }; const writtenFileText = getWrittenFileText(block); - const inputStr = writtenFileText ?? getToolCallInputText(block); + const bashView = getBashCommandView(block); + const inputStr = writtenFileText ?? bashView?.command ?? getToolCallInputText(block); const isStreamingInput = block.rawInput !== undefined; const isEditTool = isEditToolName(block.toolName); const resultDiff = result && !result.isError ? getResultDiff(result) : null; const patchFiles = getApplyPatchFiles(block, result); // Flat arguments as rows instead of a JSON blob, unless the card has a view of - // its own (a write's file text, an applied patch). See `lib/tool-fields.ts`. + // its own (a write's file text, a shell command's text, an applied patch). + // See `lib/tool-fields.ts`. const toolFields = - !isStreamingInput && writtenFileText === null && patchFiles === null ? toolFieldsOfInput(block.input) : null; + !isStreamingInput && writtenFileText === null && bashView === null && patchFiles === null ? toolFieldsOfInput(block.input) : null; + // A shell command's first line (+ timeout, as the TUI's `$ cmd (timeout Ns)`), + // live while it streams instead of raw JSON. Falls back when unparseable. + const bashPreview = bashView !== null ? bashHeaderPreview(bashView) : null; const patchLabel = isApplyPatchToolName(block.toolName) ? summarizeApplyPatchInput(block) : null; @@ -1159,7 +1164,7 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool {block.toolName} - {isStreamingInput ? t("chat.generatingToolInput") : (patchLabel ?? getToolPreview(block))} + {isStreamingInput ? (bashPreview ?? t("chat.generatingToolInput")) : (patchLabel ?? (bashPreview ?? getToolPreview(block)))} {duration !== undefined && ( {duration}s @@ -1995,6 +2000,74 @@ function getWrittenFileText(block: ToolCallContent): string | null { return Object.keys(block.input).every((key) => WRITE_VIEW_KEYS.has(key)) ? content : null; } +const BASH_VIEW_KEYS = new Set(["command", "timeout"]); + +interface BashCommandView { + command: string; + timeout?: number; +} + +// A shell command's text in place of its JSON: `JSON.stringify` escapes newlines +// as `\n`, so a multi-line command reads as one escaped blob (and streamed +// input is raw JSON still). A call with any other argument keeps the generic +// view, so nothing is hidden, as with `getWrittenFileText`. +function getBashCommandView(block: ToolCallContent): BashCommandView | null { + if (!isBashToolName(block.toolName)) return null; + if (block.rawInput !== undefined) return extractStreamingBashCommand(block.rawInput); + const input = block.input; + if (input === null || typeof input !== "object" || Array.isArray(input)) return null; + if (!Object.keys(input).every((key) => BASH_VIEW_KEYS.has(key))) return null; + const { command, timeout } = input as Record; + if (typeof command !== "string" || command === "") return null; + if (timeout !== undefined && typeof timeout !== "number") return null; + return timeout === undefined ? { command } : { command, timeout }; +} + +// The shell tool's `{ command, timeout? }` out of streamed raw input, which is +// still incomplete JSON: complete input parses whole, otherwise the command's +// string value is decoded up to the last chunk (a cut `\u` escape included). +function extractStreamingBashCommand(rawInput: string): BashCommandView | null { + try { + const parsed: unknown = JSON.parse(rawInput); + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + const record = parsed as Record; + if (typeof record.command === "string" && record.command !== "") { + return typeof record.timeout === "number" + ? { command: record.command, timeout: record.timeout } + : { command: record.command }; + } + return null; + } + } catch { + // Incomplete JSON: fall through to the partial-string decode below. + } + const match = /"command"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(rawInput); + if (!match) return null; + const command = decodePartialJsonString(match[1]); + return command === null || command === "" ? null : { command }; +} + +// A JSON string value that may end mid-escape: a cut escape is at most 6 chars +// (`\uXXXX`), so trimming a few trailing chars either parses or never will. +function decodePartialJsonString(fragment: string): string | null { + for (let trim = 0; trim <= 6 && trim <= fragment.length; trim++) { + try { + return JSON.parse(`"${fragment.slice(0, fragment.length - trim)}"`); + } catch { + // Try a shorter prefix. + } + } + return null; +} + +// The header's one-line preview: the command's first non-blank line, as pi's +// TUI `$ command (timeout Ns)` suffixes a timeout. +function bashHeaderPreview(view: BashCommandView): string { + const firstLine = view.command.replace(/\r\n?/g, "\n").split("\n").find((line) => line.trim() !== "") ?? ""; + const preview = firstLine.slice(0, 120); + return view.timeout === undefined ? preview : `${preview} (timeout ${view.timeout}s)`; +} + const TOOL_FIELD_LABEL: CSSProperties = { padding: "4px 12px 4px 0", color: "var(--text-dim)", diff --git a/demo/lib/tool-names.ts b/demo/lib/tool-names.ts index 92d8480a11..595902f992 100644 --- a/demo/lib/tool-names.ts +++ b/demo/lib/tool-names.ts @@ -24,6 +24,20 @@ export function isEditToolName(toolName: string): boolean { name.includes("replace_editor"); } +/** Shell tools (`bash` / `powershell` share one `{ command, timeout? }` schema). */ +export function isBashToolName(toolName: string): boolean { + const name = toolName.toLowerCase(); + return name === "bash" || + name === "powershell" || + name === "bash (local)" || + name.startsWith("bash_") || + name.startsWith("powershell_") || + name.endsWith(".bash") || + name.endsWith("_bash") || + name.endsWith(".powershell") || + name.endsWith("_powershell"); +} + /** Codex-style patch tools (e.g. the pi-apply-patch extension). */ export function isApplyPatchToolName(toolName: string): boolean { const name = toolName.toLowerCase(); diff --git a/docs/agents/sessions.md b/docs/agents/sessions.md index e99fbb77b6..9e1815e2a2 100644 --- a/docs/agents/sessions.md +++ b/docs/agents/sessions.md @@ -72,7 +72,7 @@ The streaming reply's `t/s` counts estimated tokens from when its first ones sho - A codemode card shows the script in the box any tool's input uses (plain text) and its `details.calls` as rows, never as cards: those calls never reach the model as tool calls. `handleAgentEvent` keeps a running script's progress snapshot in `activeToolResults`, as for shell output, so calls list while they run. - A tool card's time is the result's `durationMs` (pi 1.1: `execute()` measured with a monotonic clock, saved in the session file), formatted as the CLI's "Took" (`formatToolDuration()`), and none under 100 ms. An older result falls back to its timestamp minus the assistant message's, which is when that message's request **started**, so it counts the model's generation too. A thinking block's time likewise prefers the assistant message's own `durationMs`. - A patch takes the columns the viewport has. `SplitFilesView()` reads `useIsMobile()` and gives `PatchDiffFiles` a `layout`: two code columns side by side, or one. Two columns are two code columns, and a phone does not have two — at 390px each half is about 150px, so every line of a real edit wrapped inside its own half and the reader compared two broken columns from memory. The one-column layout is the file viewer's diff (`components/FileViewer.tsx`), which has always been one column, and both now read off the same parsed rows: `unifiedDiffLines()` (`lib/patch.ts`) turns a file's paired rows into one column — each pair as its removed line above the added line beside it, so a block of several changed lines interleaves pair by pair rather than listing every removed line first, as the file viewer always drew it — so the two views cannot drift, and `FileViewer`'s own flattening is that call. A gap row between hunks passes through, so both layouts and the file viewer keep their "⋯ N unchanged lines" markers. One gutter, because a second costs the width the code needed: a removed line keeps its old number and every other line the new one, the rule `FileViewer`'s gutter already used. Hunk headers stay dropped as in the two-column view, and context lines print once rather than twice, which `MessageView.test.mjs` counts. A patch with several files names each section by the file's own path (its new path, the one that survives) instead of the two labels `Before` / `After`, which over one column would read as two columns. The layout is taken once, in the dispatcher both the `apply_patch` card and a result's patch render through, so a phone never gets one view of each; `MessageView.test.mjs` pins that both call sites go through it. -- A tool call whose arguments are all single values (string, number, boolean, null) shows them as rows instead of the input JSON (`lib/tool-fields.ts`, `ToolFieldRows` in `components/MessageView.tsx`): one row per argument in the order the tool received them, a value with newlines of its own in a scrollable block (a subagent's `prompt`). The rule is structural, not a list of tools, and all or nothing: one object or array value (`edit`'s `edits[]`, an MCP argument holding a list) keeps the whole JSON, since a row that drops a subtree hides an argument. A card with a view of its own keeps it: a write of just a path and its text shows the file text (the header has the path), codemode its script and calls, an applied patch its split diff; streamed input and a call with no arguments keep the raw body. +- A tool call whose arguments are all single values (string, number, boolean, null) shows them as rows instead of the input JSON (`lib/tool-fields.ts`, `ToolFieldRows` in `components/MessageView.tsx`): one row per argument in the order the tool received them, a value with newlines of its own in a scrollable block (a subagent's `prompt`). The rule is structural, not a list of tools, and all or nothing: one object or array value (`edit`'s `edits[]`, an MCP argument holding a list) keeps the whole JSON, since a row that drops a subtree hides an argument. A card with a view of its own keeps it: a write of just a path and its text shows the file text (the header has the path), a shell call of just a command (and an optional timeout) shows the command text with real line breaks instead of `JSON.stringify`'s `\n` escapes (the header previews its first line plus the TUI's `(timeout Ns)` suffix), codemode its script and calls, an applied patch its split diff; streamed input and a call with no arguments keep the raw body, except a shell call whose streamed `command` already parses (whole or as a prefix) decodes that far live. ## Custom messages collapse from their header (`CustomMessageView`, `components/MessageView.tsx`) A displayed custom message starts expanded: pi's TUI draws its default custom message in full and has no per-message toggle (only a `registerMessageRenderer` renderer gets the global Ctrl+O `expanded` flag, and pi-web runs no TUI renderers). The header is a button (`aria-expanded`, the tool cards' chevron as its **last** element, the timestamp just before it) that folds the card to that one row, with the text's start on it as a one-line preview, like a tool card's header; the body, the footer (Copy, details) and the details mount only when expanded. The footer button is about `details` only. Session search never lands in a custom message (it matches titles and user/assistant entries only), so the card needs no `reveal`. diff --git a/lib/tool-names.ts b/lib/tool-names.ts index 92d8480a11..595902f992 100644 --- a/lib/tool-names.ts +++ b/lib/tool-names.ts @@ -24,6 +24,20 @@ export function isEditToolName(toolName: string): boolean { name.includes("replace_editor"); } +/** Shell tools (`bash` / `powershell` share one `{ command, timeout? }` schema). */ +export function isBashToolName(toolName: string): boolean { + const name = toolName.toLowerCase(); + return name === "bash" || + name === "powershell" || + name === "bash (local)" || + name.startsWith("bash_") || + name.startsWith("powershell_") || + name.endsWith(".bash") || + name.endsWith("_bash") || + name.endsWith(".powershell") || + name.endsWith("_powershell"); +} + /** Codex-style patch tools (e.g. the pi-apply-patch extension). */ export function isApplyPatchToolName(toolName: string): boolean { const name = toolName.toLowerCase();