Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions components/MessageView.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<table"));
assert.doesNotMatch(text, /"command":/);
} finally {
clearExpandedToolCalls();
}
});

test("a bash timeout rides in the header, and extra arguments keep the table", (t) => {
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("<table"));

const extra = {
type: "toolCall",
toolCallId: "call-bash-extra",
toolName: "bash",
input: { command: "ls", workdir: "/tmp" },
};
setToolCallExpanded(extra.toolCallId, true);
t.after(() => setToolCallExpanded(extra.toolCallId, false));
const extraHtml = renderMessage({ role: "assistant", provider: "anthropic", model: "claude-test", content: [extra] });
assert.ok(extraHtml.includes("<table"));
});

test("a streaming bash call decodes newlines instead of showing raw JSON", () => {
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("<table"));
} finally {
clearExpandedToolCalls();
}
}
});

test("a nested argument keeps the JSON body: a row cannot hold a subtree", () => {
const block = {
type: "toolCall",
Expand Down
85 changes: 79 additions & 6 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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) : [];
Expand Down Expand Up @@ -1264,8 +1269,8 @@ function ToolCallBlock({ block, result, duration, onOpenSubagent }: { block: Too
</span>
<span style={{ color: "var(--text-dim)", fontFamily: "var(--font-mono)", fontSize: 11, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 }}>
{isStreamingInput
? t("chat.generatingToolInput")
: (patchLabel ?? (codemode ? codemodeScriptPreview(codemode.code) : getToolPreview(block)))}
? (bashPreview ?? t("chat.generatingToolInput"))
: (patchLabel ?? (codemode ? codemodeScriptPreview(codemode.code) : (bashPreview ?? getToolPreview(block))))}
</span>
{codemodeCallCount > 0 && (
<span style={{ fontSize: 11, color: "var(--text-dim)", flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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)",
Expand Down
83 changes: 78 additions & 5 deletions demo/components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1159,7 +1164,7 @@ function ToolCallBlock({ block, result, duration, onOpenSession }: { block: Tool
{block.toolName}
</span>
<span style={{ color: "var(--text-dim)", fontFamily: "var(--font-mono)", fontSize: 11, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 }}>
{isStreamingInput ? t("chat.generatingToolInput") : (patchLabel ?? getToolPreview(block))}
{isStreamingInput ? (bashPreview ?? t("chat.generatingToolInput")) : (patchLabel ?? (bashPreview ?? getToolPreview(block)))}
</span>
{duration !== undefined && (
<span style={{ fontSize: 11, color: "var(--text-dim)", flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>{duration}s</span>
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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)",
Expand Down
14 changes: 14 additions & 0 deletions demo/lib/tool-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading