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 <noreply@anthropic.com>
This commit is contained in:
lbcheng
2026-05-09 03:21:17 +08:00
parent f0de2b2de8
commit 019330c219
5 changed files with 77 additions and 27 deletions
+6
View File
@@ -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<NotificationCondition>,
/// 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<bool>,
}
/// High-level notification trigger override. See
+4
View File
@@ -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()
+8
View File
@@ -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),
}
}
+7 -7
View File
@@ -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();
+52 -20
View File
@@ -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 {