fix(tui): protect multiline drafts on arrow navigation

This commit is contained in:
Hunter B
2026-06-01 19:58:39 -07:00
parent 41edcd5c4f
commit 88f34fc9dd
5 changed files with 73 additions and 14 deletions
+7 -2
View File
@@ -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
+7 -2
View File
@@ -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
+1 -1
View File
@@ -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",
+29 -2
View File
@@ -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)
}
+29 -7
View File
@@ -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