From e8b10e40a7e95df5122fbdd8d19ed516d36b2ae4 Mon Sep 17 00:00:00 2001 From: shibayu36 Date: Sun, 30 Aug 2026 20:09:10 +0900 Subject: [PATCH 1/3] refactor: rename TerminalInputContext to InputContext Prepare the input-context enum for a non-terminal variant: it is about to model the input context of a key press, terminal-bound or not, so the "terminal input" name would no longer describe it. TerminalInputTarget keeps its name because it is genuinely terminal-only. refs #2371 --- src/app/input/lease.rs | 28 ++++++++++++++-------------- src/app/mod.rs | 16 ++++++++-------- src/app/runtime.rs | 8 ++++---- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/app/input/lease.rs b/src/app/input/lease.rs index 6724b80429..53c4d054c4 100644 --- a/src/app/input/lease.rs +++ b/src/app/input/lease.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::app::{InputSourceId, TerminalInputContext, TerminalInputTarget}; +use crate::app::{InputContext, InputSourceId, TerminalInputTarget}; use crate::input::{KeyIdentity, TerminalKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -26,7 +26,7 @@ pub(crate) struct ForwardedInputLease { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ConsumedInputLease { - ReprocessRepeats(TerminalInputContext), + ReprocessRepeats(InputContext), SuppressRepeats, } @@ -39,7 +39,7 @@ pub(crate) enum InputLease { pub(crate) enum RepeatPlan { Forwarded(TerminalInputTarget), Reprocess { - context: TerminalInputContext, + context: InputContext, repetitions: u16, tracked: bool, }, @@ -76,8 +76,8 @@ impl InputLeaseTable { &mut self, lease_key: InputLeaseKey, key: &TerminalKey, - initial_context: Option<&TerminalInputContext>, - resulting_context: Option<&TerminalInputContext>, + initial_context: Option<&InputContext>, + resulting_context: Option<&InputContext>, target: Option, ) -> RepeatPlan { if key.generated_text.is_some() && !key.has_physical_identity() { @@ -114,7 +114,7 @@ impl InputLeaseTable { &mut self, lease_key: InputLeaseKey, key: &TerminalKey, - current_context: Option<&TerminalInputContext>, + current_context: Option<&InputContext>, ) -> RepeatPlan { match self.leases.get(&lease_key) { Some(InputLease::Forwarded(lease)) => { @@ -151,8 +151,8 @@ impl InputLeaseTable { pub(crate) fn reprocess_allowed( &mut self, lease_key: InputLeaseKey, - expected_context: &TerminalInputContext, - current_context: Option<&TerminalInputContext>, + expected_context: &InputContext, + current_context: Option<&InputContext>, tracked: bool, ) -> bool { let allowed = current_context == Some(expected_context); @@ -353,7 +353,7 @@ mod tests { fn physical_generated_text_keeps_native_repeat_lifecycle() { let key = physical_generated_slash(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; + let context = InputContext::Pane; let forwarded_target = target(); let mut leases = InputLeaseTable::default(); @@ -387,13 +387,13 @@ mod tests { fn consumed_grouped_physical_generated_text_reprocesses_repeats() { let key = physical_generated_slash(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; + let context = InputContext::Pane; let mut leases = InputLeaseTable::default(); assert!(matches!( leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), RepeatPlan::Reprocess { - context: TerminalInputContext::Pane, + context: InputContext::Pane, repetitions: 2, tracked: true, } @@ -406,7 +406,7 @@ mod tests { .with_generated_text(Some("/".to_owned())) .with_repeat_count(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; + let context = InputContext::Pane; let mut leases = InputLeaseTable::default(); assert!(matches!( @@ -426,7 +426,7 @@ mod tests { fn new_semantic_press_recomputes_consumed_repeat_disposition() { let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_repeat_count(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; + let context = InputContext::Pane; let mut leases = InputLeaseTable::default(); leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); @@ -436,7 +436,7 @@ mod tests { assert!(matches!( plan, RepeatPlan::Reprocess { - context: TerminalInputContext::Pane, + context: InputContext::Pane, repetitions: 2, tracked: true, } diff --git a/src/app/mod.rs b/src/app/mod.rs index c28f62da1e..6f6bad52d3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -220,7 +220,7 @@ pub(crate) struct TerminalInputTarget { } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum TerminalInputContext { +pub(crate) enum InputContext { Pane, Popup(crate::terminal::TerminalId), } @@ -1666,11 +1666,11 @@ impl App { // --------------------------------------------------------------------------- impl App { - pub(crate) fn terminal_input_context(&self) -> Option { + pub(crate) fn input_context(&self) -> Option { if let Some(popup) = &self.state.popup_pane { - Some(TerminalInputContext::Popup(popup.terminal_id.clone())) + Some(InputContext::Popup(popup.terminal_id.clone())) } else if self.state.mode == Mode::Terminal { - Some(TerminalInputContext::Pane) + Some(InputContext::Pane) } else { None } @@ -1706,7 +1706,7 @@ impl App { } continue; } - let current_context = self.terminal_input_context(); + let current_context = self.input_context(); if !self.input_leases.reprocess_allowed( lease_key, &context, @@ -1793,14 +1793,14 @@ impl App { let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { - let initial_context = self.terminal_input_context(); + let initial_context = self.input_context(); let target = if initial_context.is_some() { self.handle_terminal_key_headless_from(source_id, key.clone()) } else { self.handle_non_terminal_key_headless(key.clone()); None }; - let resulting_context = self.terminal_input_context(); + let resulting_context = self.input_context(); let plan = self.input_leases.complete_press( lease_key, &key, @@ -1811,7 +1811,7 @@ impl App { self.execute_repeat_plan_headless(source_id, lease_key, key, plan); } crossterm::event::KeyEventKind::Repeat => { - let current_context = self.terminal_input_context(); + let current_context = self.input_context(); let plan = self.input_leases.plan_repeat( lease_key, &key, diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 8367a22350..17dba9ddf7 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -152,7 +152,7 @@ impl App { } continue; } - let current_context = self.terminal_input_context(); + let current_context = self.input_context(); if !self.input_leases.reprocess_allowed( lease_key, &context, @@ -189,9 +189,9 @@ impl App { let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { - let initial_context = self.terminal_input_context(); + let initial_context = self.input_context(); let target = self.handle_key(key.clone()).await; - let resulting_context = self.terminal_input_context(); + let resulting_context = self.input_context(); let plan = self.input_leases.complete_press( lease_key, &key, @@ -203,7 +203,7 @@ impl App { true } crossterm::event::KeyEventKind::Repeat => { - let current_context = self.terminal_input_context(); + let current_context = self.input_context(); let plan = self.input_leases.plan_repeat( lease_key, &key, From e9f245e7975e16a5d7059634eca8c155c2790503 Mon Sep 17 00:00:00 2001 From: shibayu36 Date: Sun, 30 Aug 2026 20:10:06 +0900 Subject: [PATCH 2/3] fix: restore key auto-repeat in copy mode Track copy mode as a non-terminal input context so held semantic keys (arrows, etc.) pass the press/repeat context comparison instead of being suppressed, and route reprocessed repeats through the non-terminal key handler in the headless pipeline. refs #2371 --- src/app/mod.rs | 183 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 1 deletion(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 6f6bad52d3..bb500ccf38 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -223,6 +223,12 @@ pub(crate) struct TerminalInputTarget { pub(crate) enum InputContext { Pane, Popup(crate::terminal::TerminalId), + NonTerminal(NonTerminalInputContext), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum NonTerminalInputContext { + Copy, } pub(crate) type InputSourceId = u64; @@ -1671,6 +1677,8 @@ impl App { Some(InputContext::Popup(popup.terminal_id.clone())) } else if self.state.mode == Mode::Terminal { Some(InputContext::Pane) + } else if self.state.mode == Mode::Copy { + Some(InputContext::NonTerminal(NonTerminalInputContext::Copy)) } else { None } @@ -1715,6 +1723,10 @@ impl App { ) { break; } + if matches!(context, InputContext::NonTerminal(_)) { + self.handle_non_terminal_key_headless(key.clone()); + continue; + } if let Some(target) = self.handle_terminal_key_headless_from(source_id, key.clone()) { @@ -1794,7 +1806,10 @@ impl App { match key.kind { crossterm::event::KeyEventKind::Press => { let initial_context = self.input_context(); - let target = if initial_context.is_some() { + let target = if matches!( + initial_context, + Some(InputContext::Pane | InputContext::Popup(_)) + ) { self.handle_terminal_key_headless_from(source_id, key.clone()) } else { self.handle_non_terminal_key_headless(key.clone()); @@ -2093,6 +2108,89 @@ mod tests { assert!(app.input_leases.is_empty()); } + #[tokio::test] + async fn copy_mode_press_moves_cursor_and_forwards_nothing() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.route_client_events( + vec![raw_key( + KeyCode::Down, + KeyModifiers::empty(), + KeyEventKind::Press, + )], + false, + ); + + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 1 + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn copy_mode_repeat_after_press_moves_cursor_again() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.route_client_events( + vec![ + raw_key(KeyCode::Down, KeyModifiers::empty(), KeyEventKind::Press), + raw_key(KeyCode::Down, KeyModifiers::empty(), KeyEventKind::Repeat), + ], + false, + ); + + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 2, + "repeat should move the copy-mode cursor a second time" + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn copy_mode_repeat_with_repeat_count_moves_cursor_by_count() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.route_client_events( + vec![ + raw_key(KeyCode::Down, KeyModifiers::empty(), KeyEventKind::Press), + crate::raw_input::RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Down, KeyModifiers::empty()) + .with_kind(KeyEventKind::Repeat) + .with_repeat_count(2), + ), + ], + false, + ); + + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 3, + "a single repeat_count=2 event should move the cursor two more times" + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn copy_mode_esc_repeat_does_not_leak_after_exit() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.route_client_events( + vec![ + raw_key(KeyCode::Esc, KeyModifiers::empty(), KeyEventKind::Press), + raw_key(KeyCode::Esc, KeyModifiers::empty(), KeyEventKind::Repeat), + ], + false, + ); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_none()); + assert!(rx.try_recv().is_err()); + } + fn release_notes_state() -> state::ReleaseNotesState { state::ReleaseNotesState { version: "0.1.0".into(), @@ -2113,6 +2211,41 @@ mod tests { ) } + /// Puts a fresh app into copy mode with a channel-backed pane runtime + /// showing several lines of text, and pins the copy-mode cursor to the + /// top row so vertical cursor movement is unambiguous. + fn app_with_copy_mode() -> ( + App, + crate::layout::PaneId, + tokio::sync::mpsc::Receiver, + ) { + let mut app = test_app(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 20, 5)); + let info = pane_infos[0].clone(); + let (runtime, rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 0, + b"line-0\r\nline-1\r\nline-2\r\nline-3\r\nline-4\r\n", + 8, + ); + ws.tabs[0].runtimes.insert(pane_id, runtime); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.view.pane_infos = pane_infos; + app.state.enter_copy_mode(&app.terminal_runtimes); + app.state + .copy_mode + .as_mut() + .expect("copy mode entered") + .cursor_row = 0; + (app, pane_id, rx) + } + fn unique_temp_path(name: &str) -> std::path::PathBuf { let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -4051,6 +4184,54 @@ mod tests { assert!(next_press_handled); } + #[tokio::test] + async fn monolithic_copy_mode_repeat_after_press_moves_cursor_again() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.handle_raw_input_event(raw_key( + KeyCode::Down, + KeyModifiers::empty(), + KeyEventKind::Press, + )) + .await; + app.handle_raw_input_event(raw_key( + KeyCode::Down, + KeyModifiers::empty(), + KeyEventKind::Repeat, + )) + .await; + + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 2, + "repeat should move the copy-mode cursor a second time" + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn monolithic_copy_mode_esc_repeat_does_not_leak_after_exit() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.handle_raw_input_event(raw_key( + KeyCode::Esc, + KeyModifiers::empty(), + KeyEventKind::Press, + )) + .await; + app.handle_raw_input_event(raw_key( + KeyCode::Esc, + KeyModifiers::empty(), + KeyEventKind::Repeat, + )) + .await; + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_none()); + assert!(rx.try_recv().is_err()); + } + #[test] fn read_only_api_requests_do_not_force_rerender() { let read_only = crate::api::schema::Request { From bf83c437f88f954c6ca29f604c221105dde98516 Mon Sep 17 00:00:00 2001 From: shibayu36 Date: Sun, 30 Aug 2026 20:23:20 +0900 Subject: [PATCH 3/3] fix: extend key auto-repeat to navigator, navigate, and keybind help modes Map Mode::Navigator, Mode::Navigate, and Mode::KeybindHelp to non-terminal input contexts so held movement keys repeat in the quick jump modal, the workspaces sidebar, and the keybind help screen. All three were reported in the issue comments. Their confirm and exit keys change modes, so those repeats stay suppressed by the context comparison without extra handling. refs #2371 --- src/app/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index bb500ccf38..36664a98c0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -229,6 +229,9 @@ pub(crate) enum InputContext { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum NonTerminalInputContext { Copy, + Navigator, + Navigate, + KeybindHelp, } pub(crate) type InputSourceId = u64; @@ -1679,6 +1682,16 @@ impl App { Some(InputContext::Pane) } else if self.state.mode == Mode::Copy { Some(InputContext::NonTerminal(NonTerminalInputContext::Copy)) + } else if self.state.mode == Mode::Navigator { + Some(InputContext::NonTerminal( + NonTerminalInputContext::Navigator, + )) + } else if self.state.mode == Mode::Navigate { + Some(InputContext::NonTerminal(NonTerminalInputContext::Navigate)) + } else if self.state.mode == Mode::KeybindHelp { + Some(InputContext::NonTerminal( + NonTerminalInputContext::KeybindHelp, + )) } else { None }