diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 87a665480..c03561c07 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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 | diff --git a/docs/tools/edit.md b/docs/tools/edit.md index 57b6eb020..27285a3ba 100644 --- a/docs/tools/edit.md +++ b/docs/tools/edit.md @@ -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: diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 34ed28b0f..961753db4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 7472b4f8c..c0740b2d2 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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)", }, }, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 85142fcd5..6a14bb026 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -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 | undefined; if (editObj) { - if (editObj.mode === "atom") { + if (editObj.mode === "atom" || editObj.mode === "vim") { editObj.mode = "hashline"; } const modelVariants = editObj.modelVariants as Record | 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"; } diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index 8285ebd0e..eca306572 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -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; -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) => void, - ) => Promise>; + onUpdate?: (partialResult: AgentToolResult) => void, + ) => Promise>; }; function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined { @@ -284,7 +278,6 @@ export class EditTool implements AgentTool { readonly #fuzzyThreshold: number; readonly #writethrough: WritethroughCallback; readonly #editMode?: EditMode; - readonly #vimTool: VimTool; readonly #pendingDeferredFetches = new Map(); constructor(private readonly session: ToolSession) { @@ -298,7 +291,6 @@ export class EditTool implements AgentTool { 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 { _toolCallId: string, params: EditParams, signal?: AbortSignal, - onUpdate?: AgentToolUpdateCallback, + onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, - ): Promise> { + ): Promise> { const modeDefinition = this.#getModeDefinition(); return modeDefinition.execute(this, params, signal, getLspBatchRequest(context?.toolCall), onUpdate); } @@ -460,29 +452,6 @@ export class EditTool implements AgentTool { 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) => void, - ) => { - const handleUpdate = onUpdate - ? (partialResult: AgentToolResult) => { - onUpdate(partialResult as AgentToolResult); - } - : undefined; - return (await tool.#vimTool.execute( - "edit", - params as VimParams, - signal, - handleUpdate, - )) as AgentToolResult; - }, - }, }[this.mode]; } diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 5c0020575..3869ff3ca 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -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)) { diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index 76463f658..3f356d99b 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -523,27 +523,11 @@ const applyPatchStrategy: EditStreamingStrategy = { 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 = { - extractCompleteEdits(args) { - return args; - }, - async computeDiffPreview() { - return null; - }, - renderStreamingFallback() { - return ""; - }, -}; - export const EDIT_MODE_STRATEGIES: Record> = { replace: replaceStrategy as EditStreamingStrategy, patch: patchStrategy as EditStreamingStrategy, hashline: hashlineStrategy as EditStreamingStrategy, apply_patch: applyPatchStrategy as EditStreamingStrategy, - vim: vimStrategy, }; export { resolveEditMode }; diff --git a/packages/coding-agent/src/prompts/tools/vim.md b/packages/coding-agent/src/prompts/tools/vim.md deleted file mode 100644 index 40edff663..000000000 --- a/packages/coding-agent/src/prompts/tools/vim.md +++ /dev/null @@ -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"]}]}` -- 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 ``). - -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"]}]} -``` - -Find and replace: -```json -{"file": "f.py", "steps": [{"kbd": [":%s/old/new/g"]}]} -``` - -Delete line range: -```json -{"file": "f.py", "steps": [{"kbd": [":3,5d"]}]} -``` -Ex commands always start with `:` and end with ``. `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: `` `` `` `` `` `` `` `` `` -Motions: `h j k l 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`/`` undo/redo, `/pattern` 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 $` diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 61c4a8b3e..5060d8ace 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -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"; diff --git a/packages/coding-agent/src/tools/vim.ts b/packages/coding-agent/src/tools/vim.ts deleted file mode 100644 index 862342b2f..000000000 --- a/packages/coding-agent/src/tools/vim.ts +++ /dev/null @@ -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; -type VimStep = z.infer; - -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): 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(); - 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, -): Promise { - 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 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, -): Promise { - 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; - onInsertStep?: () => Promise; -} - -// Auto-reorder line-positioned steps to descending order (bottom-up) when all steps -// are simple `NG` 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 `G` 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 { - // 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 { - 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 { - readonly name = "vim"; - readonly label = "Vim"; - readonly description: string; - readonly parameters = vimSchema; - readonly concurrency = "exclusive"; - - #engines = new Map(); - #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 { - 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 { - 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 { - 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 { - const details = buildToolDetailsFromEngine( - engine, - viewportLines, - preferredStart, - closed, - errorLocation, - statusMessage, - ); - const resultText = modelDiff ? `${renderVimDetails(details)}\n\nDiff:\n${modelDiff}` : renderVimDetails(details); - const builder = toolResult(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, - _context?: AgentToolContext, - ): Promise> { - 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 }; diff --git a/packages/coding-agent/src/utils/edit-mode.ts b/packages/coding-agent/src/utils/edit-mode.ts index 5eeb5dd98..8c2cdfc85 100644 --- a/packages/coding-agent/src/utils/edit-mode.ts +++ b/packages/coding-agent/src/utils/edit-mode.ts @@ -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; export const EDIT_MODES = Object.keys(EDIT_MODE_IDS) as EditMode[]; diff --git a/packages/coding-agent/src/vim/buffer.ts b/packages/coding-agent/src/vim/buffer.ts deleted file mode 100644 index a3f5f233e..000000000 --- a/packages/coding-agent/src/vim/buffer.ts +++ /dev/null @@ -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)); - } -} diff --git a/packages/coding-agent/src/vim/commands.ts b/packages/coding-agent/src/vim/commands.ts deleted file mode 100644 index 892562c7d..000000000 --- a/packages/coding-agent/src/vim/commands.ts +++ /dev/null @@ -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}.`); -} diff --git a/packages/coding-agent/src/vim/engine.ts b/packages/coding-agent/src/vim/engine.ts deleted file mode 100644 index 10951542d..000000000 --- a/packages/coding-agent/src/vim/engine.ts +++ /dev/null @@ -1,2409 +0,0 @@ -import type { FileDiagnosticsResult } from "../lsp"; -import { snapshotEqual, type VimBuffer } from "./buffer"; -import { parseExCommand } from "./commands"; -import { replayTokens } from "./parser"; -import type { - Position, - VimBufferSnapshot, - VimInputMode, - VimKeyToken, - VimLineRange, - VimLoadedFile, - VimPendingInput, - VimRegister, - VimSearchState, - VimSelection, - VimUndoEntry, -} from "./types"; -import { clonePosition, maxPosition, minPosition, toPublicMode, VimInputError as VimError } from "./types"; - -export interface VimSaveResult { - loaded: VimLoadedFile; - diagnostics?: FileDiagnosticsResult; -} - -export interface VimEngineCallbacks { - beforeMutate: (buffer: VimBuffer) => Promise; - loadBuffer: (path: string) => Promise; - saveBuffer: (buffer: VimBuffer, options?: { force?: boolean }) => Promise; -} - -interface PendingChange { - before: VimBufferSnapshot; - tokens: string[]; - moveCursorLeftOnEscape: boolean; - inserted: boolean; -} - -interface MotionResult { - nextIndex: number; - target: Position; - inclusive?: boolean; - linewise?: boolean; - range?: { start: number; end: number; linewise?: boolean }; -} - -const WORD_CHAR = /[A-Za-z0-9_]/; -const DEFAULT_VIEWPORT_HEIGHT = 10; -const BRACKET_PAIRS = new Map([ - ["(", ")"], - ["[", "]"], - ["{", "}"], - ["<", ">"], -]); -const CLOSING_BRACKETS = new Map( - Array.from(BRACKET_PAIRS.entries()).map(([open, close]) => [close, open]), -); -const NOOP_Z_COMMANDS = new Set(["a", "A", "c", "C", "m", "M", "o", "O", "r", "R", "v", "x", "X"]); - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function isWhitespace(char: string): boolean { - return /\s/.test(char); -} - -function isWordChar(char: string): boolean { - return WORD_CHAR.test(char); -} - -function wordCategory(char: string, bigWord: boolean): "space" | "word" | "punct" { - if (char.length === 0 || isWhitespace(char)) { - return "space"; - } - if (bigWord) { - return "word"; - } - return isWordChar(char) ? "word" : "punct"; -} - -function decodeReplacement(replacement: string): string { - return replacement.replace(/\\\//g, "/").replace(/\\\\/g, "\\"); -} - -function literalTextToReplayTokens(text: string): string[] { - const tokens: string[] = []; - for (const char of text) { - if (char === "\n") { - tokens.push("CR"); - continue; - } - if (char === "\t") { - tokens.push("Tab"); - continue; - } - tokens.push(char); - } - return tokens; -} - -// Convert a vim-style search pattern to a JavaScript RegExp. -// In vim's default ("magic") mode, (, ), {, }, |, + are literal unless backslash-escaped. -// In JS regex these are metacharacters. Swap the escaping so bare chars are literal -// and \( etc. become regex groups. -function vimPatternToJsRegex(pattern: string): string { - return pattern.replace(/\\([(){}|+])|([(){}|+])/g, (_match, escaped, bare) => { - if (escaped) return escaped; // \( -> ( (regex group) - return `\\${bare}`; // ( -> \( (literal paren) - }); -} - -function createSearchRegex(pattern: string, flags = "g"): RegExp { - try { - return new RegExp(vimPatternToJsRegex(pattern), flags); - } catch { - return new RegExp(escapeRegex(pattern), flags); - } -} - -function detectIndentUnit(lines: string[]): string { - for (const line of lines) { - if (line.startsWith("\t")) { - return "\t"; - } - if (line.startsWith(" ")) { - return " "; - } - } - return "\t"; -} - -function normalizeRange(start: number, end: number): { start: number; end: number } { - return { - start: Math.min(start, end), - end: Math.max(start, end), - }; -} - -function selectionFromAnchor(buffer: VimBuffer, anchor: Position, linewise: boolean): VimSelection { - if (linewise) { - const startLine = Math.min(anchor.line, buffer.cursor.line); - const endLine = Math.max(anchor.line, buffer.cursor.line); - return { - kind: "line", - start: { line: startLine + 1, col: 1 }, - end: { line: endLine + 1, col: buffer.getLine(endLine).length + 1 }, - }; - } - const start = minPosition(anchor, buffer.cursor); - const end = maxPosition(anchor, buffer.cursor); - return { - kind: "char", - start: { line: start.line + 1, col: start.col + 1 }, - end: { line: end.line + 1, col: end.col + 1 }, - }; -} - -function expandVisualOffsets( - buffer: VimBuffer, - anchor: Position, - linewise: boolean, -): { start: number; end: number; linewise: boolean } { - if (linewise) { - const startLine = Math.min(anchor.line, buffer.cursor.line); - const endLine = Math.max(anchor.line, buffer.cursor.line); - const startOffset = buffer.positionToOffset({ line: startLine, col: 0 }); - const endOffset = - endLine >= buffer.lastLineIndex() - ? buffer.getText().length - : buffer.positionToOffset({ line: endLine + 1, col: 0 }); - return { start: startOffset, end: endOffset, linewise: true }; - } - const anchorOffset = buffer.positionToOffset(anchor); - const cursorOffset = buffer.positionToOffset(buffer.cursor); - const { start, end } = normalizeRange(anchorOffset, cursorOffset); - return { start, end: end + 1, linewise: false }; -} - -function nextWordStart(text: string, offset: number, bigWord: boolean): number { - let index = Math.min(Math.max(offset, 0), text.length); - if (index >= text.length) { - return text.length; - } - - const currentCategory = wordCategory(text[index] ?? "", bigWord); - if (currentCategory === "space") { - while (index < text.length && wordCategory(text[index] ?? "", bigWord) === "space") { - index += 1; - } - return index; - } - - while (index < text.length && wordCategory(text[index] ?? "", bigWord) === currentCategory) { - index += 1; - } - while (index < text.length && wordCategory(text[index] ?? "", bigWord) === "space") { - index += 1; - } - return index; -} - -function previousWordStart(text: string, offset: number, bigWord: boolean): number { - let index = Math.min(Math.max(offset - 1, 0), text.length); - while (index > 0 && wordCategory(text[index] ?? "", bigWord) === "space") { - index -= 1; - } - const category = wordCategory(text[index] ?? "", bigWord); - while (index > 0 && wordCategory(text[index - 1] ?? "", bigWord) === category) { - index -= 1; - } - return index; -} - -function endOfWord(text: string, offset: number, bigWord: boolean): number { - let index = Math.min(Math.max(offset, 0), text.length); - while (index < text.length && wordCategory(text[index] ?? "", bigWord) === "space") { - index += 1; - } - const category = wordCategory(text[index] ?? "", bigWord); - while (index < text.length && wordCategory(text[index] ?? "", bigWord) === category) { - index += 1; - } - return Math.max(0, index - 1); -} - -function endOfPreviousWord(text: string, offset: number, bigWord: boolean): number { - if (text.length === 0) { - return 0; - } - - let index = Math.min(Math.max(offset - 1, 0), text.length - 1); - while (index >= 0 && wordCategory(text[index] ?? "", bigWord) === "space") { - index -= 1; - } - if (index < 0) { - return 0; - } - - const currentCategory = wordCategory(text[index] ?? "", bigWord); - while (index >= 0 && wordCategory(text[index] ?? "", bigWord) === currentCategory) { - index -= 1; - } - while (index >= 0 && wordCategory(text[index] ?? "", bigWord) === "space") { - index -= 1; - } - - return Math.max(0, index); -} - -function toggleCase(text: string): string { - let toggled = ""; - for (const char of text) { - if (char >= "a" && char <= "z") { - toggled += char.toUpperCase(); - continue; - } - if (char >= "A" && char <= "Z") { - toggled += char.toLowerCase(); - continue; - } - toggled += char; - } - return toggled; -} - -function lastNonBlankColumn(line: string): number { - for (let index = line.length - 1; index >= 0; index -= 1) { - if (!isWhitespace(line[index] ?? "")) { - return index; - } - } - return 0; -} - -function findParagraphStart(lines: string[], line: number): number { - let index = Math.max(0, line - 1); - while (index > 0 && lines[index]!.trim().length > 0) { - index -= 1; - } - while (index > 0 && lines[index - 1]!.trim().length === 0) { - index -= 1; - } - return index; -} - -function findParagraphEnd(lines: string[], line: number): number { - let index = Math.min(lines.length - 1, line + 1); - while (index < lines.length - 1 && lines[index]!.trim().length > 0) { - index += 1; - } - while (index < lines.length - 1 && lines[index + 1]!.trim().length === 0) { - index += 1; - } - return index; -} - -function cloneUndoStack(stack: VimUndoEntry[]): VimUndoEntry[] { - return stack.map(entry => ({ - before: { - ...entry.before, - lines: [...entry.before.lines], - cursor: clonePosition(entry.before.cursor), - baseFingerprint: entry.before.baseFingerprint ? { ...entry.before.baseFingerprint } : null, - }, - after: { - ...entry.after, - lines: [...entry.after.lines], - cursor: clonePosition(entry.after.cursor), - baseFingerprint: entry.after.baseFingerprint ? { ...entry.after.baseFingerprint } : null, - }, - })); -} - -export class VimEngine { - buffer: VimBuffer; - inputMode: VimInputMode = "normal"; - selectionAnchor: Position | null = null; - register: VimRegister = { kind: "char", text: "" }; - lastSearch: VimSearchState | null = null; - lastCharFind: { char: string; mode: "f" | "F" | "t" | "T" } | null = null; - lastVisual: { anchor: Position; cursor: Position; mode: VimInputMode } | null = null; - lastCommand?: string; - statusMessage?: string; - diagnostics?: FileDiagnosticsResult; - viewportStart = 1; - closed = false; - - #callbacks: VimEngineCallbacks; - #undoStack: VimUndoEntry[] = []; - #redoStack: VimUndoEntry[] = []; - #pendingInput = ""; - #lastChangeTokens: string[] | null = null; - #pendingChange: PendingChange | null = null; - #stepCallback?: () => Promise; - - constructor(buffer: VimBuffer, callbacks: VimEngineCallbacks) { - this.buffer = buffer; - this.#callbacks = callbacks; - } - - clone(callbacks?: Partial): VimEngine { - const next = new VimEngine(this.buffer.clone(), { - beforeMutate: callbacks?.beforeMutate ?? this.#callbacks.beforeMutate, - loadBuffer: callbacks?.loadBuffer ?? this.#callbacks.loadBuffer, - saveBuffer: callbacks?.saveBuffer ?? this.#callbacks.saveBuffer, - }); - next.inputMode = this.inputMode; - next.selectionAnchor = this.selectionAnchor ? clonePosition(this.selectionAnchor) : null; - next.register = { ...this.register }; - next.lastSearch = this.lastSearch ? { ...this.lastSearch } : null; - next.lastCharFind = this.lastCharFind ? { ...this.lastCharFind } : null; - next.lastVisual = this.lastVisual - ? { - anchor: clonePosition(this.lastVisual.anchor), - cursor: clonePosition(this.lastVisual.cursor), - mode: this.lastVisual.mode, - } - : null; - next.lastCommand = this.lastCommand; - next.statusMessage = this.statusMessage; - next.diagnostics = this.diagnostics; - next.viewportStart = this.viewportStart; - next.closed = this.closed; - next.#pendingInput = this.#pendingInput; - next.#lastChangeTokens = this.#lastChangeTokens ? [...this.#lastChangeTokens] : null; - next.#pendingChange = this.#pendingChange - ? { - before: { - ...this.#pendingChange.before, - lines: [...this.#pendingChange.before.lines], - cursor: clonePosition(this.#pendingChange.before.cursor), - baseFingerprint: this.#pendingChange.before.baseFingerprint - ? { ...this.#pendingChange.before.baseFingerprint } - : null, - }, - tokens: [...this.#pendingChange.tokens], - moveCursorLeftOnEscape: this.#pendingChange.moveCursorLeftOnEscape, - inserted: this.#pendingChange.inserted, - } - : null; - next.#undoStack = cloneUndoStack(this.#undoStack); - next.#redoStack = cloneUndoStack(this.#redoStack); - return next; - } - - getPublicMode() { - return toPublicMode(this.inputMode); - } - - getSelection(): VimSelection | undefined { - if (this.selectionAnchor === null) { - return undefined; - } - return selectionFromAnchor(this.buffer, this.selectionAnchor, this.inputMode === "visual-line"); - } - - getPendingInput(): VimPendingInput | undefined { - switch (this.inputMode) { - case "insert": - return { kind: "insert", text: "" }; - case "command": - case "search-forward": - case "search-backward": - return { kind: this.inputMode, text: this.#pendingInput }; - default: - return undefined; - } - } - - rollbackPendingInsert(): void { - if (this.#pendingChange) { - this.buffer.restore(this.#pendingChange.before); - this.#pendingChange = null; - } - this.inputMode = "normal"; - this.selectionAnchor = null; - this.#pendingInput = ""; - } - - setCursor(line: number, col: number): void { - this.buffer.setCursor({ line, col }); - } - - async executeTokens( - tokens: readonly VimKeyToken[], - lastCommand?: string, - onStep?: () => Promise, - ): Promise { - const previousStepCallback = this.#stepCallback; - this.#stepCallback = onStep ?? previousStepCallback; - this.lastCommand = lastCommand; - this.statusMessage = undefined; - this.diagnostics = undefined; - - try { - for (let index = 0; index < tokens.length; ) { - switch (this.inputMode) { - case "insert": - index = await this.#executeInsert(tokens, index); - break; - case "command": - case "search-forward": - case "search-backward": - index = await this.#executePrompt(tokens, index); - break; - case "visual": - case "visual-line": - index = await this.#executeVisual(tokens, index); - break; - default: - index = await this.#executeNormal(tokens, index); - break; - } - if (this.closed) { - break; - } - this.#ensureCursorVisible(); - await this.#stepCallback?.(); - } - } finally { - this.#stepCallback = previousStepCallback; - } - } - - async close(force: boolean): Promise { - if (this.buffer.modified && !force) { - throw new VimError("Unsaved changes; use force to discard"); - } - this.closed = true; - this.statusMessage = `Closed ${this.buffer.displayPath}`; - } - - #ensureCursorVisible(): void { - const line = this.buffer.cursor.line + 1; - if (line < this.viewportStart) { - this.viewportStart = line; - return; - } - const viewportEnd = this.viewportStart + DEFAULT_VIEWPORT_HEIGHT - 1; - if (line > viewportEnd) { - this.viewportStart = Math.max(1, line - DEFAULT_VIEWPORT_HEIGHT + 1); - } - } - - centerViewportOnCursor(size = DEFAULT_VIEWPORT_HEIGHT): void { - const lineCount = Math.max(this.buffer.lineCount(), 1); - const clampedSize = Math.max(1, Math.min(size, lineCount)); - const maxStart = Math.max(1, lineCount - clampedSize + 1); - this.viewportStart = Math.max(1, Math.min(this.buffer.cursor.line + 1 - Math.floor(clampedSize / 2), maxStart)); - } - - #clearSelection(): void { - if (this.selectionAnchor && (this.inputMode === "visual" || this.inputMode === "visual-line")) { - this.lastVisual = { - anchor: clonePosition(this.selectionAnchor), - cursor: clonePosition(this.buffer.cursor), - mode: this.inputMode, - }; - } - this.selectionAnchor = null; - if (this.inputMode === "visual" || this.inputMode === "visual-line") { - this.inputMode = "normal"; - } - } - - async #ensureEditable(): Promise { - await this.#callbacks.beforeMutate(this.buffer); - this.diagnostics = undefined; - this.statusMessage = undefined; - } - - #pushUndo(entry: VimUndoEntry, changeTokens?: readonly string[]): void { - if (snapshotEqual(entry.before, entry.after)) { - return; - } - this.#undoStack.push(entry); - this.#redoStack = []; - if (changeTokens && changeTokens.length > 0) { - this.#lastChangeTokens = [...changeTokens]; - } - } - - #beginPendingChange(prefixTokens: readonly string[], moveCursorLeftOnEscape: boolean): void { - this.#pendingChange = { - before: this.buffer.createSnapshot(), - tokens: [...prefixTokens], - moveCursorLeftOnEscape, - inserted: false, - }; - } - - #markPendingInserted(): void { - if (this.#pendingChange) { - this.#pendingChange.inserted = true; - } - } - - #commitPendingChange(): void { - if (!this.#pendingChange) { - return; - } - const entry: VimUndoEntry = { - before: this.#pendingChange.before, - after: this.buffer.createSnapshot(), - }; - this.#pushUndo(entry, this.#pendingChange.tokens); - this.#pendingChange = null; - } - - async #applyAtomicChange(tokens: readonly string[], mutator: () => void): Promise { - await this.#ensureEditable(); - const before = this.buffer.createSnapshot(); - mutator(); - this.buffer.modified = true; - this.#pushUndo({ before, after: this.buffer.createSnapshot() }, tokens); - } - - async #startInsertChange( - tokens: readonly string[], - mutator?: () => void, - moveCursorLeftOnEscape = true, - ): Promise { - await this.#ensureEditable(); - this.#beginPendingChange(tokens, moveCursorLeftOnEscape); - mutator?.(); - this.buffer.modified = true; - this.inputMode = "insert"; - } - - async #executePrompt(tokens: readonly VimKeyToken[], index: number): Promise { - const token = tokens[index]!; - if (token.value === "Esc") { - this.#pendingInput = ""; - this.inputMode = "normal"; - return index + 1; - } - if (token.value === "BS") { - this.#pendingInput = this.#pendingInput.slice(0, -1); - return index + 1; - } - if (token.value !== "CR") { - this.#pendingInput += token.value === "Tab" ? "\t" : token.value; - return index + 1; - } - - const input = this.#pendingInput; - this.#pendingInput = ""; - const mode = this.inputMode; - this.inputMode = "normal"; - if (mode === "command") { - await this.#executeEx(input); - } else { - await this.#runSearch(input, mode === "search-forward" ? 1 : -1, true); - } - return index + 1; - } - - #exitInsertMode(): void { - if (this.#pendingChange) { - this.#pendingChange.tokens.push("Esc"); - if (this.#pendingChange.moveCursorLeftOnEscape && this.#pendingChange.inserted && this.buffer.cursor.col > 0) { - this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col - 1 }); - } - } - this.inputMode = "normal"; - this.#commitPendingChange(); - } - - async applyLiteralInsert(text: string, exitInsertMode: boolean): Promise { - if (this.inputMode !== "insert" || !this.#pendingChange) { - throw new VimError("Insert payload requires INSERT mode."); - } - - if (text.length > 0) { - const offset = this.buffer.currentOffset(); - this.buffer.replaceOffsets(offset, offset, text, offset + text.length); - this.buffer.modified = true; - if (text.includes("\n")) { - this.buffer.trailingNewline = this.buffer.trailingNewline || text.endsWith("\n"); - } - this.#pendingChange.tokens.push(...literalTextToReplayTokens(text)); - this.#markPendingInserted(); - } - - if (exitInsertMode) { - this.#exitInsertMode(); - } - } - - async #executeInsert(tokens: readonly VimKeyToken[], index: number): Promise { - const token = tokens[index]!; - if (token.value === "Esc") { - this.#exitInsertMode(); - return index + 1; - } - if (token.value === "CR") { - const offset = this.buffer.currentOffset(); - this.buffer.replaceOffsets(offset, offset, "\n", offset + 1); - this.buffer.modified = true; - this.buffer.trailingNewline = true; - this.#pendingChange?.tokens.push(token.value); - this.#markPendingInserted(); - return index + 1; - } - if (token.value === "BS") { - const offset = this.buffer.currentOffset(); - if (offset > 0) { - this.buffer.deleteOffsets(offset - 1, offset); - this.buffer.modified = true; - this.#pendingChange?.tokens.push(token.value); - this.#markPendingInserted(); - } - return index + 1; - } - if (token.value === "Tab") { - const offset = this.buffer.currentOffset(); - this.buffer.replaceOffsets(offset, offset, "\t", offset + 1); - this.buffer.modified = true; - this.#pendingChange?.tokens.push(token.value); - this.#markPendingInserted(); - return index + 1; - } - if (token.value === "C-w") { - const offset = this.buffer.currentOffset(); - const text = this.buffer.getText(); - let start = previousWordStart(text, offset, false); - if (start === offset && start > 0) { - start -= 1; - } - this.buffer.deleteOffsets(start, offset); - this.buffer.modified = true; - this.#pendingChange?.tokens.push(token.value); - this.#markPendingInserted(); - return index + 1; - } - if (token.value === "C-u") { - const offset = this.buffer.currentOffset(); - const lineStart = this.buffer.positionToOffset({ line: this.buffer.cursor.line, col: 0 }); - if (offset > lineStart) { - this.buffer.deleteOffsets(lineStart, offset); - this.buffer.modified = true; - this.#pendingChange?.tokens.push(token.value); - this.#markPendingInserted(); - } - return index + 1; - } - if (token.value === "C-o") { - // Execute one normal-mode command, then return to insert - const nextToken = tokens[index + 1]; - if (!nextToken) { - return index + 1; - } - const savedMode = this.inputMode; - this.inputMode = "normal"; - const nextIdx = await this.#executeNormal(tokens, index + 1); - this.inputMode = savedMode; - return nextIdx; - } - - const insertText = token.value; - const offset = this.buffer.currentOffset(); - this.buffer.replaceOffsets(offset, offset, insertText, offset + insertText.length); - this.buffer.modified = true; - this.#pendingChange?.tokens.push(token.value); - this.#markPendingInserted(); - return index + 1; - } - - async #executeVisual(tokens: readonly VimKeyToken[], index: number): Promise { - const token = tokens[index]!; - if (token.value === "Esc") { - this.#clearSelection(); - return index + 1; - } - if (token.value === "v") { - if (this.inputMode === "visual") { - this.#clearSelection(); - } - return index + 1; - } - if (token.value === "V") { - this.inputMode = this.inputMode === "visual-line" ? "visual" : "visual-line"; - return index + 1; - } - if (token.value === "o") { - if (this.selectionAnchor) { - const tmp = clonePosition(this.buffer.cursor); - this.buffer.setCursor(this.selectionAnchor); - this.selectionAnchor = tmp; - } - return index + 1; - } - if (token.value === "J") { - const visual = expandVisualOffsets( - this.buffer, - this.selectionAnchor ?? this.buffer.cursor, - this.inputMode === "visual-line", - ); - const startLine = this.buffer.offsetToPosition(visual.start).line; - const endLine = this.buffer.offsetToPosition(Math.max(visual.start, visual.end - 1)).line; - await this.#applyAtomicChange(["J"], () => { - this.buffer.joinLines(startLine, endLine - startLine); - }); - this.#clearSelection(); - return index + 1; - } - if (token.value === "u" || token.value === "U") { - const visual = expandVisualOffsets( - this.buffer, - this.selectionAnchor ?? this.buffer.cursor, - this.inputMode === "visual-line", - ); - await this.#applyAtomicChange([token.value], () => { - const original = this.buffer.getText().slice(visual.start, visual.end); - const transformed = token.value === "U" ? original.toUpperCase() : original.toLowerCase(); - this.buffer.replaceOffsets(visual.start, visual.end, transformed, visual.start); - }); - this.#clearSelection(); - return index + 1; - } - if (token.value === "p" || token.value === "P") { - const visual = expandVisualOffsets( - this.buffer, - this.selectionAnchor ?? this.buffer.cursor, - this.inputMode === "visual-line", - ); - await this.#applyAtomicChange([token.value], () => { - const removed = this.buffer.getText().slice(visual.start, visual.end); - const pasteText = this.register.text; - this.buffer.replaceOffsets(visual.start, visual.end, pasteText, visual.start + pasteText.length); - this.register = { kind: visual.linewise ? "line" : "char", text: removed }; - }); - this.#clearSelection(); - return index + 1; - } - if (token.value === "g") { - const next = tokens[index + 1]; - if (!next) { - throw new VimError("g requires a second key", token); - } - if (next.value === "J") { - const visual = expandVisualOffsets( - this.buffer, - this.selectionAnchor ?? this.buffer.cursor, - this.inputMode === "visual-line", - ); - const startLine = this.buffer.offsetToPosition(visual.start).line; - const endLine = this.buffer.offsetToPosition(Math.max(visual.start, visual.end - 1)).line; - await this.#applyAtomicChange(["g", "J"], () => { - const start = this.buffer.clampLine(startLine); - const end = this.buffer.clampLine(endLine); - if (start < end) { - const joined = this.buffer.lines.slice(start, end + 1).join(""); - this.buffer.lines.splice(start, end - start + 1, joined); - this.buffer.setCursor({ line: start, col: Math.max(0, joined.length - 1) }); - } - }); - this.#clearSelection(); - return index + 2; - } - if (next.value === "u" || next.value === "U" || next.value === "~") { - const visual = expandVisualOffsets( - this.buffer, - this.selectionAnchor ?? this.buffer.cursor, - this.inputMode === "visual-line", - ); - await this.#applyAtomicChange(["g", next.value], () => { - const original = this.buffer.getText().slice(visual.start, visual.end); - const transformed = - next.value === "u" - ? original.toLowerCase() - : next.value === "U" - ? original.toUpperCase() - : toggleCase(original); - this.buffer.replaceOffsets(visual.start, visual.end, transformed, visual.start); - }); - this.#clearSelection(); - return index + 2; - } - throw new VimError(`Unsupported g command: g${next.display}`, next); - } - - const { count, hasCount, nextIndex } = this.#readCount(tokens, index); - const opToken = tokens[nextIndex]; - if (!opToken) { - return nextIndex; - } - - switch (opToken.value) { - case "d": - case "x": - case "X": - case "D": - case "y": - case "c": - case "s": - case "S": - case "C": - case ">": - case "<": - case "~": { - const visual = expandVisualOffsets( - this.buffer, - this.selectionAnchor ?? this.buffer.cursor, - this.inputMode === "visual-line", - ); - const consumeExtraIndent = - (opToken.value === ">" || opToken.value === "<") && tokens[nextIndex + 1]?.value === opToken.value; - const operatorValue = - opToken.value === "x" || opToken.value === "X" || opToken.value === "D" - ? "d" - : opToken.value === "s" || opToken.value === "S" || opToken.value === "C" - ? "c" - : opToken.value; - const visualTokens = consumeExtraIndent ? [opToken.value, opToken.value] : [opToken.value]; - await this.#applyVisualOperator(operatorValue, visual, count, visualTokens); - return nextIndex + visualTokens.length; - } - case "r": { - const replacement = tokens[nextIndex + 1]; - if (!replacement || replacement.value.length !== 1) { - throw new VimError("Visual replace requires a literal character", opToken); - } - const visual = expandVisualOffsets( - this.buffer, - this.selectionAnchor ?? this.buffer.cursor, - this.inputMode === "visual-line", - ); - await this.#applyAtomicChange(["r", replacement.value], () => { - const original = this.buffer.getText().slice(visual.start, visual.end); - let replaced = ""; - for (const char of original) { - replaced += char === "\n" ? "\n" : replacement.value; - } - this.buffer.replaceOffsets(visual.start, visual.end, replaced, visual.start); - }); - this.#clearSelection(); - return nextIndex + 2; - } - default: - break; - } - - const motion = this.#resolveMotion(tokens, nextIndex, count, hasCount); - this.buffer.setCursor(motion.target); - return motion.nextIndex; - } - - async #applyVisualOperator( - operator: string, - visual: { start: number; end: number; linewise: boolean }, - count: number, - tokens: readonly string[], - ): Promise { - switch (operator) { - case "y": { - this.register = { - kind: visual.linewise ? "line" : "char", - text: this.buffer.getText().slice(visual.start, visual.end), - }; - this.#clearSelection(); - this.statusMessage = `Yanked ${count} selection${count === 1 ? "" : "s"}`; - return; - } - case "d": { - await this.#applyAtomicChange(tokens, () => { - this.#yankAndDeleteRange(visual); - }); - this.#clearSelection(); - return; - } - case "c": { - await this.#startInsertChange(tokens, () => { - this.#yankAndDeleteRange(visual); - }); - this.#clearSelection(); - return; - } - case ">": - case "<": { - const startLine = this.buffer.offsetToPosition(visual.start).line; - const endLine = this.buffer.offsetToPosition(Math.max(visual.start, visual.end - 1)).line; - await this.#applyAtomicChange(tokens, () => { - this.buffer.indentLines( - startLine, - endLine, - detectIndentUnit(this.buffer.lines), - operator === ">" ? 1 : -1, - ); - }); - this.#clearSelection(); - return; - } - case "~": { - await this.#applyAtomicChange(tokens, () => { - const original = this.buffer.getText().slice(visual.start, visual.end); - this.buffer.replaceOffsets(visual.start, visual.end, toggleCase(original), visual.start); - }); - this.#clearSelection(); - return; - } - default: - throw new VimError(`Unsupported visual operator: ${operator}`); - } - } - - async #executeNormal(tokens: readonly VimKeyToken[], index: number): Promise { - const { count, hasCount, nextIndex } = this.#readCount(tokens, index); - const token = tokens[nextIndex]; - if (!token) { - return nextIndex; - } - - switch (token.value) { - case "h": - this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col - count }); - return nextIndex + 1; - case "j": - this.buffer.setCursor({ line: this.buffer.cursor.line + count, col: this.buffer.cursor.col }); - return nextIndex + 1; - case "k": - this.buffer.setCursor({ line: this.buffer.cursor.line - count, col: this.buffer.cursor.col }); - return nextIndex + 1; - case "l": - case " ": - this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col + count }); - return nextIndex + 1; - case "w": - case "W": - case "b": - case "B": - case "e": - case "E": - case "0": - case "$": - case "^": - case "|": - case ";": - case ",": - case "G": - case "f": - case "F": - case "t": - case "T": - case "{": - case "}": - case "%": - case "H": - case "M": - case "+": - case "-": - case "_": - case "L": { - const motion = this.#resolveMotion(tokens, nextIndex, count, hasCount); - this.buffer.setCursor(motion.target); - return motion.nextIndex; - } - case "*": - case "#": { - const text = this.buffer.getText(); - const offset = this.buffer.currentOffset(); - const cat = wordCategory(text[offset] ?? "", false); - if (cat === "space") { - throw new VimError("No word under cursor", token); - } - let start = offset; - while (start > 0 && wordCategory(text[start - 1] ?? "", false) === cat) start -= 1; - let end = offset; - while (end < text.length && wordCategory(text[end] ?? "", false) === cat) end += 1; - const word = text.slice(start, end); - const pattern = `\\b${escapeRegex(word)}\\b`; - const direction = token.value === "*" ? 1 : -1; - for (let step = 0; step < count; step += 1) { - await this.#runSearch(pattern, direction, true); - } - return nextIndex + 1; - } - case "n": - await this.#repeatSearch(this.lastSearch?.direction ?? 1, count); - return nextIndex + 1; - case "N": - await this.#repeatSearch(((this.lastSearch?.direction ?? 1) * -1) as 1 | -1, count); - return nextIndex + 1; - case "/": - this.inputMode = "search-forward"; - this.#pendingInput = ""; - return nextIndex + 1; - case "?": - this.inputMode = "search-backward"; - this.#pendingInput = ""; - return nextIndex + 1; - case ":": - this.inputMode = "command"; - this.#pendingInput = ""; - return nextIndex + 1; - case "v": - this.inputMode = "visual"; - this.selectionAnchor = clonePosition(this.buffer.cursor); - return nextIndex + 1; - case "V": - this.inputMode = "visual-line"; - this.selectionAnchor = clonePosition(this.buffer.cursor); - return nextIndex + 1; - case "i": - // When count > 1 (e.g. `2i`), interpret as `2Gi` — go to line N then insert. - // Models confuse `Ni` with `NGi`; bare `i` with a high count is almost never intended. - if (hasCount) { - this.buffer.setCursor({ line: Math.min(count, this.buffer.lineCount()) - 1, col: 0 }); - } - await this.#startInsertChange(["i"]); - return nextIndex + 1; - case "a": - this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col + 1 }); - await this.#startInsertChange(["a"]); - return nextIndex + 1; - case "I": - this.buffer.setCursor({ - line: this.buffer.cursor.line, - col: this.buffer.firstNonBlank(this.buffer.cursor.line), - }); - await this.#startInsertChange(["I"]); - return nextIndex + 1; - case "A": - this.buffer.setCursor({ - line: this.buffer.cursor.line, - col: this.buffer.getLine(this.buffer.cursor.line).length, - }); - await this.#startInsertChange(["A"]); - return nextIndex + 1; - case "o": - // When count > 1 (e.g. `13o`), interpret as `13Go` — go to line N then open below. - // Models confuse `No` with `NGo`; bare `o` with a high count is almost never intended. - if (hasCount) { - this.buffer.setCursor({ line: Math.min(count, this.buffer.lineCount()) - 1, col: 0 }); - } - await this.#startInsertChange(["o"], () => { - const line = this.buffer.cursor.line + 1; - this.buffer.insertLines(line, [""]); - }); - return nextIndex + 1; - case "O": - if (hasCount) { - this.buffer.setCursor({ line: Math.min(count, this.buffer.lineCount()) - 1, col: 0 }); - } - await this.#startInsertChange(["O"], () => { - const line = this.buffer.cursor.line; - this.buffer.insertLines(line, [""]); - }); - return nextIndex + 1; - case "s": - await this.#startInsertChange(["s"], () => { - this.#deleteCharsForward(count); - }); - return nextIndex + 1; - case "S": - await this.#changeWholeLines(count, ["S"]); - return nextIndex + 1; - case "x": - await this.#applyAtomicChange(["x"], () => { - this.#deleteCharsForward(count); - }); - return nextIndex + 1; - case "X": - await this.#applyAtomicChange(["X"], () => { - const end = this.buffer.currentOffset(); - const start = Math.max(0, end - count); - this.register = { kind: "char", text: this.buffer.deleteOffsets(start, end) }; - }); - return nextIndex + 1; - case "r": { - const replacement = tokens[nextIndex + 1]; - if (!replacement || replacement.value.length !== 1) { - throw new VimError("r requires a replacement character", token); - } - await this.#applyAtomicChange(["r", replacement.value], () => { - const start = this.buffer.currentOffset(); - this.buffer.replaceOffsets( - start, - Math.min(this.buffer.getText().length, start + count), - replacement.value.repeat(count), - start, - ); - }); - return nextIndex + 2; - } - case "~": - await this.#applyAtomicChange(["~"], () => { - const start = this.buffer.currentOffset(); - const end = Math.min(this.buffer.getText().length, start + count); - const text = this.buffer.getText().slice(start, end); - this.buffer.replaceOffsets(start, end, toggleCase(text), end); - }); - return nextIndex + 1; - case "J": - await this.#applyAtomicChange(["J"], () => { - this.buffer.joinLines(this.buffer.cursor.line, count); - }); - return nextIndex + 1; - case "p": - case "P": - await this.#applyAtomicChange([token.value], () => { - this.#paste(token.value === "p", count); - }); - return nextIndex + 1; - case "u": - await this.#undo(count); - return nextIndex + 1; - case "C-r": - await this.#redo(count); - return nextIndex + 1; - case ".": - await this.#repeatLastChange(count, token); - return nextIndex + 1; - case "d": - case "c": - case "y": - case ">": - case "<": - return this.#executeOperator(tokens, nextIndex, count, hasCount, token.value); - case "D": - await this.#applyAtomicChange(["D"], () => { - const start = this.buffer.currentOffset(); - const line = this.buffer.getLine(this.buffer.cursor.line); - const end = start + (line.length - this.buffer.cursor.col); - this.register = { kind: "char", text: this.buffer.deleteOffsets(start, end) }; - }); - return nextIndex + 1; - case "C": - await this.#startInsertChange(["C"], () => { - const start = this.buffer.currentOffset(); - const line = this.buffer.getLine(this.buffer.cursor.line); - const end = start + (line.length - this.buffer.cursor.col); - this.register = { kind: "char", text: this.buffer.deleteOffsets(start, end) }; - }); - return nextIndex + 1; - case "z": { - const zTarget = tokens[nextIndex + 1]; - if (!zTarget) { - throw new VimError("z requires a second key", token); - } - if (zTarget.value === "z") { - this.centerViewportOnCursor(); - } else if (zTarget.value === "t" || zTarget.value === "CR") { - this.viewportStart = this.buffer.cursor.line + 1; - this.buffer.setCursor({ - line: this.buffer.cursor.line, - col: this.buffer.firstNonBlank(this.buffer.cursor.line), - }); - } else if (zTarget.value === "b" || zTarget.value === "-") { - this.viewportStart = Math.max(1, this.buffer.cursor.line + 1 - (DEFAULT_VIEWPORT_HEIGHT - 1)); - this.buffer.setCursor({ - line: this.buffer.cursor.line, - col: this.buffer.firstNonBlank(this.buffer.cursor.line), - }); - } else if (zTarget.value === ".") { - this.centerViewportOnCursor(); - this.buffer.setCursor({ - line: this.buffer.cursor.line, - col: this.buffer.firstNonBlank(this.buffer.cursor.line), - }); - } else if (NOOP_Z_COMMANDS.has(zTarget.value)) { - this.statusMessage = `Ignored z${zTarget.display} (folds unsupported)`; - } else { - throw new VimError(`Unsupported z command: z${zTarget.display}`, zTarget); - } - return nextIndex + 2; - } - case "C-f": - this.buffer.setCursor({ - line: this.buffer.cursor.line + Math.max(1, (DEFAULT_VIEWPORT_HEIGHT - 2) * count), - col: this.buffer.cursor.col, - }); - return nextIndex + 1; - case "C-b": - this.buffer.setCursor({ - line: this.buffer.cursor.line - Math.max(1, (DEFAULT_VIEWPORT_HEIGHT - 2) * count), - col: this.buffer.cursor.col, - }); - return nextIndex + 1; - case "C-d": - this.buffer.setCursor({ - line: this.buffer.cursor.line + Math.max(1, Math.floor(DEFAULT_VIEWPORT_HEIGHT / 2) * count), - col: this.buffer.cursor.col, - }); - return nextIndex + 1; - case "C-u": - this.buffer.setCursor({ - line: this.buffer.cursor.line - Math.max(1, Math.floor(DEFAULT_VIEWPORT_HEIGHT / 2) * count), - col: this.buffer.cursor.col, - }); - return nextIndex + 1; - case "Esc": - return nextIndex + 1; - case "Y": { - const start = this.buffer.cursor.line; - const end = this.buffer.clampLine(start + count - 1); - this.register = { kind: "line", text: this.buffer.lines.slice(start, end + 1).join("\n") }; - this.statusMessage = `Yanked ${end - start + 1} line${end === start ? "" : "s"}`; - return nextIndex + 1; - } - case "R": - await this.#startInsertChange(["R"], undefined, false); - return nextIndex + 1; - case "g": { - const gNext = tokens[nextIndex + 1]; - if (!gNext) { - throw new VimError("g requires a second key", token); - } - if (gNext.value === "g") { - this.buffer.setCursor({ line: hasCount ? Math.max(0, count - 1) : 0, col: 0 }); - return nextIndex + 2; - } - if (gNext.value === "v") { - if (this.lastVisual) { - this.selectionAnchor = clonePosition(this.lastVisual.anchor); - this.buffer.setCursor(this.lastVisual.cursor); - this.inputMode = this.lastVisual.mode; - } - return nextIndex + 2; - } - if (gNext.value === "*" || gNext.value === "#") { - const text = this.buffer.getText(); - const offset = this.buffer.currentOffset(); - const cat = wordCategory(text[offset] ?? "", false); - if (cat === "space") { - throw new VimError("No word under cursor", gNext); - } - let start = offset; - while (start > 0 && wordCategory(text[start - 1] ?? "", false) === cat) start -= 1; - let end = offset; - while (end < text.length && wordCategory(text[end] ?? "", false) === cat) end += 1; - const word = text.slice(start, end); - const direction = gNext.value === "*" ? 1 : -1; - for (let step = 0; step < count; step += 1) { - await this.#runSearch(escapeRegex(word), direction, true); - } - return nextIndex + 2; - } - if (gNext.value === "U" || gNext.value === "u") { - const caseOp = gNext.value; - const { - count: motionCount, - hasCount: hasMotionCount, - nextIndex: motionStart, - } = this.#readCount(tokens, nextIndex + 2); - const motionToken = tokens[motionStart]; - if (!motionToken) { - throw new VimError(`g${caseOp} requires a motion`, gNext); - } - if ((motionToken.value === "U" && caseOp === "U") || (motionToken.value === "u" && caseOp === "u")) { - const effectiveCount = hasMotionCount ? count * motionCount : count; - await this.#applyAtomicChange(["g", caseOp, motionToken.value], () => { - const start = this.buffer.cursor.line; - const end = this.buffer.clampLine(start + effectiveCount - 1); - for (let line = start; line <= end; line++) { - const content = this.buffer.getLine(line); - this.buffer.replaceLine(line, caseOp === "U" ? content.toUpperCase() : content.toLowerCase()); - } - }); - return motionStart + 1; - } - const effectiveCount = hasMotionCount ? count * motionCount : count; - const motion = this.#resolveMotion(tokens, motionStart, effectiveCount, hasCount || hasMotionCount); - const range = this.#resolveMotionRange(motion); - await this.#applyAtomicChange( - tokens.slice(nextIndex, motion.nextIndex).map(tokenEntry => tokenEntry.value), - () => { - const text = this.buffer.getText(); - const slice = text.slice(range.start, range.end); - const transformed = caseOp === "U" ? slice.toUpperCase() : slice.toLowerCase(); - this.buffer.replaceOffsets(range.start, range.end, transformed, range.start); - }, - ); - return motion.nextIndex; - } - if (gNext.value === "~") { - const { - count: motionCount, - hasCount: hasMotionCount, - nextIndex: motionStart, - } = this.#readCount(tokens, nextIndex + 2); - const motionToken = tokens[motionStart]; - if (!motionToken) { - throw new VimError("g~ requires a motion", gNext); - } - if (motionToken.value === "~") { - const effectiveCount = hasMotionCount ? count * motionCount : count; - await this.#applyAtomicChange(["g", "~", motionToken.value], () => { - const start = this.buffer.cursor.line; - const end = this.buffer.clampLine(start + effectiveCount - 1); - for (let line = start; line <= end; line += 1) { - this.buffer.replaceLine(line, toggleCase(this.buffer.getLine(line))); - } - }); - return motionStart + 1; - } - const effectiveCount = hasMotionCount ? count * motionCount : count; - const motion = this.#resolveMotion(tokens, motionStart, effectiveCount, hasCount || hasMotionCount); - const range = this.#resolveMotionRange(motion); - await this.#applyAtomicChange( - tokens.slice(nextIndex, motion.nextIndex).map(tokenEntry => tokenEntry.value), - () => { - const text = this.buffer.getText(); - const slice = text.slice(range.start, range.end); - this.buffer.replaceOffsets(range.start, range.end, toggleCase(slice), range.start); - }, - ); - return motion.nextIndex; - } - if (gNext.value === "J") { - await this.#applyAtomicChange(["g", "J"], () => { - const start = this.buffer.clampLine(this.buffer.cursor.line); - const end = this.buffer.clampLine(start + Math.max(count, 1)); - if (start < end) { - const joined = this.buffer.lines.slice(start, end + 1).join(""); - this.buffer.lines.splice(start, end - start + 1, joined); - this.buffer.setCursor({ line: start, col: Math.max(0, joined.length - 1) }); - } - }); - return nextIndex + 2; - } - throw new VimError(`Unsupported g command: g${gNext.display}`, gNext); - } - case "Z": { - const zNext = tokens[nextIndex + 1]; - if (!zNext) { - throw new VimError("Z requires a second key", token); - } - if (zNext.value === "Z") { - await this.#executeEx("wq"); - return nextIndex + 2; - } - if (zNext.value === "Q") { - await this.#executeEx("q!"); - return nextIndex + 2; - } - throw new VimError(`Unsupported Z command: Z${zNext.display}`, zNext); - } - default: - throw new VimError(`Unsupported command: ${token.display}`, token); - } - } - - async #repeatLastChange(count: number, token: VimKeyToken): Promise { - if (!this.#lastChangeTokens || this.#lastChangeTokens.length === 0) { - throw new VimError("No previous change to repeat", token); - } - for (let index = 0; index < count; index += 1) { - await this.executeTokens(replayTokens(this.#lastChangeTokens), "."); - } - } - - async #undo(count: number): Promise { - await this.#ensureEditable(); - let applied = 0; - for (let index = 0; index < count; index += 1) { - const entry = this.#undoStack.pop(); - if (!entry) { - break; - } - this.#redoStack.push(entry); - this.buffer.restore(entry.before); - applied += 1; - } - this.inputMode = "normal"; - this.selectionAnchor = null; - this.#pendingChange = null; - this.statusMessage = `Undid ${applied} change${applied === 1 ? "" : "s"}`; - } - - async #redo(count: number): Promise { - await this.#ensureEditable(); - let applied = 0; - for (let index = 0; index < count; index += 1) { - const entry = this.#redoStack.pop(); - if (!entry) { - break; - } - this.#undoStack.push(entry); - this.buffer.restore(entry.after); - applied += 1; - } - this.inputMode = "normal"; - this.selectionAnchor = null; - this.#pendingChange = null; - this.statusMessage = `Redid ${applied} change${applied === 1 ? "" : "s"}`; - } - - async #executeOperator( - tokens: readonly VimKeyToken[], - operatorIndex: number, - operatorCount: number, - hasOperatorCount: boolean, - operator: string, - ): Promise { - const { count: motionCount, hasCount: hasMotionCount, nextIndex } = this.#readCount(tokens, operatorIndex + 1); - const token = tokens[nextIndex]; - if (!token) { - throw new VimError(`Operator ${operator} requires a motion`, tokens[operatorIndex]); - } - const hasAnyCount = hasOperatorCount || hasMotionCount; - const effectiveCount = hasMotionCount ? operatorCount * motionCount : operatorCount; - - if (token.value === operator) { - if (operator === "d") { - await this.#applyAtomicChange([operator, operator], () => { - const start = this.buffer.cursor.line; - const removed = this.buffer.deleteLines(start, start + Math.max(1, effectiveCount) - 1); - this.register = { kind: "line", text: removed.join("\n") }; - }); - return nextIndex + 1; - } - if (operator === "y") { - const start = this.buffer.cursor.line; - const end = this.buffer.clampLine(start + Math.max(1, effectiveCount) - 1); - this.register = { kind: "line", text: this.buffer.lines.slice(start, end + 1).join("\n") }; - this.statusMessage = `Yanked ${end - start + 1} line${end === start ? "" : "s"}`; - return nextIndex + 1; - } - if (operator === "c") { - await this.#changeWholeLines(Math.max(1, effectiveCount), [operator, operator]); - return nextIndex + 1; - } - if (operator === ">" || operator === "<") { - await this.#applyAtomicChange([operator, operator], () => { - this.buffer.indentLines( - this.buffer.cursor.line, - this.buffer.cursor.line + Math.max(1, effectiveCount) - 1, - detectIndentUnit(this.buffer.lines), - operator === ">" ? 1 : -1, - ); - }); - return nextIndex + 1; - } - } - - if (token.value === "i" || token.value === "a") { - const object = tokens[nextIndex + 1]; - if (!object) { - throw new VimError(`Missing text object after ${operator}${token.value}`, token); - } - const textObject = this.#resolveTextObject(token.value === "i", object.value, object); - await this.#applyOperatorToMotion( - operator, - { nextIndex: nextIndex + 2, target: this.buffer.cursor, range: textObject }, - [operator, token.value, object.value], - ); - return nextIndex + 2; - } - - // In vim, `cw` and `cW` act like `ce` and `cE` (don't include trailing whitespace) - const motionToken = tokens[nextIndex]; - let motion: MotionResult; - if (operator === "c" && motionToken && (motionToken.value === "w" || motionToken.value === "W")) { - const eMotionValue = motionToken.value === "w" ? "e" : "E"; - const syntheticTokens: readonly VimKeyToken[] = [ - ...tokens.slice(0, nextIndex), - { ...motionToken, value: eMotionValue }, - ...tokens.slice(nextIndex + 1), - ]; - motion = this.#resolveMotion(syntheticTokens, nextIndex, effectiveCount, hasAnyCount); - } else { - motion = this.#resolveMotion(tokens, nextIndex, effectiveCount, hasAnyCount); - } - await this.#applyOperatorToMotion( - operator, - motion, - tokens.slice(operatorIndex, motion.nextIndex).map(tokenEntry => tokenEntry.value), - ); - return motion.nextIndex; - } - - async #applyOperatorToMotion(operator: string, motion: MotionResult, tokens: readonly string[]): Promise { - if (operator === "y") { - const range = this.#resolveMotionRange(motion); - this.register = { - kind: range.linewise ? "line" : "char", - text: this.buffer.getText().slice(range.start, range.end), - }; - this.statusMessage = `Yanked ${range.linewise ? "line" : "selection"}`; - return; - } - - if (operator === ">" || operator === "<") { - const range = this.#resolveMotionRange(motion); - const startLine = this.buffer.offsetToPosition(range.start).line; - const endLine = this.buffer.offsetToPosition(Math.max(range.start, range.end - 1)).line; - await this.#applyAtomicChange(tokens, () => { - this.buffer.indentLines(startLine, endLine, detectIndentUnit(this.buffer.lines), operator === ">" ? 1 : -1); - }); - return; - } - - if (operator === "d") { - const range = this.#resolveMotionRange(motion); - await this.#applyAtomicChange(tokens, () => { - this.#yankAndDeleteRange(range); - }); - return; - } - - if (operator === "c") { - const range = this.#resolveMotionRange(motion); - await this.#startInsertChange(tokens, () => { - this.#yankAndDeleteRange(range); - }); - return; - } - } - - #yankAndDeleteRange(range: { start: number; end: number; linewise: boolean }): void { - this.register = { - kind: range.linewise ? "line" : "char", - text: this.buffer.getText().slice(range.start, range.end), - }; - this.buffer.deleteOffsets(range.start, range.end); - } - - #deleteCharsForward(count: number): void { - const start = this.buffer.currentOffset(); - this.register = { - kind: "char", - text: this.buffer.deleteOffsets(start, Math.min(this.buffer.getText().length, start + count)), - }; - } - - async #changeWholeLines(count: number, tokens: readonly string[]): Promise { - await this.#startInsertChange(tokens, () => { - const start = this.buffer.cursor.line; - const end = this.buffer.clampLine(start + count - 1); - const removed = this.buffer.lines.slice(start, end + 1); - this.register = { kind: "line", text: removed.join("\n") }; - this.buffer.lines.splice(start, end - start + 1, ""); - if (this.buffer.lines.length === 0) { - this.buffer.lines = [""]; - } - this.buffer.setCursor({ line: Math.min(start, this.buffer.lastLineIndex()), col: 0 }); - }); - } - - #resolveMotionRange(motion: MotionResult): { start: number; end: number; linewise: boolean } { - if (motion.range) { - return { - start: motion.range.start, - end: motion.range.end, - linewise: motion.range.linewise ?? false, - }; - } - - if (motion.linewise) { - const startLine = Math.min(this.buffer.cursor.line, motion.target.line); - const endLine = Math.max(this.buffer.cursor.line, motion.target.line); - const start = this.buffer.positionToOffset({ line: startLine, col: 0 }); - const end = - endLine >= this.buffer.lastLineIndex() - ? this.buffer.getText().length - : this.buffer.positionToOffset({ line: endLine + 1, col: 0 }); - return { start, end, linewise: true }; - } - - const from = this.buffer.positionToOffset(this.buffer.cursor); - const to = this.buffer.positionToOffset(motion.target); - const normalized = normalizeRange(from, to); - return { - start: normalized.start, - end: normalized.end + (motion.inclusive === false ? 0 : 1), - linewise: false, - }; - } - - #resolveMotion(tokens: readonly VimKeyToken[], index: number, count: number, hasCount = true): MotionResult { - const token = tokens[index]; - if (!token) { - throw new VimError("Missing motion"); - } - - const text = this.buffer.getText(); - switch (token.value) { - case "h": - return { - nextIndex: index + 1, - target: { line: this.buffer.cursor.line, col: this.buffer.cursor.col - count }, - }; - case "j": - return { - nextIndex: index + 1, - target: { line: this.buffer.cursor.line + count, col: this.buffer.cursor.col }, - linewise: true, - }; - case "k": - return { - nextIndex: index + 1, - target: { line: this.buffer.cursor.line - count, col: this.buffer.cursor.col }, - linewise: true, - }; - case "l": - case " ": - return { - nextIndex: index + 1, - target: { line: this.buffer.cursor.line, col: this.buffer.cursor.col + count }, - }; - case "w": - case "W": { - let offset = this.buffer.currentOffset(); - for (let step = 0; step < count; step += 1) { - offset = nextWordStart(text, step === 0 ? offset + 1 : offset, token.value === "W"); - } - return { nextIndex: index + 1, target: this.buffer.offsetToPosition(offset), inclusive: false }; - } - case "b": - case "B": { - let offset = this.buffer.currentOffset(); - for (let step = 0; step < count; step += 1) { - offset = previousWordStart(text, offset, token.value === "B"); - } - return { nextIndex: index + 1, target: this.buffer.offsetToPosition(offset) }; - } - case "e": - case "E": { - let offset = this.buffer.currentOffset(); - for (let step = 0; step < count; step += 1) { - offset = endOfWord(text, step === 0 ? offset : offset + 1, token.value === "E"); - } - return { nextIndex: index + 1, target: this.buffer.offsetToPosition(offset) }; - } - case "0": - return { nextIndex: index + 1, target: { line: this.buffer.cursor.line, col: 0 } }; - case "^": - return { - nextIndex: index + 1, - target: { line: this.buffer.cursor.line, col: this.buffer.firstNonBlank(this.buffer.cursor.line) }, - }; - case "|": - return { - nextIndex: index + 1, - target: { line: this.buffer.cursor.line, col: Math.max(0, count - 1) }, - }; - case "$": - return { - nextIndex: index + 1, - target: { - line: this.buffer.cursor.line, - col: Math.max(0, this.buffer.getLine(this.buffer.cursor.line).length - 1), - }, - }; - case "+": { - const targetLine = this.buffer.clampLine(this.buffer.cursor.line + count); - return { - nextIndex: index + 1, - target: { line: targetLine, col: this.buffer.firstNonBlank(targetLine) }, - linewise: true, - }; - } - case "-": { - const targetLine = this.buffer.clampLine(this.buffer.cursor.line - count); - return { - nextIndex: index + 1, - target: { line: targetLine, col: this.buffer.firstNonBlank(targetLine) }, - linewise: true, - }; - } - case "_": { - const targetLine = this.buffer.clampLine(this.buffer.cursor.line + (count - 1)); - return { - nextIndex: index + 1, - target: { line: targetLine, col: this.buffer.firstNonBlank(targetLine) }, - linewise: true, - }; - } - case "g": { - const next = tokens[index + 1]; - if (!next) { - throw new VimError("Unsupported g motion", token); - } - if (next.value === "g") { - return { - nextIndex: index + 2, - target: { line: hasCount ? Math.max(0, count - 1) : 0, col: 0 }, - linewise: true, - }; - } - if (next.value === "e" || next.value === "E") { - let offset = this.buffer.currentOffset(); - for (let step = 0; step < count; step += 1) { - offset = endOfPreviousWord(text, offset, next.value === "E"); - } - return { nextIndex: index + 2, target: this.buffer.offsetToPosition(offset) }; - } - if (next.value === "_") { - const targetLine = this.buffer.clampLine(this.buffer.cursor.line + (count - 1)); - return { - nextIndex: index + 2, - target: { line: targetLine, col: lastNonBlankColumn(this.buffer.getLine(targetLine)) }, - }; - } - throw new VimError("Unsupported g motion", token); - } - case "G": - return { - nextIndex: index + 1, - target: { line: hasCount ? count - 1 : this.buffer.lastLineIndex(), col: 0 }, - linewise: true, - }; - case "f": - case "F": - case "t": - case "T": { - const searchToken = tokens[index + 1]; - if (!searchToken || searchToken.value.length !== 1) { - throw new VimError(`${token.value} requires a literal character`, token); - } - this.lastCharFind = { char: searchToken.value, mode: token.value as "f" | "F" | "t" | "T" }; - const line = this.buffer.getLine(this.buffer.cursor.line); - const cursorCol = this.buffer.cursor.col; - let matchIndex = -1; - if (token.value === "f" || token.value === "t") { - let start = cursorCol + 1; - for (let step = 0; step < count; step += 1) { - matchIndex = line.indexOf(searchToken.value, start); - if (matchIndex === -1) break; - start = matchIndex + 1; - } - if (matchIndex === -1) { - throw new VimError(`Character not found: ${searchToken.value}`, searchToken); - } - if (token.value === "t") { - matchIndex -= 1; - } - } else { - let start = Math.max(0, cursorCol - 1); - for (let step = 0; step < count; step += 1) { - matchIndex = line.lastIndexOf(searchToken.value, start); - if (matchIndex === -1) break; - start = matchIndex - 1; - } - if (matchIndex === -1) { - throw new VimError(`Character not found: ${searchToken.value}`, searchToken); - } - if (token.value === "T") { - matchIndex += 1; - } - } - return { - nextIndex: index + 2, - target: { line: this.buffer.cursor.line, col: Math.max(0, matchIndex) }, - }; - } - case "{": - return { - nextIndex: index + 1, - target: { line: findParagraphStart(this.buffer.lines, this.buffer.cursor.line), col: 0 }, - linewise: true, - }; - case "}": - return { - nextIndex: index + 1, - target: { line: findParagraphEnd(this.buffer.lines, this.buffer.cursor.line), col: 0 }, - linewise: true, - }; - case "%": { - const match = this.#findMatchingBracket(); - return { nextIndex: index + 1, target: match }; - } - case "H": - return { - nextIndex: index + 1, - target: { line: Math.max(0, this.viewportStart - 1), col: 0 }, - linewise: true, - }; - case "M": - return { - nextIndex: index + 1, - target: { line: Math.max(0, this.viewportStart - 1 + 20), col: 0 }, - linewise: true, - }; - case "L": - return { - nextIndex: index + 1, - target: { line: Math.max(0, this.viewportStart - 1 + 39), col: 0 }, - linewise: true, - }; - case ";": - case ",": { - if (!this.lastCharFind) { - throw new VimError( - "No previous character search. If you meant an ex-command range like `:4,5d`, add the `:` prefix and `` suffix.", - token, - ); - } - let mode = this.lastCharFind.mode; - if (token.value === ",") { - const reverseMap: Record = { f: "F", F: "f", t: "T", T: "t" }; - mode = reverseMap[mode]!; - } - const line = this.buffer.getLine(this.buffer.cursor.line); - const cursorCol = this.buffer.cursor.col; - let matchIndex = -1; - if (mode === "f" || mode === "t") { - let start = cursorCol + 1; - for (let step = 0; step < count; step += 1) { - matchIndex = line.indexOf(this.lastCharFind.char, start); - if (matchIndex === -1) break; - start = matchIndex + 1; - } - if (matchIndex !== -1 && mode === "t") matchIndex -= 1; - } else { - let start = Math.max(0, cursorCol - 1); - for (let step = 0; step < count; step += 1) { - matchIndex = line.lastIndexOf(this.lastCharFind.char, start); - if (matchIndex === -1) break; - start = matchIndex - 1; - } - if (matchIndex !== -1 && mode === "T") matchIndex += 1; - } - if (matchIndex === -1) { - throw new VimError(`Character not found: ${this.lastCharFind.char}`, token); - } - return { - nextIndex: index + 1, - target: { line: this.buffer.cursor.line, col: Math.max(0, matchIndex) }, - }; - } - default: - throw new VimError(`Unsupported motion: ${token.display}`, token); - } - } - - #resolveTextObject( - inner: boolean, - objectToken: string, - sourceToken: VimKeyToken, - ): { start: number; end: number; linewise?: boolean } { - if (objectToken === "w" || objectToken === "W") { - return this.#resolveWordTextObject(inner, objectToken === "W"); - } - if (objectToken === '"' || objectToken === "'" || objectToken === "`") { - return this.#resolveQuoteTextObject(inner, objectToken, sourceToken); - } - - if (objectToken === "p") { - return this.#resolveParagraphTextObject(inner); - } - - const normalized = - objectToken === ")" - ? "(" - : objectToken === "}" - ? "{" - : objectToken === "]" - ? "[" - : objectToken === ">" - ? "<" - : objectToken; - if (!BRACKET_PAIRS.has(normalized)) { - throw new VimError(`Unsupported text object: ${objectToken}`, sourceToken); - } - return this.#resolveBracketTextObject(inner, normalized, sourceToken); - } - - #resolveWordTextObject(inner: boolean, bigWord: boolean): { start: number; end: number } { - const text = this.buffer.getText(); - const cursor = this.buffer.currentOffset(); - let start = cursor; - if (wordCategory(text[start] ?? "", bigWord) === "space") { - start = nextWordStart(text, start, bigWord); - } - const category = wordCategory(text[start] ?? "", bigWord); - while (start > 0 && wordCategory(text[start - 1] ?? "", bigWord) === category) { - start -= 1; - } - let end = start; - while (end < text.length && wordCategory(text[end] ?? "", bigWord) === category) { - end += 1; - } - if (!inner) { - while (end < text.length && wordCategory(text[end] ?? "", bigWord) === "space") { - end += 1; - } - while (start > 0 && wordCategory(text[start - 1] ?? "", bigWord) === "space") { - start -= 1; - } - } - return { start, end }; - } - - #resolveParagraphTextObject(inner: boolean): { start: number; end: number; linewise: boolean } { - const lines = this.buffer.lines; - const cursorLine = this.buffer.cursor.line; - let start = cursorLine; - let end = cursorLine; - // Find paragraph boundaries (delimited by blank lines) - if (lines[cursorLine]?.trim().length === 0) { - // On a blank line: select contiguous blank lines - while (start > 0 && lines[start - 1]!.trim().length === 0) start -= 1; - while (end < lines.length - 1 && lines[end + 1]!.trim().length === 0) end += 1; - if (!inner) { - // Include following non-blank paragraph - while (end < lines.length - 1 && lines[end + 1]!.trim().length > 0) end += 1; - } - } else { - // On a non-blank line: select contiguous non-blank lines - while (start > 0 && lines[start - 1]!.trim().length > 0) start -= 1; - while (end < lines.length - 1 && lines[end + 1]!.trim().length > 0) end += 1; - if (!inner) { - // Include trailing blank lines - while (end < lines.length - 1 && lines[end + 1]!.trim().length === 0) end += 1; - } - } - const startOffset = this.buffer.positionToOffset({ line: start, col: 0 }); - const endOffset = - end >= this.buffer.lastLineIndex() - ? this.buffer.getText().length - : this.buffer.positionToOffset({ line: end + 1, col: 0 }); - return { start: startOffset, end: endOffset, linewise: true }; - } - - #resolveQuoteTextObject(inner: boolean, quote: string, sourceToken: VimKeyToken): { start: number; end: number } { - const line = this.buffer.getLine(this.buffer.cursor.line); - const col = this.buffer.cursor.col; - const before = line.lastIndexOf(quote, col); - const after = line.indexOf(quote, col + (line[col] === quote ? 1 : 0)); - if (before === -1 || after === -1 || before === after) { - throw new VimError(`Quote text object not found for ${quote}`, sourceToken); - } - const startCol = inner ? before + 1 : before; - const endCol = inner ? after : after + 1; - return { - start: this.buffer.positionToOffset({ line: this.buffer.cursor.line, col: startCol }), - end: this.buffer.positionToOffset({ line: this.buffer.cursor.line, col: endCol }), - }; - } - - #resolveBracketTextObject(inner: boolean, open: string, sourceToken: VimKeyToken): { start: number; end: number } { - const close = BRACKET_PAIRS.get(open)!; - const text = this.buffer.getText(); - const cursor = this.buffer.currentOffset(); - let start = -1; - let depth = 0; - for (let index = cursor; index >= 0; index -= 1) { - const char = text[index] ?? ""; - if (char === close) { - depth += 1; - } else if (char === open) { - if (depth === 0) { - start = index; - break; - } - depth -= 1; - } - } - if (start === -1) { - throw new VimError(`Text object ${open}${close} not found`, sourceToken); - } - let end = -1; - depth = 0; - for (let index = start; index < text.length; index += 1) { - const char = text[index] ?? ""; - if (char === open) { - depth += 1; - } else if (char === close) { - depth -= 1; - if (depth === 0) { - end = index; - break; - } - } - } - if (end === -1) { - throw new VimError(`Text object ${open}${close} not found`, sourceToken); - } - return { - start: inner ? start + 1 : start, - end: inner ? end : end + 1, - }; - } - - #findMatchingBracket(): Position { - const text = this.buffer.getText(); - const cursor = this.buffer.currentOffset(); - let offset = cursor; - let char = text[offset] ?? ""; - if (!BRACKET_PAIRS.has(char) && !CLOSING_BRACKETS.has(char)) { - offset += 1; - char = text[offset] ?? ""; - } - if (BRACKET_PAIRS.has(char)) { - const close = BRACKET_PAIRS.get(char)!; - let depth = 0; - for (let index = offset; index < text.length; index += 1) { - const current = text[index] ?? ""; - if (current === char) depth += 1; - if (current === close) { - depth -= 1; - if (depth === 0) { - return this.buffer.offsetToPosition(index); - } - } - } - } - if (CLOSING_BRACKETS.has(char)) { - const open = CLOSING_BRACKETS.get(char)!; - let depth = 0; - for (let index = offset; index >= 0; index -= 1) { - const current = text[index] ?? ""; - if (current === char) depth += 1; - if (current === open) { - depth -= 1; - if (depth === 0) { - return this.buffer.offsetToPosition(index); - } - } - } - } - throw new VimError("Matching bracket not found"); - } - - async #runSearch(pattern: string, direction: 1 | -1, updateState: boolean): Promise { - const text = this.buffer.getText(); - const regex = createSearchRegex(pattern, "g"); - const cursor = this.buffer.currentOffset(); - let matchOffset = -1; - - if (direction > 0) { - regex.lastIndex = Math.min(text.length, cursor + 1); - const match = regex.exec(text); - if (match && match.index >= 0) { - matchOffset = match.index; - } else { - regex.lastIndex = 0; - const wrapMatch = regex.exec(text); - if (wrapMatch && wrapMatch.index >= 0) { - matchOffset = wrapMatch.index; - } - } - } else { - const matches = Array.from(text.matchAll(regex)); - for (let index = matches.length - 1; index >= 0; index -= 1) { - const match = matches[index]; - if ((match.index ?? -1) < cursor) { - matchOffset = match.index ?? -1; - break; - } - } - if (matchOffset === -1 && matches.length > 0) { - matchOffset = matches[matches.length - 1]?.index ?? -1; - } - } - - if (matchOffset === -1) { - throw new VimError(`Pattern not found: ${pattern}`); - } - - this.buffer.setCursor(this.buffer.offsetToPosition(matchOffset)); - this.statusMessage = `${direction > 0 ? "/" : "?"}${pattern}`; - if (updateState) { - this.lastSearch = { pattern, direction }; - } - } - - async #repeatSearch(direction: 1 | -1, count: number): Promise { - if (!this.lastSearch) { - throw new VimError("No previous search"); - } - for (let index = 0; index < count; index += 1) { - await this.#runSearch(this.lastSearch.pattern, direction, false); - } - this.lastSearch = { pattern: this.lastSearch.pattern, direction }; - } - - #resolveExRange( - range: VimLineRange | "all" | undefined, - defaultStart: number, - defaultEnd = defaultStart, - ): VimLineRange { - const totalLines = Math.max(1, this.buffer.lineCount()); - if (range === "all") { - return { start: 1, end: totalLines }; - } - const next = range ?? { start: defaultStart, end: defaultEnd }; - const start = Math.max(1, Math.min(next.start, totalLines)); - const end = Math.max(start, Math.min(next.end, totalLines)); - return { start, end }; - } - - async #executeEx(input: string): Promise { - const command = parseExCommand(input, { - currentLine: this.buffer.cursor.line + 1, - lastLine: this.buffer.lineCount(), - }); - switch (command.kind) { - case "goto-line": - this.buffer.setCursor({ line: Math.max(0, command.line - 1), col: 0 }); - this.statusMessage = `Line ${command.line}`; - return; - case "write": { - const result = await this.#callbacks.saveBuffer(this.buffer, { force: command.force }); - this.buffer.markSaved(result.loaded); - this.diagnostics = result.diagnostics; - this.statusMessage = result.diagnostics - ? `Wrote ${this.buffer.displayPath} (${result.diagnostics.summary})` - : `Wrote ${this.buffer.displayPath}`; - this.#undoStack = []; - this.#redoStack = []; - return; - } - case "update": - if (!this.buffer.modified) { - this.statusMessage = `${this.buffer.displayPath} unchanged`; - return; - } - await this.#executeEx(command.force ? "w!" : "w"); - return; - case "write-quit": - await this.#executeEx(command.force ? "w!" : "w"); - this.closed = true; - this.statusMessage = `Wrote and closed ${this.buffer.displayPath}`; - return; - case "quit": - if (this.buffer.modified && !command.force) { - throw new VimError("Unsaved changes; use :q! to discard"); - } - this.closed = true; - this.statusMessage = `Closed ${this.buffer.displayPath}`; - return; - case "edit": { - if (this.buffer.modified && !command.force) { - throw new VimError("Unsaved changes; use :e! to reload or force open"); - } - const next = await this.#callbacks.loadBuffer(command.path ?? this.buffer.displayPath); - this.buffer.replaceLoadedFile(next); - this.inputMode = "normal"; - this.selectionAnchor = null; - this.#pendingInput = ""; - this.#pendingChange = null; - this.#undoStack = []; - this.#redoStack = []; - this.statusMessage = command.path - ? `Opened ${this.buffer.displayPath}` - : `Reloaded ${this.buffer.displayPath}`; - return; - } - case "substitute": { - const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1); - const startLine = range.start; - const endLine = range.end; - const regexFlags = command.flags.includes("i") ? "gi" : "g"; - const regex = createSearchRegex(command.pattern, regexFlags); - let replacements = 0; - await this.#applyAtomicChange([":substitute"], () => { - for (let lineIndex = startLine - 1; lineIndex <= endLine - 1; lineIndex += 1) { - const line = this.buffer.getLine(lineIndex); - let lineReplacements = 0; - const nextLine = line.replace(regex, match => { - if (!command.flags.includes("g") && lineReplacements > 0) { - return match; - } - lineReplacements += 1; - replacements += 1; - return decodeReplacement(command.replacement).replace(/&/g, match); - }); - this.buffer.replaceLine(lineIndex, nextLine); - regex.lastIndex = 0; - } - }); - if (replacements === 0) { - throw new VimError(`Pattern not found: ${command.pattern}`); - } - this.statusMessage = `${replacements} substitution${replacements === 1 ? "" : "s"}`; - return; - } - case "delete": { - const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1); - await this.#applyAtomicChange([":delete"], () => { - const removed = this.buffer.deleteLines(range.start - 1, range.end - 1); - this.register = { kind: "line", text: removed.join("\n") }; - }); - this.statusMessage = `Deleted ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`; - return; - } - case "yank": { - const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1); - this.register = { - kind: "line", - text: this.buffer.lines.slice(range.start - 1, range.end).join("\n"), - }; - this.statusMessage = `Yanked ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`; - return; - } - case "put": { - if (!this.register.text) { - this.statusMessage = "Register empty"; - return; - } - const anchorRange = this.#resolveExRange(command.range, this.buffer.cursor.line + 1); - const anchorLine = command.before ? anchorRange.start : anchorRange.end; - const lines = this.register.text.split("\n"); - await this.#applyAtomicChange([":put"], () => { - const insertAt = command.before - ? Math.max(0, anchorLine - 1) - : Math.min(anchorLine, this.buffer.lineCount()); - this.buffer.insertLines(insertAt, lines); - }); - this.statusMessage = `Put ${lines.length} line${lines.length === 1 ? "" : "s"}`; - return; - } - case "copy": { - const totalLines = this.buffer.lineCount(); - const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1); - const dest = Math.max(0, Math.min(command.destination, totalLines)); - await this.#applyAtomicChange([":copy"], () => { - const lines = this.buffer.lines.slice(range.start - 1, range.end); - this.buffer.insertLines(dest, lines); - }); - this.statusMessage = `Copied ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`; - return; - } - case "move": { - const totalLines = this.buffer.lineCount(); - const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1); - const dest = Math.max(0, Math.min(command.destination, totalLines)); - await this.#applyAtomicChange([":move"], () => { - const lines = this.buffer.lines.splice(range.start - 1, range.end - range.start + 1); - const adjustedDest = dest > range.end - 1 ? dest - lines.length : dest; - this.buffer.lines.splice(adjustedDest, 0, ...lines); - if (this.buffer.lines.length === 0) this.buffer.lines = [""]; - this.buffer.setCursor({ line: adjustedDest, col: 0 }); - }); - this.statusMessage = `Moved ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`; - return; - } - case "sort": { - const range = this.#resolveExRange(command.range ?? "all", 1, this.buffer.lineCount()); - const startLine = range.start; - const endLine = range.end; - const reverse = command.flags.includes("!"); - const ignoreCase = command.flags.includes("i"); - await this.#applyAtomicChange([":sort"], () => { - const slice = this.buffer.lines.slice(startLine - 1, endLine); - slice.sort((a, b) => { - const left = ignoreCase ? a.toLowerCase() : a; - const right = ignoreCase ? b.toLowerCase() : b; - return left < right ? -1 : left > right ? 1 : 0; - }); - if (reverse) slice.reverse(); - for (let i = 0; i < slice.length; i++) { - this.buffer.lines[startLine - 1 + i] = slice[i]!; - } - }); - this.statusMessage = `Sorted ${endLine - startLine + 1} line${endLine === startLine ? "" : "s"}`; - return; - } - case "join": { - const currentLine = this.buffer.cursor.line + 1; - const baseRange = this.#resolveExRange( - command.range, - currentLine, - command.range ? undefined : Math.min(this.buffer.lineCount(), currentLine + 1), - ); - const startLine = baseRange.start; - const endLine = - baseRange.start === baseRange.end ? Math.min(this.buffer.lineCount(), baseRange.end + 1) : baseRange.end; - const lineCount = endLine - startLine + 1; - if (lineCount < 2) { - this.statusMessage = "Nothing to join"; - return; - } - await this.#applyAtomicChange([":join"], () => { - const startIndex = startLine - 1; - if (command.trimWhitespace) { - this.buffer.joinLines(startIndex, lineCount - 1); - return; - } - const joined = this.buffer.lines.slice(startIndex, endLine).join(""); - this.buffer.lines.splice(startIndex, lineCount, joined); - this.buffer.setCursor({ line: startIndex, col: Math.max(0, joined.length - 1) }); - }); - this.statusMessage = `Joined ${lineCount} lines`; - return; - } - case "append": { - const anchorRange = this.#resolveExRange(command.range, this.buffer.cursor.line + 1); - const anchorLine = anchorRange.end; - const lines = command.text.length > 0 ? command.text.split("\n") : [""]; - await this.#applyAtomicChange([":append"], () => { - const insertAt = Math.min(anchorLine, this.buffer.lineCount()); - this.buffer.insertLines(insertAt, lines); - }); - this.statusMessage = `Appended ${lines.length} line${lines.length === 1 ? "" : "s"}`; - return; - } - case "insert-before": { - const anchorRange = this.#resolveExRange(command.range, this.buffer.cursor.line + 1); - const anchorLine = anchorRange.start; - const lines = command.text.length > 0 ? command.text.split("\n") : [""]; - await this.#applyAtomicChange([":insert"], () => { - const insertAt = Math.max(0, anchorLine - 1); - this.buffer.insertLines(insertAt, lines); - }); - this.statusMessage = `Inserted ${lines.length} line${lines.length === 1 ? "" : "s"}`; - return; - } - case "global": { - const regex = createSearchRegex(command.pattern); - const range = this.#resolveExRange(command.range ?? "all", 1, this.buffer.lineCount()); - await this.#applyAtomicChange([":global"], () => { - const linesToProcess: number[] = []; - for (let i = range.start - 1; i <= range.end - 1; i += 1) { - const matches = regex.test(this.buffer.getLine(i)); - regex.lastIndex = 0; - if (command.invert ? !matches : matches) { - linesToProcess.push(i); - } - } - if (command.command === "d" || command.command === "delete") { - // Delete matching lines in reverse to preserve indices - for (let i = linesToProcess.length - 1; i >= 0; i--) { - this.buffer.lines.splice(linesToProcess[i]!, 1); - } - if (this.buffer.lines.length === 0) this.buffer.lines = [""]; - this.buffer.clampCursor(); - this.buffer.trailingNewline = true; - } else { - throw new VimError(`Unsupported :global sub-command: ${command.command}`); - } - }); - this.statusMessage = `Global: processed ${command.pattern}`; - return; - } - } - } - - #paste(after: boolean, count: number): void { - if (!this.register.text) { - return; - } - if (this.register.kind === "line") { - const lines = this.register.text.split("\n"); - const insertAt = after ? this.buffer.cursor.line + 1 : this.buffer.cursor.line; - for (let iteration = 0; iteration < count; iteration += 1) { - this.buffer.insertLines(insertAt + iteration * lines.length, lines); - } - return; - } - const text = this.register.text.repeat(count); - const offset = this.buffer.currentOffset() + (after ? 1 : 0); - this.buffer.replaceOffsets(offset, offset, text, offset + text.length); - } - - #readCount(tokens: readonly VimKeyToken[], index: number): { count: number; hasCount: boolean; nextIndex: number } { - let cursor = index; - let digits = ""; - while (cursor < tokens.length) { - const value = tokens[cursor]?.value ?? ""; - if (!/^\d$/.test(value)) { - break; - } - if (digits.length === 0 && value === "0") { - break; - } - digits += value; - cursor += 1; - } - return { - count: digits.length > 0 ? Number.parseInt(digits, 10) : 1, - hasCount: digits.length > 0, - nextIndex: cursor, - }; - } -} diff --git a/packages/coding-agent/src/vim/parser.ts b/packages/coding-agent/src/vim/parser.ts deleted file mode 100644 index ae1e3794f..000000000 --- a/packages/coding-agent/src/vim/parser.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { VimInputError as VimError, type VimKeyToken } from "./types"; - -const SPECIAL_KEYS = new Map([ - ["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 ""; - 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: "", - sequenceIndex, - offset, - }); - continue; - } - // Handle literal carriage return - if (char === "\r") { - tokens.push({ - value: "CR", - display: "", - 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, - })); -} diff --git a/packages/coding-agent/src/vim/render.ts b/packages/coding-agent/src/vim/render.ts deleted file mode 100644 index 7ea9dae81..000000000 --- a/packages/coding-agent/src/vim/render.ts +++ /dev/null @@ -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): 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, -): 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; -} diff --git a/packages/coding-agent/src/vim/types.ts b/packages/coding-agent/src/vim/types.ts deleted file mode 100644 index 5ca3d8940..000000000 --- a/packages/coding-agent/src/vim/types.ts +++ /dev/null @@ -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"; - } -} diff --git a/packages/coding-agent/test/sdk-tool-activation.test.ts b/packages/coding-agent/test/sdk-tool-activation.test.ts index 8dff72279..decbaab3f 100644 --- a/packages/coding-agent/test/sdk-tool-activation.test.ts +++ b/packages/coding-agent/test/sdk-tool-activation.test.ts @@ -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(); - } - }); }); diff --git a/packages/coding-agent/test/tools/index.test.ts b/packages/coding-agent/test/tools/index.test.ts index 2eea2fd44..edaff2a0f 100644 --- a/packages/coding-agent/test/tools/index.test.ts +++ b/packages/coding-agent/test/tools/index.test.ts @@ -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"]); diff --git a/packages/coding-agent/test/tools/vim.test.ts b/packages/coding-agent/test/tools/vim.test.ts deleted file mode 100644 index 064cf01d6..000000000 --- a/packages/coding-agent/test/tools/vim.test.ts +++ /dev/null @@ -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 { - 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", ":w"]); - 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", "w", "."]), "ciwbar 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", "w"]), "ciwbar 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([""]), ""); - 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"]), ":%s/alpha/delta/g"); - 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"]), ":%d"); - 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"]), ":4,6d"); - 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"]), "2G :.,$g/alpha/d"); - 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"]), ":1,2j"); - 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!"]), ":1,2join!"); - 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$"]), ":1,2t$"); - 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", "1G", ":put", "G", ":put!"]), - ":2,3yank 1G :put G :put!", - ); - 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"]), ":up"); - expect(saveBuffer).not.toHaveBeenCalled(); - expect(engine.statusMessage).toBe("test.ts unchanged"); - - await engine.executeTokens(parseKeySequences(["ccchanged", ":up"]), "ccchanged :up"); - 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/); - }); -}); - -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", "j", "."])] }); - await tool.execute("save", { file: "sample.ts", steps: [step([":w"])] }); - - 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"])] }); - - 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"]), 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"])] }); - 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|/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"])] }); - 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"])] }, - 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;/); - }); -});