merge: composer input history
This commit is contained in:
commit
d98f2ff7df
|
|
@ -107,6 +107,81 @@ impl QueuedInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPOSER_INPUT_HISTORY_LIMIT: usize = 100;
|
||||||
|
|
||||||
|
struct ComposerInputHistory {
|
||||||
|
entries: VecDeque<Vec<Segment>>,
|
||||||
|
browse: Option<ComposerInputHistoryBrowse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComposerInputHistoryBrowse {
|
||||||
|
index: usize,
|
||||||
|
draft: Vec<Segment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComposerInputHistory {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: VecDeque::new(),
|
||||||
|
browse: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, segments: Vec<Segment>) {
|
||||||
|
if segments_are_blank(&segments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.browse = None;
|
||||||
|
if self.entries.back() == Some(&segments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.entries.len() == COMPOSER_INPUT_HISTORY_LIMIT {
|
||||||
|
self.entries.pop_front();
|
||||||
|
}
|
||||||
|
self.entries.push_back(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_browsing(&self) -> bool {
|
||||||
|
self.browse.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browse_older(&mut self, draft: Vec<Segment>) -> Option<Vec<Segment>> {
|
||||||
|
if self.entries.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = match self.browse.as_mut() {
|
||||||
|
Some(browse) => {
|
||||||
|
if browse.index > 0 {
|
||||||
|
browse.index -= 1;
|
||||||
|
}
|
||||||
|
browse.index
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let index = self.entries.len() - 1;
|
||||||
|
self.browse = Some(ComposerInputHistoryBrowse { index, draft });
|
||||||
|
index
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.entries.get(index).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browse_newer(&mut self) -> Option<Vec<Segment>> {
|
||||||
|
let browse = self.browse.as_mut()?;
|
||||||
|
if browse.index + 1 < self.entries.len() {
|
||||||
|
browse.index += 1;
|
||||||
|
return self.entries.get(browse.index).cloned();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.browse.take().map(|browse| browse.draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_browse(&mut self) {
|
||||||
|
self.browse = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum ActionbarNoticeLevel {
|
pub enum ActionbarNoticeLevel {
|
||||||
|
|
@ -205,6 +280,9 @@ pub struct App {
|
||||||
/// TUI-local FIFO of user inputs submitted while the Pod is already running.
|
/// TUI-local FIFO of user inputs submitted while the Pod is already running.
|
||||||
/// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally.
|
/// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally.
|
||||||
queued_inputs: VecDeque<QueuedInput>,
|
queued_inputs: VecDeque<QueuedInput>,
|
||||||
|
/// TUI-local readline-style composer input history. This is intentionally
|
||||||
|
/// client-side only: recalled entries are plain drafts until submitted again.
|
||||||
|
input_history: ComposerInputHistory,
|
||||||
/// Local submit state kept until the accepted run either completes
|
/// Local submit state kept until the accepted run either completes
|
||||||
/// normally or reports that the empty assistant turn was rolled back.
|
/// normally or reports that the empty assistant turn was rolled back.
|
||||||
pending_submit_rollback: Option<RollbackSubmitState>,
|
pending_submit_rollback: Option<RollbackSubmitState>,
|
||||||
|
|
@ -251,6 +329,7 @@ impl App {
|
||||||
task_pane_open: false,
|
task_pane_open: false,
|
||||||
task_pane_scroll: 0,
|
task_pane_scroll: 0,
|
||||||
queued_inputs: VecDeque::new(),
|
queued_inputs: VecDeque::new(),
|
||||||
|
input_history: ComposerInputHistory::new(),
|
||||||
pending_submit_rollback: None,
|
pending_submit_rollback: None,
|
||||||
last_rolled_back_input: None,
|
last_rolled_back_input: None,
|
||||||
}
|
}
|
||||||
|
|
@ -365,6 +444,7 @@ impl App {
|
||||||
// `prefix_start` indexes the sigil atom; the text we want to
|
// `prefix_start` indexes the sigil atom; the text we want to
|
||||||
// replace lives just after it (sigil itself stays).
|
// replace lives just after it (sigil itself stays).
|
||||||
let typed_start = state.prefix_start + 1;
|
let typed_start = state.prefix_start + 1;
|
||||||
|
self.input_history.cancel_browse();
|
||||||
self.input.replace_with_text_at(typed_start, &text);
|
self.input.replace_with_text_at(typed_start, &text);
|
||||||
self.refresh_completion()
|
self.refresh_completion()
|
||||||
}
|
}
|
||||||
|
|
@ -419,6 +499,7 @@ impl App {
|
||||||
};
|
};
|
||||||
let kind = state.kind;
|
let kind = state.kind;
|
||||||
let start = state.prefix_start;
|
let start = state.prefix_start;
|
||||||
|
self.input_history.cancel_browse();
|
||||||
match kind {
|
match kind {
|
||||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
||||||
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
||||||
|
|
@ -453,6 +534,7 @@ impl App {
|
||||||
let kind = state.kind;
|
let kind = state.kind;
|
||||||
let start = state.prefix_start;
|
let start = state.prefix_start;
|
||||||
let value = entry.value.clone();
|
let value = entry.value.clone();
|
||||||
|
self.input_history.cancel_browse();
|
||||||
match kind {
|
match kind {
|
||||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
||||||
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
||||||
|
|
@ -468,11 +550,13 @@ impl App {
|
||||||
// Empty Enter only does something meaningful when the Pod
|
// Empty Enter only does something meaningful when the Pod
|
||||||
// is paused: resume the interrupted turn. Otherwise no-op.
|
// is paused: resume the interrupted turn. Otherwise no-op.
|
||||||
if self.paused {
|
if self.paused {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
return Some(Method::Resume);
|
return Some(Method::Resume);
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
self.input_history.record(segments.clone());
|
||||||
if self.running {
|
if self.running {
|
||||||
self.queued_inputs.push_back(QueuedInput::new(segments));
|
self.queued_inputs.push_back(QueuedInput::new(segments));
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
|
|
@ -503,6 +587,44 @@ impl App {
|
||||||
self.queued_inputs.len()
|
self.queued_inputs.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn input_history_len(&self) -> usize {
|
||||||
|
self.input_history.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_history_is_browsing(&self) -> bool {
|
||||||
|
self.input_history.is_browsing()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_browse_input_history_older(&self) -> bool {
|
||||||
|
self.input_history.is_browsing() || self.input.cursor_at_start()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_browse_input_history_newer(&self) -> bool {
|
||||||
|
self.input_history.is_browsing() && self.input.cursor_at_end()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn browse_input_history_older(&mut self) -> bool {
|
||||||
|
if self.input_history.entries.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let draft = self.input.submit_segments();
|
||||||
|
let Some(segments) = self.input_history.browse_older(draft) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.input.replace_with_segments(&segments);
|
||||||
|
self.completion = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn browse_input_history_newer(&mut self) -> bool {
|
||||||
|
let Some(segments) = self.input_history.browse_newer() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.input.replace_with_segments(&segments);
|
||||||
|
self.completion = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn flash_actionbar_notice(
|
pub fn flash_actionbar_notice(
|
||||||
&mut self,
|
&mut self,
|
||||||
text: impl Into<String>,
|
text: impl Into<String>,
|
||||||
|
|
@ -566,6 +688,7 @@ impl App {
|
||||||
let Some(queued) = self.queued_inputs.pop_front() else {
|
let Some(queued) = self.queued_inputs.pop_front() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
self.input_history.cancel_browse();
|
||||||
self.input.replace_with_segments(&queued.segments);
|
self.input.replace_with_segments(&queued.segments);
|
||||||
self.completion = None;
|
self.completion = None;
|
||||||
true
|
true
|
||||||
|
|
@ -1512,6 +1635,9 @@ impl App {
|
||||||
// keeping the normal composer buffer intact.
|
// keeping the normal composer buffer intact.
|
||||||
pub fn insert_char(&mut self, c: char) {
|
pub fn insert_char(&mut self, c: char) {
|
||||||
let command_mode = self.is_command_mode();
|
let command_mode = self.is_command_mode();
|
||||||
|
if !command_mode {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
|
}
|
||||||
self.active_input_mut().insert_char(c);
|
self.active_input_mut().insert_char(c);
|
||||||
if command_mode {
|
if command_mode {
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
|
|
@ -1519,6 +1645,9 @@ impl App {
|
||||||
}
|
}
|
||||||
pub fn insert_newline(&mut self) {
|
pub fn insert_newline(&mut self) {
|
||||||
let command_mode = self.is_command_mode();
|
let command_mode = self.is_command_mode();
|
||||||
|
if !command_mode {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
|
}
|
||||||
self.active_input_mut().insert_newline();
|
self.active_input_mut().insert_newline();
|
||||||
if command_mode {
|
if command_mode {
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
|
|
@ -1529,11 +1658,15 @@ impl App {
|
||||||
self.command_input.insert_str(&content);
|
self.command_input.insert_str(&content);
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
} else {
|
} else {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
self.input.insert_paste(content);
|
self.input.insert_paste(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn delete_char_before(&mut self) {
|
pub fn delete_char_before(&mut self) {
|
||||||
let command_mode = self.is_command_mode();
|
let command_mode = self.is_command_mode();
|
||||||
|
if !command_mode {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
|
}
|
||||||
self.active_input_mut().delete_before();
|
self.active_input_mut().delete_before();
|
||||||
if command_mode {
|
if command_mode {
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
|
|
@ -1541,6 +1674,9 @@ impl App {
|
||||||
}
|
}
|
||||||
pub fn delete_char_after(&mut self) {
|
pub fn delete_char_after(&mut self) {
|
||||||
let command_mode = self.is_command_mode();
|
let command_mode = self.is_command_mode();
|
||||||
|
if !command_mode {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
|
}
|
||||||
self.active_input_mut().delete_after();
|
self.active_input_mut().delete_after();
|
||||||
if command_mode {
|
if command_mode {
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
|
|
@ -2781,6 +2917,124 @@ mod completion_flow_tests {
|
||||||
assert_eq!(tasks[1].status, crate::task::TaskStatus::Inprogress);
|
assert_eq!(tasks[1].status, crate::task::TaskStatus::Inprogress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_history_records_queued_inputs_and_suppresses_consecutive_duplicates() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.running = true;
|
||||||
|
|
||||||
|
for c in "repeat".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
assert_eq!(app.input_history_len(), 1);
|
||||||
|
assert_eq!(app.queued_input_count(), 1);
|
||||||
|
|
||||||
|
for c in "repeat".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
assert_eq!(app.input_history_len(), 1);
|
||||||
|
assert_eq!(app.queued_input_count(), 2);
|
||||||
|
|
||||||
|
app.insert_char(' ');
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
assert_eq!(app.input_history_len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_history_preserves_typed_segments() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
let original = vec![
|
||||||
|
Segment::Text {
|
||||||
|
content: "see ".into(),
|
||||||
|
},
|
||||||
|
Segment::FileRef {
|
||||||
|
path: "src/main.rs".into(),
|
||||||
|
},
|
||||||
|
Segment::Text {
|
||||||
|
content: " and ".into(),
|
||||||
|
},
|
||||||
|
Segment::KnowledgeRef {
|
||||||
|
slug: "design-note".into(),
|
||||||
|
},
|
||||||
|
Segment::Text {
|
||||||
|
content: " then ".into(),
|
||||||
|
},
|
||||||
|
Segment::WorkflowInvoke {
|
||||||
|
slug: "review".into(),
|
||||||
|
},
|
||||||
|
Segment::Paste {
|
||||||
|
id: 1,
|
||||||
|
chars: 13,
|
||||||
|
lines: 1,
|
||||||
|
content: "literal paste".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
app.input.replace_with_segments(&original);
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
assert_eq!(app.input.submit_segments(), original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_history_restores_non_empty_draft_after_newest() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "sent".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
|
||||||
|
for c in "draft".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
assert_eq!(input_text(&app), "sent");
|
||||||
|
assert!(app.browse_input_history_newer());
|
||||||
|
assert_eq!(input_text(&app), "draft");
|
||||||
|
assert!(!app.input_history_is_browsing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_recalled_input_exits_history_browse_mode() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "sent".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
assert!(app.input_history_is_browsing());
|
||||||
|
app.insert_char('!');
|
||||||
|
assert!(!app.input_history_is_browsing());
|
||||||
|
assert_eq!(input_text(&app), "sent!");
|
||||||
|
assert!(!app.browse_input_history_newer());
|
||||||
|
assert_eq!(input_text(&app), "sent!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn submitting_recalled_history_sends_normally_and_records_if_not_duplicate() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "first".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
for c in "second".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
let method = app.submit_input();
|
||||||
|
match method {
|
||||||
|
Some(Method::Run { input }) => assert_eq!(Segment::flatten_to_text(&input), "first"),
|
||||||
|
other => panic!("expected recalled run, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(app.input_history_len(), 3);
|
||||||
|
assert!(!app.input_history_is_browsing());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_pane_toggle_flips_state_and_resets_scroll() {
|
fn task_pane_toggle_flips_state_and_resets_scroll() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,14 @@ impl InputBuffer {
|
||||||
self.atoms.is_empty()
|
self.atoms.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cursor_at_start(&self) -> bool {
|
||||||
|
self.cursor == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_at_end(&self) -> bool {
|
||||||
|
self.cursor == self.atoms.len()
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace the whole composer with protocol segments previously emitted
|
/// Replace the whole composer with protocol segments previously emitted
|
||||||
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
|
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
|
||||||
/// and placing the cursor at the end of the restored input.
|
/// and placing the cursor at the end of the restored input.
|
||||||
|
|
|
||||||
|
|
@ -972,13 +972,21 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
app.refresh_completion()
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
|
if app.can_browse_input_history_older() && app.browse_input_history_older() {
|
||||||
|
app.refresh_completion()
|
||||||
|
} else {
|
||||||
app.move_cursor_up();
|
app.move_cursor_up();
|
||||||
app.refresh_completion()
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
|
if app.can_browse_input_history_newer() && app.browse_input_history_newer() {
|
||||||
|
app.refresh_completion()
|
||||||
|
} else {
|
||||||
app.move_cursor_down();
|
app.move_cursor_down();
|
||||||
app.refresh_completion()
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
KeyCode::Home => {
|
KeyCode::Home => {
|
||||||
app.move_cursor_home();
|
app.move_cursor_home();
|
||||||
app.refresh_completion()
|
app.refresh_completion()
|
||||||
|
|
@ -1923,6 +1931,70 @@ mod tests {
|
||||||
assert_eq!(input_text(&app), "hello");
|
assert_eq!(input_text(&app), "hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_at_start_with_empty_history_preserves_draft_without_browsing() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
type_keys(&mut app, "draft");
|
||||||
|
app.move_cursor_start();
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
|
||||||
|
assert_eq!(input_text(&app), "draft");
|
||||||
|
assert!(!app.input_history_is_browsing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_from_empty_composer_recalls_history_and_down_restores_empty_draft() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
type_keys(&mut app, "first");
|
||||||
|
assert!(matches!(
|
||||||
|
handle_key(&mut app, key(KeyCode::Enter)),
|
||||||
|
Some(Method::Run { .. })
|
||||||
|
));
|
||||||
|
type_keys(&mut app, "second");
|
||||||
|
assert!(matches!(
|
||||||
|
handle_key(&mut app, key(KeyCode::Enter)),
|
||||||
|
Some(Method::Run { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
assert_eq!(input_text(&app), "second");
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
assert_eq!(input_text(&app), "first");
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
|
||||||
|
assert_eq!(input_text(&app), "second");
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_inside_multiline_preserves_existing_cursor_up_behavior() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
type_keys(&mut app, "ab\ncd");
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Char('X'))).is_none());
|
||||||
|
|
||||||
|
assert_eq!(input_text(&app), "abX\ncd");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_at_start_of_multiline_recalls_history() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
type_keys(&mut app, "sent");
|
||||||
|
assert!(matches!(
|
||||||
|
handle_key(&mut app, key(KeyCode::Enter)),
|
||||||
|
Some(Method::Run { .. })
|
||||||
|
));
|
||||||
|
type_keys(&mut app, "draft\nbody");
|
||||||
|
app.move_cursor_start();
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
|
||||||
|
assert_eq!(input_text(&app), "sent");
|
||||||
|
}
|
||||||
|
|
||||||
fn enter_command_mode(app: &mut App) {
|
fn enter_command_mode(app: &mut App) {
|
||||||
assert!(handle_key(app, key(KeyCode::Char(':'))).is_none());
|
assert!(handle_key(app, key(KeyCode::Char(':'))).is_none());
|
||||||
assert!(app.is_command_mode());
|
assert!(app.is_command_mode());
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user