diff --git a/crates/llm-worker/src/timeline/timeline.rs b/crates/llm-worker/src/timeline/timeline.rs index 06826592..91011918 100644 --- a/crates/llm-worker/src/timeline/timeline.rs +++ b/crates/llm-worker/src/timeline/timeline.rs @@ -47,10 +47,8 @@ fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) { pub trait ErasedHandler: Send + Sync { /// イベントをディスパッチ fn dispatch(&mut self, event: &K::Event); - /// スコープを開始(Block開始時) + /// スコープを開始 fn start_scope(&mut self); - /// スコープを終了(Block終了時) - fn end_scope(&mut self); } /// `Handler`を`ErasedHandler`として扱うためのラッパー @@ -94,10 +92,6 @@ where fn start_scope(&mut self) { self.scope = Some(H::Scope::default()); } - - fn end_scope(&mut self) { - self.scope = None; - } } // ============================================================================= diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 7deaabe9..9e105e3a 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -586,10 +586,12 @@ impl App { self.queued_inputs.len() } + #[cfg(test)] pub fn input_history_len(&self) -> usize { self.input_history.entries.len() } + #[cfg(test)] pub fn input_history_is_browsing(&self) -> bool { self.input_history.is_browsing() } @@ -1701,6 +1703,22 @@ impl App { pub fn move_cursor_right(&mut self) { self.active_input_mut().move_right(); } + pub fn move_cursor_word_left(&mut self) { + self.active_input_mut().move_word_left(); + } + pub fn move_cursor_word_right(&mut self) { + self.active_input_mut().move_word_right(); + } + pub fn delete_word_before_cursor(&mut self) { + let command_mode = self.is_command_mode(); + if !command_mode { + self.input_history.cancel_browse(); + } + self.active_input_mut().delete_word_before(); + if command_mode { + self.command_completion_selected = None; + } + } pub fn move_cursor_start(&mut self) { self.active_input_mut().move_start(); } diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 68deab5a..e9924d1e 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -573,6 +573,22 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { app.move_cursor_start(); Some(app.refresh_completion()) } + KeyCode::Left if ctrl || alt => { + app.move_cursor_word_left(); + Some(app.refresh_completion()) + } + KeyCode::Right if ctrl || alt => { + app.move_cursor_word_right(); + Some(app.refresh_completion()) + } + KeyCode::Backspace if ctrl || alt => { + app.delete_word_before_cursor(); + Some(app.refresh_completion()) + } + KeyCode::Char('w') if ctrl => { + app.delete_word_before_cursor(); + Some(app.refresh_completion()) + } KeyCode::Char('u') if ctrl && app.is_command_mode() => { app.clear_command_input(); Some(None) @@ -1067,6 +1083,125 @@ mod tests { assert_eq!(app.queued_input_count(), 0); } + #[test] + fn word_navigation_keys_edit_composer() { + let mut app = App::new("agent".to_string()); + for c in "foo bar".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL) + ) + .is_none() + ); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('_'), KeyModifiers::NONE) + ) + .is_none() + ); + assert_eq!(input_text(&app), "foo _bar"); + + assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)).is_none()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE) + ) + .is_none() + ); + assert_eq!(input_text(&app), "foo _bar!"); + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL) + ) + .is_none() + ); + assert_eq!(input_text(&app), "foo "); + } + + #[test] + fn ctrl_w_deletes_word_before_cursor() { + let mut app = App::new("agent".to_string()); + for c in "foo bar baz".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL) + ) + .is_none() + ); + assert_eq!(input_text(&app), "foo bar "); + } + + #[test] + fn word_navigation_keys_edit_command_input() { + let mut app = App::new("agent".to_string()); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE) + ) + .is_none() + ); + for c in "peer alpha beta".chars() { + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + ) + .is_none() + ); + } + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL) + ) + .is_none() + ); + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('_'), KeyModifiers::NONE) + ) + .is_none() + ); + assert_eq!(app.command_text(), "peer alpha _beta"); + + assert!( + handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL) + ) + .is_none() + ); + assert_eq!(app.command_text(), "peer alpha beta"); + assert_eq!(input_text(&app), ""); + } + #[test] fn command_mode_enters_with_colon_and_esc_restores_composer() { let mut app = App::new("agent".to_string());