Files
oh-my-pi/packages/coding-agent/src/patch/shared.ts
T
can1357 9e824d9235 feat(coding-agent): restructured hashline edit schema to nested loc/content objects
- 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.
2026-03-23 01:45:35 +01:00

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;
},
};
},
};