Files
oh-my-pi/packages/coding-agent/src/edit/renderer.ts
T
can1357 01c34db450 feat(hashline): added full-file hash snapshots with 4-hex tags
- Replaced snapshot internals with full-file records and removed contiguous/sparse snapshot APIs.
- Added file-hash normalization, computed `computeFileHash`, and updated grammar/messages to 4-hex tags.
- Simplified recovery by checking whole-file hashes first, then applying merge-replay fallback after mismatches.
- Updated coding-agent tools to use `record`/`recordFileSnapshot` and skip hash headers for unsnapshotted large files.
- Expanded patcher and snapshot tests to verify 4-hex anchors, hash deduplication, and cache-capped behavior.
2026-05-29 18:12:08 +02:00

653 lines
24 KiB
TypeScript

/**
* Edit tool renderer and LSP batching helpers.
*/
import { HL_FILE_PREFIX } from "@oh-my-pi/hashline";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
import { sanitizeText } from "@oh-my-pi/pi-utils";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { FileDiagnosticsResult } from "../lsp";
import { renderDiff as renderDiffColored } from "../modes/components/diff";
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
import type { OutputMeta } from "../tools/output-meta";
import {
formatDiagnostics,
formatDiffStats,
formatExpandHint,
formatStatusIcon,
formatTitle,
getDiffStats,
getLspBatchRequest,
type LspBatchRequest,
PREVIEW_LIMITS,
replaceTabs,
shortenPath,
truncateDiffByHunk,
} from "../tools/render-utils";
import { fileHyperlink, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import type { EditMode } from "../utils/edit-mode";
import type { DiffError, DiffResult } from "./diff";
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
import type { Operation } from "./modes/patch";
import type { PerFileDiffPreview } from "./streaming";
// ═══════════════════════════════════════════════════════════════════════════
// LSP Batching
// ═══════════════════════════════════════════════════════════════════════════
export { getLspBatchRequest, type LspBatchRequest };
// ═══════════════════════════════════════════════════════════════════════════
// Tool Details Types
// ═══════════════════════════════════════════════════════════════════════════
export interface EditToolPerFileResult {
path: string;
diff: string;
firstChangedLine?: number;
diagnostics?: FileDiagnosticsResult;
op?: Operation;
move?: string;
isError?: boolean;
errorText?: string;
/** TUI-friendly error text. When present, rendered to the user instead of `errorText`.
* Set when the underlying error carries a `displayMessage` (e.g. {@link HashlineMismatchError}). */
displayErrorText?: string;
meta?: OutputMeta;
/** Source-of-truth content before the edit; `undefined` for create operations. */
oldText?: string;
/** Source-of-truth content after the edit; `undefined` for delete operations. */
newText?: string;
}
export interface EditToolDetails {
/** Unified diff of the changes made */
diff: string;
/** Line number of the first change in the new file (for editor navigation) */
firstChangedLine?: number;
/** Diagnostic result (if available) */
diagnostics?: FileDiagnosticsResult;
/** Operation type (patch mode only) */
op?: Operation;
/** New path after move/rename (patch mode only) */
move?: string;
/** Structured output metadata */
meta?: OutputMeta;
/** Per-file results (multi-file edits) */
perFileResults?: EditToolPerFileResult[];
/** Absolute file path for single-file edit results. Required by ACP diff metadata consumers. */
path?: string;
/** Source-of-truth content before the edit; `undefined` for create operations. */
oldText?: string;
/** Source-of-truth content after the edit; `undefined` for delete operations. */
newText?: string;
}
// ═══════════════════════════════════════════════════════════════════════════
// TUI Renderer
// ═══════════════════════════════════════════════════════════════════════════
interface EditRenderArgs {
path?: string;
file_path?: string;
oldText?: string;
newText?: string;
patch?: string;
input?: string;
all?: boolean;
// Patch mode fields
op?: Operation;
rename?: string;
diff?: string;
/**
* Computed preview diff (used when tool args don't include a diff, e.g. hashline mode).
*/
previewDiff?: string;
__partialJson?: string;
// Hashline mode fields
edits?: EditRenderEntry[];
}
type EditRenderEntry = {
path?: string;
rename?: string;
move?: string;
op?: Operation;
};
interface HashlineInputRenderSummary {
entries: Array<{ path: string }>;
}
interface ApplyPatchRenderSummary {
entries: ApplyPatchEntry[];
error?: string;
}
/** Extended context for edit tool rendering */
export interface EditRenderContext {
/** Edit mode resolved by the caller; lets the renderer dispatch without shape-sniffing */
editMode?: EditMode;
/** Pre-computed diff preview (computed before tool executes) */
editDiffPreview?: DiffResult | DiffError;
/** Multi-file streaming diff preview (edits spanning several files) */
perFileDiffPreview?: PerFileDiffPreview[];
/** Raw in-flight edit text shown while a computed diff preview is unavailable */
editStreamingFallback?: string;
/** Function to render diff text with syntax highlighting */
renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
}
const EDIT_STREAMING_PREVIEW_LINES = 12;
const CALL_TEXT_PREVIEW_LINES = 6;
const CALL_TEXT_PREVIEW_WIDTH = 80;
/** Extract file path from an edit entry. */
function filePathFromEditEntry(p: string | undefined): string | undefined {
return p ?? undefined;
}
function decodePartialJsonStringFragment(fragment: string): string {
// Trim a trailing partial escape so JSON.parse sees a well-formed string.
let text = fragment.replace(/\\u[0-9a-fA-F]{0,3}$/, "");
const trailingBackslashes = text.match(/\\+$/)?.[0].length ?? 0;
if (trailingBackslashes % 2 === 1) text = text.slice(0, -1);
try {
return JSON.parse(`"${text}"`) as string;
} catch {
// Streaming fragment isn't a valid JSON string yet; surface it raw rather
// than ad-hoc unescaping that mishandles surrogates and partial escapes.
return text;
}
}
function extractPartialJsonString(partialJson: string | undefined, key: string): string | undefined {
if (!partialJson) return undefined;
const pattern = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`, "u");
const match = pattern.exec(partialJson);
if (!match) return undefined;
return decodePartialJsonStringFragment(match[1]);
}
function getPartialJsonEditPath(args: EditRenderArgs): string | undefined {
return filePathFromEditEntry(extractPartialJsonString(args.__partialJson, "path"));
}
/** Count distinct file paths in an edits array. */
function countEditFiles(edits: EditRenderEntry[]): number {
return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;
}
function countLines(text: string): number {
if (!text) return 0;
return text.split("\n").length;
}
function getOperationTitle(op: Operation | undefined): string {
return op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
}
function formatEditPathDisplay(
rawPath: string,
uiTheme: Theme,
options?: { rename?: string; firstChangedLine?: number },
): string {
let pathDisplay = rawPath
? fileHyperlink(rawPath, uiTheme.fg("accent", shortenPath(rawPath)))
: uiTheme.fg("toolOutput", "…");
if (options?.firstChangedLine) {
pathDisplay += uiTheme.fg("warning", `:${options.firstChangedLine}`);
}
if (options?.rename) {
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(options.rename, uiTheme.fg("accent", shortenPath(options.rename)))}`;
}
return pathDisplay;
}
function formatEditDescription(
rawPath: string,
uiTheme: Theme,
options?: { rename?: string; firstChangedLine?: number },
): { language: string; description: string } {
const language = getLanguageFromPath(rawPath) ?? "text";
const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language));
return {
language,
description: `${icon} ${formatEditPathDisplay(rawPath, uiTheme, options)}`,
};
}
function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string {
const previewLines = sanitizeText(text).split("\n");
let preview = "\n\n";
for (const line of previewLines.slice(0, CALL_TEXT_PREVIEW_LINES)) {
preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line, filePath), CALL_TEXT_PREVIEW_WIDTH))}\n`;
}
if (previewLines.length > CALL_TEXT_PREVIEW_LINES) {
preview += uiTheme.fg("dim", `… ${previewLines.length - CALL_TEXT_PREVIEW_LINES} more lines`);
}
return preview.trimEnd();
}
function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
if (!diff) return "";
// Hunk-aware truncation keeps the change rows themselves visible. Tail-mode
// pins the visible window to the bottom of the diff so newly streamed
// hunks stay on screen as more arrives, instead of leaving the user stuck
// staring at the head of the file while the tail scrolls offscreen.
const {
text: truncatedDiff,
hiddenHunks,
hiddenLines,
} = truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, EDIT_STREAMING_PREVIEW_LINES, { fromTail: true });
let text = "\n\n";
if (hiddenHunks > 0 || hiddenLines > 0) {
const remainder: string[] = [];
if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
}
text += renderDiffColored(truncatedDiff, { filePath: rawPath });
text += uiTheme.fg("dim", `\n(${label})`);
return text;
}
function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
const icon = uiTheme.getLangIcon(language);
if (lineCount !== null) {
return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
}
return uiTheme.fg("dim", `${icon}`);
}
function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme): string {
const parts: string[] = [];
for (const preview of previews) {
if (!preview.diff && !preview.error) continue;
const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
if (preview.error) {
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error, preview.path))}`);
continue;
}
if (preview.diff) {
parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, "preview")}`);
}
}
return parts.join("");
}
function getCallPreview(
args: EditRenderArgs,
rawPath: string,
uiTheme: Theme,
renderContext: EditRenderContext | undefined,
): string {
const multi = renderContext?.perFileDiffPreview;
if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
return formatMultiFileStreamingDiff(multi, uiTheme);
}
if (args.previewDiff) {
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
}
if (args.diff && args.op) {
return formatStreamingDiff(args.diff, rawPath, uiTheme);
}
if (args.diff) {
return renderPlainTextPreview(args.diff, uiTheme, rawPath);
}
if (args.newText || args.patch) {
return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme, rawPath);
}
if (renderContext?.editStreamingFallback) {
return renderContext.editStreamingFallback;
}
return "";
}
const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** End Patch'";
function normalizeHashlineInputPreviewPath(rawPath: string): string {
const trimmed = rawPath.trim();
const hashStart = /#[0-9a-fA-F]{4}$/u.exec(trimmed)?.index;
const withoutHash = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
if (withoutHash.length < 2) return withoutHash;
const first = withoutHash[0];
const last = withoutHash[withoutHash.length - 1];
if ((first === '"' || first === "'") && first === last) {
return withoutHash.slice(1, -1);
}
return withoutHash;
}
function parseHashlineInputPreviewHeader(line: string): string | null {
if (!line.startsWith(HL_FILE_PREFIX)) return null;
// Mirror hashline/input.ts: strip every leading file marker so canonical
// `¶ PATH` headers and stray `¶¶ PATH` / `¶¶¶PATH` runs render clean paths.
let prefixEnd = 0;
while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
const body = line.slice(prefixEnd).trim();
const previewPath = normalizeHashlineInputPreviewPath(body);
return previewPath.length > 0 ? previewPath : null;
}
function getHashlineInputPaths(input: string): string[] {
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
const paths: string[] = [];
for (const rawLine of stripped.split("\n")) {
const line = rawLine.replace(/\r$/, "");
const path = parseHashlineInputPreviewHeader(line);
if (path) paths.push(path);
}
return paths;
}
function getHashlineInputRenderSummary(
args: EditRenderArgs,
editMode: EditMode | undefined,
): HashlineInputRenderSummary | undefined {
if (editMode !== "hashline" || typeof args.input !== "string") {
return undefined;
}
return { entries: getHashlineInputPaths(args.input).map(path => ({ path })) };
}
function getApplyPatchRenderSummary(
args: EditRenderArgs,
isPartial: boolean,
editMode: EditMode | undefined,
): ApplyPatchRenderSummary | undefined {
if (editMode !== undefined && editMode !== "apply_patch") {
return undefined;
}
if (typeof args.input !== "string") {
return undefined;
}
try {
return { entries: expandApplyPatchToEntries({ input: args.input }) };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
if (isPartial && error === MISSING_APPLY_PATCH_END_ERROR) {
return { entries: expandApplyPatchToPreviewEntries({ input: args.input }) };
}
return { entries: [], error };
}
}
function renderDiffSection(
diff: string,
rawPath: string,
expanded: boolean,
uiTheme: Theme,
renderDiffFn: (t: string, o?: { filePath?: string }) => string,
): string {
let text = "";
const diffStats = getDiffStats(diff);
text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${formatDiffStats(
diffStats.added,
diffStats.removed,
diffStats.hunks,
uiTheme,
)}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
const {
text: truncatedDiff,
hiddenHunks,
hiddenLines,
} = expanded
? { text: diff, hiddenHunks: 0, hiddenLines: 0 }
: truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES);
text += `\n\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
const remainder: string[] = [];
if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
text += uiTheme.fg("toolOutput", `\n… (${remainder.join(", ")}) ${formatExpandHint(uiTheme)}`);
}
return text;
}
function wrapEditRendererLine(line: string, width: number): string[] {
if (width <= 0) return [line];
if (line.length === 0) return [""];
const startAnsi = line.match(/^((?:\x1b\[[0-9;]*m)*)/)?.[1] ?? "";
const bodyWithReset = line.slice(startAnsi.length);
const body = bodyWithReset.endsWith("\x1b[39m") ? bodyWithReset.slice(0, -"\x1b[39m".length) : bodyWithReset;
const diffMatch = /^([+\-\s])(\s*\d+)([|│])(.*)$/s.exec(body);
if (!diffMatch) {
return wrapTextWithAnsi(line, width);
}
const [, marker, lineNum, separator, content] = diffMatch;
const prefix = `${marker}${lineNum}${separator}`;
const prefixWidth = visibleWidth(prefix);
const contentWidth = Math.max(1, width - prefixWidth);
const continuationPrefix = `${" ".repeat(Math.max(0, prefixWidth - 1))}${separator}`;
const wrappedContent = wrapTextWithAnsi(content ?? "", contentWidth);
return wrappedContent.map(
(segment, index) => `${startAnsi}${index === 0 ? prefix : continuationPrefix}${segment}\x1b[39m`,
);
}
export const editToolRenderer = {
mergeCallAndResult: true,
renderCall(
args: EditRenderArgs,
options: RenderResultOptions & { renderContext?: EditRenderContext },
uiTheme: Theme,
): Component {
const renderContext = options.renderContext;
const editArgs = args as EditRenderArgs;
const hashlineInputSummary = getHashlineInputRenderSummary(editArgs, renderContext?.editMode);
const applyPatchSummary = getApplyPatchRenderSummary(editArgs, options.isPartial, renderContext?.editMode);
const firstApplyPatchEntry = applyPatchSummary?.entries[0];
const firstHashlineInputEntry = hashlineInputSummary?.entries[0];
// Extract path from first edit entry when top-level path is absent (new schema)
const firstEdit = Array.isArray(editArgs.edits) && editArgs.edits.length > 0 ? editArgs.edits[0] : undefined;
const rawPath =
editArgs.file_path ||
editArgs.path ||
filePathFromEditEntry(firstEdit?.path) ||
getPartialJsonEditPath(editArgs) ||
firstHashlineInputEntry?.path ||
firstApplyPatchEntry?.path ||
"";
const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
const { description } = formatEditDescription(rawPath, uiTheme, { rename });
const spinner =
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
// Show file count hint for multi-file edits
let fileCount = hashlineInputSummary?.entries.length ?? applyPatchSummary?.entries.length ?? 0;
if (Array.isArray(editArgs.edits)) {
fileCount = countEditFiles(editArgs.edits);
}
if (fileCount > 1) {
text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
}
text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
if (applyPatchSummary?.error) {
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), CALL_TEXT_PREVIEW_WIDTH))}`;
}
return new Text(text, 0, 0);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: EditToolDetails; isError?: boolean },
options: RenderResultOptions & { renderContext?: EditRenderContext },
uiTheme: Theme,
args?: EditRenderArgs,
): Component {
const perFileResults = result.details?.perFileResults;
const totalFiles = args?.edits ? countEditFiles(args.edits) : 0;
if (perFileResults && (perFileResults.length > 1 || totalFiles > 1)) {
return renderMultiFileResult(perFileResults, totalFiles, options, uiTheme);
}
return renderSingleFileResult(result, options, uiTheme, args);
},
};
function renderSingleFileResult(
result: {
content: Array<{ type: string; text?: string }>;
details?: EditToolDetails | EditToolPerFileResult;
isError?: boolean;
},
options: RenderResultOptions & { renderContext?: EditRenderContext },
uiTheme: Theme,
args?: EditRenderArgs,
): Component {
const details = result.details;
const isError = result.isError ?? (details && "isError" in details ? details.isError : false);
const firstEdit = args?.edits?.[0];
const hashlineInputSummary = getHashlineInputRenderSummary(args ?? {}, options.renderContext?.editMode);
const firstHashlineInputEntry = hashlineInputSummary?.entries[0];
const rawPath =
args?.file_path ||
args?.path ||
filePathFromEditEntry(firstEdit?.path) ||
(details && "path" in details ? details.path : "") ||
firstHashlineInputEntry?.path ||
"";
const op = args?.op || firstEdit?.op || details?.op;
const rename = args?.rename || firstEdit?.rename || firstEdit?.move || details?.move;
const { language } = formatEditDescription(rawPath, uiTheme, { rename });
const editTextSource = args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch;
const metadataLineCount = editTextSource ? countLines(editTextSource) : null;
const metadataLine = op !== "delete" ? `\n${formatMetadataLine(metadataLineCount, language, uiTheme)}` : "";
const displayErrorText = isError && details && "displayErrorText" in details ? details.displayErrorText : undefined;
const errorText = isError
? displayErrorText ||
(details && "errorText" in details && details.errorText) ||
(result.content?.find(c => c.type === "text")?.text ?? "")
: "";
let cached: RenderCache | undefined;
return {
render(width) {
const { expanded, renderContext } = options;
const editDiffPreview = renderContext?.editDiffPreview;
const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
const key = new Hasher().bool(expanded).u32(width).digest();
if (cached?.key === key) return cached.lines;
const firstChangedLine =
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
(details && !isError ? details.firstChangedLine : undefined);
const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
const header = renderStatusLine(
{
icon: isError ? "error" : "success",
title: getOperationTitle(op),
description,
},
uiTheme,
);
let text = header;
text += metadataLine;
if (isError) {
if (errorText) {
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText, rawPath))}`;
}
} else if (details?.diff) {
text += renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
} else if (editDiffPreview) {
if ("error" in editDiffPreview) {
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath))}`;
} else if (editDiffPreview.diff) {
text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
}
}
if (details?.diagnostics) {
text += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
uiTheme.getLangIcon(getLanguageFromPath(fp)),
);
}
const lines =
width > 0 ? text.split("\n").flatMap(line => wrapEditRendererLine(line, width)) : text.split("\n");
cached = { key, lines };
return lines;
},
invalidate() {
cached = undefined;
},
};
}
function renderMultiFileResult(
perFileResults: EditToolPerFileResult[],
totalFiles: number,
options: RenderResultOptions & { renderContext?: EditRenderContext },
uiTheme: Theme,
): Component {
const fileComponents = perFileResults.map(fileResult =>
renderSingleFileResult({ content: [], details: fileResult, isError: fileResult.isError }, options, uiTheme),
);
const remaining = Math.max(0, totalFiles - perFileResults.length);
let cached: RenderCache | undefined;
return {
render(width) {
const key = new Hasher().bool(options.expanded).u32(width).u32(perFileResults.length).u32(remaining).digest();
if (cached?.key === key) return cached.lines;
const allLines: string[] = [];
for (let i = 0; i < fileComponents.length; i++) {
if (i > 0) {
allLines.push("");
}
allLines.push(...fileComponents[i].render(width));
}
// Show pending indicator for files still being processed
if (remaining > 0) {
if (allLines.length > 0) allLines.push("");
const spinnerFrame = options.spinnerFrame;
const spinner = spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, spinnerFrame) : "";
allLines.push(
renderStatusLine(
{
icon: "pending",
title: "Edit",
description: uiTheme.fg("dim", `${remaining} more file${remaining > 1 ? "s" : ""} pending…`),
},
uiTheme,
),
);
if (spinner) {
// Replace the pending icon with spinner on the last line
allLines[allLines.length - 1] = allLines[allLines.length - 1].replace(/^(?:\x1b\[[^m]*m)*./u, spinner);
}
}
cached = { key, lines: allLines };
return allLines;
},
invalidate() {
cached = undefined;
for (const c of fileComponents) c.invalidate();
},
};
}