diff --git a/bun.lock b/bun.lock
index b8a1f76..cfc9374 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,7 +6,9 @@
"dependencies": {
"@letta-ai/letta-client": "^1.7.12",
"glob": "^13.0.0",
+ "highlight.js": "^11.11.1",
"ink-link": "^5.0.0",
+ "lowlight": "^3.3.0",
"open": "^10.2.0",
"sharp": "^0.34.5",
"ws": "^8.19.0",
@@ -98,12 +100,16 @@
"@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
+ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
"@types/picomatch": ["@types/picomatch@4.0.2", "", {}, "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vscode/ripgrep": ["@vscode/ripgrep@1.17.0", "", { "dependencies": { "https-proxy-agent": "^7.0.2", "proxy-from-env": "^1.1.0", "yauzl": "^2.9.2" } }, "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g=="],
@@ -154,8 +160,12 @@
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -178,6 +188,8 @@
"has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="],
+ "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
+
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -214,6 +226,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+ "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
+
"lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
diff --git a/package.json b/package.json
index 7283e07..584ca48 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,9 @@
"dependencies": {
"@letta-ai/letta-client": "^1.7.12",
"glob": "^13.0.0",
+ "highlight.js": "^11.11.1",
"ink-link": "^5.0.0",
+ "lowlight": "^3.3.0",
"open": "^10.2.0",
"sharp": "^0.34.5",
"ws": "^8.19.0"
diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts
index cd2d51e..8b070ea 100644
--- a/src/agent/check-approval.ts
+++ b/src/agent/check-approval.ts
@@ -430,9 +430,7 @@ export async function getResumeData(
return {
pendingApproval,
pendingApprovals,
- messageHistory: prepareMessageHistory(messages, {
- primaryOnly: true,
- }),
+ messageHistory: prepareMessageHistory(messages),
};
}
} else {
@@ -445,7 +443,7 @@ export async function getResumeData(
return {
pendingApproval: null,
pendingApprovals: [],
- messageHistory: prepareMessageHistory(messages, { primaryOnly: true }),
+ messageHistory: prepareMessageHistory(messages),
};
} else {
// Use agent messages API for "default" conversation or when no conversation ID
@@ -520,9 +518,7 @@ export async function getResumeData(
return {
pendingApproval,
pendingApprovals,
- messageHistory: prepareMessageHistory(messages, {
- primaryOnly: true,
- }),
+ messageHistory: prepareMessageHistory(messages),
};
}
} else {
@@ -535,7 +531,7 @@ export async function getResumeData(
return {
pendingApproval: null,
pendingApprovals: [],
- messageHistory: prepareMessageHistory(messages, { primaryOnly: true }),
+ messageHistory: prepareMessageHistory(messages),
};
}
} catch (error) {
diff --git a/src/cli/App.tsx b/src/cli/App.tsx
index 52c54a1..be8451d 100644
--- a/src/cli/App.tsx
+++ b/src/cli/App.tsx
@@ -12990,56 +12990,70 @@ If using apply_patch, use this exact relative patch path: ${applyPatchRelativePa
style={{ flexDirection: "column" }}
>
{(item: StaticItem, index: number) => {
- return (
- 0 ? 1 : 0}>
- {item.kind === "welcome" ? (
-
- ) : item.kind === "user" ? (
-
- ) : item.kind === "reasoning" ? (
-
- ) : item.kind === "assistant" ? (
-
- ) : item.kind === "tool_call" ? (
-
- ) : item.kind === "subagent_group" ? (
-
- ) : item.kind === "error" ? (
-
- ) : item.kind === "status" ? (
-
- ) : item.kind === "event" ? (
- !showCompactionsEnabled &&
- item.eventType === "compaction" ? null : (
-
- )
- ) : item.kind === "separator" ? (
-
- {"─".repeat(columns)}
-
- ) : item.kind === "command" ? (
-
- ) : item.kind === "bash_command" ? (
-
- ) : item.kind === "trajectory_summary" ? (
-
- ) : item.kind === "approval_preview" ? (
-
- ) : null}
-
- );
+ try {
+ return (
+ 0 ? 1 : 0}>
+ {item.kind === "welcome" ? (
+
+ ) : item.kind === "user" ? (
+
+ ) : item.kind === "reasoning" ? (
+
+ ) : item.kind === "assistant" ? (
+
+ ) : item.kind === "tool_call" ? (
+
+ ) : item.kind === "subagent_group" ? (
+
+ ) : item.kind === "error" ? (
+
+ ) : item.kind === "status" ? (
+
+ ) : item.kind === "event" ? (
+ !showCompactionsEnabled &&
+ item.eventType === "compaction" ? null : (
+
+ )
+ ) : item.kind === "separator" ? (
+
+ {"─".repeat(columns)}
+
+ ) : item.kind === "command" ? (
+
+ ) : item.kind === "bash_command" ? (
+
+ ) : item.kind === "trajectory_summary" ? (
+
+ ) : item.kind === "approval_preview" ? (
+
+ ) : null}
+
+ );
+ } catch (err) {
+ console.error(
+ `[Static render error] kind=${item.kind} id=${item.id}`,
+ err,
+ );
+ return (
+
+
+ ⚠ render error: {item.kind} ({String(err)})
+
+
+ );
+ }
}}
diff --git a/src/cli/components/InlineBashApproval.tsx b/src/cli/components/InlineBashApproval.tsx
index 6198008..84c0b7d 100644
--- a/src/cli/components/InlineBashApproval.tsx
+++ b/src/cli/components/InlineBashApproval.tsx
@@ -4,6 +4,7 @@ import { useProgressIndicator } from "../hooks/useProgressIndicator";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { useTextInputCursor } from "../hooks/useTextInputCursor";
import { colors } from "./colors";
+import { SyntaxHighlightedCommand } from "./SyntaxHighlightedCommand";
import { Text } from "./Text";
type BashInfo = {
@@ -151,9 +152,11 @@ export const InlineBashApproval = memo(
{/* Command preview */}
- {bashInfo.command}
+
{bashInfo.description && (
- {bashInfo.description}
+
+ {bashInfo.description}
+
)}
>
diff --git a/src/cli/components/SyntaxHighlightedCommand.tsx b/src/cli/components/SyntaxHighlightedCommand.tsx
new file mode 100644
index 0000000..d85da1a
--- /dev/null
+++ b/src/cli/components/SyntaxHighlightedCommand.tsx
@@ -0,0 +1,172 @@
+import type { ElementContent, RootContent } from "hast";
+import { Box } from "ink";
+import { common, createLowlight } from "lowlight";
+import { memo } from "react";
+import { colors } from "./colors";
+import { Text } from "./Text";
+
+const lowlight = createLowlight(common);
+const BASH_LANGUAGE = "bash";
+const FIRST_LINE_PREFIX = "$ ";
+
+type Props = {
+ command: string;
+ showPrompt?: boolean;
+ prefix?: string;
+ suffix?: string;
+};
+
+type ShellSyntaxPalette = typeof colors.shellSyntax;
+
+/** Styled text span with a resolved color. */
+type StyledSpan = { text: string; color: string };
+
+function colorForClassName(
+ className: string,
+ palette: ShellSyntaxPalette,
+): string {
+ if (className === "hljs-comment") return palette.comment;
+ if (className === "hljs-keyword") return palette.keyword;
+ if (className === "hljs-string" || className === "hljs-regexp") {
+ return palette.string;
+ }
+ if (className === "hljs-number") return palette.number;
+ if (className === "hljs-literal") return palette.literal;
+ if (
+ className === "hljs-built_in" ||
+ className === "hljs-builtin-name" ||
+ className === "hljs-type"
+ ) {
+ return palette.builtIn;
+ }
+ if (
+ className === "hljs-variable" ||
+ className === "hljs-template-variable" ||
+ className === "hljs-params"
+ ) {
+ return palette.variable;
+ }
+ if (className === "hljs-title" || className === "hljs-function") {
+ return palette.title;
+ }
+ if (className === "hljs-attr" || className === "hljs-attribute") {
+ return palette.attr;
+ }
+ if (className === "hljs-meta") return palette.meta;
+ if (
+ className === "hljs-operator" ||
+ className === "hljs-punctuation" ||
+ className === "hljs-symbol"
+ ) {
+ return palette.operator;
+ }
+ if (className === "hljs-subst") return palette.substitution;
+ return palette.text;
+}
+
+/**
+ * Walk the HAST tree depth-first, collecting flat StyledSpan entries.
+ * Newlines within text nodes are preserved so callers can split into lines.
+ */
+function collectSpans(
+ node: RootContent | ElementContent,
+ palette: ShellSyntaxPalette,
+ spans: StyledSpan[],
+ inheritedColor?: string,
+): void {
+ if (node.type === "text") {
+ spans.push({ text: node.value, color: inheritedColor ?? palette.text });
+ return;
+ }
+
+ if (node.type === "element") {
+ const nodeClasses =
+ (node.properties?.className as string[] | undefined) ?? [];
+ const highlightClass = [...nodeClasses]
+ .reverse()
+ .find((name) => name.startsWith("hljs-"));
+ const nodeColor = highlightClass
+ ? colorForClassName(highlightClass, palette)
+ : inheritedColor;
+
+ for (const child of node.children) {
+ collectSpans(child, palette, spans, nodeColor);
+ }
+ }
+}
+
+/**
+ * Highlight the full command at once (preserves heredoc/multi-line parser
+ * state), then split the flat span list at newline boundaries into per-line
+ * arrays.
+ */
+function highlightCommand(
+ command: string,
+ palette: ShellSyntaxPalette,
+): StyledSpan[][] {
+ let spans: StyledSpan[];
+ try {
+ const root = lowlight.highlight(BASH_LANGUAGE, command);
+ spans = [];
+ for (const child of root.children) {
+ collectSpans(child, palette, spans);
+ }
+ } catch {
+ // Fallback: plain text, split by newlines.
+ return command
+ .split("\n")
+ .map((line) => [{ text: line, color: palette.text }]);
+ }
+
+ // Split spans at newline characters into separate lines.
+ const lines: StyledSpan[][] = [[]];
+ for (const span of spans) {
+ const parts = span.text.split("\n");
+ for (let i = 0; i < parts.length; i++) {
+ if (i > 0) {
+ lines.push([]);
+ }
+ const part = parts[i];
+ if (part && part.length > 0) {
+ const currentLine = lines[lines.length - 1];
+ currentLine?.push({ text: part, color: span.color });
+ }
+ }
+ }
+ return lines;
+}
+
+export const SyntaxHighlightedCommand = memo(
+ ({ command, showPrompt = true, prefix, suffix }: Props) => {
+ const palette = colors.shellSyntax;
+ const lines = highlightCommand(command, palette);
+
+ return (
+
+ {lines.map((spans, lineIdx) => {
+ const lineKey = spans.map((s) => s.text).join("");
+ return (
+
+ {showPrompt ? (
+
+ {lineIdx === 0 ? FIRST_LINE_PREFIX : " "}
+
+ ) : null}
+
+ {lineIdx === 0 && prefix ? prefix : null}
+ {spans.map((span) => (
+
+ {span.text}
+
+ ))}
+ {lineIdx === lines.length - 1 && suffix ? suffix : null}
+
+
+ );
+ })}
+
+ );
+ },
+);
+
+SyntaxHighlightedCommand.displayName = "SyntaxHighlightedCommand";
diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx
index e00f137..3db47a8 100644
--- a/src/cli/components/ToolCallMessageRich.tsx
+++ b/src/cli/components/ToolCallMessageRich.tsx
@@ -1,7 +1,7 @@
// existsSync, readFileSync removed - no longer needed since plan content
// is shown via StaticPlanApproval during approval, not in tool result
import { Box } from "ink";
-import { memo } from "react";
+import { Fragment, memo, type ReactNode } from "react";
import { INTERRUPTED_BY_USER } from "../../constants";
import { clipToolReturn } from "../../tools/manager.js";
import type { AdvancedDiffSuccess } from "../helpers/diff";
@@ -22,6 +22,7 @@ import {
isPlanTool,
isSearchTool,
isShellOutputTool,
+ isShellTool,
isTaskTool,
isTodoTool,
} from "../helpers/toolNameMapping.js";
@@ -34,6 +35,55 @@ function isQuestionTool(name: string): boolean {
return name === "AskUserQuestion";
}
+/**
+ * Colorize tool args string with file paths, numbers, and labels.
+ * Regex-based tokenizer that applies shell syntax palette colors.
+ */
+function colorizeArgs(argsStr: string): ReactNode {
+ if (!argsStr) return null;
+
+ const palette = colors.shellSyntax;
+ const parts: ReactNode[] = [];
+ let lastIndex = 0;
+ let key = 0;
+
+ // Group 1: paths containing / (e.g. src/cli/foo.tsx, **/*.ts)
+ // Group 2: filenames with extension (e.g. foo.tsx, package.json)
+ // Group 3: labels before : (e.g. offset, limit)
+ // Group 4: standalone numbers (e.g. 50, 10)
+ const re =
+ /([\w.*?\-@~/]+\/[\w.*?\-@~/]*)|((?<=[(\s,])[\w.-]+\.\w{1,5}(?=[)\s,]|$))|(\w+)(?=\s*:)|(\b\d+\b)/g;
+
+ for (let m = re.exec(argsStr); m !== null; m = re.exec(argsStr)) {
+ if (m.index > lastIndex) {
+ parts.push(
+ {argsStr.slice(lastIndex, m.index)},
+ );
+ }
+
+ const color = m[1]
+ ? palette.string // path with /
+ : m[2]
+ ? palette.string // filename.ext
+ : m[3]
+ ? palette.comment // label (dimmed)
+ : palette.number; // number
+
+ parts.push(
+
+ {m[0]}
+ ,
+ );
+ lastIndex = m.index + m[0].length;
+ }
+
+ if (lastIndex < argsStr.length) {
+ parts.push({argsStr.slice(lastIndex)});
+ }
+
+ return <>{parts}>;
+}
+
import type { StreamingState } from "../helpers/accumulator";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
@@ -49,6 +99,7 @@ import { MarkdownDisplay } from "./MarkdownDisplay.js";
import { MemoryDiffRenderer } from "./MemoryDiffRenderer.js";
import { PlanRenderer } from "./PlanRenderer.js";
import { StreamingOutputDisplay } from "./StreamingOutputDisplay";
+import { SyntaxHighlightedCommand } from "./SyntaxHighlightedCommand";
import { TodoRenderer } from "./TodoRenderer.js";
type ToolCallLine = {
@@ -86,607 +137,607 @@ export const ToolCallMessage = memo(
isStreaming?: boolean;
}) => {
const columns = useTerminalWidth();
+ try {
+ // Parse and format the tool call
+ const rawName = line.name ?? "?";
+ const argsText =
+ typeof line.argsText === "string"
+ ? line.argsText
+ : line.argsText == null
+ ? ""
+ : JSON.stringify(line.argsText);
- // Parse and format the tool call
- const rawName = line.name ?? "?";
- const argsText =
- typeof line.argsText === "string"
- ? line.argsText
- : line.argsText == null
- ? ""
- : JSON.stringify(line.argsText);
-
- // Task tool rendering decision:
- // - Cancelled/rejected: render as error tool call (won't appear in SubagentGroupDisplay)
- // - Finished with success: render as normal tool call (for backfilled tools without subagent data)
- // - In progress: don't render here (SubagentGroupDisplay handles running subagents,
- // and liveItems handles pending approvals via InlineGenericApproval)
- if (isTaskTool(rawName)) {
- const isFinished = line.phase === "finished";
- const subagent = line.toolCallId
- ? getSubagentByToolCallId(line.toolCallId)
- : undefined;
- if (subagent) {
- // Task tool calls with subagent data are handled by SubagentGroupDisplay/Static
- return null;
+ // Task tool rendering decision:
+ // - Cancelled/rejected: render as error tool call (won't appear in SubagentGroupDisplay)
+ // - Finished with success: render as normal tool call (for backfilled tools without subagent data)
+ // - In progress: don't render here (SubagentGroupDisplay handles running subagents,
+ // and liveItems handles pending approvals via InlineGenericApproval)
+ if (isTaskTool(rawName)) {
+ const isFinished = line.phase === "finished";
+ const subagent = line.toolCallId
+ ? getSubagentByToolCallId(line.toolCallId)
+ : undefined;
+ if (subagent) {
+ // Task tool calls with subagent data are handled by SubagentGroupDisplay/Static
+ return null;
+ }
+ if (!isFinished) {
+ // Not finished - SubagentGroupDisplay or approval UI handles this
+ return null;
+ }
+ // Finished Task tools render here (both success and error)
}
- if (!isFinished) {
- // Not finished - SubagentGroupDisplay or approval UI handles this
- return null;
+
+ // Apply tool name remapping
+ let displayName = getDisplayToolName(rawName);
+
+ // For Patch tools, override display name based on patch content
+ // (Add → Write, Update → Update, Delete → Delete)
+ if (isPatchTool(rawName)) {
+ try {
+ const parsedArgs = JSON.parse(argsText);
+ if (parsedArgs.input) {
+ const patchInfo = parsePatchInput(parsedArgs.input);
+ if (patchInfo) {
+ if (patchInfo.kind === "add") displayName = "Write";
+ else if (patchInfo.kind === "update") displayName = "Update";
+ else if (patchInfo.kind === "delete") displayName = "Delete";
+ }
+ }
+ } catch {
+ // Keep default "Patch" name if parsing fails
+ }
}
- // Finished Task tools render here (both success and error)
- }
- // Apply tool name remapping
- let displayName = getDisplayToolName(rawName);
+ // For AskUserQuestion, show friendly header only after completion
+ if (isQuestionTool(rawName)) {
+ if (line.phase === "finished" && line.resultOk !== false) {
+ displayName = "User answered Letta Code's questions:";
+ } else {
+ displayName = "Asking user questions...";
+ }
+ }
- // For Patch tools, override display name based on patch content
- // (Add → Write, Update → Update, Delete → Delete)
- if (isPatchTool(rawName)) {
- try {
- const parsedArgs = JSON.parse(argsText);
- if (parsedArgs.input) {
- const patchInfo = parsePatchInput(parsedArgs.input);
- if (patchInfo) {
- if (patchInfo.kind === "add") displayName = "Write";
- else if (patchInfo.kind === "update") displayName = "Update";
- else if (patchInfo.kind === "delete") displayName = "Delete";
+ const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
+
+ // Determine args display:
+ // - Question tool: hide args (shown in result instead)
+ // - Still streaming + phase "ready": args may be incomplete, show ellipsis
+ // - Phase "running"/"finished" or stream done: args complete, show formatted
+ let args: ReactNode = null;
+ if (!isQuestionTool(rawName)) {
+ const parseArgs = (): {
+ formatted: ReturnType | null;
+ parseable: boolean;
+ } => {
+ if (!argsText.trim()) {
+ return { formatted: null, parseable: true };
+ }
+ try {
+ const formatted = formatArgsDisplay(argsText, rawName);
+ return { formatted, parseable: true };
+ } catch {
+ return { formatted: null, parseable: false };
+ }
+ };
+
+ // Args are complete once running/finished, stream done, or JSON is parseable.
+ const { formatted, parseable } = parseArgs();
+ const argsComplete =
+ parseable ||
+ line.phase === "running" ||
+ line.phase === "finished" ||
+ !isStreaming;
+
+ if (!argsComplete) {
+ args = "(…)";
+ } else {
+ const formattedArgs =
+ formatted ?? formatArgsDisplay(argsText, rawName);
+ // Normalize newlines to spaces to prevent forced line breaks
+ const normalizedDisplay = formattedArgs.display.replace(/\n/g, " ");
+ // For max 2 lines: boxWidth * 2, minus parens (2) and margin (2)
+ const argsBoxWidth = rightWidth - displayName.length;
+ const maxArgsChars = Math.max(0, argsBoxWidth * 2 - 4);
+
+ const needsTruncation = normalizedDisplay.length > maxArgsChars;
+ const truncatedDisplay = needsTruncation
+ ? `${normalizedDisplay.slice(0, maxArgsChars - 1)}…`
+ : normalizedDisplay;
+ if (rawName.toLowerCase() === "taskoutput") {
+ const separator = truncatedDisplay.startsWith("(") ? "" : " ";
+ args = colorizeArgs(separator + truncatedDisplay);
+ } else {
+ args = colorizeArgs(`(${truncatedDisplay})`);
}
}
- } catch {
- // Keep default "Patch" name if parsing fails
}
- }
- // For AskUserQuestion, show friendly header only after completion
- if (isQuestionTool(rawName)) {
- if (line.phase === "finished" && line.resultOk !== false) {
- displayName = "User answered Letta Code's questions:";
- } else {
- displayName = "Asking user questions...";
- }
- }
-
- const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
-
- // Determine args display:
- // - Question tool: hide args (shown in result instead)
- // - Still streaming + phase "ready": args may be incomplete, show ellipsis
- // - Phase "running"/"finished" or stream done: args complete, show formatted
- let args = "";
- if (!isQuestionTool(rawName)) {
- const parseArgs = (): {
- formatted: ReturnType | null;
- parseable: boolean;
- } => {
- if (!argsText.trim()) {
- return { formatted: null, parseable: true };
- }
+ let shellCommand: string | null = null;
+ if (isShellTool(rawName) && argsText.trim()) {
try {
- const formatted = formatArgsDisplay(argsText, rawName);
- return { formatted, parseable: true };
+ const parsedArgs = JSON.parse(argsText);
+ if (typeof parsedArgs.command === "string") {
+ shellCommand = parsedArgs.command;
+ } else if (Array.isArray(parsedArgs.command)) {
+ shellCommand = parsedArgs.command.join(" ");
+ }
} catch {
- return { formatted: null, parseable: false };
+ // Keep shellCommand null and fall back to plain args rendering.
}
+ }
+
+ // If name exceeds available width, fall back to simple wrapped rendering
+ const fallback = displayName.length >= rightWidth;
+
+ const dotColor = (() => {
+ switch (line.phase) {
+ case "streaming":
+ return colors.tool.streaming;
+ case "ready":
+ return colors.tool.pending;
+ case "running":
+ return colors.tool.running;
+ case "finished":
+ return line.resultOk === false
+ ? colors.tool.error
+ : colors.tool.completed;
+ default:
+ return undefined;
+ }
+ })();
+ const dotShouldAnimate =
+ line.phase === "running" || (line.phase === "ready" && !isStreaming);
+
+ // Extract display text from tool result (handles JSON responses)
+ const extractMessageFromResult = (text: string): string => {
+ try {
+ const parsed = JSON.parse(text);
+ // If it's a JSON object with a message field, extract that
+ if (
+ parsed &&
+ typeof parsed === "object" &&
+ typeof parsed.message === "string"
+ ) {
+ return parsed.message;
+ }
+ } catch {
+ // Not JSON or parsing failed, use as-is
+ }
+ return text;
};
- // Args are complete once running/finished, stream done, or JSON is parseable.
- const { formatted, parseable } = parseArgs();
- const argsComplete =
- parseable ||
- line.phase === "running" ||
- line.phase === "finished" ||
- !isStreaming;
+ // Format result for display
+ const getResultElement = () => {
+ if (!line.resultText) return null;
- if (!argsComplete) {
- args = "(…)";
- } else {
- const formattedArgs = formatted ?? formatArgsDisplay(argsText, rawName);
- // Normalize newlines to spaces to prevent forced line breaks
- const normalizedDisplay = formattedArgs.display.replace(/\n/g, " ");
- // For max 2 lines: boxWidth * 2, minus parens (2) and margin (2)
- const argsBoxWidth = rightWidth - displayName.length;
- const maxArgsChars = Math.max(0, argsBoxWidth * 2 - 4);
+ const extractedText = extractMessageFromResult(line.resultText);
+ const prefix = ` ⎿ `; // Match old format: 2 spaces, glyph, 2 spaces
+ const prefixWidth = 5; // Total width of prefix
+ const contentWidth = Math.max(0, columns - prefixWidth);
- const needsTruncation = normalizedDisplay.length > maxArgsChars;
- const truncatedDisplay = needsTruncation
- ? `${normalizedDisplay.slice(0, maxArgsChars - 1)}…`
- : normalizedDisplay;
- if (rawName.toLowerCase() === "taskoutput") {
- const separator = truncatedDisplay.startsWith("(") ? "" : " ";
- args = separator + truncatedDisplay;
- } else {
- args = `(${truncatedDisplay})`;
- }
- }
- }
-
- // If name exceeds available width, fall back to simple wrapped rendering
- const fallback = displayName.length >= rightWidth;
-
- const dotColor = (() => {
- switch (line.phase) {
- case "streaming":
- return colors.tool.streaming;
- case "ready":
- return colors.tool.pending;
- case "running":
- return colors.tool.running;
- case "finished":
- return line.resultOk === false
- ? colors.tool.error
- : colors.tool.completed;
- default:
- return undefined;
- }
- })();
- const dotShouldAnimate =
- line.phase === "running" || (line.phase === "ready" && !isStreaming);
-
- // Extract display text from tool result (handles JSON responses)
- const extractMessageFromResult = (text: string): string => {
- try {
- const parsed = JSON.parse(text);
- // If it's a JSON object with a message field, extract that
- if (
- parsed &&
- typeof parsed === "object" &&
- typeof parsed.message === "string"
- ) {
- return parsed.message;
- }
- } catch {
- // Not JSON or parsing failed, use as-is
- }
- return text;
- };
-
- // Format result for display
- const getResultElement = () => {
- if (!line.resultText) return null;
-
- const extractedText = extractMessageFromResult(line.resultText);
- const prefix = ` ⎿ `; // Match old format: 2 spaces, glyph, 2 spaces
- const prefixWidth = 5; // Total width of prefix
- const contentWidth = Math.max(0, columns - prefixWidth);
-
- // Special cases from old ToolReturnBlock (check before truncation)
- if (line.resultText === "Running...") {
- return (
-
-
- {prefix}
-
-
- Running...
-
-
- );
- }
-
- if (line.resultText === INTERRUPTED_BY_USER) {
- return (
-
-
- {prefix}
-
-
- {INTERRUPTED_BY_USER}
-
-
- );
- }
-
- // Truncate the result text for display (UI only, API gets full response)
- // Strip trailing newlines to avoid extra visual spacing (e.g., from bash echo)
- const displayResultText = clipToolReturn(extractedText).replace(
- /\n+$/,
- "",
- );
-
- // Helper to check if a value is a record
- const isRecord = (v: unknown): v is Record =>
- typeof v === "object" && v !== null;
-
- // Check if this is a todo_write tool with successful result
- if (
- isTodoTool(rawName, displayName) &&
- line.resultOk !== false &&
- line.argsText
- ) {
- try {
- const parsedArgs = JSON.parse(line.argsText);
- if (parsedArgs.todos && Array.isArray(parsedArgs.todos)) {
- // Convert todos to safe format for TodoRenderer
- // Note: Anthropic/Codex use "content", Gemini uses "description"
- const safeTodos = parsedArgs.todos.map((t: unknown, i: number) => {
- const rec = isRecord(t) ? t : {};
- const status: "pending" | "in_progress" | "completed" =
- rec.status === "completed"
- ? "completed"
- : rec.status === "in_progress"
- ? "in_progress"
- : "pending";
- const id = typeof rec.id === "string" ? rec.id : String(i);
- // Handle both "content" (Anthropic/Codex) and "description" (Gemini) fields
- const content =
- typeof rec.content === "string"
- ? rec.content
- : typeof rec.description === "string"
- ? rec.description
- : JSON.stringify(t);
- const priority: "high" | "medium" | "low" | undefined =
- rec.priority === "high"
- ? "high"
- : rec.priority === "medium"
- ? "medium"
- : rec.priority === "low"
- ? "low"
- : undefined;
- return { content, status, id, priority };
- });
-
- // Return TodoRenderer directly - it has its own prefix
- return ;
- }
- } catch {
- // If parsing fails, fall through to regular handling
- }
- }
-
- // Check if this is an update_plan tool with successful result
- if (
- isPlanTool(rawName, displayName) &&
- line.resultOk !== false &&
- line.argsText
- ) {
- try {
- const parsedArgs = JSON.parse(line.argsText);
- if (parsedArgs.plan && Array.isArray(parsedArgs.plan)) {
- // Convert plan items to safe format for PlanRenderer
- const safePlan = parsedArgs.plan.map((item: unknown) => {
- const rec = isRecord(item) ? item : {};
- const status: "pending" | "in_progress" | "completed" =
- rec.status === "completed"
- ? "completed"
- : rec.status === "in_progress"
- ? "in_progress"
- : "pending";
- const step =
- typeof rec.step === "string" ? rec.step : JSON.stringify(item);
- return { step, status };
- });
-
- const explanation =
- typeof parsedArgs.explanation === "string"
- ? parsedArgs.explanation
- : undefined;
-
- // Return PlanRenderer directly - it has its own prefix
- return ;
- }
- } catch {
- // If parsing fails, fall through to regular handling
- }
- }
-
- // Check if this is a memory tool - show diff instead of raw result
- if (isMemoryTool(rawName) && line.resultOk !== false && line.argsText) {
- const memoryDiff = (
-
- );
- if (memoryDiff) {
- return memoryDiff;
- }
- // If MemoryDiffRenderer returns null, fall through to regular handling
- }
-
- // Check if this is AskUserQuestion - show pretty Q&A format
- if (isQuestionTool(rawName) && line.resultOk !== false) {
- // Parse the result to extract questions and answers
- // Format: "Question"="Answer", "Question2"="Answer2"
- const qaPairs: Array<{ question: string; answer: string }> = [];
- const qaRegex = /"([^"]+)"="([^"]*)"/g;
- const resultText = line.resultText || "";
- const matches = resultText.matchAll(qaRegex);
- for (const match of matches) {
- if (match[1] && match[2] !== undefined) {
- qaPairs.push({ question: match[1], answer: match[2] });
- }
- }
-
- if (qaPairs.length > 0) {
- return (
-
- {qaPairs.map((qa) => (
-
-
- {prefix}
-
-
-
- · {qa.question}{" "}
- → {qa.answer}
-
-
-
- ))}
-
- );
- }
- // Fall through to regular handling if parsing fails
- }
-
- // Check if this is ExitPlanMode - just show path, not plan content
- // The plan content was already shown during approval via StaticPlanApproval
- // (rendered via Ink's and is visible in terminal scrollback)
- if (rawName === "ExitPlanMode" && line.resultOk !== false) {
- const planFilePath = lastPlanFilePath;
-
- if (planFilePath) {
+ // Special cases from old ToolReturnBlock (check before truncation)
+ if (line.resultText === "Running...") {
return (
{prefix}
- Plan saved to: {planFilePath}
+ Running...
);
}
- // Fall through to default if no plan path
- }
- // Check if this is a file edit tool - show diff instead of success message
- if (isFileEditTool(rawName) && line.resultOk !== false && line.argsText) {
- const diff = line.toolCallId
- ? precomputedDiffs?.get(line.toolCallId)
- : undefined;
+ if (line.resultText === INTERRUPTED_BY_USER) {
+ return (
+
+
+ {prefix}
+
+
+
+ {INTERRUPTED_BY_USER}
+
+
+
+ );
+ }
- try {
- const parsedArgs = JSON.parse(line.argsText);
- const filePath = parsedArgs.file_path || "";
+ // Truncate the result text for display (UI only, API gets full response)
+ // Strip trailing newlines to avoid extra visual spacing (e.g., from bash echo)
+ const displayResultText = clipToolReturn(extractedText).replace(
+ /\n+$/,
+ "",
+ );
- // Use AdvancedDiffRenderer if we have a precomputed diff
- if (diff) {
+ // Helper to check if a value is a record
+ const isRecord = (v: unknown): v is Record =>
+ typeof v === "object" && v !== null;
+
+ // Check if this is a todo_write tool with successful result
+ if (
+ isTodoTool(rawName, displayName) &&
+ line.resultOk !== false &&
+ line.argsText
+ ) {
+ try {
+ const parsedArgs = JSON.parse(line.argsText);
+ if (parsedArgs.todos && Array.isArray(parsedArgs.todos)) {
+ // Convert todos to safe format for TodoRenderer
+ // Note: Anthropic/Codex use "content", Gemini uses "description"
+ const safeTodos = parsedArgs.todos.map(
+ (t: unknown, i: number) => {
+ const rec = isRecord(t) ? t : {};
+ const status: "pending" | "in_progress" | "completed" =
+ rec.status === "completed"
+ ? "completed"
+ : rec.status === "in_progress"
+ ? "in_progress"
+ : "pending";
+ const id = typeof rec.id === "string" ? rec.id : String(i);
+ // Handle both "content" (Anthropic/Codex) and "description" (Gemini) fields
+ const content =
+ typeof rec.content === "string"
+ ? rec.content
+ : typeof rec.description === "string"
+ ? rec.description
+ : JSON.stringify(t);
+ const priority: "high" | "medium" | "low" | undefined =
+ rec.priority === "high"
+ ? "high"
+ : rec.priority === "medium"
+ ? "medium"
+ : rec.priority === "low"
+ ? "low"
+ : undefined;
+ return { content, status, id, priority };
+ },
+ );
+
+ // Return TodoRenderer directly - it has its own prefix
+ return ;
+ }
+ } catch {
+ // If parsing fails, fall through to regular handling
+ }
+ }
+
+ // Check if this is an update_plan tool with successful result
+ if (
+ isPlanTool(rawName, displayName) &&
+ line.resultOk !== false &&
+ line.argsText
+ ) {
+ try {
+ const parsedArgs = JSON.parse(line.argsText);
+ if (parsedArgs.plan && Array.isArray(parsedArgs.plan)) {
+ // Convert plan items to safe format for PlanRenderer
+ const safePlan = parsedArgs.plan.map((item: unknown) => {
+ const rec = isRecord(item) ? item : {};
+ const status: "pending" | "in_progress" | "completed" =
+ rec.status === "completed"
+ ? "completed"
+ : rec.status === "in_progress"
+ ? "in_progress"
+ : "pending";
+ const step =
+ typeof rec.step === "string"
+ ? rec.step
+ : JSON.stringify(item);
+ return { step, status };
+ });
+
+ const explanation =
+ typeof parsedArgs.explanation === "string"
+ ? parsedArgs.explanation
+ : undefined;
+
+ // Return PlanRenderer directly - it has its own prefix
+ return ;
+ }
+ } catch {
+ // If parsing fails, fall through to regular handling
+ }
+ }
+
+ // Check if this is a memory tool - show diff instead of raw result
+ if (isMemoryTool(rawName) && line.resultOk !== false && line.argsText) {
+ const memoryDiff = (
+
+ );
+ if (memoryDiff) {
+ return memoryDiff;
+ }
+ // If MemoryDiffRenderer returns null, fall through to regular handling
+ }
+
+ // Check if this is AskUserQuestion - show pretty Q&A format
+ if (isQuestionTool(rawName) && line.resultOk !== false) {
+ // Parse the result to extract questions and answers
+ // Format: "Question"="Answer", "Question2"="Answer2"
+ const qaPairs: Array<{ question: string; answer: string }> = [];
+ const qaRegex = /"([^"]+)"="([^"]*)"/g;
+ const resultText = line.resultText || "";
+ const matches = resultText.matchAll(qaRegex);
+ for (const match of matches) {
+ if (match[1] && match[2] !== undefined) {
+ qaPairs.push({ question: match[1], answer: match[2] });
+ }
+ }
+
+ if (qaPairs.length > 0) {
+ return (
+
+ {qaPairs.map((qa) => (
+
+
+ {prefix}
+
+
+
+ · {qa.question}{" "}
+ → {qa.answer}
+
+
+
+ ))}
+
+ );
+ }
+ // Fall through to regular handling if parsing fails
+ }
+
+ // Check if this is ExitPlanMode - just show path, not plan content
+ // The plan content was already shown during approval via StaticPlanApproval
+ // (rendered via Ink's and is visible in terminal scrollback)
+ if (rawName === "ExitPlanMode" && line.resultOk !== false) {
+ const planFilePath = lastPlanFilePath;
+
+ if (planFilePath) {
+ return (
+
+
+ {prefix}
+
+
+ Plan saved to: {planFilePath}
+
+
+ );
+ }
+ // Fall through to default if no plan path
+ }
+
+ // Check if this is a file edit tool - show diff instead of success message
+ if (
+ isFileEditTool(rawName) &&
+ line.resultOk !== false &&
+ line.argsText
+ ) {
+ const diff = line.toolCallId
+ ? precomputedDiffs?.get(line.toolCallId)
+ : undefined;
+
+ try {
+ const parsedArgs = JSON.parse(line.argsText);
+ const filePath = parsedArgs.file_path || "";
+
+ // Use AdvancedDiffRenderer if we have a precomputed diff
+ if (diff) {
+ // Multi-edit: has edits array
+ if (parsedArgs.edits && Array.isArray(parsedArgs.edits)) {
+ const edits = parsedArgs.edits.map(
+ (e: {
+ old_string?: string;
+ new_string?: string;
+ replace_all?: boolean;
+ }) => ({
+ old_string: e.old_string || "",
+ new_string: e.new_string || "",
+ replace_all: e.replace_all,
+ }),
+ );
+ return (
+
+ );
+ }
+ // Single edit
+ return (
+
+ );
+ }
+
+ // Fallback to simple renderers when no precomputed diff
// Multi-edit: has edits array
if (parsedArgs.edits && Array.isArray(parsedArgs.edits)) {
const edits = parsedArgs.edits.map(
- (e: {
- old_string?: string;
- new_string?: string;
- replace_all?: boolean;
- }) => ({
+ (e: { old_string?: string; new_string?: string }) => ({
old_string: e.old_string || "",
new_string: e.new_string || "",
- replace_all: e.replace_all,
}),
);
return (
-
);
}
- // Single edit
- return (
-
- );
- }
- // Fallback to simple renderers when no precomputed diff
- // Multi-edit: has edits array
- if (parsedArgs.edits && Array.isArray(parsedArgs.edits)) {
- const edits = parsedArgs.edits.map(
- (e: { old_string?: string; new_string?: string }) => ({
- old_string: e.old_string || "",
- new_string: e.new_string || "",
- }),
- );
- return (
-
- );
- }
-
- // Single edit: has old_string/new_string
- if (parsedArgs.old_string !== undefined) {
- return (
-
- );
- }
- } catch {
- // If parsing fails, fall through to regular handling
- }
- }
-
- // Check if this is a file write tool - show written content
- if (
- isFileWriteTool(rawName) &&
- line.resultOk !== false &&
- line.argsText
- ) {
- const diff = line.toolCallId
- ? precomputedDiffs?.get(line.toolCallId)
- : undefined;
-
- try {
- const parsedArgs = JSON.parse(line.argsText);
- const filePath = parsedArgs.file_path || "";
- const content = parsedArgs.content || "";
-
- if (filePath && content) {
- if (diff) {
+ // Single edit: has old_string/new_string
+ if (parsedArgs.old_string !== undefined) {
return (
-
);
}
- return ;
+ } catch {
+ // If parsing fails, fall through to regular handling
}
- } catch {
- // If parsing fails, fall through to regular handling
}
- }
- // Check if this is a patch tool - show diff/content based on operation type
- if (isPatchTool(rawName) && line.resultOk !== false && line.argsText) {
- try {
- const parsedArgs = JSON.parse(line.argsText);
- if (parsedArgs.input) {
- const operations = parsePatchOperations(parsedArgs.input);
+ // Check if this is a file write tool - show written content
+ if (
+ isFileWriteTool(rawName) &&
+ line.resultOk !== false &&
+ line.argsText
+ ) {
+ const diff = line.toolCallId
+ ? precomputedDiffs?.get(line.toolCallId)
+ : undefined;
- if (operations.length > 0) {
- return (
-
- {operations.map((op) => {
- // Look up precomputed diff using compound key
- const key = `${line.toolCallId}:${op.path}`;
- const diff = precomputedDiffs?.get(key);
+ try {
+ const parsedArgs = JSON.parse(line.argsText);
+ const filePath = parsedArgs.file_path || "";
+ const content = parsedArgs.content || "";
- if (op.kind === "add") {
- return diff ? (
-
- ) : (
-
- );
- }
- if (op.kind === "update") {
- return diff ? (
-
- ) : (
-
- );
- }
- if (op.kind === "delete") {
- const gutterWidth = 4;
- return (
-
-
-
- {" "}
- ⎿
-
-
-
-
- Deleted {op.path}
-
-
-
- );
- }
- return null;
- })}
-
- );
+ if (filePath && content) {
+ if (diff) {
+ return (
+
+ );
+ }
+ return ;
}
+ } catch {
+ // If parsing fails, fall through to regular handling
}
- } catch {
- // If parsing fails, fall through to regular handling
- }
- }
-
- // Check if this is a file read tool - show line count or image summary
- if (
- isFileReadTool(rawName) &&
- line.resultOk !== false &&
- line.resultText
- ) {
- // Check if this is an image result (starts with "[Image: filename]")
- const isImageResult = line.resultText.startsWith("[Image: ");
-
- if (isImageResult) {
- return (
-
-
- {prefix}
-
-
-
- Read 1 image
-
-
-
- );
}
- // Count lines in the result (the content returned by Read tool)
- const lineCount = line.resultText.split("\n").length;
- return (
-
-
- {prefix}
-
-
-
- Read {lineCount} line
- {lineCount !== 1 ? "s" : ""}
-
-
-
- );
- }
+ // Check if this is a patch tool - show diff/content based on operation type
+ if (isPatchTool(rawName) && line.resultOk !== false && line.argsText) {
+ try {
+ const parsedArgs = JSON.parse(line.argsText);
+ if (parsedArgs.input) {
+ const operations = parsePatchOperations(parsedArgs.input);
- // Check if this is a search/grep tool - show line/file count summary
- if (isSearchTool(rawName) && line.resultOk !== false && line.resultText) {
- const text = line.resultText;
- // Match "Found N file(s)" at start of output (files_with_matches mode)
- const filesMatch = text.match(/^Found (\d+) files?/);
- const noFilesMatch = text === "No files found";
- const noMatchesMatch = text === "No matches found";
+ if (operations.length > 0) {
+ return (
+
+ {operations.map((op) => {
+ // Look up precomputed diff using compound key
+ const key = `${line.toolCallId}:${op.path}`;
+ const diff = precomputedDiffs?.get(key);
- if (filesMatch?.[1]) {
- const count = parseInt(filesMatch[1], 10);
+ if (op.kind === "add") {
+ return diff ? (
+
+ ) : (
+
+ );
+ }
+ if (op.kind === "update") {
+ return diff ? (
+
+ ) : (
+
+ );
+ }
+ if (op.kind === "delete") {
+ const gutterWidth = 4;
+ return (
+
+
+
+ {" "}
+ ⎿
+
+
+
+
+ Deleted {op.path}
+
+
+
+ );
+ }
+ return null;
+ })}
+
+ );
+ }
+ }
+ } catch {
+ // If parsing fails, fall through to regular handling
+ }
+ }
+
+ // Check if this is a file read tool - show line count or image summary
+ if (
+ isFileReadTool(rawName) &&
+ line.resultOk !== false &&
+ line.resultText
+ ) {
+ // Check if this is an image result (starts with "[Image: filename]")
+ const isImageResult = line.resultText.startsWith("[Image: ");
+
+ if (isImageResult) {
+ return (
+
+
+ {prefix}
+
+
+
+ Read 1 image
+
+
+
+ );
+ }
+
+ // Count lines in the result (the content returned by Read tool)
+ const lineCount = line.resultText.split("\n").length;
return (
@@ -694,198 +745,251 @@ export const ToolCallMessage = memo(
- Found {count} file{count !== 1 ? "s" : ""}
-
-
-
- );
- } else if (noFilesMatch || noMatchesMatch) {
- return (
-
-
- {prefix}
-
-
-
- Found 0 {noFilesMatch ? "files" : "matches"}
-
-
-
- );
- } else {
- // Content mode - count lines in the output
- const lineCount = text.split("\n").length;
- return (
-
-
- {prefix}
-
-
-
- Found {lineCount} line
+ Read {lineCount} line
{lineCount !== 1 ? "s" : ""}
);
}
- }
- // Check if this is a glob tool - show file count summary
- if (isGlobTool(rawName) && line.resultOk !== false && line.resultText) {
- const text = line.resultText;
- const filesMatch = text.match(/^Found (\d+) files?/);
- const noFilesMatch = text === "No files found";
+ // Check if this is a search/grep tool - show line/file count summary
+ if (
+ isSearchTool(rawName) &&
+ line.resultOk !== false &&
+ line.resultText
+ ) {
+ const text = line.resultText;
+ // Match "Found N file(s)" at start of output (files_with_matches mode)
+ const filesMatch = text.match(/^Found (\d+) files?/);
+ const noFilesMatch = text === "No files found";
+ const noMatchesMatch = text === "No matches found";
- if (filesMatch?.[1]) {
- const count = parseInt(filesMatch[1], 10);
- return (
-
-
- {prefix}
+ if (filesMatch?.[1]) {
+ const count = parseInt(filesMatch[1], 10);
+ return (
+
+
+ {prefix}
+
+
+
+ Found {count} file{count !== 1 ? "s" : ""}
+
+
-
-
- Found {count} file{count !== 1 ? "s" : ""}
-
+ );
+ } else if (noFilesMatch || noMatchesMatch) {
+ return (
+
+
+ {prefix}
+
+
+
+ Found 0{" "}
+ {noFilesMatch ? "files" : "matches"}
+
+
-
- );
- } else if (noFilesMatch) {
- return (
-
-
- {prefix}
+ );
+ } else {
+ // Content mode - count lines in the output
+ const lineCount = text.split("\n").length;
+ return (
+
+
+ {prefix}
+
+
+
+ Found {lineCount} line
+ {lineCount !== 1 ? "s" : ""}
+
+
-
-
- Found 0 files
-
-
-
- );
+ );
+ }
}
- // Fall through to default if no match pattern found
- }
- // Regular result handling
- const isError = line.resultOk === false;
+ // Check if this is a glob tool - show file count summary
+ if (isGlobTool(rawName) && line.resultOk !== false && line.resultText) {
+ const text = line.resultText;
+ const filesMatch = text.match(/^Found (\d+) files?/);
+ const noFilesMatch = text === "No files found";
- // Try to parse JSON for cleaner error display
- let displayText = displayResultText;
- try {
- const parsed = JSON.parse(displayResultText);
- if (parsed.error && typeof parsed.error === "string") {
- displayText = parsed.error;
+ if (filesMatch?.[1]) {
+ const count = parseInt(filesMatch[1], 10);
+ return (
+
+
+ {prefix}
+
+
+
+ Found {count} file{count !== 1 ? "s" : ""}
+
+
+
+ );
+ } else if (noFilesMatch) {
+ return (
+
+
+ {prefix}
+
+
+
+ Found 0 files
+
+
+
+ );
+ }
+ // Fall through to default if no match pattern found
}
- } catch {
- // Not JSON, use raw text
- }
- // Format tool denial errors more user-friendly
- if (isError && displayText.includes("request to call tool denied")) {
- // Use [\s\S]+ to match multiline reasons
- const match = displayText.match(/User reason: ([\s\S]+)$/);
- const reason = match?.[1]?.trim() || "(empty)";
- displayText = `User rejected the tool call with reason: ${reason}`;
- }
+ // Regular result handling
+ const isError = line.resultOk === false;
+
+ // Try to parse JSON for cleaner error display
+ let displayText = displayResultText;
+ try {
+ const parsed = JSON.parse(displayResultText);
+ if (parsed.error && typeof parsed.error === "string") {
+ displayText = parsed.error;
+ }
+ } catch {
+ // Not JSON, use raw text
+ }
+
+ // Format tool denial errors more user-friendly
+ if (isError && displayText.includes("request to call tool denied")) {
+ // Use [\s\S]+ to match multiline reasons
+ const match = displayText.match(/User reason: ([\s\S]+)$/);
+ const reason = match?.[1]?.trim() || "(empty)";
+ displayText = `User rejected the tool call with reason: ${reason}`;
+ }
+
+ return (
+
+
+ {prefix}
+
+
+ {isError ? (
+ {displayText}
+ ) : (
+
+ )}
+
+
+ );
+ };
return (
-
-
- {prefix}
+
+ {/* Tool call with exact wrapping logic from old codebase */}
+
+
+
+
+
+
+ {fallback ? (
+
+ {isMemoryTool(rawName) ? (
+ <>
+
+ {displayName}
+
+ {args}
+ >
+ ) : (
+ <>
+ {displayName}
+ {args}
+ >
+ )}
+
+ ) : (
+
+
+ {displayName}
+
+ {shellCommand ? (
+
+
+
+ ) : args ? (
+
+ {args}
+
+ ) : null}
+
+ )}
+
-
- {isError ? (
- {displayText}
- ) : (
-
+
+ {/* Streaming output for shell tools during execution */}
+ {isShellOutputTool(rawName) &&
+ line.phase === "running" &&
+ line.streaming && (
+
)}
-
+
+ {/* Collapsed output for shell tools after completion */}
+ {isShellOutputTool(rawName) &&
+ line.phase === "finished" &&
+ line.resultText &&
+ line.resultOk !== false && (
+
+ )}
+
+ {/* Tool result for non-shell tools or shell tool errors */}
+ {(() => {
+ // Show default result element when:
+ // - Not a shell tool (always show result)
+ // - Shell tool with error (show error message)
+ // - Shell tool in streaming/ready phase (show default "Running..." etc)
+ const showDefaultResult =
+ !isShellOutputTool(rawName) ||
+ (line.phase === "finished" && line.resultOk === false) ||
+ (line.phase !== "running" && line.phase !== "finished");
+ return showDefaultResult ? getResultElement() : null;
+ })()}
);
- };
-
- return (
-
- {/* Tool call with exact wrapping logic from old codebase */}
-
-
-
-
-
-
- {fallback ? (
-
- {isMemoryTool(rawName) ? (
- <>
-
- {displayName}
-
- {args}
- >
- ) : (
- <>
- {displayName}
- {args}
- >
- )}
-
- ) : (
-
-
- {displayName}
-
- {args ? (
-
- {args}
-
- ) : null}
-
- )}
-
-
-
- {/* Streaming output for shell tools during execution */}
- {isShellOutputTool(rawName) &&
- line.phase === "running" &&
- line.streaming && (
-
- )}
-
- {/* Collapsed output for shell tools after completion */}
- {isShellOutputTool(rawName) &&
- line.phase === "finished" &&
- line.resultText &&
- line.resultOk !== false && (
-
- )}
-
- {/* Tool result for non-shell tools or shell tool errors */}
- {(() => {
- // Show default result element when:
- // - Not a shell tool (always show result)
- // - Shell tool with error (show error message)
- // - Shell tool in streaming/ready phase (show default "Running..." etc)
- const showDefaultResult =
- !isShellOutputTool(rawName) ||
- (line.phase === "finished" && line.resultOk === false) ||
- (line.phase !== "running" && line.phase !== "finished");
- return showDefaultResult ? getResultElement() : null;
- })()}
-
- );
+ } catch (err) {
+ console.error(
+ `[ToolCallMessage render error] tool=${line.name} id=${line.id}`,
+ err,
+ );
+ return (
+
+ ⚠ render error: {line.name ?? "?"} ({String(err)})
+
+ );
+ }
},
);
diff --git a/src/cli/components/colors.ts b/src/cli/components/colors.ts
index f616cdc..43d0884 100644
--- a/src/cli/components/colors.ts
+++ b/src/cli/components/colors.ts
@@ -145,6 +145,42 @@ const _colors = {
dot: brandColors.statusError, // Red dot in output
},
+ // Shell command syntax highlighting
+ shellSyntaxDark: {
+ prompt: "#cba6f7",
+ text: "#cdd6f4",
+ comment: "#6c7086",
+ keyword: "#89b4fa",
+ string: "#a6e3a1",
+ number: "#fab387",
+ literal: "#fab387",
+ builtIn: "#f9e2af",
+ variable: "#f5c2e7",
+ title: "#94e2d5",
+ attr: "#74c7ec",
+ operator: "#bac2de",
+ punctuation: "#bac2de",
+ meta: "#f38ba8",
+ substitution: "#f5c2e7",
+ },
+ shellSyntaxLight: {
+ prompt: "#8839ef",
+ text: "#4c4f69",
+ comment: "#9ca0b0",
+ keyword: "#1e66f5",
+ string: "#40a02b",
+ number: "#fe640b",
+ literal: "#fe640b",
+ builtIn: "#df8e1d",
+ variable: "#ea76cb",
+ title: "#179299",
+ attr: "#209fb5",
+ operator: "#5c5f77",
+ punctuation: "#5c5f77",
+ meta: "#d20f39",
+ substitution: "#e64553",
+ },
+
// Todo list
todo: {
completed: brandColors.primaryAccent, // Same blue as in-progress, with strikethrough
@@ -220,6 +256,12 @@ const _colors = {
export const colors = {
..._colors,
+ get shellSyntax() {
+ return getTerminalTheme() === "light"
+ ? _colors.shellSyntaxLight
+ : _colors.shellSyntaxDark;
+ },
+
// User messages (past prompts) - theme-aware background
// Uses getter to read theme at render time (after async init)
get userMessage() {
diff --git a/src/cli/components/previews/BashPreview.tsx b/src/cli/components/previews/BashPreview.tsx
index a47ff40..d1d84cc 100644
--- a/src/cli/components/previews/BashPreview.tsx
+++ b/src/cli/components/previews/BashPreview.tsx
@@ -2,6 +2,7 @@ import { Box } from "ink";
import { memo } from "react";
import { useTerminalWidth } from "../../hooks/useTerminalWidth";
import { colors } from "../colors";
+import { SyntaxHighlightedCommand } from "../SyntaxHighlightedCommand";
import { Text } from "../Text";
const SOLID_LINE = "─";
@@ -36,7 +37,7 @@ export const BashPreview = memo(({ command, description }: Props) => {
{/* Command preview */}
- {command}
+
{description && {description}}
>
diff --git a/vendor/ink-text-input/build/index.js b/vendor/ink-text-input/build/index.js
index edc1116..c13d9d1 100644
--- a/vendor/ink-text-input/build/index.js
+++ b/vendor/ink-text-input/build/index.js
@@ -70,21 +70,21 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask,
let renderedValue = value;
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
if (showCursor && focus) {
- renderedPlaceholder = placeholder.length > 0 ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) : chalk.inverse(' ');
- renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
+ renderedPlaceholder = placeholder.length > 0 ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) : chalk.inverse('\u00A0');
+ renderedValue = value.length > 0 ? '' : chalk.inverse('\u00A0');
let i = 0;
for (const char of value) {
const isCursorPosition = i >= cursorOffset - cursorActualWidth && i <= cursorOffset;
if (isCursorPosition && char === '\n') {
// Newline at cursor: show inverted space (visible cursor) then the newline
- renderedValue += chalk.inverse(' ') + char;
+ renderedValue += chalk.inverse('\u00A0') + char;
} else {
renderedValue += isCursorPosition ? chalk.inverse(char) : char;
}
i++;
}
if (value.length > 0 && cursorOffset === value.length) {
- renderedValue += chalk.inverse(' ');
+ renderedValue += chalk.inverse('\u00A0');
}
}
useInput((input, key) => {