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).
This commit is contained in:
Hunter Bown
2026-05-11 16:43:45 -05:00
parent 7454b23ae6
commit c13ddb04d4
2 changed files with 32 additions and 6 deletions
+7
View File
@@ -878,6 +878,12 @@ pub struct App {
/// Esc primes, second Esc opens the live-transcript overlay scoped to /// Esc primes, second Esc opens the live-transcript overlay scoped to
/// previous user messages so the user can rewind a turn. /// previous user messages so the user can rewind a turn.
pub backtrack: crate::tui::backtrack::BacktrackState, 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 /// Current session ID for auto-save updates
pub current_session_id: Option<String>, pub current_session_id: Option<String>,
/// Metadata-only registry of large tool outputs produced in this session. /// Metadata-only registry of large tool outputs produced in this session.
@@ -1465,6 +1471,7 @@ impl App {
}, },
view_stack: ViewStack::new(), view_stack: ViewStack::new(),
backtrack: crate::tui::backtrack::BacktrackState::new(), backtrack: crate::tui::backtrack::BacktrackState::new(),
transcript_pending_g: false,
current_session_id: None, current_session_id: None,
session_artifacts: Vec::new(), session_artifacts: Vec::new(),
trust_mode: initial_mode == AppMode::Yolo, trust_mode: initial_mode == AppMode::Yolo,
+25 -6
View File
@@ -2391,7 +2391,9 @@ async fn run_event_loop(
app.mention_menu_hidden = true; app.mention_menu_hidden = true;
app.mention_menu_selected = 0; 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 => { EscapeAction::CloseSlashMenu => {
// A popup-style action wins over backtrack — clear // A popup-style action wins over backtrack — clear
// any prime so a stale Primed state can't jump us // 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) => { KeyCode::Up if key.modifiers.contains(KeyModifiers::SUPER) => {
app.scroll_up(app.viewport.last_transcript_visible.max(3)); app.scroll_up(app.viewport.last_transcript_visible.max(3));
} }
@@ -2572,10 +2575,20 @@ async fn run_event_loop(
KeyCode::Char('g') KeyCode::Char('g')
if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open => if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open =>
{ {
if let Some(anchor) = // Vim-style 'gg' — double-tap 'g' to jump to top.
TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0) // First 'g' arms the pending flag; second executes the scroll.
{ // This prevents a single 'g' (the first letter of a message)
app.viewport.transcript_scroll = anchor; // 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') KeyCode::Char('G')
@@ -2675,6 +2688,7 @@ async fn run_event_loop(
} }
} }
KeyCode::Enter => { KeyCode::Enter => {
app.transcript_pending_g = false;
// #573: when the user typed a slash-command prefix that // #573: when the user typed a slash-command prefix that
// the popup is matching (e.g. `/mo` → `/model`), Enter // the popup is matching (e.g. `/mo` → `/model`), Enter
// should run the *highlighted match* rather than // should run the *highlighted match* rather than
@@ -2986,6 +3000,11 @@ async fn run_event_loop(
// absorb — Visual mode not yet fully implemented // absorb — Visual mode not yet fully implemented
} }
KeyCode::Char(c) => { 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); app.insert_char(c);
} }
_ => {} _ => {}