From f0de2b2de81af7a0910f6ca5d21e09b26947b3a5 Mon Sep 17 00:00:00 2001 From: lbcheng Date: Fri, 8 May 2026 22:33:59 +0800 Subject: [PATCH 1/2] fix(tui): scroll transcript when composer is empty, not history Restore pre-#1117 behaviour where plain Up/Down scroll the transcript when the composer is empty. Terminals that map trackpad gestures to arrow keys rely on this to scroll the history area. When the composer has text, Up/Down still navigate input history. Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/ui.rs | 18 ++++++++++++++++-- crates/tui/src/tui/ui/tests.rs | 22 +++++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c7f5b208..e35e8540 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3513,13 +3513,27 @@ 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(); + match key.code { KeyCode::Up => { - app.history_up(); + if composer_empty { + app.scroll_up(1); + } else { + app.history_up(); + } true } KeyCode::Down => { - app.history_down(); + if composer_empty { + app.scroll_down(1); + } else { + app.history_down(); + } true } _ => false, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 1c0ca72e..6c8aa5f7 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3925,14 +3925,28 @@ 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. 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"); +} - assert_eq!(app.input, "previous prompt"); +#[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"); } #[test] @@ -3942,14 +3956,15 @@ 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.input, "previous prompt"); + assert_eq!(app.viewport.pending_scroll_delta, -1); + assert_eq!(app.input, " ", "whitespace-only input should stay unchanged"); } #[test] @@ -3959,6 +3974,7 @@ 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), From 019330c2199f1ef43f71adb5dd0f234a35da302b Mon Sep 17 00:00:00 2001 From: lbcheng Date: Sat, 9 May 2026 03:21:17 +0800 Subject: [PATCH 2/2] 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 {