diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 5ec122d5c7..2491da39e5 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -8,6 +8,7 @@ - The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits. ### Fixed +- Copy-mode arrow, Page Up/Down, Home, and End keys now repeat when held in terminals that report explicit key-repeat events. - Configs containing the retired Herdr-written `ui.agent_panel_scope` setting no longer report it as an unknown key after upgrades. (#2292) - Claude Code confirmation prompts using `Enter to confirm ยท Esc to cancel` now report `blocked` instead of `idle`. (#2268) - Sidebar agent lists keep scrolling when differently sized clients are attached to the same session. (#2255, thanks @aiworkflowpro) diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index b744d2ce6e..512c3a77bc 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -27,6 +27,10 @@ impl App { } impl AppState { + pub(crate) fn allows_copy_navigation_repeat(&self, key: &TerminalKey) -> bool { + self.mode == Mode::Copy && !self.is_prefix_key(key) && is_repeatable_navigation_key(key) + } + pub(crate) fn enter_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { let Some(ws_idx) = self.active else { return; @@ -955,6 +959,20 @@ fn copy_mode_page_lines(height: u16, half_page: bool) -> usize { } } +fn is_repeatable_navigation_key(key: &TerminalKey) -> bool { + matches!( + key.code, + KeyCode::Left + | KeyCode::Down + | KeyCode::Up + | KeyCode::Right + | KeyCode::PageUp + | KeyCode::PageDown + | KeyCode::Home + | KeyCode::End + ) +} + fn copy_mode_command_char(key: TerminalKey) -> Option { if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { return None; @@ -1166,6 +1184,20 @@ mod tests { ); } + #[tokio::test] + async fn copy_mode_repeats_ghostty_enhanced_arrow_navigation() { + let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + app.state.copy_mode.as_mut().expect("copy mode").cursor_col = 3; + + app.route_client_input(b"\x1b[1;1:1D\x1b[1;1:2D\x1b[1;1:2D\x1b[1;1:3D".to_vec()); + + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + 0 + ); + } + #[tokio::test] async fn copy_mode_ctrl_b_uses_page_up() { let bytes = numbered_lines_bytes(64); diff --git a/src/app/input/lease.rs b/src/app/input/lease.rs index de118334d4..9375d5512e 100644 --- a/src/app/input/lease.rs +++ b/src/app/input/lease.rs @@ -27,6 +27,7 @@ pub(crate) struct ForwardedInputLease { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ConsumedInputLease { ReprocessRepeats(TerminalInputContext), + RepeatCopyNavigation, SuppressRepeats, } @@ -129,7 +130,9 @@ impl InputLeaseTable { self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); return RepeatPlan::Ignore; } - Some(InputLease::Consumed(ConsumedInputLease::SuppressRepeats)) => { + Some(InputLease::Consumed( + ConsumedInputLease::RepeatCopyNavigation | ConsumedInputLease::SuppressRepeats, + )) => { return RepeatPlan::Ignore; } None => {} @@ -170,6 +173,15 @@ impl InputLeaseTable { self.leases.contains_key(key) } + pub(crate) fn repeats_copy_navigation(&self, key: &InputLeaseKey) -> bool { + matches!( + self.leases.get(key), + Some(InputLease::Consumed( + ConsumedInputLease::RepeatCopyNavigation + )) + ) + } + pub(crate) fn insert_forwarded( &mut self, key: InputLeaseKey, diff --git a/src/app/mod.rs b/src/app/mod.rs index 5e3c86de0f..399782d2ec 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1683,6 +1683,8 @@ impl App { let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { + let repeat_copy_navigation = + self.state.allows_copy_navigation_repeat(&key); let initial_context = self.terminal_input_context(); let target = if initial_context.is_some() { self.handle_terminal_key_headless_from(source_id, key.clone()) @@ -1698,8 +1700,27 @@ impl App { resulting_context.as_ref(), target, ); + if repeat_copy_navigation && self.state.mode == Mode::Copy { + self.input_leases.insert_consumed( + lease_key, + input::ConsumedInputLease::RepeatCopyNavigation, + ); + } self.execute_repeat_plan_headless(source_id, lease_key, key, plan); } + crossterm::event::KeyEventKind::Repeat + if self.state.allows_copy_navigation_repeat(&key) + && self.input_leases.repeats_copy_navigation(&lease_key) => + { + let repetitions = key.repeat_count; + let key = key.with_repeat_count(1); + for _ in 0..repetitions { + if self.state.mode != Mode::Copy { + break; + } + self.handle_non_terminal_key_headless(key.clone()); + } + } crossterm::event::KeyEventKind::Repeat => { let current_context = self.terminal_input_context(); let plan = self.input_leases.plan_repeat( diff --git a/src/app/runtime.rs b/src/app/runtime.rs index e05b95998e..7ccedad2dd 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -181,6 +181,7 @@ impl App { let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { + let repeat_copy_navigation = self.state.allows_copy_navigation_repeat(&key); let initial_context = self.terminal_input_context(); let target = self.handle_key(key.clone()).await; let resulting_context = self.terminal_input_context(); @@ -191,9 +192,29 @@ impl App { resulting_context.as_ref(), target, ); + if repeat_copy_navigation && self.state.mode == crate::app::Mode::Copy { + self.input_leases.insert_consumed( + lease_key, + super::input::ConsumedInputLease::RepeatCopyNavigation, + ); + } self.execute_repeat_plan(lease_key, key, plan).await; true } + crossterm::event::KeyEventKind::Repeat + if self.state.allows_copy_navigation_repeat(&key) + && self.input_leases.repeats_copy_navigation(&lease_key) => + { + let repetitions = key.repeat_count; + let key = key.with_repeat_count(1); + for _ in 0..repetitions { + if self.state.mode != crate::app::Mode::Copy { + break; + } + self.handle_key(key.clone()).await; + } + true + } crossterm::event::KeyEventKind::Repeat => { let current_context = self.terminal_input_context(); let plan = self.input_leases.plan_repeat(