diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 4620e24648b6..0d28a7cba97e 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -235,6 +235,8 @@ export interface ShellToolConfig { label: string; shellName: string; prompt: string; + /** Highlight heredoc bodies and inline scripts, which requires POSIX shell syntax. */ + embeddedCode: boolean; promptSnippet: string; promptGuidelines?: readonly string[]; tempFilePrefix: string; @@ -410,7 +412,7 @@ export function createShellToolDefinition( clearUpdateTimer(); } }, - ...createShellRenderers(config.prompt), + ...createShellRenderers(config.prompt, { embeddedCode: config.embeddedCode }), }; } @@ -419,6 +421,7 @@ const bashToolConfig: ShellToolConfig = { label: "bash", shellName: "bash", prompt: "$", + embeddedCode: true, promptSnippet: bashToolSystemPromptContribution.snippet, promptGuidelines: bashToolSystemPromptContribution.guidelines, tempFilePrefix: "pi-bash", diff --git a/packages/coding-agent/src/core/tools/powershell.ts b/packages/coding-agent/src/core/tools/powershell.ts index ac8a491170ee..140025bdb025 100644 --- a/packages/coding-agent/src/core/tools/powershell.ts +++ b/packages/coding-agent/src/core/tools/powershell.ts @@ -41,6 +41,7 @@ const powershellToolConfig: ShellToolConfig = { label: "powershell", shellName: "PowerShell", prompt: "PS>", + embeddedCode: false, promptSnippet: powershellToolSystemPromptContribution.snippet, promptGuidelines: powershellToolSystemPromptContribution.guidelines, tempFilePrefix: "pi-powershell", diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index c0fca375d3bd..c0074f8525db 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -55,6 +55,66 @@ export function normalizeDisplayText(text: string): string { return text.replace(/\r/g, ""); } +/** Leading lines of a streaming highlight that are re-highlighted with full context on each update. */ +const STREAMING_FULL_HIGHLIGHT_PREFIX_LINES = 50; + +/** Highlighted display lines of streamed source text, for {@link updateStreamingHighlight}. */ +export interface StreamingHighlight { + source: string; + language: string | undefined; + theme: Theme; + /** Display lines of `source` after normalizeDisplayText and replaceTabs. */ + lines: string[]; + /** Styled version of each entry in `lines`. */ + highlighted: string[]; + /** False when some lines were highlighted one at a time, without multi-line context. */ + exact: boolean; +} + +/** + * Highlight source text that arrives in growing chunks, such as streamed tool arguments. + * + * Renderers run on every argument delta, so highlighting the full text each time costs O(n^2) while + * a long body streams in. When the new source extends the previous one, only the changed last line + * and new lines are highlighted, one line at a time. The first lines, which collapsed previews show, + * are re-highlighted with full context. Once `complete` is set, the text is highlighted in full once + * and then reused until the source, language, or theme changes. + */ +export function updateStreamingHighlight( + previous: StreamingHighlight | undefined, + source: string, + options: { + language: string | undefined; + theme: Theme; + complete: boolean; + highlight: (code: string) => string[]; + }, +): StreamingHighlight { + const { language, theme, complete, highlight } = options; + const reusable = previous !== undefined && previous.language === language && previous.theme === theme; + if (reusable && previous.source === source && (previous.exact || !complete)) return previous; + if (reusable && !complete && source.startsWith(previous.source)) { + const highlightLine = (line: string) => highlight(line)[0] ?? ""; + const { lines, highlighted } = previous; + const added = replaceTabs(normalizeDisplayText(source.slice(previous.source.length))).split("\n"); + const last = lines.length - 1; + lines[last] += added[0]; + highlighted[last] = highlightLine(lines[last]); + for (let i = 1; i < added.length; i++) { + lines.push(added[i]); + highlighted.push(highlightLine(added[i])); + } + const prefixCount = Math.min(STREAMING_FULL_HIGHLIGHT_PREFIX_LINES, lines.length); + const prefix = highlight(lines.slice(0, prefixCount).join("\n")); + for (let i = 0; i < prefixCount; i++) highlighted[i] = prefix[i] ?? highlighted[i]; + previous.source = source; + previous.exact = prefixCount === lines.length; + return previous; + } + const lines = replaceTabs(normalizeDisplayText(source)).split("\n"); + return { source, language, theme, lines, highlighted: highlight(lines.join("\n")), exact: true }; +} + export function getTextOutput( result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> } | undefined, showImages: boolean, diff --git a/packages/coding-agent/src/core/tools/renderers/bash.ts b/packages/coding-agent/src/core/tools/renderers/bash.ts index 4631b6b2cb3c..d11f9b054e17 100644 --- a/packages/coding-agent/src/core/tools/renderers/bash.ts +++ b/packages/coding-agent/src/core/tools/renderers/bash.ts @@ -9,20 +9,127 @@ import { Container, Spacer, Text } from "@earendil-works/pi-tui"; import { keyHint } from "../../../modes/interactive/components/keybinding-hints.ts"; import { VisualLinePreview } from "../../../modes/interactive/components/visual-truncate.ts"; -import { theme } from "../../../modes/interactive/theme/theme.ts"; +import { + getLanguageFromPath, + getThemeInstance, + highlightCode, + type Theme, + theme, +} from "../../../modes/interactive/theme/theme.ts"; +import { splitShellCommand } from "../../../utils/shell-embedded-code.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts"; import type { BashToolDetails } from "../bash.ts"; -import { getTextOutput, invalidArgText, startToolTimer, str, toolTimerText } from "../render-utils.ts"; +import { + getTextOutput, + invalidArgText, + type StreamingHighlight, + startToolTimer, + str, + toolTimerText, + updateStreamingHighlight, +} from "../render-utils.ts"; import { DEFAULT_MAX_BYTES, formatSize } from "../truncate.ts"; const BASH_PREVIEW_LINES = 5; +/** Lines of each heredoc body or inline script shown before the call is expanded. */ +const EMBEDDED_CODE_PREVIEW_LINES = 10; export const BASH_UPDATE_THROTTLE_MS = 100; -function formatShellCall(args: { command?: string; timeout?: number } | undefined, prompt: string): string { +function styleShellLines(text: string): string[] { + return text.split("\n").map((line) => (line ? theme.fg("toolTitle", theme.bold(line)) : "")); +} +/** Style normalized embedded code. Data with no known language keeps the plain output color. */ +function styleEmbeddedLines(code: string, language: string | undefined): string[] { + if (!language) return code.split("\n").map((line) => theme.fg("toolOutput", line)); + return highlightCode(code, language); +} + +/** + * Lines to show for one embedded segment. The first line continues the preceding shell line and the + * last line continues into the following shell text, so collapsed previews keep both and elide the + * middle. + */ +function formatEmbeddedCode(lines: string[], expanded: boolean): string[] { + const hidden = lines.length - 1 - EMBEDDED_CODE_PREVIEW_LINES; + if (expanded || hidden <= 0) return lines; + const hint = `${theme.fg("muted", `... (${hidden} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + return [...lines.slice(0, EMBEDDED_CODE_PREVIEW_LINES), hint, lines[lines.length - 1]]; +} + +class ShellCallRenderComponent extends Text { + theme: Theme | undefined; + command: string | undefined; + expanded = false; + timeout: number | undefined; + /** Formatted call text for the fields above. */ + rendered: string | undefined; + /** Highlights of the embedded segments of `command`, in order. */ + embedded: StreamingHighlight[] = []; + + constructor() { + super("", 0, 0); + } +} + +function formatShellCall( + component: ShellCallRenderComponent, + args: { command?: string; timeout?: number } | undefined, + prompt: string, + options: { expanded: boolean; embeddedCode: boolean; argsComplete: boolean }, +): string { const command = str(args?.command); const timeout = args?.timeout as number | undefined; const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; - const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); - return theme.fg("toolTitle", theme.bold(`${prompt} ${commandDisplay}`)) + timeoutSuffix; + if (!command || !options.embeddedCode) { + component.rendered = undefined; + const commandDisplay = + command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); + return theme.fg("toolTitle", theme.bold(`${prompt} ${commandDisplay}`)) + timeoutSuffix; + } + + const currentTheme = getThemeInstance(); + // renderCall also runs for every throttled output update with unchanged arguments. Reuse the text + // unless the theme changed or the final arguments still need a full highlight. + if ( + component.rendered !== undefined && + component.theme === currentTheme && + component.command === command && + component.expanded === options.expanded && + component.timeout === timeout && + (!options.argsComplete || component.embedded.every((entry) => entry.exact)) + ) { + return component.rendered; + } + + const lines = [""]; + const append = (styled: string[]) => { + lines[lines.length - 1] += styled[0] ?? ""; + for (let i = 1; i < styled.length; i++) lines.push(styled[i]); + }; + append(styleShellLines(`${prompt} `)); + const embedded: StreamingHighlight[] = []; + for (const segment of splitShellCommand(command, { languageFromPath: getLanguageFromPath })) { + if (!segment.embedded) { + append(styleShellLines(segment.text)); + continue; + } + const language = segment.language; + const highlight = updateStreamingHighlight(component.embedded[embedded.length], segment.text, { + language, + theme: currentTheme, + complete: options.argsComplete, + highlight: (code) => styleEmbeddedLines(code, language), + }); + embedded.push(highlight); + append(formatEmbeddedCode(highlight.highlighted, options.expanded)); + } + + component.theme = currentTheme; + component.command = command; + component.expanded = options.expanded; + component.timeout = timeout; + component.embedded = embedded; + component.rendered = lines.join("\n") + timeoutSuffix; + return component.rendered; } function rebuildBashResultRenderComponent( component: Container, @@ -89,14 +196,28 @@ function rebuildBashResultRenderComponent( if (timerText) component.addChild(new Text(`\n${theme.fg("muted", timerText)}`, 0, 0)); } -/** Shell renderers are shared by bash and powershell, which differ only in the prompt they display. */ -export function createShellRenderers(prompt: string): Pick, "renderCall" | "renderResult"> { +/** + * Shell renderers are shared by bash and powershell. `embeddedCode` highlights heredoc bodies and + * inline scripts, which is only implemented for POSIX shell syntax. + */ +export function createShellRenderers( + prompt: string, + options: { embeddedCode: boolean }, +): Pick, "renderCall" | "renderResult"> { return { renderCall(args, _theme, context) { startToolTimer(context); - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatShellCall(args as { command?: string; timeout?: number } | undefined, prompt)); - return text; + const component = + (context.lastComponent as ShellCallRenderComponent | undefined) ?? new ShellCallRenderComponent(); + const previous = component.rendered; + const text = formatShellCall(component, args as { command?: string; timeout?: number } | undefined, prompt, { + expanded: context.expanded, + embeddedCode: options.embeddedCode, + argsComplete: context.argsComplete, + }); + // setText drops the wrapped-line cache, so skip it when output updates leave the call unchanged. + if (text !== previous) component.setText(text); + return component; }, renderResult(result, options, _theme, context) { const timerText = toolTimerText(context, options.isPartial); diff --git a/packages/coding-agent/src/core/tools/renderers/index.ts b/packages/coding-agent/src/core/tools/renderers/index.ts index bd15d01de698..d6794b8f79d5 100644 --- a/packages/coding-agent/src/core/tools/renderers/index.ts +++ b/packages/coding-agent/src/core/tools/renderers/index.ts @@ -32,8 +32,8 @@ export { export function createAllToolRenderers(): Record { return { read: readRenderers, - bash: createShellRenderers("$"), - powershell: createShellRenderers("PS>"), + bash: createShellRenderers("$", { embeddedCode: true }), + powershell: createShellRenderers("PS>", { embeddedCode: false }), edit: editRenderers, write: writeRenderers, grep: grepRenderers, diff --git a/packages/coding-agent/src/core/tools/renderers/write.ts b/packages/coding-agent/src/core/tools/renderers/write.ts index 7c8f32636ce3..b23b853b14f2 100644 --- a/packages/coding-agent/src/core/tools/renderers/write.ts +++ b/packages/coding-agent/src/core/tools/renderers/write.ts @@ -8,84 +8,29 @@ import { Container, Text } from "@earendil-works/pi-tui"; import { keyHint } from "../../../modes/interactive/components/keybinding-hints.ts"; -import { getLanguageFromPath, highlightCode, type Theme } from "../../../modes/interactive/theme/theme.ts"; +import { + getLanguageFromPath, + getThemeInstance, + highlightCode, + type Theme, +} from "../../../modes/interactive/theme/theme.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts"; -import { normalizeDisplayText, renderToolPath, replaceTabs, str } from "../render-utils.ts"; +import { + normalizeDisplayText, + renderToolPath, + replaceTabs, + type StreamingHighlight, + str, + updateStreamingHighlight, +} from "../render-utils.ts"; -type WriteHighlightCache = { - rawPath: string | null; - lang: string; - rawContent: string; - normalizedLines: string[]; - highlightedLines: string[]; -}; class WriteCallRenderComponent extends Text { - cache?: WriteHighlightCache; + cache?: StreamingHighlight; constructor() { super("", 0, 0); } } -const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50; -function highlightSingleLine(line: string, lang: string): string { - const highlighted = highlightCode(line, lang); - return highlighted[0] ?? ""; -} -function refreshWriteHighlightPrefix(cache: WriteHighlightCache): void { - const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length); - if (prefixCount === 0) return; - const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n"); - const prefixHighlighted = highlightCode(prefixSource, cache.lang); - for (let i = 0; i < prefixCount; i++) { - cache.highlightedLines[i] = - prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang); - } -} -function rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): WriteHighlightCache | undefined { - const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; - if (!lang) return undefined; - const displayContent = normalizeDisplayText(fileContent); - const normalized = replaceTabs(displayContent); - return { - rawPath, - lang, - rawContent: fileContent, - normalizedLines: normalized.split("\n"), - highlightedLines: highlightCode(normalized, lang), - }; -} -function updateWriteHighlightCacheIncremental( - cache: WriteHighlightCache | undefined, - rawPath: string | null, - fileContent: string, -): WriteHighlightCache | undefined { - const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; - if (!lang) return undefined; - if (!cache) return rebuildWriteHighlightCacheFull(rawPath, fileContent); - if (cache.lang !== lang || cache.rawPath !== rawPath) return rebuildWriteHighlightCacheFull(rawPath, fileContent); - if (!fileContent.startsWith(cache.rawContent)) return rebuildWriteHighlightCacheFull(rawPath, fileContent); - if (fileContent.length === cache.rawContent.length) return cache; - - const deltaRaw = fileContent.slice(cache.rawContent.length); - const deltaDisplay = normalizeDisplayText(deltaRaw); - const deltaNormalized = replaceTabs(deltaDisplay); - cache.rawContent = fileContent; - if (cache.normalizedLines.length === 0) { - cache.normalizedLines.push(""); - cache.highlightedLines.push(""); - } - - const segments = deltaNormalized.split("\n"); - const lastIndex = cache.normalizedLines.length - 1; - cache.normalizedLines[lastIndex] += segments[0]; - cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang); - for (let i = 1; i < segments.length; i++) { - cache.normalizedLines.push(segments[i]); - cache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang)); - } - refreshWriteHighlightPrefix(cache); - return cache; -} function trimTrailingEmptyLines(lines: string[]): string[] { let end = lines.length; while (end > 0 && lines[end - 1] === "") { @@ -97,7 +42,7 @@ function formatWriteCall( args: { path?: string; file_path?: string; content?: string } | undefined, options: ToolRenderResultOptions, theme: Theme, - cache: WriteHighlightCache | undefined, + cache: StreamingHighlight | undefined, cwd: string, ): string { const rawPath = str(args?.file_path ?? args?.path); @@ -110,7 +55,7 @@ function formatWriteCall( } else if (fileContent) { const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; const renderedLines = lang - ? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang)) + ? (cache?.highlighted ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang)) : normalizeDisplayText(fileContent).split("\n"); const lines = trimTrailingEmptyLines(renderedLines); const totalLines = lines.length; @@ -149,13 +94,16 @@ export const writeRenderers: Pick, "renderCall" | "rend const fileContent = str(renderArgs?.content); const component = (context.lastComponent as WriteCallRenderComponent | undefined) ?? new WriteCallRenderComponent(); - if (fileContent !== null) { - component.cache = context.argsComplete - ? rebuildWriteHighlightCacheFull(rawPath, fileContent) - : updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent); - } else { - component.cache = undefined; - } + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + component.cache = + fileContent !== null && lang + ? updateStreamingHighlight(component.cache, fileContent, { + language: lang, + theme: getThemeInstance(), + complete: context.argsComplete, + highlight: (code) => highlightCode(code, lang), + }) + : undefined; component.setText( formatWriteCall( renderArgs, diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 876ba9e8638e..cd45bec5c3ba 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -658,6 +658,16 @@ export const theme: Theme = new Proxy({} as Theme, { }, }); +/** + * The active theme instance. Switching or reloading a theme installs a new instance, so renderers can + * compare identities to invalidate cached styled output. + */ +export function getThemeInstance(): Theme { + const t = (globalThis as Record)[THEME_KEY]; + if (!t) throw new Error("Theme not initialized. Call initTheme() first."); + return t; +} + function setGlobalTheme(t: Theme): void { (globalThis as Record)[THEME_KEY] = t; (globalThis as Record)[THEME_KEY_OLD] = t; diff --git a/packages/coding-agent/src/utils/shell-embedded-code.ts b/packages/coding-agent/src/utils/shell-embedded-code.ts new file mode 100644 index 000000000000..912ac83e6988 --- /dev/null +++ b/packages/coding-agent/src/utils/shell-embedded-code.ts @@ -0,0 +1,631 @@ +/** + * Split a POSIX shell command into shell text and embedded code for display. + * + * Models often run scripts through the shell tool instead of writing files first: + * + * cd repo && python3 - <<'EOF' + * print("hi") + * EOF + * node -e 'console.log(1)' + * + * Highlighting the whole command as shell renders the heredoc body and inline scripts as shell, + * where a lone quote in the embedded code colors the rest of the command. This module finds + * heredoc bodies and interpreter inline scripts (`-c`, `-e`, ...) and infers their language from + * the command that consumes them, so each part can be highlighted on its own. + * + * The scanner is approximate and only used for display. It tracks quotes, command boundaries, + * redirects, and `$(...)` nesting well enough for the commands models write, and never throws. + */ + +export interface ShellSegment { + /** Exact source text. Concatenating all segment texts yields the original command. */ + text: string; + /** False for shell syntax, true for a heredoc body or inline script. */ + embedded: boolean; + /** Highlight language for embedded code. Undefined for plain data such as a commit message. */ + language?: string; +} + +export interface SplitShellCommandOptions { + /** Resolve a highlight language from a file path, used for `cat > file <<'EOF'`. */ + languageFromPath?: (path: string) => string | undefined; +} + +interface ShellCommand { + words: string[]; + /** Word being built, or undefined between words. */ + word: string | undefined; + /** Set while the next word is a redirect target. */ + pendingRedirect: { fd: string; dup: boolean } | undefined; + /** Target of the last stdout redirect (`> file`, `>> file`, `&> file`). */ + stdoutTarget: string | undefined; +} + +interface ShellFrame { + kind: "shell"; + command: ShellCommand; + /** Open parentheses inside this `$(...)` frame. */ + parenDepth: number; + /** True for `$(...)`, `<(...)`, and `>(...)`, which end at the matching `)`. */ + substitution: boolean; +} + +interface DoubleQuoteFrame { + kind: "double"; +} + +type Frame = ShellFrame | DoubleQuoteFrame; + +interface PendingHeredoc { + delimiter: string; + stripTabs: boolean; + command: ShellCommand; +} + +interface InterpreterSpec { + language: string; + /** Flag that introduces an inline script, e.g. `-c` or `-e`. */ + inlineFlag?: RegExp; + /** The first positional argument is the script, as in `awk '{ print $1 }'`. */ + inlineFirstPositional?: boolean; + /** Options whose value is the next word, e.g. `-r json` for ruby. */ + valueOptions?: ReadonlySet; + /** Stdin is code even when positional arguments are present, e.g. `psql mydb <<'SQL'`. */ + stdinAlwaysCode?: boolean; +} + +const SHELL_INLINE_FLAG = /^-[A-Za-z]*c$/; +const NODE_INLINE_FLAG = /^(?:-[A-Za-z]*[ep]|--eval|--print)$/; + +const PYTHON_SPEC: InterpreterSpec = { + language: "python", + inlineFlag: /^-[A-Za-z]*c$/, + valueOptions: new Set(["-W", "-X", "-m"]), +}; +const NODE_SPEC: InterpreterSpec = { + language: "javascript", + inlineFlag: NODE_INLINE_FLAG, + valueOptions: new Set(["-r", "--require", "--import", "--loader", "--input-type", "--conditions", "-C"]), +}; +const TYPESCRIPT_SPEC: InterpreterSpec = { + language: "typescript", + inlineFlag: NODE_INLINE_FLAG, + valueOptions: NODE_SPEC.valueOptions, +}; +const SHELL_SPEC: InterpreterSpec = { + language: "bash", + inlineFlag: SHELL_INLINE_FLAG, + valueOptions: new Set(["-o", "+o", "-O", "+O"]), +}; +const AWK_SPEC: InterpreterSpec = { + language: "awk", + inlineFirstPositional: true, + valueOptions: new Set(["-F", "-v", "-f"]), +}; + +const INTERPRETERS: Record = { + python: PYTHON_SPEC, + pypy: PYTHON_SPEC, + node: NODE_SPEC, + bun: NODE_SPEC, + tsx: TYPESCRIPT_SPEC, + "ts-node": TYPESCRIPT_SPEC, + ruby: { language: "ruby", inlineFlag: /^-[A-Za-z]*e$/, valueOptions: new Set(["-r", "-I"]) }, + perl: { language: "perl", inlineFlag: /^-[A-Za-z0-9]*[eE]$/, valueOptions: new Set(["-I", "-M", "-m"]) }, + php: { language: "php", inlineFlag: /^-r$/ }, + lua: { language: "lua", inlineFlag: /^-e$/ }, + swift: { language: "swift", inlineFlag: /^-e$/ }, + osascript: { language: "applescript", inlineFlag: /^-e$/, valueOptions: new Set(["-l"]) }, + sh: SHELL_SPEC, + bash: SHELL_SPEC, + zsh: SHELL_SPEC, + dash: SHELL_SPEC, + ksh: SHELL_SPEC, + awk: AWK_SPEC, + gawk: AWK_SPEC, + mawk: AWK_SPEC, + psql: { + language: "sql", + inlineFlag: /^(?:-c|--command)$/, + valueOptions: new Set(["-d", "-h", "-p", "-U", "-f"]), + stdinAlwaysCode: true, + }, + mysql: { + language: "sql", + inlineFlag: /^(?:-e|--execute)$/, + valueOptions: new Set(["-u", "-h", "-P", "-D"]), + stdinAlwaysCode: true, + }, + duckdb: { language: "sql", inlineFlag: /^-c$/, stdinAlwaysCode: true }, + sqlite3: { language: "sql", stdinAlwaysCode: true }, +}; + +/** Heredoc delimiter names that state the body language, e.g. `node tool.js <<'JS'`. */ +const DELIMITER_LANGUAGES: Record = { + PY: "python", + PYTHON: "python", + JS: "javascript", + JAVASCRIPT: "javascript", + NODE: "javascript", + MJS: "javascript", + TS: "typescript", + TYPESCRIPT: "typescript", + RB: "ruby", + RUBY: "ruby", + PL: "perl", + PERL: "perl", + PHP: "php", + LUA: "lua", + SWIFT: "swift", + SQL: "sql", + SH: "bash", + BASH: "bash", + ZSH: "bash", + SHELL: "bash", + JSON: "json", + YAML: "yaml", + YML: "yaml", + TOML: "ini", + HTML: "xml", + XML: "xml", + CSS: "css", + RS: "rust", + RUST: "rust", + GO: "go", + C: "c", + CPP: "cpp", + MD: "markdown", + MARKDOWN: "markdown", + DIFF: "diff", + PATCH: "diff", +}; + +/** Words that precede the real command without changing what consumes stdin. */ +const TRANSPARENT_WORDS = new Set([ + "sudo", + "time", + "nohup", + "exec", + "command", + "builtin", + "do", + "then", + "else", + "elif", + "if", + "while", + "until", + "!", + "{", +]); + +const UV_VALUE_OPTIONS = new Set([ + "--with", + "--with-editable", + "--with-requirements", + "--python", + "-p", + "--project", + "--directory", + "--extra", + "--group", + "--env-file", + "--package", + "--index", + "--from", +]); + +const ENV_VALUE_OPTIONS = new Set(["-u", "-C", "-S"]); +const TIMEOUT_VALUE_OPTIONS = new Set(["-s", "-k", "-n", "--signal", "--kill-after"]); + +const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; + +function newCommand(): ShellCommand { + return { words: [], word: undefined, pendingRedirect: undefined, stdoutTarget: undefined }; +} + +function skipOptions(words: string[], index: number, valueOptions?: ReadonlySet): number { + let i = index; + while (i < words.length && words[i].startsWith("-") && words[i] !== "-") { + if (words[i] === "--") return i + 1; + if (valueOptions?.has(words[i])) i++; + i++; + } + return i; +} + +/** Strip assignments and wrappers such as `sudo` or `uv run --with x`, returning the program index. */ +function findProgramIndex(words: string[]): number { + let i = 0; + while (i < words.length) { + const word = words[i]; + if (ASSIGNMENT.test(word) || TRANSPARENT_WORDS.has(word)) { + i++; + } else if (word === "env") { + i = skipOptions(words, i + 1, ENV_VALUE_OPTIONS); + while (i < words.length && ASSIGNMENT.test(words[i])) i++; + } else if (word === "timeout" || word === "nice") { + i = skipOptions(words, i + 1, TIMEOUT_VALUE_OPTIONS); + if (word === "timeout") i++; + } else if (word === "uv" && words[i + 1] === "run") { + i = skipOptions(words, i + 2, UV_VALUE_OPTIONS); + } else if (word === "uv" && words[i + 1] === "tool" && words[i + 2] === "run") { + i = skipOptions(words, i + 3, UV_VALUE_OPTIONS); + } else if (word === "npx" || word === "bunx" || (word === "pnpm" && words[i + 1] === "exec")) { + i = skipOptions(words, word === "pnpm" ? i + 2 : i + 1); + } else { + return i; + } + } + return i; +} + +function programName(word: string): string { + const base = word.slice(word.lastIndexOf("/") + 1).replace(/\.exe$/i, ""); + if (/^python\d*(?:\.\d+)?$/.test(base) || /^pypy\d*$/.test(base)) return "python"; + return base; +} + +/** `python -m pkg` hands all later arguments to the module, so `-c` is no longer Python's flag. */ +function runsPythonModule(spec: InterpreterSpec, args: string[]): boolean { + return spec === PYTHON_SPEC && args.some((arg) => arg.startsWith("-m")); +} + +function substituteVariables(value: string, variables: ReadonlyMap): string { + return value.replace( + /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, + (match, braced: string | undefined, bare: string | undefined) => variables.get(braced ?? bare ?? "") ?? match, + ); +} + +/** Language of a heredoc body, from the command that reads it or the delimiter name. */ +function heredocLanguage( + heredoc: PendingHeredoc, + variables: ReadonlyMap, + options: SplitShellCommandOptions, +): string | undefined { + const words = heredoc.command.words; + const programIndex = findProgramIndex(words); + const program = programIndex < words.length ? programName(words[programIndex]) : undefined; + const pathLanguage = (path: string | undefined) => + path ? options.languageFromPath?.(substituteVariables(path, variables)) : undefined; + + let language: string | undefined; + if (program === "cat") { + language = pathLanguage(heredoc.command.stdoutTarget); + } else if (program === "tee") { + const argsStart = skipOptions(words, programIndex + 1); + language = pathLanguage(words[argsStart]) ?? pathLanguage(heredoc.command.stdoutTarget); + } else if (program !== undefined) { + const spec = INTERPRETERS[program]; + if (spec) { + const positionalIndex = skipOptions(words, programIndex + 1, spec.valueOptions); + const positional = words[positionalIndex]; + const readsScriptFromStdin = positional === undefined || positional === "-"; + const hasInlineScript = spec.inlineFlag + ? words.slice(programIndex + 1).some((word) => spec.inlineFlag?.test(word)) + : false; + const runsModule = runsPythonModule(spec, words.slice(programIndex + 1)); + if (spec.stdinAlwaysCode || (readsScriptFromStdin && !hasInlineScript && !runsModule)) { + language = spec.language; + } + } + } + return language ?? DELIMITER_LANGUAGES[heredoc.delimiter.toUpperCase()]; +} + +/** + * Language of a quoted word that starts at the current position, if it is an interpreter's inline + * script such as the argument of `python3 -c` or `node -e`. Returns undefined for regular words. + */ +function inlineScriptLanguage(command: ShellCommand): string | undefined { + const words = command.words; + const programIndex = findProgramIndex(words); + if (programIndex >= words.length) return undefined; + const spec = INTERPRETERS[programName(words[programIndex])]; + if (!spec) return undefined; + const args = words.slice(programIndex + 1); + // Options after a positional argument belong to a script, as in `python3 tool.py -c x`. + if (skipOptions(args, 0, spec.valueOptions) !== args.length || runsPythonModule(spec, args)) return undefined; + const previous = args[args.length - 1]; + if (spec.inlineFlag && previous !== undefined && spec.inlineFlag.test(previous)) return spec.language; + if (spec.inlineFirstPositional) return spec.language; + return undefined; +} + +/** Split a shell command into shell text, heredoc bodies, and inline scripts. */ +export function splitShellCommand(command: string, options: SplitShellCommandOptions = {}): ShellSegment[] { + const segments: ShellSegment[] = []; + const variables = new Map(); + const frames: Frame[] = [{ kind: "shell", command: newCommand(), parenDepth: 0, substitution: false }]; + let pendingHeredocs: PendingHeredoc[] = []; + let segmentStart = 0; + let i = 0; + + const pushSegment = (end: number, embedded: boolean, language?: string) => { + if (end <= segmentStart) return; + const text = command.slice(segmentStart, end); + const previous = segments[segments.length - 1]; + if (!embedded && previous && !previous.embedded) { + previous.text += text; + } else { + segments.push(embedded ? { text, embedded, language } : { text, embedded }); + } + segmentStart = end; + }; + + const shellFrame = (): ShellFrame => { + for (let index = frames.length - 1; index >= 0; index--) { + const frame = frames[index]; + if (frame.kind === "shell") return frame; + } + return frames[0] as ShellFrame; + }; + + const appendToWord = (text: string) => { + const current = shellFrame().command; + current.word = (current.word ?? "") + text; + }; + + const finishWord = (current: ShellCommand) => { + if (current.word === undefined) return; + const word = current.word; + current.word = undefined; + if (current.pendingRedirect) { + const { fd, dup } = current.pendingRedirect; + current.pendingRedirect = undefined; + if (!dup && (fd === "1" || fd === "&")) current.stdoutTarget = word; + return; + } + current.words.push(word); + }; + + const endCommand = (frame: ShellFrame) => { + finishWord(frame.command); + const words = frame.command.words; + const assignments = words[0] === "export" ? words.slice(1) : words; + if (assignments.length > 0 && assignments.every((word) => ASSIGNMENT.test(word))) { + for (const word of assignments) { + const eq = word.indexOf("="); + variables.set(word.slice(0, eq), substituteVariables(word.slice(eq + 1), variables)); + } + } + frame.command = newCommand(); + }; + + /** Consume heredoc bodies that start at `bodyStart`, returning the index after the last delimiter line. */ + const consumeHeredocs = (bodyStart: number): number => { + let position = bodyStart; + for (const heredoc of pendingHeredocs) { + const language = heredocLanguage(heredoc, variables, options); + pushSegment(position, false); + let lineStart = position; + let delimiterStart = -1; + let delimiterEnd = command.length; + while (lineStart < command.length) { + const newline = command.indexOf("\n", lineStart); + const lineEnd = newline === -1 ? command.length : newline; + let line = command.slice(lineStart, lineEnd); + if (heredoc.stripTabs) line = line.replace(/^\t+/, ""); + // A delimiter on the last line of a streaming command may still be growing, but an + // exact match is also how a complete command ends, so both cases are treated as closed. + if (line === heredoc.delimiter) { + delimiterStart = lineStart; + delimiterEnd = newline === -1 ? command.length : newline + 1; + break; + } + if (newline === -1) break; + lineStart = newline + 1; + } + if (delimiterStart === -1) { + pushSegment(command.length, true, language); + pendingHeredocs = []; + return command.length; + } + pushSegment(delimiterStart, true, language); + position = delimiterEnd; + } + pendingHeredocs = []; + return position; + }; + + /** Emit an inline script inside quotes that open at `quoteIndex` and close at `closeIndex`. */ + const emitInlineScript = (quoteIndex: number, closeIndex: number, language: string) => { + pushSegment(quoteIndex + 1, false); + pushSegment(closeIndex, true, language); + }; + + const findDoubleQuoteEnd = (start: number): number => { + for (let index = start; index < command.length; index++) { + const char = command[index]; + if (char === "\\") index++; + else if (char === '"') return index; + } + return command.length; + }; + + while (i < command.length) { + const frame = frames[frames.length - 1]; + const char = command[i]; + const next = command[i + 1]; + + if (frame.kind === "double") { + if (char === "\\") { + appendToWord(next ?? ""); + i += 2; + } else if (char === '"') { + frames.pop(); + i++; + } else if (char === "$" && next === "(" && command[i + 2] !== "(") { + appendToWord("$()"); + frames.push({ kind: "shell", command: newCommand(), parenDepth: 0, substitution: true }); + i += 2; + } else { + appendToWord(char); + i++; + } + continue; + } + + const current = frame.command; + switch (char) { + case "\\": { + if (next !== "\n") appendToWord(next ?? ""); + i += 2; + break; + } + case "'": { + const close = command.indexOf("'", i + 1); + const end = close === -1 ? command.length : close; + const language = current.word === undefined ? inlineScriptLanguage(current) : undefined; + if (language) emitInlineScript(i, end, language); + appendToWord(command.slice(i + 1, end)); + i = end + 1; + break; + } + case '"': { + const language = current.word === undefined ? inlineScriptLanguage(current) : undefined; + if (language) { + const end = findDoubleQuoteEnd(i + 1); + emitInlineScript(i, end, language); + appendToWord(command.slice(i + 1, end)); + i = end + 1; + } else { + appendToWord(""); + frames.push({ kind: "double" }); + i++; + } + break; + } + case "$": { + if (next === "(" && command[i + 2] === "(") { + // Arithmetic expansion: `<<` inside is a shift, not a heredoc. + const close = command.indexOf("))", i + 3); + const end = close === -1 ? command.length : close + 2; + appendToWord(command.slice(i, end)); + i = end; + } else if (next === "(") { + appendToWord("$()"); + frames.push({ kind: "shell", command: newCommand(), parenDepth: 0, substitution: true }); + i += 2; + } else { + appendToWord(char); + i++; + } + break; + } + case "#": { + if (current.word !== undefined) { + appendToWord(char); + i++; + break; + } + const newline = command.indexOf("\n", i); + i = newline === -1 ? command.length : newline; + break; + } + case " ": + case "\t": { + finishWord(current); + i++; + break; + } + case "\n": { + endCommand(frame); + i = pendingHeredocs.length > 0 ? consumeHeredocs(i + 1) : i + 1; + break; + } + case ";": + case "|": { + endCommand(frame); + i++; + break; + } + case "&": { + if (next === ">") { + finishWord(current); + current.pendingRedirect = { fd: "&", dup: false }; + i += command[i + 2] === ">" ? 3 : 2; + } else { + endCommand(frame); + i++; + } + break; + } + case "(": { + endCommand(frame); + frame.parenDepth++; + i++; + break; + } + case ")": { + endCommand(frame); + if (frame.substitution && frame.parenDepth === 0) frames.pop(); + else frame.parenDepth = Math.max(0, frame.parenDepth - 1); + i++; + break; + } + case "<": { + if (next === "(") { + appendToWord("<()"); + frames.push({ kind: "shell", command: newCommand(), parenDepth: 0, substitution: true }); + i += 2; + break; + } + finishWord(current); + if (next !== "<") { + current.pendingRedirect = { fd: "0", dup: false }; + i++; + break; + } + if (command[i + 2] === "<") { + // Here-string: the next word is data, not an argument. + current.pendingRedirect = { fd: "0", dup: false }; + i += 3; + break; + } + const match = /^<<(-?)[ \t]*(?:'([^'\n]*)'|"([^"\n]*)"|\\?([^\s;&|<>()'"]+))/.exec(command.slice(i)); + if (!match) { + i += 2; + break; + } + const delimiter = match[2] ?? match[3] ?? match[4] ?? ""; + // While streaming, the operator line may end before the delimiter is complete. + const complete = i + match[0].length < command.length; + if (delimiter && complete) { + pendingHeredocs.push({ delimiter, stripTabs: match[1] === "-", command: current }); + } + i += match[0].length; + break; + } + case ">": { + let fd = "1"; + if (current.word !== undefined && /^\d+$/.test(current.word)) { + fd = current.word; + current.word = undefined; + } else { + finishWord(current); + } + if (next === "(") { + appendToWord(">()"); + frames.push({ kind: "shell", command: newCommand(), parenDepth: 0, substitution: true }); + i += 2; + break; + } + i++; + if (command[i] === ">" || command[i] === "|") i++; + const dup = command[i] === "&"; + if (dup) i++; + current.pendingRedirect = { fd, dup }; + break; + } + default: { + appendToWord(char); + i++; + } + } + } + + pushSegment(command.length, false); + return segments; +} diff --git a/packages/coding-agent/test/shell-embedded-code.test.ts b/packages/coding-agent/test/shell-embedded-code.test.ts new file mode 100644 index 000000000000..9d08c7e2156e --- /dev/null +++ b/packages/coding-agent/test/shell-embedded-code.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "vitest"; +import { type ShellSegment, splitShellCommand } from "../src/utils/shell-embedded-code.ts"; + +const EXTENSIONS: Record = { rs: "rust", ts: "typescript", py: "python", json: "json" }; + +function split(command: string): ShellSegment[] { + const segments = splitShellCommand(command, { + languageFromPath: (path) => EXTENSIONS[path.slice(path.lastIndexOf(".") + 1)], + }); + expect(segments.map((segment) => segment.text).join("")).toBe(command); + return segments; +} + +function embedded(command: string): Array<{ text: string; language: string | undefined }> { + return split(command) + .filter((segment) => segment.embedded) + .map((segment) => ({ text: segment.text, language: segment.language })); +} + +describe("splitShellCommand", () => { + test("leaves plain commands as a single shell segment", () => { + expect(split("cd repo && cargo test 2>&1 | grep -E 'a|b'")).toEqual([ + { text: "cd repo && cargo test 2>&1 | grep -E 'a|b'", embedded: false }, + ]); + }); + + test("splits a python heredoc and keeps following commands as shell", () => { + const command = "cd repo && python3 - <<'EOF'\ns = open('a').read()\nprint(\"it's\")\nEOF\ncargo test"; + expect(split(command)).toEqual([ + { text: "cd repo && python3 - <<'EOF'\n", embedded: false }, + { text: "s = open('a').read()\nprint(\"it's\")\n", embedded: true, language: "python" }, + { text: "EOF\ncargo test", embedded: false }, + ]); + }); + + test("infers interpreters behind wrappers and redirects", () => { + expect(embedded("uv run --no-project --with httpx --with bs4 python - <<'PY'\nx = 1\nPY")).toEqual([ + { text: "x = 1\n", language: "python" }, + ]); + expect(embedded("./python.exe - <<'PY' > /tmp/log 2>&1\nx = 1\nPY")).toEqual([ + { text: "x = 1\n", language: "python" }, + ]); + expect(embedded("node --input-type=module <<'JS'\nconsole.log(1)\nJS")).toEqual([ + { text: "console.log(1)\n", language: "javascript" }, + ]); + expect(embedded("for d in a b; do timeout 20 bash <<'EOF'\necho $d\nEOF\ndone")).toEqual([ + { text: "echo $d\n", language: "bash" }, + ]); + }); + + test("uses the file extension for cat and tee targets", () => { + expect(embedded("cat >> deser-json/tests/test_de.rs <<'EOF'\n#[test]\nfn x() {}\nEOF")).toEqual([ + { text: "#[test]\nfn x() {}\n", language: "rust" }, + ]); + expect(embedded("cat <<'EOF' > out.json\n{}\nEOF")).toEqual([{ text: "{}\n", language: "json" }]); + expect(embedded("tee -a notes.py <<'EOF'\nx = 1\nEOF")).toEqual([{ text: "x = 1\n", language: "python" }]); + expect(embedded("F=test/zz-tmp-$$.test.ts && cat > $F <<'EOF'\nconst x = 1;\nEOF")).toEqual([ + { text: "const x = 1;\n", language: "typescript" }, + ]); + }); + + test("falls back to the delimiter name when stdin is data for a script", () => { + expect(embedded("node tools/workspace.js exec --email a@b.c <<'JS' >out.json &\nreturn 1;\nJS")).toEqual([ + { text: "return 1;\n", language: "javascript" }, + ]); + expect(embedded("python3 script.py <<'EOF'\ndata\nEOF")).toEqual([{ text: "data\n", language: undefined }]); + expect(embedded("git commit -F - <<'EOF'\nfix: thing\nEOF")).toEqual([ + { text: "fix: thing\n", language: undefined }, + ]); + }); + + test("handles <<- and consecutive heredocs", () => { + expect(embedded("python3 - <<-PY\n\tx = 1\n\tPY\nnode <<'JS'\ny()\nJS\n")).toEqual([ + { text: "\tx = 1\n", language: "python" }, + { text: "y()\n", language: "javascript" }, + ]); + }); + + test("treats an unterminated heredoc as running to the end while streaming", () => { + expect(split("python3 - <<'EOF'\nprint(1)\nEO")).toEqual([ + { text: "python3 - <<'EOF'\n", embedded: false }, + { text: "print(1)\nEO", embedded: true, language: "python" }, + ]); + expect(split("python3 - <<'EO")).toEqual([{ text: "python3 - <<'EO", embedded: false }]); + }); + + test("ignores << inside quotes, arithmetic, and here-strings", () => { + expect(embedded("rg -n 'a < { + expect(split('cd x && python3 -c "\nimport sys\nprint(sys.argv)\n" 2>&1 | tail')).toEqual([ + { text: 'cd x && python3 -c "', embedded: false }, + { text: "\nimport sys\nprint(sys.argv)\n", embedded: true, language: "python" }, + { text: '" 2>&1 | tail', embedded: false }, + ]); + expect(embedded("node -e 'console.log(1)'")).toEqual([{ text: "console.log(1)", language: "javascript" }]); + expect(embedded("ruby -rjson -e 'puts 1'")).toEqual([{ text: "puts 1", language: "ruby" }]); + expect(embedded("perl -0pi -e 's/a/b/' file")).toEqual([{ text: "s/a/b/", language: "perl" }]); + expect(embedded("zsh -i -c 'echo hi'")).toEqual([{ text: "echo hi", language: "bash" }]); + expect(embedded("awk -F: '{ print $1 }' /etc/passwd")).toEqual([{ text: "{ print $1 }", language: "awk" }]); + expect(embedded("osascript -e 'tell app \"Finder\" to quit'")).toEqual([ + { text: 'tell app "Finder" to quit', language: "applescript" }, + ]); + }); + + test("does not treat flags of other tools as inline scripts", () => { + expect(embedded("grep -e 'foo' x && rg -n 'bar' && git commit -m \"feat: x\n\nbody\"")).toEqual([]); + expect(embedded("sed -e 's/a/b/' x && python3 script.py -c 'x'")).toEqual([]); + expect(embedded("python3 -m pytest -c 'conf.ini' && python -mpytest -c 'conf.ini'")).toEqual([]); + expect(embedded("python3 -m tool <<'EOF'\ndata\nEOF")).toEqual([{ text: "data\n", language: undefined }]); + }); + + test("finds heredocs inside command substitution", () => { + expect(embedded("git commit -m \"$(cat <<'EOF'\nfix: x\nEOF\n)\"")).toEqual([ + { text: "fix: x\n", language: undefined }, + ]); + }); +}); diff --git a/packages/coding-agent/test/streaming-highlight.test.ts b/packages/coding-agent/test/streaming-highlight.test.ts new file mode 100644 index 000000000000..55ed86a19775 --- /dev/null +++ b/packages/coding-agent/test/streaming-highlight.test.ts @@ -0,0 +1,109 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import type * as SyntaxHighlight from "../src/utils/syntax-highlight.ts"; + +const highlightedLines = vi.hoisted(() => ({ count: 0 })); + +vi.mock("../src/utils/syntax-highlight.ts", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + highlight: (code: string, options: Parameters[1]) => { + highlightedLines.count += code.split("\n").length; + return original.highlight(code, options); + }, + }; +}); + +import { createBashToolDefinition } from "../src/core/tools/bash.ts"; +import { createWriteToolDefinition } from "../src/core/tools/write.ts"; +import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +function createToolComponent( + name: string, + args: Record, + definition: ConstructorParameters[4], +): ToolExecutionComponent { + return new ToolExecutionComponent( + name, + `tool-${name}-cache`, + args, + {}, + definition, + { requestRender: () => {} } as unknown as TUI, + process.cwd(), + ); +} + +function createComponent(command: string): ToolExecutionComponent { + return createToolComponent( + "bash", + { command }, + createBashToolDefinition(process.cwd(), { exposeSessionEnvironment: false }), + ); +} + +describe("streaming call highlighting", () => { + beforeAll(() => { + initTheme("dark"); + }); + + test("highlights streamed heredocs incrementally and reuses the result for output updates", () => { + const header = "cat > src/lib.rs <<'EOF'\n"; + const bodyLines = Array.from({ length: 400 }, (_, i) => `fn f${i}() -> u32 { ${i} }`); + const command = `${header}${bodyLines.join("\n")}\nEOF`; + + const component = createComponent(header); + component.setExpanded(true); + highlightedLines.count = 0; + for (let i = 1; i <= bodyLines.length; i++) { + component.updateArgs({ command: `${header}${bodyLines.slice(0, i).join("\n")}\n` }); + } + component.updateArgs({ command }); + // Each update highlights its new line plus a bounded prefix; a full rehighlight per update + // would cost 400 * 401 / 2 = 80,200 lines. + expect(highlightedLines.count).toBeLessThan(bodyLines.length * 60); + + component.setArgsComplete(); + component.markExecutionStarted(); + const final = component.render(200); + const fresh = createComponent(command); + fresh.setExpanded(true); + fresh.setArgsComplete(); + fresh.markExecutionStarted(); + expect(final).toEqual(fresh.render(200)); + + highlightedLines.count = 0; + for (let i = 0; i < 10; i++) { + component.updateResult({ content: [{ type: "text", text: `line ${i}` }], isError: false }, true); + } + component.updateResult({ content: [{ type: "text", text: "done" }], isError: false }, false); + expect(highlightedLines.count).toBe(0); + }); + + test("highlights streamed write content incrementally and fully once complete", () => { + const definition = createWriteToolDefinition(process.cwd()); + const contentLines = Array.from({ length: 400 }, (_, i) => `const s${i} = \`multi`); + const content = `${contentLines.join("\n")}\n`; + + const component = createToolComponent("write", { path: "src/a.ts", content: "" }, definition); + component.setExpanded(true); + highlightedLines.count = 0; + for (let i = 1; i <= contentLines.length; i++) { + component.updateArgs({ path: "src/a.ts", content: `${contentLines.slice(0, i).join("\n")}\n` }); + } + expect(highlightedLines.count).toBeLessThan(contentLines.length * 60); + + // The unterminated template literal spans lines, so only a full highlight matches a fresh render. + component.setArgsComplete(); + const fresh = createToolComponent("write", { path: "src/a.ts", content }, definition); + fresh.setExpanded(true); + fresh.setArgsComplete(); + expect(component.render(200)).toEqual(fresh.render(200)); + + highlightedLines.count = 0; + component.updateResult({ content: [{ type: "text", text: "ok" }], isError: false }, false); + expect(highlightedLines.count).toBe(0); + }); +}); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index f334f7d6b2f6..8b37c53ca081 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -289,6 +289,36 @@ describe("ToolExecutionComponent parity", () => { expect(render(false)).toContain("Took 4.2s"); }); + test("bash renderer highlights heredoc bodies and collapses long ones", () => { + const body = Array.from({ length: 15 }, (_, i) => `import mod${i + 1}`).join("\n"); + const command = `cd repo && python3 - <<'EOF'\n${body}\nEOF\ncargo test`; + const component = new ToolExecutionComponent( + "bash", + "tool-bash-heredoc", + { command }, + {}, + createBashToolDefinition(process.cwd(), { exposeSessionEnvironment: false }), + createFakeTui(), + process.cwd(), + ); + + const collapsed = component.render(120); + const plain = collapsed.map((line) => stripAnsi(line).trimEnd()); + expect(plain).toContain(" $ cd repo && python3 - <<'EOF'"); + expect(plain).toContain(" import mod10"); + expect(plain).not.toContain(" import mod11"); + expect(plain.some((line) => line.startsWith(" ... (5 more lines,"))).toBe(true); + expect(plain).toContain(" EOF"); + expect(plain).toContain(" cargo test"); + const bodyLine = collapsed[plain.indexOf(" import mod1")]; + expect(bodyLine).toContain(theme.fg("syntaxKeyword", "import")); + + component.setExpanded(true); + const expanded = component.render(120).map((line) => stripAnsi(line).trimEnd()); + expect(expanded).toContain(" import mod15"); + expect(expanded.some((line) => line.includes("more lines"))).toBe(false); + }); + test("does not duplicate built-in headers when passed the active built-in definition", () => { const component = new ToolExecutionComponent( "read",