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