Files
oh-my-pi/packages/coding-agent/src/vim/buffer.ts
T
can1357 8bd7310cb4 feat(coding-agent): implemented vim tool-call preview mapping per command
- Added vim tool-call IDs across EventController, UiHelpers, and ToolExecution for per-call preview mapping.
- Extended Vim type and command parsing with update/write, edit, global, yank, and put handling.
- Added Vim engine support for new commands and motions, including gJ, g*, g#, g_, |, and gu/gU/g~.
- Reworked Vim tool flow to cache per-file engine clones, reuse tool details, and stream inserts in chunks.
- Updated vim.md and changelog to document new vim keys, ex aliases, and fixed :global preview behavior.
- Added parser and renderer tests plus a tmp/vim_test.txt fixture for chunked, cursor, and partial-insert cases.
2026-04-13 21:57:33 +02:00

310 lines
9.4 KiB
TypeScript

import { clonePosition, type Position, type VimBufferSnapshot, type VimFingerprint, type VimLoadedFile } from "./types";
function splitText(text: string): string[] {
if (text.length === 0) {
return [""];
}
return text.split("\n");
}
export function snapshotEqual(left: VimBufferSnapshot, right: VimBufferSnapshot): boolean {
if (
left.displayPath !== right.displayPath ||
left.filePath !== right.filePath ||
left.modified !== right.modified ||
left.trailingNewline !== right.trailingNewline ||
left.cursor.line !== right.cursor.line ||
left.cursor.col !== right.cursor.col ||
left.editabilityChecked !== right.editabilityChecked
) {
return false;
}
if (left.baseFingerprint === null || right.baseFingerprint === null) {
if (left.baseFingerprint !== right.baseFingerprint) {
return false;
}
} else if (
left.baseFingerprint.exists !== right.baseFingerprint.exists ||
left.baseFingerprint.size !== right.baseFingerprint.size ||
left.baseFingerprint.mtimeMs !== right.baseFingerprint.mtimeMs ||
left.baseFingerprint.hash !== right.baseFingerprint.hash
) {
return false;
}
if (left.lines.length !== right.lines.length) {
return false;
}
for (let index = 0; index < left.lines.length; index += 1) {
if (left.lines[index] !== right.lines[index]) {
return false;
}
}
return true;
}
export class VimBuffer {
displayPath: string;
filePath: string;
lines: string[];
cursor: Position;
modified: boolean;
trailingNewline: boolean;
baseFingerprint: VimFingerprint | null;
editabilityChecked: boolean;
constructor(input: VimLoadedFile) {
this.displayPath = input.displayPath;
this.filePath = input.absolutePath;
this.lines = input.lines.length > 0 ? [...input.lines] : [""];
this.cursor = { line: 0, col: 0 };
this.modified = false;
this.trailingNewline = input.trailingNewline;
this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
this.editabilityChecked = false;
}
clone(): VimBuffer {
const clone = new VimBuffer({
absolutePath: this.filePath,
displayPath: this.displayPath,
lines: [...this.lines],
trailingNewline: this.trailingNewline,
fingerprint: this.baseFingerprint ? { ...this.baseFingerprint } : null,
});
clone.cursor = clonePosition(this.cursor);
clone.modified = this.modified;
clone.editabilityChecked = this.editabilityChecked;
return clone;
}
createSnapshot(): VimBufferSnapshot {
return {
displayPath: this.displayPath,
filePath: this.filePath,
lines: [...this.lines],
cursor: clonePosition(this.cursor),
modified: this.modified,
trailingNewline: this.trailingNewline,
baseFingerprint: this.baseFingerprint ? { ...this.baseFingerprint } : null,
editabilityChecked: this.editabilityChecked,
};
}
restore(snapshot: VimBufferSnapshot): void {
this.displayPath = snapshot.displayPath;
this.filePath = snapshot.filePath;
this.lines = snapshot.lines.length > 0 ? [...snapshot.lines] : [""];
this.cursor = clonePosition(snapshot.cursor);
this.modified = snapshot.modified;
this.trailingNewline = snapshot.trailingNewline;
this.baseFingerprint = snapshot.baseFingerprint ? { ...snapshot.baseFingerprint } : null;
this.editabilityChecked = snapshot.editabilityChecked;
this.clampCursor();
}
replaceLoadedFile(input: VimLoadedFile): void {
this.displayPath = input.displayPath;
this.filePath = input.absolutePath;
this.lines = input.lines.length > 0 ? [...input.lines] : [""];
this.cursor = { line: 0, col: 0 };
this.modified = false;
this.trailingNewline = input.trailingNewline;
this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
this.editabilityChecked = false;
}
markSaved(input: VimLoadedFile): void {
this.lines = input.lines.length > 0 ? [...input.lines] : [""];
this.modified = false;
this.trailingNewline = input.trailingNewline;
this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
this.clampCursor();
}
lineCount(): number {
return this.lines.length;
}
lastLineIndex(): number {
return Math.max(0, this.lines.length - 1);
}
getLine(line: number): string {
return this.lines[this.clampLine(line)] ?? "";
}
clampLine(line: number): number {
return Math.min(Math.max(line, 0), this.lastLineIndex());
}
clampCol(line: number, col: number): number {
return Math.min(Math.max(col, 0), this.getLine(line).length);
}
setCursor(position: Position): void {
this.cursor = {
line: this.clampLine(position.line),
col: this.clampCol(position.line, position.col),
};
}
clampCursor(): void {
this.setCursor(this.cursor);
}
firstNonBlank(line: number): number {
const content = this.getLine(line);
const index = content.search(/\S/);
return index === -1 ? 0 : index;
}
getText(): string {
return this.lines.join("\n");
}
setText(text: string, trailingNewline = this.trailingNewline): void {
const normalizedText = trailingNewline && text.endsWith("\n") ? text.slice(0, -1) : text;
this.lines = splitText(normalizedText);
this.trailingNewline = trailingNewline;
this.clampCursor();
}
currentOffset(): number {
return this.positionToOffset(this.cursor);
}
positionToOffset(position: Position): number {
const line = this.clampLine(position.line);
const col = this.clampCol(line, position.col);
let offset = 0;
for (let index = 0; index < line; index += 1) {
offset += this.lines[index]!.length + 1;
}
return offset + col;
}
offsetToPosition(offset: number): Position {
const text = this.getText();
const clamped = Math.min(Math.max(offset, 0), text.length);
let remaining = clamped;
for (let line = 0; line < this.lines.length; line += 1) {
const current = this.lines[line]!;
if (remaining <= current.length) {
return { line, col: remaining };
}
remaining -= current.length;
if (line < this.lines.length - 1) {
if (remaining === 0) {
return { line: line + 1, col: 0 };
}
remaining -= 1;
}
}
return { line: this.lastLineIndex(), col: this.getLine(this.lastLineIndex()).length };
}
setCursorFromOffset(offset: number): void {
this.cursor = this.offsetToPosition(offset);
}
replaceOffsets(start: number, end: number, replacement: string, cursorOffset = start + replacement.length): void {
const text = this.getText();
const normalizedStart = Math.min(Math.max(start, 0), text.length);
const normalizedEnd = Math.min(Math.max(end, normalizedStart), text.length);
const nextText = `${text.slice(0, normalizedStart)}${replacement}${text.slice(normalizedEnd)}`;
// getText() omits the trailing-newline marker, so any \n in the
// replacement is content (a line separator), not a file-trailing newline.
// Bypass setText() which would incorrectly strip it.
this.lines = splitText(nextText);
this.clampCursor();
this.setCursorFromOffset(cursorOffset);
}
deleteOffsets(start: number, end: number): string {
const text = this.getText();
const normalizedStart = Math.min(Math.max(start, 0), text.length);
const normalizedEnd = Math.min(Math.max(end, normalizedStart), text.length);
const removed = text.slice(normalizedStart, normalizedEnd);
this.replaceOffsets(normalizedStart, normalizedEnd, "", normalizedStart);
return removed;
}
deleteLines(startLine: number, endLine: number): string[] {
const start = this.clampLine(Math.min(startLine, endLine));
const end = this.clampLine(Math.max(startLine, endLine));
const removed = this.lines.slice(start, end + 1);
this.lines.splice(start, end - start + 1);
if (this.lines.length === 0) {
this.lines = [""];
}
this.setCursor({ line: Math.min(start, this.lastLineIndex()), col: 0 });
if (this.lines.length > 1 || removed.length > 1) {
this.trailingNewline = true;
}
return removed;
}
insertLines(index: number, newLines: string[]): void {
const at = Math.min(Math.max(index, 0), this.lines.length);
const normalized = newLines.length > 0 ? newLines : [""];
this.lines.splice(at, 0, ...normalized);
this.setCursor({ line: at, col: 0 });
this.trailingNewline = true;
}
replaceLine(line: number, content: string): void {
const target = this.clampLine(line);
this.lines[target] = content;
this.setCursor(this.cursor);
}
joinLines(startLine: number, count: number): void {
const start = this.clampLine(startLine);
const end = this.clampLine(start + Math.max(count, 1));
if (start >= end) {
return;
}
const joined = this.lines
.slice(start, end + 1)
.map(line => line.trim())
.join(" ");
this.lines.splice(start, end - start + 1, joined);
this.setCursor({ line: start, col: Math.max(0, joined.length - 1) });
}
indentLines(startLine: number, endLine: number, indentUnit: string, direction: 1 | -1): void {
const start = this.clampLine(Math.min(startLine, endLine));
const end = this.clampLine(Math.max(startLine, endLine));
for (let line = start; line <= end; line += 1) {
const content = this.lines[line] ?? "";
if (direction > 0) {
this.lines[line] = `${indentUnit}${content}`;
continue;
}
if (content.startsWith(indentUnit)) {
this.lines[line] = content.slice(indentUnit.length);
continue;
}
const spaces = content.match(/^ +/)?.[0].length ?? 0;
this.lines[line] = content.slice(Math.min(spaces, indentUnit.length));
}
this.setCursor(this.cursor);
}
getCharacterAtOffset(offset: number): string {
const text = this.getText();
if (offset < 0 || offset >= text.length) {
return "";
}
return text[offset] ?? "";
}
getCharacter(position: Position): string {
return this.getCharacterAtOffset(this.positionToOffset(position));
}
}