feat(vim): added escape/CR literal parsing and count-aware G/gg motions

- Fixed `G`/`gg` motions to distinguish "no count" (go to last/first line) from explicit count.
- Added parsing for literal `\x1b`/`\r` bytes and backslash escape sequences (`\r`, `\e`, `\n`, `\t`).
- Updated `#readCount` to return `hasCount` flag propagated through operator and motion resolution.
- Synced vim buffer fingerprint from disk on reuse to handle LSP writethrough reformats.
This commit is contained in:
can1357
2026-04-13 15:52:58 +02:00
parent 212d56bc11
commit fb59c45439
11 changed files with 108 additions and 35 deletions
+2 -2
View File
@@ -154,7 +154,7 @@ Parallel execution framework with specialized agents and real-time streaming:
- **Real-time artifact streaming**: Task outputs stream as they're created, not just at completion
- **Full output access**: Read complete subagent output via `agent://<id>` resources when previews truncate
- **Isolation backends**: `isolated: true` runs tasks in git worktrees, Unix fuse-overlay filesystems, or Windows ProjFS (`fuse-projfs`), with patch or branch merge strategies
- **Async background jobs**: Background execution with configurable concurrency (up to 100 jobs) and `await` tool for blocking on results
- **Async background jobs**: Background execution with configurable concurrency (up to 100 jobs) and `poll` tool for blocking on results
- **Agent Control Center**: `/agents` dashboard for managing and creating custom agents
- **AI-powered agent creation**: Generate custom agent definitions with the architect model
- **Per-agent model overrides**: Assign specific models to individual agents via swarm extension
@@ -1251,7 +1251,7 @@ Use `--tools <list>` to restrict available built-in tools.
| `read` | Read files/directories (default text cap: 3000 lines) |
| `browser` | Browser automation tool (model-facing name: `puppeteer`) |
| `task` | Launch subagents for parallel execution |
| `await` | Block on async background jobs |
| `poll` | Block on async background jobs |
| `todo_write` | Phased task tracking with progress management |
| `fetch` | Fetch and extract URL content |
| `web_search` | Multi-provider web search |
+1
View File
@@ -25,6 +25,7 @@
### Fixed
- Fixed HTML session export rendering so background-job wait calls render as `poll` instead of stale `await`, while still recognizing legacy exported sessions
- Fixed OpenRouter model resolution to accept dated routed selectors such as `openrouter/z-ai/glm-4.7-20251222:nitro`, inheriting metadata from the base catalog model when the exact variant is not listed yet
- Fixed pre-execution edit preview routing so replace/patch/hashline mode diffs are computed from the new structured edit entries
- Adjusted chunk/hashline/prompt guidance and validation to align with the refactored per-entry schema
+16 -7
View File
@@ -393,6 +393,8 @@ function toNativeEditOperation(
return { op: ChunkEditOp.Prepend, sel: selector, crc, region: nativeRegion, content: operation.content };
case "append":
return { op: ChunkEditOp.Append, sel: selector, crc, region: nativeRegion, content: operation.content };
case "delete":
return { op: ChunkEditOp.Delete, sel: selector, crc, region: nativeRegion };
default: {
const exhaustive: never = operation;
return exhaustive;
@@ -554,15 +556,22 @@ export function isChunkParams(params: unknown): params is ChunkParams {
function normalizeChunkEditOperations(edits: ChunkToolEdit[]): ChunkEditOperation[] {
return edits.map((edit): ChunkEditOperation => {
const { selector } = parseChunkEditPath(edit.path);
if (edit.replace) {
return { op: "replace", sel: selector, content: edit.replace.new, find: edit.replace.old };
// When multiple ops are present (model confusion), pick the most substantive one.
// insert with real body > replace with real old/new > write string > write null (delete)
const hasInsert = edit.insert && edit.insert.body.length > 0;
const hasReplace = edit.replace && (edit.replace.old.length > 0 || edit.replace.new.length > 0);
if (hasInsert) {
const op = edit.insert!.loc === "prepend" ? "before" : "after";
return { op, sel: selector, content: edit.insert!.body };
}
if (edit.insert) {
const op = edit.insert.loc === "prepend" ? "before" : "after";
return { op, sel: selector, content: edit.insert.body };
if (hasReplace) {
return { op: "replace", sel: selector, content: edit.replace!.new, find: edit.replace!.old };
}
// write: string = put content, write: null = delete
return { op: "put", sel: selector, content: edit.write ?? "" };
// write: null = explicit delete; write: undefined = no op specified (also delete)
if (edit.write == null) {
return { op: "delete", sel: selector };
}
return { op: "put", sel: selector, content: edit.write };
});
}
File diff suppressed because one or more lines are too long
@@ -1218,10 +1218,11 @@
return html;
}
function renderAwait(name, args, result, ctx) {
function renderPoll(name, args, result, ctx) {
const badges = [];
if (Array.isArray(args.jobIds)) badges.push(args.jobIds.length + ' job' + (args.jobIds.length === 1 ? '' : 's'));
let html = toolHead('await', '', badges);
const jobs = Array.isArray(args.jobs) ? args.jobs : Array.isArray(args.jobIds) ? args.jobIds : [];
if (jobs.length > 0) badges.push(jobs.length + ' job' + (jobs.length === 1 ? '' : 's'));
let html = toolHead('poll', '', badges);
if (result) {
const output = ctx.getResultText();
if (output) html += formatExpandableOutput(output, 8);
@@ -1291,7 +1292,8 @@
report_tool_issue: renderReportToolIssue,
calc: renderCalc,
calculator: renderCalc,
await: renderAwait,
await: renderPoll,
poll: renderPoll,
cancel_job: renderCancelJob,
};
@@ -261,7 +261,7 @@ Result — the method (including its doc comment and signature) is removed.
- Match the file's real indentation characters in your snippet. The tool preserves your literal tabs/spaces after adding the target region's base indent.
{{/if}}
- Do NOT include the chunk's base indentation — only indent relative to the region's opening level.
- For `~` of a function: write at column 0, and use `\t` for *relative* nesting. Flat body: `"return x;\n"`. Nested body: `"if (cond) {\n\treturn x;\n}\n"` — the `if` is at column 0, the `return` is one tab in, and the tool adds the method's base indent to both.
- For `~` of a function: write at column 0, and use `\t` for *relative* nesting. Flat body: `"return x;\n"`. Nested body: `"if (cond) {\n\treturn x;\n}\n"` — the `if` is at column 0, the `return` is one tab in, and the tool adds the method's base indent to both. Python example — to replace `~` of `def divide(a, b):`, write: `"if b == 0:\n\treturn None\nreturn a / b\n"` — the `if` and `return a / b` are at column 0, `return None` is one `\t` in.
- For `^`: write at the chunk's own depth. A class member's head uses `"/// doc\n#[attr]\npub fn start() {"`.
{{#if chunkAutoIndent}}
- For a top-level item: start at zero indent. Write `"fn foo() {\n\treturn 1;\n}\n"`.
@@ -16,9 +16,9 @@ Rules:
- Each non-final `kbd` entry must end in NORMAL mode — use `<Esc>` or merge into one string
- To recover from mistakes: `{"file": "f.py", "kbd": ["u"]}` to undo, or `{"file": "f.py", "kbd": [":e!<CR>"]}` to reload from disk
Supported: motions (`h/j/k/l`, `w/b/e`, `0/$`, `gg/G`, `{/}`, `f/t`), counts, `.` repeat, insert (`i/a/o/O/I/A/cc/C/s/S`), visual (`v/V`), operators (`d/c/y/p`), text objects (`iw/aw/i"/a"/i(/a(`), undo/redo (`u`/`<C-r>`), search (`/pattern<CR>`, `n/N`), ex (`:s`, `:%s`, `:e`, `:e!`, ranged `:d`).
Special keys: `<Esc>`, `<CR>`, `<BS>`, `<Tab>`, `<C-d>`, `<C-u>`, `<C-r>`, `<C-w>`, `<C-o>`.
Special keys: `<Esc>`, `<Escape>`, `<CR>`, `<Enter>`, `<BS>`, `<Tab>`, `<C-d>`, `<C-u>`, `<C-r>`, `<C-w>`, `<C-o>`.
Supported: motions (`h/j/k/l`, `w/b/e`, `0/$`, `gg/G`, `{/}`, `f/t`), counts, `.` repeat, insert (`i/a/o/O/I/A/cc/C/s/S`), visual (`v/V`), operators (`d/c/y/p`), text objects (`iw/aw/i"/a"/i(/a(`), undo/redo (`u`/`<C-r>`), search (`/pattern<CR>`, `n/N`), ex (`:s`, `:%s`, `:e`, `:e!`, ranged `:d`).
Examples:
- `{"file": "src/app.ts"}` — view file
+5 -1
View File
@@ -346,9 +346,13 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
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 sequences = Array.isArray(params.kbd) ? params.kbd : undefined;
const sequences = Array.isArray(params.kbd) ? params.kbd : typeof params.kbd === "string" ? [params.kbd] : undefined;
if (!sequences) {
// No kbd — just show the file viewport
if (isNewBuffer) {
+19 -17
View File
@@ -620,7 +620,7 @@ export class VimEngine {
return index + 1;
}
const { count, nextIndex } = this.#readCount(tokens, index);
const { count, hasCount, nextIndex } = this.#readCount(tokens, index);
const opToken = tokens[nextIndex];
if (!opToken) {
return nextIndex;
@@ -669,7 +669,7 @@ export class VimEngine {
break;
}
const motion = this.#resolveMotion(tokens, nextIndex, count);
const motion = this.#resolveMotion(tokens, nextIndex, count, hasCount);
this.buffer.setCursor(motion.target);
return motion.nextIndex;
}
@@ -751,7 +751,7 @@ export class VimEngine {
}
async #executeNormal(tokens: readonly VimKeyToken[], index: number): Promise<number> {
const { count, nextIndex } = this.#readCount(tokens, index);
const { count, hasCount, nextIndex } = this.#readCount(tokens, index);
const token = tokens[nextIndex];
if (!token) {
return nextIndex;
@@ -791,7 +791,7 @@ export class VimEngine {
case "H":
case "M":
case "L": {
const motion = this.#resolveMotion(tokens, nextIndex, count);
const motion = this.#resolveMotion(tokens, nextIndex, count, hasCount);
this.buffer.setCursor(motion.target);
return motion.nextIndex;
}
@@ -941,7 +941,7 @@ export class VimEngine {
case "y":
case ">":
case "<":
return this.#executeOperator(tokens, nextIndex, count, token.value);
return this.#executeOperator(tokens, nextIndex, count, hasCount, token.value);
case "D":
await this.#applyAtomicChange(["D"], () => {
const start = this.buffer.currentOffset();
@@ -1040,40 +1040,42 @@ export class VimEngine {
tokens: readonly VimKeyToken[],
operatorIndex: number,
operatorCount: number,
hasOperatorCount: boolean,
operator: string,
): Promise<number> {
const { count: motionCount, nextIndex } = this.#readCount(tokens, operatorIndex + 1);
const { count: motionCount, hasCount: hasMotionCount, nextIndex } = this.#readCount(tokens, operatorIndex + 1);
const token = tokens[nextIndex];
if (!token) {
throw new VimError(`Operator ${operator} requires a motion`, tokens[operatorIndex]);
}
const effectiveCount = operatorCount * motionCount;
const hasAnyCount = hasOperatorCount || hasMotionCount;
const effectiveCount = hasMotionCount ? operatorCount * motionCount : operatorCount;
if (token.value === operator) {
if (operator === "d") {
await this.#applyAtomicChange([operator, operator], () => {
const start = this.buffer.cursor.line;
const removed = this.buffer.deleteLines(start, start + effectiveCount - 1);
const removed = this.buffer.deleteLines(start, start + Math.max(1, effectiveCount) - 1);
this.register = { kind: "line", text: removed.join("\n") };
});
return nextIndex + 1;
}
if (operator === "y") {
const start = this.buffer.cursor.line;
const end = this.buffer.clampLine(start + effectiveCount - 1);
const end = this.buffer.clampLine(start + Math.max(1, effectiveCount) - 1);
this.register = { kind: "line", text: this.buffer.lines.slice(start, end + 1).join("\n") };
this.statusMessage = `Yanked ${end - start + 1} line${end === start ? "" : "s"}`;
return nextIndex + 1;
}
if (operator === "c") {
await this.#changeWholeLines(effectiveCount, [operator, operator]);
await this.#changeWholeLines(Math.max(1, effectiveCount), [operator, operator]);
return nextIndex + 1;
}
if (operator === ">" || operator === "<") {
await this.#applyAtomicChange([operator, operator], () => {
this.buffer.indentLines(
this.buffer.cursor.line,
this.buffer.cursor.line + effectiveCount - 1,
this.buffer.cursor.line + Math.max(1, effectiveCount) - 1,
detectIndentUnit(this.buffer.lines),
operator === ">" ? 1 : -1,
);
@@ -1106,9 +1108,9 @@ export class VimEngine {
{ ...motionToken, value: eMotionValue },
...tokens.slice(nextIndex + 1),
];
motion = this.#resolveMotion(syntheticTokens, nextIndex, effectiveCount);
motion = this.#resolveMotion(syntheticTokens, nextIndex, effectiveCount, hasAnyCount);
} else {
motion = this.#resolveMotion(tokens, nextIndex, effectiveCount);
motion = this.#resolveMotion(tokens, nextIndex, effectiveCount, hasAnyCount);
}
await this.#applyOperatorToMotion(
operator,
@@ -1208,7 +1210,7 @@ export class VimEngine {
};
}
#resolveMotion(tokens: readonly VimKeyToken[], index: number, count: number): MotionResult {
#resolveMotion(tokens: readonly VimKeyToken[], index: number, count: number, hasCount = true): MotionResult {
const token = tokens[index];
if (!token) {
throw new VimError("Missing motion");
@@ -1282,12 +1284,12 @@ export class VimEngine {
if (!next || next.value !== "g") {
throw new VimError("Unsupported g motion", token);
}
return { nextIndex: index + 2, target: { line: Math.max(0, count - 1), col: 0 }, linewise: true };
return { nextIndex: index + 2, target: { line: hasCount ? Math.max(0, count - 1) : 0, col: 0 }, linewise: true };
}
case "G":
return {
nextIndex: index + 1,
target: { line: count > 0 ? count - 1 : this.buffer.lastLineIndex(), col: 0 },
target: { line: hasCount ? count - 1 : this.buffer.lastLineIndex(), col: 0 },
linewise: true,
};
case "f":
@@ -1706,6 +1708,6 @@ export class VimEngine {
digits += value;
cursor += 1;
}
return { count: digits.length > 0 ? Number.parseInt(digits, 10) : 1, nextIndex: cursor };
return { count: digits.length > 0 ? Number.parseInt(digits, 10) : 1, hasCount: digits.length > 0, nextIndex: cursor };
}
}
+44
View File
@@ -31,6 +31,50 @@ export function parseKeySequences(sequences: string[]): VimKeyToken[] {
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,
@@ -83,6 +83,17 @@ describe("vim parser", () => {
"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", () => {