Files
oh-my-pi/packages/coding-agent/src/modes/components/read-tool-group.ts
T
can1357 3c1a31062c ux(coding-agent): refined tool renderers with padded status headers
- Updated read-group rendering to use the enabled success mark and omitted duplicate success marks on child files in grouped reads.
- Adjusted find and search renderers to apply tool-title coloring, search icons, and padded layout via createCachedComponent.
- Added tests covering read-group success marking and padded find/search renderer output.
2026-06-07 09:03:09 +02:00

284 lines
9.1 KiB
TypeScript

import type { Component } from "@oh-my-pi/pi-tui";
import { Container, Text } from "@oh-my-pi/pi-tui";
import { InternalUrlRouter } from "../../internal-urls";
import { getLanguageFromPath, theme } from "../../modes/theme/theme";
import { splitPathAndSel } from "../../tools/path-utils";
import { PREVIEW_LIMITS, shortenPath } from "../../tools/render-utils";
import { renderCodeCell } from "../../tui";
import type { ToolExecutionHandle } from "./tool-execution";
/**
* Read calls whose target is resolved through {@link InternalUrlRouter} are
* rendered as full tool executions (not collapsed into the read group) so the
* resolved content is visible. `path` is the canonical arg; `file_path` is the
* legacy alias still tolerated by the read tool schema.
*/
function readArgsTarget(args: unknown): string | undefined {
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
const record = args as Record<string, unknown>;
return typeof record.path === "string"
? record.path
: typeof record.file_path === "string"
? record.file_path
: undefined;
}
export function readArgsHaveTarget(args: unknown): boolean {
return readArgsTarget(args) !== undefined;
}
export function readArgsTargetInternalUrl(args: unknown): boolean {
const target = readArgsTarget(args);
if (!target) return false;
return InternalUrlRouter.instance().canHandle(target);
}
type ReadRenderArgs = {
path?: string;
file_path?: string;
// Legacy field from the old schema; tolerated for rebuilt transcripts.
sel?: string;
};
type ReadToolSuffixResolution = {
from: string;
to: string;
};
type ReadToolResultDetails = {
suffixResolution?: {
from?: string;
to?: string;
};
conflictCount?: number;
};
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;
status: "pending" | "success" | "warning" | "error";
correctedFrom?: string;
contentText?: string;
conflictCount?: number;
};
/** 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;
// A read group accretes entries across multiple assistant completions for as
// long as the run of reads is uninterrupted. While it is the active group it
// must stay in the transcript's repaintable live region — its header line
// re-layouts from `Read <path>` to `Read (N)` + tree as entries arrive, so a
// frozen snapshot taken on a risk terminal would strand the single-entry form
// (see TranscriptContainer / NativeScrollbackLiveRegion). The controller calls
// `finalize()` once the run breaks so the block can commit to native scrollback.
#finalized = false;
constructor(options: ReadToolGroupOptions = {}) {
super();
this.#showContentPreview = options.showContentPreview ?? false;
this.#text = new Text("", 0, 0);
this.addChild(this.#text);
this.#updateDisplay();
}
isTranscriptBlockFinalized(): boolean {
return this.#finalized;
}
finalize(): void {
this.#finalized = true;
}
updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
if (!toolCallId) return;
const basePath = args.file_path || args.path || "";
const rawPath = args.sel ? `${basePath}:${args.sel}` : basePath;
const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
toolCallId,
path: rawPath,
status: "pending",
};
entry.path = rawPath;
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;
}
const conflictCount =
typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined;
entry.conflictCount = conflictCount;
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 statusPrefix = entry.status === "success" ? "" : `${this.#formatStatus(entry.status)} `;
const pathDisplay = this.#formatPath(entry);
lines.push(` ${theme.fg("dim", connector)} ${statusPrefix}${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 a "… N more lines ⟨<key>: Expand⟩" hint.
* When expanded: shows full content.
*/
#addContentPreview(entry: ReadEntry): void {
const lang = getLanguageFromPath(splitPathAndSel(entry.path).path);
const filePath = shortenPath(entry.path);
const correctionSuffix = entry.correctedFrom ? ` (corrected from ${shortenPath(entry.correctedFrom)})` : "";
const title = filePath ? `Read ${filePath}${correctionSuffix}` : "Read";
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.correctedFrom) {
pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(entry.correctedFrom)})`);
}
if (entry.conflictCount && entry.conflictCount > 0) {
const n = entry.conflictCount;
pathDisplay += ` ${theme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
}
return pathDisplay;
}
#formatStatus(status: ReadEntry["status"]): string {
if (status === "success") {
return theme.fg("text", theme.status.enabled);
}
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);
}
}