diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 899b4fb72..b70200fc3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -43,6 +43,16 @@ - Changed agentic commit progress output to show real-time thinking previews and structured tool argument details - Changed agentic commit progress output to display full multi-line assistant messages and render tool arguments with tree-style formatting for improved readability - Changed agentic commit progress output to render assistant messages as formatted Markdown with proper word wrapping +- Changed output block border color to reflect state (error, success, warning) for improved visual feedback +- Changed LSP hover rendering to display documentation text before code blocks in both collapsed and expanded views +- Changed Write tool to show streaming preview of content being written with syntax highlighting +- Changed Read tool to display resolved path information when reading from URLs or symlinks +- Changed Calculator tool result display to show both expression and output (e.g., `2+2 = 4`) instead of just the result +- Changed Python tool output to group status information under a labeled section for clearer organization +- Changed SSH tool output to apply consistent styling to non-ANSI output lines +- Changed Todo Write tool to respect expanded/collapsed state and use standard preview limits +- Changed Web Search related questions to respect expanded/collapsed state instead of always showing all items +- Changed empty and error state rendering across multiple tools (Find, Grep, Ls, Notebook, Calculator, Ask) to include consistent status headers ## [8.0.0] - 2026-01-23 ### Added diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index 70eb2c6a2..c0d531e70 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -52,11 +52,12 @@ export function renderResult( ): Component { const content = result.content?.[0]; if (!content || content.type !== "text" || !("text" in content) || !content.text) { - return new Text(theme.fg("error", "No result"), 0, 0); + const header = renderStatusLine({ icon: "warning", title: "LSP", description: "No result" }, theme); + return new Text([header, theme.fg("dim", "No result")].join("\n"), 0, 0); } const text = content.text; - const lines = text.split("\n").filter((l) => l.trim()); + const lines = text.split("\n"); const expanded = options.expanded; let label = "Result"; @@ -133,6 +134,8 @@ function renderHover( ): string[] { const lang = codeBlockMatch[1] || ""; const code = codeBlockMatch[2].trim(); + const codeStart = codeBlockMatch.index ?? 0; + const beforeCode = fullText.slice(0, codeStart).trimEnd(); const afterCode = fullText.slice(fullText.indexOf("```", 3) + 3).trim(); const codeLines = highlightCode(code, lang, theme); @@ -145,6 +148,11 @@ function renderHover( const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`; const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; let output = `${icon}${langLabel}`; + if (beforeCode) { + for (const line of beforeCode.split("\n")) { + output += `\n ${theme.fg("muted", line)}`; + } + } output += `\n ${theme.fg("mdCodeBlockBorder", top)}`; for (const line of codeLines) { output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${line}`; @@ -158,10 +166,14 @@ function renderHover( // Collapsed view const firstCodeLine = codeLines[0] || ""; - const hasMore = codeLines.length > 1 || Boolean(afterCode); + const hasMore = codeLines.length > 1 || Boolean(afterCode) || Boolean(beforeCode); const expandHint = formatExpandHint(theme, expanded, hasMore); let output = `${icon}${langLabel}${expandHint}`; + if (beforeCode) { + const preview = truncate(beforeCode, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis); + output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`; + } const h = theme.boxSharp.horizontal; const v = theme.boxSharp.vertical; const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index d62a70f50..8e391ba82 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -374,12 +374,14 @@ export class ToolExecutionComponent extends Container { // Check for custom tool rendering if (this.tool && (this.tool.renderCall || this.tool.renderResult)) { const tool = this.tool; + const mergeCallAndResult = Boolean((tool as { mergeCallAndResult?: boolean }).mergeCallAndResult); // Custom tools use Box for flexible component rendering this.contentBox.setBgFn(bgFn); this.contentBox.clear(); // Render call component - if (tool.renderCall) { + const shouldRenderCall = !this.result || !mergeCallAndResult; + if (shouldRenderCall && tool.renderCall) { try { const callComponent = tool.renderCall(this.args, theme); if (callComponent) { @@ -547,13 +549,13 @@ export class ToolExecutionComponent extends Container { if (this.toolName === "bash" && this.result) { // Pass raw output and expanded state - renderer handles width-aware truncation - const output = this.getTextOutput().trim(); + const output = this.getTextOutput().trimEnd(); context.output = output; context.expanded = this.expanded; context.previewLines = BASH_DEFAULT_PREVIEW_LINES; context.timeout = typeof this.args?.timeout === "number" ? this.args.timeout : undefined; } else if (this.toolName === "python" && this.result) { - const output = this.getTextOutput().trim(); + const output = this.getTextOutput().trimEnd(); context.output = output; context.expanded = this.expanded; context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES; diff --git a/packages/coding-agent/src/patch/shared.ts b/packages/coding-agent/src/patch/shared.ts index 54d15d5bb..915ffb12d 100644 --- a/packages/coding-agent/src/patch/shared.ts +++ b/packages/coding-agent/src/patch/shared.ts @@ -19,6 +19,7 @@ import { truncateDiffByHunk, } from "$c/tools/render-utils"; import type { RenderCallOptions } from "$c/tools/renderers"; +import { renderStatusLine } from "$c/tui"; import type { DiffError, DiffResult, Operation } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ @@ -178,9 +179,28 @@ export const editToolRenderer = { let text = `${ui.title(opTitle)} ${spinner ? `${spinner} ` : ""}${editIcon} ${pathDisplay}`; // Show streaming preview of diff/content - const streamingContent = args.diff ?? args.newText ?? args.patch; - if (streamingContent) { - text += formatStreamingDiff(streamingContent, rawPath, uiTheme); + if (args.diff && args.op) { + text += formatStreamingDiff(args.diff, rawPath, uiTheme); + } else if (args.diff) { + const previewLines = args.diff.split("\n"); + const maxLines = 6; + text += "\n\n"; + for (const line of previewLines.slice(0, maxLines)) { + text += `${uiTheme.fg("toolOutput", ui.truncate(line, 80))}\n`; + } + if (previewLines.length > maxLines) { + text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} ${previewLines.length - maxLines} more lines`); + } + } else if (args.newText || args.patch) { + const previewLines = (args.newText ?? args.patch ?? "").split("\n"); + const maxLines = 6; + text += "\n\n"; + for (const line of previewLines.slice(0, maxLines)) { + text += `${uiTheme.fg("toolOutput", ui.truncate(line, 80))}\n`; + } + if (previewLines.length > maxLines) { + text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} ${previewLines.length - maxLines} more lines`); + } } return new Text(text, 0, 0); @@ -221,7 +241,15 @@ export const editToolRenderer = { // Show operation type for patch mode const opTitle = op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit"; - let text = `${uiTheme.fg("toolTitle", uiTheme.bold(opTitle))} ${editIcon} ${pathDisplay}`; + const header = renderStatusLine( + { + icon: result.isError ? "error" : "success", + title: opTitle, + description: `${editIcon} ${pathDisplay}`, + }, + uiTheme, + ); + let text = header; // Skip metadata line for delete operations if (op !== "delete") { diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 3ce17a843..ddd5d35d4 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -230,7 +230,7 @@ function renderOutputSection( maxExpanded = 10, ): string[] { const lines: string[] = []; - const trimmedOutput = output.trim(); + const trimmedOutput = output.trimEnd(); if (!trimmedOutput) return lines; if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) { @@ -262,7 +262,7 @@ function renderOutputSection( lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`); - const outputLines = output.split("\n").filter((line) => line.trim()); + const outputLines = output.trimEnd().split("\n"); const previewCount = expanded ? maxExpanded : maxCollapsed; for (const line of outputLines.slice(0, previewCount)) { lines.push(`${continuePrefix} ${theme.fg("dim", truncate(line, 70, theme.format.ellipsis))}`); @@ -730,13 +730,12 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool if (handler?.renderFinal && (dataArray as unknown[]).length > 0) { hasCustomRendering = true; const component = handler.renderFinal(dataArray as unknown[], theme, expanded); + lines.push(`${continuePrefix}${theme.fg("dim", `Tool: ${toolName}`)}`); if (component instanceof Text) { // Prefix each line with continuePrefix const text = component.getText(); for (const line of text.split("\n")) { - if (line.trim()) { - lines.push(`${continuePrefix}${line}`); - } + lines.push(`${continuePrefix}${line}`); } } else if (component instanceof Container) { // For containers, render each child @@ -841,7 +840,7 @@ export function renderResult( } } - const indented = lines.map((line) => (line.trim() ? ` ${line}` : "")); + const indented = lines.map((line) => (line.length > 0 ? ` ${line}` : "")); return new Text(indented.join("\n"), 0, 0); } diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 6422c4d44..60e12f5f1 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -25,6 +25,7 @@ import { type Theme, theme } from "$c/modes/theme/theme"; import askDescription from "$c/prompts/tools/ask.md" with { type: "text" }; import type { ToolSession } from "./index"; import { ToolUIKit } from "./render-utils"; +import { renderStatusLine } from "$c/tui"; // ============================================================================= // Types @@ -381,36 +382,52 @@ export const askToolRenderer = { const { details } = result; if (!details) { const txt = result.content[0]; - return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0); + const fallback = txt?.type === "text" && txt.text ? txt.text : ""; + const header = renderStatusLine({ icon: "warning", title: "Ask" }, uiTheme); + return new Text([header, uiTheme.fg("dim", fallback)].join("\n"), 0, 0); } // Multi-part results if (details.results && details.results.length > 0) { const lines: string[] = []; + const hasAnySelection = details.results.some( + (r) => r.customInput || (r.selectedOptions && r.selectedOptions.length > 0), + ); + const header = renderStatusLine( + { icon: hasAnySelection ? "success" : "warning", title: "Ask", meta: [`${details.results.length} questions`] }, + uiTheme, + ); + lines.push(header); - for (const r of details.results) { + for (let i = 0; i < details.results.length; i++) { + const r = details.results[i]; + const isLastQuestion = i === details.results.length - 1; + const branch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch; + const continuation = isLastQuestion ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `; const hasSelection = r.customInput || r.selectedOptions.length > 0; const statusIcon = hasSelection ? uiTheme.styledSymbol("status.success", "success") : uiTheme.styledSymbol("status.warning", "warning"); - lines.push(`${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`); + lines.push( + ` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`, + ); if (r.customInput) { lines.push( - ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`, + `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`, ); } else if (r.selectedOptions.length > 0) { for (let j = 0; j < r.selectedOptions.length; j++) { const isLast = j === r.selectedOptions.length - 1; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; + const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; lines.push( - ` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`, + `${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`, ); } } else { lines.push( - ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`, + `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`, ); } } @@ -425,11 +442,12 @@ export const askToolRenderer = { } const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0); - const statusIcon = hasSelection - ? uiTheme.styledSymbol("status.success", "success") - : uiTheme.styledSymbol("status.warning", "warning"); + const header = renderStatusLine( + { icon: hasSelection ? "success" : "warning", title: "Ask", description: details.question }, + uiTheme, + ); - let text = `${statusIcon} ${uiTheme.fg("accent", details.question)}`; + let text = header; if (details.customInput) { text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 63b7b1e4a..1e9bacdc5 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -196,21 +196,23 @@ export const bashToolRenderer = { result: { content: Array<{ type: string; text?: string }>; details?: BashToolDetails; + isError?: boolean; }, options: RenderResultOptions & { renderContext?: BashRenderContext }, uiTheme: Theme, args?: BashRenderArgs, ): Component { const cmdText = args ? formatBashCommand(args, uiTheme) : undefined; - const header = renderStatusLine({ icon: "success", title: "Bash" }, uiTheme); + const isError = result.isError === true; + const header = renderStatusLine({ icon: isError ? "error" : "success", title: "Bash" }, uiTheme); const { renderContext } = options; const details = result.details; const expanded = renderContext?.expanded ?? options.expanded; const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES; // Get output from context (preferred) or fall back to result content - const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim(); - const displayOutput = output; + const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? ""); + const displayOutput = output.trimEnd(); const showingFullOutput = expanded && renderContext?.isFullOutput === true; // Build truncation warning lines (static, doesn't depend on width) @@ -244,10 +246,8 @@ export const bashToolRenderer = { return { render: (width: number): string[] => { const outputLines: string[] = []; - if (cmdText) { - outputLines.push(uiTheme.fg("dim", cmdText)); - } - if (displayOutput) { + const hasOutput = displayOutput.trim().length > 0; + if (hasOutput) { if (expanded) { outputLines.push(...displayOutput.split("\n").map((line) => uiTheme.fg("toolOutput", line))); } else { @@ -274,8 +274,11 @@ export const bashToolRenderer = { return renderOutputBlock( { header, - state: "success", - sections: [{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }], + state: isError ? "error" : "success", + sections: [ + { lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] }, + { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }, + ], width, }, uiTheme, diff --git a/packages/coding-agent/src/tools/calculator.ts b/packages/coding-agent/src/tools/calculator.ts index 548c52fe2..32ec94c68 100644 --- a/packages/coding-agent/src/tools/calculator.ts +++ b/packages/coding-agent/src/tools/calculator.ts @@ -9,7 +9,7 @@ import type { Theme } from "$c/modes/theme/theme"; import calculatorDescription from "$c/prompts/tools/calculator.md" with { type: "text" }; import { renderStatusLine, renderTreeList } from "$c/tui"; import type { ToolSession } from "./index"; -import { formatCount, formatEmptyMessage, PREVIEW_LIMITS, TRUNCATE_LENGTHS, truncate } from "./render-utils"; +import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS, TRUNCATE_LENGTHS, truncate } from "./render-utils"; // ============================================================================= // Token Types @@ -458,22 +458,33 @@ export const calculatorToolRenderer = { * Collapsed mode shows first N items with expand hint; expanded shows all. */ renderResult( - result: { content: Array<{ type: string; text?: string }>; details?: CalculatorToolDetails }, + result: { content: Array<{ type: string; text?: string }>; details?: CalculatorToolDetails; isError?: boolean }, { expanded }: RenderResultOptions, uiTheme: Theme, args?: CalculatorRenderArgs, ): Component { const details = result.details; const textContent = result.content?.find((c) => c.type === "text")?.text ?? ""; + if (result.isError) { + const header = renderStatusLine({ icon: "error", title: "Calc" }, uiTheme); + return new Text([header, formatErrorMessage(textContent, uiTheme)].join("\n"), 0, 0); + } // Prefer structured details; fall back to parsing text content - let outputs = details?.results?.map((entry) => entry.output) ?? []; + let outputs = details?.results?.map((entry) => `${entry.expression} = ${entry.output}`) ?? []; if (outputs.length === 0 && textContent.trim()) { - outputs = textContent.split("\n").filter((line) => line.trim().length > 0); + const rawOutputs = textContent.split("\n").filter((line) => line.trim().length > 0); + const expressions = args?.calculations?.map((calc) => calc.expression) ?? []; + if (expressions.length === rawOutputs.length && expressions.length > 0) { + outputs = rawOutputs.map((output, index) => `${expressions[index]} = ${output}`); + } else { + outputs = rawOutputs; + } } if (outputs.length === 0) { - return new Text(formatEmptyMessage("No results", uiTheme), 0, 0); + const header = renderStatusLine({ icon: "warning", title: "Calc" }, uiTheme); + return new Text([header, formatEmptyMessage("No results", uiTheme)].join("\n"), 0, 0); } const description = args?.calculations?.[0]?.expression diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index e5108b42b..79c57d5ab 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -1016,6 +1016,7 @@ export function renderFetchResult( } const domain = getDomain(details.finalUrl); + const path = truncate(details.finalUrl.replace(/^https?:\/\/[^/]+/, ""), 50, uiTheme.format.ellipsis); const hasRedirect = details.url !== details.finalUrl; const hasNotes = details.notes.length > 0; const truncation = details.meta?.truncation; @@ -1025,8 +1026,7 @@ export function renderFetchResult( { icon: truncated ? "warning" : "success", title: "Fetch", - description: domain, - meta: [details.method], + description: `${domain}${path ? ` ${path}` : ""}`, }, uiTheme, ); @@ -1061,7 +1061,7 @@ export function renderFetchResult( const previewLimit = expanded ? 12 : 3; const previewList = applyListLimit(contentLines, { headLimit: previewLimit }); - const previewLines = previewList.items.map((line) => truncate(line.trim(), 120, uiTheme.format.ellipsis)); + const previewLines = previewList.items.map((line) => truncate(line.trimEnd(), 120, uiTheme.format.ellipsis)); const remaining = Math.max(0, contentLines.length - previewLines.length); const contentPreviewLines = previewLines.length > 0 diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 9ddbaa918..f64554342 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -403,7 +403,12 @@ export const findToolRenderer = { const textContent = result.content?.find((c) => c.type === "text")?.text; if (!hasDetailedData) { - if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") { + if ( + !textContent || + textContent.includes("No files matching") || + textContent.includes("No files found") || + textContent.trim() === "" + ) { return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0); } @@ -431,15 +436,17 @@ export const findToolRenderer = { } const fileCount = details?.fileCount ?? 0; - const truncation = details?.meta?.truncation; + const truncation = details?.truncation ?? details?.meta?.truncation; const limits = details?.meta?.limits; - const truncated = Boolean( - details?.truncated || truncation || limits?.resultLimit || limits?.headLimit || limits?.matchLimit, - ); + const truncated = Boolean(details?.truncated || truncation || details?.resultLimitReached || limits?.resultLimit); const files = details?.files ?? []; if (fileCount === 0) { - return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0); + const header = renderStatusLine( + { icon: "warning", title: "Find", description: args?.pattern, meta: ["0 files"] }, + uiTheme, + ); + return new Text([header, formatEmptyMessage("No files found", uiTheme)].join("\n"), 0, 0); } const meta: string[] = [formatCount("file", fileCount)]; if (details?.scopePath) meta.push(`in ${details.scopePath}`); @@ -459,9 +466,11 @@ export const findToolRenderer = { ); const truncationReasons: string[] = []; + if (details?.resultLimitReached) truncationReasons.push(`limit ${details.resultLimitReached} results`); if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`); if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit"); - if (truncation?.artifactId) truncationReasons.push(`full output: artifact://${truncation.artifactId}`); + const artifactId = truncation && "artifactId" in truncation ? truncation.artifactId : undefined; + if (artifactId) truncationReasons.push(`full output: artifact://${artifactId}`); const extraLines: string[] = []; if (truncationReasons.length > 0) { diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index 3623976e5..787938793 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -678,7 +678,11 @@ export const grepToolRenderer = { const files = details?.files ?? []; if (matchCount === 0) { - return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0); + const header = renderStatusLine( + { icon: "warning", title: "Grep", description: args?.pattern, meta: ["0 matches"] }, + uiTheme, + ); + return new Text([header, formatEmptyMessage("No matches found", uiTheme)].join("\n"), 0, 0); } const summaryParts = @@ -694,6 +698,22 @@ export const grepToolRenderer = { uiTheme, ); + if (mode === "content") { + const textContent = result.content?.find((c) => c.type === "text")?.text ?? ""; + const contentLines = textContent.split("\n").filter((line) => line.trim().length > 0); + const matchLines = renderTreeList( + { + items: contentLines, + expanded, + maxCollapsed: COLLAPSED_TEXT_LIMIT, + itemType: "match", + renderItem: (line) => uiTheme.fg("toolOutput", line), + }, + uiTheme, + ); + return new Text([header, ...matchLines].join("\n"), 0, 0); + } + const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length ? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count })) : files.map((path) => ({ path })); diff --git a/packages/coding-agent/src/tools/ls.ts b/packages/coding-agent/src/tools/ls.ts index 978837fa2..f46aeb319 100644 --- a/packages/coding-agent/src/tools/ls.ts +++ b/packages/coding-agent/src/tools/ls.ts @@ -227,14 +227,22 @@ export const lsToolRenderer = { const textContent = result.content?.find((c) => c.type === "text")?.text ?? ""; if (result.isError) { - return new Text(formatErrorMessage(textContent, uiTheme), 0, 0); + const header = renderStatusLine( + { icon: "error", title: "Ls", description: args?.path || "." }, + uiTheme, + ); + return new Text([header, formatErrorMessage(textContent, uiTheme)].join("\n"), 0, 0); } if ( (!textContent || textContent.trim() === "" || textContent.trim() === "(empty directory)") && (!details?.entries || details.entries.length === 0) ) { - return new Text(formatEmptyMessage("Empty directory", uiTheme), 0, 0); + const header = renderStatusLine( + { icon: "warning", title: "Ls", description: args?.path || "." }, + uiTheme, + ); + return new Text([header, formatEmptyMessage("Empty directory", uiTheme)].join("\n"), 0, 0); } let entries: string[] = details?.entries ? [...details.entries] : []; @@ -246,7 +254,11 @@ export const lsToolRenderer = { } if (entries.length === 0) { - return new Text(` ${formatEmptyMessage("Empty directory", uiTheme)}`, 0, 0); + const header = renderStatusLine( + { icon: "warning", title: "Ls", description: args?.path || "." }, + uiTheme, + ); + return new Text([header, formatEmptyMessage("Empty directory", uiTheme)].join("\n"), 0, 0); } let dirCount = details?.dirCount; @@ -254,8 +266,11 @@ export const lsToolRenderer = { if (dirCount === undefined || fileCount === undefined) { dirCount = 0; fileCount = 0; - for (const entry of entries) { - if (entry.endsWith("/")) { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const raw = rawEntries?.[i] ?? entry; + const name = raw.split(" (")[0]; + if (name.endsWith("/")) { dirCount += 1; } else { fileCount += 1; diff --git a/packages/coding-agent/src/tools/notebook.ts b/packages/coding-agent/src/tools/notebook.ts index 9a57e9e9a..81391da49 100644 --- a/packages/coding-agent/src/tools/notebook.ts +++ b/packages/coding-agent/src/tools/notebook.ts @@ -194,9 +194,12 @@ export class NotebookTool implements AgentTool renderCodeCell( diff --git a/packages/coding-agent/src/tools/python.ts b/packages/coding-agent/src/tools/python.ts index 0c546ddf1..cd12e6c2c 100644 --- a/packages/coding-agent/src/tools/python.ts +++ b/packages/coding-agent/src/tools/python.ts @@ -793,13 +793,18 @@ export const pythonToolRenderer = { const lines: string[] = []; for (let i = 0; i < cells.length; i++) { const cell = cells[i]; + const cellTitle = cell.title; + const combinedTitle = + cellTitle && workdirLabel + ? `${workdirLabel} · ${cellTitle}` + : cellTitle ?? workdirLabel; const cellLines = renderCodeCell( { code: cell.code, language: "python", index: i, total: cells.length, - title: cell.title, + title: combinedTitle, status: "pending", width, codeMaxLines: PYTHON_DEFAULT_PREVIEW_LINES, @@ -829,7 +834,8 @@ export const pythonToolRenderer = { const expanded = renderContext?.expanded ?? options.expanded; const previewLines = renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES; - const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim(); + const output = + renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trimEnd(); const jsonOutputs = details?.jsonOutputs ?? []; const jsonLines = jsonOutputs.flatMap((value, index) => { @@ -880,7 +886,12 @@ export const pythonToolRenderer = { ), ); } - outputLines.push(...statusLines); + if (statusLines.length > 0) { + if (outputLines.length > 0) { + outputLines.push(uiTheme.fg("dim", "Status")); + } + outputLines.push(...statusLines); + } const cellLines = renderCodeCell( { code: cell.code, @@ -934,7 +945,9 @@ export const pythonToolRenderer = { } if (!combinedOutput && statusLines.length > 0) { - const lines = [...statusLines, timeoutLine, warningLine].filter(Boolean) as string[]; + const lines = [uiTheme.fg("dim", "Status"), ...statusLines, timeoutLine, warningLine].filter( + Boolean, + ) as string[]; return new Text(lines.join("\n"), 0, 0); } @@ -943,7 +956,12 @@ export const pythonToolRenderer = { .split("\n") .map((line) => uiTheme.fg("toolOutput", line)) .join("\n"); - const lines = [styledOutput, ...statusLines, timeoutLine, warningLine].filter(Boolean) as string[]; + const lines = [ + styledOutput, + ...(statusLines.length > 0 ? [uiTheme.fg("dim", "Status"), ...statusLines] : []), + timeoutLine, + warningLine, + ].filter(Boolean) as string[]; return new Text(lines.join("\n"), 0, 0); } @@ -975,8 +993,11 @@ export const pythonToolRenderer = { outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); } outputLines.push(...cachedLines); - for (const statusLine of statusLines) { - outputLines.push(truncateToWidth(statusLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); + if (statusLines.length > 0) { + outputLines.push(truncateToWidth(uiTheme.fg("dim", "Status"), width, uiTheme.fg("dim", uiTheme.format.ellipsis))); + for (const statusLine of statusLines) { + outputLines.push(truncateToWidth(statusLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); + } } if (timeoutLine) { outputLines.push(truncateToWidth(timeoutLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index a56c8e9bb..a709a977c 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -14,7 +14,7 @@ import readDescription from "$c/prompts/tools/read.md" with { type: "text" }; import type { ToolSession } from "$c/sdk"; import type { OutputMeta } from "$c/tools/output-meta"; import { ToolAbortError, ToolError, throwIfAborted } from "$c/tools/tool-errors"; -import { renderCodeCell, renderStatusLine } from "$c/tui"; +import { renderCodeCell, renderOutputBlock, renderStatusLine } from "$c/tui"; import { formatDimensionNote, resizeImage } from "$c/utils/image-resize"; import { detectSupportedImageMimeTypeFromFile } from "$c/utils/mime"; import { ensureTool } from "$c/utils/tools-manager"; @@ -379,6 +379,7 @@ const readSchema = Type.Object({ export interface ReadToolDetails { truncation?: TruncationResult; redirectedTo?: "ls"; + resolvedPath?: string; meta?: OutputMeta; } @@ -709,11 +710,11 @@ export class ReadTool implements AgentTool { // If extraction was used, return directly (no pagination) if (hasExtraction) { - let text = resource.content; + const details: ReadToolDetails = {}; if (resource.sourcePath) { - text += `\n\n[Resolved path: ${resource.sourcePath}]`; + details.resolvedPath = resource.sourcePath; } - return toolResult().text(text).sourceInternal(url).done(); + return toolResult(details).text(resource.content).sourceInternal(url).done(); } // Apply pagination similar to file reading @@ -807,9 +808,8 @@ export class ReadTool implements AgentTool { details = {}; } - // Append resolved path notice if (resource.sourcePath) { - outputText += `\n\n[Resolved path: ${resource.sourcePath}]`; + details.resolvedPath = resource.sourcePath; } const resultBuilder = toolResult(details).text(outputText).sourceInternal(url); @@ -857,6 +857,7 @@ export const readToolRenderer = { ): Component { const details = result.details; const contentText = result.content?.find((c) => c.type === "text")?.text ?? ""; + const imageContent = result.content?.find((c) => c.type === "image"); const rawPath = args?.file_path || args?.path || ""; const filePath = shortenPath(rawPath); const lang = getLanguageFromPath(rawPath); @@ -864,6 +865,12 @@ export const readToolRenderer = { const warningLines: string[] = []; const truncation = details?.meta?.truncation; const fallback = details?.truncation; + if (details?.redirectedTo) { + warningLines.push(uiTheme.fg("warning", wrapBrackets(`Redirected to ${details.redirectedTo}`, uiTheme))); + } + if (details?.resolvedPath) { + warningLines.push(uiTheme.fg("dim", wrapBrackets(`Resolved path: ${details.resolvedPath}`, uiTheme))); + } if (truncation) { let warning: string; if (fallback?.firstLineExceedsLimit) { @@ -880,6 +887,33 @@ export const readToolRenderer = { warningLines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme))); } + if (imageContent) { + const header = renderStatusLine( + { icon: "success", title: "Read", description: filePath || rawPath || "image" }, + uiTheme, + ); + const detailLines = contentText ? contentText.split("\n").map((line) => uiTheme.fg("toolOutput", line)) : []; + const lines = [...detailLines, ...warningLines]; + return { + render: (width: number) => + renderOutputBlock( + { + header, + state: "success", + sections: [ + { + label: uiTheme.fg("toolTitle", "Details"), + lines: lines.length > 0 ? lines : [uiTheme.fg("dim", "(image)")], + }, + ], + width, + }, + uiTheme, + ), + invalidate: () => {}, + }; + } + let title = filePath ? `Read ${filePath}` : "Read"; if (args?.offset !== undefined || args?.limit !== undefined) { const startLine = args.offset ?? 1; diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index a09ca3025..929f95459 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -264,7 +264,7 @@ export const sshToolRenderer = { const outputLines: string[] = []; const textContent = result.content?.find((c) => c.type === "text")?.text ?? ""; - const output = textContent.trim(); + const output = textContent.trimEnd(); if (output) { if (expanded) { @@ -279,7 +279,10 @@ export const sshToolRenderer = { ), ); } - outputLines.push(...visualLines); + const styledVisual = visualLines.map((line) => + line.includes("\x1b[") ? line : uiTheme.fg("toolOutput", line), + ); + outputLines.push(...styledVisual); } else { const outputLinesRaw = output.split("\n"); const maxLines = 5; diff --git a/packages/coding-agent/src/tools/todo-write.ts b/packages/coding-agent/src/tools/todo-write.ts index 2b2bd4f3d..9b9a8d5ff 100644 --- a/packages/coding-agent/src/tools/todo-write.ts +++ b/packages/coding-agent/src/tools/todo-write.ts @@ -13,6 +13,7 @@ import type { Theme } from "$c/modes/theme/theme"; import todoWriteDescription from "$c/prompts/tools/todo-write.md" with { type: "text" }; import type { ToolSession } from "$c/sdk"; import { renderStatusLine, renderTreeList } from "$c/tui"; +import { PREVIEW_LIMITS } from "./render-utils"; const todoWriteSchema = Type.Object({ todos: Type.Array( @@ -225,17 +226,25 @@ export const todoWriteToolRenderer = { renderResult( result: { content: Array<{ type: string; text?: string }>; details?: TodoWriteToolDetails }, - _options: RenderResultOptions, + options: RenderResultOptions, uiTheme: Theme, _args?: TodoWriteRenderArgs, ): Component { + const { expanded } = options; const todos = result.details?.todos ?? []; - const header = renderStatusLine({ icon: "success", title: "Todos", meta: [`${todos.length} items`] }, uiTheme); + const header = renderStatusLine( + { icon: "success", title: "Todo Write", meta: [`${todos.length} items`] }, + uiTheme, + ); + if (todos.length === 0) { + const fallback = result.content?.find((c) => c.type === "text")?.text ?? "No todos"; + return new Text([header, uiTheme.fg("dim", fallback)].join("\n"), 0, 0); + } const lines = renderTreeList( { items: todos, - expanded: true, - maxCollapsed: todos.length, + expanded, + maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS, itemType: "todo", renderItem: (todo) => formatTodoLine(todo, uiTheme, ""), }, diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index d9aa53c82..f7507889c 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -128,6 +128,8 @@ interface WriteRenderArgs { content?: string; } +const WRITE_STREAMING_PREVIEW_LINES = 12; + function countLines(text: string): number { if (!text) return 0; return text.split("\n").length; @@ -147,14 +149,46 @@ export const writeToolRenderer = { const filePath = shortenPath(rawPath); const pathDisplay = filePath || uiTheme.format.ellipsis; const status = options?.spinnerFrame !== undefined ? "running" : "pending"; + const text = renderStatusLine( + { icon: status, title: "Write", description: pathDisplay, spinnerFrame: options?.spinnerFrame }, + uiTheme, + ); + if (!args.content) { + return new Text(text, 0, 0); + } - const text = renderStatusLine({ icon: status, title: "Write", description: pathDisplay }, uiTheme); - return new Text(text, 0, 0); + const contentLines = args.content.split("\n"); + const displayLines = contentLines.slice(-WRITE_STREAMING_PREVIEW_LINES); + const hidden = contentLines.length - displayLines.length; + const outputLines: string[] = []; + if (hidden > 0) { + outputLines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)`)); + } + outputLines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (streaming)`)); + + return { + render: (width: number) => + renderCodeCell( + { + code: displayLines.join("\n"), + language: getLanguageFromPath(rawPath), + title: filePath ? `Write ${filePath}` : "Write", + status, + spinnerFrame: options?.spinnerFrame, + output: outputLines.join("\n"), + codeMaxLines: WRITE_STREAMING_PREVIEW_LINES, + expanded: true, + width, + }, + uiTheme, + ), + invalidate: () => {}, + }; }, renderResult( result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails }, - { expanded }: RenderResultOptions, + { expanded, isPartial, spinnerFrame }: RenderResultOptions, uiTheme: Theme, args?: WriteRenderArgs, ): Component { @@ -163,15 +197,47 @@ export const writeToolRenderer = { const fileContent = args?.content || ""; const lang = getLanguageFromPath(rawPath); const outputLines: string[] = []; + const lineCount = countLines(fileContent); - outputLines.push(formatMetadataLine(countLines(fileContent), lang ?? "text", uiTheme)); + outputLines.push(formatMetadataLine(lineCount, lang ?? "text", uiTheme)); + + if (isPartial && fileContent) { + const contentLines = fileContent.split("\n"); + const displayLines = contentLines.slice(-WRITE_STREAMING_PREVIEW_LINES); + const hidden = contentLines.length - displayLines.length; + if (hidden > 0) { + outputLines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)`)); + } + outputLines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (streaming)`)); + + return { + render: (width: number) => + renderCodeCell( + { + code: displayLines.join("\n"), + language: lang, + title: filePath ? `Write ${filePath}` : "Write", + status: spinnerFrame !== undefined ? "running" : "pending", + spinnerFrame, + output: outputLines.join("\n"), + codeMaxLines: WRITE_STREAMING_PREVIEW_LINES, + expanded: true, + width, + }, + uiTheme, + ), + invalidate: () => {}, + }; + } if (result.details?.diagnostics) { const diagText = formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp) => uiTheme.getLangIcon(getLanguageFromPath(fp)), ); if (diagText.trim()) { - outputLines.push(...diagText.split("\n")); + const diagLines = diagText.split("\n"); + const firstNonEmpty = diagLines.findIndex((line) => line.trim()); + outputLines.push(...(firstNonEmpty >= 0 ? diagLines.slice(firstNonEmpty) : [])); } } diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index bfe4e195e..d3f5f4965 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -21,7 +21,8 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st const v = theme.boxSharp.vertical; const cap = h.repeat(3); const lineWidth = Math.max(0, width); - const border = (text: string) => theme.fg("dim", text); + const borderColor = state === "error" ? "error" : state === "success" ? "success" : state === "warning" ? "warning" : "dim"; + const border = (text: string) => theme.fg(borderColor, text); const bgFn = state ? (text: string) => theme.bg(getStateBgColor(state), text) : undefined; const buildBarLine = (leftChar: string, label?: string, meta?: string): string => { diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index 7f3c73596..efaa4b856 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -28,7 +28,7 @@ const MAX_EXPANDED_ANSWER_LINES = PREVIEW_LIMITS.EXPANDED_LINES; const MAX_ANSWER_LINE_LEN = TRUNCATE_LENGTHS.LINE; const MAX_SNIPPET_LINES = 2; const MAX_SNIPPET_LINE_LEN = TRUNCATE_LENGTHS.LINE; -const MAX_RELATED_QUESTIONS = 6; +const MAX_COLLAPSED_ITEMS = PREVIEW_LIMITS.COLLAPSED_ITEMS; const MAX_QUERY_PREVIEW = 2; const MAX_QUERY_LEN = 90; const MAX_REQUEST_ID_LEN = 36; @@ -117,13 +117,19 @@ export function renderWebSearchResult( : provider === "exa" ? "Exa" : "Unknown"; - const queryPreview = args?.query ? truncate(args.query, 80, theme.format.ellipsis) : undefined; + const queryPreview = args?.query + ? truncate(args.query, 80, theme.format.ellipsis) + : searchQueries[0] + ? truncate(searchQueries[0], 80, theme.format.ellipsis) + : undefined; const header = renderStatusLine( { icon: sourceCount > 0 ? "success" : "warning", title: "Web Search", description: queryPreview ?? providerLabel, - meta: queryPreview ? [providerLabel, formatCount("source", sourceCount)] : [formatCount("source", sourceCount)], + meta: queryPreview + ? [providerLabel, formatCount("source", sourceCount)] + : [providerLabel, formatCount("source", sourceCount)], }, theme, ); @@ -148,7 +154,7 @@ export function renderWebSearchResult( { items: sources, expanded, - maxCollapsed: MAX_RELATED_QUESTIONS, + maxCollapsed: MAX_COLLAPSED_ITEMS, itemType: "source", renderItem: (src) => { const titleText = @@ -191,16 +197,18 @@ export function renderWebSearchResult( const relatedTree = renderTreeList( { items: relatedLines, - expanded: true, - maxCollapsed: MAX_RELATED_QUESTIONS, + expanded, + maxCollapsed: MAX_COLLAPSED_ITEMS, itemType: "question", renderItem: (line) => theme.fg("muted", line === "No related questions" ? line : `${theme.format.dash} ${line}`), }, theme, ); - if (relatedCount > MAX_RELATED_QUESTIONS) { - relatedTree.push(theme.fg("muted", formatMoreItems(relatedCount - MAX_RELATED_QUESTIONS, "question", theme))); + if (!expanded && relatedCount > MAX_COLLAPSED_ITEMS) { + relatedTree.push( + theme.fg("muted", formatMoreItems(relatedCount - MAX_COLLAPSED_ITEMS, "question", theme)), + ); } const metaLines: string[] = [];