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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user