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
committed by Hunter Bown
parent 665801bb8e
commit fddda04fca
5 changed files with 84 additions and 4 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
@@ -4644,6 +4644,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()
@@ -4717,6 +4718,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()
@@ -4747,6 +4749,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()
@@ -4828,6 +4831,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
@@ -720,6 +720,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)]
@@ -1440,6 +1443,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),
}
}
+16 -2
View File
@@ -3591,13 +3591,27 @@ fn handle_composer_history_arrow(
return false;
}
// 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 => {
app.history_up();
if scroll_on_empty {
app.scroll_up(1);
} else {
app.history_up();
}
true
}
KeyCode::Down => {
app.history_down();
if scroll_on_empty {
app.scroll_down(1);
} else {
app.history_down();
}
true
}
_ => false,
+50 -2
View File
@@ -1259,6 +1259,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()
};
@@ -4385,13 +4386,13 @@ fn history_arrow_handles_empty_input() {
let mut app = create_test_app();
app.input_history.push("previous prompt".to_string());
// 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.input, "previous prompt");
}
@@ -4408,7 +4409,6 @@ fn history_arrow_handles_whitespace_input() {
false,
false,
));
assert_eq!(app.input, "previous prompt");
}
@@ -4429,6 +4429,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 {