diff --git a/.gitignore b/.gitignore index 89ab3018..e726cc12 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ doc/feature +node_modules/ diff --git a/index.ts b/index.ts index 358a3773..cc4cdbd8 100644 --- a/index.ts +++ b/index.ts @@ -1214,6 +1214,7 @@ export class ModalEditor extends CustomEditor { if ("insert" === this.mode) { this.clearUnderlyingPasteStateIfActive(); this.setMode("normal"); + if (this.getCursor().col > 0) this.moveCursorBy(-1); } else { super.handleInput("\x1b"); // pass escape to abort agent } @@ -2017,7 +2018,7 @@ export class ModalEditor extends CustomEditor { this.setMode(); break; case "x": - this.cutCharUnderCursor(); + this.cutCharUnderCursor(true); break; case "j": this.moveCursorVertically(1); @@ -2615,7 +2616,7 @@ export class ModalEditor extends CustomEditor { return col >= line.length; } - private cutCharUnderCursor(): void { + private cutCharUnderCursor(normal: boolean = false): void { const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1))); const cursor = this.getCursor(); const line = this.getLines()[cursor.line] ?? ""; @@ -2630,6 +2631,10 @@ export class ModalEditor extends CustomEditor { text.slice(lineStartAbs + range.end), lineStartAbs + range.start, ); + if (normal) { + const { line, col } = this.getCurrentLineAndCol(); + if (line && col >= line.length) this.moveCursorBy(-1); + } } private cutToEndOfLine(): void { diff --git a/script/pack-check.ts b/script/pack-check.ts index b36d8bac..bf442d27 100644 --- a/script/pack-check.ts +++ b/script/pack-check.ts @@ -67,7 +67,7 @@ const THRESHOLDS = { maxFiles: 12, // WORD/delimited text objects plus mode-color settings add package surface. // Keep budgets tight enough to catch accidental docs/tests in the package. - maxSize: 31400, + maxSize: 31450, maxUnpackedSize: 139500, } as const; diff --git a/test/modal-editor.test.ts b/test/modal-editor.test.ts index a7188c49..ebd886f6 100644 --- a/test/modal-editor.test.ts +++ b/test/modal-editor.test.ts @@ -801,6 +801,36 @@ describe("mode transitions", () => { assert.equal(editor.getMode(), "normal"); }); + it("escape from insert mode places normal cursor on previous character", () => { + const editor = new ModalEditor(stubTui, stubTheme, stubKeybindings); + + sendKeys(editor, ["h", "e", "l", "l", "o"]); + assert.deepEqual(editor.getCursor(), { line: 0, col: 5 }); + + sendKeys(editor, ["\x1b"]); + + assert.equal(editor.getMode(), "normal"); + assert.deepEqual(editor.getCursor(), { line: 0, col: 4 }); + }); + + it("escape from insert mode does not move before line start", () => { + const { editor } = createEditorWithSpy("hello"); + + sendKeys(editor, ["i", "\x1b"]); + + assert.equal(editor.getMode(), "normal"); + assert.deepEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + + it("escape from insert mode moves by one grapheme", () => { + const editor = new ModalEditor(stubTui, stubTheme, stubKeybindings); + + sendKeys(editor, ["a", "😀", "\x1b"]); + + assert.equal(editor.getMode(), "normal"); + assert.deepEqual(editor.getCursor(), { line: 0, col: 1 }); + }); + it("kitty ctrl+[ enters normal mode like escape", () => { const { editor } = createEditorWithSpy("hello"); sendKeys(editor, ["i"]); @@ -3802,6 +3832,25 @@ describe("single-key edits — x / s / S / D / C", () => { assert.deepEqual(clipboardWrites, ["h"]); }); + it("x keeps the cursor on the next character after deleting in the middle", () => { + const { editor } = createEditorWithSpy("abcd"); + + sendKeys(editor, ["l", "l", "x"]); + + assert.equal(editor.getText(), "abd"); + assert.deepEqual(editor.getCursor(), { line: 0, col: 2 }); + }); + + it("x moves back by one grapheme after deleting the last character", () => { + const { editor } = createEditorWithSpy("a😀b"); + + setInternalCursor(editor, 3); + sendKeys(editor, ["x"]); + + assert.equal(editor.getText(), "a😀"); + assert.deepEqual(editor.getCursor(), { line: 0, col: 1 }); + }); + it("s: deletes char under cursor, enters insert mode", () => { const { editor } = createEditorWithSpy("hello"); sendKeys(editor, ["s"]); @@ -3856,6 +3905,7 @@ describe("Universal Counts: Edits and Put", () => { assert.equal(editor.getText(), "abcd"); assert.equal(editor.getRegister(), "ef"); + assert.deepEqual(editor.getCursor(), { line: 0, col: 3 }); }); it("3p pastes register text three times after cursor", () => { @@ -4186,6 +4236,7 @@ describe("EOL and newline semantics", () => { sendKeys(editor, ["e", "x"]); assert.equal(editor.getRegister(), "1"); assert.equal(editor.getText(), "line\nline2"); // only '1' gone, newline intact + assert.deepEqual(editor.getCursor(), { line: 0, col: 3 }); }); }); @@ -5321,7 +5372,7 @@ describe("undo / redo — u / ctrl+r", () => { initial: "hello world", keys: ["c", "w", "Z", "\x1b"], expectedText: "Zworld", - expectedCursor: { line: 0, col: 1 }, + expectedCursor: { line: 0, col: 0 }, expectedRegister: "hello ", }); });