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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user