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) => {