diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7812d4846..6fd4450dc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -23,6 +23,7 @@ - Added `gJ` to join a line range, `gv` to restore the last visual selection, and `ZZ`/`ZQ` shortcuts for save-and-exit or exit-without-save in vim mode - Added paragraph text object `p` for `ip`/`ap`-style paragraph selection - Added support for Vim ex line-address forms like `.`, `$`, `+N`/`-N`, destination addresses such as `:t$`, and ranged `:global` commands +- Added Vim ex `:join`/`:j` and `:join!`/`:j!` support to join addressed lines with or without whitespace normalization - Added a warning when chunk edits write to the `~` selector with body lines that appear over-indented, instructing users to start top-level body text at column 0 - Added validation feedback for suspect indentation in chunk-mode `~` body writes so users can align content with the tool's automatic base indentation - Added support for multi-file `edit` calls across replace, patch, hashline, and chunk modes by grouping `edits` entries by file path and returning combined per-file results @@ -7076,4 +7077,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections \ No newline at end of file +- HTML export with syntax highlighting and collapsible sections diff --git a/packages/coding-agent/src/prompts/tools/vim.md b/packages/coding-agent/src/prompts/tools/vim.md index 38db5817f..3b26c6a45 100644 --- a/packages/coding-agent/src/prompts/tools/vim.md +++ b/packages/coding-agent/src/prompts/tools/vim.md @@ -91,6 +91,6 @@ Operators: `d c y p` with motions and text objects (`iw aw ip ap i" a" i( a( i{ 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` +Ex: `:w` `:q` `:wq` `:e` `:e!` `:N` `:s///` `:%s///` `:N,Md` `:%d` `:N,Mt N` `:sort` `:j` `:j!` `:g/pattern/d` `:v/pattern/d` Addresses: absolute line numbers, `.`, `$`, and `+N`/`-N` relative offsets, including ranges like `:.,$d` and `:.+2,$g/pattern/d` More ex: `:up` `:N,My` `:put` `:put!` `:N,Mco $` `:N,Mm $` diff --git a/packages/coding-agent/src/vim/commands.ts b/packages/coding-agent/src/vim/commands.ts index 3ab5cdbf4..892562c7d 100644 --- a/packages/coding-agent/src/vim/commands.ts +++ b/packages/coding-agent/src/vim/commands.ts @@ -294,6 +294,9 @@ export function parseExCommand(input: string, context?: VimExParseContext): VimE const flags = rest.slice(4).trim(); return { kind: "sort", range: range ?? undefined, flags }; } + if (rest === "j" || rest === "join" || rest === "j!" || rest === "join!") { + return { kind: "join", range: range ?? undefined, trimWhitespace: !rest.endsWith("!") }; + } if (rest.startsWith("substitute")) { const segments = parseDelimitedSegments(rest.slice("substitute".length)); diff --git a/packages/coding-agent/src/vim/engine.ts b/packages/coding-agent/src/vim/engine.ts index af3b3a709..ff12667ac 100644 --- a/packages/coding-agent/src/vim/engine.ts +++ b/packages/coding-agent/src/vim/engine.ts @@ -2298,6 +2298,34 @@ export class VimEngine { this.statusMessage = `Sorted ${endLine - startLine + 1} line${endLine === startLine ? "" : "s"}`; return; } + case "join": { + const currentLine = this.buffer.cursor.line + 1; + const baseRange = this.#resolveExRange( + command.range, + currentLine, + command.range ? undefined : Math.min(this.buffer.lineCount(), currentLine + 1), + ); + const startLine = baseRange.start; + const endLine = + baseRange.start === baseRange.end ? Math.min(this.buffer.lineCount(), baseRange.end + 1) : baseRange.end; + const lineCount = endLine - startLine + 1; + if (lineCount < 2) { + this.statusMessage = "Nothing to join"; + return; + } + await this.#applyAtomicChange([":join"], () => { + const startIndex = startLine - 1; + if (command.trimWhitespace) { + this.buffer.joinLines(startIndex, lineCount - 1); + return; + } + const joined = this.buffer.lines.slice(startIndex, endLine).join(""); + this.buffer.lines.splice(startIndex, lineCount, joined); + this.buffer.setCursor({ line: startIndex, col: Math.max(0, joined.length - 1) }); + }); + this.statusMessage = `Joined ${lineCount} lines`; + return; + } case "append": { const anchorRange = this.#resolveExRange(command.range, this.buffer.cursor.line + 1); const anchorLine = anchorRange.end; diff --git a/packages/coding-agent/src/vim/types.ts b/packages/coding-agent/src/vim/types.ts index 2572bbc1b..0603ff525 100644 --- a/packages/coding-agent/src/vim/types.ts +++ b/packages/coding-agent/src/vim/types.ts @@ -140,6 +140,7 @@ export type VimExCommand = | { kind: "copy"; range?: VimLineRange | "all"; destination: number } | { kind: "move"; range?: VimLineRange | "all"; destination: number } | { kind: "sort"; range?: VimLineRange | "all"; flags: string } + | { kind: "join"; range?: VimLineRange | "all"; trimWhitespace: boolean } | { kind: "global"; range?: VimLineRange | "all"; pattern: string; command: string; invert: boolean } | { kind: "append"; range?: VimLineRange; text: string } | { kind: "insert-before"; range?: VimLineRange; text: string }; diff --git a/packages/coding-agent/test/tools/vim.test.ts b/packages/coding-agent/test/tools/vim.test.ts index 7098ed236..ada0452f6 100644 --- a/packages/coding-agent/test/tools/vim.test.ts +++ b/packages/coding-agent/test/tools/vim.test.ts @@ -176,6 +176,19 @@ describe("vim engine", () => { expect(engine.statusMessage).toBe("Global: processed alpha"); }); + it("supports :join aliases with whitespace normalization", async () => { + const engine = createEngine("alpha \n beta\ngamma"); + await engine.executeTokens(parseKeySequences([":1,2j"]), ":1,2j"); + expect(engine.buffer.getText()).toBe("alpha beta\ngamma"); + expect(engine.statusMessage).toBe("Joined 2 lines"); + }); + + it("supports :join! aliases without whitespace normalization", async () => { + const engine = createEngine("alpha \n beta\ngamma"); + await engine.executeTokens(parseKeySequences([":1,2join!"]), ":1,2join!"); + expect(engine.buffer.getText()).toBe("alpha beta\ngamma"); + expect(engine.statusMessage).toBe("Joined 2 lines"); + }); it("supports destination addresses for :copy", async () => { const engine = createEngine("one\ntwo\nthree\nfour"); await engine.executeTokens(parseKeySequences([":1,2t$"]), ":1,2t$"); @@ -407,9 +420,9 @@ describe("vim tool", () => { }); const saved = await Bun.file(filePath).text(); - expect(saved).toBe("alpha\nbeta\n\n"); + expect(saved).toBe("alpha\nbeta\n"); expect(textResult(rewritten)).toContain("+alpha"); - expect(rewritten.details?.cursor.line).toBe(3); + expect(rewritten.details?.cursor.line).toBe(2); }); it("rejects another kbd entry after entering insert mode", async () => {