fix(tui/composer): support backslash continuation

Fixes #312
This commit is contained in:
James Peter
2025-10-07 04:18:19 +10:00
parent 2443f1bbfc
commit 0806c20a85
3 changed files with 128 additions and 0 deletions
@@ -1505,6 +1505,9 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
if self.handle_backslash_continuation() {
return (InputResult::None, true);
}
let command_text = self.textarea.text().to_string();
let first_line = command_text.lines().next().unwrap_or("");
if let Some((name, rest)) = parse_slash_name(first_line)
@@ -1558,6 +1561,55 @@ impl ChatComposer {
}
}
fn handle_backslash_continuation(&mut self) -> bool {
let text = self.textarea.text();
if text.is_empty() {
return false;
}
let mut iter = text.char_indices().rev();
let Some((last_idx, last_char)) = iter.next() else {
return false;
};
if matches!(last_char, ' ' | '\t') {
return false;
}
if last_char != '\\' {
return false;
}
let trailing_backslashes = text[..last_idx]
.chars()
.rev()
.take_while(|c| *c == '\\')
.count()
+ 1;
if trailing_backslashes % 2 == 0 {
return false;
}
let line_start = text[..last_idx].rfind('\n').map(|idx| idx + 1).unwrap_or(0);
let line_before = &text[line_start..last_idx];
let indentation: String = line_before
.chars()
.take_while(|c| matches!(*c, ' ' | '\t'))
.collect();
let replacement = if indentation.is_empty() {
String::from("\n")
} else {
format!("\n{indentation}")
};
let backslash_end = last_idx + '\\'.len_utf8();
self.textarea.replace_range(last_idx..backslash_end, &replacement);
self.history.reset_navigation();
self.post_paste_space_guard = None;
self.pending_pastes
.retain(|(placeholder, _)| self.textarea.text().contains(placeholder));
self.typed_anything = true;
true
}
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
let text_before = self.textarea.text().to_string();
@@ -103,6 +103,11 @@ impl ComposerInput {
WidgetRef::render_ref(&self.inner, area, buf);
}
/// Return the current text contents of the composer.
pub fn text(&self) -> &str {
self.inner.text()
}
/// Return true if a paste-burst detection is currently active.
pub fn is_in_paste_burst(&self) -> bool {
self.inner.is_in_paste_burst()
+71
View File
@@ -208,6 +208,77 @@ fn composer_input_shift_enter_no_submit() {
}
}
#[test]
fn composer_input_backslash_continuation_inserts_newline() {
let mut composer = ComposerInput::new();
composer.handle_paste("Hello\\".to_string());
let enter = make_key(KeyCode::Enter, KeyModifiers::NONE);
match composer.input(enter) {
ComposerAction::None => {
assert_eq!(composer.text(), "Hello\n");
assert!(!composer.is_empty(), "composer should retain text after continuation");
}
ComposerAction::Submitted(_) => panic!("continuation should not submit"),
}
}
#[test]
fn composer_input_backslash_continuation_trailing_space_submits() {
let mut composer = ComposerInput::new();
composer.handle_paste("Hello\\ ".to_string());
match composer.input(make_key(KeyCode::Enter, KeyModifiers::NONE)) {
ComposerAction::Submitted(text) => {
assert_eq!(text, "Hello\\ ");
assert!(composer.is_empty(), "composer should clear after submission");
}
ComposerAction::None => panic!("trailing space should cancel continuation"),
}
}
#[test]
fn composer_input_backslash_even_count_submits() {
let mut composer = ComposerInput::new();
composer.handle_paste(String::from(r"Path: C\\"));
match composer.input(make_key(KeyCode::Enter, KeyModifiers::NONE)) {
ComposerAction::Submitted(text) => assert_eq!(text, String::from(r"Path: C\\")),
ComposerAction::None => panic!("even number of backslashes should submit"),
}
}
#[test]
fn composer_input_backslash_multiple_continuations() {
let mut composer = ComposerInput::new();
composer.handle_paste("line1\\".to_string());
assert!(matches!(
composer.input(make_key(KeyCode::Enter, KeyModifiers::NONE)),
ComposerAction::None
));
assert_eq!(composer.text(), "line1\n");
composer.handle_paste("line2\\".to_string());
assert!(matches!(
composer.input(make_key(KeyCode::Enter, KeyModifiers::NONE)),
ComposerAction::None
));
assert_eq!(composer.text(), "line1\nline2\n");
}
#[test]
fn composer_input_backslash_continuation_preserves_indent() {
let mut composer = ComposerInput::new();
composer.handle_paste(" indented\\".to_string());
assert!(matches!(
composer.input(make_key(KeyCode::Enter, KeyModifiers::NONE)),
ComposerAction::None
));
assert_eq!(composer.text(), " indented\n ");
}
#[test]
fn composer_input_ctrl_c_aborts_without_submit() {
let mut composer = ComposerInput::new();