Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/coding-agent/src/core/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -410,7 +412,7 @@ export function createShellToolDefinition(
clearUpdateTimer();
}
},
...createShellRenderers(config.prompt),
...createShellRenderers(config.prompt, { embeddedCode: config.embeddedCode }),
};
}

Expand All @@ -419,6 +421,7 @@ const bashToolConfig: ShellToolConfig = {
label: "bash",
shellName: "bash",
prompt: "$",
embeddedCode: true,
promptSnippet: bashToolSystemPromptContribution.snippet,
promptGuidelines: bashToolSystemPromptContribution.guidelines,
tempFilePrefix: "pi-bash",
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/tools/powershell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const powershellToolConfig: ShellToolConfig = {
label: "powershell",
shellName: "PowerShell",
prompt: "PS>",
embeddedCode: false,
promptSnippet: powershellToolSystemPromptContribution.snippet,
promptGuidelines: powershellToolSystemPromptContribution.guidelines,
tempFilePrefix: "pi-powershell",
Expand Down
60 changes: 60 additions & 0 deletions packages/coding-agent/src/core/tools/render-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
141 changes: 131 additions & 10 deletions packages/coding-agent/src/core/tools/renderers/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ToolDefinition<any, any>, "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<ToolDefinition<any, any>, "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);
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/src/core/tools/renderers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export {
export function createAllToolRenderers(): Record<ToolName, ToolRenderers> {
return {
read: readRenderers,
bash: createShellRenderers("$"),
powershell: createShellRenderers("PS>"),
bash: createShellRenderers("$", { embeddedCode: true }),
powershell: createShellRenderers("PS>", { embeddedCode: false }),
edit: editRenderers,
write: writeRenderers,
grep: grepRenderers,
Expand Down
Loading
Loading