From 4dee768cb372174fcfb18481e7ef8fc9442b1bfe Mon Sep 17 00:00:00 2001 From: Aleksandr Kladov Date: Tue, 28 Jul 2026 18:06:05 +0300 Subject: [PATCH] feat: add file boundary navigation --- README.md | 1 + agent-guide.md | 7 ++++--- src/review/shortcuts.mjs | 10 ++++++++-- src/review/ui.mjs | 41 +++++++++++++++++++++++++++++++++++++++- test-bun/ui.test.mjs | 24 +++++++++++++++++++++++ test/shortcuts.test.mjs | 6 ++++++ 6 files changed, 83 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b075ce9..d83f65f 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ The review pane uses this stable key contract: | --- | --- | | `1` / `2` / `3` | Working tree / branch / last observed turn | | arrows or `j` / `k` | Previous/next diff row or detached comment | +| `gg` / `G` | First/last diff row in the current file | | `Ctrl+U` / `Ctrl+D` | Move up/down by half a visible page | | `[` / `]` | Previous/next separated change block | | `{` / `}` | Previous/next file | diff --git a/agent-guide.md b/agent-guide.md index 18bc8e3..c667c7b 100644 --- a/agent-guide.md +++ b/agent-guide.md @@ -131,9 +131,10 @@ Fix only problems introduced by this setup. An attached client may need 3. Choose `1` for current uncommitted work, `2` for everything since the branch diverged from its local main/master base, or `3` for the latest file-changing turn observed from this exact agent. Navigate with `j`/`k`, - use `Ctrl+U`/`Ctrl+D` for half-page movement, and switch change blocks or - files with `[`/`]` and `{`/`}`; toggle the file sidebar with `b` or drag - its divider to resize it, and toggle long-row wrapping with `w`. Letter and + jump to the first/last row of the current file with `gg`/`G`, use + `Ctrl+U`/`Ctrl+D` for half-page movement, and switch change blocks or files + with `[`/`]` and `{`/`}`; toggle the file sidebar with `b` or drag its + divider to resize it, and toggle long-row wrapping with `w`. Letter and bracket shortcuts also work from a Russian keyboard layout. 4. Select a line/range with `v`, press `c`, and save with `Ctrl+S`. 5. Press `F6` to hide/restore the same live pane. diff --git a/src/review/shortcuts.mjs b/src/review/shortcuts.mjs index 0cde65b..7a72969 100644 --- a/src/review/shortcuts.mjs +++ b/src/review/shortcuts.mjs @@ -40,20 +40,26 @@ const RUSSIAN_QWERTY = Object.freeze({ }); function shiftedShortcut(name, shift) { + if (shift && name === "g") return "G"; return shift ? (SHIFTED_BASE_KEYS[name] ?? name) : name; } export function shortcutName(key) { if (Number.isInteger(key.baseCode)) { const base = String.fromCodePoint(key.baseCode); - if (/^[A-Z]$/u.test(base)) return base.toLowerCase(); - if (/^[a-z]$/u.test(base)) return base; + if (/^[A-Z]$/u.test(base)) { + return shiftedShortcut(base.toLowerCase(), key.shift); + } + if (/^[a-z]$/u.test(base)) return shiftedShortcut(base, key.shift); return shiftedShortcut(base, key.shift); } const name = String(key.name ?? ""); if ([...name].length !== 1) return name; const lower = name.toLocaleLowerCase("ru-RU"); + if (/^[a-z]$/u.test(lower)) { + return shiftedShortcut(lower, key.shift || name !== lower); + } const physical = RUSSIAN_QWERTY[lower]; if (!physical) return name; const shifted = key.shift || name !== lower; diff --git a/src/review/ui.mjs b/src/review/ui.mjs index 1fd62ae..1be7cc4 100644 --- a/src/review/ui.mjs +++ b/src/review/ui.mjs @@ -168,6 +168,7 @@ export class ReviewUI { this.resizingSidebar = false; this.selectedDetachedNoteId = null; this.diffNavigation = []; + this.pendingG = false; this.root = new BoxRenderable(this.ctx, { id: "review-root", @@ -261,6 +262,27 @@ export class ReviewUI { return; } + if (name === "g") { + key.preventDefault(); + if (this.pendingG) { + this.pendingG = false; + this.moveToFileBoundary("top"); + } else { + this.pendingG = true; + this.controller.status = "Press g again to jump to the top of this file."; + } + this.render(); + return; + } + const jumpToBottom = name === "G"; + this.pendingG = false; + if (jumpToBottom) { + key.preventDefault(); + this.moveToFileBoundary("bottom"); + this.render(); + return; + } + const defaultSidebarVisible = this.renderer.terminalWidth >= 72; if (this.showNotes && ["up", "k", "down", "j", "return"].includes(name)) { key.preventDefault(); @@ -383,6 +405,23 @@ export class ReviewUI { this.controller.moveRow(direction * Math.max(1, Math.floor(visibleRows / 2))); } + moveToFileBoundary(boundary) { + const rowCount = this.controller.file?.rows.length ?? 0; + if (!rowCount) { + this.controller.status = "The current file has no diff rows."; + return; + } + this.selectedDetachedNoteId = null; + this.controller.rowIndex = boundary === "top" ? 0 : rowCount - 1; + this.controller.rangeStart = null; + this.controller.rangeEnd = null; + this.controller.status = + boundary === "top" + ? "Moved to the top of the current file." + : "Moved to the bottom of the current file."; + this.controller.persistUI(); + } + moveDiffSelection(delta) { if (!this.diffNavigation.length) { this.controller.moveRow(delta); @@ -922,7 +961,7 @@ export class ReviewUI { text( this.ctx, "help", - `1 working · 2 branch · 3 last turn · ↑/k ↓/j rows · Ctrl+U/D half-page · [ ] change blocks · { } files\nb sidebar · w row wrap · v range · s context target · c comment · e edit · x resolve/reopen · n notes · d d delete · r refresh · Esc cancel`, + `1 working · 2 branch · 3 last turn · ↑/k ↓/j rows · gg/G file top/bottom · Ctrl+U/D half-page · [ ] change blocks · { } files\nb sidebar · w row wrap · v range · s context target · c comment · e edit · x resolve/reopen · n notes · d d delete · r refresh · Esc cancel`, { height: 2 }, ), ); diff --git a/test-bun/ui.test.mjs b/test-bun/ui.test.mjs index 29eb20f..b3f0a8b 100644 --- a/test-bun/ui.test.mjs +++ b/test-bun/ui.test.mjs @@ -393,6 +393,30 @@ test("j/k navigation keeps the selected row inside a long diff viewport", async expect(app.captureCharFrame()).toContain("const line1 = 1;"); }); +test("gg and G jump to file boundaries and keep the selected row visible", async () => { + const app = await setup(80, 12, undefined, longModel()); + + app.mockInput.pressKey("g", { shift: true }); + await app.flush(); + expect(app.controller.rowIndex).toBe(app.controller.file.rows.length - 1); + expectSelectedRowVisible(app); + + app.mockInput.pressKey("g"); + app.mockInput.pressKey("g"); + await app.flush(); + expect(app.controller.rowIndex).toBe(0); + expectSelectedRowVisible(app); + + app.mockInput.pressKey("П", { shift: true }); + await app.flush(); + expect(app.controller.rowIndex).toBe(app.controller.file.rows.length - 1); + + app.mockInput.pressKey("п"); + app.mockInput.pressKey("п"); + await app.flush(); + expect(app.controller.rowIndex).toBe(0); +}); + test("Ctrl+D and Ctrl+U move by half a viewport and keep selection visible", async () => { const app = await setup(80, 12, undefined, longModel()); diff --git a/test/shortcuts.test.mjs b/test/shortcuts.test.mjs index bc1b94c..715f9aa 100644 --- a/test/shortcuts.test.mjs +++ b/test/shortcuts.test.mjs @@ -16,6 +16,10 @@ test("shortcut names use physical base codes when terminals report them", () => shortcutName(key(",", { baseCode: "/".codePointAt(0), shift: true })), "?", ); + assert.equal( + shortcutName(key("G", { baseCode: "G".codePointAt(0), shift: true })), + "G", + ); }); test("shortcut names fall back to Russian QWERTY aliases", () => { @@ -23,6 +27,8 @@ test("shortcut names fall back to Russian QWERTY aliases", () => { assert.equal(shortcutName(key("л")), "k"); assert.equal(shortcutName(key("с")), "c"); assert.equal(shortcutName(key("ч")), "x"); + assert.equal(shortcutName(key("п")), "g"); + assert.equal(shortcutName(key("П", { shift: true })), "G"); assert.equal(shortcutName(key("ы", { ctrl: true })), "s"); assert.equal(shortcutName(key("в", { ctrl: true })), "d"); assert.equal(shortcutName(key("г", { ctrl: true })), "u");