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
18 changes: 11 additions & 7 deletions docs/reference/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
22 changes: 21 additions & 1 deletion src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand Down
4 changes: 3 additions & 1 deletion src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)")}`,
Expand Down Expand Up @@ -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",
),
);

Expand Down
72 changes: 72 additions & 0 deletions src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────

Expand Down Expand Up @@ -141,6 +145,8 @@ export async function runInteractive(
let filterLiveStats: FilterStats | null = null;
let statsDebounceTimer: ReturnType<typeof setTimeout> | 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 = () => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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];
Expand Down