From 88f34fc9dd2f09f61e871900e28381a0a47d7e14 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 1 Jun 2026 19:58:39 -0700 Subject: [PATCH] fix(tui): protect multiline drafts on arrow navigation --- CHANGELOG.md | 9 ++++++-- crates/tui/CHANGELOG.md | 9 ++++++-- crates/tui/src/localization.rs | 2 +- crates/tui/src/tui/composer_ui.rs | 31 ++++++++++++++++++++++++-- crates/tui/src/tui/ui/tests.rs | 36 +++++++++++++++++++++++++------ 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b27fd7c3..9623b3b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 DeepSeek routes keep the 128K fallback. - Fixed npm wrapper version output so `--version` prefers the installed binary version instead of stale package metadata when both are available. +- Fixed multiline composer arrow navigation so holding Up/Down at the first or + last line no longer replaces the current draft with prompt history. +- Clarified the English DeepSeek account-balance footer chip from `bal` to + `balance` so it is less likely to be mistaken for session spend. - Fixed truncated subagent tool calls and repeated truncated subagent responses so they return model-visible errors instead of silently failing. @@ -41,8 +45,9 @@ Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, #2562, #2563, #2564), and **@HUQIANTAO** (#2527) for the work harvested into this release pass. Thanks also to issue reporters and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** -(#2494), **@ctxyao** (#2556), and **@Dr3259** (#2380) for reports and -acceptance details that shaped these fixes. +(#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and **@caiyilian** +(#2567) for reports and acceptance details that shaped these fixes, plus the +WeChat/Chinese UX reports relayed during the final triage pass. ## [0.8.49] - 2026-06-01 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index b27fd7c3..9623b3b3 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 DeepSeek routes keep the 128K fallback. - Fixed npm wrapper version output so `--version` prefers the installed binary version instead of stale package metadata when both are available. +- Fixed multiline composer arrow navigation so holding Up/Down at the first or + last line no longer replaces the current draft with prompt history. +- Clarified the English DeepSeek account-balance footer chip from `bal` to + `balance` so it is less likely to be mistaken for session spend. - Fixed truncated subagent tool calls and repeated truncated subagent responses so they return model-visible errors instead of silently failing. @@ -41,8 +45,9 @@ Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, #2562, #2563, #2564), and **@HUQIANTAO** (#2527) for the work harvested into this release pass. Thanks also to issue reporters and verification helpers including **@New2Niu** (#2561), **@buko** (#2533, #2369), **@wywsoor** -(#2494), **@ctxyao** (#2556), and **@Dr3259** (#2380) for reports and -acceptance details that shaped these fixes. +(#2494), **@ctxyao** (#2556), **@Dr3259** (#2380), and **@caiyilian** +(#2567) for reports and acceptance details that shaped these fixes, plus the +WeChat/Chinese UX reports relayed during the final triage pass. ## [0.8.49] - 2026-06-01 diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 5e67b571..ad853192 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1121,7 +1121,7 @@ fn english(id: MessageId) -> &'static str { MessageId::FooterAgentsPlural => "{count} agents", MessageId::FooterPressCtrlCAgain => "Press Ctrl+C again to quit", MessageId::FooterWorking => "working", - MessageId::FooterBalancePrefix => "bal", + MessageId::FooterBalancePrefix => "balance", MessageId::HelpSectionActions => "Actions", MessageId::HelpSectionClipboard => "Clipboard", MessageId::HelpSectionEditing => "Input editing", diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index 75275873..708f4f97 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -57,14 +57,19 @@ pub(crate) fn handle_composer_history_arrow( } // When `composer_arrows_scroll` is enabled, plain Up/Down scroll the - // transcript for single-line drafts. Multiline composers keep editor-like - // line navigation, with history fallback at the first/last line. + // transcript for single-line drafts. Multiline drafts keep editor-like + // line navigation. If the user holds Up/Down at the first/last line, do + // not replace their current draft with prompt history unless they are + // already navigating history. let scroll_transcript = app.composer_arrows_scroll && !app.input.contains('\n'); + let protect_multiline_draft = app.input.contains('\n') && app.history_index.is_none(); match key.code { KeyCode::Up => { if scroll_transcript { app.scroll_up(COMPOSER_ARROW_SCROLL_LINES); + } else if protect_multiline_draft && !cursor_has_previous_logical_line(app) { + app.needs_redraw = true; } else { app.vim_move_up(); } @@ -73,6 +78,8 @@ pub(crate) fn handle_composer_history_arrow( KeyCode::Down => { if scroll_transcript { app.scroll_down(COMPOSER_ARROW_SCROLL_LINES); + } else if protect_multiline_draft && !cursor_has_next_logical_line(app) { + app.needs_redraw = true; } else { app.vim_move_down(); } @@ -82,6 +89,26 @@ pub(crate) fn handle_composer_history_arrow( } } +fn cursor_has_previous_logical_line(app: &App) -> bool { + let cursor_byte = byte_index_at_char(&app.input, app.cursor_position); + app.input[..cursor_byte].contains('\n') +} + +fn cursor_has_next_logical_line(app: &App) -> bool { + let cursor_byte = byte_index_at_char(&app.input, app.cursor_position); + app.input[cursor_byte..].contains('\n') +} + +fn byte_index_at_char(text: &str, char_index: usize) -> usize { + if char_index == 0 { + return 0; + } + text.char_indices() + .nth(char_index) + .map(|(idx, _)| idx) + .unwrap_or(text.len()) +} + pub(crate) fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 3d7bf7f5..9434ee62 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -6935,7 +6935,7 @@ fn footer_balance_spans_formats_cny() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal ¥123.5"); + assert_eq!(spans_text(&spans), "balance ¥123.5"); } #[test] @@ -6948,7 +6948,7 @@ fn footer_balance_spans_formats_usd() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal $0.50"); + assert_eq!(spans_text(&spans), "balance $0.50"); } #[test] @@ -6961,7 +6961,7 @@ fn footer_balance_spans_rounds_large_amount() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal $1235"); + assert_eq!(spans_text(&spans), "balance $1235"); } #[test] @@ -6974,7 +6974,7 @@ fn footer_balance_spans_treats_unknown_currency_as_usd() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal $10.0"); + assert_eq!(spans_text(&spans), "balance $10.0"); } #[test] @@ -6987,7 +6987,7 @@ fn render_footer_from_with_balance_item_shows_balance() { }; *app.balance_cell.lock().unwrap() = Some(info); let props = render_footer_from(&app, &[crate::config::StatusItem::Balance], None); - assert_eq!(spans_text(&props.balance), "bal $42.5"); + assert_eq!(spans_text(&props.balance), "balance $42.5"); } #[test] @@ -7323,7 +7323,7 @@ fn composer_arrows_scroll_multiline_input_navigates_lines() { } #[test] -fn composer_arrow_up_at_first_line_falls_back_to_history_up() { +fn composer_arrow_up_at_first_line_preserves_multiline_draft() { let mut app = create_test_app(); app.composer_arrows_scroll = false; app.input = "line one\nline two".to_string(); @@ -7337,7 +7337,29 @@ fn composer_arrow_up_at_first_line_falls_back_to_history_up() { false, )); - assert_eq!(app.input, "previous prompt"); + assert_eq!(app.input, "line one\nline two"); + assert_eq!(app.cursor_position, 0); + assert!(app.history_index.is_none()); +} + +#[test] +fn composer_arrow_down_at_last_line_preserves_multiline_draft() { + let mut app = create_test_app(); + app.composer_arrows_scroll = false; + app.input = "line one\nline two".to_string(); + app.cursor_position = app.input.chars().count(); + app.input_history.push("next prompt".to_string()); + + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + false, + false, + )); + + assert_eq!(app.input, "line one\nline two"); + assert_eq!(app.cursor_position, app.input.chars().count()); + assert!(app.history_index.is_none()); } // #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll