feat(coding-agent): removed vim edit mode and migrated configs to hashline

- Removed vim edit mode and automatically map existing vim configurations to hashline mode.
- Deleted VimTool class, VimEngine implementation, and all vim-specific editing logic (2409 lines).
- Removed vim mode from EditMode union type, edit tool strategies, and configuration schemas.
- Deleted vim parser, command handler, buffer manager, and renderer modules.
- Updated documentation and tests to remove vim mode references and add deprecation mapping.
This commit is contained in:
can1357
2026-05-26 13:16:59 +02:00
parent 7f0208ac83
commit ea2f4b2557
21 changed files with 50 additions and 5726 deletions
+1 -1
View File
@@ -298,7 +298,7 @@ Extra conditional behavior:
| `LM_STUDIO_BASE_URL` | Default implicit LM Studio discovery base URL override (`http://127.0.0.1:1234/v1` if unset) |
| `OLLAMA_BASE_URL` | Default implicit Ollama discovery base URL override (`http://127.0.0.1:11434` if unset) |
| `LLAMA_CPP_BASE_URL` | Default implicit Llama.cpp discovery base URL override (`http://127.0.0.1:8080` if unset) |
| `PI_EDIT_VARIANT` | Forces edit tool variant when valid (`patch`, `replace`, `hashline`, `atom`, `vim`, `apply_patch`) |
| `PI_EDIT_VARIANT` | Forces edit tool variant when valid (`patch`, `replace`, `hashline`, `apply_patch`) |
| `PI_FORCE_IMAGE_PROTOCOL` | Forces supported image protocol (`kitty`, `iterm2`/`iterm`, `sixel`, `none`) where used |
| `PI_ALLOW_SIXEL_PASSTHROUGH` | Allows SIXEL passthrough when `PI_FORCE_IMAGE_PROTOCOL=sixel` |
| `PI_NO_PTY` | If `1`, disables interactive PTY path for bash tool |
+1 -2
View File
@@ -42,7 +42,7 @@ Patch language inside `input`:
Anchors come from `read`/`search` output. `read` formats lines as `LINEhh|TEXT` via `formatHashLine` / `formatHashLines` in `packages/coding-agent/src/hashline/hash.ts`; copy only the token left of `|` into op lines.
Other edit modes exist (`replace`, `patch`, `vim`, `apply_patch`) and are selected outside the tool payload by `resolveEditMode()` in `packages/coding-agent/src/utils/edit-mode.ts`. Their schemas are different; this document covers the default hashline mode.
Other edit modes exist (`replace`, `patch`, `apply_patch`) and are selected outside the tool payload by `resolveEditMode()` in `packages/coding-agent/src/utils/edit-mode.ts`. Their schemas are different; this document covers the default hashline mode.
## Outputs
- Single-shot tool result; hashline mode does not use a `resolve` preview/apply handshake.
@@ -98,7 +98,6 @@ Warnings:
- `replace` — exact/fuzzy old/new text replacement (`packages/coding-agent/src/edit/modes/replace.ts`).
- `patch` — structured JSON diff-hunk mode (`packages/coding-agent/src/edit/modes/patch.ts`).
- `apply_patch` — freeform Codex-style `*** Begin Patch` envelope, internally expanded into patch-mode entries (`packages/coding-agent/src/edit/modes/apply-patch.ts`).
- `vim` — persistent modal editing buffer (`packages/coding-agent/src/tools/vim.ts`).
Hashline op examples:
+34
View File
@@ -1,6 +1,40 @@
# Changelog
## [Unreleased]
### Breaking Changes
- The `vim` edit mode option is no longer available; configurations using `edit.mode: vim` will be automatically mapped to `hashline` mode
### Added
- Added file-hash computation and validation for hashline sections to detect stale edits
- Added file-read snapshot caching with multi-snapshot ring per path for recovery from agent's own writes
- Added delete operation (`!`) support to hashline grammar for explicit line deletion
- Added structural bracket/brace balance warnings when deleting lines with unclosed constructs
### Changed
- Mapped deprecated `vim` edit mode settings to `hashline` during config resolution to preserve compatibility
- Updated the edit mode option set to support `replace`, `patch`, `hashline`, and `apply_patch` variants
- Bare `A:` / `A-B:` (no payload, no inline body) now replaces the line/range with a single blank line, symmetric with bare `A↑` / `A↓` inserting a blank line; previously rejected as ambiguous
- Simplified hashline anchor format from `LINE+HASH` to bare `LINE` numbers in edit operations
- Updated hashline file headers to include 4-hex file hash: `¶PATH#HASH` format for anchored edits
- Changed hashline line separator from `|` to `:` in editable output (e.g., `42:content` instead of `42ab|content`)
- Removed per-line hash validation; file-level hash now validates entire section integrity
- Updated read/search output to emit file-hash headers (`¶PATH#HASH`) followed by numbered lines for hashline mode
- Modified hashline grammar to accept optional file hash in headers and removed hash requirements from line anchors
- Changed hashline diff preview format to use `LINE:content` instead of `LINE+HASH|content`
- Updated prompt documentation to reflect new `¶PATH#HASH` header and bare line-number syntax
### Removed
- Removed the `vim` edit mode and all associated interactive Vim buffer editing functionality
- Removed the `VimTool` class and vim tool module from the public API
- Removed the dedicated Vim-style `vim` edit mode and its interactive tooling stack from the edit command path
- Removed the exported `vim` tool module and prompt so interactive Vim buffer editing is no longer available
- Removed per-line hash anchors (2-letter bigram hashes) from hashline format
- Removed `RANGE_INTERIOR_HASH` constant; multi-line ranges no longer use `**` filler
- Removed `HashMismatch` type and hash mismatch error reporting; replaced with file-level validation
## [15.4.0] - 2026-05-26
@@ -1528,7 +1528,7 @@ export const SETTINGS_SCHEMA = {
ui: {
tab: "editing",
label: "Edit Mode",
description: "Select the edit tool variant (replace, patch, hashline, vim, or apply_patch)",
description: "Select the edit tool variant (replace, patch, hashline, or apply_patch)",
},
},
+5 -5
View File
@@ -408,7 +408,7 @@ export class Settings {
/**
* Get the edit variant for a specific model.
* Returns "patch", "replace", "hashline", "vim", "apply_patch", or null (use global default).
* Returns "patch", "replace", "hashline", "apply_patch", or null (use global default).
*/
getEditVariantForModel(model: string | undefined): EditMode | null {
if (!model) return null;
@@ -643,22 +643,22 @@ export class Settings {
}
}
// edit.mode: removed "atom" variant is now "hashline"
// edit.mode: removed "atom" and "vim" variants map back to "hashline"
const editObj = raw.edit as Record<string, unknown> | undefined;
if (editObj) {
if (editObj.mode === "atom") {
if (editObj.mode === "atom" || editObj.mode === "vim") {
editObj.mode = "hashline";
}
const modelVariants = editObj.modelVariants as Record<string, unknown> | undefined;
if (modelVariants && typeof modelVariants === "object" && !Array.isArray(modelVariants)) {
for (const [pattern, variant] of Object.entries(modelVariants)) {
if (variant === "atom") {
if (variant === "atom" || variant === "vim") {
modelVariants[pattern] = "hashline";
}
}
}
}
if (raw["edit.mode"] === "atom") {
if (raw["edit.mode"] === "atom" || raw["edit.mode"] === "vim") {
raw["edit.mode"] = "hashline";
}
+5 -36
View File
@@ -1,6 +1,5 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { prompt } from "@oh-my-pi/pi-utils";
import type * as z from "zod/v4";
import {
executeHashlineSingle,
HashlineMismatchError,
@@ -21,9 +20,7 @@ import hashlineDescription from "../prompts/tools/hashline.md" with { type: "tex
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
import type { ToolSession } from "../tools";
import { VimTool, vimSchema } from "../tools/vim";
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
import type { VimToolDetails } from "../vim/types";
import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
@@ -50,12 +47,9 @@ type TInput =
| typeof replaceEditSchema
| typeof patchEditSchema
| typeof hashlineEditParamsSchema
| typeof vimSchema
| typeof applyPatchSchema;
type VimParams = z.infer<typeof vimSchema>;
type EditParams = ReplaceParams | PatchParams | HashlineParams | VimParams | ApplyPatchParams;
type EditToolResultDetails = EditToolDetails | VimToolDetails;
type EditParams = ReplaceParams | PatchParams | HashlineParams | ApplyPatchParams;
type EditModeDefinition = {
description: (session: ToolSession) => string;
@@ -65,8 +59,8 @@ type EditModeDefinition = {
params: EditParams,
signal: AbortSignal | undefined,
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolResultDetails, TInput>) => void,
) => Promise<AgentToolResult<EditToolResultDetails, TInput>>;
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => Promise<AgentToolResult<EditToolDetails, TInput>>;
};
function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
@@ -284,7 +278,6 @@ export class EditTool implements AgentTool<TInput> {
readonly #fuzzyThreshold: number;
readonly #writethrough: WritethroughCallback;
readonly #editMode?: EditMode;
readonly #vimTool: VimTool;
readonly #pendingDeferredFetches = new Map<string, AbortController>();
constructor(private readonly session: ToolSession) {
@@ -298,7 +291,6 @@ export class EditTool implements AgentTool<TInput> {
this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy);
this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold);
this.#writethrough = createEditWritethrough(session);
this.#vimTool = new VimTool(session);
}
get mode(): EditMode {
@@ -341,9 +333,9 @@ export class EditTool implements AgentTool<TInput> {
_toolCallId: string,
params: EditParams,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<EditToolResultDetails, TInput>,
onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
context?: AgentToolContext,
): Promise<AgentToolResult<EditToolResultDetails, TInput>> {
): Promise<AgentToolResult<EditToolDetails, TInput>> {
const modeDefinition = this.#getModeDefinition();
return modeDefinition.execute(this, params, signal, getLspBatchRequest(context?.toolCall), onUpdate);
}
@@ -460,29 +452,6 @@ export class EditTool implements AgentTool<TInput> {
return executeSinglePathEntries(path, runs, batchRequest, onUpdate);
},
},
vim: {
description: () => this.#vimTool.description,
parameters: vimSchema,
execute: async (
tool: EditTool,
params: EditParams,
signal: AbortSignal | undefined,
_batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolResultDetails, TInput>) => void,
) => {
const handleUpdate = onUpdate
? (partialResult: AgentToolResult<VimToolDetails>) => {
onUpdate(partialResult as AgentToolResult<EditToolResultDetails, TInput>);
}
: undefined;
return (await tool.#vimTool.execute(
"edit",
params as VimParams,
signal,
handleUpdate,
)) as AgentToolResult<EditToolResultDetails, TInput>;
},
},
}[this.mode];
}
+1 -41
View File
@@ -25,10 +25,8 @@ import {
shortenPath,
truncateDiffByHunk,
} from "../tools/render-utils";
import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
import { fileHyperlink, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import type { EditMode } from "../utils/edit-mode";
import type { VimToolDetails } from "../vim/types";
import type { DiffError, DiffResult } from "./diff";
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
import type { Operation } from "./modes/patch";
@@ -127,31 +125,6 @@ interface ApplyPatchRenderSummary {
error?: string;
}
function isVimRenderArgs(args: EditRenderArgs | VimRenderArgs): args is VimRenderArgs {
return (
typeof args === "object" &&
args !== null &&
typeof (args as { file?: unknown }).file === "string" &&
!("path" in args) &&
!("file_path" in args) &&
!("edits" in args)
);
}
function isVimToolDetails(details: unknown): details is VimToolDetails {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return false;
}
const cursor = (details as { cursor?: unknown }).cursor;
const viewportLines = (details as { viewportLines?: unknown }).viewportLines;
return (
typeof (details as { file?: unknown }).file === "string" &&
typeof cursor === "object" &&
cursor !== null &&
Array.isArray(viewportLines)
);
}
/** Extended context for edit tool rendering */
export interface EditRenderContext {
/** Edit mode resolved by the caller; lets the renderer dispatch without shape-sniffing */
@@ -460,16 +433,11 @@ export const editToolRenderer = {
mergeCallAndResult: true,
renderCall(
args: EditRenderArgs | VimRenderArgs,
args: EditRenderArgs,
options: RenderResultOptions & { renderContext?: EditRenderContext },
uiTheme: Theme,
): Component {
const renderContext = options.renderContext;
// Dispatch on the explicit editMode when available; fall back to the
// shape probe for legacy call sites that don't thread renderContext.
if (renderContext?.editMode === "vim" || isVimRenderArgs(args)) {
return vimToolRenderer.renderCall(args as VimRenderArgs, options, uiTheme);
}
const editArgs = args as EditRenderArgs;
const hashlineInputSummary = getHashlineInputRenderSummary(editArgs, renderContext?.editMode);
@@ -514,14 +482,6 @@ export const editToolRenderer = {
uiTheme: Theme,
args?: EditRenderArgs,
): Component {
if (options.renderContext?.editMode === "vim" || isVimToolDetails(result.details)) {
return vimToolRenderer.renderResult(
result as { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
options,
uiTheme,
);
}
const perFileResults = result.details?.perFileResults;
const totalFiles = args?.edits ? countEditFiles(args.edits) : 0;
if (perFileResults && (perFileResults.length > 1 || totalFiles > 1)) {
@@ -523,27 +523,11 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
return "";
},
};
// Vim streaming preview is handled by the existing vimToolRenderer inside
// edit/renderer.ts. The strategy here is a no-op so the registry is total.
const vimStrategy: EditStreamingStrategy<unknown> = {
extractCompleteEdits(args) {
return args;
},
async computeDiffPreview() {
return null;
},
renderStreamingFallback() {
return "";
},
};
export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
replace: replaceStrategy as EditStreamingStrategy<unknown>,
patch: patchStrategy as EditStreamingStrategy<unknown>,
hashline: hashlineStrategy as EditStreamingStrategy<unknown>,
apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
vim: vimStrategy,
};
export { resolveEditMode };
@@ -1,98 +0,0 @@
Vim-style `edit` mode. The tool name stays `edit`; every call requires `file`, and the buffer loads automatically on first use.
- `{"file": "path"}` - view file
- `{"file": "path", "steps": [{"kbd": ["…"], "insert": "…"}]}` - edit file
**Multi-location edits: always edit highest line number first (bottom-up).** Each insert shifts lines below it.
## steps vs kbd vs insert
`steps` = ordered editing steps. Each step runs `kbd`, then optionally types `insert`.
`kbd` = Vim commands only (`dd`, `G`, `o`, `cc`, `gg`, etc.).
`insert` = raw text content to type into the buffer.
`o`/`O` already create a new line — do not start `insert` with `\n`. A trailing `\n` in `insert` adds an extra blank line.
Never put text content in `kbd`. Only Vim keystrokes go there.
- BAD: `{"steps": [{"kbd": ["1Gohello world<Esc>"]}]}`
- BAD: `{"steps": [{"kbd": ["1Go", "hello world"]}]}`
- BAD: `{"steps": [{"kbd": ["1Ao"], "insert": "text"}]}`
- GOOD: `{"steps": [{"kbd": ["1Go"], "insert": "hello world"}]}`
If a step uses `insert`, the last `kbd` entry in that step must leave INSERT mode active (`o`, `O`, `i`, `a`, `A`, `cc`, `C`, `s`, `S`).
Each non-final `kbd` entry inside a step must end in NORMAL mode (add `<Esc>`).
Between steps, the tool auto-exits INSERT mode.
Whitespace in `kbd` is literal. Do not use spaces as separators between keys; `ggdGi` is one sequence, not `ggdG i`.
Common mistake: `Ni` means "insert N copies", NOT "insert at line N". To insert at line N, use `NGo` (below) or `NGO` (above).
## Editing patterns
`NGo` = new line BELOW line N. `NGO` = new line ABOVE line N.
Insert new line after line 3:
```json
{"file": "f.py", "steps": [{"kbd": ["3Go"], "insert": " new line here"}]}
```
Insert new line before line 3:
```json
{"file": "f.py", "steps": [{"kbd": ["3GO"], "insert": " new line here"}]}
```
Replace line N:
```json
{"file": "f.py", "steps": [{"kbd": ["5Gcc"], "insert": " replacement content"}]}
```
Replace entire file. `ggdGi` = go to top, delete all, enter INSERT. Use that exact sequence when rewriting the whole file:
```json
{"file": "f.py", "steps": [{"kbd": ["ggdGi"], "insert": "entire new file content"}]}
```
Multi-location edit — edit **highest line number first** (bottom-up) so inserts don't shift later targets:
```json
{"file": "f.py", "steps": [
{"kbd": ["8Go"], "insert": " print(result)"},
{"kbd": ["3Go"], "insert": "def helper(x):\n return x + 1"}
]}
```
Each `o`/`O` insert adds lines, shifting everything below. Bottom-up order keeps all line numbers valid. Use `\n` within `insert` for multi-line content.
Navigation or search step without insert:
```json
{"file": "f.py", "steps": [{"kbd": ["/pattern<CR>"]}]}
```
Find and replace:
```json
{"file": "f.py", "steps": [{"kbd": [":%s/old/new/g<CR>"]}]}
```
Delete line range:
```json
{"file": "f.py", "steps": [{"kbd": [":3,5d<CR>"]}]}
```
Ex commands always start with `:` and end with `<CR>`. `3,5d` without `:` is NOT an ex command — it is interpreted as normal-mode keystrokes and will fail.
## Undo mistakes
- `{"file": "f.py", "steps": [{"kbd": ["u"]}]}` - undo last change
- `{"file": "f.py", "steps": [{"kbd": ["3u"]}]}` - undo last 3 changes
`:e!` reloads from disk. Warning: because non-paused calls auto-save, `:e!` reloads your last saved state, not the original file. Use `u` to undo instead. If stuck, use `ggdGi` with the full desired file content.
## Session persistence
The edit buffer in vim mode persists across tool calls. Cursor position, undo history, and file state are maintained until you close the buffer. Auto-save happens once after all steps in a non-paused call complete.
## Supported
Keys: `<Esc>` `<CR>` `<BS>` `<Tab>` `<C-d>` `<C-u>` `<C-r>` `<C-w>` `<C-o>`
Motions: `h j k l <Space> w b e 0 $ ^ + - _ gg G { } f F t T % H M L ; ,` with counts
Operators: `d c y p` with motions and text objects (`iw aw ip ap i" a" i( a( i{ a{`)
Insert: `i a o O I A cc C s S R` - these all enter INSERT mode; do not add another `i` after them
Visual: `v V` with `d y c > < ~ r u U p P o J`
Other: `.` repeat, `u`/`<C-r>` undo/redo, `/pattern<CR>` search, `n N * #`, `gv` `gJ` `gU` `gu` `ZZ` `ZQ`
Ex: `:w` `:q` `:wq` `:e` `:e!` `:N` `:s///` `:%s///` `:N,Md` `:%d` `:N,Mt N` `:sort` `:j` `:j!` `:g/pattern/d` `:v/pattern/d`
Addresses: absolute line numbers, `.`, `$`, and `+N`/`-N` relative offsets, including ranges like `:.,$d` and `:.+2,$g/pattern/d`
More ex: `:up` `:N,My` `:put` `:put!` `:N,Mco $` `:N,Mm $`
-1
View File
@@ -91,7 +91,6 @@ export * from "./search";
export * from "./search-tool-bm25";
export * from "./ssh";
export * from "./todo-write";
export * from "./vim";
export * from "./write";
export * from "./yield";
-949
View File
@@ -1,949 +0,0 @@
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 * as Diff from "diff";
import * as z from "zod/v4";
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 = z.object({
kbd: z.array(z.string()).describe("vim key sequences"),
insert: z.string().optional().describe("raw text to insert"),
});
const vimSchema = z.object({
file: z.string().describe("file path"),
steps: z.array(vimStepSchema).optional().describe("editing steps"),
pause: z.boolean().optional().describe("skip auto-save"),
});
type VimParams = z.infer<typeof vimSchema>;
type VimStep = z.infer<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 };
+1 -2
View File
@@ -1,6 +1,6 @@
import { $env } from "@oh-my-pi/pi-utils";
export type EditMode = "replace" | "patch" | "hashline" | "vim" | "apply_patch";
export type EditMode = "replace" | "patch" | "hashline" | "apply_patch";
export const DEFAULT_EDIT_MODE: EditMode = "hashline";
@@ -9,7 +9,6 @@ const EDIT_MODE_IDS = {
hashline: "hashline",
patch: "patch",
replace: "replace",
vim: "vim",
} as const satisfies Record<string, EditMode>;
export const EDIT_MODES = Object.keys(EDIT_MODE_IDS) as EditMode[];
-309
View File
@@ -1,309 +0,0 @@
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));
}
}
-382
View File
@@ -1,382 +0,0 @@
import type { VimExCommand, VimLineRange } from "./types";
import { VimInputError } from "./types";
export interface VimExParseContext {
currentLine: number;
lastLine: number;
}
interface ParsedLineAddress {
line: number;
nextIndex: number;
}
function clampLine(line: number, context: VimExParseContext): number {
return Math.min(Math.max(line, 1), Math.max(1, context.lastLine));
}
function readDigits(raw: string, start: number): { digits: string; nextIndex: number } {
let index = start;
let digits = "";
while (index < raw.length) {
const char = raw[index] ?? "";
if (!/^\d$/.test(char)) {
break;
}
digits += char;
index += 1;
}
return { digits, nextIndex: index };
}
function parseLineAddress(
raw: string,
start: number,
context: VimExParseContext,
relativeBase = context.currentLine,
): ParsedLineAddress | undefined {
let index = start;
let line: number | undefined;
const first = raw[index] ?? "";
if (/^\d$/.test(first)) {
const { digits, nextIndex } = readDigits(raw, index);
line = Number.parseInt(digits, 10);
index = nextIndex;
} else if (first === ".") {
line = context.currentLine;
index += 1;
} else if (first === "$") {
line = context.lastLine;
index += 1;
} else if (first === "+" || first === "-") {
line = relativeBase;
} else {
return undefined;
}
while (index < raw.length) {
const sign = raw[index];
if (sign !== "+" && sign !== "-") {
break;
}
index += 1;
const { digits, nextIndex } = readDigits(raw, index);
index = nextIndex;
const offset = digits.length > 0 ? Number.parseInt(digits, 10) : 1;
line += sign === "+" ? offset : -offset;
}
return { line: clampLine(line, context), nextIndex: index };
}
function parseLineRange(raw: string, context?: VimExParseContext): { range?: VimLineRange | "all"; rest: string } {
if (raw.startsWith("%")) {
return { range: "all", rest: raw.slice(1).trimStart() };
}
if (!context) {
const match = raw.match(/^(\d+)(?:\s*,\s*(\d+))?/);
if (!match) {
return { rest: raw };
}
const start = Number.parseInt(match[1] ?? "", 10);
const end = Number.parseInt(match[2] ?? match[1] ?? "", 10);
return {
range: { start, end },
rest: raw.slice(match[0].length).trimStart(),
};
}
const first = parseLineAddress(raw, 0, context);
if (!first) {
return { rest: raw };
}
let index = first.nextIndex;
while (raw[index] === " ") {
index += 1;
}
const separator = raw[index];
if (separator !== "," && separator !== ";") {
return {
range: { start: first.line, end: first.line },
rest: raw.slice(index).trimStart(),
};
}
index += 1;
while (raw[index] === " ") {
index += 1;
}
const second = parseLineAddress(raw, index, context, separator === ";" ? first.line : context.currentLine);
if (!second) {
throw new VimInputError(`Missing line address after ${separator}`);
}
return {
range: { start: first.line, end: second.line },
rest: raw.slice(second.nextIndex).trimStart(),
};
}
function parseDelimitedSegments(raw: string): { pattern: string; replacement: string; flags: string } {
if (raw.length === 0) {
throw new VimInputError("Missing substitute delimiter");
}
const delimiter = raw[0] ?? "/";
const segments: string[] = [];
let current = "";
let escaped = false;
for (let index = 1; index < raw.length; index += 1) {
const char = raw[index] ?? "";
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
current += char;
continue;
}
if (char === delimiter && segments.length < 2) {
segments.push(current);
current = "";
continue;
}
current += char;
}
if (segments.length !== 2) {
throw new VimInputError("Substitute command must look like :s/pattern/replacement/flags");
}
return {
pattern: segments[0] ?? "",
replacement: segments[1] ?? "",
flags: current.trim(),
};
}
function parseDestination(raw: string, context?: VimExParseContext): number {
const trimmed = raw.trim();
if (trimmed.length === 0) {
throw new VimInputError("Missing destination");
}
if (/^\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10);
}
if (context) {
const address = parseLineAddress(trimmed, 0, context);
if (address && trimmed.slice(address.nextIndex).trim().length === 0) {
return address.line;
}
}
const destination = Number.parseInt(trimmed, 10);
if (Number.isNaN(destination)) {
throw new VimInputError("Invalid destination");
}
return destination;
}
function matchGlobalCommand(rest: string): { pattern: string; command: string; invert: boolean } | undefined {
const globalMatch = rest.match(/^(g|v|g!|global|global!|vglobal)\s*([/|#])(.+?)\2(.*)$/);
if (!globalMatch) {
return undefined;
}
return {
invert: globalMatch[1] === "v" || globalMatch[1] === "vglobal" || globalMatch[1]?.endsWith("!") === true,
pattern: globalMatch[3] ?? "",
command: (globalMatch[4] ?? "d").trim() || "d",
};
}
function matchDestinationCommand(rest: string, prefixes: readonly string[]): string | undefined {
for (const prefix of prefixes) {
if (!rest.startsWith(prefix)) {
continue;
}
const suffix = rest.slice(prefix.length);
if (suffix.length === 0) {
return "";
}
if (/^\s/.test(suffix) || /^[\d.$+-]/.test(suffix)) {
return suffix.trim();
}
}
return undefined;
}
export function parseExCommand(input: string, context?: VimExParseContext): VimExCommand {
const trimmed = input.trim();
const normalized = trimmed.startsWith(":") ? trimmed.slice(1).trimStart() : trimmed;
if (normalized.length === 0) {
throw new VimInputError("Empty ex command");
}
if (/^\d+$/.test(normalized)) {
return {
kind: "goto-line",
line: Number.parseInt(normalized, 10),
};
}
if (normalized === "w" || normalized === "write") {
return { kind: "write", force: false };
}
if (normalized === "w!" || normalized === "write!") {
return { kind: "write", force: true };
}
if (normalized === "update" || normalized === "up") {
return { kind: "update", force: false };
}
if (normalized === "update!" || normalized === "up!") {
return { kind: "update", force: true };
}
if (normalized === "wq" || normalized === "x" || normalized === "xit" || normalized === "exit") {
return { kind: "write-quit", force: false };
}
if (normalized === "wq!" || normalized === "x!" || normalized === "xit!" || normalized === "exit!") {
return { kind: "write-quit", force: true };
}
if (normalized === "q" || normalized === "quit") {
return { kind: "quit", force: false };
}
if (normalized === "q!" || normalized === "quit!") {
return { kind: "quit", force: true };
}
if (normalized === "e" || normalized === "edit") {
return { kind: "edit", force: false };
}
if (normalized === "e!" || normalized === "edit!") {
return { kind: "edit", force: true };
}
if (normalized.startsWith("e ") || normalized.startsWith("edit ")) {
const path = normalized.startsWith("edit ") ? normalized.slice(5).trim() : normalized.slice(2).trim();
return { kind: "edit", force: false, path };
}
if (normalized.startsWith("e! ") || normalized.startsWith("edit! ")) {
const path = normalized.startsWith("edit! ") ? normalized.slice(6).trim() : normalized.slice(3).trim();
return { kind: "edit", force: true, path };
}
const global = matchGlobalCommand(normalized);
if (global) {
return { kind: "global", ...global };
}
const { range, rest } = parseLineRange(normalized, context);
if (range && rest.length === 0) {
if (range === "all") {
throw new VimInputError(":% requires a following command");
}
return {
kind: "goto-line",
line: range.start,
};
}
const rangedGlobal = matchGlobalCommand(rest);
if (rangedGlobal) {
return { kind: "global", range, ...rangedGlobal };
}
if (rest === "sort" || rest.startsWith("sort ") || rest.startsWith("sort!")) {
const flags = rest.slice(4).trim();
return { kind: "sort", range: range ?? undefined, flags };
}
if (rest === "j" || rest === "join" || rest === "j!" || rest === "join!") {
return { kind: "join", range: range ?? undefined, trimWhitespace: !rest.endsWith("!") };
}
if (rest.startsWith("substitute")) {
const segments = parseDelimitedSegments(rest.slice("substitute".length));
return {
kind: "substitute",
range,
pattern: segments.pattern,
replacement: segments.replacement,
flags: segments.flags,
};
}
if (/^s(?:\W|$)/.test(rest)) {
const segments = parseDelimitedSegments(rest.slice(1));
return {
kind: "substitute",
range,
pattern: segments.pattern,
replacement: segments.replacement,
flags: segments.flags,
};
}
if (
rest === "d" ||
rest === "del" ||
rest === "delete" ||
rest.startsWith("d ") ||
rest.startsWith("del ") ||
rest.startsWith("delete ")
) {
return {
kind: "delete",
range,
};
}
if (
rest === "y" ||
rest === "ya" ||
rest === "yank" ||
rest.startsWith("y ") ||
rest.startsWith("ya ") ||
rest.startsWith("yank ")
) {
return {
kind: "yank",
range,
};
}
if (rest === "pu" || rest === "put" || rest === "pu!" || rest === "put!") {
return {
kind: "put",
range,
before: rest.endsWith("!"),
};
}
const copyDestination = matchDestinationCommand(rest, ["copy", "co", "t"]);
if (copyDestination !== undefined) {
const destination = parseDestination(copyDestination, context);
return { kind: "copy", range, destination };
}
const moveDestination = matchDestinationCommand(rest, ["move", "mo", "m"]);
if (moveDestination !== undefined) {
const destination = parseDestination(moveDestination, context);
return { kind: "move", range, destination };
}
if (rest === "a" || rest === "append" || rest.startsWith("a ") || rest.startsWith("append ")) {
const text = rest.startsWith("append") ? rest.slice(6).trimStart() : rest.slice(1).trimStart();
return { kind: "append", range: range === "all" ? undefined : range, text };
}
if (rest === "i" || rest === "insert" || rest.startsWith("i ") || rest.startsWith("insert ")) {
const text = rest.startsWith("insert") ? rest.slice(6).trimStart() : rest.slice(1).trimStart();
return { kind: "insert-before", range: range === "all" ? undefined : range, text };
}
throw new VimInputError(`Unsupported ex command: ${input}.`);
}
File diff suppressed because it is too large Load Diff
-134
View File
@@ -1,134 +0,0 @@
import { VimInputError as VimError, type VimKeyToken } from "./types";
const SPECIAL_KEYS = new Map<string, string>([
["esc", "Esc"],
["escape", "Esc"],
["cr", "CR"],
["enter", "CR"],
["return", "CR"],
["bs", "BS"],
["backspace", "BS"],
["tab", "Tab"],
["c-d", "C-d"],
["c-u", "C-u"],
["c-r", "C-r"],
["c-w", "C-w"],
["c-o", "C-o"],
]);
function normalizeSpecialKey(raw: string): string | undefined {
return SPECIAL_KEYS.get(raw.trim().toLowerCase());
}
function toDisplayToken(value: string): string {
switch (value) {
case " ":
return "<Space>";
default:
return value.length === 1 ? value : `<${value}>`;
}
}
export function parseKeySequences(sequences: string[]): VimKeyToken[] {
const tokens: VimKeyToken[] = [];
for (let sequenceIndex = 0; sequenceIndex < sequences.length; sequenceIndex += 1) {
const sequence = sequences[sequenceIndex] ?? "";
for (let offset = 0; offset < sequence.length; offset += 1) {
const char = sequence[offset] ?? "";
// Handle literal escape byte (\x1b / \u001b)
if (char === "\x1b") {
tokens.push({
value: "Esc",
display: "<Esc>",
sequenceIndex,
offset,
});
continue;
}
// Handle literal carriage return
if (char === "\r") {
tokens.push({
value: "CR",
display: "<CR>",
sequenceIndex,
offset,
});
continue;
}
// Handle escaped sequences: \r → CR, \e → Esc, \n → newline, \t → Tab
if (char === "\\" && offset + 1 < sequence.length) {
const next = sequence[offset + 1];
if (next === "r") {
tokens.push({ value: "CR", display: "\\r", sequenceIndex, offset });
offset += 1;
continue;
}
if (next === "e") {
tokens.push({ value: "Esc", display: "\\e", sequenceIndex, offset });
offset += 1;
continue;
}
if (next === "n") {
tokens.push({ value: "\n", display: "\\n", sequenceIndex, offset });
offset += 1;
continue;
}
if (next === "t") {
tokens.push({ value: "Tab", display: "\\t", sequenceIndex, offset });
offset += 1;
continue;
}
}
if (char !== "<") {
tokens.push({
value: char,
display: toDisplayToken(char),
sequenceIndex,
offset,
});
continue;
}
const close = sequence.indexOf(">", offset + 1);
if (close === -1) {
throw new VimError(`Unterminated special key in sequence ${sequenceIndex + 1}`, {
value: char,
display: char,
sequenceIndex,
offset,
});
}
const rawSpecial = sequence.slice(offset + 1, close);
const special = normalizeSpecialKey(rawSpecial);
if (!special) {
throw new VimError(`Unknown special key <${rawSpecial}> in sequence ${sequenceIndex + 1}`, {
value: rawSpecial,
display: `<${rawSpecial}>`,
sequenceIndex,
offset,
});
}
tokens.push({
value: special,
display: `<${rawSpecial}>`,
sequenceIndex,
offset,
});
offset = close;
}
}
return tokens;
}
export function replayTokens(values: readonly string[]): VimKeyToken[] {
return values.map((value, index) => ({
value,
display: toDisplayToken(value),
sequenceIndex: 0,
offset: index,
}));
}
-252
View File
@@ -1,252 +0,0 @@
import { extractSegments } from "@oh-my-pi/pi-tui";
import { formatCodeFrameLine, truncateToWidth } from "../tools/render-utils";
import type {
VimErrorLocation,
VimFocusLine,
VimMode,
VimPendingInput,
VimSelection,
VimToolDetails,
VimViewport,
VimViewportLine,
} from "./types";
export const VIM_OPEN_VIEWPORT_LINES = 80;
export const VIM_DEFAULT_VIEWPORT_LINES = 10;
const VIM_TAB_DISPLAY = "→";
const VIM_INLINE_CURSOR = "▏";
const VIM_VIEWPORT_WIDTH = 140;
const VIM_FOCUS_WIDTH = 100;
interface ViewportRenderInput {
file: string;
mode: VimMode;
cursor: { line: number; col: number };
totalLines: number;
modified: boolean;
lines: string[];
viewport: VimViewport;
selection?: VimSelection;
statusMessage?: string;
lastCommand?: string;
pendingInput?: VimPendingInput;
errorLocation?: VimErrorLocation;
closed?: boolean;
}
function renderHeader(details: Pick<VimToolDetails, "file" | "modified" | "mode" | "cursor" | "totalLines">): string {
const modified = details.modified ? "[+]" : "[ ]";
return `${details.file} ${modified} ${details.mode} L${details.cursor.line}:${details.cursor.col} (${details.totalLines} lines)`;
}
function selectionContainsLine(selection: VimSelection | undefined, lineNumber: number): boolean {
if (!selection) {
return false;
}
return lineNumber >= selection.start.line && lineNumber <= selection.end.line;
}
function visibleWidthForChar(char: string): number {
return char === "\t" ? VIM_TAB_DISPLAY.length : Math.max(1, Bun.stringWidth(char));
}
function renderVisibleText(input: string): string {
let output = "";
for (const char of input) {
output += char === "\t" ? VIM_TAB_DISPLAY : char;
}
return output;
}
function renderedColumnForRawColumn(input: string, rawCol: number): number {
let column = 0;
let index = 0;
for (const char of input) {
if (index >= rawCol) {
break;
}
column += visibleWidthForChar(char);
index += 1;
}
return column;
}
function cropVisibleText(text: string, startCol: number, width: number): { text: string; startCol: number } {
if (text.length <= width) {
return { text, startCol: 0 };
}
const maxStart = Math.max(0, text.length - width);
const clampedStart = Math.max(0, Math.min(startCol, maxStart));
let window = text.slice(clampedStart, clampedStart + width);
if (clampedStart > 0 && window.length > 0) {
window = `…${window.slice(1)}`;
}
if (clampedStart + width < text.length && window.length > 0) {
window = `${window.slice(0, -1)}…`;
}
return { text: window, startCol: clampedStart };
}
function buildFocusLine(lineNumber: number, rawText: string, rawCursorCol: number): VimFocusLine {
const visibleText = renderVisibleText(rawText);
const caretCol = renderedColumnForRawColumn(rawText, rawCursorCol);
const desiredStart = Math.max(0, caretCol - Math.floor(VIM_FOCUS_WIDTH / 2));
const cropped = cropVisibleText(visibleText, desiredStart, VIM_FOCUS_WIDTH);
return {
line: lineNumber,
text: cropped.text,
windowStartCol: cropped.startCol + 1,
windowEndCol: cropped.startCol + cropped.text.length,
caretCol: Math.max(0, caretCol - cropped.startCol),
};
}
function buildViewportLines(
input: Pick<ViewportRenderInput, "lines" | "viewport" | "cursor" | "selection">,
): VimViewportLine[] {
const lines: VimViewportLine[] = [];
for (let lineNumber = input.viewport.start; lineNumber <= input.viewport.end; lineNumber += 1) {
const rawText = input.lines[lineNumber - 1] ?? "";
const visibleText = renderVisibleText(rawText);
const isCursor = lineNumber === input.cursor.line;
if (isCursor) {
const cursorCol = renderedColumnForRawColumn(rawText, input.cursor.col - 1);
const desiredStart = Math.max(0, cursorCol - Math.floor(VIM_VIEWPORT_WIDTH / 2));
const cropped = cropVisibleText(visibleText, desiredStart, VIM_VIEWPORT_WIDTH);
lines.push({
line: lineNumber,
text: cropped.text,
isCursor: true,
isSelected: selectionContainsLine(input.selection, lineNumber),
cursorCol: Math.max(0, cursorCol - cropped.startCol),
});
continue;
}
lines.push({
line: lineNumber,
text: truncateToWidth(visibleText, VIM_VIEWPORT_WIDTH),
isCursor: false,
isSelected: selectionContainsLine(input.selection, lineNumber),
});
}
return lines;
}
export function computeViewport(
cursorLine: number,
totalLines: number,
size: number,
preferredStart?: number,
): VimViewport {
const lineCount = Math.max(totalLines, 1);
const clampedSize = Math.max(1, Math.min(size, lineCount));
const maxStart = Math.max(1, lineCount - clampedSize + 1);
const centered = Math.max(1, Math.min(cursorLine - Math.floor(clampedSize / 2), maxStart));
let start = preferredStart ? Math.max(1, Math.min(preferredStart, maxStart)) : centered;
const end = Math.min(lineCount, start + clampedSize - 1);
if (cursorLine < start) {
start = cursorLine;
}
if (cursorLine > end) {
start = Math.max(1, cursorLine - clampedSize + 1);
}
return {
start,
end: Math.min(lineCount, start + clampedSize - 1),
};
}
function formatPendingInput(pending: VimPendingInput | undefined): string | undefined {
if (!pending) {
return undefined;
}
if (pending.kind === "insert") {
return "Pending: INSERT mode";
}
const prefix = pending.kind === "command" ? ":" : pending.kind === "search-forward" ? "/" : "?";
return `Pending: ${prefix}${truncateToWidth(renderVisibleText(pending.text), 80)}`;
}
function renderPlainViewportCursor(line: VimViewportLine): string {
if (!line.isCursor || line.cursorCol === undefined) {
return line.text;
}
const totalWidth = Bun.stringWidth(line.text);
const cursorCol = Math.max(0, Math.min(line.cursorCol, totalWidth));
const segments = extractSegments(line.text, cursorCol, cursorCol, Math.max(0, totalWidth - cursorCol), true);
return `${segments.before}${VIM_INLINE_CURSOR}${segments.after}`;
}
export function renderVimDetails(details: VimToolDetails): string {
const lines: string[] = [renderHeader(details)];
// Explicit cursor position indicator (models miss it in header)
lines.push(`[CURSOR] Line ${details.cursor.line}, Column ${details.cursor.col} (of ${details.totalLines} lines)`);
if (details.lastCommand) {
lines.push(`Command: ${truncateToWidth(details.lastCommand, 80)}`);
}
if (details.statusMessage) {
lines.push(`Status: ${details.statusMessage}`);
}
if (details.errorLocation) {
lines.push(
`Error location: sequence ${details.errorLocation.sequenceIndex + 1}, token ${details.errorLocation.offset + 1}`,
);
}
const pending = formatPendingInput(details.pendingInput);
if (pending) {
lines.push(pending);
}
if (details.closed) {
return lines.join("\n");
}
if (details.focus) {
const focusPrefix = formatCodeFrameLine(">", details.focus.line, "", String(details.viewport.end).length);
const caretPrefix = `${" ".repeat(focusPrefix.length)} `;
const caretPadding = " ".repeat(Math.max(0, details.focus.caretCol));
lines.push("Focus:");
lines.push(`${focusPrefix}${details.focus.text}`);
lines.push(`${caretPrefix}${caretPadding}^`);
}
if (details.viewportLines && details.viewportLines.length > 0) {
const padWidth = String(details.viewport.end).length;
lines.push("Viewport:");
for (const line of details.viewportLines) {
const marker = line.isCursor ? ">" : line.isSelected ? "*" : " ";
lines.push(formatCodeFrameLine(marker, line.line, renderPlainViewportCursor(line), padWidth));
}
}
return lines.join("\n");
}
export function buildDetails(input: ViewportRenderInput): VimToolDetails {
const details: VimToolDetails = {
file: input.file,
mode: input.mode,
cursor: input.cursor,
totalLines: input.totalLines,
modified: input.modified,
viewport: input.viewport,
selection: input.selection,
lastCommand: input.lastCommand,
statusMessage: input.statusMessage,
pendingInput: input.pendingInput,
errorLocation: input.errorLocation,
closed: input.closed,
};
if (!input.closed) {
details.focus = buildFocusLine(input.cursor.line, input.lines[input.cursor.line - 1] ?? "", input.cursor.col - 1);
details.viewportLines = buildViewportLines(input);
}
return details;
}
-197
View File
@@ -1,197 +0,0 @@
import type { FileDiagnosticsResult } from "../lsp";
import type { OutputMeta } from "../tools/output-meta";
export type VimMode = "NORMAL" | "INSERT" | "VISUAL" | "VISUAL-LINE" | "COMMAND";
export type VimInputMode =
| "normal"
| "insert"
| "visual"
| "visual-line"
| "command"
| "search-forward"
| "search-backward";
export interface Position {
line: number;
col: number;
}
export interface VimViewport {
start: number;
end: number;
}
export interface VimSelection {
kind: "char" | "line";
start: Position;
end: Position;
}
export interface VimFocusLine {
line: number;
text: string;
windowStartCol: number;
windowEndCol: number;
caretCol: number;
}
export interface VimViewportLine {
line: number;
text: string;
isCursor: boolean;
isSelected: boolean;
cursorCol?: number;
}
export interface VimPendingInput {
kind: "insert" | "command" | "search-forward" | "search-backward";
text: string;
}
export interface VimErrorLocation {
sequenceIndex: number;
offset: number;
}
export interface VimToolDetails {
file: string;
mode: VimMode;
cursor: { line: number; col: number };
totalLines: number;
modified: boolean;
viewport: VimViewport;
focus?: VimFocusLine;
viewportLines?: VimViewportLine[];
selection?: VimSelection;
pendingInput?: VimPendingInput;
errorLocation?: VimErrorLocation;
closed?: boolean;
meta?: OutputMeta;
lastCommand?: string;
statusMessage?: string;
diagnostics?: FileDiagnosticsResult;
}
export interface VimFingerprint {
exists: boolean;
size: number;
mtimeMs: number;
hash: string;
}
export interface VimLoadedFile {
absolutePath: string;
displayPath: string;
lines: string[];
trailingNewline: boolean;
fingerprint: VimFingerprint | null;
}
export interface VimKeyToken {
value: string;
display: string;
sequenceIndex: number;
offset: number;
}
export interface VimRegister {
kind: "char" | "line";
text: string;
}
export interface VimSearchState {
pattern: string;
direction: 1 | -1;
}
export interface VimBufferSnapshot {
displayPath: string;
filePath: string;
lines: string[];
cursor: Position;
modified: boolean;
trailingNewline: boolean;
baseFingerprint: VimFingerprint | null;
editabilityChecked: boolean;
}
export interface VimUndoEntry {
before: VimBufferSnapshot;
after: VimBufferSnapshot;
}
export interface VimLineRange {
start: number;
end: number;
}
export type VimExCommand =
| { kind: "write"; force: boolean }
| { kind: "update"; force: boolean }
| { kind: "quit"; force: boolean }
| { kind: "write-quit"; force: boolean }
| { kind: "edit"; force: boolean; path?: string }
| { kind: "goto-line"; line: number }
| { kind: "substitute"; range?: VimLineRange | "all"; pattern: string; replacement: string; flags: string }
| { kind: "delete"; range?: VimLineRange | "all" }
| { kind: "yank"; range?: VimLineRange | "all" }
| { kind: "put"; range?: VimLineRange | "all"; before: boolean }
| { kind: "copy"; range?: VimLineRange | "all"; destination: number }
| { kind: "move"; range?: VimLineRange | "all"; destination: number }
| { kind: "sort"; range?: VimLineRange | "all"; flags: string }
| { kind: "join"; range?: VimLineRange | "all"; trimWhitespace: boolean }
| { kind: "global"; range?: VimLineRange | "all"; pattern: string; command: string; invert: boolean }
| { kind: "append"; range?: VimLineRange; text: string }
| { kind: "insert-before"; range?: VimLineRange; text: string };
export class VimInputError extends Error {
location?: { sequenceIndex: number; offset: number };
constructor(message: string, token?: VimKeyToken) {
super(message);
this.name = "VimInputError";
if (token) {
this.location = {
sequenceIndex: token.sequenceIndex,
offset: token.offset,
};
}
}
}
export function clonePosition(position: Position): Position {
return { line: position.line, col: position.col };
}
function comparePositions(left: Position, right: Position): number {
if (left.line !== right.line) {
return left.line - right.line;
}
return left.col - right.col;
}
export function minPosition(left: Position, right: Position): Position {
return comparePositions(left, right) <= 0 ? clonePosition(left) : clonePosition(right);
}
export function maxPosition(left: Position, right: Position): Position {
return comparePositions(left, right) >= 0 ? clonePosition(left) : clonePosition(right);
}
export function toPublicMode(mode: VimInputMode): VimMode {
switch (mode) {
case "insert":
return "INSERT";
case "visual":
return "VISUAL";
case "visual-line":
return "VISUAL-LINE";
case "command":
case "search-forward":
case "search-backward":
return "COMMAND";
default:
return "NORMAL";
}
}
@@ -5,7 +5,6 @@ import * as path from "node:path";
import { getBundledModel } from "@oh-my-pi/pi-ai";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { Snowflake } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
@@ -34,15 +33,12 @@ const toolActivationExtension: ExtensionFactory = pi => {
describe("createAgentSession defaultInactive tool activation", () => {
const tempDirs: string[] = [];
const authStorages: AuthStorage[] = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
for (const authStorage of authStorages.splice(0)) {
authStorage.close();
}
vi.restoreAllMocks();
});
@@ -111,96 +107,4 @@ describe("createAgentSession defaultInactive tool activation", () => {
await session.dispose();
}
});
it("keeps edit active when vim edit mode is configured", async () => {
const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`);
tempDirs.push(tempDir);
fs.mkdirSync(tempDir, { recursive: true });
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "edit.mode": "vim" }),
model: getBundledModel("openai", "gpt-4o-mini"),
disableExtensionDiscovery: true,
extensions: [],
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
toolNames: ["read", "edit"],
});
try {
expect(session.getActiveToolNames()).toContain("edit");
expect(session.getActiveToolNames()).not.toContain("vim");
expect(session.getAllToolNames()).toContain("edit");
expect(session.getAllToolNames()).not.toContain("vim");
await session.setActiveToolsByName(["read", "edit"]);
expect(session.getActiveToolNames()).toContain("edit");
expect(session.getActiveToolNames()).not.toContain("vim");
} finally {
await session.dispose();
}
});
it("keeps the visible edit tool stable when the active model changes edit modes", async () => {
const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`);
tempDirs.push(tempDir);
fs.mkdirSync(tempDir, { recursive: true });
const settings = Settings.isolated();
vi.spyOn(settings, "getEditVariantForModel").mockImplementation(model =>
model?.includes("mini") ? "vim" : "hashline",
);
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("openai", "test-key");
const baseModel = getBundledModel("openai", "gpt-4o");
const vimModel = getBundledModel("openai", "gpt-4o-mini");
if (!baseModel || !vimModel) {
throw new Error("Expected bundled OpenAI models for edit-mode switching test");
}
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.inMemory(),
settings,
authStorage,
model: baseModel,
disableExtensionDiscovery: true,
extensions: [],
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
toolNames: ["read", "edit"],
});
try {
expect(session.getActiveToolNames()).toContain("edit");
expect(session.getActiveToolNames()).not.toContain("vim");
expect(session.getAllToolNames()).toContain("edit");
expect(session.getAllToolNames()).not.toContain("vim");
await session.setModel(vimModel);
expect(session.getActiveToolNames()).toContain("edit");
expect(session.getActiveToolNames()).not.toContain("vim");
expect(session.getAllToolNames()).toContain("edit");
expect(session.getAllToolNames()).not.toContain("vim");
} finally {
await session.dispose();
}
});
});
@@ -86,19 +86,6 @@ describe("createTools", () => {
expect(names).not.toContain("vim");
});
it("keeps edit visible when vim edit mode is active", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({
"edit.mode": "vim",
}),
});
const tools = await createTools(session);
const names = tools.map(t => t.name);
expect(names).toContain("edit");
expect(names).not.toContain("vim");
});
it("includes bash and eval when both eval backends are allowed", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({
@@ -167,18 +154,6 @@ describe("createTools", () => {
expect(names).toEqual(["read", "write", "resolve"]);
});
it("ignores vim as an unknown requested tool even when vim edit mode is active", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({
"edit.mode": "vim",
}),
});
const tools = await createTools(session, ["read", "vim"]);
const names = tools.map(t => t.name);
expect(names).toEqual(["read", "resolve"]);
});
it("lowercases requested tool subset", async () => {
const session = createTestSession();
const tools = await createTools(session, ["Read", "Write"]);
@@ -1,769 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { resetVimRendererStateForTest, VimTool, vimToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/vim";
import { VimBuffer } from "@oh-my-pi/pi-coding-agent/vim/buffer";
import { VimEngine } from "@oh-my-pi/pi-coding-agent/vim/engine";
import { parseKeySequences } from "@oh-my-pi/pi-coding-agent/vim/parser";
import type { TUI } from "@oh-my-pi/pi-tui";
function textResult(result: { content: Array<{ type: string; text?: string }> }): string {
return result.content
.filter(block => block.type === "text" && typeof block.text === "string")
.map(block => block.text)
.join("\n");
}
function createSession(cwd: string, overrides: Partial<ToolSession> = {}): ToolSession {
return {
cwd,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated({ "lsp.enabled": false }),
...overrides,
};
}
function createEngine(text: string): VimEngine {
return new VimEngine(
new VimBuffer({
absolutePath: "/tmp/test.ts",
displayPath: "test.ts",
lines: text.split("\n"),
trailingNewline: false,
fingerprint: null,
}),
{
beforeMutate: async () => {},
loadBuffer: async inputPath => ({
absolutePath: inputPath,
displayPath: inputPath,
lines: [""],
trailingNewline: false,
fingerprint: null,
}),
saveBuffer: async buffer => ({
loaded: {
absolutePath: buffer.filePath,
displayPath: buffer.displayPath,
lines: [...buffer.lines],
trailingNewline: buffer.trailingNewline,
fingerprint: null,
},
}),
},
);
}
function step(kbd: string[], insert?: string): { kbd: string[]; insert?: string } {
return insert === undefined ? { kbd } : { kbd, insert };
}
afterEach(() => {
vi.restoreAllMocks();
resetVimRendererStateForTest();
});
describe("vim parser", () => {
it("parses literal and special keys in order", () => {
const tokens = parseKeySequences(["ciwnewName<Esc>", ":w<CR>"]);
expect(tokens.map(token => token.value)).toEqual([
"c",
"i",
"w",
"n",
"e",
"w",
"N",
"a",
"m",
"e",
"Esc",
":",
"w",
"CR",
]);
});
it("handles literal escape byte and carriage return", () => {
const tokens = parseKeySequences(["itest\x1b", ":w\r"]);
expect(tokens.map(token => token.value)).toEqual(["i", "t", "e", "s", "t", "Esc", ":", "w", "CR"]);
});
it("handles backslash-r and backslash-e as CR and Esc", () => {
// Models often send \r as two chars (backslash + r) instead of a real CR byte
const tokens = parseKeySequences([":w\\r", "ciwnew\\e"]);
expect(tokens.map(token => token.value)).toEqual([":", "w", "CR", "c", "i", "w", "n", "e", "w", "Esc"]);
});
});
describe("vim engine", () => {
it("repeats the last change with dot", async () => {
const engine = createEngine("foo foo");
await engine.executeTokens(parseKeySequences(["ciwbar<Esc>", "w", "."]), "ciwbar<Esc> w .");
expect(engine.buffer.getText()).toBe("bar bar");
});
it("streams dot-repeat replays through the step callback", async () => {
const engine = createEngine("foo foo");
await engine.executeTokens(parseKeySequences(["ciwbar<Esc>", "w"]), "ciwbar<Esc> w");
const snapshots: string[] = [];
await engine.executeTokens(parseKeySequences(["."]), ".", async () => {
snapshots.push(`${engine.getPublicMode()}|${engine.buffer.cursor.col}|${engine.buffer.getText()}`);
});
expect(engine.buffer.getText()).toBe("bar bar");
expect(snapshots.length).toBeGreaterThan(1);
expect(snapshots.some(snapshot => snapshot.startsWith("INSERT|"))).toBe(true);
});
it("deletes lines and supports undo/redo", async () => {
const engine = createEngine("one\ntwo\nthree\nfour");
await engine.executeTokens(parseKeySequences(["2G", "2dd"]), "2G 2dd");
expect(engine.buffer.getText()).toBe("one\nfour");
await engine.executeTokens(parseKeySequences(["u"]), "u");
expect(engine.buffer.getText()).toBe("one\ntwo\nthree\nfour");
await engine.executeTokens(parseKeySequences(["<C-r>"]), "<C-r>");
expect(engine.buffer.getText()).toBe("one\nfour");
});
it("surfaces undo counts in the status message", async () => {
const engine = createEngine("alpha beta gamma");
await engine.executeTokens(parseKeySequences(["dw", "dw"]), "dw dw");
await engine.executeTokens(parseKeySequences(["2u"]), "2u");
expect(engine.buffer.getText()).toBe("alpha beta gamma");
expect(engine.statusMessage).toBe("Undid 2 changes");
});
it("accepts doubled indent operators in visual mode", async () => {
const engine = createEngine("one\ntwo\nthree");
await engine.executeTokens(parseKeySequences(["Vj>>"]), "Vj>>");
expect(engine.buffer.getText()).toBe("\tone\n\ttwo\nthree");
});
it("applies file-wide substitution through ex commands", async () => {
const engine = createEngine("alpha beta\nalpha gamma");
await engine.executeTokens(parseKeySequences([":%s/alpha/delta/g<CR>"]), ":%s/alpha/delta/g<CR>");
expect(engine.buffer.getText()).toBe("delta beta\ndelta gamma");
expect(engine.statusMessage).toContain("2 substitution");
});
it("deletes all lines with :%d", async () => {
const engine = createEngine("line one\nline two\nline three");
await engine.executeTokens(parseKeySequences([":%d<CR>"]), ":%d<CR>");
expect(engine.buffer.getText()).toBe("");
expect(engine.statusMessage).toBe("Deleted 3 lines");
});
it("supports explicit numeric ex ranges like :4,6d", async () => {
const engine = createEngine("one\ntwo\nthree\nfour\nfive\nsix\nseven");
await engine.executeTokens(parseKeySequences([":4,6d<CR>"]), ":4,6d<CR>");
expect(engine.buffer.getText()).toBe("one\ntwo\nthree\nseven");
expect(engine.statusMessage).toBe("Deleted 3 lines");
});
it("supports current and last-line ex addresses plus ranged :global", async () => {
const engine = createEngine("alpha\nkeep\nalpha\ntrim alpha\nfinal alpha");
await engine.executeTokens(parseKeySequences(["2G", ":.,$g/alpha/d<CR>"]), "2G :.,$g/alpha/d<CR>");
expect(engine.buffer.getText()).toBe("alpha\nkeep");
expect(engine.statusMessage).toBe("Global: processed alpha");
});
it("supports :join aliases with whitespace normalization", async () => {
const engine = createEngine("alpha \n beta\ngamma");
await engine.executeTokens(parseKeySequences([":1,2j<CR>"]), ":1,2j<CR>");
expect(engine.buffer.getText()).toBe("alpha beta\ngamma");
expect(engine.statusMessage).toBe("Joined 2 lines");
});
it("supports :join! aliases without whitespace normalization", async () => {
const engine = createEngine("alpha \n beta\ngamma");
await engine.executeTokens(parseKeySequences([":1,2join!<CR>"]), ":1,2join!<CR>");
expect(engine.buffer.getText()).toBe("alpha beta\ngamma");
expect(engine.statusMessage).toBe("Joined 2 lines");
});
it("supports destination addresses for :copy", async () => {
const engine = createEngine("one\ntwo\nthree\nfour");
await engine.executeTokens(parseKeySequences([":1,2t$<CR>"]), ":1,2t$<CR>");
expect(engine.buffer.getText()).toBe("one\ntwo\nthree\nfour\none\ntwo");
expect(engine.statusMessage).toBe("Copied 2 lines");
});
it("yanks addressed lines and puts them before or after the anchor line", async () => {
const engine = createEngine("one\ntwo\nthree\nfour");
await engine.executeTokens(
parseKeySequences([":2,3yank<CR>", "1G", ":put<CR>", "G", ":put!<CR>"]),
":2,3yank<CR> 1G :put<CR> G :put!<CR>",
);
expect(engine.buffer.getText()).toBe("one\ntwo\nthree\ntwo\nthree\ntwo\nthree\nfour");
expect(engine.statusMessage).toBe("Put 2 lines");
});
it("treats :update as a no-op for clean buffers and writes modified buffers", async () => {
const saveBuffer = vi.fn(async (buffer: VimBuffer) => ({
loaded: {
absolutePath: buffer.filePath,
displayPath: buffer.displayPath,
lines: [...buffer.lines],
trailingNewline: buffer.trailingNewline,
fingerprint: null,
},
}));
const engine = new VimEngine(
new VimBuffer({
absolutePath: "/tmp/test.ts",
displayPath: "test.ts",
lines: ["alpha"],
trailingNewline: false,
fingerprint: null,
}),
{
beforeMutate: async () => {},
loadBuffer: async inputPath => ({
absolutePath: inputPath,
displayPath: inputPath,
lines: [""],
trailingNewline: false,
fingerprint: null,
}),
saveBuffer,
},
);
await engine.executeTokens(parseKeySequences([":up<CR>"]), ":up<CR>");
expect(saveBuffer).not.toHaveBeenCalled();
expect(engine.statusMessage).toBe("test.ts unchanged");
await engine.executeTokens(parseKeySequences(["ccchanged<Esc>", ":up<CR>"]), "ccchanged<Esc> :up<CR>");
expect(saveBuffer).toHaveBeenCalledTimes(1);
expect(engine.buffer.getText()).toBe("changed");
expect(engine.statusMessage).toBe("Wrote test.ts");
});
it("renders literal spaces visibly in unsupported command errors", async () => {
const engine = createEngine("alpha");
await expect(engine.executeTokens(parseKeySequences(["z "]), "z ")).rejects.toThrow(/z<Space>/);
});
});
describe("vim tool", () => {
let tmpDir: string;
beforeEach(async () => {
resetSettingsForTest();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-tool-"));
await Settings.init({ inMemory: true, cwd: tmpDir });
});
it("opens, edits, saves, and persists content", async () => {
const filePath = path.join(tmpDir, "sample.ts");
await Bun.write(filePath, "foo = 1;\nfoo = foo + 1;\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "sample.ts" });
await tool.execute("edit", { file: "sample.ts", steps: [step(["ciwbar<Esc>", "j", "."])] });
await tool.execute("save", { file: "sample.ts", steps: [step([":w<CR>"])] });
const saved = await Bun.file(filePath).text();
expect(saved).toContain("bar = 1;");
expect(saved).toContain("bar = foo + 1;");
});
it("keeps the cursor line visible after large jumps", async () => {
const filePath = path.join(tmpDir, "long.ts");
await Bun.write(filePath, Array.from({ length: 1100 }, (_, index) => `line ${index + 1};`).join("\n"));
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "long.ts" });
const moved = await tool.execute("jump", { file: "long.ts", steps: [step(["1014G"])] });
const text = textResult(moved);
expect(text).toContain(">1014│line 1014;");
expect(moved.details?.cursor.line).toBe(1014);
});
it("centers the viewport on the cursor after a large edit", async () => {
const filePath = path.join(tmpDir, "center.ts");
await Bun.write(filePath, Array.from({ length: 500 }, (_, index) => `line ${index + 1};`).join("\n"));
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "center.ts" });
const edited = await tool.execute("edit", {
file: "center.ts",
steps: [step(["386Go"], "inserted")],
pause: true,
});
expect(edited.details?.cursor.line).toBe(387);
expect(edited.details?.viewport.start).toBe(382);
expect(edited.details?.viewport.end).toBe(391);
expect(textResult(edited)).toContain("Diff:");
expect(textResult(edited)).toContain("+inserted");
});
it("recenters the viewport and includes a diff after edits", async () => {
const filePath = path.join(tmpDir, "long-edit.ts");
await Bun.write(filePath, Array.from({ length: 1100 }, (_, index) => `line ${index + 1};`).join("\n"));
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "long-edit.ts" });
const edited = await tool.execute("edit", { file: "long-edit.ts", steps: [step(["1014G", "o"], "inserted")] });
const text = textResult(edited);
expect(edited.details?.cursor.line).toBe(1015);
expect(edited.details?.viewport.start).toBe(1010);
expect(text).toContain("Diff:");
expect(text).toContain("+inserted");
});
it("supports raw insert payloads after kbd enters insert mode", async () => {
const filePath = path.join(tmpDir, "replace.ts");
await Bun.write(filePath, "first\nsecond\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "replace.ts" });
const replaced = await tool.execute("replace", { file: "replace.ts", steps: [step(["cc"], "alpha\nbeta")] });
await tool.execute("save", { file: "replace.ts", steps: [step([":w<CR>"])] });
const saved = await Bun.file(filePath).text();
expect(saved).toBe("alpha\nbeta\nsecond\n");
expect(textResult(replaced)).toContain("Diff:");
expect(textResult(replaced)).toContain("+beta");
});
it("applies multi-step inserts at different locations", async () => {
const filePath = path.join(tmpDir, "multi-step.ts");
await Bun.write(filePath, "import sys\n\ndef main():\n pass\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "multi-step.ts" });
const edited = await tool.execute("edit", {
file: "multi-step.ts",
steps: [step(["1Go"], "import os"), step(["G", "o"], " os.path.exists('tmp')")],
});
const saved = await Bun.file(filePath).text();
expect(saved).toBe("import sys\nimport os\n\ndef main():\n pass\n os.path.exists('tmp')\n");
expect(textResult(edited)).toContain("+import os");
expect(textResult(edited)).toContain("+ os.path.exists('tmp')");
});
it("supports navigation-only steps between inserts", async () => {
const filePath = path.join(tmpDir, "multi-step-navigation.ts");
await Bun.write(filePath, "alpha\nbeta\ngamma\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "multi-step-navigation.ts" });
await tool.execute("edit", {
file: "multi-step-navigation.ts",
steps: [step(["1Go"], "between"), step(["/gamma<CR>"]), step(["o"], "tail")],
});
const saved = await Bun.file(filePath).text();
expect(saved).toBe("alpha\nbetween\nbeta\ngamma\ntail\n");
});
it("preserves earlier step changes when a later step fails", async () => {
const filePath = path.join(tmpDir, "multi-step-error.ts");
await Bun.write(filePath, "alpha\nbeta\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "multi-step-error.ts" });
await expect(
tool.execute("bad", {
file: "multi-step-error.ts",
steps: [step(["1Go"], "first"), step(["o", "o"])],
}),
).rejects.toThrow(/entered INSERT mode/i);
const viewed = await tool.execute("view", { file: "multi-step-error.ts" });
expect(textResult(viewed)).toContain("first");
expect(await Bun.file(filePath).text()).toBe("alpha\nbeta\n");
await tool.execute("save", { file: "multi-step-error.ts", steps: [step([":w<CR>"])] });
expect(await Bun.file(filePath).text()).toBe("alpha\nfirst\nbeta\n");
});
it("applies pause only to the last step of a multi-step edit", async () => {
const filePath = path.join(tmpDir, "multi-step-pause.ts");
await Bun.write(filePath, "first\nsecond\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "multi-step-pause.ts" });
const paused = await tool.execute("pause", {
file: "multi-step-pause.ts",
steps: [step(["1Go"], "alpha"), step(["G", "o"], "omega")],
pause: true,
});
expect(paused.details?.mode).toBe("INSERT");
expect(textResult(paused)).toContain("Pending: INSERT mode");
expect(await Bun.file(filePath).text()).toBe("first\nsecond\n");
await tool.execute("resume", { file: "multi-step-pause.ts", steps: [step([], "!")] });
expect(await Bun.file(filePath).text()).toBe("first\nalpha\nsecond\nomega!\n");
});
it("supports full-file rewrites when models emit a space before i", async () => {
const filePath = path.join(tmpDir, "full-rewrite.ts");
await Bun.write(filePath, "first\nsecond\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "full-rewrite.ts" });
const rewritten = await tool.execute("rewrite", {
file: "full-rewrite.ts",
steps: [step(["ggdG i"], "alpha\nbeta\n")],
});
const saved = await Bun.file(filePath).text();
expect(saved).toBe("alpha\nbeta\n");
expect(textResult(rewritten)).toContain("+alpha");
expect(rewritten.details?.cursor.line).toBe(2);
});
it("rejects another kbd entry after entering insert mode", async () => {
const filePath = path.join(tmpDir, "ambiguous.ts");
await Bun.write(filePath, "first\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "ambiguous.ts" });
await expect(tool.execute("bad", { file: "ambiguous.ts", steps: [step(["o", "o"])] })).rejects.toThrow(
/entered INSERT mode/i,
);
});
it("rejects additional kbd entries after entering insert mode", async () => {
const filePath = path.join(tmpDir, "insert-boundary.ts");
await Bun.write(filePath, "alpha\nbeta\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "insert-boundary.ts" });
await expect(
tool.execute("edit", { file: "insert-boundary.ts", steps: [step(["2G", "o", "o"])] }),
).rejects.toThrow(/insert field|<Esc>/i);
const saved = await Bun.file(filePath).text();
expect(saved).toBe("alpha\nbeta\n");
});
it("supports paused insert mode and resuming with a later insert payload", async () => {
const filePath = path.join(tmpDir, "pause.ts");
await Bun.write(filePath, "first\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "pause.ts" });
const paused = await tool.execute("pause", { file: "pause.ts", steps: [step(["cc"])], pause: true });
expect(paused.details?.mode).toBe("INSERT");
expect(textResult(paused)).toContain("Pending: INSERT mode");
await tool.execute("resume", { file: "pause.ts", steps: [step([], "replacement")] });
await tool.execute("save", { file: "pause.ts", steps: [step([":w<CR>"])] });
const saved = await Bun.file(filePath).text();
expect(saved).toBe("replacement\n");
});
it("rejects insert payloads outside insert mode with a snapshot error", async () => {
const filePath = path.join(tmpDir, "bad-insert.ts");
await Bun.write(filePath, "first\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "bad-insert.ts" });
await expect(tool.execute("bad", { file: "bad-insert.ts", steps: [step([], "nope")] })).rejects.toThrow(
/Insert payload requires INSERT mode/i,
);
});
it("renders visible tab markers and a caret-focused snapshot", async () => {
const filePath = path.join(tmpDir, "tabs.ts");
await Bun.write(filePath, "\treturn value;\n");
const tool = new VimTool(createSession(tmpDir));
const opened = await tool.execute("open", { file: "tabs.ts" });
const text = textResult(opened);
expect(text).toContain("Focus:");
expect(text).toContain("→return value;");
expect(text).toContain("^");
});
it("renders the cursor inline in plain text viewport snapshots", async () => {
const filePath = path.join(tmpDir, "cursor.txt");
await Bun.write(filePath, "alpha\n");
const tool = new VimTool(createSession(tmpDir));
const opened = await tool.execute("open", { file: "cursor.txt" });
expect(textResult(opened)).toContain(">1│▏alpha");
});
it("shows paused search input in the snapshot", async () => {
const filePath = path.join(tmpDir, "search.ts");
await Bun.write(filePath, "alpha\nbeta\n");
const tool = new VimTool(createSession(tmpDir));
await tool.execute("open", { file: "search.ts" });
const paused = await tool.execute("search", { file: "search.ts", steps: [step(["/be"])], pause: true });
expect(paused.details?.pendingInput?.kind).toBe("search-forward");
expect(textResult(paused)).toContain("Pending: /be");
});
it("streams ex command input through onUpdate while typing", async () => {
const filePath = path.join(tmpDir, "command.ts");
await Bun.write(filePath, "foo foo\n");
const tool = new VimTool(createSession(tmpDir));
const pendingInputs: string[] = [];
await tool.execute("open", { file: "command.ts" });
const result = await tool.execute(
"command",
{ file: "command.ts", steps: [step([":%s/foo/bar/g<CR>"])] },
undefined,
update => {
const pending = update.details?.pendingInput;
if (pending?.kind === "command") {
pendingInputs.push(pending.text);
}
},
);
expect(pendingInputs).toContain("");
expect(pendingInputs).toContain("%");
expect(pendingInputs).toContain("%s/foo/bar/g");
expect(textResult(result)).toContain("bar bar");
});
it("streams large insert payloads through onUpdate in chunks", async () => {
const filePath = path.join(tmpDir, "stream-insert.ts");
await Bun.write(filePath, "header\nfooter\n");
const tool = new VimTool(createSession(tmpDir));
const visibleMaxItems: number[] = [];
await tool.execute("open", { file: "stream-insert.ts" });
await tool.execute(
"insert",
{
file: "stream-insert.ts",
steps: [step(["2Go"], Array.from({ length: 60 }, (_, index) => `item ${index + 1}`).join("\n"))],
pause: true,
},
undefined,
update => {
const viewportText = update.details?.viewportLines?.map(line => line.text).join("\n") ?? "";
const matches = Array.from(viewportText.matchAll(/item (\d+)/g), match => Number(match[1]));
if (matches.length > 0) {
visibleMaxItems.push(Math.max(...matches));
}
},
);
expect(visibleMaxItems.length).toBeGreaterThan(1);
expect(visibleMaxItems.some(value => value < 60)).toBe(true);
expect(Math.max(...visibleMaxItems)).toBe(60);
});
it("streams single-line insert payloads through onUpdate in chunks", async () => {
const filePath = path.join(tmpDir, "stream-single-line.ts");
await Bun.write(filePath, "alpha\nomega\n");
const tool = new VimTool(createSession(tmpDir));
const visibleLengths: number[] = [];
const insertedText =
"// Insert a new line after line 7 with a long comment that should render incrementally in the viewport.";
await tool.execute("open", { file: "stream-single-line.ts" });
await tool.execute(
"insert-single-line",
{
file: "stream-single-line.ts",
steps: [step(["2Go"], insertedText)],
pause: true,
},
undefined,
update => {
const insertedLine = update.details?.viewportLines?.find(line => line.line === 3)?.text;
if (typeof insertedLine === "string" && insertedLine.length > 0) {
visibleLengths.push(insertedLine.length);
}
},
);
expect(visibleLengths.length).toBeGreaterThan(1);
expect(visibleLengths.some(length => length < insertedText.length)).toBe(true);
expect(Math.max(...visibleLengths)).toBe(insertedText.length);
});
it("allows navigation in plan mode but blocks mutations", async () => {
const filePath = path.join(tmpDir, "plan.ts");
await Bun.write(filePath, "one\ntwo\nthree\n");
const tool = new VimTool(
createSession(tmpDir, {
getPlanModeState: () => ({
enabled: true,
planFilePath: path.join(tmpDir, "PLAN.md"),
}),
}),
);
await tool.execute("open", { file: "plan.ts" });
const moved = await tool.execute("move", { file: "plan.ts", steps: [step(["2G"])] });
expect(textResult(moved)).toContain("L2:1");
await expect(tool.execute("edit", { file: "plan.ts", steps: [step(["dd"])] })).rejects.toThrow(/Plan mode/i);
await expect(tool.execute("insert", { file: "plan.ts", steps: [step(["cc"], "blocked")] })).rejects.toThrow(
/Plan mode/i,
);
});
});
describe("vim renderer", () => {
it("reuses the last real vim snapshot while args stream", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-preview-"));
const filePath = path.join(previewDir, "preview.ts");
await Bun.write(filePath, Array.from({ length: 900 }, (_, index) => `line ${index + 1};`).join("\n"));
const tool = new VimTool(createSession(previewDir));
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
await tool.execute("open", { file: "preview.ts" });
const component = vimToolRenderer.renderCall(
{ file: "preview.ts", steps: [step(["643G"])] },
{ expanded: false, isPartial: true, spinnerFrame: 0 },
uiTheme,
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("643G");
expect(rendered).toContain("line 1;");
expect(rendered).not.toContain("line 643;");
});
it("renders first-call streamed inserts as text instead of priming a preview buffer", async () => {
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const component = vimToolRenderer.renderCall(
{
file: "preview.txt",
steps: [step(["ggdGi"])],
__partialJson: '{"file":"preview.txt","steps":[{"kbd":["ggdGi"],"insert":"replacement',
},
{ expanded: false, isPartial: true, spinnerFrame: 0 },
uiTheme,
);
const rendered = Bun.stripANSI(component.render(140).join("\n"));
expect(rendered).toContain("ggdGi");
expect(rendered).toContain("insert: replacement");
expect(rendered).not.toContain(">1│replacement");
});
it("updates streamed vim args without async preview priming", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-growing-first-call-component-"));
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
await themeModule.initTheme(false, undefined, undefined, "dark", "light");
const uiStub = { requestRender() {} } as unknown as TUI;
const component = new ToolExecutionComponent(
"edit",
{
file: "preview.txt",
steps: [step(["ggdGi"])],
__partialJson: '{"file":"preview.txt","steps":[{"kbd":["ggdGi"],"insert":"rep',
},
{},
undefined,
uiStub,
previewDir,
"growing-first-call-component",
);
let rendered = Bun.stripANSI(component.render(140).join("\n"));
expect(rendered).toContain("insert: rep");
component.updateArgs(
{
file: "preview.txt",
steps: [step(["ggdGi"])],
__partialJson: '{"file":"preview.txt","steps":[{"kbd":["ggdGi"],"insert":"replacement text',
},
"growing-first-call-component",
);
rendered = Bun.stripANSI(component.render(140).join("\n"));
expect(rendered).toContain("insert: replacement text");
});
it("caches repeated renders for the same viewport snapshot", async () => {
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const highlightSpy = vi.spyOn(themeModule, "highlightCode");
const component = vimToolRenderer.renderResult(
{
content: [{ type: "text", text: "" }],
details: {
file: "sample.ts",
mode: "NORMAL",
cursor: { line: 1, col: 1 },
totalLines: 2,
modified: false,
viewport: { start: 1, end: 2 },
viewportLines: [
{ line: 1, text: "const foo = 1;", isCursor: true, isSelected: false },
{ line: 2, text: "return foo;", isCursor: false, isSelected: false },
],
},
},
{ expanded: false, isPartial: true, spinnerFrame: 0 },
uiTheme,
);
component.render(120);
component.render(120);
expect(highlightSpy).toHaveBeenCalledTimes(1);
});
it("renders an inline cursor highlight inside the viewport row", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-inline-cursor-"));
const filePath = path.join(previewDir, "cursor.txt");
await Bun.write(filePath, "Title line\n");
const tool = new VimTool(createSession(previewDir));
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const opened = await tool.execute("open", { file: "cursor.txt" });
const rendered = vimToolRenderer
.renderResult(opened, { expanded: false, isPartial: false, spinnerFrame: 0 }, uiTheme)
.render(160)
.join("\n");
expect(rendered).toMatch(/\x1b\[7mT/);
});
it("keeps long cursor rows horizontally centered around the cursor", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-long-line-cursor-"));
const filePath = path.join(previewDir, "cursor.txt");
await Bun.write(filePath, `prefix-${"x".repeat(220)};`);
const tool = new VimTool(createSession(previewDir));
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
await tool.execute("open", { file: "cursor.txt" });
const moved = await tool.execute("move", { file: "cursor.txt", steps: [step(["$"])] });
expect(moved.details?.viewportLines?.[0]?.text.startsWith("…")).toBe(true);
const rendered = vimToolRenderer
.renderResult(moved, { expanded: false, isPartial: false, spinnerFrame: 0 }, uiTheme)
.render(200)
.join("\n");
expect(rendered).toMatch(/\x1b\[7m;/);
});
});