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..b75f78e 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,12 @@ 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 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) { if (key === KEY_CTRL_C) { @@ -441,6 +453,66 @@ export async function runInteractive( } } + // `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; + pendingFirstG = false; + } else { + pendingFirstG = true; + } + redraw(); + continue; + } + + // `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 ( + 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 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; + } + + // 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];