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") }, + ], }, });