diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 34b124651..0d2c1288b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Removed the `searchDb` field from session and extension tool contexts, so custom tools and extensions no longer receive a shared native search DB handle from `ToolSession`, `CustomToolContext`, `ExtensionContext`, and `CreateAgentSessionOptions` @@ -9,6 +10,11 @@ ### Added +- Added support for additional Vim ex command aliases `:write`/`write!`, `:edit`/`edit!`, and `:update`/`:up` in command parsing +- Added support for vim `:global` and `:vglobal`/`/` variants as `:g/pattern/d` and `:v/pattern/d` parsing and execution +- Added support for extra Vim operations by treating `x`, `X`, `s`, `S`, `C`, and `D` as delete/change operator aliases +- Added support for new Vim motions `gE`/`ge`, `g_`, `g*`, `g#`, and `|` +- Added support for `C-f` and `C-b` page motions in vim mode - Added `C-u` and `C-o` in vim insert mode to clear to line start and execute a one-off normal-mode command before returning to insert - Added insert-mode visual operators `J`, `u`, `U`, `p`, and `P` to join lines, convert case, and replace the selected region with register content - Added normal-mode line motions `+`, `-`, and `_` to move to line offsets at the first non-blank character @@ -25,6 +31,9 @@ ### Changed +- Changed Vim page-scroll commands `C-f`, `C-b`, `C-u`, and `C-d` to move in viewport-height based increments instead of fixed constants +- Changed `z` command behavior so `zt`, `zb`, and `z.` now align cursor movement to first non-blank in the line +- Changed `:g`/`:v` global command handling to process matching lines safely by working in reverse order and preserving file structure - Changed vim tab breadcrumb rendering from ` → ` to `→` in the editor view - Changed custom tool and task execution contexts to no longer expose a shared `searchDb` accessor, removing direct access to native grep/glob/fuzzyFind search backends from extension callbacks - Changed the `task` tool `schema` field to require JSON-encoded JTD schema text instead of a schema object, matching prompt guidance and task-subagent invocation @@ -36,6 +45,8 @@ ### Fixed +- Fixed Vim `:global` command defaults to handle only supported subcommands and report unsupported ones explicitly +- Fixed vim tool rendering so streamed calls preview the live target viewport and large insert payloads update incrementally instead of popping in all at once - 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 diff --git a/packages/coding-agent/src/edit/modes/chunk.ts b/packages/coding-agent/src/edit/modes/chunk.ts index cce4eba90..fbb93459a 100644 --- a/packages/coding-agent/src/edit/modes/chunk.ts +++ b/packages/coding-agent/src/edit/modes/chunk.ts @@ -553,6 +553,54 @@ export function isChunkParams(params: unknown): params is ChunkParams { return "write" in first || "replace" in first || "insert" in first; } +/** Auto-correct indentation for content targeting a body region (`~`) when autoIndent is on. + * Handles two patterns: + * 1. Tab-based over-indentation: models include the function's base \t indent. + * 2. Space-based indentation: models use literal spaces instead of \t. + * Returns the corrected content and any warnings. */ +function autoCorrectBodyIndent(content: string, index: number): { content: string; warnings: string[] } { + const warnings: string[] = []; + if (!content || !resolveChunkAutoIndent()) return { content, warnings }; + const lines = content.split("\n"); + const nonEmpty = lines.filter(l => l.length > 0); + if (nonEmpty.length <= 1) return { content, warnings }; + + // 1. Tab-based over-indentation: strip common leading tabs. + const minTabs = Math.min(...nonEmpty.map(l => l.match(/^\t*/)?.[0].length ?? 0)); + if (minTabs >= 1) { + const fixed = lines.map(l => (l.length === 0 ? l : l.slice(minTabs))).join("\n"); + warnings.push( + `Edit ${index + 1}: auto-corrected body indentation \u2014 stripped ${minTabs} leading tab(s). When writing to \`~\`, write at column 0; the tool adds the function's base indent.`, + ); + return { content: fixed, warnings }; + } + + // 2. Space-based indentation: strip common leading spaces and convert to tabs. + const spaceIndents = nonEmpty.map(l => l.match(/^ */)?.[0].length ?? 0); + const minSpaces = Math.min(...spaceIndents); + if (minSpaces >= 2) { + const indentDiffs = spaceIndents.map(s => s - minSpaces).filter(d => d > 0); + const indentUnit = indentDiffs.length > 0 ? Math.min(...indentDiffs) : 4; + const unit = indentUnit >= 2 && indentUnit <= 8 ? indentUnit : 4; + const fixed = lines + .map(line => { + if (line.length === 0) return line; + const stripped = line.slice(minSpaces); + const leadingSpaces = stripped.match(/^ */)?.[0].length ?? 0; + const tabs = Math.floor(leadingSpaces / unit); + const rem = leadingSpaces % unit; + return "\t".repeat(tabs) + " ".repeat(rem) + stripped.slice(leadingSpaces); + }) + .join("\n"); + warnings.push( + `Edit ${index + 1}: auto-converted space indentation to tabs \u2014 stripped ${minSpaces} common leading spaces and converted ${unit}-space indent to tabs. When auto-indent is on, use \\t for indentation.`, + ); + return { content: fixed, warnings }; + } + + return { content, warnings }; +} + function normalizeChunkEditOperations(edits: ChunkToolEdit[]): { operations: ChunkEditOperation[]; warnings: string[]; @@ -579,31 +627,15 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): { ); } if (hasWrite) { - // Detect and fix over-indentation when targeting a body region (~). - // Models commonly add the function's base indent to body content. - let writeContent = edit.write; - if (selector?.endsWith("~") && writeContent.length > 0) { - const writeLines = writeContent.split("\n"); - const nonEmptyLines = writeLines.filter(l => l.length > 0); - if (nonEmptyLines.length > 1) { - const firstLineIndent = nonEmptyLines[0].match(/^\t*/)?.[0].length ?? 0; - const restNonEmpty = nonEmptyLines.slice(1); - const restMinIndent = Math.min(...restNonEmpty.map(l => l.match(/^\t*/)?.[0].length ?? 0)); - if (firstLineIndent === 0 && restMinIndent >= 1) { - // All non-first lines have extra indent — strip it - writeContent = writeLines - .map((line, i) => (i === 0 || line.length === 0 ? line : line.slice(restMinIndent))) - .join("\n"); - warnings.push( - `Edit ${index + 1}: auto-corrected body indentation — stripped ${restMinIndent} leading tab(s) from non-first lines. When writing to \`~\`, write at column 0; the tool adds the function's base indent.`, - ); - } - } + let writeContent = edit.write!; + if (selector?.endsWith("~")) { + const corrected = autoCorrectBodyIndent(writeContent, index); + writeContent = corrected.content; + warnings.push(...corrected.warnings); } return { op: "put", sel: selector, content: writeContent }; } - if (typeof edit.write === "string") { - // write: "" (empty string) — clear the chunk content + if (typeof edit.write === "string" && !hasInsert && !hasReplace) { return { op: "put", sel: selector, content: edit.write }; } if (hasReplace) { @@ -611,7 +643,13 @@ function normalizeChunkEditOperations(edits: ChunkToolEdit[]): { } if (hasInsert) { const op = edit.insert!.loc === "prepend" ? "before" : "after"; - return { op, sel: selector, content: edit.insert!.body }; + let insertContent = edit.insert!.body; + if (selector?.endsWith("~")) { + const corrected = autoCorrectBodyIndent(insertContent, index); + insertContent = corrected.content; + warnings.push(...corrected.warnings); + } + return { op, sel: selector, content: insertContent }; } // write: null or no op specified → delete return { op: "delete", sel: selector }; diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 18de7c87c..96428e4e0 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -32,6 +32,7 @@ import { import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python"; import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils"; import { toolRenderers } from "../../tools/renderers"; +import { clearVimCallPreview, primeVimCallPreview } from "../../tools/vim"; import { renderStatusLine } from "../../tui"; import { convertToPng } from "../../utils/image-convert"; import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel"; @@ -95,6 +96,7 @@ export class ToolExecutionComponent extends Container { #tool?: AgentTool; #ui: TUI; #cwd: string; + #toolCallId?: string; #result?: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean; @@ -127,17 +129,19 @@ export class ToolExecutionComponent extends Container { tool: AgentTool | undefined, ui: TUI, cwd: string = getProjectDir(), + toolCallId?: string, ) { super(); this.#toolName = toolName; this.#toolLabel = tool?.label ?? toolName; - this.#args = cloneToolArgs(args); + this.#toolCallId = toolCallId; this.#showImages = options.showImages ?? true; this.#editFuzzyThreshold = options.editFuzzyThreshold; this.#editAllowFuzzy = options.editAllowFuzzy; this.#tool = tool; this.#ui = ui; this.#cwd = cwd; + this.#args = this.#prepareArgsForRender(args, toolCallId); this.addChild(new Spacer(1)); @@ -154,11 +158,16 @@ export class ToolExecutionComponent extends Container { this.addChild(this.#contentText); } + this.#maybePrimeVimPreview(); this.#updateDisplay(); } - updateArgs(args: any, _toolCallId?: string): void { - this.#args = cloneToolArgs(args); + updateArgs(args: any, toolCallId?: string): void { + if (toolCallId) { + this.#toolCallId = toolCallId; + } + this.#args = this.#prepareArgsForRender(args, toolCallId ?? this.#toolCallId); + this.#maybePrimeVimPreview(); this.#updateSpinnerAnimation(); this.#updateDisplay(); } @@ -255,6 +264,9 @@ export class ToolExecutionComponent extends Container { ): void { this.#result = result; this.#isPartial = isPartial; + if (this.#toolName === "vim") { + clearVimCallPreview(this.#toolCallId); + } // When tool is complete, ensure args are marked complete so spinner stops if (!isPartial) { this.#argsComplete = true; @@ -265,6 +277,26 @@ export class ToolExecutionComponent extends Container { this.#maybeConvertImagesForKitty(); } + #prepareArgsForRender(args: any, toolCallId?: string): any { + const cloned = cloneToolArgs(args); + if (this.#toolName !== "vim" || !toolCallId || !cloned || typeof cloned !== "object" || Array.isArray(cloned)) { + return cloned; + } + return { ...cloned, __toolCallId: toolCallId, __cwd: this.#cwd }; + } + + #maybePrimeVimPreview(): void { + if (this.#toolName !== "vim" || this.#argsComplete || !this.#toolCallId) { + return; + } + void primeVimCallPreview(this.#toolCallId, this.#args).then(() => { + if (this.#toolCallId) { + this.#updateDisplay(); + this.#ui.requestRender(); + } + }); + } + /** * Get all image blocks from result content and details.images. * Some tools (like generate_image) store images in details to avoid bloating model context. diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 15ec80678..8b43580fd 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -244,6 +244,7 @@ export class EventController { tool, this.ctx.ui, this.ctx.sessionManager.getCwd(), + content.id, ); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); @@ -333,6 +334,7 @@ export class EventController { tool, this.ctx.ui, this.ctx.sessionManager.getCwd(), + event.toolCallId, ); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 3632053bb..f86843b7d 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -297,6 +297,7 @@ export class UiHelpers { tool, this.ctx.ui, this.ctx.sessionManager.getCwd(), + content.id, ); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(component); diff --git a/packages/coding-agent/src/prompts/tools/vim.md b/packages/coding-agent/src/prompts/tools/vim.md index 731c1ea2b..c3b733bb7 100644 --- a/packages/coding-agent/src/prompts/tools/vim.md +++ b/packages/coding-agent/src/prompts/tools/vim.md @@ -62,9 +62,9 @@ The vim buffer persists across tool calls. Your cursor position, undo history, a ## Supported Keys: `` `` `` `` `` `` `` `` `` -Motions: `h j k l w b e 0 $ gg G { } f t` with counts -Operators: `d c y p` with motions and text objects (`iw aw i" a" i( a(`) -Insert: `i a o O I A cc C s S` — these all enter INSERT mode; do NOT add another `i` after them -Visual: `v V` -Other: `.` repeat, `u`/`` undo/redo, `/pattern` search, `n N` -Ex: `:w` `:q` `:wq` `:e` `:e!` `:N` `:s///` `:%s///` `:N,Md` `:%d` +Motions: `h j k l w b e 0 $ ^ + - _ gg G { } f F t T % H M L ; ,` with counts +Operators: `d c y p` with motions and text objects (`iw aw ip ap i" a" i( a( i{ a{`) +Insert: `i a o O I A cc C s S R` — these all enter INSERT mode; do NOT add another `i` after them +Visual: `v V` with `d y c > < ~ r u U p P o J` +Other: `.` repeat, `u`/`` undo/redo, `/pattern` search, `n N * #`, `gv` `gJ` `gU` `gu` `ZZ` `ZQ` +Ex: `:w` `:q` `:wq` `:e` `:e!` `:N` `:s///` `:%s///` `:N,Md` `:%d` `:N,Mt N` `:sort` `:g/pattern/d` `:v/pattern/d` diff --git a/packages/coding-agent/src/tools/json-tree.ts b/packages/coding-agent/src/tools/json-tree.ts index 8476a8311..926c880fc 100644 --- a/packages/coding-agent/src/tools/json-tree.ts +++ b/packages/coding-agent/src/tools/json-tree.ts @@ -14,7 +14,7 @@ export const JSON_TREE_SCALAR_LEN_COLLAPSED = 60; export const JSON_TREE_SCALAR_LEN_EXPANDED = 2000; /** Keys injected by the harness that should not be displayed to users */ -const HIDDEN_ARG_KEYS = new Set([INTENT_FIELD, "__partialJson"]); +const HIDDEN_ARG_KEYS = new Set([INTENT_FIELD, "__partialJson", "__toolCallId", "__cwd"]); /** Strip harness-internal keys from tool args for display */ export function stripInternalArgs(args: Record): Record { diff --git a/packages/coding-agent/src/tools/vim.ts b/packages/coding-agent/src/tools/vim.ts index 58f0d120c..39a4d3fe0 100644 --- a/packages/coding-agent/src/tools/vim.ts +++ b/packages/coding-agent/src/tools/vim.ts @@ -11,7 +11,7 @@ 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 { VimEngine, type VimEngineCallbacks, type VimSaveResult } from "../vim/engine"; import { parseKeySequences } from "../vim/parser"; import { buildDetails, @@ -58,6 +58,21 @@ const vimSchema = Type.Object({ type VimParams = Static; +export interface VimRenderArgs { + file?: string; + kbd?: string[]; + insert?: string; + pause?: boolean; + __partialJson?: string; + __toolCallId?: string; + __cwd?: string; +} + +interface VimCallPreviewState { + key: string; + details?: VimToolDetails; +} + function fingerprintEqual(left: VimFingerprint | null, right: VimFingerprint | null): boolean { if (left === null || right === null) { return left === right; @@ -135,6 +150,156 @@ async function executeKeySequences( // Module-level cache of the last rendered VimToolDetails so that renderCall can // show the current buffer viewport while the LLM is still streaming tool arguments. let lastVimDetails: VimToolDetails | undefined; +const previewEnginesByFile = new Map(); +const previewStatesByToolCall = new Map(); + +function clearStoredPreviewEngine(file: string): void { + previewEnginesByFile.delete(file); +} + +function storePreviewEngine(engine: VimEngine): void { + const snapshot = engine.clone(); + previewEnginesByFile.set(engine.buffer.displayPath, snapshot); + previewEnginesByFile.set(engine.buffer.filePath, snapshot); +} + +function buildLoadedFileFromBuffer(buffer: VimBuffer): VimLoadedFile { + return { + absolutePath: buffer.filePath, + displayPath: buffer.displayPath, + lines: [...buffer.lines], + trailingNewline: buffer.trailingNewline, + fingerprint: buffer.baseFingerprint ? { ...buffer.baseFingerprint } : null, + }; +} + +function createPreviewCallbacks(): VimEngineCallbacks { + return { + beforeMutate: async () => {}, + loadBuffer: async inputPath => ({ + absolutePath: inputPath, + displayPath: inputPath, + lines: [""], + trailingNewline: false, + fingerprint: null, + }), + saveBuffer: async buffer => ({ + loaded: buildLoadedFileFromBuffer(buffer), + }), + }; +} + +async function loadPreviewBaseEngine(args: VimRenderArgs): Promise { + if (!args.file || !args.__cwd) { + return undefined; + } + + try { + const { absolutePath, displayPath } = normalizeTargetPath(args.file, args.__cwd); + const loaded = await readTextFile(absolutePath); + const engine = new VimEngine( + new VimBuffer({ + absolutePath, + displayPath, + lines: loaded.lines, + trailingNewline: loaded.trailingNewline, + fingerprint: loaded.fingerprint, + }), + createPreviewCallbacks(), + ); + storePreviewEngine(engine); + return engine; + } catch { + return 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 buildPreviewKey(args: VimRenderArgs): string { + return JSON.stringify({ + file: args.file, + kbd: args.kbd ?? [], + insert: getInsertForDisplay(args) ?? "", + pause: args.pause === true, + }); +} + +function splitInsertIntoChunks(text: string): string[] { + const maxChunkChars = 32; + if (text.length <= maxChunkChars) { + return text.length === 0 ? [] : [text]; + } + + const chunks: string[] = []; + let start = 0; + while (start < text.length) { + let end = Math.min(start + maxChunkChars, text.length); + if (end < text.length) { + const lastNewline = text.lastIndexOf("\n", end - 1); + if (lastNewline >= start) { + end = lastNewline + 1; + } else { + const lastSpace = Math.max(text.lastIndexOf(" ", end - 1), text.lastIndexOf("\t", end - 1)); + if (lastSpace >= start + Math.floor(maxChunkChars / 2)) { + end = lastSpace + 1; + } + } + } + if (end <= start) { + end = Math.min(start + maxChunkChars, text.length); + } + chunks.push(text.slice(start, end)); + start = end; + } + return chunks; +} + +async function applyInsertWithStreaming( + engine: VimEngine, + text: string, + exitInsertMode: boolean, + onStep?: () => Promise, +): Promise { + const chunks = splitInsertIntoChunks(text); + if (chunks.length === 0) { + await engine.applyLiteralInsert("", exitInsertMode); + return; + } + + for (let index = 0; index < chunks.length; index += 1) { + await engine.applyLiteralInsert(chunks[index]!, exitInsertMode && index === chunks.length - 1); + await onStep?.(); + } +} async function statFingerprint(absolutePath: string): Promise { try { @@ -290,31 +455,26 @@ export class VimTool implements AgentTool { statusMessage?: string, modelDiff?: string, ): AgentToolResult { - 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, + const details = buildToolDetailsFromEngine( + engine, + viewportLines, + preferredStart, closed, - }); - details.diagnostics = engine.diagnostics; + errorLocation, + statusMessage, + ); const resultText = modelDiff ? `${renderVimDetails(details)}\n\nDiff:\n${modelDiff}` : renderVimDetails(details); const builder = toolResult(details).text(resultText); if (engine.diagnostics) { builder.diagnostics(engine.diagnostics.summary, engine.diagnostics.messages ?? []); } lastVimDetails = details; + if (closed) { + clearStoredPreviewEngine(engine.buffer.displayPath); + clearStoredPreviewEngine(engine.buffer.filePath); + } else { + storePreviewEngine(engine); + } return builder.done(); } @@ -408,9 +568,9 @@ export class VimTool implements AgentTool { let lastUpdateTime = 0; const emitUpdate = onUpdate - ? async () => { + ? async (force = false) => { const now = Date.now(); - if (now - lastUpdateTime < FRAME_INTERVAL_MS) { + if (!force && now - lastUpdateTime < FRAME_INTERVAL_MS) { return; // throttle: skip if too soon } onUpdate(this.#renderFromEngine(engine, VIM_DEFAULT_VIEWPORT_LINES, engine.viewportStart)); @@ -419,13 +579,17 @@ export class VimTool implements AgentTool { } : undefined; - await executeKeySequences(engine, tokenGroups, commandText, emitUpdate); + await executeKeySequences(engine, tokenGroups, commandText, emitUpdate ? () => emitUpdate() : undefined); if (!engine.closed && params.insert !== undefined) { // Skip empty insert when not in INSERT mode (e.g., kbd included ) if (params.insert.length > 0 || engine.inputMode === "insert") { - await engine.applyLiteralInsert(params.insert, params.pause !== true); - await emitUpdate?.(); + await applyInsertWithStreaming( + engine, + params.insert, + params.pause !== true, + emitUpdate ? () => emitUpdate(true) : undefined, + ); } } @@ -534,14 +698,7 @@ function unescapePartialJsonString(value: string): string { // 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 insertStart = partialJson.search(/"insert"\s*:\s*"/u); - if (insertStart === -1) return undefined; - const valueStart = partialJson.indexOf('"', insertStart + 7) + 1; // skip past "insert":" - if (valueStart === 0) return undefined; - // Find the value - it may be incomplete (no closing quote) - const rest = partialJson.slice(valueStart); - // Match everything up to an unescaped quote or end of string - const match = rest.match(/^((?:\\.|[^"\\])*)(?:"|$)/u); + const match = partialJson.match(/"insert"\s*:\s*"((?:\\.|[^"\\])*)(?:"|$)/u); if (!match) return undefined; return unescapePartialJsonString(match[1]!); } @@ -556,12 +713,68 @@ function getInsertForDisplay(args: VimRenderArgs): string | undefined { return args.insert; } -interface VimRenderArgs { - file?: string; - kbd?: string[]; - insert?: string; - pause?: boolean; - __partialJson?: string; +async function buildPreviewDetails(args: VimRenderArgs): Promise { + if (!args.file) { + return undefined; + } + + const baseEngine = previewEnginesByFile.get(args.file) ?? (await loadPreviewBaseEngine(args)); + if (!baseEngine) { + return undefined; + } + + const preview = baseEngine.clone(createPreviewCallbacks()); + const sequences = Array.isArray(args.kbd) ? args.kbd : []; + const insertText = getInsertForDisplay(args); + + try { + if (sequences.length > 0) { + const commandText = sequences.join(" "); + const tokenGroups = splitTokensBySequence(sequences); + await executeKeySequences(preview, tokenGroups, commandText); + } + + if (!preview.closed && insertText !== undefined && (insertText.length > 0 || preview.inputMode === "insert")) { + await preview.applyLiteralInsert(insertText, false); + } + } catch { + return undefined; + } + + if (!preview.closed && insertText !== undefined) { + preview.statusMessage = preview.statusMessage ?? "Streaming insert preview"; + } + + return buildToolDetailsFromEngine(preview, VIM_DEFAULT_VIEWPORT_LINES, preview.viewportStart, preview.closed); +} + +export async function primeVimCallPreview(toolCallId: string | undefined, args: VimRenderArgs): Promise { + if (!toolCallId) { + return; + } + + const key = buildPreviewKey(args); + const existing = previewStatesByToolCall.get(toolCallId); + previewStatesByToolCall.set(toolCallId, { key, details: existing?.details }); + const details = await buildPreviewDetails(args); + const current = previewStatesByToolCall.get(toolCallId); + if (!current || current.key !== key) { + return; + } + previewStatesByToolCall.set(toolCallId, { key, details }); +} + +export function clearVimCallPreview(toolCallId: string | undefined): void { + if (!toolCallId) { + return; + } + previewStatesByToolCall.delete(toolCallId); +} + +export function resetVimRendererStateForTest(): void { + lastVimDetails = undefined; + previewEnginesByFile.clear(); + previewStatesByToolCall.clear(); } export const vimToolRenderer = { @@ -585,7 +798,9 @@ export const vimToolRenderer = { // When a vim buffer is already open, show its viewport during LLM streaming // instead of just a plain text line. This gives visual continuity. - const details = lastVimDetails; + const previewDetails = + args.__toolCallId !== undefined ? previewStatesByToolCall.get(args.__toolCallId)?.details : undefined; + const details = previewDetails ?? (lastVimDetails?.file === args.file ? lastVimDetails : undefined); if (details?.viewportLines && details.viewportLines.length > 0) { const lang = getLanguageFromPath(details.file); const langIcon = uiTheme.getLangIcon(lang); diff --git a/packages/coding-agent/src/vim/buffer.ts b/packages/coding-agent/src/vim/buffer.ts index 6994d29b0..a3f5f233e 100644 --- a/packages/coding-agent/src/vim/buffer.ts +++ b/packages/coding-agent/src/vim/buffer.ts @@ -216,7 +216,11 @@ export class VimBuffer { 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)}`; - this.setText(nextText); + // 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); } diff --git a/packages/coding-agent/src/vim/commands.ts b/packages/coding-agent/src/vim/commands.ts index 26caf63d8..992f7971f 100644 --- a/packages/coding-agent/src/vim/commands.ts +++ b/packages/coding-agent/src/vim/commands.ts @@ -73,30 +73,53 @@ export function parseExCommand(input: string): VimExCommand { }; } - if (trimmed === "w") { + if (trimmed === "w" || trimmed === "write") { return { kind: "write", force: false }; } - if (trimmed === "w!") { + if (trimmed === "w!" || trimmed === "write!") { return { kind: "write", force: true }; } - if (trimmed === "wq") { + if (trimmed === "update" || trimmed === "up") { + return { kind: "update", force: false }; + } + if (trimmed === "update!" || trimmed === "up!") { + return { kind: "update", force: true }; + } + if (trimmed === "wq" || trimmed === "x" || trimmed === "xit" || trimmed === "exit") { return { kind: "write-quit", force: false }; } - if (trimmed === "wq!") { + if (trimmed === "wq!" || trimmed === "x!" || trimmed === "xit!" || trimmed === "exit!") { return { kind: "write-quit", force: true }; } - if (trimmed === "q") { + if (trimmed === "q" || trimmed === "quit") { return { kind: "quit", force: false }; } - if (trimmed === "q!") { + if (trimmed === "q!" || trimmed === "quit!") { return { kind: "quit", force: true }; } - if (trimmed === "e") { + if (trimmed === "e" || trimmed === "edit") { return { kind: "edit", force: false }; } - if (trimmed === "e!") { + if (trimmed === "e!" || trimmed === "edit!") { return { kind: "edit", force: true }; } + if (trimmed.startsWith("e ") || trimmed.startsWith("edit ")) { + const path = trimmed.startsWith("edit ") ? trimmed.slice(5).trim() : trimmed.slice(2).trim(); + return { kind: "edit", force: false, path }; + } + if (trimmed.startsWith("e! ") || trimmed.startsWith("edit! ")) { + const path = trimmed.startsWith("edit! ") ? trimmed.slice(6).trim() : trimmed.slice(3).trim(); + return { kind: "edit", force: true, path }; + } + + const globalMatch = trimmed.match(/^(g|v|g!|global|global!|vglobal)\s*([/|#])(.+?)\2(.*)$/); + if (globalMatch) { + const invert = globalMatch[1] === "v" || globalMatch[1] === "vglobal" || globalMatch[1]?.endsWith("!"); + const pattern = globalMatch[3] ?? ""; + const command = (globalMatch[4] ?? "d").trim() || "d"; + return { kind: "global", pattern, command, invert: !!invert }; + } + if (trimmed.startsWith("e ")) { return { kind: "edit", force: false, path: trimmed.slice(2).trim() }; } @@ -115,7 +138,23 @@ export function parseExCommand(input: string): VimExCommand { }; } - if (rest.startsWith("s")) { + if (rest === "sort" || rest.startsWith("sort ") || rest.startsWith("sort!")) { + const flags = rest.slice(4).trim(); + return { kind: "sort", range: range ?? undefined, flags }; + } + + 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", @@ -126,13 +165,28 @@ export function parseExCommand(input: string): VimExCommand { }; } - if (rest === "d" || rest.startsWith("d ")) { + if (rest === "d" || rest === "delete" || rest.startsWith("d ") || rest.startsWith("delete ")) { return { kind: "delete", range, }; } + if (rest === "y" || rest === "yank" || rest.startsWith("y ") || rest.startsWith("yank ")) { + return { + kind: "yank", + range, + }; + } + + if (rest === "pu" || rest === "put" || rest === "pu!" || rest === "put!") { + return { + kind: "put", + range, + before: rest.endsWith("!"), + }; + } + if (rest.startsWith("t ") || /^t\d/.test(rest) || rest.startsWith("copy ") || /^copy\d/.test(rest)) { const destStr = rest.startsWith("copy") ? rest.slice(4).trim() : rest.slice(1).trim(); const destination = parseInt(destStr, 10); @@ -147,10 +201,5 @@ export function parseExCommand(input: string): VimExCommand { return { kind: "move", range, destination }; } - if (rest === "sort" || rest.startsWith("sort ") || rest.startsWith("sort!")) { - const flags = rest.slice(4).trim(); - return { kind: "sort", range: range ?? undefined, flags }; - } - throw new VimInputError(`Unsupported ex command: ${input}`); } diff --git a/packages/coding-agent/src/vim/engine.ts b/packages/coding-agent/src/vim/engine.ts index b62006399..c23aa6cf0 100644 --- a/packages/coding-agent/src/vim/engine.ts +++ b/packages/coding-agent/src/vim/engine.ts @@ -43,7 +43,7 @@ interface MotionResult { } const WORD_CHAR = /[A-Za-z0-9_]/; -const DEFAULT_VIEWPORT_HEIGHT = 40; +const DEFAULT_VIEWPORT_HEIGHT = 10; const BRACKET_PAIRS = new Map([ ["(", ")"], ["[", "]"], @@ -53,6 +53,7 @@ const BRACKET_PAIRS = new Map([ const CLOSING_BRACKETS = new Map( Array.from(BRACKET_PAIRS.entries()).map(([open, close]) => [close, open]), ); +const NOOP_Z_COMMANDS = new Set(["a", "A", "c", "C", "m", "M", "o", "O", "r", "R", "v", "x", "X"]); function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -221,6 +222,55 @@ function endOfWord(text: string, offset: number, bigWord: boolean): number { return Math.max(0, index - 1); } +function endOfPreviousWord(text: string, offset: number, bigWord: boolean): number { + if (text.length === 0) { + return 0; + } + + let index = Math.min(Math.max(offset - 1, 0), text.length - 1); + while (index >= 0 && wordCategory(text[index] ?? "", bigWord) === "space") { + index -= 1; + } + if (index < 0) { + return 0; + } + + const currentCategory = wordCategory(text[index] ?? "", bigWord); + while (index >= 0 && wordCategory(text[index] ?? "", bigWord) === currentCategory) { + index -= 1; + } + while (index >= 0 && wordCategory(text[index] ?? "", bigWord) === "space") { + index -= 1; + } + + return Math.max(0, index); +} + +function toggleCase(text: string): string { + let toggled = ""; + for (const char of text) { + if (char >= "a" && char <= "z") { + toggled += char.toUpperCase(); + continue; + } + if (char >= "A" && char <= "Z") { + toggled += char.toLowerCase(); + continue; + } + toggled += char; + } + return toggled; +} + +function lastNonBlankColumn(line: string): number { + for (let index = line.length - 1; index >= 0; index -= 1) { + if (!isWhitespace(line[index] ?? "")) { + return index; + } + } + return 0; +} + function findParagraphStart(lines: string[], line: number): number { let index = Math.max(0, line - 1); while (index > 0 && lines[index]!.trim().length > 0) { @@ -281,6 +331,13 @@ export class VimEngine { next.register = { ...this.register }; next.lastSearch = this.lastSearch ? { ...this.lastSearch } : null; next.lastCharFind = this.lastCharFind ? { ...this.lastCharFind } : null; + next.lastVisual = this.lastVisual + ? { + anchor: clonePosition(this.lastVisual.anchor), + cursor: clonePosition(this.lastVisual.cursor), + mode: this.lastVisual.mode, + } + : null; next.lastCommand = this.lastCommand; next.statusMessage = this.statusMessage; next.diagnostics = this.diagnostics; @@ -724,6 +781,52 @@ export class VimEngine { this.#clearSelection(); return index + 1; } + if (token.value === "g") { + const next = tokens[index + 1]; + if (!next) { + throw new VimError("g requires a second key", token); + } + if (next.value === "J") { + const visual = expandVisualOffsets( + this.buffer, + this.selectionAnchor ?? this.buffer.cursor, + this.inputMode === "visual-line", + ); + const startLine = this.buffer.offsetToPosition(visual.start).line; + const endLine = this.buffer.offsetToPosition(Math.max(visual.start, visual.end - 1)).line; + await this.#applyAtomicChange(["g", "J"], () => { + const start = this.buffer.clampLine(startLine); + const end = this.buffer.clampLine(endLine); + if (start < end) { + const joined = this.buffer.lines.slice(start, end + 1).join(""); + this.buffer.lines.splice(start, end - start + 1, joined); + this.buffer.setCursor({ line: start, col: Math.max(0, joined.length - 1) }); + } + }); + this.#clearSelection(); + return index + 2; + } + if (next.value === "u" || next.value === "U" || next.value === "~") { + const visual = expandVisualOffsets( + this.buffer, + this.selectionAnchor ?? this.buffer.cursor, + this.inputMode === "visual-line", + ); + await this.#applyAtomicChange(["g", next.value], () => { + const original = this.buffer.getText().slice(visual.start, visual.end); + const transformed = + next.value === "u" + ? original.toLowerCase() + : next.value === "U" + ? original.toUpperCase() + : toggleCase(original); + this.buffer.replaceOffsets(visual.start, visual.end, transformed, visual.start); + }); + this.#clearSelection(); + return index + 2; + } + throw new VimError(`Unsupported g command: g${next.display}`, next); + } const { count, hasCount, nextIndex } = this.#readCount(tokens, index); const opToken = tokens[nextIndex]; @@ -733,8 +836,14 @@ export class VimEngine { switch (opToken.value) { case "d": + case "x": + case "X": + case "D": case "y": case "c": + case "s": + case "S": + case "C": case ">": case "<": case "~": { @@ -745,8 +854,14 @@ export class VimEngine { ); const consumeExtraIndent = (opToken.value === ">" || opToken.value === "<") && tokens[nextIndex + 1]?.value === opToken.value; + const operatorValue = + opToken.value === "x" || opToken.value === "X" || opToken.value === "D" + ? "d" + : opToken.value === "s" || opToken.value === "S" || opToken.value === "C" + ? "c" + : opToken.value; const visualTokens = consumeExtraIndent ? [opToken.value, opToken.value] : [opToken.value]; - await this.#applyVisualOperator(opToken.value, visual, count, visualTokens); + await this.#applyVisualOperator(operatorValue, visual, count, visualTokens); return nextIndex + visualTokens.length; } case "r": { @@ -835,17 +950,7 @@ export class VimEngine { case "~": { await this.#applyAtomicChange(tokens, () => { const original = this.buffer.getText().slice(visual.start, visual.end); - let replaced = ""; - for (const char of original) { - if (char >= "a" && char <= "z") { - replaced += char.toUpperCase(); - } else if (char >= "A" && char <= "Z") { - replaced += char.toLowerCase(); - } else { - replaced += char; - } - } - this.buffer.replaceOffsets(visual.start, visual.end, replaced, visual.start); + this.buffer.replaceOffsets(visual.start, visual.end, toggleCase(original), visual.start); }); this.#clearSelection(); return; @@ -885,6 +990,7 @@ export class VimEngine { case "0": case "$": case "^": + case "|": case ";": case ",": case "G": @@ -1041,17 +1147,7 @@ export class VimEngine { const start = this.buffer.currentOffset(); const end = Math.min(this.buffer.getText().length, start + count); const text = this.buffer.getText().slice(start, end); - let toggled = ""; - for (const char of text) { - if (char >= "a" && char <= "z") { - toggled += char.toUpperCase(); - } else if (char >= "A" && char <= "Z") { - toggled += char.toLowerCase(); - } else { - toggled += char; - } - } - this.buffer.replaceOffsets(start, end, toggled, end); + this.buffer.replaceOffsets(start, end, toggleCase(text), end); }); return nextIndex + 1; case "J": @@ -1102,25 +1198,53 @@ export class VimEngine { throw new VimError("z requires a second key", token); } if (zTarget.value === "z") { - this.viewportStart = Math.max(1, this.buffer.cursor.line + 1 - 20); - } else if (zTarget.value === "t") { + this.centerViewportOnCursor(); + } else if (zTarget.value === "t" || zTarget.value === "CR") { this.viewportStart = this.buffer.cursor.line + 1; - } else if (zTarget.value === "b") { - this.viewportStart = Math.max(1, this.buffer.cursor.line + 1 - 39); + this.buffer.setCursor({ + line: this.buffer.cursor.line, + col: this.buffer.firstNonBlank(this.buffer.cursor.line), + }); + } else if (zTarget.value === "b" || zTarget.value === "-") { + this.viewportStart = Math.max(1, this.buffer.cursor.line + 1 - (DEFAULT_VIEWPORT_HEIGHT - 1)); + this.buffer.setCursor({ + line: this.buffer.cursor.line, + col: this.buffer.firstNonBlank(this.buffer.cursor.line), + }); + } else if (zTarget.value === ".") { + this.centerViewportOnCursor(); + this.buffer.setCursor({ + line: this.buffer.cursor.line, + col: this.buffer.firstNonBlank(this.buffer.cursor.line), + }); + } else if (NOOP_Z_COMMANDS.has(zTarget.value)) { + this.statusMessage = `Ignored z${zTarget.display} (folds unsupported)`; } else { throw new VimError(`Unsupported z command: z${zTarget.display}`, zTarget); } return nextIndex + 2; } + case "C-f": + this.buffer.setCursor({ + line: this.buffer.cursor.line + Math.max(1, (DEFAULT_VIEWPORT_HEIGHT - 2) * count), + col: this.buffer.cursor.col, + }); + return nextIndex + 1; + case "C-b": + this.buffer.setCursor({ + line: this.buffer.cursor.line - Math.max(1, (DEFAULT_VIEWPORT_HEIGHT - 2) * count), + col: this.buffer.cursor.col, + }); + return nextIndex + 1; case "C-d": this.buffer.setCursor({ - line: this.buffer.cursor.line + Math.max(1, Math.floor(40 / 2) * count), + line: this.buffer.cursor.line + Math.max(1, Math.floor(DEFAULT_VIEWPORT_HEIGHT / 2) * count), col: this.buffer.cursor.col, }); return nextIndex + 1; case "C-u": this.buffer.setCursor({ - line: this.buffer.cursor.line - Math.max(1, Math.floor(40 / 2) * count), + line: this.buffer.cursor.line - Math.max(1, Math.floor(DEFAULT_VIEWPORT_HEIGHT / 2) * count), col: this.buffer.cursor.col, }); return nextIndex + 1; @@ -1153,6 +1277,24 @@ export class VimEngine { } return nextIndex + 2; } + if (gNext.value === "*" || gNext.value === "#") { + const text = this.buffer.getText(); + const offset = this.buffer.currentOffset(); + const cat = wordCategory(text[offset] ?? "", false); + if (cat === "space") { + throw new VimError("No word under cursor", gNext); + } + let start = offset; + while (start > 0 && wordCategory(text[start - 1] ?? "", false) === cat) start -= 1; + let end = offset; + while (end < text.length && wordCategory(text[end] ?? "", false) === cat) end += 1; + const word = text.slice(start, end); + const direction = gNext.value === "*" ? 1 : -1; + for (let step = 0; step < count; step += 1) { + await this.#runSearch(escapeRegex(word), direction, true); + } + return nextIndex + 2; + } if (gNext.value === "U" || gNext.value === "u") { const caseOp = gNext.value; const { @@ -1179,12 +1321,49 @@ export class VimEngine { const effectiveCount = hasMotionCount ? count * motionCount : count; const motion = this.#resolveMotion(tokens, motionStart, effectiveCount, hasCount || hasMotionCount); const range = this.#resolveMotionRange(motion); - await this.#applyAtomicChange(["g", caseOp], () => { - const text = this.buffer.getText(); - const slice = text.slice(range.start, range.end); - const transformed = caseOp === "U" ? slice.toUpperCase() : slice.toLowerCase(); - this.buffer.replaceOffsets(range.start, range.end, transformed, range.start); - }); + await this.#applyAtomicChange( + tokens.slice(nextIndex, motion.nextIndex).map(tokenEntry => tokenEntry.value), + () => { + const text = this.buffer.getText(); + const slice = text.slice(range.start, range.end); + const transformed = caseOp === "U" ? slice.toUpperCase() : slice.toLowerCase(); + this.buffer.replaceOffsets(range.start, range.end, transformed, range.start); + }, + ); + return motion.nextIndex; + } + if (gNext.value === "~") { + const { + count: motionCount, + hasCount: hasMotionCount, + nextIndex: motionStart, + } = this.#readCount(tokens, nextIndex + 2); + const motionToken = tokens[motionStart]; + if (!motionToken) { + throw new VimError("g~ requires a motion", gNext); + } + if (motionToken.value === "~") { + const effectiveCount = hasMotionCount ? count * motionCount : count; + await this.#applyAtomicChange(["g", "~", motionToken.value], () => { + const start = this.buffer.cursor.line; + const end = this.buffer.clampLine(start + effectiveCount - 1); + for (let line = start; line <= end; line += 1) { + this.buffer.replaceLine(line, toggleCase(this.buffer.getLine(line))); + } + }); + return motionStart + 1; + } + const effectiveCount = hasMotionCount ? count * motionCount : count; + const motion = this.#resolveMotion(tokens, motionStart, effectiveCount, hasCount || hasMotionCount); + const range = this.#resolveMotionRange(motion); + await this.#applyAtomicChange( + tokens.slice(nextIndex, motion.nextIndex).map(tokenEntry => tokenEntry.value), + () => { + const text = this.buffer.getText(); + const slice = text.slice(range.start, range.end); + this.buffer.replaceOffsets(range.start, range.end, toggleCase(slice), range.start); + }, + ); return motion.nextIndex; } if (gNext.value === "J") { @@ -1502,6 +1681,11 @@ export class VimEngine { nextIndex: index + 1, target: { line: this.buffer.cursor.line, col: this.buffer.firstNonBlank(this.buffer.cursor.line) }, }; + case "|": + return { + nextIndex: index + 1, + target: { line: this.buffer.cursor.line, col: Math.max(0, count - 1) }, + }; case "$": return { nextIndex: index + 1, @@ -1536,14 +1720,31 @@ export class VimEngine { } case "g": { const next = tokens[index + 1]; - if (!next || next.value !== "g") { + if (!next) { throw new VimError("Unsupported g motion", token); } - return { - nextIndex: index + 2, - target: { line: hasCount ? Math.max(0, count - 1) : 0, col: 0 }, - linewise: true, - }; + if (next.value === "g") { + return { + nextIndex: index + 2, + target: { line: hasCount ? Math.max(0, count - 1) : 0, col: 0 }, + linewise: true, + }; + } + if (next.value === "e" || next.value === "E") { + let offset = this.buffer.currentOffset(); + for (let step = 0; step < count; step += 1) { + offset = endOfPreviousWord(text, offset, next.value === "E"); + } + return { nextIndex: index + 2, target: this.buffer.offsetToPosition(offset) }; + } + if (next.value === "_") { + const targetLine = this.buffer.clampLine(this.buffer.cursor.line + (count - 1)); + return { + nextIndex: index + 2, + target: { line: targetLine, col: lastNonBlankColumn(this.buffer.getLine(targetLine)) }, + }; + } + throw new VimError("Unsupported g motion", token); } case "G": return { @@ -2060,6 +2261,32 @@ export class VimEngine { this.statusMessage = `Sorted ${endLine - startLine + 1} line${endLine === startLine ? "" : "s"}`; return; } + case "global": { + const regex = createSearchRegex(command.pattern); + await this.#applyAtomicChange([":global"], () => { + const linesToProcess: number[] = []; + for (let i = 0; i < this.buffer.lineCount(); i++) { + const matches = regex.test(this.buffer.getLine(i)); + regex.lastIndex = 0; + if (command.invert ? !matches : matches) { + linesToProcess.push(i); + } + } + if (command.command === "d" || command.command === "delete") { + // Delete matching lines in reverse to preserve indices + for (let i = linesToProcess.length - 1; i >= 0; i--) { + this.buffer.lines.splice(linesToProcess[i]!, 1); + } + if (this.buffer.lines.length === 0) this.buffer.lines = [""]; + this.buffer.clampCursor(); + this.buffer.trailingNewline = true; + } else { + throw new VimError(`Unsupported :global sub-command: ${command.command}`); + } + }); + this.statusMessage = `Global: processed ${command.pattern}`; + return; + } } } diff --git a/packages/coding-agent/src/vim/render.ts b/packages/coding-agent/src/vim/render.ts index fb51f055c..6a15c51ab 100644 --- a/packages/coding-agent/src/vim/render.ts +++ b/packages/coding-agent/src/vim/render.ts @@ -11,7 +11,7 @@ import type { } from "./types"; export const VIM_OPEN_VIEWPORT_LINES = 80; -export const VIM_DEFAULT_VIEWPORT_LINES = 40; +export const VIM_DEFAULT_VIEWPORT_LINES = 10; export const VIM_TAB_DISPLAY = "→"; const VIM_VIEWPORT_WIDTH = 140; diff --git a/packages/coding-agent/src/vim/types.ts b/packages/coding-agent/src/vim/types.ts index 8b70def97..fabc863d0 100644 --- a/packages/coding-agent/src/vim/types.ts +++ b/packages/coding-agent/src/vim/types.ts @@ -127,15 +127,19 @@ export interface VimLineRange { 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: "sort"; range?: VimLineRange | "all"; flags: string } + | { kind: "global"; pattern: string; command: string; invert: boolean }; export class VimInputError extends Error { location?: { sequenceIndex: number; offset: number }; diff --git a/packages/coding-agent/test/tools/vim.test.ts b/packages/coding-agent/test/tools/vim.test.ts index de741c4e8..b1b71eff9 100644 --- a/packages/coding-agent/test/tools/vim.test.ts +++ b/packages/coding-agent/test/tools/vim.test.ts @@ -3,12 +3,19 @@ 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 { VimTool, vimToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/vim"; +import { + primeVimCallPreview, + 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 @@ -61,6 +68,7 @@ function createEngine(text: string): VimEngine { afterEach(() => { vi.restoreAllMocks(); + resetVimRendererStateForTest(); }); describe("vim parser", () => { @@ -204,8 +212,8 @@ describe("vim tool", () => { await tool.execute("open", { file: "center.ts" }); const edited = await tool.execute("edit", { file: "center.ts", kbd: ["386Go"], insert: "inserted", pause: true }); expect(edited.details?.cursor.line).toBe(387); - expect(edited.details?.viewport.start).toBe(367); - expect(edited.details?.viewport.end).toBe(406); + expect(edited.details?.viewport.start).toBe(382); + expect(edited.details?.viewport.end).toBe(391); expect(textResult(edited)).toContain("Diff:"); expect(textResult(edited)).toContain("+inserted"); }); @@ -219,7 +227,7 @@ describe("vim tool", () => { const edited = await tool.execute("edit", { file: "long-edit.ts", kbd: ["1014G", "o"], insert: "inserted" }); const text = textResult(edited); expect(edited.details?.cursor.line).toBe(1015); - expect(edited.details?.viewport.start).toBe(995); + expect(edited.details?.viewport.start).toBe(1010); expect(text).toContain("Diff:"); expect(text).toContain("+inserted"); }); @@ -316,7 +324,7 @@ describe("vim tool", () => { 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("→return value;"); expect(text).toContain("^"); }); @@ -356,6 +364,67 @@ describe("vim tool", () => { 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", + kbd: ["2Go"], + insert: 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", + kbd: ["2Go"], + insert: 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"); @@ -379,6 +448,166 @@ describe("vim tool", () => { }); describe("vim renderer", () => { + it("previews streamed cursor jumps using the live vim buffer snapshot", 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" }); + await primeVimCallPreview("preview-call", { file: "preview.ts", kbd: ["643G"], __toolCallId: "preview-call" }); + + const component = vimToolRenderer.renderCall( + { file: "preview.ts", kbd: ["643G"], __toolCallId: "preview-call" }, + { expanded: false, isPartial: true, spinnerFrame: 0 }, + uiTheme, + ); + + const rendered = component.render(160).join("\n"); + expect(rendered).toContain("643G"); + expect(rendered).toContain("line 643;"); + }); + + it("previews partial insert payloads before the tool call JSON closes", async () => { + const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-insert-preview-")); + const filePath = path.join(previewDir, "preview.txt"); + await Bun.write(filePath, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\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.txt" }); + await primeVimCallPreview("insert-preview-call", { + file: "preview.txt", + kbd: ["7Go"], + __toolCallId: "insert-preview-call", + __partialJson: + '{"file":"preview.txt","kbd":["7Go"],"insert":"// long streamed comment still being typed by the model', + }); + + const component = vimToolRenderer.renderCall( + { + file: "preview.txt", + kbd: ["7Go"], + __toolCallId: "insert-preview-call", + __partialJson: + '{"file":"preview.txt","kbd":["7Go"],"insert":"// long streamed comment still being typed by the model', + }, + { expanded: false, isPartial: true, spinnerFrame: 0 }, + uiTheme, + ); + + const rendered = component.render(160).join("\n"); + expect(rendered).toContain("7Go"); + expect(rendered).toContain("// long streamed comment still being typed by the model"); + }); + + it("previews the target file on the first vim call without a prior open", async () => { + const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-first-call-")); + const filePath = path.join(previewDir, "preview.txt"); + await Bun.write(filePath, "Line 1\nLine 2\nLine 3\n"); + const theme = await themeModule.getThemeByName("dark"); + expect(theme).toBeDefined(); + const uiTheme = theme!; + + await primeVimCallPreview("first-call-preview", { + file: "preview.txt", + kbd: ["ggdGi"], + __toolCallId: "first-call-preview", + __cwd: previewDir, + __partialJson: '{"file":"preview.txt","kbd":["ggdGi"],"insert":"replacement', + }); + + const component = vimToolRenderer.renderCall( + { + file: "preview.txt", + kbd: ["ggdGi"], + __toolCallId: "first-call-preview", + __cwd: previewDir, + __partialJson: '{"file":"preview.txt","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(">1│replacement"); + }); + + it("loads first-call vim previews through ToolExecutionComponent constructor state", async () => { + const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-first-call-component-")); + const filePath = path.join(previewDir, "preview.txt"); + await Bun.write(filePath, "Line 1\nLine 2\nLine 3\n"); + 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( + "vim", + { + file: "preview.txt", + kbd: ["ggdGi"], + __partialJson: '{"file":"preview.txt","kbd":["ggdGi"],"insert":"replacement', + }, + {}, + undefined, + uiStub, + previewDir, + "first-call-component", + ); + await Bun.sleep(50); + + const rendered = Bun.stripANSI(component.render(140).join("\n")); + expect(rendered).toContain("ggdGi"); + expect(rendered).toContain(">1│replacement"); + }); + + it("keeps vim preview state alive after args complete until a result arrives", async () => { + const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-complete-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!; + const uiStub = { requestRender() {} } as unknown as TUI; + await themeModule.initTheme(false, undefined, undefined, "dark", "light"); + + await tool.execute("open", { file: "preview.ts" }); + await primeVimCallPreview("complete-preview-call", { + file: "preview.ts", + kbd: ["643G"], + __toolCallId: "complete-preview-call", + }); + + const component = new ToolExecutionComponent( + "vim", + { file: "preview.ts", kbd: ["643G"] }, + {}, + undefined, + uiStub, + previewDir, + "complete-preview-call", + ); + component.setArgsComplete("complete-preview-call"); + + const rendered = vimToolRenderer + .renderCall( + { file: "preview.ts", kbd: ["643G"], __toolCallId: "complete-preview-call" }, + { expanded: false, isPartial: true, spinnerFrame: 0 }, + uiTheme, + ) + .render(160) + .join("\n"); + expect(Bun.stripANSI(rendered)).toContain("line 643;"); + }); + it("caches repeated renders for the same viewport snapshot", async () => { const theme = await themeModule.getThemeByName("dark"); expect(theme).toBeDefined();