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:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
Reference in New Issue
Block a user