diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index 205e74b8f..3e65fd328 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -76,6 +76,8 @@ export interface RenderResultOptions { expanded: boolean; /** Whether this is a partial/streaming result */ isPartial: boolean; + /** Current spinner frame index for animated elements (0-9, only provided during partial results) */ + spinnerFrame?: number; } export type CustomToolResult = AgentToolResult; diff --git a/packages/coding-agent/src/core/tools/task/render.ts b/packages/coding-agent/src/core/tools/task/render.ts index 29b4bab9d..e9cc19a53 100644 --- a/packages/coding-agent/src/core/tools/task/render.ts +++ b/packages/coding-agent/src/core/tools/task/render.ts @@ -52,13 +52,18 @@ function truncate(text: string, maxLen: number, ellipsis: string): string { /** * Get status icon for agent state. + * For running status, uses animated spinner if spinnerFrame is provided. */ -function getStatusIcon(status: AgentProgress["status"], theme: Theme): string { +function getStatusIcon(status: AgentProgress["status"], theme: Theme, spinnerFrame?: number): string { switch (status) { case "pending": return theme.status.pending; - case "running": - return theme.status.running; + 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]; + } case "completed": return theme.status.success; case "failed": @@ -149,14 +154,20 @@ export function renderCall(args: TaskParams, theme: Theme): Component { /** * Render streaming progress for a single agent. */ -function renderAgentProgress(progress: AgentProgress, isLast: boolean, expanded: boolean, theme: Theme): string[] { +function renderAgentProgress( + progress: AgentProgress, + isLast: boolean, + expanded: boolean, + theme: Theme, + spinnerFrame?: number, +): string[] { const lines: string[] = []; const prefix = isLast ? `${theme.boxSharp.bottomLeft}${theme.boxSharp.horizontal}` : `${theme.boxSharp.teeRight}${theme.boxSharp.horizontal}`; const continuePrefix = isLast ? " " : `${theme.boxSharp.vertical} `; - const icon = getStatusIcon(progress.status, theme); + const icon = getStatusIcon(progress.status, theme, spinnerFrame); const iconColor = progress.status === "completed" ? "success" @@ -164,20 +175,22 @@ function renderAgentProgress(progress: AgentProgress, isLast: boolean, expanded: ? "error" : "accent"; - const statusLabel = - progress.status === "running" - ? "running" - : progress.status === "completed" + // Main status line - include index for Output tool ID derivation + const agentId = `${progress.agent}(${progress.index})`; + let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", agentId)}`; + + // Only show badge for non-running states (spinner already indicates running) + if (progress.status !== "running") { + const statusLabel = + progress.status === "completed" ? "done" : progress.status === "failed" ? "failed" : progress.status === "aborted" ? "aborted" : "pending"; - - // Main status line - include index for Output tool ID derivation - const agentId = `${progress.agent}(${progress.index})`; - let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", agentId)} ${formatBadge(statusLabel, iconColor, theme)}`; + statusLine += ` ${formatBadge(statusLabel, iconColor, theme)}`; + } if (progress.status === "running") { const taskPreview = truncate(progress.task, 40, theme.format.ellipsis); @@ -435,7 +448,7 @@ export function renderResult( options: RenderResultOptions, theme: Theme, ): Component { - const { expanded, isPartial } = options; + const { expanded, isPartial, spinnerFrame } = options; const details = result.details; if (!details) { @@ -450,7 +463,7 @@ export function renderResult( // Streaming progress view details.progress.forEach((progress, i) => { const isLast = i === details.progress!.length - 1; - lines.push(...renderAgentProgress(progress, isLast, expanded, theme)); + lines.push(...renderAgentProgress(progress, isLast, expanded, theme, spinnerFrame)); }); } else if (details.results.length > 0) { // Final results view diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index e0346520e..e3997e730 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -37,8 +37,21 @@ function countLines(text: string): number { return text.split("\n").length; } -function formatMetadataLine(lineCount: number, language: string): string { - return theme.fg("dim", wrapBrackets(`${lineCount} lines, ${language}`)); +function formatMetadataLine(lineCount: number, language: string | undefined): string { + const icon = theme.getLangIcon(language); + const langLabel = language ?? "text"; + return theme.fg("dim", `${icon} ${lineCount} lines`); +} + +const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "tiff"]); +const BINARY_EXTENSIONS = new Set(["pdf", "zip", "tar", "gz", "exe", "dll", "so", "dylib", "wasm"]); + +function getFileType(filePath: string): "image" | "binary" | "text" { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return "text"; + if (IMAGE_EXTENSIONS.has(ext)) return "image"; + if (BINARY_EXTENSIONS.has(ext)) return "binary"; + return "text"; } function formatDiffStats(added: number, removed: number, hunks: number): string { @@ -337,6 +350,9 @@ export class ToolExecutionComponent extends Container { // Cached edit diff preview (computed when args arrive, before tool executes) private editDiffPreview?: EditDiffResult | EditDiffError; private editDiffArgsKey?: string; // Track which args the preview is for + // Spinner animation for partial task results + private spinnerFrame = 0; + private spinnerInterval: ReturnType | null = null; constructor( toolName: string, @@ -427,9 +443,37 @@ export class ToolExecutionComponent extends Container { ): void { this.result = result; this.isPartial = isPartial; + this.updateSpinnerAnimation(); this.updateDisplay(); } + /** + * Start or stop spinner animation based on whether this is a partial task result. + */ + private updateSpinnerAnimation(): void { + const needsSpinner = this.isPartial && this.toolName === "task"; + if (needsSpinner && !this.spinnerInterval) { + this.spinnerInterval = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % 10; + this.updateDisplay(); + this.ui.requestRender(); + }, 80); + } else if (!needsSpinner && this.spinnerInterval) { + clearInterval(this.spinnerInterval); + this.spinnerInterval = null; + } + } + + /** + * Stop spinner animation and cleanup resources. + */ + stopAnimation(): void { + if (this.spinnerInterval) { + clearInterval(this.spinnerInterval); + this.spinnerInterval = null; + } + } + setExpanded(expanded: boolean): void { this.expanded = expanded; this.updateDisplay(); @@ -475,7 +519,7 @@ export class ToolExecutionComponent extends Container { try { const resultComponent = this.customTool.renderResult( { content: this.result.content as any, details: this.result.details }, - { expanded: this.expanded, isPartial: this.isPartial }, + { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame }, theme, ); if (resultComponent) { @@ -522,7 +566,7 @@ export class ToolExecutionComponent extends Container { try { const resultComponent = renderer.renderResult( { content: this.result.content as any, details: this.result.details }, - { expanded: this.expanded, isPartial: this.isPartial }, + { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame }, theme, ); if (resultComponent) { @@ -688,9 +732,11 @@ export class ToolExecutionComponent extends Container { let text = ""; if (this.toolName === "read") { - const path = shortenPath(this.args?.file_path || this.args?.path || ""); + const rawPath = this.args?.file_path || this.args?.path || ""; + const path = shortenPath(rawPath); const offset = this.args?.offset; const limit = this.args?.limit; + const fileType = getFileType(rawPath); let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", theme.format.ellipsis); if (offset !== undefined || limit !== undefined) { @@ -703,37 +749,50 @@ export class ToolExecutionComponent extends Container { if (this.result) { const output = this.getTextOutput(); - const rawPath = this.args?.file_path || this.args?.path || ""; - const lang = getLanguageFromPath(rawPath); - const languageLabel = lang ?? "text"; - const lineCount = countLines(output); - const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n"); - // Metadata line with line count and language - text += `\n${theme.fg("dim", wrapBrackets(`${lineCount} lines, ${languageLabel}`))}`; - - // Content is hidden by default, only shown when expanded - if (this.expanded) { - text += - "\n\n" + - lines.map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n"); + if (fileType === "image") { + // Image file - use image icon + const ext = rawPath.split(".").pop()?.toLowerCase() ?? "image"; + text += `${theme.sep.dot}${theme.fg("dim", theme.getLangIcon(ext))}`; + // Images are rendered by the image component, just show hint + text += `\n${theme.fg("muted", "Image rendered below")}`; + } else if (fileType === "binary") { + // Binary file - use binary/pdf/archive icon based on extension + const ext = rawPath.split(".").pop()?.toLowerCase() ?? "binary"; + text += `${theme.sep.dot}${theme.fg("dim", theme.getLangIcon(ext))}`; } else { - // Show expand hint - text += `\n${theme.fg("dim", `${theme.nav.expand} Ctrl+O to show content`)}`; - } + // Text file - show line count and language on same line + const lang = getLanguageFromPath(rawPath); + const lineCount = countLines(output); + const lines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n"); + const langIcon = theme.getLangIcon(lang); - // Truncation warning (if content was truncated during reading) - const truncation = this.result.details?.truncation; - if (truncation?.truncated) { - let warning: string; - if (truncation.firstLineExceedsLimit) { - warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`; - } else if (truncation.truncatedBy === "lines") { - warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`; + text += `${theme.sep.dot}${theme.fg("dim", `${langIcon} ${lineCount} lines`)}`; + + // Content is hidden by default, only shown when expanded + if (this.expanded) { + text += + "\n\n" + + lines + .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) + .join("\n"); } else { - warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`; + text += `\n${theme.fg("dim", `${theme.nav.expand} Ctrl+O to show content`)}`; + } + + // Truncation warning + const truncation = this.result.details?.truncation; + if (truncation?.truncated) { + let warning: string; + if (truncation.firstLineExceedsLimit) { + warning = `First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`; + } else if (truncation.truncatedBy === "lines") { + warning = `Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`; + } else { + warning = `Truncated: ${truncation.outputLines} lines (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`; + } + text += `\n${theme.fg("warning", wrapBrackets(warning))}`; } - text += `\n${theme.fg("warning", wrapBrackets(warning))}`; } } } else if (this.toolName === "write") { diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 8b7a0e7f8..1c9bfd2be 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -126,7 +126,46 @@ export type SymbolKey = // Markdown-specific | "md.quoteBorder" | "md.hrChar" - | "md.bullet"; + | "md.bullet" + // Spinner frames (array stored as comma-separated string) + | "spinner.frames" + // Language/file type icons + | "lang.default" + | "lang.typescript" + | "lang.javascript" + | "lang.python" + | "lang.rust" + | "lang.go" + | "lang.java" + | "lang.c" + | "lang.cpp" + | "lang.csharp" + | "lang.ruby" + | "lang.php" + | "lang.swift" + | "lang.kotlin" + | "lang.shell" + | "lang.html" + | "lang.css" + | "lang.json" + | "lang.yaml" + | "lang.markdown" + | "lang.sql" + | "lang.docker" + | "lang.lua" + | "lang.text" + | "lang.env" + | "lang.toml" + | "lang.xml" + | "lang.ini" + | "lang.conf" + | "lang.log" + | "lang.csv" + | "lang.tsv" + | "lang.image" + | "lang.pdf" + | "lang.archive" + | "lang.binary"; type SymbolMap = Record; @@ -330,6 +369,45 @@ const UNICODE_SYMBOLS: SymbolMap = { "md.hrChar": "─", // pick: • | alt: · ▪ ◦ "md.bullet": "•", + // Spinner frames (pulsing dot) + "spinner.frames": "·,•,●,•", + // Language icons (unicode uses code symbol prefix) + "lang.default": "❖", + "lang.typescript": "❖ ts", + "lang.javascript": "❖ js", + "lang.python": "❖ py", + "lang.rust": "❖ rs", + "lang.go": "❖ go", + "lang.java": "❖ java", + "lang.c": "❖ c", + "lang.cpp": "❖ c++", + "lang.csharp": "❖ c#", + "lang.ruby": "❖ rb", + "lang.php": "❖ php", + "lang.swift": "❖ swift", + "lang.kotlin": "❖ kt", + "lang.shell": "❖ sh", + "lang.html": "❖ html", + "lang.css": "❖ css", + "lang.json": "❖ json", + "lang.yaml": "❖ yaml", + "lang.markdown": "❖ md", + "lang.sql": "❖ sql", + "lang.docker": "❖ docker", + "lang.lua": "❖ lua", + "lang.text": "❖ txt", + "lang.env": "❖ env", + "lang.toml": "❖ toml", + "lang.xml": "❖ xml", + "lang.ini": "❖ ini", + "lang.conf": "❖ conf", + "lang.log": "❖ log", + "lang.csv": "❖ csv", + "lang.tsv": "❖ tsv", + "lang.image": "❖ img", + "lang.pdf": "❖ pdf", + "lang.archive": "❖ zip", + "lang.binary": "❖ bin", }; const NERD_SYMBOLS: SymbolMap = { @@ -532,6 +610,45 @@ const NERD_SYMBOLS: SymbolMap = { "md.hrChar": "\u2500", // pick:  | alt:  • "md.bullet": "\uf111", + // Spinner frames (nerd font circles) + "spinner.frames": "󰪥,󰪤,󰪣,󰪢,󰪡,󰪠,󰪟,󰪞,󰪝,󰪜,󰪛,󰪥", + // Language icons (nerd font devicons) + "lang.default": "", + "lang.typescript": "", + "lang.javascript": "", + "lang.python": "", + "lang.rust": "", + "lang.go": "", + "lang.java": "", + "lang.c": "", + "lang.cpp": "", + "lang.csharp": "", + "lang.ruby": "", + "lang.php": "", + "lang.swift": "", + "lang.kotlin": "", + "lang.shell": "", + "lang.html": "", + "lang.css": "", + "lang.json": "", + "lang.yaml": "", + "lang.markdown": "", + "lang.sql": "", + "lang.docker": "", + "lang.lua": "", + "lang.text": "", + "lang.env": "", + "lang.toml": "", + "lang.xml": "󰗀", + "lang.ini": "", + "lang.conf": "", + "lang.log": "󰌱", + "lang.csv": "󰈛", + "lang.tsv": "󰈛", + "lang.image": "󰈟", + "lang.pdf": "󰈦", + "lang.archive": "", + "lang.binary": "󰆚", }; const ASCII_SYMBOLS: SymbolMap = { @@ -640,6 +757,45 @@ const ASCII_SYMBOLS: SymbolMap = { "md.quoteBorder": "|", "md.hrChar": "-", "md.bullet": "*", + // Spinner frames (ASCII) + "spinner.frames": "|,/,-,\\", + // Language icons (ASCII uses abbreviations) + "lang.default": "code", + "lang.typescript": "ts", + "lang.javascript": "js", + "lang.python": "py", + "lang.rust": "rs", + "lang.go": "go", + "lang.java": "java", + "lang.c": "c", + "lang.cpp": "cpp", + "lang.csharp": "cs", + "lang.ruby": "rb", + "lang.php": "php", + "lang.swift": "swift", + "lang.kotlin": "kt", + "lang.shell": "sh", + "lang.html": "html", + "lang.css": "css", + "lang.json": "json", + "lang.yaml": "yaml", + "lang.markdown": "md", + "lang.sql": "sql", + "lang.docker": "docker", + "lang.lua": "lua", + "lang.text": "txt", + "lang.env": "env", + "lang.toml": "toml", + "lang.xml": "xml", + "lang.ini": "ini", + "lang.conf": "conf", + "lang.log": "log", + "lang.csv": "csv", + "lang.tsv": "tsv", + "lang.image": "img", + "lang.pdf": "pdf", + "lang.archive": "zip", + "lang.binary": "bin", }; const SYMBOL_PRESETS: Record = { @@ -1008,6 +1164,95 @@ function resolveThemeColors>( // Theme Class // ============================================================================ +const langMap: Record = { + typescript: "lang.typescript", + ts: "lang.typescript", + tsx: "lang.typescript", + javascript: "lang.javascript", + js: "lang.javascript", + jsx: "lang.javascript", + mjs: "lang.javascript", + cjs: "lang.javascript", + python: "lang.python", + py: "lang.python", + rust: "lang.rust", + rs: "lang.rust", + go: "lang.go", + java: "lang.java", + c: "lang.c", + cpp: "lang.cpp", + "c++": "lang.cpp", + cc: "lang.cpp", + cxx: "lang.cpp", + csharp: "lang.csharp", + cs: "lang.csharp", + ruby: "lang.ruby", + rb: "lang.ruby", + php: "lang.php", + swift: "lang.swift", + kotlin: "lang.kotlin", + kt: "lang.kotlin", + bash: "lang.shell", + sh: "lang.shell", + zsh: "lang.shell", + fish: "lang.shell", + shell: "lang.shell", + html: "lang.html", + htm: "lang.html", + css: "lang.css", + scss: "lang.css", + sass: "lang.css", + less: "lang.css", + json: "lang.json", + yaml: "lang.yaml", + yml: "lang.yaml", + markdown: "lang.markdown", + md: "lang.markdown", + sql: "lang.sql", + dockerfile: "lang.docker", + docker: "lang.docker", + lua: "lang.lua", + text: "lang.text", + txt: "lang.text", + plain: "lang.text", + log: "lang.log", + env: "lang.env", + dotenv: "lang.env", + toml: "lang.toml", + xml: "lang.xml", + ini: "lang.ini", + conf: "lang.conf", + cfg: "lang.conf", + config: "lang.conf", + properties: "lang.conf", + csv: "lang.csv", + tsv: "lang.tsv", + image: "lang.image", + img: "lang.image", + png: "lang.image", + jpg: "lang.image", + jpeg: "lang.image", + gif: "lang.image", + webp: "lang.image", + svg: "lang.image", + ico: "lang.image", + bmp: "lang.image", + tiff: "lang.image", + pdf: "lang.pdf", + zip: "lang.archive", + tar: "lang.archive", + gz: "lang.archive", + tgz: "lang.archive", + bz2: "lang.archive", + xz: "lang.archive", + "7z": "lang.archive", + exe: "lang.binary", + dll: "lang.binary", + so: "lang.binary", + dylib: "lang.binary", + wasm: "lang.binary", + bin: "lang.binary", +}; export class Theme { private fgColors: Map; private bgColors: Map; @@ -1289,6 +1534,24 @@ export class Theme { bullet: this.symbols["md.bullet"], }; } + + /** + * Get spinner frames as an array (stored as comma-separated string in symbols). + */ + get spinnerFrames(): string[] { + return this.symbols["spinner.frames"].split(","); + } + + /** + * Get language icon for a language name. + * Maps common language names to their corresponding symbol keys. + */ + getLangIcon(lang: string | undefined): string { + if (!lang) return this.symbols["lang.default"]; + const normalized = lang.toLowerCase(); + const key = langMap[normalized]; + return key ? this.symbols[key] : this.symbols["lang.default"]; + } } // ============================================================================ @@ -1759,6 +2022,20 @@ export function highlightCode(code: string, lang?: string): string[] { * Get language identifier from file path extension. */ export function getLanguageFromPath(filePath: string): string | undefined { + const baseName = path.basename(filePath).toLowerCase(); + if (baseName === ".env" || baseName.startsWith(".env.")) return "env"; + if ( + baseName === ".gitignore" || + baseName === ".gitattributes" || + baseName === ".gitmodules" || + baseName === ".editorconfig" || + baseName === ".npmrc" || + baseName === ".prettierrc" || + baseName === ".eslintrc" + ) { + return "conf"; + } + const ext = filePath.split(".").pop()?.toLowerCase(); if (!ext) return undefined; @@ -1821,6 +2098,17 @@ export function getLanguageFromPath(filePath: string): string | undefined { proto: "protobuf", tf: "hcl", hcl: "hcl", + txt: "text", + text: "text", + log: "log", + csv: "csv", + tsv: "tsv", + ini: "ini", + cfg: "conf", + conf: "conf", + config: "conf", + properties: "conf", + env: "env", }; return extToLang[ext];