Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 4 additions & 3 deletions agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions src/review/shortcuts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 40 additions & 1 deletion src/review/ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 },
),
);
Expand Down
24 changes: 24 additions & 0 deletions test-bun/ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
6 changes: 6 additions & 0 deletions test/shortcuts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ 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", () => {
assert.equal(shortcutName(key("о")), "j");
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");
Expand Down