From c13ddb04d456b706f612e8f971ed5fd3f910fb5a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 11 May 2026 16:43:45 -0500 Subject: [PATCH] fix(tui): change transcript 'g' scroll to vim-style 'gg' double-tap A single bare 'g' with an empty composer was hijacked as a scroll-to-top command, preventing users from typing 'g' as the first character of a message. The transcript would jump to line 0 instead of inserting 'g' into the composer. Change to a vim-style 'gg' double-tap: first 'g' arms transcript_pending_g, second 'g' executes the scroll. Any other character input, Enter, or Escape resets the pending flag so a stray 'g' during composition arms without scrolling. Also adds transcript_pending_g field to App struct (default false). --- crates/tui/src/tui/app.rs | 7 +++++++ crates/tui/src/tui/ui.rs | 31 +++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 7606d033..0187d80e 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -878,6 +878,12 @@ pub struct App { /// Esc primes, second Esc opens the live-transcript overlay scoped to /// previous user messages so the user can rewind a turn. pub backtrack: crate::tui::backtrack::BacktrackState, + /// Pending 'gg' second keystroke for vim-style jump-to-top of the + /// transcript. Set by the first bare 'g' with an empty composer and + /// consumed on the second 'g' to scroll to line 0. Any other + /// navigation key resets it, so typing 'g' as the first character + /// of a message only arms this flag without scrolling. + pub transcript_pending_g: bool, /// Current session ID for auto-save updates pub current_session_id: Option, /// Metadata-only registry of large tool outputs produced in this session. @@ -1465,6 +1471,7 @@ impl App { }, view_stack: ViewStack::new(), backtrack: crate::tui::backtrack::BacktrackState::new(), + transcript_pending_g: false, current_session_id: None, session_artifacts: Vec::new(), trust_mode: initial_mode == AppMode::Yolo, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c1e72dac..5b6cf878 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2391,7 +2391,9 @@ async fn run_event_loop( app.mention_menu_hidden = true; app.mention_menu_selected = 0; } - KeyCode::Esc => match next_escape_action(app, slash_menu_open) { + KeyCode::Esc => { + app.transcript_pending_g = false; + match next_escape_action(app, slash_menu_open) { EscapeAction::CloseSlashMenu => { // A popup-style action wins over backtrack — clear // any prime so a stale Primed state can't jump us @@ -2460,7 +2462,8 @@ async fn run_event_loop( } } } - }, + } + }, KeyCode::Up if key.modifiers.contains(KeyModifiers::SUPER) => { app.scroll_up(app.viewport.last_transcript_visible.max(3)); } @@ -2572,10 +2575,20 @@ async fn run_event_loop( KeyCode::Char('g') if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open => { - if let Some(anchor) = - TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0) - { - app.viewport.transcript_scroll = anchor; + // Vim-style 'gg' — double-tap 'g' to jump to top. + // First 'g' arms the pending flag; second executes the scroll. + // This prevents a single 'g' (the first letter of a message) + // from hijacking the transcript scroll. + if app.transcript_pending_g { + if let Some(anchor) = TranscriptScroll::anchor_for( + app.viewport.transcript_cache.line_meta(), + 0, + ) { + app.viewport.transcript_scroll = anchor; + } + app.transcript_pending_g = false; + } else { + app.transcript_pending_g = true; } } KeyCode::Char('G') @@ -2675,6 +2688,7 @@ async fn run_event_loop( } } KeyCode::Enter => { + app.transcript_pending_g = false; // #573: when the user typed a slash-command prefix that // the popup is matching (e.g. `/mo` → `/model`), Enter // should run the *highlighted match* rather than @@ -2986,6 +3000,11 @@ async fn run_event_loop( // absorb — Visual mode not yet fully implemented } KeyCode::Char(c) => { + // Any typed character after a pending 'g' means the user + // is composing a message, not navigating — disarm the + // 'gg' double-tap so the next bare 'g' on an empty + // composer starts a new sequence. + app.transcript_pending_g = false; app.insert_char(c); } _ => {}