diff --git a/README.md b/README.md index 588b2a5e7..441fb26c0 100644 --- a/README.md +++ b/README.md @@ -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://` 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 ` 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 | diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4f0160de7..a75d88c45 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/edit/modes/chunk.ts b/packages/coding-agent/src/edit/modes/chunk.ts index f288b42ae..5c10772df 100644 --- a/packages/coding-agent/src/edit/modes/chunk.ts +++ b/packages/coding-agent/src/edit/modes/chunk.ts @@ -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 }; }); } diff --git a/packages/coding-agent/src/export/html/template.generated.ts b/packages/coding-agent/src/export/html/template.generated.ts index e79916587..8ed8ba91e 100644 --- a/packages/coding-agent/src/export/html/template.generated.ts +++ b/packages/coding-agent/src/export/html/template.generated.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/generate-template.ts - DO NOT EDIT -export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; +export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js index cd0fe4b43..f2f9c88d9 100644 --- a/packages/coding-agent/src/export/html/template.js +++ b/packages/coding-agent/src/export/html/template.js @@ -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, }; diff --git a/packages/coding-agent/src/prompts/tools/chunk-edit.md b/packages/coding-agent/src/prompts/tools/chunk-edit.md index 3f81b1f6a..8971a1d94 100644 --- a/packages/coding-agent/src/prompts/tools/chunk-edit.md +++ b/packages/coding-agent/src/prompts/tools/chunk-edit.md @@ -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"`. diff --git a/packages/coding-agent/src/prompts/tools/vim.md b/packages/coding-agent/src/prompts/tools/vim.md index 1287bcbf6..5bf8c0f51 100644 --- a/packages/coding-agent/src/prompts/tools/vim.md +++ b/packages/coding-agent/src/prompts/tools/vim.md @@ -16,9 +16,9 @@ Rules: - Each non-final `kbd` entry must end in NORMAL mode — use `` or merge into one string - To recover from mistakes: `{"file": "f.py", "kbd": ["u"]}` to undo, or `{"file": "f.py", "kbd": [":e!"]}` 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`/``), search (`/pattern`, `n/N`), ex (`:s`, `:%s`, `:e`, `:e!`, ranged `:d`). +Special keys: ``, ``, ``, ``, ``, ``, ``, ``, ``. -Special keys: ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``. +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`/``), search (`/pattern`, `n/N`), ex (`:s`, `:%s`, `:e`, `:e!`, ranged `:d`). Examples: - `{"file": "src/app.ts"}` — view file diff --git a/packages/coding-agent/src/tools/vim.ts b/packages/coding-agent/src/tools/vim.ts index d403c5d9c..435f298e1 100644 --- a/packages/coding-agent/src/tools/vim.ts +++ b/packages/coding-agent/src/tools/vim.ts @@ -346,9 +346,13 @@ export class VimTool implements AgentTool { 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) { diff --git a/packages/coding-agent/src/vim/engine.ts b/packages/coding-agent/src/vim/engine.ts index fdf5cef78..65c5a79b5 100644 --- a/packages/coding-agent/src/vim/engine.ts +++ b/packages/coding-agent/src/vim/engine.ts @@ -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 { - 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 { - 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 }; } } diff --git a/packages/coding-agent/src/vim/parser.ts b/packages/coding-agent/src/vim/parser.ts index d6c6b50da..2e1af92be 100644 --- a/packages/coding-agent/src/vim/parser.ts +++ b/packages/coding-agent/src/vim/parser.ts @@ -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: "", + sequenceIndex, + offset, + }); + continue; + } + // Handle literal carriage return + if (char === "\r") { + tokens.push({ + value: "CR", + display: "", + sequenceIndex, + offset, + }); + continue; + } + // Handle escaped sequences: \r → CR, \e → Esc, \n → newline, \t → Tab + if (char === "\\" && offset + 1 < sequence.length) { + const next = sequence[offset + 1]; + if (next === "r") { + tokens.push({ value: "CR", display: "\\r", sequenceIndex, offset }); + offset += 1; + continue; + } + if (next === "e") { + tokens.push({ value: "Esc", display: "\\e", sequenceIndex, offset }); + offset += 1; + continue; + } + if (next === "n") { + tokens.push({ value: "\n", display: "\\n", sequenceIndex, offset }); + offset += 1; + continue; + } + if (next === "t") { + tokens.push({ value: "Tab", display: "\\t", sequenceIndex, offset }); + offset += 1; + continue; + } + } if (char !== "<") { tokens.push({ value: char, diff --git a/packages/coding-agent/test/tools/vim.test.ts b/packages/coding-agent/test/tools/vim.test.ts index 807772c59..233044f51 100644 --- a/packages/coding-agent/test/tools/vim.test.ts +++ b/packages/coding-agent/test/tools/vim.test.ts @@ -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", () => {