@@ -38,6 +38,10 @@ const KEY_ALT_B = "\x1bb";
3838const KEY_ALT_F = "\x1bf" ;
3939const KEY_DELETE = "\x1b[3~" ;
4040const KEY_SHIFT_TAB = "\x1b[Z" ; // Shift+Tab — cycle filter target in filter mode
41+ const KEY_PAGE_UP = "\x1b[5~" ; // Page Up — scroll up one page
42+ const KEY_PAGE_DOWN = "\x1b[6~" ; // Page Down — scroll down one page
43+ const KEY_CTRL_U = "\x15" ; // Ctrl+U — page up (Vim-style)
44+ const KEY_CTRL_D = "\x04" ; // Ctrl+D — page down (Vim-style)
4145
4246// ─── Word-boundary helpers ────────────────────────────────────────────────────
4347
@@ -141,6 +145,8 @@ export async function runInteractive(
141145 let filterLiveStats : FilterStats | null = null ;
142146 let statsDebounceTimer : ReturnType < typeof setTimeout > | null = null ;
143147 let showHelp = false ;
148+ // Track first 'g' keypress so that a second consecutive 'g' jumps to the top.
149+ let pendingFirstG = false ;
144150
145151 /** Schedule a debounced stats recompute (while typing in filter bar). */
146152 const scheduleStatsUpdate = ( ) => {
@@ -176,6 +182,12 @@ export async function runInteractive(
176182 for await ( const chunk of process . stdin ) {
177183 const key = chunk . toString ( ) ;
178184
185+ // Reset the gg pending state on every key that isn't a sequence of one
186+ // or more plain "g" characters. This allows terminals that batch key
187+ // repeats (e.g. delivering "gg" in a single chunk) to still participate
188+ // in the gg shortcut without interfering with any other shortcut.
189+ if ( ! / ^ g + $ / . test ( key ) ) pendingFirstG = false ;
190+
179191 // ── Filter input mode ────────────────────────────────────────────────────
180192 if ( filterMode ) {
181193 if ( key === KEY_CTRL_C ) {
@@ -441,6 +453,66 @@ export async function runInteractive(
441453 }
442454 }
443455
456+ // `gg` — jump to top (first non-section row).
457+ // Handles both two consecutive single-g chunks and a single "gg" chunk
458+ // (terminals that batch repeated keypresses into one read() call).
459+ if ( / ^ g + $ / . test ( key ) ) {
460+ if ( pendingFirstG || key . length >= 2 ) {
461+ // Second g (or a multi-g chunk) — jump to the first non-section row
462+ cursor = 0 ;
463+ while ( cursor < rows . length - 1 && rows [ cursor ] ?. type === "section" ) cursor ++ ;
464+ scrollOffset = 0 ;
465+ pendingFirstG = false ;
466+ } else {
467+ pendingFirstG = true ;
468+ }
469+ redraw ( ) ;
470+ continue ;
471+ }
472+
473+ // `G` — jump to last row (bottom)
474+ if ( key === "G" ) {
475+ if ( rows . length === 0 ) {
476+ // No rows to jump to; avoid putting cursor into an invalid state
477+ pendingFirstG = false ;
478+ continue ;
479+ }
480+ cursor = rows . length - 1 ;
481+ while ( cursor > 0 && rows [ cursor ] ?. type === "section" ) cursor -- ;
482+ while (
483+ scrollOffset < cursor &&
484+ ! isCursorVisible ( rows , groups , cursor , scrollOffset , getViewportHeight ( ) )
485+ ) {
486+ scrollOffset ++ ;
487+ }
488+ }
489+
490+ // Page Up / Ctrl+U — scroll up by a full page
491+ if ( key === KEY_PAGE_UP || key === KEY_CTRL_U ) {
492+ const pageSize = Math . max ( 1 , getViewportHeight ( ) ) ;
493+ cursor = Math . max ( 0 , cursor - pageSize ) ;
494+ while ( cursor > 0 && rows [ cursor ] ?. type === "section" ) cursor -- ;
495+ // If we've paged up to the top and the first row is a section,
496+ // advance to the first non-section row (mirror `gg` behavior).
497+ if ( cursor === 0 && rows [ 0 ] ?. type === "section" ) {
498+ while ( cursor < rows . length - 1 && rows [ cursor ] ?. type === "section" ) cursor ++ ;
499+ }
500+ if ( cursor < scrollOffset ) scrollOffset = cursor ;
501+ }
502+
503+ // Page Down / Ctrl+D — scroll down by a full page
504+ if ( key === KEY_PAGE_DOWN || key === KEY_CTRL_D ) {
505+ const pageSize = Math . max ( 1 , getViewportHeight ( ) ) ;
506+ cursor = Math . min ( rows . length - 1 , cursor + pageSize ) ;
507+ while ( cursor < rows . length - 1 && rows [ cursor ] ?. type === "section" ) cursor ++ ;
508+ while (
509+ scrollOffset < cursor &&
510+ ! isCursorVisible ( rows , groups , cursor , scrollOffset , getViewportHeight ( ) )
511+ ) {
512+ scrollOffset ++ ;
513+ }
514+ }
515+
444516 if ( key === " " && row && row . type !== "section" ) {
445517 if ( row . type === "repo" ) {
446518 const group = groups [ row . repoIndex ] ;
0 commit comments