diff --git a/package.json b/package.json index 1fe2fc801..9bba3be2c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ ], "scripts": { "install:dev": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link && bun --cwd=packages/mom link", + "agent": "bun --cwd=packages/coding-agent src/cli.ts", "build": "bun --cwd=packages/web-ui run build:css", "dev": "bun --cwd=packages/web-ui run dev", "check": "biome check --write . && bun --cwd=packages/coding-agent run check && bun --cwd=packages/web-ui run check", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ed979af5f..ed3d0bf2e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,15 +1,18 @@ # Changelog ## [Unreleased] - ### Added +- Added shared render utilities module with standardized formatting functions for truncation, byte/token/duration display, and tree rendering +- Added `resolveOmpCommand()` helper to resolve subprocess command from environment or entry point - Added `cycleRoleModels()` method to cycle through configured role-based models in a fixed order with deduplication - Added language-specific file icons to LSP diagnostics output showing file locations - Added language-specific file icon to edit tool header display ### Changed +- Updated web search tool to support `auto` as explicit provider option for auto-detection +- Standardized tool result rendering across grep, find, ls, notebook, ask, output, and web search tools with consistent tree formatting and expand hints - Updated grep and find tool output to display language-specific icons for files and folder icons for directories - Updated file listing to display language-specific icons based on file extension instead of generic file icons diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c05ad0986..e4c398822 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -18,7 +18,6 @@ import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai"; import type { Rule } from "../capability/rule"; import { getAuthPath } from "../config"; -import { parseModelString } from "./model-resolver"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor"; import { type CompactionResult, @@ -46,6 +45,7 @@ import type { import { logger } from "./logger"; import type { BashExecutionMessage, HookMessage } from "./messages"; import type { ModelRegistry } from "./model-registry"; +import { parseModelString } from "./model-resolver"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager"; import type { SettingsManager, SkillsSettings } from "./settings-manager"; import { expandSlashCommand, type FileSlashCommand, parseCommandArgs } from "./slash-commands"; @@ -921,7 +921,7 @@ export class AgentSession { for (const role of roleOrder) { const roleModelStr = role === "default" - ? this.settingsManager.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}` + ? (this.settingsManager.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`) : this.settingsManager.getModelRole(role); if (!roleModelStr) continue; diff --git a/packages/coding-agent/src/core/tools/exa/render.ts b/packages/coding-agent/src/core/tools/exa/render.ts index eb998bb8d..4a4c25a90 100644 --- a/packages/coding-agent/src/core/tools/exa/render.ts +++ b/packages/coding-agent/src/core/tools/exa/render.ts @@ -9,29 +9,37 @@ import { Text } from "@oh-my-pi/pi-tui"; import type { Theme } from "../../../modes/interactive/theme/theme"; import type { RenderResultOptions } from "../../custom-tools/types"; import { logger } from "../../logger"; +import { + formatCount, + formatExpandHint, + formatMoreItems, + getDomain, + getPreviewLines, + getStyledStatusIcon, + PREVIEW_LIMITS, + TRUNCATE_LENGTHS, + truncate, +} from "../render-utils"; import type { ExaRenderDetails } from "./types"; -/** Truncate text to max length with ellipsis */ -function truncate(text: string, maxLen: number, ellipsis: string): string { - if (text.length <= maxLen) return text; - const sliceLen = Math.max(0, maxLen - ellipsis.length); - return `${text.slice(0, sliceLen)}${ellipsis}`; +const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.COLLAPSED_LINES; +const COLLAPSED_PREVIEW_LINE_LEN = TRUNCATE_LENGTHS.LONG; +const EXPANDED_TEXT_LINES = 5; +const EXPANDED_TEXT_LINE_LEN = 90; +const MAX_TITLE_LEN = TRUNCATE_LENGTHS.TITLE; +const MAX_HIGHLIGHT_LEN = TRUNCATE_LENGTHS.CONTENT; + +function renderErrorMessage(message: string, theme: Theme): Text { + const clean = message.replace(/^Error:\s*/, "").trim(); + return new Text( + `${getStyledStatusIcon("error", theme)} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`, + 0, + 0, + ); } -/** Extract domain from URL */ -function getDomain(url: string): string { - try { - const u = new URL(url); - return u.hostname.replace(/^www\./, ""); - } catch { - return url; - } -} - -/** Get first N lines of text as preview */ -function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] { - const lines = text.split("\n").filter((l) => l.trim()); - return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis)); +function renderEmptyMessage(message: string, theme: Theme): Text { + return new Text(`${getStyledStatusIcon("warning", theme)} ${theme.fg("muted", message)}`, 0, 0); } /** Render Exa result with tree-based layout */ @@ -43,26 +51,42 @@ export function renderExaResult( const { expanded } = options; const details = result.details; - // Handle error case if (details?.error) { logger.error("Exa render error", { error: details.error, toolName: details.toolName }); - return new Text(uiTheme.fg("error", `Error: ${details.error}`), 0, 0); + return renderErrorMessage(details.error, uiTheme); } const response = details?.response; if (!response) { - // Non-search response: show raw result if (details?.raw) { const rawText = typeof details.raw === "string" ? details.raw : JSON.stringify(details.raw, null, 2); - const preview = expanded ? rawText : truncate(rawText, 200, uiTheme.format.ellipsis); - const toolLabel = details?.toolName ?? "Exa"; - return new Text( - `${uiTheme.fg("success", uiTheme.format.bullet)} ${uiTheme.fg("toolTitle", toolLabel)}\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${preview}`, - 0, - 0, - ); + const rawLines = rawText.split("\n").filter((l) => l.trim()); + const maxLines = expanded ? rawLines.length : Math.min(rawLines.length, COLLAPSED_PREVIEW_LINES); + const displayLines = rawLines.slice(0, maxLines); + const remaining = rawLines.length - maxLines; + const expandHint = formatExpandHint(expanded, remaining > 0, uiTheme); + + let text = `${getStyledStatusIcon("info", uiTheme)} ${uiTheme.fg("dim", "Raw response")}${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", + truncate(displayLines[i], COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis), + )}`; + } + + if (remaining > 0) { + text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg( + "muted", + formatMoreItems(remaining, "line", uiTheme), + )}`; + } + + return new Text(text, 0, 0); } - return new Text(uiTheme.fg("error", "No response data"), 0, 0); + return renderEmptyMessage("No response data", uiTheme); } const results = response.results ?? []; @@ -70,118 +94,135 @@ export function renderExaResult( const cost = response.costDollars?.total; const time = response.searchTime; - // Build header: Exa Search · N results · $X.XX · Xs - const icon = - resultCount > 0 ? uiTheme.fg("success", uiTheme.format.bullet) : uiTheme.fg("warning", uiTheme.format.bullet); - const expandHint = expanded ? "" : uiTheme.fg("dim", " (Ctrl+O for full results)"); - const toolLabel = details?.toolName ?? "Exa Search"; + const icon = getStyledStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme); - let headerParts = `${icon} ${uiTheme.fg("toolTitle", toolLabel)}${uiTheme.sep.dot}${uiTheme.fg( - "dim", - `${resultCount} result${resultCount !== 1 ? "s" : ""}`, - )}`; + const metaParts = [formatCount("result", resultCount)]; + if (cost !== undefined) metaParts.push(`cost:$${cost.toFixed(4)}`); + if (time !== undefined) metaParts.push(`time:${time.toFixed(2)}s`); + const summaryText = metaParts.join(uiTheme.sep.dot); - if (cost !== undefined) { - headerParts += `${uiTheme.sep.dot}${uiTheme.fg("muted", `$${cost.toFixed(4)}`)}`; - } - if (time !== undefined) { - headerParts += `${uiTheme.sep.dot}${uiTheme.fg("muted", `${time.toFixed(2)}s`)}`; + let hasMorePreview = false; + if (!expanded && resultCount > 0) { + const previewText = results[0].text ?? results[0].title ?? ""; + const totalLines = previewText.split("\n").filter((l) => l.trim()).length; + hasMorePreview = totalLines > COLLAPSED_PREVIEW_LINES || resultCount > 1; } + const expandHint = formatExpandHint(expanded, hasMorePreview, uiTheme); - let text = headerParts + expandHint; + let text = `${icon} ${uiTheme.fg("dim", summaryText)}${expandHint}`; if (!expanded) { - // Collapsed view: show 3-line preview from first result - if (resultCount > 0) { - const first = results[0]; - const previewText = first.text ?? first.title ?? ""; - const previewLines = getPreviewLines(previewText, 3, 100, uiTheme.format.ellipsis); + if (resultCount === 0) { + text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No results")}`; + return new Text(text, 0, 0); + } - for (const line of previewLines) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${uiTheme.fg("dim", line)}`; - } + const first = results[0]; + const previewText = first.text ?? first.title ?? ""; + const previewLines = previewText + ? getPreviewLines(previewText, COLLAPSED_PREVIEW_LINES, COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis) + : []; + const safePreviewLines = previewLines.length > 0 ? previewLines : ["No preview text"]; + const totalLines = previewText.split("\n").filter((l) => l.trim()).length; + const remainingLines = Math.max(0, totalLines - previewLines.length); + const extraItems: string[] = []; + if (remainingLines > 0) { + extraItems.push(formatMoreItems(remainingLines, "line", uiTheme)); + } + if (resultCount > 1) { + extraItems.push(formatMoreItems(resultCount - 1, "result", uiTheme)); + } - const totalLines = previewText.split("\n").filter((l) => l.trim()).length; - if (totalLines > 3) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)} ${uiTheme.fg( - "muted", - `${uiTheme.format.ellipsis} ${totalLines - 3} more lines`, + for (let i = 0; i < safePreviewLines.length; i++) { + const isLast = i === safePreviewLines.length - 1 && extraItems.length === 0; + const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; + const line = safePreviewLines[i]; + const color = line === "No preview text" ? "muted" : "toolOutput"; + text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(color, line)}`; + } + + for (let i = 0; i < extraItems.length; i++) { + const isLast = i === extraItems.length - 1; + const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; + text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("muted", extraItems[i])}`; + } + + return new Text(text, 0, 0); + } + + if (resultCount === 0) { + text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No results")}`; + return new Text(text, 0, 0); + } + + for (let i = 0; i < results.length; i++) { + const res = results[i]; + const isLast = i === results.length - 1; + const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; + const cont = isLast ? " " : uiTheme.tree.vertical; + + const title = truncate(res.title ?? "Untitled", MAX_TITLE_LEN, uiTheme.format.ellipsis); + const domain = res.url ? getDomain(res.url) : ""; + const domainPart = domain ? uiTheme.fg("dim", ` (${domain})`) : ""; + + text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", title)}${domainPart}`; + + if (res.url) { + text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( + "mdLinkUrl", + res.url, + )}`; + } + + if (res.author) { + text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( + "muted", + `Author: ${res.author}`, + )}`; + } + + if (res.publishedDate) { + text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( + "muted", + `Published: ${res.publishedDate}`, + )}`; + } + + if (res.text) { + const textLines = res.text.split("\n").filter((l) => l.trim()); + const displayLines = textLines.slice(0, EXPANDED_TEXT_LINES); + for (const line of displayLines) { + text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( + "toolOutput", + truncate(line.trim(), EXPANDED_TEXT_LINE_LEN, uiTheme.format.ellipsis), )}`; } - - if (resultCount > 1) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg( + if (textLines.length > EXPANDED_TEXT_LINES) { + text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( "muted", - `${resultCount - 1} more result${resultCount !== 2 ? "s" : ""}`, + formatMoreItems(textLines.length - EXPANDED_TEXT_LINES, "line", uiTheme), )}`; } } - } else { - // Expanded view: full results tree - if (resultCount > 0) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.vertical)}`; - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("accent", "Results")}`; - for (let i = 0; i < results.length; i++) { - const res = results[i]; - const isLast = i === results.length - 1; - const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - const cont = isLast ? " " : uiTheme.tree.vertical; - - // Title + domain - const title = truncate(res.title ?? "Untitled", 60, uiTheme.format.ellipsis); - const domain = res.url ? getDomain(res.url) : ""; - const domainPart = domain ? uiTheme.fg("dim", ` (${domain})`) : ""; - - text += `\n ${uiTheme.fg("dim", " ")} ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", title)}${domainPart}`; - - // URL - if (res.url) { - text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("mdLinkUrl", res.url)}`; - } - - // Author - if (res.author) { - text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("muted", `Author: ${res.author}`)}`; - } - - // Published date - if (res.publishedDate) { - text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("muted", `Published: ${res.publishedDate}`)}`; - } - - // Text content - if (res.text) { - const textLines = res.text.split("\n").filter((l) => l.trim()); - const displayLines = textLines.slice(0, 5); // Show first 5 lines - for (const line of displayLines) { - text += `\n ${uiTheme.fg("dim", cont)} ${truncate(line.trim(), 90, uiTheme.format.ellipsis)}`; - } - if (textLines.length > 5) { - text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg( - "muted", - `${uiTheme.format.ellipsis} ${textLines.length - 5} more lines`, - )}`; - } - } - - // Highlights - if (res.highlights?.length) { - text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("accent", "Highlights:")}`; - for (let j = 0; j < Math.min(res.highlights.length, 3); j++) { - const h = res.highlights[j]; - text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg( - "muted", - `${uiTheme.format.bullet} ${truncate(h, 80, uiTheme.format.ellipsis)}`, - )}`; - } - if (res.highlights.length > 3) { - text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg( - "muted", - `${uiTheme.format.ellipsis} ${res.highlights.length - 3} more`, - )}`; - } - } + if (res.highlights?.length) { + text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( + "accent", + "Highlights", + )}`; + const maxHighlights = Math.min(res.highlights.length, 3); + for (let j = 0; j < maxHighlights; j++) { + const h = res.highlights[j]; + text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( + "muted", + `${uiTheme.format.dash} ${truncate(h, MAX_HIGHLIGHT_LEN, uiTheme.format.ellipsis)}`, + )}`; + } + if (res.highlights.length > maxHighlights) { + text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( + "muted", + formatMoreItems(res.highlights.length - maxHighlights, "highlight", uiTheme), + )}`; } } } @@ -191,10 +232,14 @@ export function renderExaResult( /** Render Exa call (query/args preview) */ export function renderExaCall(args: Record, toolName: string, uiTheme: Theme): Component { - const query = typeof args.query === "string" ? truncate(args.query, 80, uiTheme.format.ellipsis) : ""; + const toolLabel = toolName || "Exa Search"; + const query = typeof args.query === "string" ? truncate(args.query, 80, uiTheme.format.ellipsis) : "?"; const numResults = typeof args.num_results === "number" ? args.num_results : undefined; - const detail = numResults ? uiTheme.fg("dim", ` (${numResults} results)`) : ""; - const text = `${uiTheme.fg("toolTitle", toolName)} ${uiTheme.fg("muted", query)}${detail}`; + let text = `${uiTheme.fg("toolTitle", toolLabel)} ${uiTheme.fg("accent", query)}`; + if (numResults !== undefined) { + text += ` ${uiTheme.fg("muted", `results:${numResults}`)}`; + } + return new Text(text, 0, 0); } diff --git a/packages/coding-agent/src/core/tools/lsp/render.ts b/packages/coding-agent/src/core/tools/lsp/render.ts index 090fe901b..1954fff1e 100644 --- a/packages/coding-agent/src/core/tools/lsp/render.ts +++ b/packages/coding-agent/src/core/tools/lsp/render.ts @@ -12,6 +12,7 @@ import type { AgentToolResult, RenderResultOptions } from "@oh-my-pi/pi-agent-co import { Text } from "@oh-my-pi/pi-tui"; import { highlight, supportsLanguage } from "cli-highlight"; import { getLanguageFromPath, type Theme } from "../../../modes/interactive/theme/theme"; +import { formatExpandHint, formatMoreItems, TRUNCATE_LENGTHS, truncate } from "../render-utils"; import type { LspParams, LspToolDetails } from "./types"; // ============================================================================= @@ -25,8 +26,8 @@ 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 || "?"); + let text = theme.fg("toolTitle", theme.bold("LSP")); + text += ` ${theme.fg("accent", p.action || "?")}`; if (p.file) { text += ` ${theme.fg("muted", p.file)}`; @@ -112,7 +113,7 @@ function renderHover( const v = theme.boxSharp.vertical; const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`; const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; - let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}`; + let output = `${icon}${langLabel}`; output += `\n ${theme.fg("mdCodeBlockBorder", top)}`; for (const line of codeLines) { output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${line}`; @@ -126,9 +127,10 @@ function renderHover( // Collapsed view const firstCodeLine = codeLines[0] || ""; - const expandHint = theme.fg("dim", " (Ctrl+O to expand)"); + const hasMore = codeLines.length > 1 || Boolean(afterCode); + const expandHint = formatExpandHint(false, hasMore, theme); - let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}${expandHint}`; + let output = `${icon}${langLabel}${expandHint}`; const h = theme.boxSharp.horizontal; const v = theme.boxSharp.vertical; const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; @@ -142,9 +144,7 @@ function renderHover( } if (afterCode) { - const ellipsis = theme.format.ellipsis; - const sliceLen = Math.max(0, 60 - ellipsis.length); - const docPreview = afterCode.length > 60 ? `${afterCode.slice(0, sliceLen)}${ellipsis}` : afterCode; + const docPreview = truncate(afterCode, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis); output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", docPreview)}`; } else { output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`; @@ -224,7 +224,7 @@ function renderDiagnostics( const fallbackDiagnostics: RawDiagnostic[] = diagLines.map((line) => ({ raw: line.trim() })); if (expanded) { - let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}`; + let output = `${icon} ${theme.fg("dim", meta.join(theme.sep.dot))}`; const items: DiagnosticItem[] = parsedDiagnostics.length > 0 ? parsedDiagnostics : fallbackDiagnostics; for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -242,20 +242,19 @@ function renderDiagnostics( `[${item.severity}]`, )}`; if (item.message) { - output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg("muted", trimTo(item.message, 120, theme))}`; + output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg("muted", truncate(item.message, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis))}`; } } return new Text(output, 0, 0); } // Collapsed view - const expandHint = theme.fg("dim", " (Ctrl+O to expand)"); - let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}${expandHint}`; - const previewItems: DiagnosticItem[] = parsedDiagnostics.length > 0 ? parsedDiagnostics.slice(0, 3) : fallbackDiagnostics.slice(0, 3); const remaining = (parsedDiagnostics.length > 0 ? parsedDiagnostics.length : fallbackDiagnostics.length) - previewItems.length; + const expandHint = formatExpandHint(false, remaining > 0, theme); + let output = `${icon} ${theme.fg("dim", meta.join(theme.sep.dot))}${expandHint}`; for (let i = 0; i < previewItems.length; i++) { const item = previewItems[i]; const isLast = i === previewItems.length - 1 && remaining <= 0; @@ -266,14 +265,13 @@ function renderDiagnostics( } const severityColor = severityToColor(item.severity); const location = formatDiagnosticLocation(item.file, item.line, item.col, theme); - const message = item.message ? ` ${theme.fg("muted", trimTo(item.message, 80, theme))}` : ""; + const message = item.message + ? ` ${theme.fg("muted", truncate(item.message, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis))}` + : ""; output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`; } if (remaining > 0) { - output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( - "muted", - `${theme.format.ellipsis} ${remaining} more`, - )}`; + output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)}`; } return new Text(output, 0, 0); @@ -307,8 +305,8 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: const files = Array.from(byFile.keys()); const renderGrouped = (maxFiles: number, maxLocsPerFile: number, showHint: boolean): string => { - const expandHint = showHint ? theme.fg("dim", " (Ctrl+O to expand)") : ""; - let output = `${icon} ${theme.fg("toolTitle", "References")} ${theme.fg("dim", `${refCount} found`)}${expandHint}`; + const expandHint = formatExpandHint(false, showHint, theme); + let output = `${icon} ${theme.fg("dim", `${refCount} found`)}${expandHint}`; const filesToShow = files.slice(0, maxFiles); for (let fi = 0; fi < filesToShow.length; fi++) { @@ -336,7 +334,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: const context = `at ${file}:${line}:${col}`; output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locCont)}${theme.fg( "muted", - trimTo(context, 120, theme), + truncate(context, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis), )}`; } } @@ -352,7 +350,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: if (files.length > maxFiles) { output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", - `${theme.format.ellipsis} ${files.length - maxFiles} more files`, + formatMoreItems(files.length - maxFiles, "file", theme), )}`; } @@ -430,7 +428,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded const topLevelCount = symbols.filter((s) => s.indent === 0).length; if (expanded) { - let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}`; + let output = `${icon} ${theme.fg("dim", `in ${fileName}`)}`; for (let i = 0; i < symbols.length; i++) { const sym = symbols[i]; @@ -448,10 +446,10 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded } // Collapsed: show first 3 top-level symbols - const expandHint = theme.fg("dim", " (Ctrl+O to expand)"); - let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`; - const topLevel = symbols.filter((s) => s.indent === 0).slice(0, 3); + const hasMoreSymbols = symbols.length > topLevel.length; + const expandHint = formatExpandHint(false, hasMoreSymbols, theme); + let output = `${icon} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`; for (let i = 0; i < topLevel.length; i++) { const sym = topLevel[i]; const isLast = i === topLevel.length - 1 && topLevelCount <= 3; @@ -462,10 +460,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded )} ${theme.fg("muted", `line ${sym.line}`)}`; } if (topLevelCount > 3) { - output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( - "muted", - `${theme.format.ellipsis} ${topLevelCount - 3} more`, - )}`; + output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${topLevelCount - 3} more`)}`; } return new Text(output, 0, 0); @@ -490,7 +485,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: : theme.styledSymbol("status.info", "accent"); if (expanded) { - let output = `${icon} ${theme.fg("toolTitle", "LSP")} ${theme.fg("dim", "Output")}`; + let output = `${icon} ${theme.fg("dim", "Output")}`; for (let i = 0; i < lines.length; i++) { const isLast = i === lines.length - 1; const branch = isLast ? theme.tree.last : theme.tree.branch; @@ -500,21 +495,18 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: } const firstLine = lines[0] || "No output"; - const expandHint = lines.length > 1 ? theme.fg("dim", " (Ctrl+O to expand)") : ""; - let output = `${icon} ${theme.fg("toolTitle", "LSP")} ${theme.fg("dim", firstLine.slice(0, 60))}${expandHint}`; + const expandHint = formatExpandHint(false, lines.length > 1, theme); + let output = `${icon} ${theme.fg("dim", truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis))}${expandHint}`; if (lines.length > 1) { const previewLines = lines.slice(1, 4); for (let i = 0; i < previewLines.length; i++) { const isLast = i === previewLines.length - 1 && lines.length <= 4; const branch = isLast ? theme.tree.last : theme.tree.branch; - output += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", previewLines[i].trim().slice(0, 80))}`; + output += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", truncate(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis))}`; } if (lines.length > 4) { - output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( - "muted", - `${theme.format.ellipsis} ${lines.length - 4} more lines`, - )}`; + output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(lines.length - 4, "line", theme))}`; } } @@ -558,9 +550,3 @@ function severityToColor(severity: string): "error" | "warning" | "accent" | "di return "dim"; } } - -function trimTo(value: string, maxLength: number, theme: Theme): string { - if (value.length <= maxLength) return value; - const sliceLen = Math.max(0, maxLength - theme.format.ellipsis.length); - return `${value.slice(0, sliceLen)}${theme.format.ellipsis}`; -} diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts new file mode 100644 index 000000000..69a66d095 --- /dev/null +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -0,0 +1,268 @@ +/** + * Shared utilities and constants for tool renderers. + * + * Provides consistent formatting, truncation, and display patterns across all + * tool renderers to ensure a unified TUI experience. + */ + +import type { Theme } from "../../modes/interactive/theme/theme"; + +// ============================================================================= +// Standardized Display Constants +// ============================================================================= + +/** Preview limits for collapsed/expanded views */ +export const PREVIEW_LIMITS = { + /** Lines shown in collapsed view */ + COLLAPSED_LINES: 3, + /** Lines shown in expanded view */ + EXPANDED_LINES: 12, + /** Items (files, results) shown in collapsed view */ + COLLAPSED_ITEMS: 8, + /** Output preview lines in collapsed view */ + OUTPUT_COLLAPSED: 3, + /** Output preview lines in expanded view */ + OUTPUT_EXPANDED: 10, +} as const; + +/** Truncation lengths for different content types */ +export const TRUNCATE_LENGTHS = { + /** Short titles, labels */ + TITLE: 60, + /** Medium-length content (messages, previews) */ + CONTENT: 80, + /** Longer content (code, explanations) */ + LONG: 100, + /** Full line content */ + LINE: 110, + /** Very short (task previews, badges) */ + SHORT: 40, +} as const; + +/** Standard expand hint text */ +export const EXPAND_HINT = "(Ctrl+O to expand)"; + +// ============================================================================= +// Text Truncation Utilities +// ============================================================================= + +/** + * Truncate text to max length with ellipsis. + * The most commonly duplicated utility across renderers. + */ +export function truncate(text: string, maxLen: number, ellipsis: string): string { + if (text.length <= maxLen) return text; + const sliceLen = Math.max(0, maxLen - ellipsis.length); + return `${text.slice(0, sliceLen)}${ellipsis}`; +} + +/** + * Get first N lines of text as preview, with each line truncated. + */ +export function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] { + const lines = text.split("\n").filter((l) => l.trim()); + return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis)); +} + +// ============================================================================= +// URL Utilities +// ============================================================================= + +/** + * Extract domain from URL, stripping www. prefix. + */ +export function getDomain(url: string): string { + try { + const u = new URL(url); + return u.hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +// ============================================================================= +// Formatting Utilities +// ============================================================================= + +/** + * Format byte count for display (e.g., "1.5KB", "2.3MB"). + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +/** + * Format token count for display (e.g., "1.5k", "25k"). + */ +export function formatTokens(tokens: number): string { + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return String(tokens); +} + +/** + * Format duration for display (e.g., "500ms", "2.5s", "1.2m"). + */ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}m`; +} + +/** + * Format count with pluralized label (e.g., "3 files", "1 error"). + */ +export function formatCount(label: string, count: number): string { + const safeCount = Number.isFinite(count) ? count : 0; + return `${safeCount} ${pluralize(label, safeCount)}`; +} + +/** + * Format age from seconds to human-readable string. + */ +export function formatAge(ageSeconds: number | null | undefined): string { + if (!ageSeconds) return ""; + const mins = Math.floor(ageSeconds / 60); + const hours = Math.floor(mins / 60); + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + const months = Math.floor(days / 30); + + if (months > 0) return `${months}mo ago`; + if (weeks > 0) return `${weeks}w ago`; + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (mins > 0) return `${mins}m ago`; + return "just now"; +} + +// ============================================================================= +// Theme Helper Utilities +// ============================================================================= + +/** + * Get the appropriate status icon with color for a given state. + * Standardizes status icon usage across all renderers. + */ +export function getStyledStatusIcon( + status: "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted", + theme: Theme, + spinnerFrame?: number, +): string { + switch (status) { + case "success": + return theme.styledSymbol("status.success", "success"); + case "error": + return theme.styledSymbol("status.error", "error"); + case "warning": + return theme.styledSymbol("status.warning", "warning"); + case "info": + return theme.styledSymbol("status.info", "accent"); + case "pending": + return theme.styledSymbol("status.pending", "muted"); + case "running": + if (spinnerFrame !== undefined) { + const frames = theme.spinnerFrames; + return frames[spinnerFrame % frames.length]; + } + return theme.styledSymbol("status.running", "accent"); + case "aborted": + return theme.styledSymbol("status.aborted", "error"); + } +} + +/** + * Format the expand hint with proper theming. + * Returns empty string if already expanded or there is nothing more to show. + */ +export function formatExpandHint(expanded: boolean, hasMore: boolean, theme: Theme): string { + return !expanded && hasMore ? theme.fg("dim", ` ${EXPAND_HINT}`) : ""; +} + +/** + * Format a badge like [done] or [failed] with brackets and color. + */ +export function formatBadge( + label: string, + color: "success" | "error" | "warning" | "accent" | "muted", + theme: Theme, +): string { + const left = theme.format.bracketLeft; + const right = theme.format.bracketRight; + return theme.fg(color, `${left}${label}${right}`); +} + +/** + * Build a "more items" suffix line for truncated lists. + * Uses consistent wording pattern. + */ +export function formatMoreItems(remaining: number, itemType: string, theme: Theme): string { + const safeRemaining = Number.isFinite(remaining) ? remaining : 0; + return `${theme.format.ellipsis} ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`; +} + +function pluralize(label: string, count: number): string { + if (count === 1) return label; + if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`; + if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`; + return `${label}s`; +} + +// ============================================================================= +// 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. + * + * @param items - Full list of items to render + * @param expanded - Whether view is expanded + * @param maxCollapsed - Max items to show when collapsed + * @param renderItem - Function to render a single item + * @param itemType - Type name for "more X" message (e.g., "file", "entry") + * @param theme - Theme instance + * @returns Array of formatted lines + */ +export function renderTreeList( + items: T[], + expanded: boolean, + maxCollapsed: number, + renderItem: (item: T, branch: string, isLast: boolean, theme: Theme) => string, + itemType: string, + theme: Theme, +): string[] { + 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); + lines.push(renderItem(items[i], branch, isLast, theme)); + } + + if (!expanded && items.length > maxCollapsed) { + const remaining = items.length - maxCollapsed; + lines.push( + ` ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType, theme))}`, + ); + } + + return lines; +} diff --git a/packages/coding-agent/src/core/tools/renderers.ts b/packages/coding-agent/src/core/tools/renderers.ts index c87170642..d309e5553 100644 --- a/packages/coding-agent/src/core/tools/renderers.ts +++ b/packages/coding-agent/src/core/tools/renderers.ts @@ -16,6 +16,15 @@ import { renderCall as renderLspCall, renderResult as renderLspResult } from "./ import type { LspToolDetails } from "./lsp/types"; import type { NotebookToolDetails } from "./notebook"; import type { OutputToolDetails } from "./output"; +import { + formatBytes, + formatCount, + formatExpandHint, + formatMoreItems, + PREVIEW_LIMITS, + TRUNCATE_LENGTHS, + truncate, +} from "./render-utils"; import { renderCall as renderTaskCall, renderResult as renderTaskResult } from "./task/render"; import type { TaskToolDetails } from "./task/types"; import { renderWebFetchCall, renderWebFetchResult, type WebFetchToolDetails } from "./web-fetch"; @@ -32,6 +41,34 @@ interface ToolRenderer { ): Component; } +const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS; +const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2; + +function formatMeta(meta: string[], theme: Theme): string { + return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : ""; +} + +function formatScope(scopePath: string | undefined, theme: Theme): string { + return scopePath ? ` ${theme.fg("muted", `in ${scopePath}`)}` : ""; +} + +function formatTruncationSuffix(truncated: boolean, theme: Theme): string { + return truncated ? theme.fg("warning", " (truncated)") : ""; +} + +function renderErrorMessage(_toolLabel: string, message: string, theme: Theme): Text { + const clean = message.replace(/^Error:\s*/, "").trim(); + return new Text( + `${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`, + 0, + 0, + ); +} + +function renderEmptyMessage(_toolLabel: string, message: string, theme: Theme): Text { + return new Text(`${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`, 0, 0); +} + // ============================================================================ // Grep Renderer // ============================================================================ @@ -52,58 +89,55 @@ interface GrepArgs { const grepRenderer: ToolRenderer = { renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("grep ")); - text += theme.fg("accent", args.pattern || "?"); + const label = theme.fg("toolTitle", theme.bold("Grep")); + let text = `${label} ${theme.fg("accent", args.pattern || "?")}`; const meta: string[] = []; - if (args.path) meta.push(args.path); + if (args.path) meta.push(`in ${args.path}`); if (args.glob) meta.push(`glob:${args.glob}`); if (args.type) meta.push(`type:${args.type}`); - if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(args.outputMode); + if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(`mode:${args.outputMode}`); if (args.caseSensitive) { - meta.push("--case-sensitive"); + meta.push("case:sensitive"); } else if (args.ignoreCase) { - meta.push("-i"); + meta.push("case:insensitive"); } + if (args.literal) meta.push("literal"); if (args.multiline) meta.push("multiline"); + if (args.context !== undefined) meta.push(`context:${args.context}`); + if (args.limit !== undefined) meta.push(`limit:${args.limit}`); - if (meta.length > 0) { - text += ` ${theme.fg("muted", meta.join(" "))}`; - } + text += formatMeta(meta, theme); return new Text(text, 0, 0); }, renderResult(result, { expanded }, theme) { + const label = "Grep"; const details = result.details; - // Error case if (details?.error) { - return new Text(`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", details.error)}`, 0, 0); + return renderErrorMessage(label, details.error, theme); } - // Check for detailed rendering data - fall back to structured output if not available const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined; if (!hasDetailedData) { const textContent = result.content?.find((c) => c.type === "text")?.text; if (!textContent || textContent === "No matches found") { - return new Text( - `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No matches found")}`, - 0, - 0, - ); + return renderEmptyMessage(label, "No matches found", theme); } const lines = textContent.split("\n").filter((line) => line.trim() !== ""); - const maxLines = expanded ? lines.length : 10; + 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; - let text = `${theme.styledSymbol("status.success", "success")} ${theme.fg("toolTitle", "grep")} ${theme.fg( - "dim", - `${lines.length} item${lines.length !== 1 ? "s" : ""}`, - )}`; + const icon = theme.styledSymbol("status.success", "success"); + const summary = formatCount("item", lines.length); + const expandHint = formatExpandHint(expanded, hasMore, theme); + let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`; for (let i = 0; i < displayLines.length; i++) { const isLast = i === displayLines.length - 1 && remaining === 0; @@ -114,9 +148,10 @@ const grepRenderer: ToolRenderer = { if (remaining > 0) { text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", - `${theme.format.ellipsis} ${remaining} more items`, + formatMoreItems(remaining, "item", theme), )}`; } + return new Text(text, 0, 0); } @@ -126,33 +161,29 @@ const grepRenderer: ToolRenderer = { const truncated = details?.truncated ?? details?.truncation?.truncated ?? false; const files = details?.files ?? []; - // No matches if (matchCount === 0) { - return new Text( - `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No matches found")}`, - 0, - 0, - ); + return renderEmptyMessage(label, "No matches found", theme); } - // Build summary const icon = theme.styledSymbol("status.success", "success"); - let summary: string; - if (mode === "files_with_matches") { - summary = `${fileCount} file${fileCount !== 1 ? "s" : ""}`; - } else if (mode === "count") { - summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`; - } else { - summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`; - } + const summaryParts = + mode === "files_with_matches" + ? [formatCount("file", fileCount)] + : [formatCount("match", matchCount), formatCount("file", fileCount)]; + const summaryText = summaryParts.join(theme.sep.dot); + const scopeLabel = formatScope(details?.scopePath, theme); - if (truncated) { - summary += theme.fg("warning", " (truncated)"); - } + 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 = formatExpandHint(expanded, hasMoreFiles, theme); - const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); - const scopeLabel = details?.scopePath ? ` ${theme.fg("muted", `in ${details.scopePath}`)}` : ""; - let text = `${icon} ${theme.fg("toolTitle", "grep")} ${theme.fg("dim", summary)}${scopeLabel}${expandHint}`; + let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix( + truncated, + theme, + )}${scopeLabel}${expandHint}`; const truncationReasons: string[] = []; if (details?.matchLimitReached) { @@ -168,16 +199,12 @@ const grepRenderer: ToolRenderer = { truncationReasons.push("line length"); } - 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 hasTruncation = truncationReasons.length > 0; - // Show file tree if we have files if (fileEntries.length > 0) { - const maxFiles = expanded ? fileEntries.length : Math.min(fileEntries.length, 8); for (let i = 0; i < maxFiles; i++) { const entry = fileEntries[i]; - const isLast = i === maxFiles - 1 && (expanded || fileEntries.length <= 8); + const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation; const branch = isLast ? theme.tree.last : theme.tree.branch; const isDir = entry.path.endsWith("/"); const entryPath = isDir ? entry.path.slice(0, -1) : entry.path; @@ -192,15 +219,16 @@ const grepRenderer: ToolRenderer = { text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry.path)}${countLabel}`; } - if (!expanded && fileEntries.length > 8) { - text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( + if (hasMoreFiles) { + const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last; + text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg( "muted", - `${theme.format.ellipsis} ${fileEntries.length - 8} more files`, + formatMoreItems(fileEntries.length - maxFiles, "file", theme), )}`; } } - if (truncationReasons.length > 0) { + if (hasTruncation) { text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "warning", `truncated: ${truncationReasons.join(", ")}`, @@ -226,54 +254,48 @@ interface FindArgs { const findRenderer: ToolRenderer = { renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("find ")); - text += theme.fg("accent", args.pattern || "*"); + const label = theme.fg("toolTitle", theme.bold("Find")); + let text = `${label} ${theme.fg("accent", args.pattern || "*")}`; const meta: string[] = []; - if (args.path) meta.push(args.path); + if (args.path) meta.push(`in ${args.path}`); if (args.type && args.type !== "all") meta.push(`type:${args.type}`); - if (args.hidden) meta.push("--hidden"); + if (args.hidden) meta.push("hidden"); + if (args.sortByMtime) meta.push("sort:mtime"); + if (args.limit !== undefined) meta.push(`limit:${args.limit}`); - if (meta.length > 0) { - text += ` ${theme.fg("muted", meta.join(" "))}`; - } + text += formatMeta(meta, theme); return new Text(text, 0, 0); }, renderResult(result, { expanded }, theme) { + const label = "Find"; const details = result.details; - // Error case if (details?.error) { - return new Text(`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", details.error)}`, 0, 0); + return renderErrorMessage(label, details.error, theme); } - // Check for detailed rendering data - fall back to parsing raw output if not available const hasDetailedData = details?.fileCount !== undefined; - - // Get text content for fallback or to extract file list const textContent = result.content?.find((c) => c.type === "text")?.text; if (!hasDetailedData) { if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") { - return new Text( - `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No files found")}`, - 0, - 0, - ); + return renderEmptyMessage(label, "No files found", theme); } - // Parse the raw output as file list const lines = textContent.split("\n").filter((l) => l.trim()); - const maxLines = expanded ? lines.length : Math.min(lines.length, 8); + 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 = theme.styledSymbol("status.success", "success"); + const summary = formatCount("file", lines.length); + const expandHint = formatExpandHint(expanded, hasMore, theme); + let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`; - let text = `${theme.styledSymbol("status.success", "success")} ${theme.fg("toolTitle", "find")} ${theme.fg( - "dim", - `${lines.length} file${lines.length !== 1 ? "s" : ""}`, - )}`; for (let i = 0; i < displayLines.length; i++) { const isLast = i === displayLines.length - 1 && remaining === 0; const branch = isLast ? theme.tree.last : theme.tree.branch; @@ -282,7 +304,7 @@ const findRenderer: ToolRenderer = { if (remaining > 0) { text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", - `${theme.format.ellipsis} ${remaining} more files`, + formatMoreItems(remaining, "file", theme), )}`; } return new Text(text, 0, 0); @@ -292,26 +314,21 @@ const findRenderer: ToolRenderer = { const truncated = details?.truncated ?? details?.truncation?.truncated ?? false; const files = details?.files ?? []; - // No matches if (fileCount === 0) { - return new Text( - `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "No files found")}`, - 0, - 0, - ); + return renderEmptyMessage(label, "No files found", theme); } - // Build summary const icon = theme.styledSymbol("status.success", "success"); - let summary = `${fileCount} file${fileCount !== 1 ? "s" : ""}`; + const summaryText = formatCount("file", fileCount); + const scopeLabel = formatScope(details?.scopePath, theme); + const maxFiles = expanded ? files.length : Math.min(files.length, COLLAPSED_LIST_LIMIT); + const hasMoreFiles = files.length > maxFiles; + const expandHint = formatExpandHint(expanded, hasMoreFiles, theme); - if (truncated) { - summary += theme.fg("warning", " (truncated)"); - } - - const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); - const scopeLabel = details?.scopePath ? ` ${theme.fg("muted", `in ${details.scopePath}`)}` : ""; - let text = `${icon} ${theme.fg("toolTitle", "find")} ${theme.fg("dim", summary)}${scopeLabel}${expandHint}`; + let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix( + truncated, + theme, + )}${scopeLabel}${expandHint}`; const truncationReasons: string[] = []; if (details?.resultLimitReached) { @@ -321,11 +338,11 @@ const findRenderer: ToolRenderer = { truncationReasons.push("size limit"); } - // Show file tree if we have files + const hasTruncation = truncationReasons.length > 0; + if (files.length > 0) { - const maxFiles = expanded ? files.length : Math.min(files.length, 8); for (let i = 0; i < maxFiles; i++) { - const isLast = i === maxFiles - 1 && (expanded || files.length <= 8); + const isLast = i === maxFiles - 1 && !hasMoreFiles && !hasTruncation; const branch = isLast ? theme.tree.last : theme.tree.branch; const entry = files[i]; const isDir = entry.endsWith("/"); @@ -337,15 +354,16 @@ const findRenderer: ToolRenderer = { text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg("accent", entry)}`; } - if (!expanded && files.length > 8) { - text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( + if (hasMoreFiles) { + const moreFilesBranch = hasTruncation ? theme.tree.branch : theme.tree.last; + text += `\n ${theme.fg("dim", moreFilesBranch)} ${theme.fg( "muted", - `${theme.format.ellipsis} ${files.length - 8} more files`, + formatMoreItems(files.length - maxFiles, "file", theme), )}`; } } - if (truncationReasons.length > 0) { + if (hasTruncation) { text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "warning", `truncated: ${truncationReasons.join(", ")}`, @@ -378,7 +396,7 @@ function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): st return `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", "(empty cell)")}`; } - const maxLines = expanded ? normalized.length : Math.min(normalized.length, 6); + const maxLines = expanded ? normalized.length : Math.min(normalized.length, COLLAPSED_TEXT_LIMIT); let text = ""; for (let i = 0; i < maxLines; i++) { @@ -390,10 +408,7 @@ function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): st const remaining = normalized.length - maxLines; if (remaining > 0) { - text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( - "muted", - `${theme.format.ellipsis} ${remaining} more lines`, - )}`; + text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line", theme))}`; } return text; @@ -401,28 +416,26 @@ function renderCellPreview(lines: string[], expanded: boolean, theme: Theme): st const notebookRenderer: ToolRenderer = { renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("notebook ")); - text += theme.fg("accent", args.action || "?"); + const label = theme.fg("toolTitle", theme.bold("Notebook")); + let text = `${label} ${theme.fg("accent", args.action || "?")}`; const meta: string[] = []; - meta.push(args.notebookPath || "?"); + meta.push(`in ${args.notebookPath || "?"}`); if (args.cellNumber !== undefined) meta.push(`cell:${args.cellNumber}`); - if (args.cellType) meta.push(args.cellType); + if (args.cellType) meta.push(`type:${args.cellType}`); - if (meta.length > 0) { - text += ` ${theme.fg("muted", meta.join(" "))}`; - } + text += formatMeta(meta, theme); return new Text(text, 0, 0); }, renderResult(result, { expanded }, theme) { + const label = "Notebook"; const details = result.details; - // Error case - check for error in content const content = result.content?.[0]; if (content?.type === "text" && content.text?.startsWith("Error:")) { - return new Text(`${theme.styledSymbol("status.error", "error")} ${theme.fg("error", content.text)}`, 0, 0); + return renderErrorMessage(label, content.text, theme); } const action = details?.action ?? "edit"; @@ -431,33 +444,18 @@ const notebookRenderer: ToolRenderer = { const totalCells = details?.totalCells; const cellSource = details?.cellSource; const lineCount = cellSource?.length; - const canExpand = cellSource !== undefined && cellSource.length > 6; + const canExpand = cellSource !== undefined && cellSource.length > COLLAPSED_TEXT_LIMIT; - // Build summary const icon = theme.styledSymbol("status.success", "success"); - let summary: string; + 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)); + if (totalCells !== undefined) summaryParts.push(`${totalCells} total`); + const summaryText = summaryParts.join(theme.sep.dot); - switch (action) { - case "insert": - summary = `Inserted ${cellType || "cell"} at index ${cellIndex}`; - break; - case "delete": - summary = `Deleted cell at index ${cellIndex}`; - break; - default: - summary = `Edited ${cellType || "cell"} at index ${cellIndex}`; - } - - if (lineCount !== undefined) { - summary += ` (${lineCount} line${lineCount !== 1 ? "s" : ""})`; - } - - if (totalCells !== undefined) { - summary += ` (${totalCells} total)`; - } - - const expandHint = !expanded && canExpand ? theme.fg("dim", " (Ctrl+O to expand)") : ""; - let text = `${icon} ${theme.fg("toolTitle", "notebook")} ${theme.fg("dim", summary)}${expandHint}`; + const expandHint = formatExpandHint(expanded, canExpand, theme); + let text = `${icon} ${theme.fg("dim", summaryText)}${expandHint}`; if (cellSource) { text += renderCellPreview(cellSource, expanded, theme); @@ -480,15 +478,26 @@ interface AskArgs { const askRenderer: ToolRenderer = { renderCall(args, theme) { if (!args.question) { - return new Text(theme.fg("error", "ask: no question provided"), 0, 0); + return renderErrorMessage("Ask", "No question provided", theme); } - const multiTag = args.multi ? theme.fg("muted", " [multi-select]") : ""; - let text = theme.fg("toolTitle", "? ") + theme.fg("accent", args.question) + multiTag; + const label = theme.fg("toolTitle", theme.bold("Ask")); + let text = `${label} ${theme.fg("accent", args.question)}`; + + const meta: string[] = []; + if (args.multi) meta.push("multi"); + if (args.options?.length) meta.push(`options:${args.options.length}`); + text += formatMeta(meta, theme); if (args.options?.length) { - for (const opt of args.options) { - text += `\n${theme.fg("dim", ` ${theme.checkbox.unchecked} `)}${theme.fg("muted", opt.label)}`; + for (let i = 0; i < args.options.length; i++) { + const opt = args.options[i]; + const isLast = i === args.options.length - 1; + const branch = isLast ? theme.tree.last : theme.tree.branch; + text += `\n ${theme.fg("dim", branch)} ${theme.fg( + "dim", + theme.checkbox.unchecked, + )} ${theme.fg("muted", opt.label)}`; } } @@ -507,7 +516,7 @@ const askRenderer: ToolRenderer = { ? theme.styledSymbol("status.success", "success") : theme.styledSymbol("status.warning", "warning"); - let text = `${statusIcon} ${theme.fg("toolTitle", "ask")} ${theme.fg("accent", details.question)}`; + let text = `${statusIcon} ${theme.fg("accent", details.question)}`; if (details.customInput) { text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.styledSymbol( @@ -565,23 +574,10 @@ interface OutputArgs { format?: "raw" | "json" | "stripped"; } -/** Format byte count for display */ -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`; - return `${(bytes / (1024 * 1024)).toFixed(1)}M`; -} - -function truncateLine(text: string, maxLen: number, ellipsis: string): string { - if (text.length <= maxLen) return text; - const sliceLen = Math.max(0, maxLen - ellipsis.length); - return `${text.slice(0, sliceLen)}${ellipsis}`; -} - type OutputEntry = OutputToolDetails["outputs"][number]; function formatOutputMeta(entry: OutputEntry, theme: Theme): string { - const metaParts = [`${entry.lineCount} lines, ${formatBytes(entry.charCount)}`]; + const metaParts = [formatCount("line", entry.lineCount), formatBytes(entry.charCount)]; if (entry.provenance) { metaParts.push(`agent ${entry.provenance.agent}(${entry.provenance.index})`); } @@ -591,48 +587,56 @@ function formatOutputMeta(entry: OutputEntry, theme: Theme): string { const outputRenderer: ToolRenderer = { renderCall(args, theme) { const ids = args.ids?.join(", ") ?? "?"; - const label = theme.fg("toolTitle", theme.bold("output")); - const format = args.format && args.format !== "raw" ? theme.fg("muted", ` (${args.format})`) : ""; - return new Text(`${label} ${theme.fg("dim", ids)}${format}`, 0, 0); + const label = theme.fg("toolTitle", theme.bold("Output")); + let text = `${label} ${theme.fg("accent", ids)}`; + + const meta: string[] = []; + if (args.format && args.format !== "raw") meta.push(`format:${args.format}`); + text += formatMeta(meta, theme); + + return new Text(text, 0, 0); }, renderResult(result, { expanded }, theme) { + const label = "Output"; const details = result.details; - // Error case: some IDs not found if (details?.notFound?.length) { - let text = `${theme.styledSymbol("status.error", "error")} Not found: ${details.notFound.join(", ")}`; + const icon = theme.styledSymbol("status.error", "error"); + let text = `${icon} ${theme.fg("error", `Error: Not found: ${details.notFound.join(", ")}`)}`; if (details.availableIds?.length) { - text += `\n${theme.fg("dim", "Available:")} ${details.availableIds.join(", ")}`; + text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( + "muted", + `Available: ${details.availableIds.join(", ")}`, + )}`; } else { - text += `\n${theme.fg("dim", "No outputs available in current session")}`; + text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( + "muted", + "No outputs available in current session", + )}`; } return new Text(text, 0, 0); } const outputs = details?.outputs ?? []; - // No session case if (outputs.length === 0) { const textContent = result.content?.find((c) => c.type === "text")?.text; - return new Text( - `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", textContent || "No outputs")}`, - 0, - 0, - ); + return renderEmptyMessage(label, textContent || "No outputs", theme); } - // Success: summary + tree display - const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); const icon = theme.styledSymbol("status.success", "success"); - const summary = `read ${outputs.length} output${outputs.length !== 1 ? "s" : ""}`; - let text = `${icon} ${theme.fg("toolTitle", "output")} ${theme.fg("dim", summary)}${expandHint}`; - + const summary = `read ${formatCount("output", outputs.length)}`; const previewLimit = expanded ? 3 : 1; const maxOutputs = expanded ? outputs.length : Math.min(outputs.length, 5); + const hasMoreOutputs = outputs.length > maxOutputs; + const hasMorePreview = outputs.some((o) => (o.previewLines?.length ?? 0) > previewLimit); + const expandHint = formatExpandHint(expanded, hasMoreOutputs || hasMorePreview, theme); + let text = `${icon} ${theme.fg("dim", summary)}${expandHint}`; + for (let i = 0; i < maxOutputs; i++) { const o = outputs[i]; - const isLast = i === maxOutputs - 1 && (expanded || outputs.length <= 5); + const isLast = i === maxOutputs - 1 && !hasMoreOutputs; const branch = isLast ? theme.tree.last : theme.tree.branch; text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", o.id)} ${formatOutputMeta(o, theme)}`; @@ -641,19 +645,19 @@ const outputRenderer: ToolRenderer = { if (shownPreview.length > 0) { const childPrefix = isLast ? " " : ` ${theme.fg("dim", theme.tree.vertical)} `; for (const line of shownPreview) { - const previewText = truncateLine(line, 80, theme.format.ellipsis); - text += `\n${childPrefix}${theme.fg("dim", theme.tree.hook)} ${theme.fg("muted", "preview:")} ${theme.fg( - "toolOutput", - previewText, - )}`; + const previewText = truncate(line, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis); + text += `\n${childPrefix}${theme.fg("dim", theme.tree.hook)} ${theme.fg( + "muted", + "preview:", + )} ${theme.fg("toolOutput", previewText)}`; } } } - if (!expanded && outputs.length > 5) { + if (hasMoreOutputs) { text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", - `${theme.format.ellipsis} ${outputs.length - 5} more outputs`, + formatMoreItems(outputs.length - maxOutputs, "output", theme), )}`; } @@ -681,27 +685,26 @@ interface LsArgs { const lsRenderer: ToolRenderer = { renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("ls ")); - text += theme.fg("accent", args.path || "."); - if (args.limit !== undefined) { - text += ` ${theme.fg("muted", `(limit ${args.limit})`)}`; - } + const label = theme.fg("toolTitle", theme.bold("Ls")); + let text = `${label} ${theme.fg("accent", args.path || ".")}`; + + const meta: string[] = []; + if (args.limit !== undefined) meta.push(`limit:${args.limit}`); + text += formatMeta(meta, theme); + return new Text(text, 0, 0); }, renderResult(result, { expanded }, theme) { + const label = "Ls"; const details = result.details; - const textContent = result.content?.find((c: any) => c.type === "text")?.text ?? ""; + const textContent = result.content?.find((c) => c.type === "text")?.text ?? ""; if ( (!textContent || textContent.trim() === "" || textContent.trim() === "(empty directory)") && (!details?.entries || details.entries.length === 0) ) { - return new Text( - `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "Empty directory")}`, - 0, - 0, - ); + return renderEmptyMessage(label, "Empty directory", theme); } let entries: string[] = details?.entries ? [...details.entries] : []; @@ -711,11 +714,7 @@ const lsRenderer: ToolRenderer = { } if (entries.length === 0) { - return new Text( - `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", "Empty directory")}`, - 0, - 0, - ); + return renderEmptyMessage(label, "Empty directory", theme); } let dirCount = details?.dirCount; @@ -737,45 +736,47 @@ const lsRenderer: ToolRenderer = { ? theme.styledSymbol("status.warning", "warning") : theme.styledSymbol("status.success", "success"); - const dirLabel = `${dirCount} dir${dirCount !== 1 ? "s" : ""}`; - const fileLabel = `${fileCount} file${fileCount !== 1 ? "s" : ""}`; - let text = `${icon} ${theme.fg("toolTitle", "ls")} ${theme.fg("dim", `${dirLabel}, ${fileLabel}`)}`; + const summaryText = [formatCount("dir", dirCount ?? 0), formatCount("file", fileCount ?? 0)].join(theme.sep.dot); + const maxEntries = expanded ? entries.length : Math.min(entries.length, COLLAPSED_LIST_LIMIT); + const hasMoreEntries = entries.length > maxEntries; + const expandHint = formatExpandHint(expanded, hasMoreEntries, theme); - if (truncated) { - const reasonParts: string[] = []; - if (details?.entryLimitReached) { - reasonParts.push(`entry limit ${details.entryLimitReached}`); - } - if (details?.truncation?.truncated) { - reasonParts.push(`output cap ${formatBytes(details.truncation.maxBytes)}`); - } - const reasonText = reasonParts.length > 0 ? `truncated: ${reasonParts.join(", ")}` : "truncated"; - text += ` ${theme.fg("warning", `(${reasonText})`)}`; + let text = `${icon} ${theme.fg("dim", summaryText)}${formatTruncationSuffix(truncated, theme)}${expandHint}`; + + const truncationReasons: string[] = []; + if (details?.entryLimitReached) { + truncationReasons.push(`entry limit ${details.entryLimitReached}`); + } + if (details?.truncation?.truncated) { + truncationReasons.push(`output cap ${formatBytes(details.truncation.maxBytes)}`); } - if (!expanded) { - text += `\n${theme.fg("dim", `${theme.nav.expand} Ctrl+O to expand list`)}`; - } + const hasTruncation = truncationReasons.length > 0; - const maxEntries = expanded ? entries.length : Math.min(entries.length, 12); for (let i = 0; i < maxEntries; i++) { const entry = entries[i]; - const isLast = i === maxEntries - 1 && (expanded || entries.length <= 12); + const isLast = i === maxEntries - 1 && !hasMoreEntries && !hasTruncation; const branch = isLast ? theme.tree.last : theme.tree.branch; const isDir = entry.endsWith("/"); const entryPath = isDir ? entry.slice(0, -1) : entry; const lang = isDir ? undefined : getLanguageFromPath(entryPath); - const entryIcon = isDir - ? theme.fg("accent", theme.icon.folder) - : theme.fg("muted", theme.getLangIcon(lang)); + const entryIcon = isDir ? theme.fg("accent", theme.icon.folder) : theme.fg("muted", theme.getLangIcon(lang)); const entryColor = isDir ? "accent" : "toolOutput"; text += `\n ${theme.fg("dim", branch)} ${entryIcon} ${theme.fg(entryColor, entry)}`; } - if (!expanded && entries.length > 12) { - text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( + if (hasMoreEntries) { + const moreEntriesBranch = hasTruncation ? theme.tree.branch : theme.tree.last; + text += `\n ${theme.fg("dim", moreEntriesBranch)} ${theme.fg( "muted", - `${theme.format.ellipsis} ${entries.length - 12} more entries`, + formatMoreItems(entries.length - maxEntries, "entry", theme), + )}`; + } + + if (hasTruncation) { + text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( + "warning", + `truncated: ${truncationReasons.join(", ")}`, )}`; } diff --git a/packages/coding-agent/src/core/tools/task/executor.ts b/packages/coding-agent/src/core/tools/task/executor.ts index bcfb2c3e4..ae9b2f972 100644 --- a/packages/coding-agent/src/core/tools/task/executor.ts +++ b/packages/coding-agent/src/core/tools/task/executor.ts @@ -12,6 +12,7 @@ import * as path from "node:path"; import * as readline from "node:readline"; import { ensureArtifactsDir, getArtifactPaths } from "./artifacts"; import { resolveModelPattern } from "./model-resolver"; +import { resolveOmpCommand } from "./omp-command"; import { subprocessToolRegistry } from "./subprocess-tool-registry"; import { type AgentDefinition, @@ -23,12 +24,6 @@ import { type SingleResult, } from "./types"; -/** omp command: 'omp.cmd' on Windows, 'omp' elsewhere */ -const OMP_CMD = process.platform === "win32" ? "omp.cmd" : "omp"; - -/** Windows shell option for spawn */ -const OMP_SHELL_OPT = process.platform === "win32"; - /** Options for subprocess execution */ export interface ExecutorOptions { cwd: string; @@ -268,10 +263,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise = { 3: "P3", }; -/** - * Format token count for display (e.g., 1.5k, 25k). - */ -function formatTokens(tokens: number): string { - if (tokens >= 1000) { - return `${(tokens / 1000).toFixed(1)}k`; - } - return String(tokens); -} - -/** - * Format duration for display. - */ -export function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; - return `${(ms / 60000).toFixed(1)}m`; -} - -/** - * Truncate text to max length with ellipsis. - */ -function truncate(text: string, maxLen: number, ellipsis: string): string { - if (text.length <= maxLen) return text; - const sliceLen = Math.max(0, maxLen - ellipsis.length); - return `${text.slice(0, sliceLen)}${ellipsis}`; -} - /** * Get status icon for agent state. * For running status, uses animated spinner if spinnerFrame is provided. + * Maps AgentProgress status to styled icon format. */ function getStatusIcon(status: AgentProgress["status"], theme: Theme, spinnerFrame?: number): string { switch (status) { case "pending": - return theme.status.pending; - case "running": { - // Use animated spinner if frame is provided, otherwise static icon - if (spinnerFrame === undefined) return theme.status.running; - const frames = theme.spinnerFrames; - return frames[spinnerFrame % frames.length]; - } + return getStyledStatusIcon("pending", theme); + case "running": + return getStyledStatusIcon("running", theme, spinnerFrame); case "completed": - return theme.status.success; + return getStyledStatusIcon("success", theme); case "failed": - return theme.status.error; + return getStyledStatusIcon("error", theme); case "aborted": - return theme.status.aborted; + return getStyledStatusIcon("aborted", theme); } } -function formatBadge(label: string, color: "success" | "error" | "warning" | "accent" | "muted", theme: Theme): string { - const left = theme.format.bracketLeft; - const right = theme.format.bracketRight; - return theme.fg(color, `${left}${label}${right}`); -} - function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): string { if (findings.length === 0) return theme.fg("dim", "Findings: none"); @@ -119,10 +90,7 @@ function renderOutputSection( if (outputLines.length > previewCount) { lines.push( - `${continuePrefix} ${theme.fg( - "dim", - `${theme.format.ellipsis} ${outputLines.length - previewCount} more lines`, - )}`, + `${continuePrefix} ${theme.fg("dim", formatMoreItems(outputLines.length - previewCount, "line", theme))}`, ); } @@ -133,7 +101,7 @@ function renderOutputSection( * Render the tool call arguments. */ export function renderCall(args: TaskParams, theme: Theme): Component { - const label = theme.fg("toolTitle", theme.bold("task")); + const label = theme.fg("toolTitle", theme.bold("Task")); if (args.tasks.length === 1) { // Single task - show agent and task preview @@ -235,9 +203,7 @@ function renderAgentProgress( } } if (dataArray.length > 3) { - lines.push( - `${continuePrefix}${theme.fg("dim", `${theme.format.ellipsis} ${dataArray.length - 3} more`)}`, - ); + lines.push(`${continuePrefix}${theme.fg("dim", formatMoreItems(dataArray.length - 3, "item", theme))}`); } } } @@ -337,9 +303,7 @@ function renderFindings( } if (!expanded && findings.length > 3) { - lines.push( - `${continuePrefix}${theme.fg("dim", `${theme.format.ellipsis} ${findings.length - 3} more findings`)}`, - ); + lines.push(`${continuePrefix}${theme.fg("dim", formatMoreItems(findings.length - 3, "finding", theme))}`); } return lines; @@ -479,11 +443,11 @@ export function renderResult( let summary = `\n${theme.fg("dim", "Total:")} `; if (abortedCount > 0) { summary += theme.fg("error", `${abortedCount} aborted`); - if (successCount > 0 || failCount > 0) summary += ", "; + if (successCount > 0 || failCount > 0) summary += theme.sep.dot; } if (successCount > 0) { summary += theme.fg("success", `${successCount} succeeded`); - if (failCount > 0) summary += ", "; + if (failCount > 0) summary += theme.sep.dot; } if (failCount > 0) { summary += theme.fg("error", `${failCount} failed`); diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts index 2b2d72ac3..96056832e 100644 --- a/packages/coding-agent/src/core/tools/web-fetch.ts +++ b/packages/coding-agent/src/core/tools/web-fetch.ts @@ -2384,7 +2384,7 @@ export function renderWebFetchResult( ? uiTheme.styledSymbol("status.warning", "warning") : uiTheme.styledSymbol("status.success", "success"); const expandHint = expanded ? "" : uiTheme.fg("dim", " (Ctrl+O to expand)"); - let text = `${statusIcon} ${uiTheme.fg("toolTitle", "Web Fetch")} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandHint}`; + let text = `${statusIcon} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandHint}`; // Get content text const contentText = result.content[0]?.text ?? ""; diff --git a/packages/coding-agent/src/core/tools/web-search/index.ts b/packages/coding-agent/src/core/tools/web-search/index.ts index 00ebd052b..420152d0b 100644 --- a/packages/coding-agent/src/core/tools/web-search/index.ts +++ b/packages/coding-agent/src/core/tools/web-search/index.ts @@ -20,10 +20,11 @@ import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../c import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchResponse } from "../exa/mcp-client"; import { renderExaCall, renderExaResult } from "../exa/render"; import type { ExaRenderDetails } from "../exa/types"; +import { formatAge } from "../render-utils"; import { searchAnthropic } from "./providers/anthropic"; import { searchExa } from "./providers/exa"; import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity"; -import { formatAge, renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render"; +import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render"; import type { WebSearchProvider, WebSearchResponse } from "./types"; /** Web search parameters schema */ @@ -31,8 +32,8 @@ export const webSearchSchema = Type.Object({ // Common query: Type.String({ description: "Search query" }), provider: Type.Optional( - Type.Union([Type.Literal("exa"), Type.Literal("anthropic"), Type.Literal("perplexity")], { - description: "Search provider (auto-detected if omitted based on API keys)", + Type.Union([Type.Literal("auto"), Type.Literal("exa"), Type.Literal("anthropic"), Type.Literal("perplexity")], { + description: "Search provider (auto-detected if omitted or set to auto)", }), ), num_results: Type.Optional(Type.Number({ description: "Maximum number of results to return" })), @@ -81,7 +82,7 @@ export const webSearchSchema = Type.Object({ export type WebSearchParams = { query: string; - provider?: "exa" | "anthropic" | "perplexity"; + provider?: "auto" | "exa" | "anthropic" | "perplexity"; num_results?: number; // Anthropic system_prompt?: string; @@ -198,7 +199,7 @@ async function executeWebSearch( params: WebSearchParams, ): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> { try { - const provider = params.provider ?? (await detectProvider()); + const provider = params.provider && params.provider !== "auto" ? params.provider : await detectProvider(); let response: WebSearchResponse; if (provider === "exa") { diff --git a/packages/coding-agent/src/core/tools/web-search/render.ts b/packages/coding-agent/src/core/tools/web-search/render.ts index bcfd4157c..73d2cc172 100644 --- a/packages/coding-agent/src/core/tools/web-search/render.ts +++ b/packages/coding-agent/src/core/tools/web-search/render.ts @@ -8,72 +8,39 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import type { Theme } from "../../../modes/interactive/theme/theme"; import type { RenderResultOptions } from "../../custom-tools/types"; +import { + formatAge, + formatCount, + formatExpandHint, + formatMoreItems, + getDomain, + getPreviewLines, + getStyledStatusIcon, + PREVIEW_LIMITS, + TRUNCATE_LENGTHS, + truncate, +} from "../render-utils"; import type { WebSearchResponse } from "./types"; -/** Truncate text to max length with ellipsis */ -export function truncate(text: string, maxLen: number, ellipsis: string): string { - if (text.length <= maxLen) return text; - const sliceLen = Math.max(0, maxLen - ellipsis.length); - return `${text.slice(0, sliceLen)}${ellipsis}`; -} - -/** Extract domain from URL */ -export function getDomain(url: string): string { - try { - const u = new URL(url); - return u.hostname.replace(/^www\./, ""); - } catch { - return url; - } -} - -/** Format age string from seconds */ -export function formatAge(ageSeconds: number | null | undefined): string { - if (!ageSeconds) return ""; - const mins = Math.floor(ageSeconds / 60); - const hours = Math.floor(mins / 60); - const days = Math.floor(hours / 24); - const weeks = Math.floor(days / 7); - const months = Math.floor(days / 30); - - if (months > 0) return `${months}mo ago`; - if (weeks > 0) return `${weeks}w ago`; - if (days > 0) return `${days}d ago`; - if (hours > 0) return `${hours}h ago`; - if (mins > 0) return `${mins}m ago`; - return "just now"; -} - -/** Get first N lines of text as preview */ -export function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] { - const lines = text.split("\n").filter((l) => l.trim()); - return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis)); -} - -const MAX_COLLAPSED_ANSWER_LINES = 3; -const MAX_EXPANDED_ANSWER_LINES = 12; -const MAX_ANSWER_LINE_LEN = 110; +const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES; +const MAX_EXPANDED_ANSWER_LINES = PREVIEW_LIMITS.EXPANDED_LINES; +const MAX_ANSWER_LINE_LEN = TRUNCATE_LENGTHS.LINE; const MAX_SNIPPET_LINES = 2; -const MAX_SNIPPET_LINE_LEN = 110; +const MAX_SNIPPET_LINE_LEN = TRUNCATE_LENGTHS.LINE; const MAX_RELATED_QUESTIONS = 6; const MAX_QUERY_PREVIEW = 2; const MAX_QUERY_LEN = 90; const MAX_REQUEST_ID_LEN = 36; -function formatCount(label: string, count: number): string { - const safeCount = Number.isFinite(count) ? count : 0; - return `${safeCount} ${label}${safeCount === 1 ? "" : "s"}`; -} - function renderFallbackText(contentText: string, expanded: boolean, theme: Theme): Component { const lines = contentText.split("\n").filter((line) => line.trim()); const maxLines = expanded ? lines.length : 6; const displayLines = lines.slice(0, maxLines).map((line) => truncate(line.trim(), 110, theme.format.ellipsis)); const remaining = lines.length - displayLines.length; - const headerIcon = theme.fg("warning", theme.status.warning); - const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); - let text = `${headerIcon} ${theme.fg("toolTitle", "Web Search")}${expandHint}`; + const headerIcon = getStyledStatusIcon("warning", theme); + const expandHint = formatExpandHint(expanded, remaining > 0, theme); + let text = `${headerIcon} ${theme.fg("dim", "Response")}${expandHint}`; if (displayLines.length === 0) { text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", "No response data")}`; @@ -87,10 +54,7 @@ function renderFallbackText(contentText: string, expanded: boolean, theme: Theme } if (!expanded && remaining > 0) { - text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( - "muted", - `${theme.format.ellipsis} ${remaining} more line${remaining === 1 ? "" : "s"}`, - )}`; + text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line", theme))}`; } return new Text(text, 0, 0); @@ -134,23 +98,6 @@ export function renderWebSearchResult( : []; const provider = response.provider; - // Build header: status icon Web Search (provider) · counts - const providerLabel = - provider === "anthropic" - ? "Anthropic" - : provider === "perplexity" - ? "Perplexity" - : provider === "exa" - ? "Exa" - : "Unknown"; - const headerIcon = - sourceCount > 0 ? theme.fg("success", theme.status.success) : theme.fg("warning", theme.status.warning); - const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); - let text = `${headerIcon} ${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${providerLabel})`)}${theme.sep.dot}${theme.fg( - "dim", - formatCount("source", sourceCount), - )}${expandHint}`; - // Get answer text const answerText = typeof response.answer === "string" ? response.answer.trim() : ""; const contentText = answerText || rawText; @@ -160,36 +107,57 @@ 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" + : provider === "perplexity" + ? "Perplexity" + : provider === "exa" + ? "Exa" + : "Unknown"; + const headerIcon = getStyledStatusIcon(sourceCount > 0 ? "success" : "warning", theme); + const hasMore = + totalAnswerLines > answerPreview.length || + sourceCount > 0 || + citationCount > 0 || + relatedCount > 0 || + searchQueries.length > 0; + const expandHint = formatExpandHint(expanded, hasMore, theme); + 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.vertical)} ${answerTitle}`; - - if (answerPreview.length === 0) { - text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg( - "muted", - "No answer text returned", - )}`; - } else { - for (const line of answerPreview) { - text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg( - "dim", - line, - )}`; - } - } + 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) { - text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg( - "muted", - `${theme.format.ellipsis} ${remaining} more line${remaining === 1 ? "" : "s"}`, - )}`; + 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", relatedCount), + 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); @@ -201,9 +169,7 @@ export function renderWebSearchResult( ); const remainingAnswer = totalAnswerLines - answerPreview.length; if (remainingAnswer > 0) { - answerSectionLines.push( - theme.fg("muted", `${theme.format.ellipsis} ${remainingAnswer} more line${remainingAnswer === 1 ? "" : "s"}`), - ); + answerSectionLines.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line", theme))); } const sourceLines: string[] = []; @@ -263,14 +229,7 @@ export function renderWebSearchResult( relatedLines.push(theme.fg("muted", `${theme.format.dash} ${related[i]}`)); } if (relatedCount > maxRelated) { - relatedLines.push( - theme.fg( - "muted", - `${theme.format.ellipsis} ${relatedCount - maxRelated} more question${ - relatedCount - maxRelated === 1 ? "" : "s" - }`, - ), - ); + relatedLines.push(theme.fg("muted", formatMoreItems(relatedCount - maxRelated, "question", theme))); } } @@ -311,36 +270,29 @@ export function renderWebSearchResult( 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", - `${theme.format.ellipsis} ${searchQueries.length - MAX_QUERY_PREVIEW} more query${ - searchQueries.length - MAX_QUERY_PREVIEW === 1 ? "" : "s" - }`, - ), - ); + metaLines.push(theme.fg("muted", formatMoreItems(searchQueries.length - MAX_QUERY_PREVIEW, "query", theme))); } } const sections: Array<{ title: string; icon: string; lines: string[] }> = [ { title: "Answer", - icon: theme.fg("accent", theme.status.info), + icon: getStyledStatusIcon("info", theme), lines: answerSectionLines, }, { title: "Sources", - icon: sourceCount > 0 ? theme.fg("success", theme.status.success) : theme.fg("warning", theme.status.warning), + icon: getStyledStatusIcon(sourceCount > 0 ? "success" : "warning", theme), lines: sourceLines, }, { title: "Related", - icon: relatedCount > 0 ? theme.fg("accent", theme.status.info) : theme.fg("warning", theme.status.warning), + icon: getStyledStatusIcon(relatedCount > 0 ? "info" : "warning", theme), lines: relatedLines, }, { title: "Meta", - icon: theme.fg("accent", theme.status.info), + icon: getStyledStatusIcon("info", theme), lines: metaLines, }, ]; @@ -349,11 +301,14 @@ export function renderWebSearchResult( 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; + const indent = isLast ? " " : `${theme.tree.vertical} `; text += `\n ${theme.fg("dim", branch)} ${section.icon} ${theme.fg("accent", section.title)}`; - for (const line of section.lines) { - text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", `${theme.tree.hook} `)}${line}`; + 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}`; } } diff --git a/packages/coding-agent/src/modes/interactive/components/status-line.ts b/packages/coding-agent/src/modes/interactive/components/status-line.ts index 5ed651ddd..4684fff64 100644 --- a/packages/coding-agent/src/modes/interactive/components/status-line.ts +++ b/packages/coding-agent/src/modes/interactive/components/status-line.ts @@ -333,9 +333,7 @@ export class StatusLineComponent implements Component { ? separatorDef.endCaps.right : separatorDef.endCaps.left : ""; - const capPrefix = separatorDef.endCaps?.useBgAsFg - ? bgAnsi.replace("\x1b[48;", "\x1b[38;") - : bgAnsi + sepAnsi; + const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : bgAnsi + sepAnsi; const capText = cap ? `${capPrefix}${cap}\x1b[0m` : ""; let content = bgAnsi + fgAnsi; diff --git a/packages/coding-agent/src/modes/interactive/theme/defaults/index.ts b/packages/coding-agent/src/modes/interactive/theme/defaults/index.ts index fb68f45ac..158b5a705 100644 --- a/packages/coding-agent/src/modes/interactive/theme/defaults/index.ts +++ b/packages/coding-agent/src/modes/interactive/theme/defaults/index.ts @@ -96,11 +96,11 @@ import sandstone from "./sandstone.json" with { type: "json" }; import titanium from "./titanium.json" with { type: "json" }; export const defaultThemes = { - "alabaster": alabaster, - "amethyst": amethyst, - "anthracite": anthracite, - "basalt": basalt, - "birch": birch, + alabaster: alabaster, + amethyst: amethyst, + anthracite: anthracite, + basalt: basalt, + birch: birch, "dark-abyss": dark_abyss, "dark-arctic": dark_arctic, "dark-aurora": dark_aurora, @@ -143,7 +143,7 @@ export const defaultThemes = { "dark-tundra": dark_tundra, "dark-twilight": dark_twilight, "dark-volcanic": dark_volcanic, - "graphite": graphite, + graphite: graphite, "light-arctic": light_arctic, "light-aurora-day": light_aurora_day, "light-canyon": light_canyon, @@ -182,14 +182,14 @@ export const defaultThemes = { "light-tokyo-night": light_tokyo_night, "light-wetland": light_wetland, "light-zenith": light_zenith, - "limestone": limestone, - "mahogany": mahogany, - "marble": marble, - "obsidian": obsidian, - "onyx": onyx, - "pearl": pearl, - "porcelain": porcelain, - "quartz": quartz, - "sandstone": sandstone, - "titanium": titanium, + limestone: limestone, + mahogany: mahogany, + marble: marble, + obsidian: obsidian, + onyx: onyx, + pearl: pearl, + porcelain: porcelain, + quartz: quartz, + sandstone: sandstone, + titanium: titanium, };