Files
oh-my-pi/packages/coding-agent/src/modes/components/read-tool-group.ts
T
can1357 7453c10801 feat(coding-agent): added optional inline read preview toggle
- Added a new read.toolResultPreview boolean setting defaulting to false to control inline read result rendering.
- Updated read tool group rendering to show inline previews only when enabled and to hide duplicate summary rows for previewed entries.
- Passed the setting through event and UI helper constructors and added tests for default-off behavior and the duplicate-preview summary case.
2026-04-18 23:37:47 +02:00

236 lines
7.0 KiB
TypeScript

import type { Component } from "@oh-my-pi/pi-tui";
import { Container, Text } from "@oh-my-pi/pi-tui";
import { getLanguageFromPath, theme } from "../../modes/theme/theme";
import { PREVIEW_LIMITS, shortenPath } from "../../tools/render-utils";
import { renderCodeCell } from "../../tui";
import type { ToolExecutionHandle } from "./tool-execution";
type ReadRenderArgs = {
path?: string;
file_path?: string;
sel?: string;
};
type ReadToolSuffixResolution = {
from: string;
to: string;
};
type ReadToolResultDetails = {
suffixResolution?: {
from?: string;
to?: string;
};
};
type ReadToolGroupOptions = {
showContentPreview?: boolean;
};
function getSuffixResolution(details: ReadToolResultDetails | undefined): ReadToolSuffixResolution | undefined {
if (typeof details?.suffixResolution?.from !== "string" || typeof details.suffixResolution.to !== "string") {
return undefined;
}
return { from: details.suffixResolution.from, to: details.suffixResolution.to };
}
type ReadEntry = {
toolCallId: string;
path: string;
sel?: string;
status: "pending" | "success" | "warning" | "error";
correctedFrom?: string;
contentText?: string;
};
/** Number of code lines to show in collapsed preview mode */
const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED;
export class ReadToolGroupComponent extends Container implements ToolExecutionHandle {
#entries = new Map<string, ReadEntry>();
#text: Text;
#expanded = false;
#showContentPreview: boolean;
constructor(options: ReadToolGroupOptions = {}) {
super();
this.#showContentPreview = options.showContentPreview ?? false;
this.#text = new Text("", 0, 0);
this.addChild(this.#text);
this.#updateDisplay();
}
updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
if (!toolCallId) return;
const rawPath = args.file_path || args.path || "";
const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
toolCallId,
path: rawPath,
sel: args.sel,
status: "pending",
};
entry.path = rawPath;
entry.sel = args.sel;
this.#entries.set(toolCallId, entry);
this.#updateDisplay();
}
updateResult(
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
isPartial = false,
toolCallId?: string,
): void {
if (!toolCallId) return;
const entry = this.#entries.get(toolCallId);
if (!entry) return;
if (isPartial) return;
const details = result.details as ReadToolResultDetails | undefined;
const suffixResolution = getSuffixResolution(details);
if (suffixResolution) {
entry.path = suffixResolution.to;
entry.correctedFrom = suffixResolution.from;
} else {
entry.correctedFrom = undefined;
}
entry.status = result.isError ? "error" : suffixResolution ? "warning" : "success";
// Store the text content for preview/expanded display
const textContent = result.content?.find(c => c.type === "text")?.text;
if (textContent !== undefined) {
entry.contentText = textContent;
}
this.#updateDisplay();
}
setArgsComplete(_toolCallId?: string): void {
this.#updateDisplay();
}
setExpanded(expanded: boolean): void {
this.#expanded = expanded;
this.#updateDisplay();
}
getComponent(): Component {
return this;
}
#updateDisplay(): void {
const entries = [...this.#entries.values()];
// Clear previous children and rebuild the summary and preview blocks.
this.clear();
this.#text = new Text("", 0, 0);
if (entries.length === 0) {
this.#text.setText(` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))}`);
this.addChild(this.#text);
return;
}
if (entries.length === 1) {
const entry = entries[0];
if (!this.#shouldRenderPreview(entry)) {
const statusSymbol = this.#formatStatus(entry.status);
const pathDisplay = this.#formatPath(entry);
this.#text.setText(
` ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd(),
);
this.addChild(this.#text);
}
if (this.#shouldRenderPreview(entry)) {
this.#addContentPreview(entry);
}
return;
}
const header = `${theme.fg("toolTitle", theme.bold("Read"))}${theme.fg("dim", ` (${entries.length})`)}`;
const lines = [` ${theme.format.bullet} ${header}`];
const entriesWithoutPreview = entries.filter(entry => !this.#shouldRenderPreview(entry));
const total = entriesWithoutPreview.length;
for (const [index, entry] of entriesWithoutPreview.entries()) {
const connector = index === total - 1 ? theme.tree.last : theme.tree.branch;
const statusSymbol = this.#formatStatus(entry.status);
const pathDisplay = this.#formatPath(entry);
lines.push(` ${theme.fg("dim", connector)} ${statusSymbol} ${pathDisplay}`.trimEnd());
}
this.#text.setText(lines.join("\n"));
this.addChild(this.#text);
for (const entry of entries) {
if (this.#shouldRenderPreview(entry)) {
this.#addContentPreview(entry);
}
}
}
/**
* Add a code-cell content preview below the entry summary.
* When collapsed: shows first COLLAPSED_PREVIEW_LINES lines with "… N more lines (Ctrl+O for more)" hint.
* When expanded: shows full content.
*/
#addContentPreview(entry: ReadEntry): void {
const lang = getLanguageFromPath(entry.path);
const filePath = shortenPath(entry.path);
const selectionSuffix = entry.sel ? `:${entry.sel}` : "";
const correctionSuffix = entry.correctedFrom ? ` (corrected from ${shortenPath(entry.correctedFrom)})` : "";
const title = filePath ? `Read ${filePath}${selectionSuffix}${correctionSuffix}` : `Read${selectionSuffix}`;
let cachedWidth: number | undefined;
let cachedLines: string[] | undefined;
const expanded = this.#expanded;
const component: Component = {
render: (width: number) => {
if (cachedLines && cachedWidth === width) return cachedLines;
cachedLines = renderCodeCell(
{
code: entry.contentText ?? "",
language: lang,
title,
status: entry.status === "success" ? "complete" : entry.status,
expanded,
codeMaxLines: expanded ? undefined : COLLAPSED_PREVIEW_LINES,
width,
},
theme,
);
cachedWidth = width;
return cachedLines;
},
invalidate: () => {
cachedWidth = undefined;
cachedLines = undefined;
},
};
this.addChild(component);
}
#shouldRenderPreview(entry: ReadEntry): boolean {
return this.#showContentPreview && entry.contentText !== undefined;
}
#formatPath(entry: ReadEntry): string {
const filePath = shortenPath(entry.path);
let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…");
if (entry.sel) {
pathDisplay += theme.fg("warning", `:${entry.sel}`);
}
if (entry.correctedFrom) {
pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(entry.correctedFrom)})`);
}
return pathDisplay;
}
#formatStatus(status: ReadEntry["status"]): string {
if (status === "success") {
return theme.fg("success", theme.status.success);
}
if (status === "warning") {
return theme.fg("warning", theme.status.warning);
}
if (status === "error") {
return theme.fg("error", theme.status.error);
}
return theme.fg("dim", theme.status.pending);
}
}