feat(coding-agent): split tool renderers and theme validation from their implementations

A presentation draws tool calls and colours; it executes nothing and validates
nothing. Both cost it typebox anyway, which is 17 MB of module graph: 682 ESM
modules whose narrowest entry re-exports ~300 of them, so there is no
cherry-picking and both doors had to close before any process stopped paying.

ToolExecutionComponent no longer looks up createAllToolDefinitions by tool name;
callers pass what to render, and its parameter type now says so. Each built-in
tool's presentation moves to core/tools/renderers/<tool>.ts, leaving the schema
and execute path behind; bash and powershell share createShellRenderers(prompt).
Tool definitions keep their exact public shape and spread the renderers in.
withBuiltInRenderers() preserves the old fallback, where an extension override
without renderers still draws like the built-in - that behaviour used to live
inside the component, and two tests caught its absence.

theme.ts keeps palettes; the typebox schema and its error formatting move to
theme-json.ts, installed through setThemeJsonValidator. Built-in themes were
never validated, so only processes that load user-authored themes need it. pi
installs it during startup, before the first theme loads, and the invalid-theme
message is unchanged.

Measured with mini, running built output: the presentation drops from 87 MB and
99 files to 72 MB and 88 files, with typebox absent from its graph, while still
rendering tool output through the real built-in renderers.

Also: packages/server's vitest aliases were prefix matches, so a subpath import
resolved onto index.ts/utils/uuid and failed five suites; they are exact now
with explicit subpath rules. And mini-test.sh spawns mini the way pi-test.sh
spawns pi, with --fresh/--stop for the detached session server.
This commit is contained in:
Mario Zechner 2026-08-26 23:08:57 +02:00
parent 5976a2f8b9
commit eb3e9feed1
26 changed files with 1345 additions and 1096 deletions

46
mini-test.sh Executable file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MINI_SRC="$SCRIPT_DIR/packages/coding-agent/src/experimental/mini/main.ts"
MINI_DIST="$SCRIPT_DIR/packages/coding-agent/dist/experimental/mini/main.js"
# The session server is spawned detached and outlives the TUI, so it keeps running whatever code it
# started with. Restart it after changing anything under mini/, or the protocol will not match.
stop_server() {
pkill -f "mini/server/entry" 2>/dev/null || true
rm -f "${PI_AGENT_DIR:-$HOME/.pi/agent}/experimental/mini.sock"
}
USE_DIST=false
ARGS=()
for arg in "$@"; do
case "$arg" in
--dist) USE_DIST=true ;;
--fresh) stop_server ;;
--stop) stop_server; echo "Stopped the mini session server."; exit 0 ;;
--help)
cat <<'USAGE'
Usage: ./mini-test.sh [--dist] [--fresh] [--stop] [mini args...]
--dist run built output with plain node instead of tsx on sources
--fresh stop the detached session server first, so it picks up your changes
--stop stop the detached session server and exit
Mini args are passed through, for example:
./mini-test.sh --continue
USAGE
exit 0 ;;
*) ARGS+=("$arg") ;;
esac
done
if [[ "$USE_DIST" == "true" ]]; then
if [[ ! -f "$MINI_DIST" ]]; then
echo "No build found. Run: npm run build -w @earendil-works/pi-coding-agent" >&2
exit 1
fi
exec node "$MINI_DIST" ${ARGS[@]+"${ARGS[@]}"}
fi
exec "$SCRIPT_DIR/node_modules/.bin/tsx" --tsconfig "$SCRIPT_DIR/tsconfig.json" "$MINI_SRC" ${ARGS[@]+"${ARGS[@]}"}

View file

@ -1,12 +1,8 @@
import { constants } from "node:fs";
import { access as fsAccess } from "node:fs/promises";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { spawn } from "child_process";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.ts";
import { theme } from "../../modes/interactive/theme/theme.ts";
import { waitForChildProcess } from "../../utils/child-process.ts";
import {
getShellConfig,
@ -17,9 +13,9 @@ import {
untrackDetachedChildPid,
} from "../../utils/shell.ts";
import { getExperimentalToolSampling } from "../experimental.ts";
import type { ExtensionContext, ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
import { OutputAccumulator } from "./output-accumulator.ts";
import { getTextOutput, invalidArgText, str } from "./render-utils.ts";
import { BASH_UPDATE_THROTTLE_MS, createShellRenderers } from "./renderers/bash.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.ts";
@ -208,123 +204,12 @@ export interface BashToolOptions {
spawnHook?: BashSpawnHook;
}
const BASH_PREVIEW_LINES = 5;
const BASH_UPDATE_THROTTLE_MS = 100;
export type BashRenderState = {
startedAt: number | undefined;
endedAt: number | undefined;
interval: NodeJS.Timeout | undefined;
};
type BashResultRenderState = {
cachedWidth: number | undefined;
cachedLines: string[] | undefined;
cachedSkipped: number | undefined;
};
class BashResultRenderComponent extends Container {
state: BashResultRenderState = {
cachedWidth: undefined,
cachedLines: undefined,
cachedSkipped: undefined,
};
}
function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
function formatShellCall(args: { command?: string; timeout?: number } | undefined, prompt: string): 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;
}
function rebuildBashResultRenderComponent(
component: BashResultRenderComponent,
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: BashToolDetails;
},
options: ToolRenderResultOptions,
showImages: boolean,
startedAt: number | undefined,
endedAt: number | undefined,
): void {
const state = component.state;
component.clear();
let output = getTextOutput(result as any, showImages).trim();
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) {
const footerStart = output.lastIndexOf("\n\n[");
if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) {
output = output.slice(0, footerStart).trimEnd();
}
}
if (output) {
const styledOutput = output
.split("\n")
.map((line) => theme.fg("toolOutput", line))
.join("\n");
if (options.expanded) {
component.addChild(new Text(`\n${styledOutput}`, 0, 0));
} else {
component.addChild({
render: (width: number) => {
if (state.cachedLines === undefined || state.cachedWidth !== width) {
const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width);
state.cachedLines = preview.visualLines;
state.cachedSkipped = preview.skippedCount;
state.cachedWidth = width;
}
if (state.cachedSkipped && state.cachedSkipped > 0) {
const hint =
theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) +
` ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])];
}
return ["", ...(state.cachedLines ?? [])];
},
invalidate: () => {
state.cachedWidth = undefined;
state.cachedLines = undefined;
state.cachedSkipped = undefined;
},
});
}
}
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {
warnings.push(`Full output: ${fullOutputPath}`);
}
if (truncation?.truncated) {
if (truncation.truncatedBy === "lines") {
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
} else {
warnings.push(
`Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
);
}
}
component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
}
if (startedAt !== undefined) {
const label = options.isPartial ? "Elapsed" : "Took";
const endTime = endedAt ?? Date.now();
component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0));
}
}
export interface ShellToolConfig {
name: string;
label: string;
@ -478,41 +363,7 @@ export function createShellToolDefinition(
clearUpdateTimer();
}
},
renderCall(args, _theme, context) {
const state = context.state;
if (context.executionStarted && state.startedAt === undefined) {
state.startedAt = Date.now();
state.endedAt = undefined;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatShellCall(args, config.prompt));
return text;
},
renderResult(result, options, _theme, context) {
const state = context.state;
if (state.startedAt !== undefined && options.isPartial && !state.interval) {
state.interval = setInterval(() => context.invalidate(), 1000);
}
if (!options.isPartial || context.isError) {
state.endedAt ??= Date.now();
if (state.interval) {
clearInterval(state.interval);
state.interval = undefined;
}
}
const component =
(context.lastComponent as BashResultRenderComponent | undefined) ?? new BashResultRenderComponent();
rebuildBashResultRenderComponent(
component,
result as any,
options,
context.showImages,
state.startedAt,
state.endedAt,
);
component.invalidate();
return component;
},
...createShellRenderers(config.prompt),
};
}

View file

@ -1,20 +1,14 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { type Static, Type } from "typebox";
import { renderDiff } from "../../modes/interactive/components/diff.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import { splitBom } from "../../utils/text.ts";
import { getExperimentalToolSampling } from "../experimental.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import {
applyEditsToNormalizedContent,
computeEditsDiff,
detectLineEnding,
type Edit,
type EditDiffError,
type EditDiffResult,
generateDiffString,
generateUnifiedPatch,
normalizeToLF,
@ -22,15 +16,9 @@ import {
} from "./edit-diff.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { renderToolPath, str } from "./render-utils.ts";
import { type EditRenderState, editRenderers } from "./renderers/edit.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
type EditPreview = EditDiffResult | EditDiffError;
type EditRenderState = {
callComponent?: EditCallRenderComponent;
};
const replaceEditSchema = Type.Object(
{
oldText: Type.String({
@ -153,166 +141,6 @@ function validateEditInput(input: EditToolInput): { path: string; edits: Edit[]
return { path: input.path, edits: input.edits };
}
type RenderableEditArgs = {
path?: string;
file_path?: string;
edits?: Edit[];
oldText?: string;
newText?: string;
};
type EditToolResultLike = {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: EditToolDetails;
};
type EditCallRenderComponent = Box & {
preview?: EditPreview;
previewArgsKey?: string;
previewPending?: boolean;
settledError?: boolean;
};
function createEditCallRenderComponent(): EditCallRenderComponent {
return Object.assign(new Box(1, 1, (text: string) => text), {
preview: undefined as EditPreview | undefined,
previewArgsKey: undefined as string | undefined,
previewPending: false,
settledError: false,
});
}
function getEditCallRenderComponent(state: EditRenderState, lastComponent: unknown): EditCallRenderComponent {
if (lastComponent instanceof Box) {
const component = lastComponent as EditCallRenderComponent;
state.callComponent = component;
return component;
}
if (state.callComponent) {
return state.callComponent;
}
const component = createEditCallRenderComponent();
state.callComponent = component;
return component;
}
function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path: string; edits: Edit[] } | null {
if (!args) {
return null;
}
const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : null;
if (!path) {
return null;
}
if (
Array.isArray(args.edits) &&
args.edits.length > 0 &&
args.edits.every((edit) => typeof edit?.oldText === "string" && typeof edit?.newText === "string")
) {
return { path, edits: args.edits };
}
if (typeof args.oldText === "string" && typeof args.newText === "string") {
return { path, edits: [{ oldText: args.oldText, newText: args.newText }] };
}
return null;
}
function formatEditCall(args: RenderableEditArgs | undefined, theme: Theme, cwd: string): string {
const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd);
return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
}
function formatEditResult(
args: RenderableEditArgs | undefined,
preview: EditPreview | undefined,
result: EditToolResultLike,
theme: Theme,
isError: boolean,
): string | undefined {
const rawPath = str(args?.file_path ?? args?.path);
const previewDiff = preview && !("error" in preview) ? preview.diff : undefined;
const previewError = preview && "error" in preview ? preview.error : undefined;
if (isError) {
const errorText = result.content
.filter((c) => c.type === "text")
.map((c) => c.text || "")
.join("\n");
if (!errorText || errorText === previewError) {
return undefined;
}
return theme.fg("error", errorText);
}
const resultDiff = result.details?.diff;
if (resultDiff && resultDiff !== previewDiff) {
return renderDiff(resultDiff, { filePath: rawPath ?? undefined });
}
return undefined;
}
function getEditHeaderBg(
preview: EditPreview | undefined,
settledError: boolean | undefined,
theme: Theme,
): (text: string) => string {
if (preview) {
if ("error" in preview) {
return (text: string) => theme.bg("toolErrorBg", text);
}
return (text: string) => theme.bg("toolSuccessBg", text);
}
if (settledError) {
return (text: string) => theme.bg("toolErrorBg", text);
}
return (text: string) => theme.bg("toolPendingBg", text);
}
function buildEditCallComponent(
component: EditCallRenderComponent,
args: RenderableEditArgs | undefined,
theme: Theme,
cwd: string,
): EditCallRenderComponent {
component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme));
component.clear();
component.addChild(new Text(formatEditCall(args, theme, cwd), 0, 0));
if (!component.preview) {
return component;
}
const body =
"error" in component.preview ? theme.fg("error", component.preview.error) : renderDiff(component.preview.diff);
component.addChild(new Spacer(1));
component.addChild(new Text(body, 0, 0));
return component;
}
function setEditPreview(
component: EditCallRenderComponent,
preview: EditPreview,
argsKey: string | undefined,
): boolean {
const current = component.preview;
const changed =
current === undefined ||
("error" in current && "error" in preview
? current.error !== preview.error
: "error" in current !== "error" in preview) ||
(!("error" in current) &&
!("error" in preview) &&
(current.diff !== preview.diff || current.firstChangedLine !== preview.firstChangedLine));
component.preview = preview;
component.previewArgsKey = argsKey;
component.previewPending = false;
return changed;
}
export function createEditToolDefinition(
cwd: string,
options?: EditToolOptions,
@ -384,75 +212,7 @@ export function createEditToolDefinition(
};
});
},
renderCall(args, theme, context) {
const component = getEditCallRenderComponent(context.state, context.lastComponent);
const previewInput = getRenderablePreviewInput(args as RenderableEditArgs | undefined);
const argsKey = previewInput
? JSON.stringify({ path: previewInput.path, edits: previewInput.edits })
: undefined;
if (component.previewArgsKey !== argsKey) {
component.preview = undefined;
component.previewArgsKey = argsKey;
component.previewPending = false;
component.settledError = false;
}
if (context.argsComplete && previewInput && !component.preview && !component.previewPending) {
component.previewPending = true;
const requestKey = argsKey;
void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd).then((preview) => {
if (component.previewArgsKey === requestKey) {
setEditPreview(component, preview, requestKey);
context.invalidate();
}
});
}
return buildEditCallComponent(component, args, theme, context.cwd);
},
renderResult(result, _options, theme, context) {
const callComponent = context.state.callComponent;
const previewInput = getRenderablePreviewInput(context.args as RenderableEditArgs | undefined);
const argsKey = previewInput
? JSON.stringify({ path: previewInput.path, edits: previewInput.edits })
: undefined;
const typedResult = result as EditToolResultLike;
const resultDiff = !context.isError ? typedResult.details?.diff : undefined;
let changed = false;
if (callComponent) {
if (typeof resultDiff === "string") {
changed =
setEditPreview(
callComponent,
{ diff: resultDiff, firstChangedLine: typedResult.details?.firstChangedLine },
argsKey,
) || changed;
}
if (callComponent.settledError !== context.isError) {
callComponent.settledError = context.isError;
changed = true;
}
if (changed) {
buildEditCallComponent(
callComponent,
context.args as RenderableEditArgs | undefined,
theme,
context.cwd,
);
}
}
const output = formatEditResult(context.args, callComponent?.preview, typedResult, theme, context.isError);
const component = (context.lastComponent as Container | undefined) ?? new Container();
component.clear();
if (!output) {
return component;
}
component.addChild(new Spacer(1));
component.addChild(new Text(output, 1, 0));
return component;
},
...editRenderers,
};
}

View file

@ -1,15 +1,12 @@
import { createInterface } from "node:readline";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Text } from "@earendil-works/pi-tui";
import { spawn } from "child_process";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import { pathExists, resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { findRenderers } from "./renderers/find.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
@ -70,56 +67,6 @@ export interface FindToolOptions {
operations?: FindOperations;
}
function formatFindCall(args: { pattern: string; path?: string; limit?: number } | undefined, theme: Theme): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text =
theme.fg("toolTitle", theme.bold("find")) +
" " +
(pattern === null ? invalidArg : theme.fg("accent", pattern || "")) +
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
return text;
}
function formatFindResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: FindToolDetails;
},
options: ToolRenderResultOptions,
theme: Theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
}
const resultLimit = result.details?.resultLimitReached;
const truncation = result.details?.truncation;
if (resultLimit || truncation?.truncated) {
const warnings: string[] = [];
if (resultLimit) warnings.push(`${resultLimit} results limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export function createFindToolDefinition(
cwd: string,
options?: FindToolOptions,
@ -362,16 +309,7 @@ export function createFindToolDefinition(
})();
});
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatFindCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatFindResult(result as any, options, theme, context.showImages));
return text;
},
...findRenderers,
};
}

View file

@ -1,16 +1,13 @@
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
import { createInterface } from "node:readline";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Text } from "@earendil-works/pi-tui";
import { spawn } from "child_process";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { grepRenderers } from "./renderers/grep.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import {
DEFAULT_MAX_BYTES,
@ -70,61 +67,6 @@ export interface GrepToolOptions {
operations?: GrepOperations;
}
function formatGrepCall(
args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined,
theme: Theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const glob = str(args?.glob);
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text =
theme.fg("toolTitle", theme.bold("grep")) +
" " +
(pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
if (glob) text += theme.fg("toolOutput", ` (${glob})`);
if (limit !== undefined) text += theme.fg("toolOutput", ` limit ${limit}`);
return text;
}
function formatGrepResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: GrepToolDetails;
},
options: ToolRenderResultOptions,
theme: Theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 15;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
}
const matchLimit = result.details?.matchLimitReached;
const truncation = result.details?.truncation;
const linesTruncated = result.details?.linesTruncated;
if (matchLimit || truncation?.truncated || linesTruncated) {
const warnings: string[] = [];
if (matchLimit) warnings.push(`${matchLimit} matches limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
if (linesTruncated) warnings.push("some lines truncated");
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export function createGrepToolDefinition(
cwd: string,
options?: GrepToolOptions,
@ -372,16 +314,7 @@ export function createGrepToolDefinition(
})();
});
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatGrepCall(args, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatGrepResult(result as any, options, theme, context.showImages));
return text;
},
...grepRenderers,
};
}

View file

@ -1,13 +1,10 @@
import { readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Text } from "@earendil-works/pi-tui";
import nodePath from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import { pathExists, resolveToCwd } from "./path-utils.ts";
import { getTextOutput, renderToolPath, str } from "./render-utils.ts";
import { lsRenderers } from "./renderers/ls.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
@ -54,49 +51,6 @@ export interface LsToolOptions {
operations?: LsOperations;
}
function formatLsCall(args: { path?: string; limit?: number } | undefined, theme: Theme, cwd: string): string {
const limit = args?.limit;
const pathDisplay = renderToolPath(str(args?.path), theme, cwd, { emptyFallback: "." });
let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${pathDisplay}`;
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
return text;
}
function formatLsResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: LsToolDetails;
},
options: ToolRenderResultOptions,
theme: Theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
}
const entryLimit = result.details?.entryLimitReached;
const truncation = result.details?.truncation;
if (entryLimit || truncation?.truncated) {
const warnings: string[] = [];
if (entryLimit) warnings.push(`${entryLimit} entries limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export function createLsToolDefinition(
cwd: string,
options?: LsToolOptions,
@ -212,16 +166,7 @@ export function createLsToolDefinition(
})();
});
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatLsCall(args, theme, context.cwd));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatLsResult(result as any, options, theme, context.showImages));
return text;
},
...lsRenderers,
};
}

View file

@ -1,20 +1,14 @@
import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from "node:path";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import { Text } from "@earendil-works/pi-tui";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
import { type Static, Type } from "typebox";
import { getReadmePath } from "../../config.ts";
import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.ts";
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts";
import { processImage } from "../../utils/image-process.ts";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts";
import { getExperimentalToolSampling } from "../experimental.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts";
import { getTextOutput, renderToolPath, replaceTabs, str } from "./render-utils.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import { resolveReadPathAsync } from "./path-utils.ts";
import { readRenderers } from "./renderers/read.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
@ -35,13 +29,6 @@ export interface ReadToolDetails {
truncation?: TruncationResult;
}
interface CompactReadClassification {
kind: "docs" | "resource" | "skill";
label: string;
}
const COMPACT_RESOURCE_FILE_NAMES = new Set(["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]);
/**
* Pluggable operations for the read tool.
* Override these to delegate file reading to remote systems (for example SSH).
@ -68,28 +55,6 @@ export interface ReadToolOptions {
operations?: ReadOperations;
}
type ReadRenderArgs = { path?: string; file_path?: string; offset?: number; limit?: number };
function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): string {
if (args?.offset === undefined && args?.limit === undefined) return "";
const startLine = args.offset ?? 1;
const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme, cwd: string): string {
const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd);
return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`;
}
function trimTrailingEmptyLines(lines: string[]): string[] {
let end = lines.length;
while (end > 0 && lines[end - 1] === "") {
end--;
}
return lines.slice(0, end);
}
function getNonVisionImageNote(model: Model<Api> | undefined): string | undefined {
if (!model || model.input.includes("image")) {
return undefined;
@ -97,115 +62,6 @@ function getNonVisionImageNote(model: Model<Api> | undefined): string | undefine
return "[Current model does not support images. The image will be omitted from this request.]";
}
function toPosixPath(filePath: string): string {
return filePath.split(sep).join("/");
}
function getPiDocsClassification(absolutePath: string): CompactReadClassification | undefined {
const packageRoot = dirname(getReadmePath());
const relativePath = relative(resolvePath(packageRoot), resolvePath(absolutePath));
if (
relativePath === "" ||
relativePath === ".." ||
relativePath.startsWith(`..${sep}`) ||
isAbsolute(relativePath)
) {
return undefined;
}
const label = toPosixPath(relativePath);
if (label === "README.md" || label.startsWith("docs/") || label.startsWith("examples/")) {
return { kind: "docs", label };
}
return undefined;
}
function getCompactReadClassification(
args: ReadRenderArgs | undefined,
cwd: string,
): CompactReadClassification | undefined {
const rawPath = str(args?.file_path ?? args?.path);
if (!rawPath) return undefined;
const absolutePath = resolveToCwd(rawPath, cwd);
const fileName = basename(absolutePath);
if (fileName === "SKILL.md") {
return { kind: "skill", label: basename(dirname(absolutePath)) || fileName };
}
const docsClassification = getPiDocsClassification(absolutePath);
if (docsClassification) return docsClassification;
if (COMPACT_RESOURCE_FILE_NAMES.has(fileName)) {
return { kind: "resource", label: formatPathRelativeToCwdOrAbsolute(absolutePath, cwd) };
}
return undefined;
}
function formatCompactReadCall(
classification: CompactReadClassification,
args: ReadRenderArgs | undefined,
theme: Theme,
): string {
const expandHint = theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`);
if (classification.kind === "skill") {
return (
theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) +
theme.fg("customMessageText", classification.label) +
formatReadLineRange(args, theme) +
expandHint
);
}
return (
theme.fg("toolTitle", theme.bold(`read ${classification.kind}`)) +
" " +
theme.fg("accent", classification.label) +
formatReadLineRange(args, theme) +
expandHint
);
}
function formatReadResult(
args: ReadRenderArgs | undefined,
result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails },
options: ToolRenderResultOptions,
theme: Theme,
showImages: boolean,
_cwd: string,
isError: boolean,
): string {
if (!options.expanded && !isError) {
return "";
}
const rawPath = str(args?.file_path ?? args?.path);
const output = getTextOutput(result, showImages);
const lang = !isError && rawPath ? getLanguageFromPath(rawPath) : undefined;
const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
const lines = trimTrailingEmptyLines(renderedLines);
const maxLines = options.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
const truncation = result.details?.truncation;
if (truncation?.truncated) {
if (truncation.firstLineExceedsLimit) {
text += `\n${theme.fg("warning", `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`)}`;
} else if (truncation.truncatedBy === "lines") {
text += `\n${theme.fg("warning", `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`)}`;
} else {
text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`;
}
}
return text;
}
export function createReadToolDefinition(
cwd: string,
options?: ReadToolOptions,
@ -333,23 +189,7 @@ export function createReadToolDefinition(
},
);
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined;
text.setText(
classification
? formatCompactReadCall(classification, args, theme)
: formatReadCall(args, theme, context.cwd),
);
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(
formatReadResult(context.args, result, options, theme, context.showImages, context.cwd, context.isError),
);
return text;
},
...readRenderers,
};
}

View file

@ -0,0 +1,163 @@
/**
* Presentation for the shell tools.
*
* Renderers live apart from the implementation so a process that only displays tool output does not
* load the execution path or its typebox parameter schema. `bash.ts` spreads these into the shell
* tool definition, so the tool's public shape is unchanged.
*/
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { keyHint } from "../../../modes/interactive/components/keybinding-hints.ts";
import { truncateToVisualLines } from "../../../modes/interactive/components/visual-truncate.ts";
import { theme } from "../../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts";
import type { BashToolDetails } from "../bash.ts";
import { getTextOutput, invalidArgText, str } from "../render-utils.ts";
import { DEFAULT_MAX_BYTES, formatSize } from "../truncate.ts";
const BASH_PREVIEW_LINES = 5;
export const BASH_UPDATE_THROTTLE_MS = 100;
type BashResultRenderState = {
cachedWidth: number | undefined;
cachedLines: string[] | undefined;
cachedSkipped: number | undefined;
};
class BashResultRenderComponent extends Container {
state: BashResultRenderState = {
cachedWidth: undefined,
cachedLines: undefined,
cachedSkipped: undefined,
};
}
function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
function formatShellCall(args: { command?: string; timeout?: number } | undefined, prompt: string): 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;
}
function rebuildBashResultRenderComponent(
component: BashResultRenderComponent,
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: BashToolDetails;
},
options: ToolRenderResultOptions,
showImages: boolean,
startedAt: number | undefined,
endedAt: number | undefined,
): void {
const state = component.state;
component.clear();
let output = getTextOutput(result as any, showImages).trim();
const truncation = result.details?.truncation;
const fullOutputPath = result.details?.fullOutputPath;
if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) {
const footerStart = output.lastIndexOf("\n\n[");
if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) {
output = output.slice(0, footerStart).trimEnd();
}
}
if (output) {
const styledOutput = output
.split("\n")
.map((line) => theme.fg("toolOutput", line))
.join("\n");
if (options.expanded) {
component.addChild(new Text(`\n${styledOutput}`, 0, 0));
} else {
component.addChild({
render: (width: number) => {
if (state.cachedLines === undefined || state.cachedWidth !== width) {
const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width);
state.cachedLines = preview.visualLines;
state.cachedSkipped = preview.skippedCount;
state.cachedWidth = width;
}
if (state.cachedSkipped && state.cachedSkipped > 0) {
const hint =
theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) +
` ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])];
}
return ["", ...(state.cachedLines ?? [])];
},
invalidate: () => {
state.cachedWidth = undefined;
state.cachedLines = undefined;
state.cachedSkipped = undefined;
},
});
}
}
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {
warnings.push(`Full output: ${fullOutputPath}`);
}
if (truncation?.truncated) {
if (truncation.truncatedBy === "lines") {
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
} else {
warnings.push(
`Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
);
}
}
component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
}
if (startedAt !== undefined) {
const label = options.isPartial ? "Elapsed" : "Took";
const endTime = endedAt ?? Date.now();
component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 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"> {
return {
renderCall(args, _theme, context) {
const state = context.state;
if (context.executionStarted && state.startedAt === undefined) {
state.startedAt = Date.now();
state.endedAt = undefined;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatShellCall(args as { command?: string; timeout?: number } | undefined, prompt));
return text;
},
renderResult(result, options, _theme, context) {
const state = context.state;
if (state.startedAt !== undefined && options.isPartial && !state.interval) {
state.interval = setInterval(() => context.invalidate(), 1000);
}
if (!options.isPartial || context.isError) {
state.endedAt ??= Date.now();
if (state.interval) {
clearInterval(state.interval);
state.interval = undefined;
}
}
const component =
(context.lastComponent as BashResultRenderComponent | undefined) ?? new BashResultRenderComponent();
rebuildBashResultRenderComponent(
component,
result as any,
options,
context.showImages,
state.startedAt,
state.endedAt,
);
component.invalidate();
return component;
},
};
}

View file

@ -0,0 +1,238 @@
/**
* Presentation for the edit tool.
*
* Renderers live apart from the implementation so a process that only displays tool output does not
* load the execution path or its typebox parameter schema. `edit.ts` spreads these into its
* definition, so the tool's public shape is unchanged.
*/
import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
import { renderDiff } from "../../../modes/interactive/components/diff.ts";
import type { Theme } from "../../../modes/interactive/theme/theme.ts";
import type { ToolDefinition } from "../../extensions/types.ts";
import type { EditToolDetails } from "../edit.ts";
import { computeEditsDiff, type Edit, type EditDiffError, type EditDiffResult } from "../edit-diff.ts";
import { renderToolPath, str } from "../render-utils.ts";
type EditPreview = EditDiffResult | EditDiffError;
export type EditRenderState = {
callComponent?: EditCallRenderComponent;
};
type RenderableEditArgs = {
path?: string;
file_path?: string;
edits?: Edit[];
oldText?: string;
newText?: string;
};
type EditToolResultLike = {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: EditToolDetails;
};
type EditCallRenderComponent = Box & {
preview?: EditPreview;
previewArgsKey?: string;
previewPending?: boolean;
settledError?: boolean;
};
function createEditCallRenderComponent(): EditCallRenderComponent {
return Object.assign(new Box(1, 1, (text: string) => text), {
preview: undefined as EditPreview | undefined,
previewArgsKey: undefined as string | undefined,
previewPending: false,
settledError: false,
});
}
function getEditCallRenderComponent(state: EditRenderState, lastComponent: unknown): EditCallRenderComponent {
if (lastComponent instanceof Box) {
const component = lastComponent as EditCallRenderComponent;
state.callComponent = component;
return component;
}
if (state.callComponent) {
return state.callComponent;
}
const component = createEditCallRenderComponent();
state.callComponent = component;
return component;
}
function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path: string; edits: Edit[] } | null {
if (!args) {
return null;
}
const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : null;
if (!path) {
return null;
}
if (
Array.isArray(args.edits) &&
args.edits.length > 0 &&
args.edits.every((edit) => typeof edit?.oldText === "string" && typeof edit?.newText === "string")
) {
return { path, edits: args.edits };
}
if (typeof args.oldText === "string" && typeof args.newText === "string") {
return { path, edits: [{ oldText: args.oldText, newText: args.newText }] };
}
return null;
}
function formatEditCall(args: RenderableEditArgs | undefined, theme: Theme, cwd: string): string {
const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd);
return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
}
function formatEditResult(
args: RenderableEditArgs | undefined,
preview: EditPreview | undefined,
result: EditToolResultLike,
theme: Theme,
isError: boolean,
): string | undefined {
const rawPath = str(args?.file_path ?? args?.path);
const previewDiff = preview && !("error" in preview) ? preview.diff : undefined;
const previewError = preview && "error" in preview ? preview.error : undefined;
if (isError) {
const errorText = result.content
.filter((c) => c.type === "text")
.map((c) => c.text || "")
.join("\n");
if (!errorText || errorText === previewError) {
return undefined;
}
return theme.fg("error", errorText);
}
const resultDiff = result.details?.diff;
if (resultDiff && resultDiff !== previewDiff) {
return renderDiff(resultDiff, { filePath: rawPath ?? undefined });
}
return undefined;
}
function getEditHeaderBg(
preview: EditPreview | undefined,
settledError: boolean | undefined,
theme: Theme,
): (text: string) => string {
if (preview) {
if ("error" in preview) {
return (text: string) => theme.bg("toolErrorBg", text);
}
return (text: string) => theme.bg("toolSuccessBg", text);
}
if (settledError) {
return (text: string) => theme.bg("toolErrorBg", text);
}
return (text: string) => theme.bg("toolPendingBg", text);
}
function buildEditCallComponent(
component: EditCallRenderComponent,
args: RenderableEditArgs | undefined,
theme: Theme,
cwd: string,
): EditCallRenderComponent {
component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme));
component.clear();
component.addChild(new Text(formatEditCall(args, theme, cwd), 0, 0));
if (!component.preview) {
return component;
}
const body =
"error" in component.preview ? theme.fg("error", component.preview.error) : renderDiff(component.preview.diff);
component.addChild(new Spacer(1));
component.addChild(new Text(body, 0, 0));
return component;
}
function setEditPreview(
component: EditCallRenderComponent,
preview: EditPreview,
argsKey: string | undefined,
): boolean {
const current = component.preview;
const changed =
current === undefined ||
("error" in current && "error" in preview
? current.error !== preview.error
: "error" in current !== "error" in preview) ||
(!("error" in current) &&
!("error" in preview) &&
(current.diff !== preview.diff || current.firstChangedLine !== preview.firstChangedLine));
component.preview = preview;
component.previewArgsKey = argsKey;
component.previewPending = false;
return changed;
}
export const editRenderers: Pick<ToolDefinition<any, any>, "renderCall" | "renderResult"> = {
renderCall(args, theme, context) {
const component = getEditCallRenderComponent(context.state, context.lastComponent);
const previewInput = getRenderablePreviewInput(args as RenderableEditArgs | undefined);
const argsKey = previewInput ? JSON.stringify({ path: previewInput.path, edits: previewInput.edits }) : undefined;
if (component.previewArgsKey !== argsKey) {
component.preview = undefined;
component.previewArgsKey = argsKey;
component.previewPending = false;
component.settledError = false;
}
if (context.argsComplete && previewInput && !component.preview && !component.previewPending) {
component.previewPending = true;
const requestKey = argsKey;
void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd).then((preview) => {
if (component.previewArgsKey === requestKey) {
setEditPreview(component, preview, requestKey);
context.invalidate();
}
});
}
return buildEditCallComponent(component, args as RenderableEditArgs | undefined, theme, context.cwd);
},
renderResult(result, _options, theme, context) {
const callComponent = context.state.callComponent;
const previewInput = getRenderablePreviewInput(context.args as RenderableEditArgs | undefined);
const argsKey = previewInput ? JSON.stringify({ path: previewInput.path, edits: previewInput.edits }) : undefined;
const typedResult = result as EditToolResultLike;
const resultDiff = !context.isError ? typedResult.details?.diff : undefined;
let changed = false;
if (callComponent) {
if (typeof resultDiff === "string") {
changed =
setEditPreview(
callComponent,
{ diff: resultDiff, firstChangedLine: typedResult.details?.firstChangedLine },
argsKey,
) || changed;
}
if (callComponent.settledError !== context.isError) {
callComponent.settledError = context.isError;
changed = true;
}
if (changed) {
buildEditCallComponent(callComponent, context.args as RenderableEditArgs | undefined, theme, context.cwd);
}
}
const output = formatEditResult(
context.args as RenderableEditArgs | undefined,
callComponent?.preview,
typedResult,
theme,
context.isError,
);
const component = (context.lastComponent as Container | undefined) ?? new Container();
component.clear();
if (!output) {
return component;
}
component.addChild(new Spacer(1));
component.addChild(new Text(output, 1, 0));
return component;
},
};

View file

@ -0,0 +1,77 @@
/**
* Presentation for the find tool.
*
* Renderers live apart from the implementation so a process that only displays tool output does not
* load the execution path or its typebox parameter schema. `find.ts` spreads these into its
* definition, so the tool's public shape is unchanged.
*/
import { Text } from "@earendil-works/pi-tui";
import { keyHint } from "../../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts";
import type { FindToolDetails } from "../find.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "../render-utils.ts";
import { DEFAULT_MAX_BYTES, formatSize } from "../truncate.ts";
function formatFindCall(args: { pattern: string; path?: string; limit?: number } | undefined, theme: Theme): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text =
theme.fg("toolTitle", theme.bold("find")) +
" " +
(pattern === null ? invalidArg : theme.fg("accent", pattern || "")) +
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
return text;
}
function formatFindResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: FindToolDetails;
},
options: ToolRenderResultOptions,
theme: Theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
}
const resultLimit = result.details?.resultLimitReached;
const truncation = result.details?.truncation;
if (resultLimit || truncation?.truncated) {
const warnings: string[] = [];
if (resultLimit) warnings.push(`${resultLimit} results limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export const findRenderers: Pick<ToolDefinition<any, any>, "renderCall" | "renderResult"> = {
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatFindCall(args as any, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatFindResult(result as any, options, theme, context.showImages));
return text;
},
};

View file

@ -0,0 +1,82 @@
/**
* Presentation for the grep tool.
*
* Renderers live apart from the implementation so a process that only displays tool output does not
* load the execution path or its typebox parameter schema. `grep.ts` spreads these into its
* definition, so the tool's public shape is unchanged.
*/
import { Text } from "@earendil-works/pi-tui";
import { keyHint } from "../../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts";
import type { GrepToolDetails } from "../grep.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "../render-utils.ts";
import { DEFAULT_MAX_BYTES, formatSize } from "../truncate.ts";
function formatGrepCall(
args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined,
theme: Theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
const glob = str(args?.glob);
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text =
theme.fg("toolTitle", theme.bold("grep")) +
" " +
(pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) +
theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`);
if (glob) text += theme.fg("toolOutput", ` (${glob})`);
if (limit !== undefined) text += theme.fg("toolOutput", ` limit ${limit}`);
return text;
}
function formatGrepResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: GrepToolDetails;
},
options: ToolRenderResultOptions,
theme: Theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 15;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
}
const matchLimit = result.details?.matchLimitReached;
const truncation = result.details?.truncation;
const linesTruncated = result.details?.linesTruncated;
if (matchLimit || truncation?.truncated || linesTruncated) {
const warnings: string[] = [];
if (matchLimit) warnings.push(`${matchLimit} matches limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
if (linesTruncated) warnings.push("some lines truncated");
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export const grepRenderers: Pick<ToolDefinition<any, any>, "renderCall" | "renderResult"> = {
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatGrepCall(args as any, theme));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatGrepResult(result as any, options, theme, context.showImages));
return text;
},
};

View file

@ -0,0 +1,63 @@
/**
* Built-in tool renderers, without the tools themselves.
*
* A presentation displays tool calls and results; it does not execute them and does not need their
* typebox parameter schemas. Importing this instead of `core/tools/index.ts` keeps ~17 MB of module
* graph out of a process that only renders.
*/
import type { ToolDefinition } from "../../extensions/types.ts";
import type { ToolName } from "../index.ts";
import { createShellRenderers } from "./bash.ts";
import { editRenderers } from "./edit.ts";
import { findRenderers } from "./find.ts";
import { grepRenderers } from "./grep.ts";
import { lsRenderers } from "./ls.ts";
import { readRenderers } from "./read.ts";
import { writeRenderers } from "./write.ts";
export type ToolRenderers = Pick<ToolDefinition<any, any>, "renderCall" | "renderResult">;
export {
createShellRenderers,
editRenderers,
findRenderers,
grepRenderers,
lsRenderers,
readRenderers,
writeRenderers,
};
/** Renderers for every built-in tool, keyed by tool name. */
export function createAllToolRenderers(): Record<ToolName, ToolRenderers> {
return {
read: readRenderers,
bash: createShellRenderers("$"),
powershell: createShellRenderers("PS>"),
edit: editRenderers,
write: writeRenderers,
grep: grepRenderers,
find: findRenderers,
ls: lsRenderers,
};
}
/**
* Merge built-in renderers into a tool definition that does not supply its own.
*
* `ToolExecutionComponent` used to do this lookup itself, which forced every presentation to import
* the tool implementations. Callers do it now, so a process that renders can import renderers alone.
*/
export function withBuiltInRenderers<TDefinition extends ToolRenderers>(
toolName: string,
definition: TDefinition | undefined,
): TDefinition | ToolRenderers | undefined {
const builtIn = createAllToolRenderers()[toolName as ToolName];
if (!definition) return builtIn;
if (!builtIn) return definition;
return {
...definition,
renderCall: definition.renderCall ?? builtIn.renderCall,
renderResult: definition.renderResult ?? builtIn.renderResult,
};
}

View file

@ -0,0 +1,70 @@
/**
* Presentation for the ls tool.
*
* Renderers live apart from the implementation so a process that only displays tool output does not
* load the execution path or its typebox parameter schema. `ls.ts` spreads these into its
* definition, so the tool's public shape is unchanged.
*/
import { Text } from "@earendil-works/pi-tui";
import { keyHint } from "../../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts";
import type { LsToolDetails } from "../ls.ts";
import { getTextOutput, renderToolPath, str } from "../render-utils.ts";
import { DEFAULT_MAX_BYTES, formatSize } from "../truncate.ts";
function formatLsCall(args: { path?: string; limit?: number } | undefined, theme: Theme, cwd: string): string {
const limit = args?.limit;
const pathDisplay = renderToolPath(str(args?.path), theme, cwd, { emptyFallback: "." });
let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${pathDisplay}`;
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
return text;
}
function formatLsResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: LsToolDetails;
},
options: ToolRenderResultOptions,
theme: Theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
let text = "";
if (output) {
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : 20;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
}
const entryLimit = result.details?.entryLimitReached;
const truncation = result.details?.truncation;
if (entryLimit || truncation?.truncated) {
const warnings: string[] = [];
if (entryLimit) warnings.push(`${entryLimit} entries limit`);
if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
return text;
}
export const lsRenderers: Pick<ToolDefinition<any, any>, "renderCall" | "renderResult"> = {
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatLsCall(args as any, theme, context.cwd));
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatLsResult(result as any, options, theme, context.showImages));
return text;
},
};

View file

@ -0,0 +1,175 @@
/**
* Presentation for the read tool.
*
* Renderers live apart from the implementation so a process that only displays tool output does not
* load the execution path or its typebox parameter schema. `read.ts` spreads these into its
* definition, so the tool's public shape is unchanged.
*/
import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from "node:path";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { Text } from "@earendil-works/pi-tui";
import { getReadmePath } from "../../../config.ts";
import { keyHint, keyText } from "../../../modes/interactive/components/keybinding-hints.ts";
import { getLanguageFromPath, highlightCode, type Theme } from "../../../modes/interactive/theme/theme.ts";
import { formatPathRelativeToCwdOrAbsolute } from "../../../utils/paths.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts";
import { resolveToCwd } from "../path-utils.ts";
import type { ReadToolDetails } from "../read.ts";
import { getTextOutput, renderToolPath, replaceTabs, str } from "../render-utils.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../truncate.ts";
interface CompactReadClassification {
kind: "docs" | "resource" | "skill";
label: string;
}
const COMPACT_RESOURCE_FILE_NAMES = new Set(["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]);
type ReadRenderArgs = { path?: string; file_path?: string; offset?: number; limit?: number };
function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): string {
if (args?.offset === undefined && args?.limit === undefined) return "";
const startLine = args.offset ?? 1;
const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme, cwd: string): string {
const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd);
return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`;
}
function trimTrailingEmptyLines(lines: string[]): string[] {
let end = lines.length;
while (end > 0 && lines[end - 1] === "") {
end--;
}
return lines.slice(0, end);
}
function toPosixPath(filePath: string): string {
return filePath.split(sep).join("/");
}
function getPiDocsClassification(absolutePath: string): CompactReadClassification | undefined {
const packageRoot = dirname(getReadmePath());
const relativePath = relative(resolvePath(packageRoot), resolvePath(absolutePath));
if (
relativePath === "" ||
relativePath === ".." ||
relativePath.startsWith(`..${sep}`) ||
isAbsolute(relativePath)
) {
return undefined;
}
const label = toPosixPath(relativePath);
if (label === "README.md" || label.startsWith("docs/") || label.startsWith("examples/")) {
return { kind: "docs", label };
}
return undefined;
}
function getCompactReadClassification(
args: ReadRenderArgs | undefined,
cwd: string,
): CompactReadClassification | undefined {
const rawPath = str(args?.file_path ?? args?.path);
if (!rawPath) return undefined;
const absolutePath = resolveToCwd(rawPath, cwd);
const fileName = basename(absolutePath);
if (fileName === "SKILL.md") {
return { kind: "skill", label: basename(dirname(absolutePath)) || fileName };
}
const docsClassification = getPiDocsClassification(absolutePath);
if (docsClassification) return docsClassification;
if (COMPACT_RESOURCE_FILE_NAMES.has(fileName)) {
return { kind: "resource", label: formatPathRelativeToCwdOrAbsolute(absolutePath, cwd) };
}
return undefined;
}
function formatCompactReadCall(
classification: CompactReadClassification,
args: ReadRenderArgs | undefined,
theme: Theme,
): string {
const expandHint = theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`);
if (classification.kind === "skill") {
return (
theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) +
theme.fg("customMessageText", classification.label) +
formatReadLineRange(args, theme) +
expandHint
);
}
return (
theme.fg("toolTitle", theme.bold(`read ${classification.kind}`)) +
" " +
theme.fg("accent", classification.label) +
formatReadLineRange(args, theme) +
expandHint
);
}
function formatReadResult(
args: ReadRenderArgs | undefined,
result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails },
options: ToolRenderResultOptions,
theme: Theme,
showImages: boolean,
_cwd: string,
isError: boolean,
): string {
if (!options.expanded && !isError) {
return "";
}
const rawPath = str(args?.file_path ?? args?.path);
const output = getTextOutput(result, showImages);
const lang = !isError && rawPath ? getLanguageFromPath(rawPath) : undefined;
const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n");
const lines = trimTrailingEmptyLines(renderedLines);
const maxLines = options.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
const truncation = result.details?.truncation;
if (truncation?.truncated) {
if (truncation.firstLineExceedsLimit) {
text += `\n${theme.fg("warning", `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`)}`;
} else if (truncation.truncatedBy === "lines") {
text += `\n${theme.fg("warning", `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`)}`;
} else {
text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`;
}
}
return text;
}
export const readRenderers: Pick<ToolDefinition<any, ReadToolDetails | undefined>, "renderCall" | "renderResult"> = {
renderCall(rawArgs, theme, context) {
const args = rawArgs as ReadRenderArgs | undefined;
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined;
text.setText(
classification ? formatCompactReadCall(classification, args, theme) : formatReadCall(args, theme, context.cwd),
);
return text;
},
renderResult(result, options, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(
formatReadResult(
context.args as ReadRenderArgs | undefined,
result,
options,
theme,
context.showImages,
context.cwd,
context.isError,
),
);
return text;
},
};

View file

@ -0,0 +1,181 @@
/**
* Presentation for the write tool.
*
* Renderers live apart from the implementation so a process that only displays tool output does not
* load the execution path or its typebox parameter schema. `write.ts` spreads these into its
* definition, so the tool's public shape is unchanged.
*/
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 type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts";
import { normalizeDisplayText, renderToolPath, replaceTabs, str } from "../render-utils.ts";
type WriteHighlightCache = {
rawPath: string | null;
lang: string;
rawContent: string;
normalizedLines: string[];
highlightedLines: string[];
};
class WriteCallRenderComponent extends Text {
cache?: WriteHighlightCache;
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] === "") {
end--;
}
return lines.slice(0, end);
}
function formatWriteCall(
args: { path?: string; file_path?: string; content?: string } | undefined,
options: ToolRenderResultOptions,
theme: Theme,
cache: WriteHighlightCache | undefined,
cwd: string,
): string {
const rawPath = str(args?.file_path ?? args?.path);
const fileContent = str(args?.content);
const pathDisplay = renderToolPath(rawPath, theme, cwd);
let text = `${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}`;
if (fileContent === null) {
text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
} else if (fileContent) {
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
const renderedLines = lang
? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang))
: normalizeDisplayText(fileContent).split("\n");
const lines = trimTrailingEmptyLines(renderedLines);
const totalLines = lines.length;
const maxLines = options.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
}
return text;
}
function formatWriteResult(
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean },
theme: Theme,
): string | undefined {
if (!result.isError) {
return undefined;
}
const output = result.content
.filter((c) => c.type === "text")
.map((c) => c.text || "")
.join("\n");
if (!output) {
return undefined;
}
return `\n${theme.fg("error", output)}`;
}
export const writeRenderers: Pick<ToolDefinition<any, any>, "renderCall" | "renderResult"> = {
renderCall(args, theme, context) {
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
const rawPath = str(renderArgs?.file_path ?? renderArgs?.path);
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;
}
component.setText(
formatWriteCall(
renderArgs,
{ expanded: context.expanded, isPartial: context.isPartial },
theme,
component.cache,
context.cwd,
),
);
return component;
},
renderResult(result, _options, theme, context) {
const output = formatWriteResult({ ...result, isError: context.isError }, theme);
if (!output) {
const component = (context.lastComponent as Container | undefined) ?? new Container();
component.clear();
return component;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(output);
return text;
},
};

View file

@ -1,15 +1,12 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Container, Text } from "@earendil-works/pi-tui";
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
import { dirname } from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts";
import { getExperimentalToolSampling } from "../experimental.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { normalizeDisplayText, renderToolPath, replaceTabs, str } from "./render-utils.ts";
import { writeRenderers } from "./renderers/write.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
const writeSchema = Type.Object({
@ -45,145 +42,6 @@ export interface WriteToolOptions {
operations?: WriteOperations;
}
type WriteHighlightCache = {
rawPath: string | null;
lang: string;
rawContent: string;
normalizedLines: string[];
highlightedLines: string[];
};
class WriteCallRenderComponent extends Text {
cache?: WriteHighlightCache;
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] === "") {
end--;
}
return lines.slice(0, end);
}
function formatWriteCall(
args: { path?: string; file_path?: string; content?: string } | undefined,
options: ToolRenderResultOptions,
theme: Theme,
cache: WriteHighlightCache | undefined,
cwd: string,
): string {
const rawPath = str(args?.file_path ?? args?.path);
const fileContent = str(args?.content);
const pathDisplay = renderToolPath(rawPath, theme, cwd);
let text = `${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}`;
if (fileContent === null) {
text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
} else if (fileContent) {
const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
const renderedLines = lang
? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang))
: normalizeDisplayText(fileContent).split("\n");
const lines = trimTrailingEmptyLines(renderedLines);
const totalLines = lines.length;
const maxLines = options.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
}
}
return text;
}
function formatWriteResult(
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean },
theme: Theme,
): string | undefined {
if (!result.isError) {
return undefined;
}
const output = result.content
.filter((c) => c.type === "text")
.map((c) => c.text || "")
.join("\n");
if (!output) {
return undefined;
}
return `\n${theme.fg("error", output)}`;
}
export function createWriteToolDefinition(
cwd: string,
options?: WriteToolOptions,
@ -231,41 +89,7 @@ export function createWriteToolDefinition(
};
});
},
renderCall(args, theme, context) {
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
const rawPath = str(renderArgs?.file_path ?? renderArgs?.path);
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;
}
component.setText(
formatWriteCall(
renderArgs,
{ expanded: context.expanded, isPartial: context.isPartial },
theme,
component.cache,
context.cwd,
),
);
return component;
},
renderResult(result, _options, theme, context) {
const output = formatWriteResult({ ...result, isError: context.isError }, theme);
if (!output) {
const component = (context.lastComponent as Container | undefined) ?? new Container();
component.clear();
return component;
}
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(output);
return text;
},
...writeRenderers,
};
}

View file

@ -7,6 +7,7 @@
*/
import { spawn } from "node:child_process";
import { extname } from "node:path";
import { fileURLToPath } from "node:url";
// Narrow entries: the server routes and lists sessions, it never runs an agent.
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core/harness/context";
@ -16,7 +17,8 @@ import { type SessionSummary, Sessions, type SessionsServiceApi, Worker } from "
import { createPeer, type RpcPeer } from "../shared/rpc.ts";
import { childConnection, type Transport } from "../shared/transport.ts";
const WORKER_ENTRY = fileURLToPath(new URL("../worker/entry.ts", import.meta.url));
const SELF_EXTENSION = extname(fileURLToPath(import.meta.url));
const WORKER_ENTRY = fileURLToPath(new URL(`../worker/entry${SELF_EXTENSION}`, import.meta.url));
const WORKER_START_TIMEOUT_MS = 30_000;
/** Grace period before an idle server retires, so a reconnecting presentation does not race it. */
const IDLE_SHUTDOWN_MS = 10_000;

View file

@ -4,14 +4,15 @@
import { spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { extname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getAgentDir } from "../../../config.ts";
import { socketTransport, type Transport } from "../shared/transport.ts";
import { connect, listSessions } from "./session.ts";
import { runView } from "./view.ts";
const SERVER_ENTRY = fileURLToPath(new URL("../server/entry.ts", import.meta.url));
const SELF_EXTENSION = extname(fileURLToPath(import.meta.url));
const SERVER_ENTRY = fileURLToPath(new URL(`../server/entry${SELF_EXTENSION}`, import.meta.url));
const SERVER_START_TIMEOUT_MS = 10_000;
export interface TuiOptions {

View file

@ -28,6 +28,7 @@ import {
} from "@earendil-works/pi-tui";
import { getAgentDir } from "../../../config.ts";
import { KeybindingsManager } from "../../../core/keybindings.ts";
import { createAllToolRenderers } from "../../../core/tools/renderers/index.ts";
import { AssistantMessageComponent } from "../../../modes/interactive/components/assistant-message.ts";
import { CustomEditor } from "../../../modes/interactive/components/custom-editor.ts";
import { DynamicBorder } from "../../../modes/interactive/components/dynamic-border.ts";
@ -42,7 +43,7 @@ import {
type StatusIndicator,
WorkingStatusIndicator,
} from "../../../modes/interactive/components/status-indicator.ts";
import { ToolExecutionComponent } from "../../../modes/interactive/components/tool-execution.ts";
import { ToolExecutionComponent, type ToolRenderers } from "../../../modes/interactive/components/tool-execution.ts";
import { UserMessageComponent } from "../../../modes/interactive/components/user-message.ts";
import { getEditorTheme, initTheme, theme } from "../../../modes/interactive/theme/theme.ts";
import type { AuthPromptRequest, CommandResult, ProviderAccount } from "../shared/protocol.ts";
@ -338,6 +339,9 @@ class MiniTui {
}
}
/** Built-in renderers, without the tool implementations or their schemas. */
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
/** Get or create the component for a tool call; omit `args` to look up without overwriting them. */
#tool(toolName: string, toolCallId: string, args?: unknown): ToolExecutionComponent {
const existing = this.#tools.get(toolCallId);
@ -350,7 +354,7 @@ class MiniTui {
toolCallId,
args ?? {},
{},
undefined,
MiniTui.#renderers[toolName],
this.#ui,
this.#cwd,
);

View file

@ -70,7 +70,8 @@ import { startForegroundServer } from "./experimental/server.ts";
import { builtInExtensions } from "./extensions/index.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { initTheme, setThemeJsonValidator, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { validateThemeJson } from "./modes/interactive/theme/theme-json.ts";
import { cleanupManagedInstall, handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts";
@ -980,6 +981,8 @@ export async function main(args: string[], options?: MainOptions) {
stdinContent,
);
time("prepareInitialMessage");
// pi reads user-authored themes, so it opts into full validation before any theme loads.
setThemeJsonValidator(validateThemeJson);
initTheme(settingsManager.getTheme(), appMode === "interactive");
time("initTheme");

View file

@ -1,3 +1,4 @@
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import {
Box,
type Component,
@ -10,8 +11,27 @@ import {
type TUI,
type TuiMouseEvent,
} from "@earendil-works/pi-tui";
import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.ts";
import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.ts";
import type { ToolDefinition, ToolRenderContext, ToolRenderResultOptions } from "../../../core/extensions/types.ts";
import type { Theme } from "../theme/theme.ts";
/**
* What this component needs from a tool: how to draw it. It neither executes tools nor reads their
* parameter schemas, so a definition and a bare renderer pair are equally acceptable.
*
* The renderer parameters are `any` on purpose: a `ToolDefinition` types them from its schema, and
* narrowing them here would make those definitions unassignable.
*/
export interface ToolRenderers {
renderShell?: "default" | "self";
renderCall?: (args: any, theme: Theme, context: ToolRenderContext<any, any>) => Component;
renderResult?: (
result: AgentToolResult<any>,
options: ToolRenderResultOptions,
theme: Theme,
context: ToolRenderContext<any, any>,
) => Component;
}
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts";
import { convertToPng } from "../../../utils/image-convert.ts";
import { theme } from "../theme/theme.ts";
@ -42,8 +62,7 @@ export class ToolExecutionComponent extends Container {
private showImages: boolean;
private imageWidthCells: number;
private isPartial = true;
private toolDefinition?: ToolDefinition<any, any>;
private builtInToolDefinition?: ToolDefinition<any, any>;
private toolDefinition?: ToolRenderers;
private ui: TUI;
private cwd: string;
private executionStarted = false;
@ -61,7 +80,7 @@ export class ToolExecutionComponent extends Container {
toolCallId: string,
args: any,
options: ToolExecutionOptions = {},
toolDefinition: ToolDefinition<any, any> | undefined,
toolDefinition: ToolRenderers | ToolDefinition<any, any, any> | undefined,
ui: TUI,
cwd: string,
) {
@ -70,7 +89,6 @@ export class ToolExecutionComponent extends Container {
this.toolCallId = toolCallId;
this.args = args;
this.toolDefinition = toolDefinition;
this.builtInToolDefinition = createAllToolDefinitions(cwd)[toolName as ToolName];
this.showImages = options.showImages ?? true;
this.imageWidthCells = options.imageWidthCells ?? 60;
this.ui = ui;
@ -96,37 +114,19 @@ export class ToolExecutionComponent extends Container {
}
private getCallRenderer(): ToolDefinition<any, any>["renderCall"] | undefined {
if (!this.builtInToolDefinition) {
return this.toolDefinition?.renderCall;
}
if (!this.toolDefinition) {
return this.builtInToolDefinition.renderCall;
}
return this.toolDefinition.renderCall ?? this.builtInToolDefinition.renderCall;
return this.toolDefinition?.renderCall;
}
private getResultRenderer(): ToolDefinition<any, any>["renderResult"] | undefined {
if (!this.builtInToolDefinition) {
return this.toolDefinition?.renderResult;
}
if (!this.toolDefinition) {
return this.builtInToolDefinition.renderResult;
}
return this.toolDefinition.renderResult ?? this.builtInToolDefinition.renderResult;
return this.toolDefinition?.renderResult;
}
private hasRendererDefinition(): boolean {
return this.builtInToolDefinition !== undefined || this.toolDefinition !== undefined;
return this.toolDefinition !== undefined;
}
private getRenderShell(): "default" | "self" {
if (!this.builtInToolDefinition) {
return this.toolDefinition?.renderShell ?? "default";
}
if (!this.toolDefinition) {
return this.builtInToolDefinition.renderShell ?? "default";
}
return this.toolDefinition.renderShell ?? this.builtInToolDefinition.renderShell ?? "default";
return this.toolDefinition?.renderShell ?? "default";
}
private getRenderContext(lastComponent: Component | undefined): ToolRenderContext {

View file

@ -97,6 +97,7 @@ import type { FullscreenExitOutput, TuiMode } from "../../core/settings-manager.
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import { withBuiltInRenderers } from "../../core/tools/renderers/index.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
import { getUsageCostBreakdown } from "../../core/usage-totals.ts";
@ -2054,8 +2055,12 @@ export class InteractiveMode {
/**
* Get a registered tool definition by name (for custom rendering).
*/
/**
* Extension-registered definition, falling back to the built-in one. The renderer components take
* whatever this returns, so they never reach into the tool registry themselves.
*/
private getRegisteredToolDefinition(toolName: string) {
return this.session.getToolDefinition(toolName);
return withBuiltInRenderers(toolName, this.session.getToolDefinition(toolName));
}
private getMarkdownTransformers(): MarkdownTransformer[] {

View file

@ -0,0 +1,144 @@
/**
* Theme JSON validation, kept out of `theme.ts` on purpose.
*
* Validating user-authored theme files needs typebox, which costs ~17 MB of module graph to import.
* Palette lookup does not, so a presentation that only uses built-in themes should never pay for it.
* `interactive-mode.ts` installs this validator; anything that does not simply skips validation, as
* built-in themes already do.
*/
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
const ColorValueSchema = Type.Union([
Type.String(), // hex "#ff0000", var ref "primary", or empty ""
Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index
]);
const ThemeJsonSchema = Type.Object({
$schema: Type.Optional(Type.String()),
name: Type.String(),
vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
colors: Type.Object({
// Core UI (10 colors)
accent: ColorValueSchema,
border: ColorValueSchema,
borderAccent: ColorValueSchema,
borderMuted: ColorValueSchema,
success: ColorValueSchema,
error: ColorValueSchema,
warning: ColorValueSchema,
muted: ColorValueSchema,
dim: ColorValueSchema,
text: ColorValueSchema,
thinkingText: ColorValueSchema,
// Backgrounds & Content Text (11 required, 3 optional)
selectedBg: ColorValueSchema,
scrollbarThumb: Type.Optional(ColorValueSchema),
searchMatchBg: Type.Optional(ColorValueSchema),
searchMatchText: Type.Optional(ColorValueSchema),
userMessageBg: ColorValueSchema,
userMessageText: ColorValueSchema,
customMessageBg: ColorValueSchema,
customMessageText: ColorValueSchema,
customMessageLabel: ColorValueSchema,
toolPendingBg: ColorValueSchema,
toolSuccessBg: ColorValueSchema,
toolErrorBg: ColorValueSchema,
toolTitle: ColorValueSchema,
toolOutput: ColorValueSchema,
// Markdown (10 colors)
mdHeading: ColorValueSchema,
mdLink: ColorValueSchema,
mdLinkUrl: ColorValueSchema,
mdCode: ColorValueSchema,
mdCodeBlock: ColorValueSchema,
mdCodeBlockBorder: ColorValueSchema,
mdQuote: ColorValueSchema,
mdQuoteBorder: ColorValueSchema,
mdHr: ColorValueSchema,
mdListBullet: ColorValueSchema,
// Tool Diffs (3 colors)
toolDiffAdded: ColorValueSchema,
toolDiffRemoved: ColorValueSchema,
toolDiffContext: ColorValueSchema,
// Syntax Highlighting (9 colors)
syntaxComment: ColorValueSchema,
syntaxKeyword: ColorValueSchema,
syntaxFunction: ColorValueSchema,
syntaxVariable: ColorValueSchema,
syntaxString: ColorValueSchema,
syntaxNumber: ColorValueSchema,
syntaxType: ColorValueSchema,
syntaxOperator: ColorValueSchema,
syntaxPunctuation: ColorValueSchema,
// Thinking Level Borders (6 colors)
thinkingOff: ColorValueSchema,
thinkingMinimal: ColorValueSchema,
thinkingLow: ColorValueSchema,
thinkingMedium: ColorValueSchema,
thinkingHigh: ColorValueSchema,
thinkingXhigh: ColorValueSchema,
thinkingMax: Type.Optional(ColorValueSchema),
// Bash Mode (1 color)
bashMode: ColorValueSchema,
}),
export: Type.Optional(
Type.Object({
pageBg: Type.Optional(ColorValueSchema),
cardBg: Type.Optional(ColorValueSchema),
infoBg: Type.Optional(ColorValueSchema),
}),
),
});
const compiledThemeSchema = Compile(ThemeJsonSchema);
export type ThemeColorValue = Static<typeof ColorValueSchema>;
export type ValidatedThemeJson = Static<typeof ThemeJsonSchema>;
/** Validate one theme document, throwing a message that names the offending tokens. */
export function validateThemeJson(label: string, json: unknown): ValidatedThemeJson {
if (!compiledThemeSchema.Check(json)) {
const errors = Array.from(compiledThemeSchema.Errors(json));
const missingColors = new Set<string>();
const otherErrors: string[] = [];
for (const error of errors) {
if (error.keyword === "required" && error.instancePath === "/colors") {
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
for (const requiredProperty of requiredProperties ?? []) {
missingColors.add(requiredProperty);
}
continue;
}
const path = error.instancePath || "/";
otherErrors.push(` - ${path}: ${error.message}`);
}
let errorMessage = `Invalid theme "${label}":\n`;
if (missingColors.size > 0) {
errorMessage += "\nMissing required color tokens:\n";
errorMessage += Array.from(missingColors)
.sort()
.map((color) => ` - ${color}`)
.join("\n");
errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.';
errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values.";
}
if (otherErrors.length > 0) {
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
}
throw new Error(errorMessage);
}
const themeJson = json as ValidatedThemeJson;
if (themeJson.name.includes("/")) {
throw new Error(
`Invalid theme name "${themeJson.name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`,
);
}
return themeJson;
}

View file

@ -10,8 +10,6 @@ import {
type SettingsListTheme,
} from "@earendil-works/pi-tui";
import chalk from "chalk";
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
import { getCustomThemesDir, getThemesDir } from "../../../config.ts";
import type { SourceInfo } from "../../../core/source-info.ts";
import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts";
@ -22,93 +20,23 @@ import { stripBom } from "../../../utils/text.ts";
// Types & Schema
// ============================================================================
const ColorValueSchema = Type.Union([
Type.String(), // hex "#ff0000", var ref "primary", or empty ""
Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index
]);
/** The schema that validates this shape lives in `theme-json.ts`; importing the type is free. */
import type { ThemeColorValue as ColorValue, ValidatedThemeJson as ThemeJson } from "./theme-json.ts";
type ColorValue = Static<typeof ColorValueSchema>;
export type { ValidatedThemeJson as ThemeJson } from "./theme-json.ts";
const ThemeJsonSchema = Type.Object({
$schema: Type.Optional(Type.String()),
name: Type.String(),
vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
colors: Type.Object({
// Core UI (10 colors)
accent: ColorValueSchema,
border: ColorValueSchema,
borderAccent: ColorValueSchema,
borderMuted: ColorValueSchema,
success: ColorValueSchema,
error: ColorValueSchema,
warning: ColorValueSchema,
muted: ColorValueSchema,
dim: ColorValueSchema,
text: ColorValueSchema,
thinkingText: ColorValueSchema,
// Backgrounds & Content Text (11 required, 3 optional)
selectedBg: ColorValueSchema,
scrollbarThumb: Type.Optional(ColorValueSchema),
searchMatchBg: Type.Optional(ColorValueSchema),
searchMatchText: Type.Optional(ColorValueSchema),
userMessageBg: ColorValueSchema,
userMessageText: ColorValueSchema,
customMessageBg: ColorValueSchema,
customMessageText: ColorValueSchema,
customMessageLabel: ColorValueSchema,
toolPendingBg: ColorValueSchema,
toolSuccessBg: ColorValueSchema,
toolErrorBg: ColorValueSchema,
toolTitle: ColorValueSchema,
toolOutput: ColorValueSchema,
// Markdown (10 colors)
mdHeading: ColorValueSchema,
mdLink: ColorValueSchema,
mdLinkUrl: ColorValueSchema,
mdCode: ColorValueSchema,
mdCodeBlock: ColorValueSchema,
mdCodeBlockBorder: ColorValueSchema,
mdQuote: ColorValueSchema,
mdQuoteBorder: ColorValueSchema,
mdHr: ColorValueSchema,
mdListBullet: ColorValueSchema,
// Tool Diffs (3 colors)
toolDiffAdded: ColorValueSchema,
toolDiffRemoved: ColorValueSchema,
toolDiffContext: ColorValueSchema,
// Syntax Highlighting (9 colors)
syntaxComment: ColorValueSchema,
syntaxKeyword: ColorValueSchema,
syntaxFunction: ColorValueSchema,
syntaxVariable: ColorValueSchema,
syntaxString: ColorValueSchema,
syntaxNumber: ColorValueSchema,
syntaxType: ColorValueSchema,
syntaxOperator: ColorValueSchema,
syntaxPunctuation: ColorValueSchema,
// Thinking Level Borders (6 colors)
thinkingOff: ColorValueSchema,
thinkingMinimal: ColorValueSchema,
thinkingLow: ColorValueSchema,
thinkingMedium: ColorValueSchema,
thinkingHigh: ColorValueSchema,
thinkingXhigh: ColorValueSchema,
thinkingMax: Type.Optional(ColorValueSchema),
// Bash Mode (1 color)
bashMode: ColorValueSchema,
}),
export: Type.Optional(
Type.Object({
pageBg: Type.Optional(ColorValueSchema),
cardBg: Type.Optional(ColorValueSchema),
infoBg: Type.Optional(ColorValueSchema),
}),
),
});
export type ThemeJsonValidator = (label: string, json: unknown) => ThemeJson;
type ThemeJson = Static<typeof ThemeJsonSchema>;
let themeJsonValidator: ThemeJsonValidator | undefined;
const validateThemeJson = Compile(ThemeJsonSchema);
/**
* Install full theme validation. Without it, documents are accepted as-is, which is what built-in
* themes already do: validating user-authored JSON needs typebox, and a presentation that only uses
* built-in themes should not pay ~17 MB of module graph for it.
*/
export function setThemeJsonValidator(validator: ThemeJsonValidator): void {
themeJsonValidator = validator;
}
export type ThemeColor =
| "accent"
@ -554,44 +482,11 @@ function assertThemeNameIsValid(name: string): void {
}
function parseThemeJson(label: string, json: unknown): ThemeJson {
if (!validateThemeJson.Check(json)) {
const errors = Array.from(validateThemeJson.Errors(json));
const missingColors = new Set<string>();
const otherErrors: string[] = [];
for (const error of errors) {
if (error.keyword === "required" && error.instancePath === "/colors") {
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
for (const requiredProperty of requiredProperties ?? []) {
missingColors.add(requiredProperty);
}
continue;
}
const path = error.instancePath || "/";
otherErrors.push(` - ${path}: ${error.message}`);
}
let errorMessage = `Invalid theme "${label}":\n`;
if (missingColors.size > 0) {
errorMessage += "\nMissing required color tokens:\n";
errorMessage += Array.from(missingColors)
.sort()
.map((color) => ` - ${color}`)
.join("\n");
errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.';
errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values.";
}
if (otherErrors.length > 0) {
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
}
throw new Error(errorMessage);
if (themeJsonValidator) return themeJsonValidator(label, json);
if (typeof json !== "object" || json === null || !("colors" in json)) {
throw new Error(`Invalid theme "${label}": expected an object with a "colors" map.`);
}
const themeJson = json as ThemeJson;
assertThemeNameIsValid(themeJson.name);
return themeJson;
return json as ThemeJson;
}
function parseThemeJsonContent(label: string, content: string): ThemeJson {

View file

@ -6,6 +6,7 @@ import { getReadmePath } from "../src/config.ts";
import type { ToolDefinition } from "../src/core/extensions/types.ts";
import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.ts";
import { createReadTool, createReadToolDefinition } from "../src/core/tools/read.ts";
import { withBuiltInRenderers } from "../src/core/tools/renderers/index.ts";
import { createWriteToolDefinition } from "../src/core/tools/write.ts";
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts";
import { initTheme, theme } from "../src/modes/interactive/theme/theme.ts";
@ -108,7 +109,7 @@ describe("ToolExecutionComponent parity", () => {
"tool-2",
{ path: "README.md", oldText: "before", newText: "after" },
{},
overrideDefinition,
withBuiltInRenderers("edit", overrideDefinition),
createFakeTui(),
process.cwd(),
);
@ -217,7 +218,7 @@ describe("ToolExecutionComponent parity", () => {
"tool-4b",
{ path: "notes.txt" },
{},
overrideDefinition,
withBuiltInRenderers("read", overrideDefinition),
createFakeTui(),
process.cwd(),
);
@ -239,7 +240,7 @@ describe("ToolExecutionComponent parity", () => {
"tool-4c",
{ path: "README.md" },
{},
overrideDefinition,
withBuiltInRenderers("read", overrideDefinition),
createFakeTui(),
process.cwd(),
);

View file

@ -1,6 +1,12 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const src = (path: string): string => fileURLToPath(new URL(path, import.meta.url));
/**
* Exact matches for bare specifiers, plus one rule per package for subpath exports such as
* `@earendil-works/pi-ai/utils/uuid`. A prefix alias would rewrite those onto `index.ts/utils/uuid`.
*/
export default defineConfig({
test: {
globals: true,
@ -8,11 +14,13 @@ export default defineConfig({
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
},
resolve: {
alias: {
"@earendil-works/pi-agent-core": fileURLToPath(new URL("../agent/src/index.ts", import.meta.url)),
"@earendil-works/pi-ai": fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)),
"@earendil-works/pi-telemetry": fileURLToPath(new URL("../telemetry/src/index.ts", import.meta.url)),
"@earendil-works/pi-protocol": fileURLToPath(new URL("../protocol/src/index.ts", import.meta.url)),
},
alias: [
{ find: /^@earendil-works\/pi-agent-core$/, replacement: src("../agent/src/index.ts") },
{ find: /^@earendil-works\/pi-agent-core\/(.+)$/, replacement: `${src("../agent/src/")}$1.ts` },
{ find: /^@earendil-works\/pi-ai$/, replacement: src("../ai/src/index.ts") },
{ find: /^@earendil-works\/pi-ai\/(.+)$/, replacement: `${src("../ai/src/")}$1.ts` },
{ find: /^@earendil-works\/pi-telemetry$/, replacement: src("../telemetry/src/index.ts") },
{ find: /^@earendil-works\/pi-protocol$/, replacement: src("../protocol/src/index.ts") },
],
},
});