Files
oh-my-pi/packages/coding-agent/src/tools/vim.ts
T
can1357 4f85ebe472 fix(coding-agent): corrected gutter marker alignment in diff rendering
- Added `CodeFrameMarker` and `formatCodeFrameLine()` to centralize code-frame gutter formatting.
- Extended diff rendering to preserve `|` and `│` separators, aligning gutter markers and line numbers.
- Reworked AST, grep, hashline, Vim, and diff renderers to use shared line formatting with computed `lineNumberWidth`.
- Updated atom editing flow and tests, including `resolveAtomEntryPaths` migration and new loc-based/edge-case coverage.
- Adjusted benchmark runner early-stop configuration by passing `buildEarlyStop` through prompt collection.
2026-04-26 03:00:51 +02:00

966 lines
32 KiB
TypeScript

import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { extractSegments, sliceWithWidth, Text } from "@oh-my-pi/pi-tui";
import { isEnoent, logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import * as Diff from "diff";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
import vimDescription from "../prompts/tools/vim.md" with { type: "text" };
import { CachedOutputBlock } from "../tui/output-block";
import { renderStatusLine } from "../tui/status-line";
import { VimBuffer } from "../vim/buffer";
import { VimEngine, type VimSaveResult } from "../vim/engine";
import { parseKeySequences } from "../vim/parser";
import {
buildDetails,
computeViewport,
renderVimDetails,
VIM_DEFAULT_VIEWPORT_LINES,
VIM_OPEN_VIEWPORT_LINES,
} from "../vim/render";
import type { VimFingerprint, VimKeyToken, VimLoadedFile, VimToolDetails, VimViewportLine } from "../vim/types";
import { VimInputError } from "../vim/types";
import type { ToolSession } from ".";
import { parseArchivePathCandidates } from "./archive-reader";
import { assertEditableFile } from "./auto-generated-guard";
import { isReadableUrlPath } from "./fetch";
import { normalizePathLikeInput, resolveToCwd } from "./path-utils";
import { enforcePlanModeWrite } from "./plan-mode-guard";
import { formatDiagnostics, replaceTabs } from "./render-utils";
import { isSqliteFile, parseSqlitePathCandidates } from "./sqlite-reader";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
const INTERNAL_URL_PREFIX = /^(agent|artifact|skill|rule|local|mcp):\/\//;
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const vimStepSchema = Type.Object({
kbd: Type.Array(Type.String(), {
description: "vim key sequences",
examples: [["ggdGi"], ["3Go"], ["dd"]],
}),
insert: Type.Optional(
Type.String({
description: "raw text to insert",
examples: ["hello world"],
}),
),
});
const vimSchema = Type.Object({
file: Type.String({ description: "file path", examples: ["src/foo.ts"] }),
steps: Type.Optional(
Type.Array(vimStepSchema, {
description: "editing steps",
}),
),
pause: Type.Optional(
Type.Boolean({
description: "skip auto-save",
}),
),
});
type VimParams = Static<typeof vimSchema>;
type VimStep = Static<typeof vimStepSchema>;
interface VimRenderStep {
kbd?: string[];
insert?: string;
}
export interface VimRenderArgs {
file?: string;
steps?: VimRenderStep[];
pause?: boolean;
__partialJson?: string;
}
function fingerprintEqual(left: VimFingerprint | null, right: VimFingerprint | null): boolean {
if (left === null || right === null) {
return left === right;
}
return (
left.exists === right.exists &&
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.hash === right.hash
);
}
function renderText(text: string): Component {
return new Text(replaceTabs(text), 0, 0);
}
function serializeBufferText(buffer: Pick<VimBuffer, "getText" | "trailingNewline">): string {
return `${buffer.getText()}${buffer.trailingNewline ? "\n" : ""}`;
}
function buildModelDiff(beforeText: string, afterText: string): string | undefined {
if (beforeText === afterText) {
return undefined;
}
const patch = Diff.structuredPatch("", "", beforeText, afterText, "", "", { context: 3 });
const diff = patch.hunks
.flatMap(hunk => [`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`, ...hunk.lines])
.join("\n");
return diff.length > 0 ? diff : undefined;
}
function renderViewportCursor(line: VimViewportLine, styledText: string, uiTheme: Theme): string {
if (!line.isCursor || line.cursorCol === undefined) {
return styledText;
}
const totalWidth = Bun.stringWidth(line.text);
const cursorCol = Math.max(0, Math.min(line.cursorCol, totalWidth));
const cursorSlice = sliceWithWidth(line.text, cursorCol, 1, false);
const replaceWidth = cursorSlice.width;
const afterStart = Math.min(totalWidth, cursorCol + replaceWidth);
const segments = extractSegments(styledText, cursorCol, afterStart, Math.max(0, totalWidth - afterStart), true);
const cursorText = cursorSlice.text.length > 0 ? cursorSlice.text : " ";
const invertedCursor = uiTheme.inverse(cursorText);
const cursorHighlight = invertedCursor === cursorText ? `\x1b[7m${cursorText}\x1b[27m` : invertedCursor;
return `${segments.before}${cursorHighlight}${segments.after}`;
}
function renderViewportLine(line: VimViewportLine, styledText: string, padWidth: number, uiTheme: Theme): string {
const marker = line.isCursor ? ">" : line.isSelected ? "*" : "";
const gutterText = `${marker}${line.line}`.padStart(padWidth + 1, " ");
const gutterStyled = line.isCursor
? uiTheme.fg("accent", gutterText)
: line.isSelected
? uiTheme.fg("warning", gutterText)
: uiTheme.fg("dim", gutterText);
const separator = uiTheme.fg("dim", "│");
return `${gutterStyled}${separator}${renderViewportCursor(line, styledText, uiTheme)}`;
}
function splitTokensBySequence(kbd: string[]): Array<{ sequence: string; tokens: VimKeyToken[] }> {
const groups = new Map<number, VimKeyToken[]>();
for (const token of parseKeySequences(kbd)) {
const group = groups.get(token.sequenceIndex);
if (group) {
group.push(token);
continue;
}
groups.set(token.sequenceIndex, [token]);
}
return kbd.map((sequence, sequenceIndex) => ({ sequence, tokens: groups.get(sequenceIndex) ?? [] }));
}
async function executeKeySequences(
engine: VimEngine,
groups: Array<{ sequence: string; tokens: VimKeyToken[] }>,
commandText: string,
onStep?: () => Promise<void>,
): Promise<void> {
for (let index = 0; index < groups.length; index += 1) {
const group = groups[index]!;
if (group.tokens.length === 0) {
continue;
}
await engine.executeTokens(group.tokens, commandText, onStep);
if (index < groups.length - 1 && engine.inputMode === "insert") {
// Roll back partial changes to prevent buffer corruption across calls.
engine.rollbackPendingInsert();
const nextSeq = groups[index + 1]?.sequence ?? "";
const looksLikeText = nextSeq.length > 0 && /\s/.test(nextSeq) && !/^[:/%]/.test(nextSeq);
let hint =
"Use the insert field for inserted text, or include <Esc> to return to NORMAL mode before the next kbd entry.";
if (looksLikeText) {
hint += ` The next entry (\`${nextSeq.length > 40 ? `${nextSeq.slice(0, 37)}...` : nextSeq}\`) looks like text content — put it in the \`insert\` field instead. For another edit location, add a new \`steps\` entry instead of another kbd entry.`;
}
throw new VimInputError(
`Sequence ${index + 1} (\`${group.sequence}\`) entered INSERT mode — changes rolled back. ${hint}`,
group.tokens[group.tokens.length - 1],
);
}
}
}
// Module-level cache of the last real vim result so renderCall can reuse that
// viewport while the next tool call is still streaming.
let lastVimDetails: VimToolDetails | undefined;
function buildToolDetailsFromEngine(
engine: VimEngine,
viewportLines: number,
preferredStart?: number,
closed = false,
errorLocation?: VimToolDetails["errorLocation"],
statusMessage?: string,
): VimToolDetails {
const cursorLine = engine.buffer.cursor.line + 1;
const cursorCol = engine.buffer.cursor.col + 1;
const viewport = computeViewport(cursorLine, engine.buffer.lineCount(), viewportLines, preferredStart);
const details = buildDetails({
file: engine.buffer.displayPath,
mode: engine.getPublicMode(),
cursor: { line: cursorLine, col: cursorCol },
totalLines: engine.buffer.lineCount(),
modified: engine.buffer.modified,
lines: engine.buffer.lines,
viewport,
selection: engine.getSelection(),
lastCommand: engine.lastCommand,
statusMessage: statusMessage ?? engine.statusMessage,
pendingInput: engine.getPendingInput(),
errorLocation,
closed,
});
details.diagnostics = engine.diagnostics;
return details;
}
function getLastStepInsert(steps: readonly VimStep[] | undefined): string | undefined {
if (!steps || steps.length === 0) {
return undefined;
}
return steps[steps.length - 1]?.insert;
}
function getNormalizedSteps(steps: VimRenderArgs["steps"]): VimStep[] | undefined {
if (!Array.isArray(steps)) {
return undefined;
}
return steps.map(step => ({
kbd: Array.isArray(step?.kbd) ? [...step.kbd] : [],
...(step?.insert !== undefined ? { insert: step.insert } : {}),
}));
}
function getStepsForDisplay(args: VimRenderArgs): VimStep[] | undefined {
const steps = getNormalizedSteps(args.steps);
if (!steps || steps.length === 0) {
return steps;
}
const partialInsert = extractPartialInsert(args.__partialJson);
if (partialInsert === undefined) {
return steps;
}
const lastStep = steps[steps.length - 1]!;
if (lastStep.insert === undefined || partialInsert.length >= lastStep.insert.length) {
lastStep.insert = partialInsert;
}
return steps;
}
function splitInsertIntoChunks(text: string): string[] {
const maxChunkChars = 32;
if (text.length <= maxChunkChars) {
return text.length === 0 ? [] : [text];
}
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
let end = Math.min(start + maxChunkChars, text.length);
if (end < text.length) {
const lastNewline = text.lastIndexOf("\n", end - 1);
if (lastNewline >= start) {
end = lastNewline + 1;
} else {
const lastSpace = Math.max(text.lastIndexOf(" ", end - 1), text.lastIndexOf("\t", end - 1));
if (lastSpace >= start + Math.floor(maxChunkChars / 2)) {
end = lastSpace + 1;
}
}
}
if (end <= start) {
end = Math.min(start + maxChunkChars, text.length);
}
chunks.push(text.slice(start, end));
start = end;
}
return chunks;
}
async function applyInsertWithStreaming(
engine: VimEngine,
text: string,
exitInsertMode: boolean,
onStep?: () => Promise<void>,
): Promise<void> {
const chunks = splitInsertIntoChunks(text);
if (chunks.length === 0) {
await engine.applyLiteralInsert("", exitInsertMode);
return;
}
for (let index = 0; index < chunks.length; index += 1) {
await engine.applyLiteralInsert(chunks[index]!, exitInsertMode && index === chunks.length - 1);
await onStep?.();
}
}
interface ExecuteVimStepsOptions {
pauseLastStep?: boolean;
onKbdStep?: () => Promise<void>;
onInsertStep?: () => Promise<void>;
}
// Auto-reorder line-positioned steps to descending order (bottom-up) when all steps
// are simple `NG<cmd>` patterns and appear in ascending order (top-down). Bottom-up
// ordering is safe for any mix of insert/replace commands because edits at higher
// line numbers never shift lower line numbers.
function autoReorderSteps(steps: readonly VimStep[]): VimStep[] {
if (steps.length < 2) return [...steps];
// Match single kbd entry of `<number>G<cmd>` where cmd enters insert mode
const linePattern = /^(\d+)G(o|O|cc|C|S|s|i|I|a|A)$/;
const parsed: Array<{ line: number; step: VimStep }> = [];
for (const step of steps) {
if (step.kbd.length !== 1) return [...steps];
const match = step.kbd[0]!.match(linePattern);
if (!match) return [...steps];
parsed.push({ line: Number(match[1]), step });
}
// Only reorder if steps are in strictly ascending order (top-down, likely a mistake).
// If already descending, mixed, or equal, the model likely planned the order deliberately.
for (let i = 1; i < parsed.length; i++) {
if (parsed[i]!.line <= parsed[i - 1]!.line) {
return [...steps];
}
}
// Sort by descending line number (bottom-up)
parsed.sort((a, b) => b.line - a.line);
logger.debug("vim: auto-reordered steps to bottom-up", {
original: steps.map(s => s.kbd[0]),
reordered: parsed.map(p => p.step.kbd[0]),
});
return parsed.map(p => p.step);
}
async function executeVimSteps(
engine: VimEngine,
steps: readonly VimStep[],
options: ExecuteVimStepsOptions = {},
): Promise<void> {
// Auto-reorder ascending line-positioned steps to descending (bottom-up)
// to prevent line-shift corruption from top-down edits.
const orderedSteps = autoReorderSteps(steps);
for (let index = 0; index < orderedSteps.length; index += 1) {
if (engine.closed) {
break;
}
const step = orderedSteps[index]!;
const isLast = index === orderedSteps.length - 1;
const hasKbd = step.kbd.some(sequence => sequence.length > 0);
const preservePausedState = !hasKbd && step.insert === undefined && isLast && options.pauseLastStep === true;
if (engine.inputMode === "insert" && (hasKbd || step.insert === undefined) && !preservePausedState) {
engine.rollbackPendingInsert();
}
if (step.kbd.length > 0) {
const commandText = step.kbd.join(" ");
const tokenGroups = splitTokensBySequence(step.kbd);
await executeKeySequences(engine, tokenGroups, commandText, options.onKbdStep);
}
if (!engine.closed && step.insert !== undefined && (step.insert.length > 0 || engine.inputMode === "insert")) {
// Strip trailing newline from insert text — `o`/`O` already create a line boundary,
// so a trailing \n would produce an unwanted blank line.
const normalizedInsert = step.insert.endsWith("\n") ? step.insert.slice(0, -1) : step.insert;
const exitInsertMode = !(isLast && options.pauseLastStep === true);
await applyInsertWithStreaming(engine, normalizedInsert, exitInsertMode, options.onInsertStep);
}
if (!isLast && engine.inputMode === "insert") {
engine.rollbackPendingInsert();
}
}
}
async function statFingerprint(absolutePath: string): Promise<VimFingerprint | null> {
try {
const file = Bun.file(absolutePath);
const stat = await file.stat();
if (!stat.isFile()) {
throw new ToolError(`Not a regular file: ${absolutePath}`);
}
const bytes = await file.bytes();
return {
exists: true,
size: stat.size,
mtimeMs: stat.mtimeMs,
hash: String(Bun.hash(bytes)),
};
} catch (error) {
if (isEnoent(error)) {
return null;
}
throw error;
}
}
async function readTextFile(
absolutePath: string,
): Promise<{ lines: string[]; trailingNewline: boolean; fingerprint: VimFingerprint | null }> {
try {
const file = Bun.file(absolutePath);
const stat = await file.stat();
if (!stat.isFile()) {
throw new ToolError(`Not a regular file: ${absolutePath}`);
}
const bytes = await file.bytes();
for (const byte of bytes) {
if (byte === 0) {
throw new ToolError("Edit tool in vim mode only supports UTF-8 text files in v1");
}
}
const text = utf8Decoder.decode(bytes);
const trailingNewline = text.endsWith("\n");
const body = trailingNewline ? text.slice(0, -1) : text;
return {
lines: body.length === 0 ? [""] : body.split("\n"),
trailingNewline,
fingerprint: {
exists: true,
size: stat.size,
mtimeMs: stat.mtimeMs,
hash: String(Bun.hash(bytes)),
},
};
} catch (error) {
if (isEnoent(error)) {
return {
lines: [""],
trailingNewline: false,
fingerprint: null,
};
}
if (error instanceof TypeError) {
throw new ToolError("Edit tool in vim mode only supports UTF-8 text files in v1");
}
throw error;
}
}
function normalizeTargetPath(inputPath: string, cwd: string): { absolutePath: string; displayPath: string } {
const normalized = normalizePathLikeInput(inputPath);
if (INTERNAL_URL_PREFIX.test(normalized)) {
throw new ToolError("Edit tool in vim mode only supports regular filesystem paths in v1");
}
if (isReadableUrlPath(normalized)) {
throw new ToolError("Edit tool in vim mode only supports local filesystem paths in v1");
}
if (parseArchivePathCandidates(normalized).some(candidate => candidate.archivePath === normalized)) {
throw new ToolError("Edit tool in vim mode does not support archive targets in v1");
}
if (parseSqlitePathCandidates(normalized).some(candidate => candidate.sqlitePath === normalized)) {
throw new ToolError("Edit tool in vim mode does not support SQLite targets in v1");
}
return {
absolutePath: resolveToCwd(normalized, cwd),
displayPath: normalized,
};
}
export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
readonly name = "vim";
readonly label = "Vim";
readonly description: string;
readonly parameters = vimSchema;
readonly concurrency = "exclusive";
#engines = new Map<string, VimEngine>();
#writethrough: WritethroughCallback;
constructor(private readonly session: ToolSession) {
const enableLsp = session.enableLsp ?? true;
const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite");
const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnWrite");
this.#writethrough = enableLsp
? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics })
: writethroughNoop;
this.description = prompt.render(vimDescription);
}
async #loadBuffer(targetPath: string): Promise<VimLoadedFile> {
const { absolutePath, displayPath } = normalizeTargetPath(targetPath, this.session.cwd);
if (await isSqliteFile(absolutePath)) {
throw new ToolError("Edit tool in vim mode does not support SQLite targets in v1");
}
const loaded = await readTextFile(absolutePath);
return {
absolutePath,
displayPath,
lines: loaded.lines,
trailingNewline: loaded.trailingNewline,
fingerprint: loaded.fingerprint,
};
}
async #beforeMutate(buffer: VimBuffer): Promise<void> {
enforcePlanModeWrite(this.session, buffer.displayPath, { op: buffer.baseFingerprint ? "update" : "create" });
if (!buffer.editabilityChecked && buffer.baseFingerprint) {
await assertEditableFile(buffer.filePath, buffer.displayPath);
buffer.editabilityChecked = true;
}
}
async #saveBuffer(buffer: VimBuffer, options?: { force?: boolean }): Promise<VimSaveResult> {
enforcePlanModeWrite(this.session, buffer.displayPath, { op: buffer.baseFingerprint ? "update" : "create" });
if (buffer.baseFingerprint) {
await assertEditableFile(buffer.filePath, buffer.displayPath);
}
if (!options?.force) {
const diskFingerprint = await statFingerprint(buffer.filePath);
if (!fingerprintEqual(buffer.baseFingerprint, diskFingerprint)) {
throw new ToolError("File changed on disk since open; reload with :e! before saving.");
}
}
const content = `${buffer.getText()}${buffer.trailingNewline ? "\n" : ""}`;
const diagnostics = (await this.#writethrough(buffer.filePath, content)) as FileDiagnosticsResult | undefined;
const loaded = await this.#loadBuffer(buffer.displayPath);
return { loaded, diagnostics };
}
#renderFromEngine(
engine: VimEngine,
viewportLines: number,
preferredStart?: number,
closed = false,
errorLocation?: VimToolDetails["errorLocation"],
statusMessage?: string,
modelDiff?: string,
): AgentToolResult<VimToolDetails> {
const details = buildToolDetailsFromEngine(
engine,
viewportLines,
preferredStart,
closed,
errorLocation,
statusMessage,
);
const resultText = modelDiff ? `${renderVimDetails(details)}\n\nDiff:\n${modelDiff}` : renderVimDetails(details);
const builder = toolResult<VimToolDetails>(details).text(resultText);
if (engine.diagnostics) {
builder.diagnostics(engine.diagnostics.summary, engine.diagnostics.messages ?? []);
}
lastVimDetails = details;
return builder.done();
}
#throwWithSnapshot(engine: VimEngine, error: unknown): never {
const location = error instanceof VimInputError ? error.location : undefined;
const statusMessage = error instanceof Error ? error.message : String(error);
const result = this.#renderFromEngine(
engine,
VIM_DEFAULT_VIEWPORT_LINES,
engine.viewportStart,
engine.closed,
location,
statusMessage,
);
const text = result.content.find(block => block.type === "text")?.text ?? statusMessage;
throw new ToolError(text);
}
async execute(
_toolCallId: string,
params: VimParams,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<VimToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<VimToolDetails>> {
return untilAborted(signal, async () => {
// Resolve file path and get-or-create engine for this buffer
const { absolutePath } = normalizeTargetPath(params.file, this.session.cwd);
let engine = this.#engines.get(absolutePath);
let isNewBuffer = false;
if (!engine) {
const loaded = await this.#loadBuffer(params.file);
engine = new VimEngine(new VimBuffer(loaded), {
beforeMutate: buffer => this.#beforeMutate(buffer),
loadBuffer: path => this.#loadBuffer(path),
saveBuffer: (buffer, options) => this.#saveBuffer(buffer, options),
});
engine.viewportStart = 1;
this.#engines.set(absolutePath, engine);
isNewBuffer = true;
} else if (!engine.buffer.modified) {
// Sync fingerprint from disk to handle LSP writethrough reformats
const fp = await statFingerprint(absolutePath);
if (fp) engine.buffer.baseFingerprint = fp;
}
const steps = params.steps;
if (!steps || steps.length === 0) {
// No steps — just show the file viewport
if (isNewBuffer) {
engine.statusMessage = `Opened ${engine.buffer.displayPath}`;
}
return this.#renderFromEngine(engine, VIM_OPEN_VIEWPORT_LINES, engine.viewportStart);
}
const beforeText = serializeBufferText(engine.buffer);
if (this.session.getPlanModeState?.()?.enabled) {
if (steps.some(step => step.insert !== undefined)) {
throw new ToolError("Plan mode: edit is read-only in vim mode; insert payloads are not allowed.");
}
const preview = engine.clone({
beforeMutate: async () => {
throw new VimInputError(
"Plan mode: edit is read-only in vim mode; only navigation, search, open, and close are allowed.",
);
},
saveBuffer: async () => {
throw new VimInputError("Plan mode: :w is not allowed.");
},
});
await executeVimSteps(preview, steps, { pauseLastStep: params.pause === true });
}
try {
const FRAME_INTERVAL_MS = 16; // ~60fps
let lastUpdateTime = 0;
const emitUpdate = onUpdate
? async (force = false) => {
const now = Date.now();
if (!force && now - lastUpdateTime < FRAME_INTERVAL_MS) {
return; // throttle: skip if too soon
}
onUpdate(this.#renderFromEngine(engine, VIM_DEFAULT_VIEWPORT_LINES, engine.viewportStart));
lastUpdateTime = Date.now();
await Bun.sleep(FRAME_INTERVAL_MS); // real delay for terminal to render
}
: undefined;
await executeVimSteps(engine, steps, {
pauseLastStep: params.pause === true,
onKbdStep: emitUpdate ? () => emitUpdate(true) : undefined,
onInsertStep: emitUpdate ? () => emitUpdate(true) : undefined,
});
if (params.pause === true && !engine.closed && engine.getPendingInput()) {
engine.statusMessage = engine.statusMessage ?? `Paused in ${engine.getPublicMode()} mode`;
}
} catch (error) {
this.#throwWithSnapshot(engine, error);
}
if (beforeText !== serializeBufferText(engine.buffer)) {
engine.centerViewportOnCursor();
}
// Auto-save when buffer was modified
if (!engine.closed && engine.buffer.modified && params.pause !== true) {
try {
const result = await this.#saveBuffer(engine.buffer);
engine.buffer.markSaved(result.loaded);
engine.diagnostics = result.diagnostics;
if (beforeText !== serializeBufferText(engine.buffer)) {
engine.centerViewportOnCursor();
}
} catch (error) {
this.#throwWithSnapshot(engine, error);
}
}
const afterText = serializeBufferText(engine.buffer);
const modelDiff = buildModelDiff(beforeText, afterText);
const result = this.#renderFromEngine(
engine,
VIM_DEFAULT_VIEWPORT_LINES,
engine.viewportStart,
engine.closed,
undefined,
undefined,
modelDiff,
);
if (engine.closed) {
this.#engines.delete(absolutePath);
}
return result;
});
}
}
// Unescape JSON string escape sequences from a partial (potentially incomplete) JSON string value.
function unescapePartialJsonString(value: string): string {
let output = "";
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (char !== "\\") {
output += char;
continue;
}
const next = value[index + 1];
if (!next) {
output += "\\";
break;
}
index += 1;
switch (next) {
case '"':
case "\\":
case "/":
output += next;
break;
case "b":
output += "\b";
break;
case "f":
output += "\f";
break;
case "n":
output += "\n";
break;
case "r":
output += "\r";
break;
case "t":
output += "\t";
break;
case "u": {
const codePoint = value.slice(index + 1, index + 5);
if (codePoint.length === 4) {
const parsed = parseInt(codePoint, 16);
if (!Number.isNaN(parsed)) {
output += String.fromCharCode(parsed);
index += 4;
continue;
}
}
output += "\\u";
break;
}
default:
output += `\\${next}`;
}
}
return output;
}
// Extract partial insert text from raw JSON buffer during streaming.
// partial-json often doesn't surface string values until the closing quote is seen.
function extractPartialInsert(partialJson: string | undefined): string | undefined {
if (!partialJson) {
return undefined;
}
const matches = Array.from(partialJson.matchAll(/"insert"\s*:\s*"((?:\\.|[^"\\])*)(?:"|$)/gu));
const match = matches[matches.length - 1];
if (!match) {
return undefined;
}
return unescapePartialJsonString(match[1]!);
}
function describeStepsForDisplay(args: VimRenderArgs): string {
const steps = getStepsForDisplay(args);
if (!steps || steps.length === 0) {
return "";
}
const kbdSummary = steps.map(step => step.kbd.join(" ")).filter(summary => summary.length > 0);
let description = steps.length === 1 ? (kbdSummary[0] ?? "1 step") : `${steps.length} steps`;
if (steps.length > 1 && kbdSummary.length > 0) {
description += ` · ${kbdSummary.join(" → ")}`;
}
const insertText = getLastStepInsert(steps);
if (insertText !== undefined && insertText.length > 0) {
description += `${description.length > 0 ? " · " : ""}insert: ${insertText}`;
}
if (args.pause) {
description += `${description.length > 0 ? " · " : ""}pause`;
}
return description;
}
export function resetVimRendererStateForTest(): void {
lastVimDetails = undefined;
}
export const vimToolRenderer = {
renderCall(args: VimRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
if (args.file && (!args.steps || args.steps.length === 0)) {
return renderText(`${uiTheme.bold("Edit")} open ${args.file}`);
}
// Build a description of the streaming args for the header
const argsDescription = describeStepsForDisplay(args);
// Reuse the last real vim result for the same file while the next call is still streaming.
const details = lastVimDetails?.file === args.file ? lastVimDetails : undefined;
if (details?.viewportLines && details.viewportLines.length > 0) {
const lang = getLanguageFromPath(details.file);
const langIcon = uiTheme.getLangIcon(lang);
const modified = details.modified ? " [+]" : "";
const position = `L${details.cursor.line}:${details.cursor.col}`;
const padWidth = String(details.viewport.end).length;
const viewportLines = details.viewportLines;
const highlightedLines = highlightCode(viewportLines.map(line => line.text).join("\n"), lang);
const renderedLines = viewportLines.map((line, index) =>
renderViewportLine(line, highlightedLines[index] ?? line.text, padWidth, uiTheme),
);
if (details.statusMessage) {
renderedLines.push(uiTheme.fg("dim", details.statusMessage));
}
const outputBlock = new CachedOutputBlock();
let cached: { key: string; result: string[] } | undefined;
return {
render: (width: number): string[] => {
const cacheKey = `${width}|${options.spinnerFrame ?? -1}|${argsDescription}`;
if (cached?.key === cacheKey) {
return cached.result;
}
const header = renderStatusLine(
{
icon: "pending",
spinnerFrame: options.spinnerFrame,
title: "Edit",
description: argsDescription || details.file + modified,
meta: [`${langIcon} ${details.totalLines} lines`, position],
},
uiTheme,
);
const lines = outputBlock.render(
{
header,
state: "pending",
sections: [{ lines: renderedLines }],
width,
},
uiTheme,
);
cached = { key: cacheKey, result: lines };
return lines;
},
invalidate: () => {
cached = undefined;
outputBlock.invalidate();
},
};
}
// Fallback: no previous viewport available (first vim call)
if (argsDescription) {
return renderText(`${uiTheme.bold("Edit")} ${argsDescription}`);
}
return renderText(`${uiTheme.bold("Edit")}`);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
options: RenderResultOptions,
uiTheme: Theme,
): Component {
const details = result.details;
const isError = result.isError === true;
// No structured details (e.g. closed): fall back to plain text
if (!details?.viewportLines || details.viewportLines.length === 0) {
if (details) {
return renderText(renderVimDetails(details));
}
const text = result.content.find(block => block.type === "text")?.text ?? "";
return renderText(text);
}
const lang = getLanguageFromPath(details.file);
const langIcon = uiTheme.getLangIcon(lang);
const modified = details.modified ? " [+]" : "";
const position = `L${details.cursor.line}:${details.cursor.col}`;
const padWidth = String(details.viewport.end).length;
const viewportLines = details.viewportLines;
const highlightedLines = highlightCode(viewportLines.map(line => line.text).join("\n"), lang);
const renderedLines = viewportLines.map((line, index) =>
renderViewportLine(line, highlightedLines[index] ?? line.text, padWidth, uiTheme),
);
if (details.statusMessage) {
renderedLines.push(uiTheme.fg("dim", details.statusMessage));
}
const sections: Array<{ label?: string; lines: string[] }> = [{ lines: renderedLines }];
if (details.diagnostics?.messages && details.diagnostics.messages.length > 0) {
const diagText = formatDiagnostics(
{
errored: isError,
summary: details.diagnostics.summary,
messages: details.diagnostics.messages,
},
options.expanded,
uiTheme,
(filePath: string) => uiTheme.getLangIcon(getLanguageFromPath(filePath)),
);
if (diagText) {
sections.push({ lines: [diagText] });
}
}
const outputBlock = new CachedOutputBlock();
let cached: { key: string; result: string[] } | undefined;
return {
render: (width: number): string[] => {
const cacheKey = `${width}|${options.isPartial ? 1 : 0}|${isError ? 1 : 0}|${options.spinnerFrame ?? -1}`;
if (cached?.key === cacheKey) {
return cached.result;
}
const icon = options.isPartial ? "pending" : isError ? "error" : "success";
// Mode badge
const modeBadge =
details.mode === "NORMAL"
? undefined
: {
label: details.mode,
color:
details.mode === "INSERT"
? ("success" as const)
: details.mode === "VISUAL" || details.mode === "VISUAL-LINE"
? ("warning" as const)
: ("accent" as const),
};
const header = renderStatusLine(
{
icon,
spinnerFrame: options.spinnerFrame,
title: "Edit",
description: details.file + modified,
badge: modeBadge,
meta: [`${langIcon} ${details.totalLines} lines`, position],
},
uiTheme,
);
const lines = outputBlock.render(
{
header,
state: options.isPartial ? "pending" : isError ? "error" : "success",
sections,
width,
},
uiTheme,
);
cached = { key: cacheKey, result: lines };
return lines;
},
invalidate: () => {
cached = undefined;
outputBlock.invalidate();
},
};
},
mergeCallAndResult: true,
};
export { vimSchema };