From 763faa1615fcdafdfb17435873406d8ab4b07f84 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 23 Jan 2026 13:46:01 +0100 Subject: [PATCH] refactor(coding-agent): migrated tool rendering to declarative TUI component APIs - Created new TUI component library with renderStatusLine, renderOutputBlock, renderCodeCell, renderFileList, and renderTreeList utilities for consistent tool output rendering. - Refactored 13+ tools to use centralized TUI components, replacing inline theme formatting and ToolUIKit class usage with declarative component APIs. - Moved tree rendering utilities (getTreeBranch, getTreeContinuePrefix) from tool render-utils to TUI module for improved code organization. - Streamlined render function return types across tools from Text to Component objects with render/invalidate methods for better state management. --- packages/coding-agent/CHANGELOG.md | 20 ++ packages/coding-agent/src/lsp/render.ts | 111 +++++--- packages/coding-agent/src/sdk.ts | 14 +- packages/coding-agent/src/task/render.ts | 8 +- packages/coding-agent/src/tools/bash.ts | 111 +++----- packages/coding-agent/src/tools/calculator.ts | 61 ++-- packages/coding-agent/src/tools/fetch.ts | 166 +++++------ packages/coding-agent/src/tools/find.ts | 122 +++----- packages/coding-agent/src/tools/grep.ts | 147 ++++------ packages/coding-agent/src/tools/ls.ts | 85 ++---- packages/coding-agent/src/tools/notebook.ts | 88 ++---- packages/coding-agent/src/tools/python.ts | 222 +++------------ packages/coding-agent/src/tools/read.ts | 39 ++- .../coding-agent/src/tools/render-utils.ts | 16 +- packages/coding-agent/src/tools/ssh.ts | 51 ++-- packages/coding-agent/src/tools/todo-write.ts | 30 +- packages/coding-agent/src/tools/write.ts | 116 ++++---- packages/coding-agent/src/tui/code-cell.ts | 110 +++++++ packages/coding-agent/src/tui/file-list.ts | 48 ++++ packages/coding-agent/src/tui/index.ts | 11 + packages/coding-agent/src/tui/output-block.ts | 64 +++++ packages/coding-agent/src/tui/status-line.ts | 40 +++ packages/coding-agent/src/tui/tree-list.ts | 56 ++++ packages/coding-agent/src/tui/types.ts | 17 ++ packages/coding-agent/src/tui/utils.ts | 49 ++++ .../coding-agent/src/web/search/render.ts | 268 +++++++----------- 26 files changed, 1053 insertions(+), 1017 deletions(-) create mode 100644 packages/coding-agent/src/tui/code-cell.ts create mode 100644 packages/coding-agent/src/tui/file-list.ts create mode 100644 packages/coding-agent/src/tui/index.ts create mode 100644 packages/coding-agent/src/tui/output-block.ts create mode 100644 packages/coding-agent/src/tui/status-line.ts create mode 100644 packages/coding-agent/src/tui/tree-list.ts create mode 100644 packages/coding-agent/src/tui/types.ts create mode 100644 packages/coding-agent/src/tui/utils.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6c43b5044..86c62ad99 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,9 +6,29 @@ - Added `omp commit` command to generate conventional commits with changelog updates - Added configurable settings for map-reduce analysis including concurrency, timeout, file thresholds, and token limits - Added support for excluding YAML lock files (`.lock.yml`, `.lock.yaml`, `-lock.yml`, `-lock.yaml`) from commit analysis +- Added new TUI component library with reusable rendering utilities including code cells, file lists, tree lists, status lines, and output blocks +- Added renderCodeCell component for displaying code with optional output sections, supporting syntax highlighting and status indicators +- Added renderFileList component for rendering file/directory listings with language icons and metadata +- Added renderTreeList component for hierarchical tree-based item rendering with expand/collapse support +- Added renderStatusLine component for standardized tool status headers with icons, descriptions, and metadata +- Added renderOutputBlock component for bordered output containers with structured sections +- Added renderOutputBlock to Bash tool for improved output formatting with status indicators ### Changed - Changed changelog diff truncation limit to be configurable via settings +- Changed tool result rendering to use new TUI component library across multiple tools (bash, calculator, fetch, find, grep, ls, notebook, python, read, ssh, write, lsp, web search) for consistent output formatting +- Changed Bash tool output rendering to use renderOutputBlock with proper section handling and width-aware truncation +- Changed Python tool output rendering to use renderCodeCell component for code cell display with status indicators +- Changed Read tool output rendering to use renderCodeCell with syntax highlighting and warnings display +- Changed Write tool output rendering to use renderCodeCell for code display with streaming preview support +- Changed Fetch tool output rendering to use renderOutputBlock with metadata and content preview sections +- Changed LSP tool output rendering to use renderStatusLine and renderOutputBlock for structured output display +- Changed Web Search result rendering to use renderOutputBlock with answer, sources, related questions, and metadata sections +- Changed Find, Grep, and Ls tools to use renderFileList and renderTreeList for consistent file/item listing +- Changed Calculator tool result rendering to use renderTreeList for result item display +- Changed Notebook and TodoWrite tools to use new TUI rendering components for consistent output format +- Refactored render-utils to move tree-related utilities to TUI module (getTreeBranch, getTreeContinuePrefix) +- Changed import organization in sdk.ts for consistency ## [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 c05d0d72c..e6ec462fa 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -9,10 +9,11 @@ */ import type { AgentToolResult, RenderResultOptions } from "@oh-my-pi/pi-agent-core"; -import { Text } from "@oh-my-pi/pi-tui"; +import { type Component, Text } from "@oh-my-pi/pi-tui"; import { highlight, supportsLanguage } from "cli-highlight"; import { getLanguageFromPath, type Theme } from "$c/modes/theme/theme"; import { formatExpandHint, formatMoreItems, TRUNCATE_LENGTHS, truncate } from "$c/tools/render-utils"; +import { renderOutputBlock, renderStatusLine } from "$c/tui"; import type { LspParams, LspToolDetails } from "./types"; // ============================================================================= @@ -25,16 +26,13 @@ import type { LspParams, LspToolDetails } from "./types"; */ export function renderCall(args: unknown, theme: Theme): Text { const p = args as LspParams & { file?: string; files?: string[] }; - - let text = theme.fg("toolTitle", theme.bold("LSP")); - text += ` ${theme.fg("accent", p.action || "?")}`; - + const meta: string[] = []; if (p.file) { - text += ` ${theme.fg("muted", p.file)}`; + meta.push(p.file); } else if (p.files?.length) { - text += ` ${theme.fg("muted", `${p.files.length} file(s)`)}`; + meta.push(`${p.files.length} file(s)`); } - + const text = renderStatusLine({ icon: "pending", title: "LSP", description: p.action || "?", meta }, theme); return new Text(text, 0, 0); } @@ -50,7 +48,7 @@ export function renderResult( result: AgentToolResult, options: RenderResultOptions, theme: Theme, -): Text { +): 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); @@ -60,30 +58,55 @@ export function renderResult( const lines = text.split("\n").filter((l) => l.trim()); const expanded = options.expanded; - // Detect result type and render accordingly + let label = "Result"; + let state: "success" | "warning" | "error" = "success"; + let bodyLines: string[] = []; + const codeBlockMatch = text.match(/```(\w*)\n([\s\S]*?)```/); if (codeBlockMatch) { - return renderHover(codeBlockMatch, text, lines, expanded, theme); + label = "Hover"; + bodyLines = renderHover(codeBlockMatch, text, lines, expanded, theme); + } else { + const errorMatch = text.match(/(\d+)\s+error\(s\)/); + const warningMatch = text.match(/(\d+)\s+warning\(s\)/); + if (errorMatch || warningMatch || text.includes(theme.status.error)) { + label = "Diagnostics"; + const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0; + const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0; + state = errorCount > 0 ? "error" : warnCount > 0 ? "warning" : "success"; + bodyLines = renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme); + } else { + const refMatch = text.match(/(\d+)\s+reference\(s\)/); + if (refMatch) { + label = "References"; + bodyLines = renderReferences(refMatch, lines, expanded, theme); + } else { + const symbolsMatch = text.match(/Symbols in (.+):/); + if (symbolsMatch) { + label = "Symbols"; + bodyLines = renderSymbols(symbolsMatch, lines, expanded, theme); + } else { + label = "Response"; + bodyLines = renderGeneric(text, lines, expanded, theme); + } + } + } } - const errorMatch = text.match(/(\d+)\s+error\(s\)/); - const warningMatch = text.match(/(\d+)\s+warning\(s\)/); - if (errorMatch || warningMatch || text.includes(theme.status.error)) { - return renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme); - } - - const refMatch = text.match(/(\d+)\s+reference\(s\)/); - if (refMatch) { - return renderReferences(refMatch, lines, expanded, theme); - } - - const symbolsMatch = text.match(/Symbols in (.+):/); - if (symbolsMatch) { - return renderSymbols(symbolsMatch, lines, expanded, theme); - } - - // Default fallback rendering - return renderGeneric(text, lines, expanded, theme); + const header = renderStatusLine({ icon: state, title: "LSP", description: label }, theme); + return { + render: (width: number) => + renderOutputBlock( + { + header, + state, + sections: [{ label: theme.fg("toolTitle", label), lines: bodyLines }], + width, + }, + theme, + ), + invalidate: () => {}, + }; } // ============================================================================= @@ -99,7 +122,7 @@ function renderHover( _lines: string[], expanded: boolean, theme: Theme, -): Text { +): string[] { const lang = codeBlockMatch[1] || ""; const code = codeBlockMatch[2].trim(); const afterCode = fullText.slice(fullText.indexOf("```", 3) + 3).trim(); @@ -122,7 +145,7 @@ function renderHover( if (afterCode) { output += `\n ${theme.fg("muted", afterCode)}`; } - return new Text(output, 0, 0); + return output.split("\n"); } // Collapsed view @@ -150,7 +173,7 @@ function renderHover( output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`; } - return new Text(output, 0, 0); + return output.split("\n"); } /** @@ -201,7 +224,7 @@ function renderDiagnostics( lines: string[], expanded: boolean, theme: Theme, -): Text { +): string[] { const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0; const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0; @@ -248,7 +271,7 @@ function renderDiagnostics( )}`; } } - return new Text(output, 0, 0); + return output.split("\n"); } // Collapsed view @@ -280,7 +303,7 @@ function renderDiagnostics( )}`; } - return new Text(output, 0, 0); + return output.split("\n"); } // ============================================================================= @@ -290,7 +313,7 @@ function renderDiagnostics( /** * Render references grouped by file. */ -function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): Text { +function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): string[] { const refCount = Number.parseInt(refMatch[1], 10); const icon = refCount > 0 ? theme.styledSymbol("status.success", "success") : theme.styledSymbol("status.warning", "warning"); @@ -364,10 +387,10 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: }; if (expanded) { - return new Text(renderGrouped(files.length, 3, false), 0, 0); + return renderGrouped(files.length, 3, false).split("\n"); } - return new Text(renderGrouped(3, 1, true), 0, 0); + return renderGrouped(3, 1, true).split("\n"); } // ============================================================================= @@ -377,7 +400,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: /** * Render document symbols in a hierarchical tree. */ -function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): Text { +function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): string[] { const fileName = symbolsMatch[1]; const icon = theme.styledSymbol("status.info", "accent"); @@ -445,7 +468,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded output += `\n${prefix}${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg("accent", sym.name)}`; output += `\n${prefix}${theme.fg("dim", detailPrefix)}${theme.fg("muted", `line ${sym.line}`)}`; } - return new Text(output, 0, 0); + return output.split("\n"); } // Collapsed: show first 3 top-level symbols @@ -469,7 +492,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded )}`; } - return new Text(output, 0, 0); + return output.split("\n"); } // ============================================================================= @@ -479,7 +502,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded /** * Generic fallback rendering for unknown result types. */ -function renderGeneric(text: string, lines: string[], expanded: boolean, theme: Theme): Text { +function renderGeneric(text: string, lines: string[], expanded: boolean, theme: Theme): string[] { const hasError = text.includes("Error:") || text.includes(theme.status.error); const hasSuccess = text.includes(theme.status.success) || text.includes("Applied"); @@ -497,7 +520,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: const branch = isLast ? theme.tree.last : theme.tree.branch; output += `\n ${theme.fg("dim", branch)} ${lines[i]}`; } - return new Text(output, 0, 0); + return output.split("\n"); } const firstLine = lines[0] || "No output"; @@ -525,7 +548,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: } } - return new Text(output, 0, 0); + return output.split("\n"); } // ============================================================================= diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 2af1a67f8..28edb8e98 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -26,15 +26,15 @@ * ``` */ +import { existsSync } from "node:fs"; +import { rename } from "node:fs/promises"; +import { join } from "node:path"; import { Agent, type AgentEvent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { type Message, type Model, supportsXhigh } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { logger, postmortem } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import chalk from "chalk"; -import { existsSync } from "node:fs"; -import { rename } from "node:fs/promises"; -import { join } from "node:path"; // Import discovery to register all providers on startup import { loadCapability } from "$c/capability/index"; import { type Rule, ruleCapability } from "$c/capability/rule"; @@ -62,9 +62,9 @@ import { ExtensionRunner, ExtensionToolWrapper, type ExtensionUIContext, + type LoadExtensionsResult, loadExtensionFromFactory, loadExtensions, - type LoadExtensionsResult, type ToolDefinition, wrapRegisteredTools, } from "./extensibility/extensions/index"; @@ -96,18 +96,18 @@ import { createTools, EditTool, FindTool, - getWebSearchTools, GrepTool, - loadSshTool, + getWebSearchTools, LsTool, + loadSshTool, PythonTool, ReadTool, setPreferredImageProvider, setPreferredWebSearchProvider, type Tool, type ToolSession, - warmupLspServers, WriteTool, + warmupLspServers, } from "./tools/index"; import { wrapToolsWithMetaNotice } from "./tools/output-meta"; import { EventBus } from "./utils/event-bus"; diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index eded01cbe..3ce17a843 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -25,6 +25,7 @@ import { type ReportFindingDetails, type SubmitReviewDetails, } from "$c/tools/review"; +import { renderStatusLine } from "$c/tui"; import { subprocessToolRegistry } from "./subprocess-tool-registry"; import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types"; @@ -395,13 +396,8 @@ function renderArgsSection( * Render the tool call arguments. */ export function renderCall(args: TaskParams, theme: Theme): Component { - const label = theme.fg("toolTitle", theme.bold("Task")); - const agentTag = theme.italic( - theme.fg("dim", `${theme.format.bracketLeft}${args.agent}${theme.format.bracketRight}`), - ); - const lines: string[] = []; - lines.push(`${label} ${agentTag}`); + lines.push(renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme)); const contextTemplate = args.context ?? ""; const context = contextTemplate.trim(); diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 10c56674e..2a3262c03 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1,7 +1,7 @@ import { relative, resolve, sep } from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; -import { Text, truncateToWidth } from "@oh-my-pi/pi-tui"; +import { Text } from "@oh-my-pi/pi-tui"; import { Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "$c/config/prompt-templates"; import { type BashExecutorOptions, executeBash } from "$c/exec/bash-executor"; @@ -11,12 +11,13 @@ import type { Theme } from "$c/modes/theme/theme"; import bashDescription from "$c/prompts/tools/bash.md" with { type: "text" }; import type { OutputMeta } from "$c/tools/output-meta"; import { ToolError } from "$c/tools/tool-errors"; +import { renderOutputBlock, renderStatusLine } from "$c/tui"; import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor"; import type { ToolSession } from "./index"; import { allocateOutputArtifact, createTailBuffer } from "./output-utils"; import { resolveToCwd } from "./path-utils"; -import { ToolUIKit } from "./render-utils"; +import { formatBytes, wrapBrackets } from "./render-utils"; import { toolResult } from "./tool-result"; import { DEFAULT_MAX_BYTES } from "./truncate"; @@ -163,9 +164,8 @@ export const BASH_PREVIEW_LINES = 10; export const bashToolRenderer = { renderCall(args: BashRenderArgs, uiTheme: Theme): Component { - const ui = new ToolUIKit(uiTheme); const command = args.command || uiTheme.format.ellipsis; - const prompt = uiTheme.fg("accent", "$"); + const prompt = "$"; const cwd = process.cwd(); let displayWorkdir = args.cwd; @@ -183,10 +183,8 @@ export const bashToolRenderer = { } } - const cmdText = displayWorkdir - ? `${prompt} ${uiTheme.fg("dim", `cd ${displayWorkdir} &&`)} ${command}` - : `${prompt} ${command}`; - const text = ui.title(cmdText); + const cmdText = displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${command}` : `${prompt} ${command}`; + const text = renderStatusLine({ icon: "pending", title: "Bash", description: cmdText }, uiTheme); return new Text(text, 0, 0); }, @@ -198,7 +196,7 @@ export const bashToolRenderer = { options: RenderResultOptions & { renderContext?: BashRenderContext }, uiTheme: Theme, ): Component { - const ui = new ToolUIKit(uiTheme); + const header = renderStatusLine({ icon: "success", title: "Bash" }, uiTheme); const { renderContext } = options; const details = result.details; const expanded = renderContext?.expanded ?? options.expanded; @@ -214,7 +212,10 @@ export const bashToolRenderer = { const timeoutSeconds = renderContext?.timeout; const timeoutLine = typeof timeoutSeconds === "number" - ? uiTheme.fg("dim", ui.wrapBrackets(`Timeout: ${timeoutSeconds}s`)) + ? uiTheme.fg( + "dim", + `${uiTheme.format.bracketLeft}Timeout: ${timeoutSeconds}s${uiTheme.format.bracketRight}`, + ) : undefined; let warningLine: string | undefined; if (truncation && !showingFullOutput) { @@ -226,72 +227,52 @@ export const bashToolRenderer = { warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); } else { warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.outputBytes)} limit)`, + `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`, ); } if (warnings.length > 0) { - warningLine = uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". "))); + warningLine = uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme)); } } - if (!displayOutput) { - // No output - just show warning if any - const lines = [timeoutLine, warningLine].filter(Boolean) as string[]; - return new Text(lines.join("\n"), 0, 0); - } - - if (expanded) { - // Show all lines when expanded - const styledOutput = displayOutput - .split("\n") - .map((line) => uiTheme.fg("toolOutput", line)) - .join("\n"); - const lines = [styledOutput, timeoutLine, warningLine].filter(Boolean) as string[]; - return new Text(lines.join("\n"), 0, 0); - } - - // Collapsed: use width-aware caching component - const styledOutput = displayOutput - .split("\n") - .map((line) => uiTheme.fg("toolOutput", line)) - .join("\n"); - const textContent = `\n${styledOutput}`; - - let cachedWidth: number | undefined; - let cachedLines: string[] | undefined; - let cachedSkipped: number | undefined; - return { render: (width: number): string[] => { - if (cachedLines === undefined || cachedWidth !== width) { - const result = truncateToVisualLines(textContent, previewLines, width); - cachedLines = result.visualLines; - cachedSkipped = result.skippedCount; - cachedWidth = width; - } const outputLines: string[] = []; - if (cachedSkipped && cachedSkipped > 0) { - outputLines.push(""); - const skippedLine = uiTheme.fg( - "dim", - `${uiTheme.format.ellipsis} (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`, - ); - outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); + if (displayOutput) { + if (expanded) { + outputLines.push(...displayOutput.split("\n").map((line) => uiTheme.fg("toolOutput", line))); + } else { + const styledOutput = displayOutput + .split("\n") + .map((line) => uiTheme.fg("toolOutput", line)) + .join("\n"); + const textContent = `\n${styledOutput}`; + const result = truncateToVisualLines(textContent, previewLines, width); + if (result.skippedCount > 0) { + outputLines.push( + uiTheme.fg( + "dim", + `${uiTheme.format.ellipsis} (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (ctrl+o to expand)`, + ), + ); + } + outputLines.push(...result.visualLines); + } } - outputLines.push(...cachedLines); - if (timeoutLine) { - outputLines.push(truncateToWidth(timeoutLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); - } - if (warningLine) { - outputLines.push(truncateToWidth(warningLine, width, uiTheme.fg("warning", uiTheme.format.ellipsis))); - } - return outputLines; - }, - invalidate: () => { - cachedWidth = undefined; - cachedLines = undefined; - cachedSkipped = undefined; + if (timeoutLine) outputLines.push(timeoutLine); + if (warningLine) outputLines.push(warningLine); + + return renderOutputBlock( + { + header, + state: "success", + sections: [{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }], + width, + }, + uiTheme, + ); }, + invalidate: () => {}, }; }, }; diff --git a/packages/coding-agent/src/tools/calculator.ts b/packages/coding-agent/src/tools/calculator.ts index 66f8bea26..1bdbab937 100644 --- a/packages/coding-agent/src/tools/calculator.ts +++ b/packages/coding-agent/src/tools/calculator.ts @@ -7,17 +7,9 @@ import { renderPromptTemplate } from "$c/config/prompt-templates"; import type { RenderResultOptions } from "$c/extensibility/custom-tools/types"; 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, - formatExpandHint, - formatMeta, - formatMoreItems, - PREVIEW_LIMITS, - TRUNCATE_LENGTHS, - truncate, -} from "./render-utils"; +import { formatCount, formatEmptyMessage, PREVIEW_LIMITS, TRUNCATE_LENGTHS, truncate } from "./render-utils"; // ============================================================================= // Token Types @@ -453,16 +445,11 @@ export const calculatorToolRenderer = { * Format: "Calc (N calcs)" */ renderCall(args: CalculatorRenderArgs, uiTheme: Theme): Component { - const label = uiTheme.fg("toolTitle", uiTheme.bold("Calc")); const count = args.calculations?.length ?? 0; const firstExpression = args.calculations?.[0]?.expression; - let text = label; - if (firstExpression) { - text += ` ${uiTheme.fg("accent", truncate(firstExpression, TRUNCATE_LENGTHS.TITLE, "..."))}`; - } - const meta: string[] = []; - if (count > 0) meta.push(formatCount("calc", count)); - text += formatMeta(meta, uiTheme); + const description = firstExpression ? truncate(firstExpression, TRUNCATE_LENGTHS.TITLE, "...") : undefined; + const meta = count > 0 ? [formatCount("calc", count)] : []; + const text = renderStatusLine({ icon: "pending", title: "Calc", description, meta }, uiTheme); return new Text(text, 0, 0); }, @@ -488,29 +475,21 @@ export const calculatorToolRenderer = { return new Text(formatEmptyMessage("No results", uiTheme), 0, 0); } - // Limit visible items in collapsed mode - const maxItems = expanded ? outputs.length : Math.min(outputs.length, COLLAPSED_LIST_LIMIT); - const hasMore = outputs.length > maxItems; - const icon = uiTheme.styledSymbol("status.success", "success"); - const summary = uiTheme.fg("dim", formatCount("result", outputs.length)); - const expandHint = formatExpandHint(uiTheme, expanded, hasMore); - let text = `${icon} ${summary}${expandHint}`; + const header = renderStatusLine( + { icon: "success", title: "Calc", meta: [formatCount("result", outputs.length)] }, + uiTheme, + ); + const lines = renderTreeList( + { + items: outputs, + expanded, + maxCollapsed: COLLAPSED_LIST_LIMIT, + itemType: "result", + renderItem: (output) => uiTheme.fg("toolOutput", output), + }, + uiTheme, + ); - // Render each result as a tree branch - for (let i = 0; i < maxItems; i += 1) { - const isLast = i === maxItems - 1 && !hasMore; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", outputs[i])}`; - } - - // Show overflow indicator for collapsed mode - if (hasMore) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg( - "muted", - formatMoreItems(outputs.length - maxItems, "result", uiTheme), - )}`; - } - - return new Text(text, 0, 0); + return new Text([header, ...lines].join("\n"), 0, 0); }, }; diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index 532124ba9..1bf0ca5a9 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -13,6 +13,7 @@ import { type Theme, theme } from "$c/modes/theme/theme"; import fetchDescription from "$c/prompts/tools/fetch.md" with { type: "text" }; import type { OutputMeta } from "$c/tools/output-meta"; import { ToolAbortError } from "$c/tools/tool-errors"; +import { renderOutputBlock, renderStatusLine } from "$c/tui"; import { ensureTool } from "$c/utils/tools-manager"; import { specialHandlers } from "$c/web/scrapers/index"; import type { RenderResult } from "$c/web/scrapers/types"; @@ -993,8 +994,11 @@ export function renderFetchCall( ): Component { const domain = getDomain(args.url); const path = truncate(args.url.replace(/^https?:\/\/[^/]+/, ""), 50, uiTheme.format.ellipsis); - const icon = uiTheme.styledSymbol("status.pending", "muted"); - const text = `${icon} ${uiTheme.fg("toolTitle", "Fetch")} ${uiTheme.fg("accent", domain)}${uiTheme.fg("dim", path)}`; + const description = `${domain}${path ? ` ${path}` : ""}`.trim(); + const meta: string[] = []; + if (args.raw) meta.push("raw"); + if (args.timeout !== undefined) meta.push(`timeout:${args.timeout}s`); + const text = renderStatusLine({ icon: "pending", title: "Fetch", description, meta }, uiTheme); return new Text(text, 0, 0); } @@ -1016,16 +1020,18 @@ export function renderFetchResult( const hasNotes = details.notes.length > 0; const truncation = details.meta?.truncation; const truncated = Boolean(details.truncated || truncation); - const statusIcon = truncated - ? uiTheme.styledSymbol("status.warning", "warning") - : uiTheme.styledSymbol("status.success", "success"); - const expandHint = formatExpandHint(uiTheme, expanded); - const expandSuffix = expandHint ? ` ${expandHint}` : ""; - let text = `${statusIcon} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandSuffix}`; - // Get content text + const header = renderStatusLine( + { + icon: truncated ? "warning" : "success", + title: "Fetch", + description: domain, + meta: [details.method], + }, + uiTheme, + ); + const contentText = result.content[0]?.text ?? ""; - // Extract just the content part (after the --- separator) const contentBody = contentText.includes("---\n\n") ? contentText.split("---\n\n").slice(1).join("---\n\n") : contentText; @@ -1033,101 +1039,57 @@ export function renderFetchResult( const charCount = contentBody.trim().length; const contentLines = contentBody.split("\n").filter((l) => l.trim()); - if (!expanded) { - // Collapsed view: metadata + preview - const metaLines: string[] = [ - `${uiTheme.fg("muted", "Content-Type:")} ${details.contentType || "unknown"}`, - `${uiTheme.fg("muted", "Method:")} ${details.method}`, - ]; - if (hasRedirect) { - metaLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`); - } - if (truncated) { - metaLines.push(uiTheme.fg("warning", `${uiTheme.status.warning} Output truncated`)); - if (truncation?.artifactId) { - metaLines.push(uiTheme.fg("warning", `Full output: artifact://${truncation.artifactId}`)); - } - } - if (hasNotes) { - metaLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`); - } - - const previewLimit = 3; - const previewList = applyListLimit(contentLines, { headLimit: previewLimit }); - const previewLines = previewList.items.map((line) => truncate(line.trim(), 100, uiTheme.format.ellipsis)); - const detailLines: string[] = [...metaLines]; - - if (previewLines.length === 0) { - detailLines.push(uiTheme.fg("dim", "(no content)")); - } else { - for (const line of previewLines) { - detailLines.push(uiTheme.fg("dim", line)); - } - } - - const remaining = Math.max(0, contentLines.length - previewLines.length); - if (remaining > 0) { - detailLines.push(uiTheme.fg("muted", `${uiTheme.format.ellipsis} ${remaining} more lines`)); - } else { - const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`; - detailLines.push(uiTheme.fg("muted", `${lineLabel}${uiTheme.sep.dot}${charCount} chars`)); - } - - for (let i = 0; i < detailLines.length; i++) { - const isLast = i === detailLines.length - 1; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.vertical; - text += `\n ${uiTheme.fg("dim", branch)} ${detailLines[i]}`; - } - } else { - // Expanded view: structured metadata + bounded content preview - const metaLines: string[] = [ - `${uiTheme.fg("muted", "Content-Type:")} ${details.contentType || "unknown"}`, - `${uiTheme.fg("muted", "Method:")} ${details.method}`, - ]; - if (hasRedirect) { - metaLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`); - } - const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`; - metaLines.push(`${uiTheme.fg("muted", "Lines:")} ${lineLabel}`); - metaLines.push(`${uiTheme.fg("muted", "Chars:")} ${charCount}`); - if (truncated) { - metaLines.push(uiTheme.fg("warning", `${uiTheme.status.warning} Output truncated`)); - if (truncation?.artifactId) { - metaLines.push(uiTheme.fg("warning", `Full output: artifact://${truncation.artifactId}`)); - } - } - if (hasNotes) { - metaLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`); - } - - text += `\n ${uiTheme.fg("dim", uiTheme.tree.branch)} ${uiTheme.fg("accent", "Metadata")}`; - for (let i = 0; i < metaLines.length; i++) { - const isLast = i === metaLines.length - 1; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${uiTheme.fg("dim", branch)} ${metaLines[i]}`; - } - - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("accent", "Content Preview")}`; - const previewLimit = 12; - const previewList = applyListLimit(contentLines, { headLimit: previewLimit }); - const previewLines = previewList.items.map((line) => truncate(line.trim(), 120, uiTheme.format.ellipsis)); - const remaining = Math.max(0, contentLines.length - previewLines.length); - const contentPrefix = uiTheme.fg("dim", " "); - - if (previewLines.length === 0) { - text += `\n ${contentPrefix} ${uiTheme.fg("dim", "(no content)")}`; - } else { - for (const line of previewLines) { - text += `\n ${contentPrefix} ${uiTheme.fg("dim", line)}`; - } - } - - if (remaining > 0) { - text += `\n ${contentPrefix} ${uiTheme.fg("muted", `${uiTheme.format.ellipsis} ${remaining} more lines`)}`; + const metadataLines: string[] = [ + `${uiTheme.fg("muted", "Content-Type:")} ${details.contentType || "unknown"}`, + `${uiTheme.fg("muted", "Method:")} ${details.method}`, + ]; + if (hasRedirect) { + metadataLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`); + } + const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`; + metadataLines.push(`${uiTheme.fg("muted", "Lines:")} ${lineLabel}`); + metadataLines.push(`${uiTheme.fg("muted", "Chars:")} ${charCount}`); + if (truncated) { + metadataLines.push(uiTheme.fg("warning", `${uiTheme.status.warning} Output truncated`)); + if (truncation?.artifactId) { + metadataLines.push(uiTheme.fg("warning", `Full output: artifact://${truncation.artifactId}`)); } } + if (hasNotes) { + metadataLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`); + } - return new Text(text, 0, 0); + 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 remaining = Math.max(0, contentLines.length - previewLines.length); + const contentPreviewLines = + previewLines.length > 0 + ? previewLines.map((line) => uiTheme.fg("dim", line)) + : [uiTheme.fg("dim", "(no content)")]; + if (remaining > 0) { + const hint = formatExpandHint(uiTheme, expanded, true); + contentPreviewLines.push( + uiTheme.fg("muted", `${uiTheme.format.ellipsis} ${remaining} more lines${hint ? ` ${hint}` : ""}`), + ); + } + + return { + render: (width: number) => + renderOutputBlock( + { + header, + state: truncated ? "warning" : "success", + sections: [ + { label: uiTheme.fg("toolTitle", "Metadata"), lines: metadataLines }, + { label: uiTheme.fg("toolTitle", "Content Preview"), lines: contentPreviewLines }, + ], + width, + }, + uiTheme, + ), + invalidate: () => {}, + }; } export const fetchToolRenderer = { diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index e9d576a5f..66ee93be4 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -8,16 +8,17 @@ import type { Static } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "$c/config/prompt-templates"; import type { RenderResultOptions } from "$c/extensibility/custom-tools/types"; -import { getLanguageFromPath, type Theme } from "$c/modes/theme/theme"; +import type { Theme } from "$c/modes/theme/theme"; import findDescription from "$c/prompts/tools/find.md" with { type: "text" }; import type { OutputMeta } from "$c/tools/output-meta"; import { ToolAbortError, ToolError, throwIfAborted } from "$c/tools/tool-errors"; +import { renderFileList, renderStatusLine, renderTreeList } from "$c/tui"; import { ensureTool } from "$c/utils/tools-manager"; import type { ToolSession } from "./index"; import { applyListLimit } from "./list-limit"; import { resolveToCwd } from "./path-utils"; -import { PREVIEW_LIMITS, ToolUIKit } from "./render-utils"; +import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils"; import { toolResult } from "./tool-result"; import { type TruncationResult, truncateHead } from "./truncate"; @@ -371,10 +372,6 @@ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS; export const findToolRenderer = { inline: true, renderCall(args: FindRenderArgs, uiTheme: Theme): Component { - const ui = new ToolUIKit(uiTheme); - const label = ui.title("Find"); - let text = `${uiTheme.format.bullet} ${label} ${uiTheme.fg("accent", args.pattern || "*")}`; - const meta: string[] = []; if (args.path) meta.push(`in ${args.path}`); if (args.type && args.type !== "all") meta.push(`type:${args.type}`); @@ -382,8 +379,10 @@ export const findToolRenderer = { if (args.sortByMtime) meta.push("sort:mtime"); if (args.limit !== undefined) meta.push(`limit:${args.limit}`); - text += ui.meta(meta); - + const text = renderStatusLine( + { icon: "pending", title: "Find", description: args.pattern || "*", meta }, + uiTheme, + ); return new Text(text, 0, 0); }, @@ -392,12 +391,11 @@ export const findToolRenderer = { { expanded }: RenderResultOptions, uiTheme: Theme, ): Component { - const ui = new ToolUIKit(uiTheme); const details = result.details; if (result.isError || details?.error) { const errorText = details?.error || result.content?.find((c) => c.type === "text")?.text || "Unknown error"; - return new Text(` ${ui.errorMessage(errorText)}`, 0, 0); + return new Text(formatErrorMessage(errorText, uiTheme), 0, 0); } const hasDetailedData = details?.fileCount !== undefined; @@ -405,29 +403,25 @@ export const findToolRenderer = { if (!hasDetailedData) { if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") { - return new Text(` ${ui.emptyMessage("No files found")}`, 0, 0); + return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0); } const lines = textContent.split("\n").filter((l) => l.trim()); - const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_LIST_LIMIT); - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - const hasMore = remaining > 0; - - const icon = uiTheme.styledSymbol("status.success", "success"); - const summary = ui.count("file", lines.length); - const expandHint = ui.expandHint(expanded, hasMore); - let text = ` ${icon} ${uiTheme.fg("dim", summary)}${expandHint}`; - - for (let i = 0; i < displayLines.length; i++) { - const isLast = i === displayLines.length - 1 && remaining === 0; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", displayLines[i])}`; - } - if (remaining > 0) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "file"))}`; - } - return new Text(text, 0, 0); + const header = renderStatusLine( + { icon: "success", title: "Find", meta: [formatCount("file", lines.length)] }, + uiTheme, + ); + const listLines = renderTreeList( + { + items: lines, + expanded, + maxCollapsed: COLLAPSED_LIST_LIMIT, + itemType: "file", + renderItem: (line) => uiTheme.fg("accent", line), + }, + uiTheme, + ); + return new Text([header, ...listLines].join("\n"), 0, 0); } const fileCount = details?.fileCount ?? 0; @@ -439,58 +433,32 @@ export const findToolRenderer = { const files = details?.files ?? []; if (fileCount === 0) { - return new Text(` ${ui.emptyMessage("No files found")}`, 0, 0); + return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0); } + const meta: string[] = [formatCount("file", fileCount)]; + if (details?.scopePath) meta.push(`in ${details.scopePath}`); + if (truncated) meta.push(uiTheme.fg("warning", "truncated")); + const header = renderStatusLine({ icon: truncated ? "warning" : "success", title: "Find", meta }, uiTheme); - const icon = uiTheme.styledSymbol("status.success", "success"); - const summaryText = ui.count("file", fileCount); - const scopeLabel = ui.scope(details?.scopePath); - const maxFiles = expanded ? files.length : Math.min(files.length, COLLAPSED_LIST_LIMIT); - const hasMoreFiles = files.length > maxFiles; - const expandHint = ui.expandHint(expanded, hasMoreFiles); - - let text = ` ${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`; + const fileLines = renderFileList( + { + files: files.map((entry) => ({ path: entry, isDirectory: entry.endsWith("/") })), + expanded, + maxCollapsed: COLLAPSED_LIST_LIMIT, + }, + uiTheme, + ); const truncationReasons: string[] = []; - 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}`); + 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 extraLines: string[] = []; + if (truncationReasons.length > 0) { + extraLines.push(uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)); } - const hasTruncation = truncationReasons.length > 0; - - if (files.length > 0) { - for (let i = 0; i < maxFiles; i++) { - const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - const entry = files[i]; - const isDir = entry.endsWith("/"); - const entryPath = isDir ? entry.slice(0, -1) : entry; - const lang = isDir ? undefined : getLanguageFromPath(entryPath); - const entryIcon = isDir - ? uiTheme.fg("accent", uiTheme.icon.folder) - : uiTheme.fg("muted", uiTheme.getLangIcon(lang)); - text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg("accent", entry)}`; - } - - if (hasMoreFiles) { - const moreFilesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last; - text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg( - "muted", - ui.moreItems(files.length - maxFiles, "file"), - )}`; - } - } - - if (hasTruncation) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)}`; - } - - return new Text(text, 0, 0); + return new Text([header, ...fileLines, ...extraLines].join("\n"), 0, 0); }, }; diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index faeac18a4..57e53576e 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -8,16 +8,17 @@ import { Type } from "@sinclair/typebox"; import { $ } from "bun"; import { renderPromptTemplate } from "$c/config/prompt-templates"; import type { RenderResultOptions } from "$c/extensibility/custom-tools/types"; -import { getLanguageFromPath, type Theme } from "$c/modes/theme/theme"; +import type { Theme } from "$c/modes/theme/theme"; import grepDescription from "$c/prompts/tools/grep.md" with { type: "text" }; import type { OutputMeta } from "$c/tools/output-meta"; import { ToolAbortError, ToolError } from "$c/tools/tool-errors"; +import { renderFileList, renderStatusLine, renderTreeList } from "$c/tui"; import { ensureTool } from "$c/utils/tools-manager"; import { untilAborted } from "$c/utils/utils"; import type { ToolSession } from "./index"; import { applyListLimit } from "./list-limit"; import { resolveToCwd } from "./path-utils"; -import { PREVIEW_LIMITS, ToolUIKit } from "./render-utils"; +import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils"; import { toolResult } from "./tool-result"; import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead, truncateLine } from "./truncate"; @@ -600,10 +601,6 @@ const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2; export const grepToolRenderer = { inline: true, renderCall(args: GrepRenderArgs, uiTheme: Theme): Component { - const ui = new ToolUIKit(uiTheme); - const label = ui.title("Grep"); - let text = `${uiTheme.format.bullet} ${label} ${uiTheme.fg("accent", args.pattern || "?")}`; - const meta: string[] = []; if (args.path) meta.push(`in ${args.path}`); if (args.glob) meta.push(`glob:${args.glob}`); @@ -619,8 +616,10 @@ export const grepToolRenderer = { if (args.context !== undefined) meta.push(`context:${args.context}`); if (args.limit !== undefined) meta.push(`limit:${args.limit}`); - text += ui.meta(meta); - + const text = renderStatusLine( + { icon: "pending", title: "Grep", description: args.pattern || "?", meta }, + uiTheme, + ); return new Text(text, 0, 0); }, @@ -629,12 +628,11 @@ export const grepToolRenderer = { { expanded }: RenderResultOptions, uiTheme: Theme, ): Component { - const ui = new ToolUIKit(uiTheme); const details = result.details; if (result.isError || details?.error) { const errorText = details?.error || result.content?.find((c) => c.type === "text")?.text || "Unknown error"; - return new Text(` ${ui.errorMessage(errorText)}`, 0, 0); + return new Text(formatErrorMessage(errorText, uiTheme), 0, 0); } const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined; @@ -642,31 +640,24 @@ export const grepToolRenderer = { if (!hasDetailedData) { const textContent = result.content?.find((c) => c.type === "text")?.text; if (!textContent || textContent === "No matches found") { - return new Text(` ${ui.emptyMessage("No matches found")}`, 0, 0); + return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0); } - const lines = textContent.split("\n").filter((line) => line.trim() !== ""); - const maxLines = expanded ? lines.length : Math.min(lines.length, COLLAPSED_TEXT_LIMIT); - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - const hasMore = remaining > 0; - - const icon = uiTheme.styledSymbol("status.success", "success"); - const summary = ui.count("item", lines.length); - const expandHint = ui.expandHint(expanded, hasMore); - let text = ` ${icon} ${uiTheme.fg("dim", summary)}${expandHint}`; - - for (let i = 0; i < displayLines.length; i++) { - const isLast = i === displayLines.length - 1 && remaining === 0; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", displayLines[i])}`; - } - - if (remaining > 0) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", ui.moreItems(remaining, "item"))}`; - } - - return new Text(text, 0, 0); + const header = renderStatusLine( + { icon: "success", title: "Grep", meta: [formatCount("item", lines.length)] }, + uiTheme, + ); + const listLines = renderTreeList( + { + items: lines, + expanded, + maxCollapsed: COLLAPSED_TEXT_LIMIT, + itemType: "item", + renderItem: (line) => uiTheme.fg("toolOutput", line), + }, + uiTheme, + ); + return new Text([header, ...listLines].join("\n"), 0, 0); } const matchCount = details?.matchCount ?? 0; @@ -685,79 +676,45 @@ export const grepToolRenderer = { const files = details?.files ?? []; if (matchCount === 0) { - return new Text(` ${ui.emptyMessage("No matches found")}`, 0, 0); + return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0); } - const icon = uiTheme.styledSymbol("status.success", "success"); const summaryParts = mode === "files_with_matches" - ? [ui.count("file", fileCount)] - : [ui.count("match", matchCount), ui.count("file", fileCount)]; - const summaryText = summaryParts.join(uiTheme.sep.dot); - const scopeLabel = ui.scope(details?.scopePath); + ? [formatCount("file", fileCount)] + : [formatCount("match", matchCount), formatCount("file", fileCount)]; + const meta = [...summaryParts]; + if (details?.scopePath) meta.push(`in ${details.scopePath}`); + if (truncated) meta.push(uiTheme.fg("warning", "truncated")); + const header = renderStatusLine({ icon: truncated ? "warning" : "success", title: "Grep", meta }, uiTheme); const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length ? details.fileMatches.map((entry) => ({ path: entry.path, count: entry.count })) : files.map((path) => ({ path })); - const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, COLLAPSED_LIST_LIMIT); - const hasMoreFiles = fileEntries.length > maxFiles; - const expandHint = ui.expandHint(expanded, hasMoreFiles); - - let text = ` ${icon} ${uiTheme.fg("dim", summaryText)}${ui.truncationSuffix(truncated)}${scopeLabel}${expandHint}`; + const fileLines = renderFileList( + { + files: fileEntries.map((entry) => ({ + path: entry.path, + isDirectory: entry.path.endsWith("/"), + meta: entry.count !== undefined ? `(${entry.count} match${entry.count !== 1 ? "es" : ""})` : undefined, + })), + expanded, + maxCollapsed: COLLAPSED_LIST_LIMIT, + }, + uiTheme, + ); const truncationReasons: string[] = []; - if (limits?.matchLimit) { - truncationReasons.push(`limit ${limits.matchLimit.reached} matches`); - } - if (limits?.resultLimit) { - truncationReasons.push(`limit ${limits.resultLimit.reached} results`); - } - if (limits?.headLimit) { - truncationReasons.push(`head limit ${limits.headLimit.reached}`); - } - if (truncation) { - truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit"); - } - if (limits?.columnTruncated) { - truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`); - } - if (truncation?.artifactId) { - truncationReasons.push(`full output: artifact://${truncation.artifactId}`); - } + if (limits?.matchLimit) truncationReasons.push(`limit ${limits.matchLimit.reached} matches`); + if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`); + if (limits?.headLimit) truncationReasons.push(`head limit ${limits.headLimit.reached}`); + if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit"); + if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`); + if (truncation?.artifactId) truncationReasons.push(`full output: artifact://${truncation.artifactId}`); - const hasTruncation = truncationReasons.length > 0; + const extraLines = + truncationReasons.length > 0 ? [uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)] : []; - if (fileEntries.length > 0) { - for (let i = 0; i < maxFiles; i++) { - const entry = fileEntries[i]; - const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - const isDir = entry.path.endsWith("/"); - const entryPath = isDir ? entry.path.slice(0, -1) : entry.path; - const lang = isDir ? undefined : getLanguageFromPath(entryPath); - const entryIcon = isDir - ? uiTheme.fg("accent", uiTheme.icon.folder) - : uiTheme.fg("muted", uiTheme.getLangIcon(lang)); - const countLabel = - entry.count !== undefined - ? ` ${uiTheme.fg("dim", `(${entry.count} match${entry.count !== 1 ? "es" : ""})`)}` - : ""; - text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg("accent", entry.path)}${countLabel}`; - } - - if (hasMoreFiles) { - const moreFilesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last; - text += `\n ${uiTheme.fg("dim", moreFilesBranch)} ${uiTheme.fg( - "muted", - ui.moreItems(fileEntries.length - maxFiles, "file"), - )}`; - } - } - - if (hasTruncation) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)}`; - } - - return new Text(text, 0, 0); + return new Text([header, ...fileLines, ...extraLines].join("\n"), 0, 0); }, }; diff --git a/packages/coding-agent/src/tools/ls.ts b/packages/coding-agent/src/tools/ls.ts index 06e75596f..5138b12ae 100644 --- a/packages/coding-agent/src/tools/ls.ts +++ b/packages/coding-agent/src/tools/ls.ts @@ -4,9 +4,10 @@ import { type Component, Text } from "@oh-my-pi/pi-tui"; import { untilAborted } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import type { RenderResultOptions } from "$c/extensibility/custom-tools/types"; -import { getLanguageFromPath, type Theme } from "$c/modes/theme/theme"; +import type { Theme } from "$c/modes/theme/theme"; import type { OutputMeta } from "$c/tools/output-meta"; import { ToolError, throwIfAborted } from "$c/tools/tool-errors"; +import { renderFileList, renderStatusLine } from "$c/tui"; import type { ToolSession } from "./index"; import { applyListLimit } from "./list-limit"; import { resolveToCwd } from "./path-utils"; @@ -16,10 +17,6 @@ import { formatCount, formatEmptyMessage, formatErrorMessage, - formatExpandHint, - formatMeta, - formatMoreItems, - formatTruncationSuffix, PREVIEW_LIMITS, } from "./render-utils"; import { toolResult } from "./tool-result"; @@ -214,13 +211,9 @@ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS; export const lsToolRenderer = { inline: true, renderCall(args: LsRenderArgs, uiTheme: Theme): Component { - const label = uiTheme.fg("toolTitle", uiTheme.bold("Ls")); - let text = `${uiTheme.format.bullet} ${label} ${uiTheme.fg("accent", args.path || ".")}`; - const meta: string[] = []; if (args.limit !== undefined) meta.push(`limit:${args.limit}`); - text += formatMeta(meta, uiTheme); - + const text = renderStatusLine({ icon: "pending", title: "Ls", description: args.path || ".", meta }, uiTheme); return new Text(text, 0, 0); }, @@ -233,14 +226,14 @@ export const lsToolRenderer = { const textContent = result.content?.find((c) => c.type === "text")?.text ?? ""; if (result.isError) { - return new Text(` ${formatErrorMessage(textContent, uiTheme)}`, 0, 0); + return new Text(formatErrorMessage(textContent, uiTheme), 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); + return new Text(formatEmptyMessage("Empty directory", uiTheme), 0, 0); } let entries: string[] = details?.entries ? [...details.entries] : []; @@ -272,62 +265,32 @@ export const lsToolRenderer = { const truncation = details?.meta?.truncation; const limits = details?.meta?.limits; const truncated = Boolean(details?.entryLimitReached || truncation || limits?.resultLimit || limits?.headLimit); - const icon = truncated - ? uiTheme.styledSymbol("status.warning", "warning") - : uiTheme.styledSymbol("status.success", "success"); - const summaryText = [formatCount("dir", dirCount ?? 0), formatCount("file", fileCount ?? 0)].join( uiTheme.sep.dot, ); - const maxEntries = expanded ? entries.length : Math.min(entries.length, COLLAPSED_LIST_LIMIT); - const hasMoreEntries = entries.length > maxEntries; - const expandHint = formatExpandHint(uiTheme, expanded, hasMoreEntries); + const meta = truncated ? [summaryText, uiTheme.fg("warning", "truncated")] : [summaryText]; + const header = renderStatusLine({ icon: truncated ? "warning" : "success", title: "Ls", meta }, uiTheme); - let text = ` ${icon} ${uiTheme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, uiTheme)}${expandHint}`; + const fileLines = renderFileList( + { + files: entries.map((entry, index) => ({ + path: entry, + isDirectory: (rawEntries?.[index] ?? entry).endsWith("/"), + })), + expanded, + maxCollapsed: COLLAPSED_LIST_LIMIT, + }, + uiTheme, + ); const truncationReasons: string[] = []; - if (limits?.resultLimit) { - truncationReasons.push(`entry limit ${limits.resultLimit.reached}`); - } - if (truncation) { - truncationReasons.push(`output cap ${formatBytes(truncation.outputBytes)}`); - } - if (truncation?.artifactId) { - truncationReasons.push(`full output: artifact://${truncation.artifactId}`); - } + if (limits?.resultLimit) truncationReasons.push(`entry limit ${limits.resultLimit.reached}`); + if (truncation) truncationReasons.push(`output cap ${formatBytes(truncation.outputBytes)}`); + if (truncation?.artifactId) truncationReasons.push(`full output: artifact://${truncation.artifactId}`); - const hasTruncation = truncationReasons.length > 0; + const extraLines = + truncationReasons.length > 0 ? [uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)] : []; - for (let i = 0; i < maxEntries; i++) { - const entry = entries[i]; - const rawEntry = rawEntries?.[i] ?? entry; - const isLast = i === maxEntries - 1 && !hasMoreEntries && !hasTruncation; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - const isDir = rawEntry.endsWith("/"); - const entryPath = isDir ? rawEntry.slice(0, -1) : rawEntry; - const lang = isDir ? undefined : getLanguageFromPath(entryPath); - const entryIcon = isDir - ? uiTheme.fg("accent", uiTheme.icon.folder) - : uiTheme.fg("muted", uiTheme.getLangIcon(lang)); - const entryColor = isDir ? "accent" : "toolOutput"; - text += `\n ${uiTheme.fg("dim", branch)} ${entryIcon} ${uiTheme.fg(entryColor, entry)}`; - } - - if (hasMoreEntries) { - const moreEntriesBranch = hasTruncation ? uiTheme.tree.branch : uiTheme.tree.last; - text += `\n ${uiTheme.fg("dim", moreEntriesBranch)} ${uiTheme.fg( - "muted", - formatMoreItems(entries.length - maxEntries, "entry", uiTheme), - )}`; - } - - if (hasTruncation) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg( - "warning", - `truncated: ${truncationReasons.join(", ")}`, - )}`; - } - - return new Text(text, 0, 0); + return new Text([header, ...fileLines, ...extraLines].join("\n"), 0, 0); }, }; diff --git a/packages/coding-agent/src/tools/notebook.ts b/packages/coding-agent/src/tools/notebook.ts index 5145742c4..b85889270 100644 --- a/packages/coding-agent/src/tools/notebook.ts +++ b/packages/coding-agent/src/tools/notebook.ts @@ -7,15 +7,9 @@ import { type Static, Type } from "@sinclair/typebox"; import type { RenderResultOptions } from "$c/extensibility/custom-tools/types"; import type { Theme } from "$c/modes/theme/theme"; import type { ToolSession } from "$c/sdk"; +import { renderCodeCell, renderStatusLine } from "$c/tui"; import { resolveToCwd } from "./path-utils"; -import { - formatCount, - formatErrorMessage, - formatExpandHint, - formatMeta, - formatMoreItems, - PREVIEW_LIMITS, -} from "./render-utils"; +import { formatCount, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils"; const notebookSchema = Type.Object({ action: StringEnum(["edit", "insert", "delete"], { @@ -208,49 +202,17 @@ interface NotebookRenderArgs { const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2; -function normalizeCellLines(lines: string[]): string[] { - return lines.map((line) => (line.endsWith("\n") ? line.slice(0, -1) : line)); -} - -function renderCellPreview(lines: string[], expanded: boolean, uiTheme: Theme): string { - const normalized = normalizeCellLines(lines); - if (normalized.length === 0) { - return `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "(empty cell)")}`; - } - - const maxLines = expanded ? normalized.length : Math.min(normalized.length, COLLAPSED_TEXT_LIMIT); - let text = ""; - - for (let i = 0; i < maxLines; i++) { - const isLast = i === maxLines - 1 && (expanded || normalized.length <= maxLines); - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - const line = normalized[i]; - text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("toolOutput", line)}`; - } - - const remaining = normalized.length - maxLines; - if (remaining > 0) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg( - "muted", - formatMoreItems(remaining, "line", uiTheme), - )}`; - } - - return text; -} - export const notebookToolRenderer = { renderCall(args: NotebookRenderArgs, uiTheme: Theme): Component { - const label = uiTheme.fg("toolTitle", uiTheme.bold("Notebook")); - let text = `${label} ${uiTheme.fg("accent", args.action || "?")}`; - const meta: string[] = []; meta.push(`in ${args.notebookPath || "?"}`); if (args.cellNumber !== undefined) meta.push(`cell:${args.cellNumber}`); if (args.cellType) meta.push(`type:${args.cellType}`); - text += formatMeta(meta, uiTheme); - + const text = renderStatusLine( + { icon: "pending", title: "Notebook", description: args.action || "?", meta }, + uiTheme, + ); return new Text(text, 0, 0); }, @@ -269,25 +231,35 @@ export const notebookToolRenderer = { const cellIndex = details?.cellIndex; const cellType = details?.cellType; const totalCells = details?.totalCells; - const cellSource = details?.cellSource; - const lineCount = cellSource?.length; - const canExpand = cellSource !== undefined && cellSource.length > COLLAPSED_TEXT_LIMIT; + const cellSource = details?.cellSource ?? []; + const lineCount = cellSource.length; - const icon = uiTheme.styledSymbol("status.success", "success"); const actionLabel = action === "insert" ? "Inserted" : action === "delete" ? "Deleted" : "Edited"; const cellLabel = cellType || "cell"; - const summaryParts = [`${actionLabel} ${cellLabel} at index ${cellIndex ?? "?"}`]; - if (lineCount !== undefined) summaryParts.push(formatCount("line", lineCount)); + const summaryParts = [`${actionLabel} ${cellLabel} ${cellIndex ?? "?"}`]; + if (lineCount > 0) summaryParts.push(formatCount("line", lineCount)); if (totalCells !== undefined) summaryParts.push(`${totalCells} total`); - const summaryText = summaryParts.join(uiTheme.sep.dot); - const expandHint = expanded || !canExpand ? "" : formatExpandHint(uiTheme); - let text = `${icon} ${uiTheme.fg("dim", summaryText)}${expandHint}`; + const outputLines = summaryParts.map((part) => uiTheme.fg("dim", part)); + const codeText = cellSource.join(""); + const language = cellType === "markdown" ? "markdown" : undefined; - if (cellSource) { - text += renderCellPreview(cellSource, expanded, uiTheme); - } - - return new Text(text, 0, 0); + return { + render: (width: number) => + renderCodeCell( + { + code: codeText, + language, + title: "Notebook", + status: "complete", + output: outputLines.join("\n"), + codeMaxLines: expanded ? Number.POSITIVE_INFINITY : COLLAPSED_TEXT_LIMIT, + expanded, + width, + }, + uiTheme, + ), + invalidate: () => {}, + }; }, }; diff --git a/packages/coding-agent/src/tools/python.ts b/packages/coding-agent/src/tools/python.ts index d0c334938..0c546ddf1 100644 --- a/packages/coding-agent/src/tools/python.ts +++ b/packages/coding-agent/src/tools/python.ts @@ -2,22 +2,23 @@ import { relative, resolve, sep } from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; -import { Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { Text, truncateToWidth } from "@oh-my-pi/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "$c/config/prompt-templates"; import type { RenderResultOptions } from "$c/extensibility/custom-tools/types"; import { executePython, getPreludeDocs, type PythonExecutorOptions } from "$c/ipy/executor"; import type { PreludeHelper, PythonStatusEvent } from "$c/ipy/kernel"; import { truncateToVisualLines } from "$c/modes/components/visual-truncate"; -import { highlightCode, type Theme } from "$c/modes/theme/theme"; +import type { Theme } from "$c/modes/theme/theme"; import pythonDescription from "$c/prompts/tools/python.md" with { type: "text" }; import { OutputSink, type OutputSummary } from "$c/session/streaming-output"; import type { OutputMeta } from "$c/tools/output-meta"; import { ToolAbortError, ToolError } from "$c/tools/tool-errors"; +import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "$c/tui"; import type { ToolSession } from "./index"; import { allocateOutputArtifact, createTailBuffer } from "./output-utils"; import { resolveToCwd } from "./path-utils"; -import { getTreeBranch, getTreeContinuePrefix, shortenPath, ToolUIKit, truncate } from "./render-utils"; +import { shortenPath, ToolUIKit, truncate } from "./render-utils"; import { toolResult } from "./tool-result"; import { DEFAULT_MAX_BYTES } from "./truncate"; @@ -740,56 +741,6 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: return lines; } -function applyCellBackground(line: string, width: number, bgFn?: (text: string) => string): string { - if (!bgFn) return line; - if (width <= 0) return bgFn(line); - const paddingNeeded = Math.max(0, width - visibleWidth(line)); - const padded = line + " ".repeat(paddingNeeded); - return bgFn(padded); -} - -function highlightPythonCode(code?: string): string[] { - return highlightCode(code ?? "", "python"); -} - -function formatCellStatus(cell: PythonCellResult, ui: ToolUIKit, spinnerFrame?: number): string | undefined { - switch (cell.status) { - case "pending": - return `${ui.statusIcon("pending")} ${ui.theme.fg("muted", "pending")}`; - case "running": - return `${ui.statusIcon("running", spinnerFrame)} ${ui.theme.fg("muted", "running")}`; - case "complete": - return ui.statusIcon("success"); - case "error": - return ui.statusIcon("error"); - } -} - -function formatCellHeader( - cell: PythonCellResult, - index: number, - total: number, - ui: ToolUIKit, - spinnerFrame?: number, - workdirLabel?: string, -): string { - const indexLabel = ui.theme.fg("accent", `[${index + 1}/${total}]`); - const title = cell.title ? ` ${cell.title}` : ""; - const metaParts: string[] = []; - if (workdirLabel) { - metaParts.push(ui.theme.fg("dim", workdirLabel)); - } - if (cell.durationMs !== undefined) { - metaParts.push(ui.theme.fg("dim", `(${ui.formatDuration(cell.durationMs)})`)); - } - const statusLabel = formatCellStatus(cell, ui, spinnerFrame); - if (statusLabel) { - metaParts.push(statusLabel); - } - const meta = metaParts.length > 0 ? ` ${metaParts.join(ui.theme.fg("dim", ui.theme.sep.dot))}` : ""; - return `${indexLabel}${title}${meta}`; -} - function formatCellOutputLines( cell: PythonCellResult, expanded: boolean, @@ -808,101 +759,6 @@ function formatCellOutputLines( return { lines: outputLines, hiddenCount }; } -function renderCellBlock( - cell: PythonCellResult, - index: number, - total: number, - ui: ToolUIKit, - options: { - expanded: boolean; - previewLines: number; - spinnerFrame?: number; - showOutput: boolean; - workdirLabel?: string; - width: number; - bgFn?: (text: string) => string; - }, -): string[] { - const { expanded, previewLines, spinnerFrame, showOutput, workdirLabel, width, bgFn } = options; - const h = ui.theme.boxSharp.horizontal; - const v = ui.theme.boxSharp.vertical; - const cap = h.repeat(3); - const border = (text: string) => ui.theme.fg("dim", text); - const lineWidth = Math.max(0, width); - - const buildBarLine = (leftChar: string, label?: string): string => { - const left = border(`${leftChar}${cap}`); - if (lineWidth <= 0) return left; - const rawLabel = label ? ` ${label} ` : " "; - const maxLabelWidth = Math.max(0, lineWidth - visibleWidth(left)); - const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth, ui.theme.format.ellipsis); - const fillCount = Math.max(0, lineWidth - visibleWidth(left + trimmedLabel)); - return `${left}${trimmedLabel}${border(h.repeat(fillCount))}`; - }; - - const lines: string[] = []; - lines.push( - applyCellBackground( - buildBarLine(ui.theme.boxSharp.topLeft, formatCellHeader(cell, index, total, ui, spinnerFrame, workdirLabel)), - lineWidth, - bgFn, - ), - ); - - const codePrefix = border(`${v} `); - const codeWidth = Math.max(0, lineWidth - visibleWidth(codePrefix)); - const codeLines = highlightPythonCode(cell.code); - for (const line of codeLines) { - const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis); - lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn)); - } - - const statusLines = renderStatusEvents(cell.statusEvents ?? [], ui.theme, expanded); - const outputContent = formatCellOutputLines(cell, expanded, previewLines, ui.theme); - const hasOutput = outputContent.lines.length > 0; - const hasStatus = statusLines.length > 0; - const showOutputSection = showOutput && (hasOutput || hasStatus); - - if (showOutputSection) { - lines.push( - applyCellBackground( - buildBarLine(ui.theme.boxSharp.teeRight, ui.theme.fg("toolTitle", "Output")), - lineWidth, - bgFn, - ), - ); - - for (const line of outputContent.lines) { - const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis); - lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn)); - } - if (!expanded && outputContent.hiddenCount > 0) { - const hint = ui.theme.fg( - "dim", - `${ui.theme.format.ellipsis} ${outputContent.hiddenCount} more lines (ctrl+o to expand)`, - ); - lines.push( - applyCellBackground( - `${codePrefix}${truncateToWidth(hint, codeWidth, ui.theme.format.ellipsis)}`, - lineWidth, - bgFn, - ), - ); - } - - for (const line of statusLines) { - const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis); - lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn)); - } - } - - const bottomLeft = border(`${ui.theme.boxSharp.bottomLeft}${cap}`); - const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft)); - const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}`; - lines.push(applyCellBackground(bottomLine, lineWidth, bgFn)); - return lines; -} - export const pythonToolRenderer = { renderCall(args: PythonRenderArgs, uiTheme: Theme): Component { const ui = new ToolUIKit(uiTheme); @@ -937,23 +793,21 @@ export const pythonToolRenderer = { const lines: string[] = []; for (let i = 0; i < cells.length; i++) { const cell = cells[i]; - const cellResult: PythonCellResult = { - index: i, - title: cell.title, - code: cell.code, - output: "", - status: "pending", - }; - lines.push( - ...renderCellBlock(cellResult, i, cells.length, ui, { - expanded: true, - previewLines: PYTHON_DEFAULT_PREVIEW_LINES, - showOutput: false, - workdirLabel: i === 0 ? workdirLabel : undefined, + const cellLines = renderCodeCell( + { + code: cell.code, + language: "python", + index: i, + total: cells.length, + title: cell.title, + status: "pending", width, - bgFn: (text: string) => uiTheme.bg("toolPendingBg", text), - }), + codeMaxLines: PYTHON_DEFAULT_PREVIEW_LINES, + expanded: true, + }, + uiTheme, ); + lines.push(...cellLines); if (i < cells.length - 1) { lines.push(""); } @@ -1015,23 +869,37 @@ export const pythonToolRenderer = { const lines: string[] = []; for (let i = 0; i < cellResults.length; i++) { const cell = cellResults[i]; - const showOutput = cell.status !== "pending"; - const bgColor = - cell.status === "error" - ? "toolErrorBg" - : cell.status === "complete" - ? "toolSuccessBg" - : "toolPendingBg"; - lines.push( - ...renderCellBlock(cell, i, cellResults.length, ui, { - expanded, - previewLines, + const statusLines = renderStatusEvents(cell.statusEvents ?? [], uiTheme, expanded); + const outputContent = formatCellOutputLines(cell, expanded, previewLines, uiTheme); + const outputLines = [...outputContent.lines]; + if (!expanded && outputContent.hiddenCount > 0) { + outputLines.push( + uiTheme.fg( + "dim", + `${uiTheme.format.ellipsis} ${outputContent.hiddenCount} more lines (ctrl+o to expand)`, + ), + ); + } + outputLines.push(...statusLines); + const cellLines = renderCodeCell( + { + code: cell.code, + language: "python", + index: i, + total: cellResults.length, + title: cell.title, + status: cell.status, spinnerFrame: options.spinnerFrame, - showOutput, + duration: cell.durationMs, + output: outputLines.length > 0 ? outputLines.join("\n") : undefined, + outputMaxLines: previewLines, + codeMaxLines: expanded ? Number.POSITIVE_INFINITY : PYTHON_DEFAULT_PREVIEW_LINES, + expanded, width, - bgFn: (text: string) => uiTheme.bg(bgColor, text), - }), + }, + uiTheme, ); + lines.push(...cellLines); if (i < cellResults.length - 1) { lines.push(""); } diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index b856fc188..377d41b5f 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -9,11 +9,12 @@ import { Type } from "@sinclair/typebox"; import { CONFIG_DIR_NAME } from "$c/config"; import { renderPromptTemplate } from "$c/config/prompt-templates"; import type { RenderResultOptions } from "$c/extensibility/custom-tools/types"; -import type { Theme } from "$c/modes/theme/theme"; +import { getLanguageFromPath, type Theme } from "$c/modes/theme/theme"; 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 { formatDimensionNote, resizeImage } from "$c/utils/image-resize"; import { detectSupportedImageMimeTypeFromFile } from "$c/utils/mime"; import { ensureTool } from "$c/utils/tools-manager"; @@ -837,14 +838,14 @@ export const readToolRenderer = { const offset = args.offset; const limit = args.limit; - let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis); + let pathDisplay = filePath || uiTheme.format.ellipsis; if (offset !== undefined || limit !== undefined) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ""; - pathDisplay += uiTheme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); + pathDisplay += `:${startLine}${endLine ? `-${endLine}` : ""}`; } - const text = `${uiTheme.fg("toolTitle", uiTheme.bold("Read"))} ${pathDisplay}`; + const text = renderStatusLine({ icon: "pending", title: "Read", description: pathDisplay }, uiTheme); return new Text(text, 0, 0); }, @@ -852,13 +853,15 @@ export const readToolRenderer = { result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails }, _options: RenderResultOptions, uiTheme: Theme, - _args?: ReadRenderArgs, + args?: ReadRenderArgs, ): Component { const details = result.details; - const lines: string[] = []; - - lines.push(uiTheme.fg("dim", "Content hidden")); + const contentText = result.content?.find((c) => c.type === "text")?.text ?? ""; + const rawPath = args?.file_path || args?.path || ""; + const filePath = shortenPath(rawPath); + const lang = getLanguageFromPath(rawPath); + const warningLines: string[] = []; const truncation = details?.meta?.truncation; const fallback = details?.truncation; if (truncation) { @@ -874,9 +877,25 @@ export const readToolRenderer = { if (truncation.artifactId) { warning += `. Full output: artifact://${truncation.artifactId}`; } - lines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme))); + warningLines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme))); } - return new Text(lines.join("\n"), 0, 0); + const title = filePath ? `Read ${filePath}` : "Read"; + return { + render: (width: number) => + renderCodeCell( + { + code: contentText, + language: lang, + title, + status: "complete", + output: warningLines.length > 0 ? warningLines.join("\n") : undefined, + expanded: true, + width, + }, + uiTheme, + ), + invalidate: () => {}, + }; }, }; diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index f1017ffe6..24650fbcf 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -7,6 +7,7 @@ import { homedir } from "node:os"; import type { Theme } from "$c/modes/theme/theme"; +import { getTreeBranch } from "$c/tui/utils"; // ============================================================================= // Standardized Display Constants @@ -667,21 +668,6 @@ function pluralize(label: string, count: number): string { // ============================================================================= // Tree Rendering Utilities // ============================================================================= - -/** - * Get the branch character for a tree item. - */ -export function getTreeBranch(isLast: boolean, theme: Theme): string { - return isLast ? theme.tree.last : theme.tree.branch; -} - -/** - * Get the continuation prefix for nested content under a tree item. - */ -export function getTreeContinuePrefix(isLast: boolean, theme: Theme): string { - return isLast ? " " : `${theme.tree.vertical} `; -} - /** * Render a list of items with tree branches, handling truncation. * diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 2ea228335..f5ba00939 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -14,9 +14,10 @@ import { ensureHostInfo, getHostInfoForHost } from "$c/ssh/connection-manager"; import { executeSSH } from "$c/ssh/ssh-executor"; import type { OutputMeta } from "$c/tools/output-meta"; import { ToolError } from "$c/tools/tool-errors"; +import { renderOutputBlock, renderStatusLine } from "$c/tui"; import type { ToolSession } from "./index"; import { allocateOutputArtifact, createTailBuffer } from "./output-utils"; -import { ToolUIKit } from "./render-utils"; +import { formatBytes, wrapBrackets } from "./render-utils"; import { toolResult } from "./tool-result"; import { DEFAULT_MAX_BYTES } from "./truncate"; @@ -237,10 +238,9 @@ interface SshRenderContext { export const sshToolRenderer = { renderCall(args: SshRenderArgs, uiTheme: Theme): Component { - const ui = new ToolUIKit(uiTheme); const host = args.host || uiTheme.format.ellipsis; const command = args.command || uiTheme.format.ellipsis; - const text = ui.title(`[${host}] $ ${command}`); + const text = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}] $ ${command}` }, uiTheme); return new Text(text, 0, 0); }, @@ -252,41 +252,38 @@ export const sshToolRenderer = { options: RenderResultOptions & { renderContext?: SshRenderContext }, uiTheme: Theme, ): Component { - const ui = new ToolUIKit(uiTheme); const { expanded, renderContext } = options; const details = result.details; - const lines: string[] = []; + const header = renderStatusLine({ icon: "success", title: "SSH" }, uiTheme); + const outputLines: string[] = []; const textContent = result.content?.find((c) => c.type === "text")?.text ?? ""; const output = textContent.trim(); if (output) { if (expanded) { - const styledOutput = output - .split("\n") - .map((line) => uiTheme.fg("toolOutput", line)) - .join("\n"); - lines.push(styledOutput); + outputLines.push(...output.split("\n").map((line) => uiTheme.fg("toolOutput", line))); } else if (renderContext?.visualLines) { const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext; if (skippedCount > 0) { - lines.push( + outputLines.push( uiTheme.fg( "dim", `${uiTheme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`, ), ); } - lines.push(...visualLines); + outputLines.push(...visualLines); } else { - const outputLines = output.split("\n"); + const outputLinesRaw = output.split("\n"); const maxLines = 5; - const displayLines = outputLines.slice(0, maxLines); - const remaining = outputLines.length - maxLines; - - lines.push(...displayLines.map((line) => uiTheme.fg("toolOutput", line))); + const displayLines = outputLinesRaw.slice(0, maxLines); + const remaining = outputLinesRaw.length - maxLines; + outputLines.push(...displayLines.map((line) => uiTheme.fg("toolOutput", line))); if (remaining > 0) { - lines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${remaining} more lines) (ctrl+o to expand)`)); + outputLines.push( + uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${remaining} more lines) (ctrl+o to expand)`), + ); } } } @@ -301,12 +298,24 @@ export const sshToolRenderer = { warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); } else { warnings.push( - `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.outputBytes)} limit)`, + `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.outputBytes)} limit)`, ); } - lines.push(uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". ")))); + outputLines.push(uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme))); } - return new Text(lines.join("\n"), 0, 0); + return { + render: (width: number) => + renderOutputBlock( + { + header, + state: "success", + sections: [{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }], + width, + }, + uiTheme, + ), + invalidate: () => {}, + }; }, }; diff --git a/packages/coding-agent/src/tools/todo-write.ts b/packages/coding-agent/src/tools/todo-write.ts index a5a8b04c0..54508f4a1 100644 --- a/packages/coding-agent/src/tools/todo-write.ts +++ b/packages/coding-agent/src/tools/todo-write.ts @@ -12,6 +12,7 @@ import type { RenderResultOptions } from "$c/extensibility/custom-tools/types"; 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"; const todoWriteSchema = Type.Object({ todos: Type.Array( @@ -217,8 +218,9 @@ interface TodoWriteRenderArgs { export const todoWriteToolRenderer = { renderCall(args: TodoWriteRenderArgs, uiTheme: Theme): Component { const count = args.todos?.length ?? 0; - const summary = count > 0 ? uiTheme.fg("accent", `${count} items`) : uiTheme.fg("toolOutput", "empty"); - return new Text(`${uiTheme.fg("toolTitle", uiTheme.bold("Todo Write"))} ${summary}`, 0, 0); + const meta = count > 0 ? [`${count} items`] : ["empty"]; + const text = renderStatusLine({ icon: "pending", title: "Todo Write", meta }, uiTheme); + return new Text(text, 0, 0); }, renderResult( @@ -228,18 +230,18 @@ export const todoWriteToolRenderer = { _args?: TodoWriteRenderArgs, ): Component { const todos = result.details?.todos ?? []; - const indent = " "; - const hook = uiTheme.tree.hook; - const lines = [indent + uiTheme.bold(uiTheme.fg("accent", "Todos"))]; + const header = renderStatusLine({ icon: "success", title: "Todos", meta: [`${todos.length} items`] }, uiTheme); + const lines = renderTreeList( + { + items: todos, + expanded: true, + maxCollapsed: todos.length, + itemType: "todo", + renderItem: (todo) => formatTodoLine(todo, uiTheme, ""), + }, + uiTheme, + ); - if (todos.length > 0) { - const visibleTodos = todos; - visibleTodos.forEach((todo, index) => { - const prefix = `${indent}${index === 0 ? hook : " "} `; - lines.push(formatTodoLine(todo, uiTheme, prefix)); - }); - } - - return new Text(lines.join("\n"), 0, 0); + return new Text([header, ...lines].join("\n"), 0, 0); }, }; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 69dadbf20..f5194a146 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -17,12 +17,13 @@ import { type WritethroughCallback, writethroughNoop, } from "$c/lsp/index"; -import { getLanguageFromPath, highlightCode, type Theme } from "$c/modes/theme/theme"; +import { getLanguageFromPath, type Theme } from "$c/modes/theme/theme"; import writeDescription from "$c/prompts/tools/write.md" with { type: "text" }; import type { ToolSession } from "$c/sdk"; import { type OutputMeta, outputMeta } from "$c/tools/output-meta"; +import { renderCodeCell, renderStatusLine } from "$c/tui"; import { resolveToCwd } from "./path-utils"; -import { formatDiagnostics, formatExpandHint, formatStatusIcon, replaceTabs, shortenPath } from "./render-utils"; +import { formatDiagnostics, shortenPath } from "./render-utils"; import type { RenderCallOptions } from "./renderers"; const writeSchema = Type.Object({ @@ -134,27 +135,6 @@ function countLines(text: string): number { return text.split("\n").length; } -function formatStreamingContent(content: string, rawPath: string, uiTheme: Theme): string { - if (!content) return ""; - const lang = getLanguageFromPath(rawPath); - const lines = content.split("\n"); - const total = lines.length; - const displayLines = lines.slice(-WRITE_STREAMING_PREVIEW_LINES); - const hidden = total - displayLines.length; - - const formattedLines = lang - ? highlightCode(replaceTabs(displayLines.join("\n")), lang) - : displayLines.map((line: string) => uiTheme.fg("toolOutput", replaceTabs(line))); - - let text = "\n\n"; - if (hidden > 0) { - text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)\n`); - } - text += formattedLines.join("\n"); - text += uiTheme.fg("dim", `\n${uiTheme.format.ellipsis} (streaming)`); - return text; -} - function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string { const icon = uiTheme.getLangIcon(language); if (lineCount !== null) { @@ -167,16 +147,40 @@ export const writeToolRenderer = { renderCall(args: WriteRenderArgs, uiTheme: Theme, options?: RenderCallOptions): Component { const rawPath = args.file_path || args.path || ""; const filePath = shortenPath(rawPath); - const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis); - const spinner = - options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : ""; - let text = `${uiTheme.fg("toolTitle", uiTheme.bold("Write"))} ${spinner ? `${spinner} ` : ""}${pathDisplay}`; + const pathDisplay = filePath || uiTheme.format.ellipsis; + const status = options?.spinnerFrame !== undefined ? "running" : "pending"; - // Show streaming preview of content if (args.content) { - text += formatStreamingContent(args.content, rawPath, uiTheme); + 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: () => {}, + }; } + const text = renderStatusLine({ icon: status, title: "Write", description: pathDisplay }, uiTheme); return new Text(text, 0, 0); }, @@ -187,48 +191,38 @@ export const writeToolRenderer = { args?: WriteRenderArgs, ): Component { const rawPath = args?.file_path || args?.path || ""; + const filePath = shortenPath(rawPath); const fileContent = args?.content || ""; const lang = getLanguageFromPath(rawPath); - const contentLines = fileContent - ? lang - ? highlightCode(replaceTabs(fileContent), lang) - : fileContent.split("\n") - : []; - const totalLines = contentLines.length; const outputLines: string[] = []; outputLines.push(formatMetadataLine(countLines(fileContent), lang ?? "text", uiTheme)); - if (fileContent) { - const maxLines = expanded ? contentLines.length : 10; - const displayLines = contentLines.slice(0, maxLines); - const remaining = contentLines.length - maxLines; - - outputLines.push( - "", - ...displayLines.map((line: string) => - lang ? replaceTabs(line) : uiTheme.fg("toolOutput", replaceTabs(line)), - ), + if (result.details?.diagnostics) { + const diagText = formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp) => + uiTheme.getLangIcon(getLanguageFromPath(fp)), ); - if (remaining > 0) { - outputLines.push( - uiTheme.fg( - "toolOutput", - `${uiTheme.format.ellipsis} (${remaining} more lines, ${totalLines} total) ${formatExpandHint(uiTheme)}`, - ), - ); + if (diagText.trim()) { + outputLines.push(...diagText.split("\n")); } } - // Show LSP diagnostics if available - if (result.details?.diagnostics) { - outputLines.push( - formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp) => - uiTheme.getLangIcon(getLanguageFromPath(fp)), + return { + render: (width: number) => + renderCodeCell( + { + code: fileContent, + language: lang, + title: filePath ? `Write ${filePath}` : "Write", + status: "complete", + output: outputLines.join("\n"), + codeMaxLines: expanded ? Number.POSITIVE_INFINITY : 10, + expanded, + width, + }, + uiTheme, ), - ); - } - - return new Text(outputLines.join("\n"), 0, 0); + invalidate: () => {}, + }; }, }; diff --git a/packages/coding-agent/src/tui/code-cell.ts b/packages/coding-agent/src/tui/code-cell.ts new file mode 100644 index 000000000..de628d4a4 --- /dev/null +++ b/packages/coding-agent/src/tui/code-cell.ts @@ -0,0 +1,110 @@ +/** + * Render a code cell with optional output section. + */ + +import { highlightCode, type Theme } from "$c/modes/theme/theme"; +import { formatDuration, formatExpandHint, formatMoreItems, replaceTabs } from "$c/tools/render-utils"; +import { renderOutputBlock } from "./output-block"; +import type { State } from "./types"; +import { getStateIcon } from "./utils"; + +export interface CodeCellOptions { + code: string; + language?: string; + index?: number; + total?: number; + title?: string; + status?: "pending" | "running" | "complete" | "error"; + spinnerFrame?: number; + duration?: number; + output?: string; + outputMaxLines?: number; + codeMaxLines?: number; + expanded?: boolean; + width: number; +} + +function getState(status?: CodeCellOptions["status"]): State | undefined { + if (!status) return undefined; + if (status === "complete") return "success"; + if (status === "error") return "error"; + if (status === "running") return "running"; + return "pending"; +} + +function formatHeader(options: CodeCellOptions, theme: Theme): { title: string; meta?: string } { + const { index, total, title, status, spinnerFrame, duration } = options; + const parts: string[] = []; + if (index !== undefined && total !== undefined) { + parts.push(theme.fg("accent", `[${index + 1}/${total}]`)); + } + if (title) { + parts.push(theme.fg("toolTitle", title)); + } + const headerTitle = parts.length > 0 ? parts.join(" ") : theme.fg("toolTitle", "Code"); + + const metaParts: string[] = []; + if (duration !== undefined) { + metaParts.push(theme.fg("dim", `(${formatDuration(duration)})`)); + } + if (status) { + const icon = getStateIcon( + status === "complete" + ? "success" + : status === "error" + ? "error" + : status === "running" + ? "running" + : "pending", + theme, + spinnerFrame, + ); + if (status === "pending" || status === "running") { + metaParts.push(`${icon} ${theme.fg("muted", status)}`); + } else { + metaParts.push(icon); + } + } + + if (metaParts.length === 0) return { title: headerTitle }; + return { title: headerTitle, meta: metaParts.join(theme.fg("dim", theme.sep.dot)) }; +} + +export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[] { + const { code, language, output, expanded = false, outputMaxLines = 6, codeMaxLines = 12, width } = options; + const { title, meta } = formatHeader(options, theme); + const state = getState(options.status); + + const rawCodeLines = highlightCode(replaceTabs(code), language); + const maxCodeLines = expanded ? rawCodeLines.length : Math.min(rawCodeLines.length, codeMaxLines); + const codeLines = rawCodeLines.slice(0, maxCodeLines); + const hiddenCodeLines = rawCodeLines.length - codeLines.length; + if (hiddenCodeLines > 0) { + const hint = formatExpandHint(theme, expanded, hiddenCodeLines > 0); + const moreLine = `${formatMoreItems(hiddenCodeLines, "line", theme)}${hint ? ` ${hint}` : ""}`; + codeLines.push(theme.fg("dim", moreLine)); + } + + const outputLines: string[] = []; + if (output?.trim()) { + const rawLines = output.split("\n"); + const maxLines = expanded ? rawLines.length : Math.min(rawLines.length, outputMaxLines); + const displayLines = rawLines + .slice(0, maxLines) + .map((line) => (line.includes("\x1b[") ? line : theme.fg("toolOutput", line))); + outputLines.push(...displayLines); + const remaining = rawLines.length - maxLines; + if (remaining > 0) { + const hint = formatExpandHint(theme, expanded, remaining > 0); + const moreLine = `${formatMoreItems(remaining, "line", theme)}${hint ? ` ${hint}` : ""}`; + outputLines.push(theme.fg("dim", moreLine)); + } + } + + const sections: Array<{ label?: string; lines: string[] }> = [{ lines: codeLines }]; + if (outputLines.length > 0) { + sections.push({ label: theme.fg("toolTitle", "Output"), lines: outputLines }); + } + + return renderOutputBlock({ header: title, headerMeta: meta, state, sections, width }, theme); +} diff --git a/packages/coding-agent/src/tui/file-list.ts b/packages/coding-agent/src/tui/file-list.ts new file mode 100644 index 000000000..f6d715299 --- /dev/null +++ b/packages/coding-agent/src/tui/file-list.ts @@ -0,0 +1,48 @@ +/** + * Render file listings with optional icons and metadata. + */ + +import type { Theme } from "$c/modes/theme/theme"; +import { getLanguageFromPath } from "$c/modes/theme/theme"; +import { renderTreeList } from "./tree-list"; + +export interface FileEntry { + path: string; + isDirectory?: boolean; + meta?: string; +} + +export interface FileListOptions { + files: FileEntry[]; + expanded?: boolean; + maxCollapsed?: number; + showIcons?: boolean; +} + +export function renderFileList(options: FileListOptions, theme: Theme): string[] { + const { files, expanded = false, maxCollapsed = 8, showIcons = true } = options; + + return renderTreeList( + { + items: files, + expanded, + maxCollapsed, + itemType: "file", + renderItem: (entry) => { + const isDirectory = entry.isDirectory ?? entry.path.endsWith("/"); + const displayPath = isDirectory && entry.path.endsWith("/") ? entry.path : entry.path; + const lang = isDirectory ? undefined : getLanguageFromPath(displayPath); + const icon = !showIcons + ? "" + : isDirectory + ? theme.fg("accent", theme.icon.folder) + : theme.fg("muted", theme.getLangIcon(lang)); + const labelColor = isDirectory ? "accent" : "toolOutput"; + const meta = entry.meta ? ` ${theme.fg("dim", entry.meta)}` : ""; + const iconPrefix = icon ? `${icon} ` : ""; + return `${iconPrefix}${theme.fg(labelColor, displayPath)}${meta}`; + }, + }, + theme, + ); +} diff --git a/packages/coding-agent/src/tui/index.ts b/packages/coding-agent/src/tui/index.ts new file mode 100644 index 000000000..75b76f462 --- /dev/null +++ b/packages/coding-agent/src/tui/index.ts @@ -0,0 +1,11 @@ +/** + * Public exports for shared TUI components. + */ + +export * from "./code-cell"; +export * from "./file-list"; +export * from "./output-block"; +export * from "./status-line"; +export * from "./tree-list"; +export * from "./types"; +export * from "./utils"; diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts new file mode 100644 index 000000000..bfe4e195e --- /dev/null +++ b/packages/coding-agent/src/tui/output-block.ts @@ -0,0 +1,64 @@ +/** + * Bordered output container with optional header and sections. + */ + +import { visibleWidth } from "@oh-my-pi/pi-tui"; +import type { Theme } from "$c/modes/theme/theme"; +import type { State } from "./types"; +import { getStateBgColor, padToWidth, truncateToWidth } from "./utils"; + +export interface OutputBlockOptions { + header?: string; + headerMeta?: string; + state?: State; + sections?: Array<{ label?: string; lines: string[] }>; + width: number; +} + +export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] { + const { header, headerMeta, state, sections = [], width } = options; + const h = theme.boxSharp.horizontal; + 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 bgFn = state ? (text: string) => theme.bg(getStateBgColor(state), text) : undefined; + + const buildBarLine = (leftChar: string, label?: string, meta?: string): string => { + const left = border(`${leftChar}${cap}`); + if (lineWidth <= 0) return left; + const labelText = [label, meta].filter(Boolean).join(theme.sep.dot); + const rawLabel = labelText ? ` ${labelText} ` : " "; + const maxLabelWidth = Math.max(0, lineWidth - visibleWidth(left)); + const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth, theme.format.ellipsis); + const fillCount = Math.max(0, lineWidth - visibleWidth(left + trimmedLabel)); + return `${left}${trimmedLabel}${border(h.repeat(fillCount))}`; + }; + + const contentPrefix = border(`${v} `); + const contentWidth = Math.max(0, lineWidth - visibleWidth(contentPrefix)); + const lines: string[] = []; + + lines.push(padToWidth(buildBarLine(theme.boxSharp.topLeft, header, headerMeta), lineWidth, bgFn)); + + const hasSections = sections.length > 0; + const normalizedSections = hasSections ? sections : [{ lines: [] }]; + + for (let i = 0; i < normalizedSections.length; i++) { + const section = normalizedSections[i]; + if (section.label) { + lines.push(padToWidth(buildBarLine(theme.boxSharp.teeRight, section.label), lineWidth, bgFn)); + } + for (const line of section.lines) { + const text = truncateToWidth(line, contentWidth, theme.format.ellipsis); + lines.push(padToWidth(`${contentPrefix}${text}`, lineWidth, bgFn)); + } + } + + const bottomLeft = border(`${theme.boxSharp.bottomLeft}${cap}`); + const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft)); + const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}`; + lines.push(padToWidth(bottomLine, lineWidth, bgFn)); + + return lines; +} diff --git a/packages/coding-agent/src/tui/status-line.ts b/packages/coding-agent/src/tui/status-line.ts new file mode 100644 index 000000000..99438db25 --- /dev/null +++ b/packages/coding-agent/src/tui/status-line.ts @@ -0,0 +1,40 @@ +/** + * Standardized status header rendering for tool output. + */ + +import type { Theme, ThemeColor } from "$c/modes/theme/theme"; +import type { IconType } from "./types"; +import { getStateIcon } from "./utils"; + +export interface StatusLineOptions { + icon?: IconType; + spinnerFrame?: number; + title: string; + titleColor?: ThemeColor; + description?: string; + badge?: { label: string; color: ThemeColor }; + meta?: string[]; +} + +export function renderStatusLine(options: StatusLineOptions, theme: Theme): string { + const icon = options.icon ? getStateIcon(options.icon, theme, options.spinnerFrame) : ""; + const titleColor = options.titleColor ?? "accent"; + const title = theme.fg(titleColor, options.title); + let line = icon ? `${icon} ${title}` : title; + + if (options.description) { + line += `: ${theme.fg("muted", options.description)}`; + } + + if (options.badge) { + const { label, color } = options.badge; + line += ` ${theme.fg(color, `${theme.format.bracketLeft}${label}${theme.format.bracketRight}`)}`; + } + + const meta = options.meta?.filter((value) => value.trim().length > 0) ?? []; + if (meta.length > 0) { + line += ` ${theme.fg("dim", meta.join(theme.sep.dot))}`; + } + + return line; +} diff --git a/packages/coding-agent/src/tui/tree-list.ts b/packages/coding-agent/src/tui/tree-list.ts new file mode 100644 index 000000000..44a5e79f0 --- /dev/null +++ b/packages/coding-agent/src/tui/tree-list.ts @@ -0,0 +1,56 @@ +/** + * Hierarchical tree list rendering helper. + */ + +import type { Theme } from "$c/modes/theme/theme"; +import { formatMoreItems } from "$c/tools/render-utils"; +import type { TreeContext } from "./types"; +import { getTreeBranch, getTreeContinuePrefix } from "./utils"; + +export interface TreeListOptions { + items: T[]; + expanded?: boolean; + maxCollapsed?: number; + itemType?: string; + renderItem: (item: T, context: TreeContext) => string | string[]; +} + +export function renderTreeList(options: TreeListOptions, theme: Theme): string[] { + const { items, expanded = false, maxCollapsed = 8, itemType = "item", renderItem } = options; + const lines: string[] = []; + const maxItems = expanded ? items.length : Math.min(items.length, maxCollapsed); + + for (let i = 0; i < maxItems; i++) { + const isLast = i === maxItems - 1 && (expanded || items.length <= maxCollapsed); + const branch = getTreeBranch(isLast, theme); + const prefix = `${theme.fg("dim", branch)} `; + const continuePrefix = `${theme.fg("dim", getTreeContinuePrefix(isLast, theme))}`; + const context: TreeContext = { + index: i, + isLast, + depth: 0, + theme, + prefix, + continuePrefix, + }; + const rendered = renderItem(items[i], context); + if (Array.isArray(rendered)) { + if (rendered.length === 0) continue; + lines.push(`${prefix}${rendered[0]}`); + for (let j = 1; j < rendered.length; j++) { + lines.push(`${continuePrefix}${rendered[j]}`); + } + } else { + lines.push(`${prefix}${rendered}`); + } + } + + if (!expanded && items.length > maxItems) { + const remaining = items.length - maxItems; + lines.push( + `${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType, theme))}`, + ); + } + + return lines; +} diff --git a/packages/coding-agent/src/tui/types.ts b/packages/coding-agent/src/tui/types.ts new file mode 100644 index 000000000..29ca95033 --- /dev/null +++ b/packages/coding-agent/src/tui/types.ts @@ -0,0 +1,17 @@ +/** + * Shared types for TUI rendering components. + */ + +import type { Theme } from "$c/modes/theme/theme"; + +export type State = "pending" | "running" | "success" | "error" | "warning"; +export type IconType = "success" | "error" | "running" | "pending" | "warning" | "info"; + +export interface TreeContext { + index: number; + isLast: boolean; + depth: number; + theme: Theme; + prefix: string; + continuePrefix: string; +} diff --git a/packages/coding-agent/src/tui/utils.ts b/packages/coding-agent/src/tui/utils.ts new file mode 100644 index 000000000..35ff46127 --- /dev/null +++ b/packages/coding-agent/src/tui/utils.ts @@ -0,0 +1,49 @@ +/** + * Shared helpers for tool-rendered UI components. + */ + +import { truncateToWidth as truncateToWidthBase, visibleWidth } from "@oh-my-pi/pi-tui"; +import type { Theme, ThemeBg } from "$c/modes/theme/theme"; +import type { IconType, State } from "./types"; + +export function buildTreePrefix(ancestors: boolean[], theme: Theme): string { + return ancestors.map((hasNext) => (hasNext ? `${theme.tree.vertical} ` : " ")).join(""); +} + +export function getTreeBranch(isLast: boolean, theme: Theme): string { + return isLast ? theme.tree.last : theme.tree.branch; +} + +export function getTreeContinuePrefix(isLast: boolean, theme: Theme): string { + return isLast ? " " : `${theme.tree.vertical} `; +} + +export function truncateToWidth(text: string, width: number, ellipsis: string): string { + return truncateToWidthBase(text, width, ellipsis); +} + +export function padToWidth(text: string, width: number, bgFn?: (s: string) => string): string { + if (width <= 0) return bgFn ? bgFn(text) : text; + const paddingNeeded = Math.max(0, width - visibleWidth(text)); + const padded = paddingNeeded > 0 ? text + " ".repeat(paddingNeeded) : text; + return bgFn ? bgFn(padded) : padded; +} + +export function getStateBgColor(state: State): ThemeBg { + if (state === "success") return "toolSuccessBg"; + if (state === "error") return "toolErrorBg"; + return "toolPendingBg"; +} + +export function getStateIcon(icon: IconType, theme: Theme, spinnerFrame?: number): string { + if (icon === "success") return theme.styledSymbol("status.success", "success"); + if (icon === "error") return theme.styledSymbol("status.error", "error"); + if (icon === "warning") return theme.styledSymbol("status.warning", "warning"); + if (icon === "info") return theme.styledSymbol("status.info", "accent"); + if (icon === "pending") return theme.styledSymbol("status.pending", "muted"); + if (spinnerFrame !== undefined) { + const frames = theme.spinnerFrames; + return frames[spinnerFrame % frames.length]; + } + return theme.styledSymbol("status.running", "accent"); +} diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index 6d2007ad9..ecede5665 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -20,6 +20,7 @@ import { TRUNCATE_LENGTHS, truncate, } from "$c/tools/render-utils"; +import { renderOutputBlock, renderStatusLine, renderTreeList } from "$c/tui"; import type { WebSearchResponse } from "./types"; const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES; @@ -107,7 +108,6 @@ export function renderWebSearchResult( ? getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN, theme.format.ellipsis) : []; - // Build header: status icon Web Search (provider) ยท counts const providerLabel = provider === "anthropic" ? "Anthropic" @@ -116,144 +116,106 @@ export function renderWebSearchResult( : provider === "exa" ? "Exa" : "Unknown"; - const headerIcon = formatStatusIcon(sourceCount > 0 ? "success" : "warning", theme); - const hasMore = - totalAnswerLines > answerPreview.length || - sourceCount > 0 || - citationCount > 0 || - relatedCount > 0 || - searchQueries.length > 0; - const expandHint = formatExpandHint(theme, expanded, hasMore); - let text = `${headerIcon} ${theme.fg("dim", `(${providerLabel})`)}${theme.sep.dot}${theme.fg( - "dim", - formatCount("source", sourceCount), - )}${expandHint}`; - - if (!expanded) { - const answerTitle = `${theme.fg("accent", theme.status.info)} ${theme.fg("accent", "Answer")}`; - text += `\n ${theme.fg("dim", theme.tree.branch)} ${answerTitle}`; - - const remaining = totalAnswerLines - answerPreview.length; - const allLines: Array<{ text: string; style: "dim" | "muted" }> = []; - - if (answerPreview.length === 0) { - allLines.push({ text: "No answer text returned", style: "muted" }); - } else { - for (const line of answerPreview) { - allLines.push({ text: line, style: "dim" }); - } - } - if (remaining > 0) { - allLines.push({ text: formatMoreItems(remaining, "line", theme), style: "muted" }); - } - - for (let i = 0; i < allLines.length; i++) { - const { text: lineText, style } = allLines[i]; - const isLastLine = i === allLines.length - 1; - const lineBranch = isLastLine ? theme.tree.last : theme.tree.branch; - text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", lineBranch)} ${theme.fg(style, lineText)}`; - } - - const summary = [ - formatCount("source", sourceCount), - formatCount("citation", citationCount), - formatCount("related question", relatedCount), - ].join(theme.sep.dot); - text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", summary)}`; - return new Text(text, 0, 0); - } - - const answerLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"]; - const answerSectionLines = answerLines.map((line) => - line === "No answer text returned" ? theme.fg("muted", line) : theme.fg("text", line), + const header = renderStatusLine( + { + icon: sourceCount > 0 ? "success" : "warning", + title: "Web Search", + description: providerLabel, + meta: [formatCount("source", sourceCount)], + }, + theme, ); + const remainingAnswer = totalAnswerLines - answerPreview.length; + const answerLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"]; + const answerTree = renderTreeList( + { + items: answerLines, + expanded: true, + maxCollapsed: answerLines.length, + itemType: "line", + renderItem: (line) => (line === "No answer text returned" ? theme.fg("muted", line) : theme.fg("dim", line)), + }, + theme, + ); if (remainingAnswer > 0) { - answerSectionLines.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line", theme))); + answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line", theme))); } - const sourceLines: string[] = []; - if (sourceCount === 0) { - sourceLines.push(theme.fg("muted", "No sources returned")); - } else { - for (const src of sources) { - const titleText = - typeof src.title === "string" && src.title.trim() - ? src.title - : typeof src.url === "string" && src.url.trim() - ? src.url - : "Untitled"; - const title = truncate(titleText, 70, theme.format.ellipsis); - const url = typeof src.url === "string" ? src.url : ""; - const domain = url ? getDomain(url) : ""; - const age = formatAge(src.ageSeconds) || (typeof src.publishedDate === "string" ? src.publishedDate : ""); - const metaParts: string[] = []; - if (domain) { - metaParts.push(theme.fg("dim", `(${domain})`)); - } - if (typeof src.author === "string" && src.author.trim()) { - metaParts.push(theme.fg("muted", src.author)); - } - if (age) { - metaParts.push(theme.fg("muted", age)); - } - const metaSep = theme.fg("dim", theme.sep.dot); - const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : ""; - sourceLines.push(`${theme.fg("accent", title)}${metaSuffix}`); - - const snippetText = typeof src.snippet === "string" ? src.snippet : ""; - if (snippetText.trim()) { - const snippetLines = getPreviewLines( - snippetText, - MAX_SNIPPET_LINES, - MAX_SNIPPET_LINE_LEN, - theme.format.ellipsis, - ); - for (const snippetLine of snippetLines) { - sourceLines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`)); + const sourceTree = renderTreeList( + { + items: sources, + expanded, + maxCollapsed: MAX_RELATED_QUESTIONS, + itemType: "source", + renderItem: (src) => { + const titleText = + typeof src.title === "string" && src.title.trim() + ? src.title + : typeof src.url === "string" && src.url.trim() + ? src.url + : "Untitled"; + const title = truncate(titleText, 70, theme.format.ellipsis); + const url = typeof src.url === "string" ? src.url : ""; + const domain = url ? getDomain(url) : ""; + const age = formatAge(src.ageSeconds) || (typeof src.publishedDate === "string" ? src.publishedDate : ""); + const metaParts: string[] = []; + if (domain) metaParts.push(theme.fg("dim", `(${domain})`)); + if (typeof src.author === "string" && src.author.trim()) metaParts.push(theme.fg("muted", src.author)); + if (age) metaParts.push(theme.fg("muted", age)); + const metaSep = theme.fg("dim", theme.sep.dot); + const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : ""; + const lines: string[] = [`${theme.fg("accent", title)}${metaSuffix}`]; + const snippetText = typeof src.snippet === "string" ? src.snippet : ""; + if (snippetText.trim()) { + const snippetLines = getPreviewLines( + snippetText, + MAX_SNIPPET_LINES, + MAX_SNIPPET_LINE_LEN, + theme.format.ellipsis, + ); + for (const snippetLine of snippetLines) { + lines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`)); + } } - } + if (url) lines.push(theme.fg("mdLinkUrl", url)); + return lines; + }, + }, + theme, + ); - if (url) { - sourceLines.push(theme.fg("mdLinkUrl", url)); - } - } - } - - const relatedLines: string[] = []; - if (relatedCount === 0) { - relatedLines.push(theme.fg("muted", "No related questions")); - } else { - const maxRelated = Math.min(MAX_RELATED_QUESTIONS, related.length); - for (let i = 0; i < maxRelated; i++) { - relatedLines.push(theme.fg("muted", `${theme.format.dash} ${related[i]}`)); - } - if (relatedCount > maxRelated) { - relatedLines.push(theme.fg("muted", formatMoreItems(relatedCount - maxRelated, "question", theme))); - } + const relatedLines = related.length > 0 ? related : ["No related questions"]; + const relatedTree = renderTreeList( + { + items: relatedLines, + expanded: true, + maxCollapsed: MAX_RELATED_QUESTIONS, + 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))); } const metaLines: string[] = []; metaLines.push(`${theme.fg("muted", "Provider:")} ${theme.fg("text", providerLabel)}`); - if (response.model) { - metaLines.push(`${theme.fg("muted", "Model:")} ${theme.fg("text", response.model)}`); - } + if (response.model) metaLines.push(`${theme.fg("muted", "Model:")} ${theme.fg("text", response.model)}`); metaLines.push(`${theme.fg("muted", "Sources:")} ${theme.fg("text", String(sourceCount))}`); - if (citationCount > 0) { + if (citationCount > 0) metaLines.push(`${theme.fg("muted", "Citations:")} ${theme.fg("text", String(citationCount))}`); - } - if (relatedCount > 0) { - metaLines.push(`${theme.fg("muted", "Related:")} ${theme.fg("text", String(relatedCount))}`); - } + if (relatedCount > 0) metaLines.push(`${theme.fg("muted", "Related:")} ${theme.fg("text", String(relatedCount))}`); if (response.usage) { const usageParts: string[] = []; if (response.usage.inputTokens !== undefined) usageParts.push(`in ${response.usage.inputTokens}`); if (response.usage.outputTokens !== undefined) usageParts.push(`out ${response.usage.outputTokens}`); if (response.usage.totalTokens !== undefined) usageParts.push(`total ${response.usage.totalTokens}`); if (response.usage.searchRequests !== undefined) usageParts.push(`search ${response.usage.searchRequests}`); - if (usageParts.length > 0) { + if (usageParts.length > 0) metaLines.push(`${theme.fg("muted", "Usage:")} ${theme.fg("text", usageParts.join(theme.sep.dot))}`); - } } if (response.requestId) { metaLines.push( @@ -264,55 +226,35 @@ export function renderWebSearchResult( ); } if (searchQueries.length > 0) { - metaLines.push(`${theme.fg("muted", "Search queries:")} ${theme.fg("text", String(searchQueries.length))}`); - const queryPreview = searchQueries.slice(0, MAX_QUERY_PREVIEW); - for (const q of queryPreview) { - metaLines.push(theme.fg("muted", `${theme.format.dash} ${truncate(q, MAX_QUERY_LEN, theme.format.ellipsis)}`)); - } - if (searchQueries.length > MAX_QUERY_PREVIEW) { - metaLines.push(theme.fg("muted", formatMoreItems(searchQueries.length - MAX_QUERY_PREVIEW, "query", theme))); - } + const queriesPreview = searchQueries.slice(0, MAX_QUERY_PREVIEW); + const queryList = queriesPreview.map((q) => truncate(q, MAX_QUERY_LEN, theme.format.ellipsis)); + const suffix = searchQueries.length > queriesPreview.length ? theme.format.ellipsis : ""; + metaLines.push(`${theme.fg("muted", "Queries:")} ${theme.fg("text", queryList.join("; "))}${suffix}`); } - const sections: Array<{ title: string; icon: string; lines: string[] }> = [ + const sections = [ + { label: theme.fg("toolTitle", "Answer"), lines: answerTree }, { - title: "Answer", - icon: formatStatusIcon("info", theme), - lines: answerSectionLines, - }, - { - title: "Sources", - icon: formatStatusIcon(sourceCount > 0 ? "success" : "warning", theme), - lines: sourceLines, - }, - { - title: "Related", - icon: formatStatusIcon(relatedCount > 0 ? "info" : "warning", theme), - lines: relatedLines, - }, - { - title: "Meta", - icon: formatStatusIcon("info", theme), - lines: metaLines, + label: theme.fg("toolTitle", "Sources"), + lines: sourceTree.length > 0 ? sourceTree : [theme.fg("muted", "No sources returned")], }, + { label: theme.fg("toolTitle", "Related"), lines: relatedTree }, + { label: theme.fg("toolTitle", "Metadata"), lines: metaLines }, ]; - for (let i = 0; i < sections.length; i++) { - const section = sections[i]; - const isLast = i === sections.length - 1; - const branch = isLast ? theme.tree.last : theme.tree.branch; - const indent = isLast ? " " : `${theme.tree.vertical} `; - - text += `\n ${theme.fg("dim", branch)} ${section.icon} ${theme.fg("accent", section.title)}`; - for (let j = 0; j < section.lines.length; j++) { - const line = section.lines[j]; - const isLastLine = j === section.lines.length - 1; - const lineBranch = isLastLine ? theme.tree.last : theme.tree.branch; - text += `\n ${theme.fg("dim", indent)}${theme.fg("dim", lineBranch)} ${line}`; - } - } - - return new Text(text, 0, 0); + return { + render: (width: number) => + renderOutputBlock( + { + header, + state: sourceCount > 0 ? "success" : "warning", + sections, + width, + }, + theme, + ), + invalidate: () => {}, + }; } /** Render web search call (query preview) */ @@ -322,7 +264,7 @@ export function renderWebSearchCall( ): Component { const provider = args.provider ?? "auto"; const query = truncate(args.query, 80, theme.format.ellipsis); - const text = `${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${provider})`)} ${theme.fg("muted", query)}`; + const text = renderStatusLine({ icon: "pending", title: "Web Search", description: query, meta: [provider] }, theme); return new Text(text, 0, 0); }