From 174d6b6571fbb9ff71cfd57709d059e745447fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sun, 1 Mar 2026 23:59:11 +0100 Subject: [PATCH 1/2] Add fast navigation: gg/G top/bottom, Page Up/Down, Ctrl+U/D Closes #68 - gg: two consecutive g presses jump to the first row - G (Shift+G): jumps to the last row - Page Up / Ctrl+U: scroll up one full page - Page Down / Ctrl+D: scroll down one full page - All jumps skip section-header rows and keep cursor visible - pendingFirstG state tracks the first g keypress; reset on any other key - renderHelpOverlay: add gg/G and PgUp/PgDn lines - Status bar hint line: add gg/G top/bot and PgUp/Dn page entries - Tests: overlay and status bar contain the new shortcut labels - docs/reference/keyboard-shortcuts.md: documents all new shortcuts --- docs/reference/keyboard-shortcuts.md | 18 +++++---- src/render.test.ts | 22 ++++++++++- src/render.ts | 4 +- src/tui.ts | 59 ++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 71641ff..bf4a8b0 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -4,13 +4,17 @@ All shortcuts are active in the interactive TUI. Keys are **case-sensitive** — ## Navigation -| Key | Action | -| --------- | ------------------------------------------------------------------------------------------ | -| `↑` / `k` | Move cursor up (repos and extracts) | -| `↓` / `j` | Move cursor down (repos and extracts) | -| `←` | Fold the repo under the cursor | -| `→` | Unfold the repo under the cursor | -| `Z` | **Global fold / unfold** — fold all repos if any is unfolded; unfold all if all are folded | +| Key | Action | +| ---------------------- | ------------------------------------------------------------------------------------------ | +| `↑` / `k` | Move cursor up (repos and extracts) | +| `↓` / `j` | Move cursor down (repos and extracts) | +| `←` | Fold the repo under the cursor | +| `→` | Unfold the repo under the cursor | +| `Z` | **Global fold / unfold** — fold all repos if any is unfolded; unfold all if all are folded | +| `gg` | Jump to the **top** (first result) | +| `G` | Jump to the **bottom** (last result) | +| `Page Up` / `Ctrl+U` | Scroll up one full page | +| `Page Down` / `Ctrl+D` | Scroll down one full page | Section header rows (shown when `--group-by-team-prefix` is active) are skipped automatically during navigation. diff --git a/src/render.test.ts b/src/render.test.ts index d992e31..6e020cd 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -915,6 +915,24 @@ describe("renderHelpOverlay", () => { expect(stripped).toContain("open in browser"); }); + it("documents gg/G fast navigation shortcuts", () => { + const out = renderHelpOverlay(); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("gg"); + expect(stripped).toContain("jump to top"); + expect(stripped).toContain("G"); + expect(stripped).toContain("jump to bottom"); + }); + + it("documents Page Up/Down fast navigation shortcuts", () => { + const out = renderHelpOverlay(); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("PgUp"); + expect(stripped).toContain("PgDn"); + expect(stripped).toContain("page up"); + expect(stripped).toContain("page down"); + }); + it("is returned by renderGroups when showHelp=true", () => { const groups = [makeGroup("org/repo", ["a.ts"])]; const rows = buildRows(groups); @@ -981,13 +999,15 @@ describe("renderGroups filter opts", () => { expect(stripped).not.toContain("Filter:"); }); - it("status bar hint line includes Z fold-all and o open shortcuts", () => { + it("status bar hint line includes all navigation hint shortcuts", () => { const groups = [makeGroup("org/repo", ["a.ts"])]; const rows = buildRows(groups); const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", {}); const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); expect(stripped).toContain("Z fold-all"); expect(stripped).toContain("o open"); + expect(stripped).toContain("gg/G top/bot"); + expect(stripped).toContain("PgUp/Dn page"); }); it("shows mode badge [content] when filterTarget=content", () => { diff --git a/src/render.ts b/src/render.ts index ba09e02..cf4b73f 100644 --- a/src/render.ts +++ b/src/render.ts @@ -34,6 +34,8 @@ export function renderHelpOverlay(): string { ` ${pc.yellow("↑")} / ${pc.yellow("k")} navigate up ${pc.yellow("↓")} / ${pc.yellow("j")} navigate down`, ` ${pc.yellow("←")} fold repo ${pc.yellow("→")} unfold repo`, ` ${pc.yellow("Z")} fold / unfold all repos`, + ` ${pc.yellow("gg")} jump to top ${pc.yellow("G")} jump to bottom`, + ` ${pc.yellow("PgUp")} / ${pc.yellow("Ctrl+U")} page up ${pc.yellow("PgDn")} / ${pc.yellow("Ctrl+D")} page down`, ` ${pc.yellow("Space")} toggle selection ${pc.yellow("Enter")} confirm & output`, ` ${pc.yellow("a")} select all ${pc.yellow("n")} select none`, ` ${pc.dim("(respects active filter)")}`, @@ -299,7 +301,7 @@ export function renderGroups( lines.push( pc.dim( - "← / → fold/unfold Z fold-all ↑ / ↓ navigate spc select a all n none o open f filter t target h help ↵ confirm q quit\n", + "← / → fold/unfold Z fold-all ↑ / ↓ navigate gg/G top/bot PgUp/Dn page spc select a all n none o open f filter t target h help ↵ confirm q quit\n", ), ); diff --git a/src/tui.ts b/src/tui.ts index a620e87..30fd17c 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -38,6 +38,10 @@ const KEY_ALT_B = "\x1bb"; const KEY_ALT_F = "\x1bf"; const KEY_DELETE = "\x1b[3~"; const KEY_SHIFT_TAB = "\x1b[Z"; // Shift+Tab — cycle filter target in filter mode +const KEY_PAGE_UP = "\x1b[5~"; // Page Up — scroll up one page +const KEY_PAGE_DOWN = "\x1b[6~"; // Page Down — scroll down one page +const KEY_CTRL_U = "\x15"; // Ctrl+U — page up (Vim-style) +const KEY_CTRL_D = "\x04"; // Ctrl+D — page down (Vim-style) // ─── Word-boundary helpers ──────────────────────────────────────────────────── @@ -141,6 +145,8 @@ export async function runInteractive( let filterLiveStats: FilterStats | null = null; let statsDebounceTimer: ReturnType | null = null; let showHelp = false; + // Track first 'g' keypress so that a second consecutive 'g' jumps to the top. + let pendingFirstG = false; /** Schedule a debounced stats recompute (while typing in filter bar). */ const scheduleStatsUpdate = () => { @@ -176,6 +182,11 @@ export async function runInteractive( for await (const chunk of process.stdin) { const key = chunk.toString(); + // Reset the gg pending state on every key that isn’t g itself. + // This lets `gg` work as two consecutive g presses without interfering + // with any other shortcut. + if (key !== "g") pendingFirstG = false; + // ── Filter input mode ──────────────────────────────────────────────────── if (filterMode) { if (key === KEY_CTRL_C) { @@ -441,6 +452,54 @@ export async function runInteractive( } } + // `g` — first g of gg sequence (jump to top on second g) + if (key === "g") { + if (pendingFirstG) { + // Second consecutive g — jump to the first non-section row + cursor = 0; + while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++; + scrollOffset = 0; + pendingFirstG = false; + } else { + pendingFirstG = true; + } + redraw(); + continue; + } + + // `G` — jump to last row (bottom) + if (key === "G") { + cursor = rows.length - 1; + while (cursor > 0 && rows[cursor]?.type === "section") cursor--; + while ( + scrollOffset < cursor && + !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight()) + ) { + scrollOffset++; + } + } + + // Page Up / Ctrl+U — scroll up by a full page + if (key === KEY_PAGE_UP || key === KEY_CTRL_U) { + const pageSize = Math.max(1, getViewportHeight()); + cursor = Math.max(0, cursor - pageSize); + while (cursor > 0 && rows[cursor]?.type === "section") cursor--; + if (cursor < scrollOffset) scrollOffset = cursor; + } + + // Page Down / Ctrl+D — scroll down by a full page + if (key === KEY_PAGE_DOWN || key === KEY_CTRL_D) { + const pageSize = Math.max(1, getViewportHeight()); + cursor = Math.min(rows.length - 1, cursor + pageSize); + while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++; + while ( + scrollOffset < cursor && + !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight()) + ) { + scrollOffset++; + } + } + if (key === " " && row && row.type !== "section") { if (row.type === "repo") { const group = groups[row.repoIndex]; From 54460e95262080c5cea1a1e0980c03c3944cea82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Mon, 2 Mar 2026 00:19:57 +0100 Subject: [PATCH 2/2] Address review feedback: multi-chunk gg, G empty-rows guard, page-up section skip, fix docs --- src/tui.ts | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/tui.ts b/src/tui.ts index 30fd17c..b75f78e 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -182,10 +182,11 @@ export async function runInteractive( for await (const chunk of process.stdin) { const key = chunk.toString(); - // Reset the gg pending state on every key that isn’t g itself. - // This lets `gg` work as two consecutive g presses without interfering - // with any other shortcut. - if (key !== "g") pendingFirstG = false; + // Reset the gg pending state on every key that isn't a sequence of one + // or more plain "g" characters. This allows terminals that batch key + // repeats (e.g. delivering "gg" in a single chunk) to still participate + // in the gg shortcut without interfering with any other shortcut. + if (!/^g+$/.test(key)) pendingFirstG = false; // ── Filter input mode ──────────────────────────────────────────────────── if (filterMode) { @@ -452,10 +453,12 @@ export async function runInteractive( } } - // `g` — first g of gg sequence (jump to top on second g) - if (key === "g") { - if (pendingFirstG) { - // Second consecutive g — jump to the first non-section row + // `gg` — jump to top (first non-section row). + // Handles both two consecutive single-g chunks and a single "gg" chunk + // (terminals that batch repeated keypresses into one read() call). + if (/^g+$/.test(key)) { + if (pendingFirstG || key.length >= 2) { + // Second g (or a multi-g chunk) — jump to the first non-section row cursor = 0; while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++; scrollOffset = 0; @@ -469,6 +472,11 @@ export async function runInteractive( // `G` — jump to last row (bottom) if (key === "G") { + if (rows.length === 0) { + // No rows to jump to; avoid putting cursor into an invalid state + pendingFirstG = false; + continue; + } cursor = rows.length - 1; while (cursor > 0 && rows[cursor]?.type === "section") cursor--; while ( @@ -484,6 +492,11 @@ export async function runInteractive( const pageSize = Math.max(1, getViewportHeight()); cursor = Math.max(0, cursor - pageSize); while (cursor > 0 && rows[cursor]?.type === "section") cursor--; + // If we've paged up to the top and the first row is a section, + // advance to the first non-section row (mirror `gg` behavior). + if (cursor === 0 && rows[0]?.type === "section") { + while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++; + } if (cursor < scrollOffset) scrollOffset = cursor; }