refactor(coding-agent): migrated tool rendering to declarative TUI component APIs

- Created new TUI component library with renderStatusLine, renderOutputBlock, renderCodeCell, renderFileList, and renderTreeList utilities for consistent tool output rendering.
- Refactored 13+ tools to use centralized TUI components, replacing inline theme formatting and ToolUIKit class usage with declarative component APIs.
- Moved tree rendering utilities (getTreeBranch, getTreeContinuePrefix) from tool render-utils to TUI module for improved code organization.
- Streamlined render function return types across tools from Text to Component objects with render/invalidate methods for better state management.
This commit is contained in:
can1357
2026-01-23 13:46:01 +01:00
parent a683344fd8
commit 763faa1615
26 changed files with 1053 additions and 1017 deletions
+45 -177
View File
@@ -2,22 +2,23 @@ import { relative, resolve, sep } from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { Text, truncateToWidth } from "@oh-my-pi/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "$c/config/prompt-templates";
import type { RenderResultOptions } from "$c/extensibility/custom-tools/types";
import { executePython, getPreludeDocs, type PythonExecutorOptions } from "$c/ipy/executor";
import type { PreludeHelper, PythonStatusEvent } from "$c/ipy/kernel";
import { truncateToVisualLines } from "$c/modes/components/visual-truncate";
import { highlightCode, type Theme } from "$c/modes/theme/theme";
import type { Theme } from "$c/modes/theme/theme";
import pythonDescription from "$c/prompts/tools/python.md" with { type: "text" };
import { OutputSink, type OutputSummary } from "$c/session/streaming-output";
import type { OutputMeta } from "$c/tools/output-meta";
import { ToolAbortError, ToolError } from "$c/tools/tool-errors";
import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "$c/tui";
import type { ToolSession } from "./index";
import { allocateOutputArtifact, createTailBuffer } from "./output-utils";
import { resolveToCwd } from "./path-utils";
import { getTreeBranch, getTreeContinuePrefix, shortenPath, ToolUIKit, truncate } from "./render-utils";
import { shortenPath, ToolUIKit, truncate } from "./render-utils";
import { toolResult } from "./tool-result";
import { DEFAULT_MAX_BYTES } from "./truncate";
@@ -740,56 +741,6 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded:
return lines;
}
function applyCellBackground(line: string, width: number, bgFn?: (text: string) => string): string {
if (!bgFn) return line;
if (width <= 0) return bgFn(line);
const paddingNeeded = Math.max(0, width - visibleWidth(line));
const padded = line + " ".repeat(paddingNeeded);
return bgFn(padded);
}
function highlightPythonCode(code?: string): string[] {
return highlightCode(code ?? "", "python");
}
function formatCellStatus(cell: PythonCellResult, ui: ToolUIKit, spinnerFrame?: number): string | undefined {
switch (cell.status) {
case "pending":
return `${ui.statusIcon("pending")} ${ui.theme.fg("muted", "pending")}`;
case "running":
return `${ui.statusIcon("running", spinnerFrame)} ${ui.theme.fg("muted", "running")}`;
case "complete":
return ui.statusIcon("success");
case "error":
return ui.statusIcon("error");
}
}
function formatCellHeader(
cell: PythonCellResult,
index: number,
total: number,
ui: ToolUIKit,
spinnerFrame?: number,
workdirLabel?: string,
): string {
const indexLabel = ui.theme.fg("accent", `[${index + 1}/${total}]`);
const title = cell.title ? ` ${cell.title}` : "";
const metaParts: string[] = [];
if (workdirLabel) {
metaParts.push(ui.theme.fg("dim", workdirLabel));
}
if (cell.durationMs !== undefined) {
metaParts.push(ui.theme.fg("dim", `(${ui.formatDuration(cell.durationMs)})`));
}
const statusLabel = formatCellStatus(cell, ui, spinnerFrame);
if (statusLabel) {
metaParts.push(statusLabel);
}
const meta = metaParts.length > 0 ? ` ${metaParts.join(ui.theme.fg("dim", ui.theme.sep.dot))}` : "";
return `${indexLabel}${title}${meta}`;
}
function formatCellOutputLines(
cell: PythonCellResult,
expanded: boolean,
@@ -808,101 +759,6 @@ function formatCellOutputLines(
return { lines: outputLines, hiddenCount };
}
function renderCellBlock(
cell: PythonCellResult,
index: number,
total: number,
ui: ToolUIKit,
options: {
expanded: boolean;
previewLines: number;
spinnerFrame?: number;
showOutput: boolean;
workdirLabel?: string;
width: number;
bgFn?: (text: string) => string;
},
): string[] {
const { expanded, previewLines, spinnerFrame, showOutput, workdirLabel, width, bgFn } = options;
const h = ui.theme.boxSharp.horizontal;
const v = ui.theme.boxSharp.vertical;
const cap = h.repeat(3);
const border = (text: string) => ui.theme.fg("dim", text);
const lineWidth = Math.max(0, width);
const buildBarLine = (leftChar: string, label?: string): string => {
const left = border(`${leftChar}${cap}`);
if (lineWidth <= 0) return left;
const rawLabel = label ? ` ${label} ` : " ";
const maxLabelWidth = Math.max(0, lineWidth - visibleWidth(left));
const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth, ui.theme.format.ellipsis);
const fillCount = Math.max(0, lineWidth - visibleWidth(left + trimmedLabel));
return `${left}${trimmedLabel}${border(h.repeat(fillCount))}`;
};
const lines: string[] = [];
lines.push(
applyCellBackground(
buildBarLine(ui.theme.boxSharp.topLeft, formatCellHeader(cell, index, total, ui, spinnerFrame, workdirLabel)),
lineWidth,
bgFn,
),
);
const codePrefix = border(`${v} `);
const codeWidth = Math.max(0, lineWidth - visibleWidth(codePrefix));
const codeLines = highlightPythonCode(cell.code);
for (const line of codeLines) {
const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
}
const statusLines = renderStatusEvents(cell.statusEvents ?? [], ui.theme, expanded);
const outputContent = formatCellOutputLines(cell, expanded, previewLines, ui.theme);
const hasOutput = outputContent.lines.length > 0;
const hasStatus = statusLines.length > 0;
const showOutputSection = showOutput && (hasOutput || hasStatus);
if (showOutputSection) {
lines.push(
applyCellBackground(
buildBarLine(ui.theme.boxSharp.teeRight, ui.theme.fg("toolTitle", "Output")),
lineWidth,
bgFn,
),
);
for (const line of outputContent.lines) {
const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
}
if (!expanded && outputContent.hiddenCount > 0) {
const hint = ui.theme.fg(
"dim",
`${ui.theme.format.ellipsis} ${outputContent.hiddenCount} more lines (ctrl+o to expand)`,
);
lines.push(
applyCellBackground(
`${codePrefix}${truncateToWidth(hint, codeWidth, ui.theme.format.ellipsis)}`,
lineWidth,
bgFn,
),
);
}
for (const line of statusLines) {
const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
}
}
const bottomLeft = border(`${ui.theme.boxSharp.bottomLeft}${cap}`);
const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft));
const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}`;
lines.push(applyCellBackground(bottomLine, lineWidth, bgFn));
return lines;
}
export const pythonToolRenderer = {
renderCall(args: PythonRenderArgs, uiTheme: Theme): Component {
const ui = new ToolUIKit(uiTheme);
@@ -937,23 +793,21 @@ export const pythonToolRenderer = {
const lines: string[] = [];
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
const cellResult: PythonCellResult = {
index: i,
title: cell.title,
code: cell.code,
output: "",
status: "pending",
};
lines.push(
...renderCellBlock(cellResult, i, cells.length, ui, {
expanded: true,
previewLines: PYTHON_DEFAULT_PREVIEW_LINES,
showOutput: false,
workdirLabel: i === 0 ? workdirLabel : undefined,
const cellLines = renderCodeCell(
{
code: cell.code,
language: "python",
index: i,
total: cells.length,
title: cell.title,
status: "pending",
width,
bgFn: (text: string) => uiTheme.bg("toolPendingBg", text),
}),
codeMaxLines: PYTHON_DEFAULT_PREVIEW_LINES,
expanded: true,
},
uiTheme,
);
lines.push(...cellLines);
if (i < cells.length - 1) {
lines.push("");
}
@@ -1015,23 +869,37 @@ export const pythonToolRenderer = {
const lines: string[] = [];
for (let i = 0; i < cellResults.length; i++) {
const cell = cellResults[i];
const showOutput = cell.status !== "pending";
const bgColor =
cell.status === "error"
? "toolErrorBg"
: cell.status === "complete"
? "toolSuccessBg"
: "toolPendingBg";
lines.push(
...renderCellBlock(cell, i, cellResults.length, ui, {
expanded,
previewLines,
const statusLines = renderStatusEvents(cell.statusEvents ?? [], uiTheme, expanded);
const outputContent = formatCellOutputLines(cell, expanded, previewLines, uiTheme);
const outputLines = [...outputContent.lines];
if (!expanded && outputContent.hiddenCount > 0) {
outputLines.push(
uiTheme.fg(
"dim",
`${uiTheme.format.ellipsis} ${outputContent.hiddenCount} more lines (ctrl+o to expand)`,
),
);
}
outputLines.push(...statusLines);
const cellLines = renderCodeCell(
{
code: cell.code,
language: "python",
index: i,
total: cellResults.length,
title: cell.title,
status: cell.status,
spinnerFrame: options.spinnerFrame,
showOutput,
duration: cell.durationMs,
output: outputLines.length > 0 ? outputLines.join("\n") : undefined,
outputMaxLines: previewLines,
codeMaxLines: expanded ? Number.POSITIVE_INFINITY : PYTHON_DEFAULT_PREVIEW_LINES,
expanded,
width,
bgFn: (text: string) => uiTheme.bg(bgColor, text),
}),
},
uiTheme,
);
lines.push(...cellLines);
if (i < cellResults.length - 1) {
lines.push("");
}