From 019330c2199f1ef43f71adb5dd0f234a35da302b Mon Sep 17 00:00:00 2001 From: lbcheng Date: Sat, 9 May 2026 03:21:17 +0800 Subject: [PATCH] fix(tui): add composer_arrows_scroll config option for trackpad terminals Instead of unconditionally changing Up/Down behavior, gate the empty-composer-scroll path behind a new `tui.composer_arrows_scroll` config option (default false). Users whose terminals map trackpad gestures to arrow keys can opt in via: [tui] composer_arrows_scroll = true When enabled, empty-composer Up/Down scroll the transcript; otherwise plain arrows always navigate input history (preserving #1117 default). Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/config.rs | 6 +++ crates/tui/src/main.rs | 4 ++ crates/tui/src/tui/app.rs | 8 ++++ crates/tui/src/tui/ui.rs | 14 +++---- crates/tui/src/tui/ui/tests.rs | 72 ++++++++++++++++++++++++---------- 5 files changed, 77 insertions(+), 27 deletions(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 8d3debe6..4e8cd334 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -388,6 +388,12 @@ pub struct TuiConfig { /// - `Never` — suppress all turn-completion notifications. /// - Unset (default) — fall back to the `[notifications]` defaults. pub notification_condition: Option, + /// When `true`, plain Up/Down on an empty composer scroll the + /// transcript instead of recalling input history. Useful for + /// terminals that map trackpad gestures to arrow keys. Default: + /// `false` (plain arrows always navigate input history, #1117). + #[serde(default)] + pub composer_arrows_scroll: Option, } /// High-level notification trigger override. See diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 1557a25a..162a5523 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4633,6 +4633,7 @@ mod terminal_mode_tests { terminal_probe_timeout_ms: None, status_items: None, osc8_links: None, + composer_arrows_scroll: None, notification_condition: None, }), ..Config::default() @@ -4677,6 +4678,7 @@ mod terminal_mode_tests { terminal_probe_timeout_ms: None, status_items: None, osc8_links: None, + composer_arrows_scroll: None, notification_condition: None, }), ..Config::default() @@ -4703,6 +4705,7 @@ mod terminal_mode_tests { terminal_probe_timeout_ms: None, status_items: None, osc8_links: None, + composer_arrows_scroll: None, notification_condition: None, }), ..Config::default() @@ -4777,6 +4780,7 @@ mod terminal_mode_tests { terminal_probe_timeout_ms: None, status_items: None, osc8_links: None, + composer_arrows_scroll: None, notification_condition: None, }), ..Config::default() diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 5332623b..e554743e 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -714,6 +714,9 @@ pub struct App { pub use_memory: bool, pub use_alt_screen: bool, pub use_mouse_capture: bool, + /// When true, plain Up/Down on an empty composer scroll the transcript + /// instead of navigating input history (#1117 opt-in). + pub composer_arrows_scroll: bool, pub use_bracketed_paste: bool, pub use_paste_burst_detection: bool, #[allow(dead_code)] @@ -1429,6 +1432,11 @@ impl App { collapsed_cell_map: Vec::new(), edit_in_progress: false, lsp_enabled: config.lsp.as_ref().and_then(|l| l.enabled).unwrap_or(true), + composer_arrows_scroll: config + .tui + .as_ref() + .and_then(|tui| tui.composer_arrows_scroll) + .unwrap_or(false), } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e35e8540..f0841745 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3513,15 +3513,15 @@ fn handle_composer_history_arrow( return false; } - // When the composer is empty, plain Up/Down scroll the transcript so - // terminals that map trackpad gestures to arrow keys can still scroll - // the history area. When the composer has text, they navigate input - // history so the user can recall previous prompts. - let composer_empty = app.input.trim().is_empty(); + // When `composer_arrows_scroll` is enabled and the composer is empty, + // plain Up/Down scroll the transcript. This helps terminals that map + // trackpad gestures to arrow keys. Otherwise arrows always navigate + // input history regardless of composer state (#1117). + let scroll_on_empty = app.composer_arrows_scroll && app.input.trim().is_empty(); match key.code { KeyCode::Up => { - if composer_empty { + if scroll_on_empty { app.scroll_up(1); } else { app.history_up(); @@ -3529,7 +3529,7 @@ fn handle_composer_history_arrow( true } KeyCode::Down => { - if composer_empty { + if scroll_on_empty { app.scroll_down(1); } else { app.history_down(); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 6c8aa5f7..e436fb08 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -925,6 +925,7 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() { status_items: None, osc8_links: None, notification_condition: None, + composer_arrows_scroll: None, }), ..Config::default() }; @@ -3925,28 +3926,14 @@ fn history_arrow_handles_empty_input() { let mut app = create_test_app(); app.input_history.push("previous prompt".to_string()); - // Empty composer: Up scrolls the transcript, not navigates history. + // Default: empty composer Up navigates input history (#1117). assert!(handle_composer_history_arrow( &mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), false, false, )); - assert_eq!(app.viewport.pending_scroll_delta, -1, "empty composer Up should scroll up"); - assert!(app.input.is_empty(), "input should stay empty, not load history"); -} - -#[test] -fn history_arrow_handles_empty_input_down() { - let mut app = create_test_app(); - // Empty composer: Down scrolls the transcript. - assert!(handle_composer_history_arrow( - &mut app, - KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), - false, - false, - )); - assert_eq!(app.viewport.pending_scroll_delta, 1, "empty composer Down should scroll down"); + assert_eq!(app.input, "previous prompt"); } #[test] @@ -3956,15 +3943,13 @@ fn history_arrow_handles_whitespace_input() { app.cursor_position = app.input.chars().count(); app.input_history.push("previous prompt".to_string()); - // Whitespace-only composer: Up scrolls transcript. assert!(handle_composer_history_arrow( &mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), false, false, )); - assert_eq!(app.viewport.pending_scroll_delta, -1); - assert_eq!(app.input, " ", "whitespace-only input should stay unchanged"); + assert_eq!(app.input, "previous prompt"); } #[test] @@ -3974,7 +3959,6 @@ fn history_arrow_handles_nonempty_input() { app.cursor_position = app.input.chars().count(); app.input_history.push("previous prompt".to_string()); - // Non-empty composer: Up navigates input history. assert!(handle_composer_history_arrow( &mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), @@ -3985,6 +3969,54 @@ fn history_arrow_handles_nonempty_input() { assert_eq!(app.input, "previous prompt"); } +#[test] +fn composer_arrows_scroll_empty_up() { + let mut app = create_test_app(); + app.composer_arrows_scroll = true; + + // Opt-in: empty composer Up scrolls transcript. + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + false, + false, + )); + assert_eq!(app.viewport.pending_scroll_delta, -1); + assert!(app.input.is_empty()); +} + +#[test] +fn composer_arrows_scroll_empty_down() { + let mut app = create_test_app(); + app.composer_arrows_scroll = true; + + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + false, + false, + )); + assert_eq!(app.viewport.pending_scroll_delta, 1); +} + +#[test] +fn composer_arrows_scroll_nonempty_still_navigates_history() { + let mut app = create_test_app(); + app.composer_arrows_scroll = true; + app.input = "hello".to_string(); + app.cursor_position = app.input.chars().count(); + app.input_history.push("previous prompt".to_string()); + + // Even with the option on, non-empty composer still navigates history. + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + false, + false, + )); + assert_eq!(app.input, "previous prompt"); +} + #[test] fn notification_settings_tui_always_keeps_configured_method_no_threshold() { let config = Config {