From eb3e9feed1bd092535ae77232e9dd351bc781e23 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 26 Aug 2026 23:08:57 +0200 Subject: [PATCH] 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/.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. --- mini-test.sh | 46 ++++ packages/coding-agent/src/core/tools/bash.ts | 155 +---------- packages/coding-agent/src/core/tools/edit.ts | 244 +----------------- packages/coding-agent/src/core/tools/find.ts | 68 +---- packages/coding-agent/src/core/tools/grep.ts | 73 +----- packages/coding-agent/src/core/tools/ls.ts | 61 +---- packages/coding-agent/src/core/tools/read.ts | 168 +----------- .../src/core/tools/renderers/bash.ts | 163 ++++++++++++ .../src/core/tools/renderers/edit.ts | 238 +++++++++++++++++ .../src/core/tools/renderers/find.ts | 77 ++++++ .../src/core/tools/renderers/grep.ts | 82 ++++++ .../src/core/tools/renderers/index.ts | 63 +++++ .../src/core/tools/renderers/ls.ts | 70 +++++ .../src/core/tools/renderers/read.ts | 175 +++++++++++++ .../src/core/tools/renderers/write.ts | 181 +++++++++++++ packages/coding-agent/src/core/tools/write.ts | 182 +------------ .../src/experimental/mini/server/run.ts | 4 +- .../src/experimental/mini/tui/run.ts | 5 +- .../src/experimental/mini/tui/view.ts | 8 +- packages/coding-agent/src/main.ts | 5 +- .../interactive/components/tool-execution.ts | 56 ++-- .../src/modes/interactive/interactive-mode.ts | 7 +- .../src/modes/interactive/theme/theme-json.ts | 144 +++++++++++ .../src/modes/interactive/theme/theme.ts | 139 ++-------- .../test/tool-execution-component.test.ts | 7 +- packages/server/vitest.config.ts | 20 +- 26 files changed, 1345 insertions(+), 1096 deletions(-) create mode 100755 mini-test.sh create mode 100644 packages/coding-agent/src/core/tools/renderers/bash.ts create mode 100644 packages/coding-agent/src/core/tools/renderers/edit.ts create mode 100644 packages/coding-agent/src/core/tools/renderers/find.ts create mode 100644 packages/coding-agent/src/core/tools/renderers/grep.ts create mode 100644 packages/coding-agent/src/core/tools/renderers/index.ts create mode 100644 packages/coding-agent/src/core/tools/renderers/ls.ts create mode 100644 packages/coding-agent/src/core/tools/renderers/read.ts create mode 100644 packages/coding-agent/src/core/tools/renderers/write.ts create mode 100644 packages/coding-agent/src/modes/interactive/theme/theme-json.ts diff --git a/mini-test.sh b/mini-test.sh new file mode 100755 index 000000000..9ac8c70d5 --- /dev/null +++ b/mini-test.sh @@ -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[@]}"} diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index b3139b85d..ec94cabd5 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -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), }; } diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index eb124864c..8d4fd9c8a 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -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, }; } diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index f3228ef77..37887a587 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -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, }; } diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index a65cab9da..cc04025cd 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -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, }; } diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 893e13c25..4d992c383 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -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, }; } diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 1f7666101..c622d397e 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -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 | undefined): string | undefined { if (!model || model.input.includes("image")) { return undefined; @@ -97,115 +62,6 @@ function getNonVisionImageNote(model: Model | 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, }; } diff --git a/packages/coding-agent/src/core/tools/renderers/bash.ts b/packages/coding-agent/src/core/tools/renderers/bash.ts new file mode 100644 index 000000000..01e170a82 --- /dev/null +++ b/packages/coding-agent/src/core/tools/renderers/bash.ts @@ -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, "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; + }, + }; +} diff --git a/packages/coding-agent/src/core/tools/renderers/edit.ts b/packages/coding-agent/src/core/tools/renderers/edit.ts new file mode 100644 index 000000000..6db555671 --- /dev/null +++ b/packages/coding-agent/src/core/tools/renderers/edit.ts @@ -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, "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; + }, +}; diff --git a/packages/coding-agent/src/core/tools/renderers/find.ts b/packages/coding-agent/src/core/tools/renderers/find.ts new file mode 100644 index 000000000..c9c65835d --- /dev/null +++ b/packages/coding-agent/src/core/tools/renderers/find.ts @@ -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, "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; + }, +}; diff --git a/packages/coding-agent/src/core/tools/renderers/grep.ts b/packages/coding-agent/src/core/tools/renderers/grep.ts new file mode 100644 index 000000000..1f5479a4d --- /dev/null +++ b/packages/coding-agent/src/core/tools/renderers/grep.ts @@ -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, "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; + }, +}; diff --git a/packages/coding-agent/src/core/tools/renderers/index.ts b/packages/coding-agent/src/core/tools/renderers/index.ts new file mode 100644 index 000000000..6a88b7e8a --- /dev/null +++ b/packages/coding-agent/src/core/tools/renderers/index.ts @@ -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, "renderCall" | "renderResult">; + +export { + createShellRenderers, + editRenderers, + findRenderers, + grepRenderers, + lsRenderers, + readRenderers, + writeRenderers, +}; + +/** Renderers for every built-in tool, keyed by tool name. */ +export function createAllToolRenderers(): Record { + 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( + 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, + }; +} diff --git a/packages/coding-agent/src/core/tools/renderers/ls.ts b/packages/coding-agent/src/core/tools/renderers/ls.ts new file mode 100644 index 000000000..bcc1ee000 --- /dev/null +++ b/packages/coding-agent/src/core/tools/renderers/ls.ts @@ -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, "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; + }, +}; diff --git a/packages/coding-agent/src/core/tools/renderers/read.ts b/packages/coding-agent/src/core/tools/renderers/read.ts new file mode 100644 index 000000000..2f5af2103 --- /dev/null +++ b/packages/coding-agent/src/core/tools/renderers/read.ts @@ -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, "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; + }, +}; diff --git a/packages/coding-agent/src/core/tools/renderers/write.ts b/packages/coding-agent/src/core/tools/renderers/write.ts new file mode 100644 index 000000000..7c8f32636 --- /dev/null +++ b/packages/coding-agent/src/core/tools/renderers/write.ts @@ -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, "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; + }, +}; diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index d25435b0b..d8e136d89 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -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, }; } diff --git a/packages/coding-agent/src/experimental/mini/server/run.ts b/packages/coding-agent/src/experimental/mini/server/run.ts index 474b66f0c..5b505071c 100644 --- a/packages/coding-agent/src/experimental/mini/server/run.ts +++ b/packages/coding-agent/src/experimental/mini/server/run.ts @@ -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; diff --git a/packages/coding-agent/src/experimental/mini/tui/run.ts b/packages/coding-agent/src/experimental/mini/tui/run.ts index 9fd5c2ff6..651adee35 100644 --- a/packages/coding-agent/src/experimental/mini/tui/run.ts +++ b/packages/coding-agent/src/experimental/mini/tui/run.ts @@ -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 { diff --git a/packages/coding-agent/src/experimental/mini/tui/view.ts b/packages/coding-agent/src/experimental/mini/tui/view.ts index 0df330a32..ac61fd5b5 100644 --- a/packages/coding-agent/src/experimental/mini/tui/view.ts +++ b/packages/coding-agent/src/experimental/mini/tui/view.ts @@ -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 = 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, ); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 79b65a988..f9d0c2d33 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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"); diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index a35c1d8fc..5355a3637 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -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) => Component; + renderResult?: ( + result: AgentToolResult, + options: ToolRenderResultOptions, + theme: Theme, + context: ToolRenderContext, + ) => 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; - private builtInToolDefinition?: ToolDefinition; + 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 | undefined, + toolDefinition: ToolRenderers | ToolDefinition | 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["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["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 { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index acf96ae2f..ac358c420 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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[] { diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-json.ts b/packages/coding-agent/src/modes/interactive/theme/theme-json.ts new file mode 100644 index 000000000..915f9d305 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/theme/theme-json.ts @@ -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; +export type ValidatedThemeJson = Static; + +/** 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(); + 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; +} diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index c49bad29e..3a8c596b7 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -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; +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; +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(); - 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 { diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index e45774c18..5ebec2e8a 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -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(), ); diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index fbcf54e11..4d9ef2747 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -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") }, + ], }, });