Files
oh-my-pi/packages/coding-agent/src/modes/components/read-tool-group.ts
T

857 lines
28 KiB
TypeScript

import * as path from "node:path";
import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Container, Text } from "@oh-my-pi/pi-tui";
import { InternalUrlRouter, XD_URL_PREFIX } from "../../internal-urls";
import { getLanguageFromPath, theme } from "../../modes/theme/theme";
import { parseLineRanges, selectorLineRanges, splitPathAndSel } from "../../tools/path-utils";
import { PREVIEW_LIMITS, shortenPath } from "../../tools/render-utils";
import { fileHyperlink, renderCodeCell, tryResolveInternalUrlSync } from "../../tui";
import { canonicalizeMessage } from "../../utils/thinking-display";
import type { ToolExecutionHandle } from "./tool-execution";
import { formatUsageRow } from "./usage-row";
/**
* Extract the read call's target path. `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;
}
/**
* Whether a read collapses into the compact {@link ReadToolGroupComponent}
* rather than a full tool execution. Filesystem/external targets always
* collapse; other internal URLs (`skill://`, `agent://`, …) render full so
* their resolved content is visible. `xd://` device reads are the exception —
* they list devices/docs and read better in the compact grouped view.
*/
export function readArgsCollapseIntoGroup(args: unknown): boolean {
const target = readArgsTarget(args);
if (target === undefined) return false;
return target.startsWith(XD_URL_PREFIX) || !InternalUrlRouter.instance().canHandle(target);
}
/**
* Return the collapsed read calls that can own a turn's usage row. Mixed-tool
* turns and visible content after a read keep the standalone row so request
* metrics retain their transcript ordering.
*/
export function groupedReadUsageCallIds(message: AssistantMessage): string[] | undefined {
const toolCallIds: string[] = [];
let sawToolCall = false;
for (const content of message.content) {
if (content.type === "toolCall") {
if (content.name !== "read" || !readArgsCollapseIntoGroup(content.arguments)) return undefined;
sawToolCall = true;
toolCallIds.push(content.id);
continue;
}
if (
sawToolCall &&
(content.type === "image" ||
(content.type === "text" && canonicalizeMessage(content.text)) ||
(content.type === "thinking" && canonicalizeMessage(content.thinking)))
) {
return undefined;
}
}
return toolCallIds.length > 0 ? toolCallIds : undefined;
}
type ReadRenderArgs = {
path?: string;
file_path?: string;
};
type ReadToolSuffixResolution = {
from: string;
to: string;
};
type ReadToolResultDetails = {
resolvedPath?: string;
suffixResolution?: {
from?: string;
to?: string;
};
conflictCount?: number;
displayReadTargets?: unknown;
displayContent?: {
text?: string;
startLine?: number;
lineNumbers?: Array<number | null>;
};
meta?: {
source?: {
type?: string;
value?: 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;
displayPaths?: string[];
linkPath?: string;
status: "pending" | "success" | "warning" | "error";
correctedFrom?: string;
contentText?: string;
conflictCount?: number;
codeStartLine?: number;
codeLineNumbers?: Array<number | null>;
};
type ReadUsageRow = {
toolCallIds: readonly string[];
usage: Usage;
durationMs?: number;
ttftMs?: number;
timestamp?: number;
};
/** Number of code lines to show in collapsed preview mode */
const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED;
type ReadDisplayTarget = {
entry: ReadEntry;
targetPath: string;
basePath: string;
linkPath?: string;
selector?: string;
};
type ReadSummaryRow = {
targetPath: string;
basePath: string;
targets: ReadDisplayTarget[];
};
const READ_STATUS_RANK: Record<ReadEntry["status"], number> = {
success: 0,
pending: 1,
warning: 2,
error: 3,
};
const URL_LIKE_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
function getDisplayReadTargets(details: ReadToolResultDetails | undefined): string[] | undefined {
if (!Array.isArray(details?.displayReadTargets)) return undefined;
const targets = details.displayReadTargets
.filter((target): target is string => typeof target === "string")
.map(target => target.trim())
.filter(target => target.length > 0);
return targets.length > 0 ? targets : undefined;
}
function displayPathWithSuffixResolution(currentPath: string, suffixResolution: ReadToolSuffixResolution): string {
const currentSelector = splitPathAndSel(currentPath).sel;
if (!currentSelector || splitPathAndSel(suffixResolution.to).sel) return suffixResolution.to;
return `${suffixResolution.to}:${currentSelector}`;
}
function readSourceFsPath(details: ReadToolResultDetails | undefined): string | undefined {
const source = details?.meta?.source;
return source?.type === "path" && typeof source.value === "string" ? source.value : undefined;
}
function readResultLinkPath(details: ReadToolResultDetails | undefined): string | undefined {
return typeof details?.resolvedPath === "string" ? details.resolvedPath : readSourceFsPath(details);
}
function readTargetLinkPath(basePath: string, entryLinkPath: string | undefined): string | undefined {
if (entryLinkPath) return entryLinkPath;
const resolvedInternalPath = tryResolveInternalUrlSync(basePath);
if (resolvedInternalPath) return resolvedInternalPath;
return path.isAbsolute(basePath) ? basePath : undefined;
}
function firstSelectorLine(selector: string | undefined): number | undefined {
try {
return selectorLineRanges(selector)?.[0].startLine;
} catch {
return undefined;
}
}
function firstSelectorLineForTargets(targets: ReadDisplayTarget[]): number | undefined {
let line: number | undefined;
for (const target of targets) {
const targetLine = firstSelectorLine(target.selector);
if (targetLine === undefined) continue;
if (line === undefined || targetLine < line) line = targetLine;
}
return line;
}
function linkPathForTargets(targets: ReadDisplayTarget[]): string | undefined {
for (const target of targets) {
if (target.linkPath) return target.linkPath;
}
return undefined;
}
function selectorChunkIsLineRangeList(chunk: string): boolean {
const trimmed = chunk.trim();
if (!trimmed) return false;
try {
return parseLineRanges(trimmed) !== null;
} catch {
return false;
}
}
function nextTopLevelToken(input: string, start: number): string {
let braceDepth = 0;
for (let i = start; i < input.length; i++) {
const ch = input[i];
if (ch === "\\" && i + 1 < input.length) {
i++;
continue;
}
if (ch === "{") {
braceDepth++;
continue;
}
if (ch === "}") {
if (braceDepth > 0) braceDepth--;
continue;
}
if (braceDepth === 0 && (ch === "," || ch === ";")) {
return input.slice(start, i);
}
}
return input.slice(start);
}
function commaContinuesLineRangeSelector(input: string, partStart: number, commaIndex: number): boolean {
const currentPart = input.slice(partStart, commaIndex).trim();
if (!splitPathAndSel(currentPart).sel) return false;
return selectorChunkIsLineRangeList(nextTopLevelToken(input, commaIndex + 1));
}
function splitReadDisplayPathSpecs(rawPath: string): string[] {
const normalized = rawPath.trim();
if (!normalized || URL_LIKE_RE.test(normalized)) return [rawPath];
const parts: string[] = [];
let braceDepth = 0;
let partStart = 0;
for (let i = 0; i < normalized.length; i++) {
const ch = normalized[i];
if (ch === "\\" && i + 1 < normalized.length) {
i++;
continue;
}
if (ch === "{") {
braceDepth++;
continue;
}
if (ch === "}") {
if (braceDepth > 0) braceDepth--;
continue;
}
if (braceDepth !== 0 || (ch !== "," && ch !== ";")) continue;
if (ch === "," && commaContinuesLineRangeSelector(normalized, partStart, i)) continue;
parts.push(normalized.slice(partStart, i).trim());
partStart = i + 1;
}
parts.push(normalized.slice(partStart).trim());
const cleanParts = parts.filter(part => part.length > 0);
if (cleanParts.length <= 1) return [rawPath];
return cleanParts.every(part => splitPathAndSel(part).sel !== undefined) ? cleanParts : [rawPath];
}
function splitSelectorDisplayParts(sel: string | undefined): Array<string | undefined> {
if (!sel) return [undefined];
const chunks = sel.split(":");
if (chunks.length === 1) {
if (!selectorChunkIsLineRangeList(sel) || !sel.includes(",")) return [sel];
return sel
.split(",")
.map(chunk => chunk.trim())
.filter(chunk => chunk.length > 0);
}
if (chunks.length === 2) {
const [left, right] = chunks as [string, string];
const leftIsRange = selectorChunkIsLineRangeList(left);
const rightIsRange = selectorChunkIsLineRangeList(right);
if (leftIsRange && left.includes(",")) {
return left
.split(",")
.map(chunk => chunk.trim())
.filter(chunk => chunk.length > 0)
.map(chunk => `${chunk}:${right}`);
}
if (rightIsRange && right.includes(",")) {
return right
.split(",")
.map(chunk => chunk.trim())
.filter(chunk => chunk.length > 0)
.map(chunk => `${left}:${chunk}`);
}
}
return [sel];
}
function formatMergedSelectorParts(selectors: string[]): string {
if (selectors.length <= 3) return selectors.join(",");
const first = selectors[0]!;
const second = selectors[1]!;
const last = selectors[selectors.length - 1]!;
return `${first},${second},…,${last}`;
}
export class ReadToolGroupComponent extends Container implements ToolExecutionHandle {
#entries = new Map<string, ReadEntry>();
#usageRows = new Map<string, ReadUsageRow>();
#usageBatchByToolCallId = new Map<string, string>();
#text: Text;
#expanded = false;
#toolActivityVisible = true;
#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;
// Forced terminal even with a still-pending entry: the turn ended (abort or
// completion) so no late result is coming. Set via `seal()`.
#sealed = false;
constructor(options: ReadToolGroupOptions = {}) {
super();
this.#showContentPreview = options.showContentPreview ?? false;
this.#text = new Text("", 0, 0);
this.addChild(this.#text);
this.#updateDisplay();
}
override render(width: number): readonly string[] {
if (!this.#toolActivityVisible) return [];
return super.render(width);
}
isTranscriptBlockFinalized(): boolean {
if (this.#sealed) return true;
if (!this.#finalized) return false;
// Closed to new entries, but a still-pending entry means its result is in
// flight — parallel reads can finalize the group (a sibling tool starts and
// breaks the run) before a read's `tool_execution_end` lands. Stay live so
// the late result repaints instead of freezing the pending preview into
// native scrollback on ED3-risk terminals (#issue: stuck "Read <path>").
return !this.#hasPendingEntries();
}
#hasPendingEntries(): boolean {
for (const entry of this.#entries.values()) {
if (entry.status === "pending") return true;
}
return false;
}
finalize(): void {
this.#finalized = true;
}
/**
* Force the group terminal even if an entry never received its result (the
* turn aborted or ended). Lets it freeze and stop pinning the transcript live
* region instead of lingering on a pending preview until the next thaw.
*/
seal(): void {
this.#sealed = true;
}
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,
status: "pending",
};
entry.path = rawPath;
this.#entries.set(toolCallId, entry);
this.#updateDisplay();
}
/**
* Re-key an entry whose streamed tool-call id changed mid-stream (a provider
* rewriting the id across deltas; see EventController's
* `#streamedToolCallIdByIndex`). Preserves row order so a sibling read run is
* not visibly reshuffled, and no-ops when the rename would collide.
*/
renameEntry(oldId: string, newId: string): void {
if (oldId === newId || !newId) return;
const entry = this.#entries.get(oldId);
if (!entry || this.#entries.has(newId)) return;
entry.toolCallId = newId;
const reordered = [...this.#entries].map(([key, value]): [string, ReadEntry] => [
key === oldId ? newId : key,
value,
]);
this.#entries.clear();
for (const [key, value] of reordered) this.#entries.set(key, value);
this.#updateDisplay();
}
/** Remove one call without discarding successful siblings in the shared group. */
removeEntry(toolCallId: string): boolean {
if (!this.#entries.delete(toolCallId)) return this.#entries.size === 0;
this.#updateDisplay();
return this.#entries.size === 0;
}
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);
const displayPaths = getDisplayReadTargets(details);
entry.linkPath = readResultLinkPath(details);
if (suffixResolution) {
entry.path = displayPathWithSuffixResolution(entry.path, suffixResolution);
entry.correctedFrom = suffixResolution.from;
entry.displayPaths = undefined;
} else {
entry.correctedFrom = undefined;
entry.displayPaths = displayPaths;
}
const conflictCount =
typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined;
entry.conflictCount = conflictCount;
entry.status = result.isError ? "error" : suffixResolution ? "warning" : "success";
// Store clean display content for preview/expanded display when the read
// tool provides it; fall back to model-facing text for legacy results.
const displayContent = details?.displayContent;
const textContent = result.content?.find(c => c.type === "text")?.text;
if (displayContent !== undefined || textContent !== undefined) {
entry.contentText = displayContent?.text ?? textContent;
entry.codeStartLine = displayContent?.startLine;
entry.codeLineNumbers = displayContent?.lineNumbers;
}
this.#updateDisplay();
}
/**
* Nest one request's usage beneath the last visible read call from that
* request. Parallel reads share one row rather than duplicating request totals.
*/
attachUsage(
toolCallIds: readonly string[],
usage: Usage,
durationMs?: number,
ttftMs?: number,
timestamp?: number,
): boolean {
const attachedToolCallIds: string[] = [];
let anchorId: string | undefined;
for (const toolCallId of toolCallIds) {
if (!this.#entries.has(toolCallId)) continue;
attachedToolCallIds.push(toolCallId);
anchorId = toolCallId;
}
if (!anchorId) return false;
for (const toolCallId of attachedToolCallIds) {
this.#usageBatchByToolCallId.set(toolCallId, anchorId);
}
this.#usageRows.set(anchorId, { toolCallIds: attachedToolCallIds, usage, durationMs, ttftMs, timestamp });
this.#updateDisplay();
return true;
}
setArgsComplete(_toolCallId?: string): void {
this.#updateDisplay();
}
setExpanded(expanded: boolean): void {
this.#expanded = expanded;
this.#updateDisplay();
}
setToolActivityVisible(visible: boolean): void {
this.#toolActivityVisible = visible;
super.invalidate();
}
getComponent(): Component {
return this;
}
#updateDisplay(): void {
const entries = [...this.#entries.values()];
const displayTargets = this.#displayTargetsForEntries(entries);
const displayRows = this.#buildSummaryRows(displayTargets);
// Clear previous children and rebuild the summary and preview blocks.
this.clear();
this.#text = new Text("", 0, 0);
if (displayRows.length === 0) {
this.#text.setText(` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))}`);
this.addChild(this.#text);
return;
}
if (displayRows.length === 1) {
const row = displayRows[0]!;
if (!this.#shouldRenderPreviewRow(row)) {
const statusSymbol = this.#formatStatus(this.#statusForTargets(row.targets));
const pathDisplay = this.#formatRowPath(row);
const lines = [` ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd()];
const usageRows = this.#usageRowsBySummaryRow(displayRows).get(0) ?? [];
this.#appendUsageRows(lines, usageRows, " ");
this.#text.setText(lines.join("\n"));
this.addChild(this.#text);
}
for (const entry of this.#previewEntriesForRow(row)) {
this.#addContentPreview(entry);
this.#addPreviewUsage(entry);
}
return;
}
const header = `${theme.fg("toolTitle", theme.bold("Read"))}${theme.fg("dim", ` (${displayRows.length})`)}`;
const lines = [` ${theme.format.bullet} ${header}`];
const entriesWithoutPreview = entries.filter(entry => !this.#shouldRenderPreview(entry));
const summaryTargets = this.#displayTargetsForEntries(entriesWithoutPreview);
const rows = this.#buildSummaryRows(summaryTargets);
const usageRowsBySummaryRow = this.#usageRowsBySummaryRow(rows);
for (const [index, row] of rows.entries()) {
this.#appendSummaryRow(lines, row, index, rows.length, usageRowsBySummaryRow.get(index) ?? []);
}
this.#text.setText(lines.join("\n"));
this.addChild(this.#text);
for (const entry of entries) {
if (this.#shouldRenderPreview(entry)) {
this.#addContentPreview(entry);
this.#addPreviewUsage(entry);
}
}
}
#displayTargetsForEntries(entries: ReadEntry[]): ReadDisplayTarget[] {
const targets: ReadDisplayTarget[] = [];
for (const entry of entries) {
const pathSpecs = entry.displayPaths ?? splitReadDisplayPathSpecs(entry.path);
const useEntryLinkPath = pathSpecs.length === 1;
for (const pathSpec of pathSpecs) {
const split = splitPathAndSel(pathSpec);
const linkPath = readTargetLinkPath(split.path, useEntryLinkPath ? entry.linkPath : undefined);
for (const selector of splitSelectorDisplayParts(split.sel)) {
targets.push({
entry,
targetPath: selector ? `${split.path}:${selector}` : pathSpec,
basePath: split.path,
linkPath,
selector,
});
}
}
}
return targets;
}
#buildSummaryRows(targets: ReadDisplayTarget[]): ReadSummaryRow[] {
const selectorTargetsByBasePathAndBatch = new Map<string, Map<string | undefined, ReadDisplayTarget[]>>();
for (const target of targets) {
if (!target.selector) continue;
let targetsByBatch = selectorTargetsByBasePathAndBatch.get(target.basePath);
if (!targetsByBatch) {
targetsByBatch = new Map<string | undefined, ReadDisplayTarget[]>();
selectorTargetsByBasePathAndBatch.set(target.basePath, targetsByBatch);
}
const batchId = this.#usageBatchByToolCallId.get(target.entry.toolCallId);
const existing = targetsByBatch.get(batchId);
if (existing) existing.push(target);
else targetsByBatch.set(batchId, [target]);
}
const mergedTargetsByTarget = new Map<ReadDisplayTarget, ReadDisplayTarget[]>();
for (const [basePath, targetsByBatch] of selectorTargetsByBasePathAndBatch) {
if (!basePath) continue;
for (const groupedTargets of targetsByBatch.values()) {
if (groupedTargets.length <= 1) continue;
for (const target of groupedTargets) {
mergedTargetsByTarget.set(target, groupedTargets);
}
}
}
const emittedMergedTargets = new Set<ReadDisplayTarget[]>();
const rows: ReadSummaryRow[] = [];
for (const target of targets) {
const mergedTargets = mergedTargetsByTarget.get(target);
if (mergedTargets) {
if (!emittedMergedTargets.has(mergedTargets)) {
rows.push({
targetPath: `${target.basePath}:${formatMergedSelectorParts(
mergedTargets
.map(mergedTarget => mergedTarget.selector)
.filter(selector => selector !== undefined),
)}`,
basePath: target.basePath,
targets: mergedTargets,
});
emittedMergedTargets.add(mergedTargets);
}
continue;
}
rows.push({ targetPath: target.targetPath, basePath: target.basePath, targets: [target] });
}
return rows;
}
#appendSummaryRow(
lines: string[],
row: ReadSummaryRow,
index: number,
total: number,
usageRows: ReadUsageRow[],
): void {
const connector = index === total - 1 ? theme.tree.last : theme.tree.branch;
lines.push(` ${theme.fg("dim", connector)} ${this.#formatRow(row)}`.trimEnd());
const connectorWidth = Bun.stringWidth(connector);
const continuation =
index === total - 1
? " ".repeat(connectorWidth)
: `${theme.tree.vertical}${" ".repeat(Math.max(0, connectorWidth - Bun.stringWidth(theme.tree.vertical)))}`;
this.#appendUsageRows(lines, usageRows, ` ${continuation} `);
}
#usageRowsBySummaryRow(rows: ReadSummaryRow[]): Map<number, ReadUsageRow[]> {
const lastRowIndexByToolCallId = new Map<string, number>();
for (const [index, row] of rows.entries()) {
for (const target of row.targets) {
lastRowIndexByToolCallId.set(target.entry.toolCallId, index);
}
}
const usageRowsByIndex = new Map<number, ReadUsageRow[]>();
for (const usageRow of this.#usageRows.values()) {
let lastRowIndex: number | undefined;
for (const toolCallId of usageRow.toolCallIds) {
const index = lastRowIndexByToolCallId.get(toolCallId);
if (index !== undefined && (lastRowIndex === undefined || index > lastRowIndex)) {
lastRowIndex = index;
}
}
if (lastRowIndex === undefined) continue;
const usageRows = usageRowsByIndex.get(lastRowIndex);
if (usageRows) usageRows.push(usageRow);
else usageRowsByIndex.set(lastRowIndex, [usageRow]);
}
return usageRowsByIndex;
}
#appendUsageRows(lines: string[], usageRows: ReadUsageRow[], prefix: string): void {
for (const usageRow of usageRows) {
lines.push(
theme.fg(
"dim",
`${prefix}${formatUsageRow(usageRow.usage, usageRow.durationMs, usageRow.ttftMs, usageRow.timestamp)}`,
),
);
}
}
#formatRow(row: ReadSummaryRow): string {
const status = this.#statusForTargets(row.targets);
const statusPrefix = status === "success" ? "" : `${this.#formatStatus(status)} `;
return `${statusPrefix}${this.#formatRowPath(row)}`;
}
#formatRowPath(row: ReadSummaryRow): string {
return this.#formatPathValue(row.targetPath, {
correctedFrom: this.#correctedFromForTargets(row.targets),
conflictCount: this.#conflictCountForTargets(row.targets),
line: firstSelectorLineForTargets(row.targets),
linkPath: linkPathForTargets(row.targets),
});
}
#statusForTargets(targets: ReadDisplayTarget[]): ReadEntry["status"] {
let status: ReadEntry["status"] = "success";
for (const target of targets) {
if (READ_STATUS_RANK[target.entry.status] > READ_STATUS_RANK[status]) {
status = target.entry.status;
}
}
return status;
}
#correctedFromForTargets(targets: ReadDisplayTarget[]): string | undefined {
for (const target of targets) {
if (target.entry.correctedFrom) return target.entry.correctedFrom;
}
return undefined;
}
#conflictCountForTargets(targets: ReadDisplayTarget[]): number | undefined {
let conflictCount = 0;
for (const target of targets) {
if (target.entry.conflictCount && target.entry.conflictCount > conflictCount) {
conflictCount = target.entry.conflictCount;
}
}
return conflictCount > 0 ? conflictCount : undefined;
}
#previewEntriesForRow(row: ReadSummaryRow): ReadEntry[] {
const entries: ReadEntry[] = [];
const seen = new Set<string>();
for (const target of row.targets) {
if (seen.has(target.entry.toolCallId) || !this.#shouldRenderPreview(target.entry)) continue;
entries.push(target.entry);
seen.add(target.entry.toolCallId);
}
return entries;
}
#shouldRenderPreviewRow(row: ReadSummaryRow): boolean {
return this.#previewEntriesForRow(row).length > 0;
}
#formatPathValue(
value: string,
options: { correctedFrom?: string; conflictCount?: number; line?: number; linkPath?: string } = {},
): string {
const split = splitPathAndSel(value);
const selectorSuffix = split.sel ? `:${split.sel}` : "";
const baseValue = split.sel ? split.path : value;
const filePath = shortenPath(baseValue);
let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…");
if (filePath && options.linkPath) {
const linkOptions = options.line !== undefined ? { line: options.line } : undefined;
pathDisplay = fileHyperlink(options.linkPath, pathDisplay, linkOptions);
}
if (selectorSuffix) {
pathDisplay += theme.fg("accent", selectorSuffix);
}
if (options.correctedFrom) {
pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(options.correctedFrom)})`);
}
pathDisplay += this.#formatConflictBadge(options.conflictCount);
return pathDisplay;
}
#formatConflictBadge(conflictCount: number | undefined): string {
if (!conflictCount || conflictCount <= 0) return "";
const n = conflictCount;
return ` ${theme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
}
/**
* 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 split = splitPathAndSel(entry.path);
const lang = getLanguageFromPath(split.path);
const pathValue = shortenPath(entry.path);
const pathDisplay = pathValue
? this.#formatPathValue(entry.path, {
correctedFrom: entry.correctedFrom,
conflictCount: entry.conflictCount,
line: firstSelectorLine(split.sel),
linkPath: readTargetLinkPath(split.path, entry.linkPath),
})
: "";
const title = pathDisplay ? `Read ${pathDisplay}` : "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,
codeStartLine: entry.codeStartLine,
codeLineNumbers: entry.codeLineNumbers,
width,
},
theme,
);
cachedWidth = width;
return cachedLines;
},
invalidate: () => {
cachedWidth = undefined;
cachedLines = undefined;
},
};
this.addChild(component);
}
#addPreviewUsage(entry: ReadEntry): void {
const usageRow = this.#usageRows.get(entry.toolCallId);
if (!usageRow) return;
this.addChild(
new Text(
theme.fg("dim", formatUsageRow(usageRow.usage, usageRow.durationMs, usageRow.ttftMs, usageRow.timestamp)),
3,
0,
),
);
}
#shouldRenderPreview(entry: ReadEntry): boolean {
return this.#showContentPreview && entry.contentText !== undefined;
}
#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);
}
}