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();