9e824d9235
- Restructured hashline edit schema from flat op/pos/end/lines fields to nested loc/content objects with discriminated union types. - Replaced operation names (replace_line, replace_range, append_at, prepend_at) with unified loc object patterns supporting line, block, append, and prepend anchors. - Updated content field to accept array of strings or null instead of lines parameter in edit entries. - Refactored edit resolution logic to dispatch on loc shape instead of op enum, simplifying location handling.
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
/**
|
|
* Shared utilities for edit tool TUI rendering.
|
|
*/
|
|
import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
|
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
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,
|
|
PREVIEW_LIMITS,
|
|
replaceTabs,
|
|
shortenPath,
|
|
truncateDiffByHunk,
|
|
} from "../tools/render-utils";
|
|
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
|
|
import type { HashlineToolEdit } from "./index";
|
|
import type { DiffError, DiffResult, Operation } from "./types";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// LSP Batching
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const LSP_BATCH_TOOLS = new Set(["edit", "write"]);
|
|
|
|
export function getLspBatchRequest(toolCall: ToolCallContext | undefined): { id: string; flush: boolean } | undefined {
|
|
if (!toolCall) {
|
|
return undefined;
|
|
}
|
|
const hasOtherWrites = toolCall.toolCalls.some(
|
|
(call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name),
|
|
);
|
|
if (!hasOtherWrites) {
|
|
return undefined;
|
|
}
|
|
const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some(call => LSP_BATCH_TOOLS.has(call.name));
|
|
return { id: toolCall.batchId, flush: !hasLaterWrites };
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Tool Details Types
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
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;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// TUI Renderer
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
interface EditRenderArgs {
|
|
path?: string;
|
|
file_path?: string;
|
|
oldText?: string;
|
|
newText?: string;
|
|
patch?: 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;
|
|
// Hashline mode fields
|
|
edits?: Partial<HashlineToolEdit>[];
|
|
}
|
|
|
|
/** Extended context for edit tool rendering */
|
|
export interface EditRenderContext {
|
|
/** Pre-computed diff preview (computed before tool executes) */
|
|
editDiffPreview?: DiffResult | DiffError;
|
|
/** Function to render diff text with syntax highlighting */
|
|
renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
|
|
}
|
|
|
|
const EDIT_STREAMING_PREVIEW_LINES = 12;
|
|
|
|
function countLines(text: string): number {
|
|
if (!text) return 0;
|
|
return text.split("\n").length;
|
|
}
|
|
|
|
function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
|
|
if (!diff) return "";
|
|
const lines = diff.split("\n");
|
|
const total = lines.length;
|
|
const displayLines = lines.slice(-EDIT_STREAMING_PREVIEW_LINES);
|
|
const hidden = total - displayLines.length;
|
|
let text = "\n\n";
|
|
if (hidden > 0) {
|
|
text += uiTheme.fg("dim", `… (${hidden} earlier lines)\n`);
|
|
}
|
|
text += renderDiffColored(displayLines.join("\n"), { filePath: rawPath });
|
|
text += uiTheme.fg("dim", `\n… (${label})`);
|
|
return text;
|
|
}
|
|
|
|
function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit>[], uiTheme: Theme): string {
|
|
const MAX_EDITS = 4;
|
|
const MAX_DST_LINES = 8;
|
|
let text = "\n\n";
|
|
text += uiTheme.fg("dim", `[${edits.length} hashline edit${edits.length === 1 ? "" : "s"}]`);
|
|
text += "\n";
|
|
let shownEdits = 0;
|
|
let shownDstLines = 0;
|
|
for (const edit of edits) {
|
|
shownEdits++;
|
|
if (shownEdits > MAX_EDITS) break;
|
|
const formatted = formatHashlineEdit(edit);
|
|
text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(formatted.srcLabel), 120));
|
|
text += "\n";
|
|
if (formatted.dst === "") {
|
|
text += uiTheme.fg("dim", truncateToWidth(" (delete)", 120));
|
|
text += "\n";
|
|
continue;
|
|
}
|
|
for (const dstLine of formatted.dst.split("\n")) {
|
|
shownDstLines++;
|
|
if (shownDstLines > MAX_DST_LINES) break;
|
|
text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(`+ ${dstLine}`), 120));
|
|
text += "\n";
|
|
}
|
|
if (shownDstLines > MAX_DST_LINES) break;
|
|
}
|
|
if (edits.length > MAX_EDITS) {
|
|
text += uiTheme.fg("dim", `… (${edits.length - MAX_EDITS} more edits)`);
|
|
}
|
|
if (shownDstLines > MAX_DST_LINES) {
|
|
text += uiTheme.fg("dim", `\n… (${shownDstLines - MAX_DST_LINES} more dst lines)`);
|
|
}
|
|
|
|
return text.trimEnd();
|
|
function formatHashlineEdit(edit: Partial<HashlineToolEdit>): { srcLabel: string; dst: string } {
|
|
if (typeof edit !== "object" || !edit) {
|
|
return { srcLabel: "• (incomplete edit)", dst: "" };
|
|
}
|
|
|
|
const contentLines = Array.isArray(edit.content) ? (edit.content as string[]).join("\n") : "";
|
|
const loc = edit.loc;
|
|
|
|
if (loc === "append" || loc === "prepend") {
|
|
return { srcLabel: `• ${loc} (file-level)`, dst: contentLines };
|
|
}
|
|
if (typeof loc === "object" && loc) {
|
|
if ("block" in loc && typeof loc.block === "object" && loc.block) {
|
|
const rb = loc.block as { pos?: string; end?: string };
|
|
return { srcLabel: `• block ${rb.pos ?? "?"}…${rb.end ?? "?"}`, dst: contentLines };
|
|
}
|
|
if ("line" in loc) {
|
|
return { srcLabel: `• line ${(loc as { line: string }).line}`, dst: contentLines };
|
|
}
|
|
if ("append" in loc) {
|
|
return { srcLabel: `• append ${(loc as { append: string }).append}`, dst: contentLines };
|
|
}
|
|
if ("prepend" in loc) {
|
|
return { srcLabel: `• prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines };
|
|
}
|
|
}
|
|
return { srcLabel: "• (unknown edit)", dst: contentLines };
|
|
}
|
|
}
|
|
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 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;
|
|
}
|
|
|
|
export const editToolRenderer = {
|
|
mergeCallAndResult: true,
|
|
|
|
renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
|
|
const rawPath = args.file_path || args.path || "";
|
|
const filePath = shortenPath(rawPath);
|
|
const editLanguage = getLanguageFromPath(rawPath) ?? "text";
|
|
const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
|
|
let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
|
|
|
|
// Add arrow for move/rename operations
|
|
if (args.rename) {
|
|
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(args.rename))}`;
|
|
}
|
|
|
|
// Show operation type for patch mode
|
|
const opTitle = args.op === "create" ? "Create" : args.op === "delete" ? "Delete" : "Edit";
|
|
const spinner =
|
|
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
|
|
let text = `${formatTitle(opTitle, uiTheme)} ${spinner ? `${spinner} ` : ""}${editIcon} ${pathDisplay}`;
|
|
|
|
// Show streaming preview of diff/content
|
|
if (args.previewDiff) {
|
|
text += formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
|
|
} else if (args.diff && args.op) {
|
|
text += formatStreamingDiff(args.diff, rawPath, uiTheme);
|
|
} else if (args.edits && args.edits.length > 0) {
|
|
text += formatStreamingHashlineEdits(args.edits, uiTheme);
|
|
} else if (args.diff) {
|
|
const previewLines = args.diff.split("\n");
|
|
const maxLines = 6;
|
|
text += "\n\n";
|
|
for (const line of previewLines.slice(0, maxLines)) {
|
|
text += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))}\n`;
|
|
}
|
|
if (previewLines.length > maxLines) {
|
|
text += uiTheme.fg("dim", `… ${previewLines.length - maxLines} more lines`);
|
|
}
|
|
} else if (args.newText || args.patch) {
|
|
const previewLines = (args.newText ?? args.patch ?? "").split("\n");
|
|
const maxLines = 6;
|
|
text += "\n\n";
|
|
for (const line of previewLines.slice(0, maxLines)) {
|
|
text += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))}\n`;
|
|
}
|
|
if (previewLines.length > maxLines) {
|
|
text += uiTheme.fg("dim", `… ${previewLines.length - maxLines} more lines`);
|
|
}
|
|
}
|
|
|
|
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 rawPath = args?.file_path || args?.path || "";
|
|
const filePath = shortenPath(rawPath);
|
|
const editLanguage = getLanguageFromPath(rawPath) ?? "text";
|
|
const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
|
|
|
|
const op = args?.op || result.details?.op;
|
|
const rename = args?.rename || result.details?.move;
|
|
const opTitle = op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
|
|
|
|
// Pre-compute metadata line (static across renders)
|
|
const metadataLine =
|
|
op !== "delete"
|
|
? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), editLanguage, uiTheme)}`
|
|
: "";
|
|
|
|
// Pre-compute error text (static)
|
|
const errorText = result.isError ? (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;
|
|
|
|
// Build path display with line number
|
|
let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
|
|
const firstChangedLine =
|
|
(editDiffPreview && "firstChangedLine" in editDiffPreview
|
|
? editDiffPreview.firstChangedLine
|
|
: undefined) || (result.details && !result.isError ? result.details.firstChangedLine : undefined);
|
|
if (firstChangedLine) {
|
|
pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`);
|
|
}
|
|
|
|
// Add arrow for rename operations
|
|
if (rename) {
|
|
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(rename))}`;
|
|
}
|
|
|
|
const header = renderStatusLine(
|
|
{
|
|
icon: result.isError ? "error" : "success",
|
|
title: opTitle,
|
|
description: `${editIcon} ${pathDisplay}`,
|
|
},
|
|
uiTheme,
|
|
);
|
|
let text = header;
|
|
text += metadataLine;
|
|
|
|
if (result.isError) {
|
|
if (errorText) {
|
|
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText))}`;
|
|
}
|
|
} else if (result.details?.diff) {
|
|
text += renderDiffSection(result.details.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
|
} else if (editDiffPreview) {
|
|
if ("error" in editDiffPreview) {
|
|
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error))}`;
|
|
} else if (editDiffPreview.diff) {
|
|
text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
|
}
|
|
}
|
|
|
|
// Show LSP diagnostics if available
|
|
if (result.details?.diagnostics) {
|
|
text += formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp: string) =>
|
|
uiTheme.getLangIcon(getLanguageFromPath(fp)),
|
|
);
|
|
}
|
|
|
|
const lines =
|
|
width > 0 ? text.split("\n").map(line => truncateToWidth(line, width, Ellipsis.Omit)) : text.split("\n");
|
|
cached = { key, lines };
|
|
return lines;
|
|
},
|
|
invalidate() {
|
|
cached = undefined;
|
|
},
|
|
};
|
|
},
|
|
};
|