fix: wire tui word navigation

This commit is contained in:
Keisuke Hirata 2026-06-03 09:22:50 +09:00
parent d0bdad58c2
commit cc98c0eb28
No known key found for this signature in database
3 changed files with 154 additions and 7 deletions

View File

@ -47,10 +47,8 @@ fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) {
pub trait ErasedHandler<K: Kind>: Send + Sync { pub trait ErasedHandler<K: Kind>: Send + Sync {
/// イベントをディスパッチ /// イベントをディスパッチ
fn dispatch(&mut self, event: &K::Event); fn dispatch(&mut self, event: &K::Event);
/// スコープを開始Block開始時 /// スコープを開始
fn start_scope(&mut self); fn start_scope(&mut self);
/// スコープを終了Block終了時
fn end_scope(&mut self);
} }
/// `Handler<K>`を`ErasedHandler<K>`として扱うためのラッパー /// `Handler<K>`を`ErasedHandler<K>`として扱うためのラッパー
@ -94,10 +92,6 @@ where
fn start_scope(&mut self) { fn start_scope(&mut self) {
self.scope = Some(H::Scope::default()); self.scope = Some(H::Scope::default());
} }
fn end_scope(&mut self) {
self.scope = None;
}
} }
// ============================================================================= // =============================================================================

View File

@ -586,10 +586,12 @@ impl App {
self.queued_inputs.len() self.queued_inputs.len()
} }
#[cfg(test)]
pub fn input_history_len(&self) -> usize { pub fn input_history_len(&self) -> usize {
self.input_history.entries.len() self.input_history.entries.len()
} }
#[cfg(test)]
pub fn input_history_is_browsing(&self) -> bool { pub fn input_history_is_browsing(&self) -> bool {
self.input_history.is_browsing() self.input_history.is_browsing()
} }
@ -1701,6 +1703,22 @@ impl App {
pub fn move_cursor_right(&mut self) { pub fn move_cursor_right(&mut self) {
self.active_input_mut().move_right(); 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) { pub fn move_cursor_start(&mut self) {
self.active_input_mut().move_start(); self.active_input_mut().move_start();
} }

View File

@ -573,6 +573,22 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.move_cursor_start(); app.move_cursor_start();
Some(app.refresh_completion()) 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() => { KeyCode::Char('u') if ctrl && app.is_command_mode() => {
app.clear_command_input(); app.clear_command_input();
Some(None) Some(None)
@ -1067,6 +1083,125 @@ mod tests {
assert_eq!(app.queued_input_count(), 0); 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] #[test]
fn command_mode_enters_with_colon_and_esc_restores_composer() { fn command_mode_enters_with_colon_and_esc_restores_composer() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());