steer_tui/tui/handlers/
vim.rs1use crate::error::Result;
2use crate::tui::Tui;
3use crate::tui::{InputMode, VimOperator};
4use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use std::time::Duration;
6use steer_core::app::AppCommand;
7use tui_textarea::{CursorMove, Input};
8
9impl Tui {
10 pub async fn handle_vim_mode(&mut self, key: KeyEvent) -> Result<bool> {
11 match self.input_mode {
12 InputMode::VimNormal => self.handle_vim_normal(key).await,
13 InputMode::VimInsert => self.handle_vim_insert(key).await,
14 InputMode::BashCommand => self.handle_bash_mode(key).await,
16 InputMode::AwaitingApproval => self.handle_approval_mode(key).await,
17 InputMode::EditMessageSelection => self.handle_edit_selection_mode(key).await,
18 InputMode::FuzzyFinder => self.handle_fuzzy_finder_mode(key).await,
19 InputMode::ConfirmExit => self.handle_confirm_exit_mode(key).await,
20 InputMode::Setup => self.handle_setup_mode(key).await,
21 InputMode::Simple => self.handle_simple_mode(key).await, }
23 }
24
25 async fn handle_vim_normal(&mut self, key: KeyEvent) -> Result<bool> {
26 let mut should_clear_state = true;
27
28 if key.modifiers.contains(KeyModifiers::CONTROL) {
30 match key.code {
31 KeyCode::Char('c') => {
32 if self.is_processing {
33 self.client
34 .send_command(AppCommand::CancelProcessing)
35 .await?;
36 } else {
37 self.switch_mode(InputMode::ConfirmExit);
38 }
39 }
40 KeyCode::Char('r') => {
41 if self.vim_state.pending_operator.is_some() {
42 self.input_panel_state.textarea.redo();
44 } else {
45 self.chat_viewport.state_mut().toggle_view_mode();
47 }
48 }
49 KeyCode::Char('u') => self.chat_viewport.state_mut().scroll_up(10),
50 KeyCode::Char('d') => self.chat_viewport.state_mut().scroll_down(10),
51 _ => {}
52 }
53 return Ok(false);
54 }
55
56 if let Some(operator) = self.vim_state.pending_operator {
58 let mut motion_handled = true;
59 match key.code {
60 KeyCode::Char('w') => {
62 if operator == VimOperator::Change {
63 self.input_panel_state.textarea.delete_next_word();
64 while let Some(line) = self
66 .input_panel_state
67 .textarea
68 .lines()
69 .get(self.input_panel_state.textarea.cursor().0)
70 {
71 if let Some(ch) =
72 line.chars().nth(self.input_panel_state.textarea.cursor().1)
73 {
74 if ch == ' ' || ch == '\t' {
75 self.input_panel_state.textarea.delete_next_char();
76 } else {
77 break;
78 }
79 } else {
80 break;
81 }
82 }
83 self.set_mode(InputMode::VimInsert);
84 } else if operator == VimOperator::Delete {
85 self.input_panel_state.textarea.delete_next_word();
86 while let Some(line) = self
88 .input_panel_state
89 .textarea
90 .lines()
91 .get(self.input_panel_state.textarea.cursor().0)
92 {
93 if let Some(ch) =
94 line.chars().nth(self.input_panel_state.textarea.cursor().1)
95 {
96 if ch == ' ' || ch == '\t' {
97 self.input_panel_state.textarea.delete_next_char();
98 } else {
99 break;
100 }
101 } else {
102 break;
103 }
104 }
105 }
106 }
107 KeyCode::Char('b') => {
108 if operator == VimOperator::Change {
109 self.input_panel_state.textarea.delete_word();
110 self.set_mode(InputMode::VimInsert);
111 } else if operator == VimOperator::Delete {
112 self.input_panel_state.textarea.delete_word();
113 }
114 }
115 KeyCode::Char('$') => {
116 if operator == VimOperator::Change {
117 self.input_panel_state.textarea.delete_line_by_end();
118 self.set_mode(InputMode::VimInsert);
119 } else if operator == VimOperator::Delete {
120 self.input_panel_state.textarea.delete_line_by_end();
121 }
122 }
123 KeyCode::Char('0') | KeyCode::Char('^') => {
124 if operator == VimOperator::Change {
125 self.input_panel_state.textarea.delete_line_by_head();
126 self.set_mode(InputMode::VimInsert);
127 } else if operator == VimOperator::Delete {
128 self.input_panel_state.textarea.delete_line_by_head();
129 }
130 }
131 KeyCode::Esc => { }
132 _ => {
133 motion_handled = false;
134 }
135 }
136
137 if motion_handled {
138 self.vim_state.pending_operator = None;
139 return Ok(false);
140 }
141 }
142
143 match key.code {
145 KeyCode::Esc => {
147 if self
149 .double_tap_tracker
150 .is_double_tap(KeyCode::Esc, Duration::from_millis(300))
151 {
152 if self.input_panel_state.content().is_empty() {
153 self.enter_edit_selection_mode();
155 } else {
156 self.input_panel_state.clear();
158 }
159 self.double_tap_tracker.clear_key(&KeyCode::Esc);
161
162 self.vim_state.pending_operator = None;
164 self.vim_state.pending_g = false;
165 self.vim_state.replace_mode = false;
166 return Ok(false);
167 }
168
169 self.double_tap_tracker.record_key(KeyCode::Esc);
171
172 if self.vim_state.visual_mode {
173 self.vim_state.visual_mode = false;
174 self.input_panel_state
176 .textarea
177 .move_cursor(CursorMove::Forward);
178 self.input_panel_state
179 .textarea
180 .move_cursor(CursorMove::Back);
181 } else if self.is_processing {
182 self.client
183 .send_command(AppCommand::CancelProcessing)
184 .await?;
185 }
186 self.vim_state.pending_operator = None;
187 self.vim_state.pending_g = false;
188 self.vim_state.replace_mode = false;
189 }
190 KeyCode::Char('d') => {
192 if self.vim_state.pending_operator == Some(VimOperator::Delete) {
193 self.input_panel_state.clear();
195 self.vim_state.pending_operator = None;
196 } else {
197 self.vim_state.pending_operator = Some(VimOperator::Delete);
198 should_clear_state = false;
200 }
201 }
202 KeyCode::Char('c') => {
203 if self.vim_state.pending_operator == Some(VimOperator::Change) {
204 self.input_panel_state.clear();
206 self.set_mode(InputMode::VimInsert);
207 self.vim_state.pending_operator = None;
208 } else {
209 self.vim_state.pending_operator = Some(VimOperator::Change);
210 should_clear_state = false;
211 }
212 }
213 KeyCode::Char('y') => {
214 if self.vim_state.pending_operator == Some(VimOperator::Yank) {
215 self.input_panel_state.textarea.copy();
217 self.vim_state.pending_operator = None;
218 } else {
219 self.vim_state.pending_operator = Some(VimOperator::Yank);
220 should_clear_state = false;
221 }
222 }
223
224 KeyCode::Char('i') => self.set_mode(InputMode::VimInsert),
226 KeyCode::Char('I') => {
227 self.input_panel_state
228 .textarea
229 .move_cursor(CursorMove::Head);
230 self.set_mode(InputMode::VimInsert);
231 }
232 KeyCode::Char('a') => {
233 self.input_panel_state
234 .textarea
235 .move_cursor(CursorMove::Forward);
236 self.set_mode(InputMode::VimInsert);
237 }
238 KeyCode::Char('A') => {
239 self.input_panel_state.textarea.move_cursor(CursorMove::End);
240 self.set_mode(InputMode::VimInsert);
241 }
242 KeyCode::Char('o') => {
243 self.input_panel_state.textarea.move_cursor(CursorMove::End);
244 self.input_panel_state.insert_str("\n");
245 self.set_mode(InputMode::VimInsert);
246 }
247 KeyCode::Char('O') => {
248 self.input_panel_state
249 .textarea
250 .move_cursor(CursorMove::Head);
251 self.input_panel_state.insert_str("\n");
252 self.input_panel_state.textarea.move_cursor(CursorMove::Up);
253 self.set_mode(InputMode::VimInsert);
254 }
255
256 KeyCode::Char('x') => {
258 self.input_panel_state.textarea.delete_next_char();
259 }
260 KeyCode::Char('X') => {
261 self.input_panel_state.textarea.delete_char();
262 }
263 KeyCode::Char('D') => {
264 self.input_panel_state.textarea.delete_line_by_end();
265 }
266 KeyCode::Char('C') => {
267 self.input_panel_state.textarea.delete_line_by_end();
268 self.set_mode(InputMode::VimInsert);
269 }
270 KeyCode::Char('p') => {
271 self.input_panel_state.textarea.paste();
272 }
273 KeyCode::Char('u') => {
274 self.input_panel_state.textarea.undo();
275 }
276 KeyCode::Char('~') => {
277 let pos = self.input_panel_state.textarea.cursor();
278 let lines = self.input_panel_state.textarea.lines();
279 if let Some(line) = lines.get(pos.0) {
280 if let Some(ch) = line.chars().nth(pos.1) {
281 self.input_panel_state.textarea.delete_next_char();
282 let toggled = if ch.is_uppercase() {
283 ch.to_lowercase().to_string()
284 } else {
285 ch.to_uppercase().to_string()
286 };
287 self.input_panel_state.textarea.insert_str(&toggled);
288 }
289 }
290 }
291 KeyCode::Char('J') => {
292 self.input_panel_state.textarea.move_cursor(CursorMove::End);
293 let pos = self.input_panel_state.textarea.cursor();
294 let lines = self.input_panel_state.textarea.lines();
295 if pos.0 < lines.len() - 1 {
296 self.input_panel_state.textarea.delete_next_char();
297 self.input_panel_state.textarea.insert_char(' ');
298 }
299 }
300
301 KeyCode::Char('h') | KeyCode::Left => self
303 .input_panel_state
304 .textarea
305 .move_cursor(CursorMove::Back),
306 KeyCode::Char('l') | KeyCode::Right => self
307 .input_panel_state
308 .textarea
309 .move_cursor(CursorMove::Forward),
310 KeyCode::Char('j') | KeyCode::Down => self.chat_viewport.state_mut().scroll_down(1),
311 KeyCode::Char('k') | KeyCode::Up => self.chat_viewport.state_mut().scroll_up(1),
312 KeyCode::Char('w') => self
313 .input_panel_state
314 .textarea
315 .move_cursor(CursorMove::WordForward),
316 KeyCode::Char('b') => self
317 .input_panel_state
318 .textarea
319 .move_cursor(CursorMove::WordBack),
320 KeyCode::Char('0') | KeyCode::Char('^') => self
321 .input_panel_state
322 .textarea
323 .move_cursor(CursorMove::Head),
324 KeyCode::Char('$') => self.input_panel_state.textarea.move_cursor(CursorMove::End),
325 KeyCode::Char('G') => self.chat_viewport.state_mut().scroll_to_bottom(),
326 KeyCode::Char('g') => {
327 if self.vim_state.pending_g {
328 self.chat_viewport.state_mut().scroll_to_top();
329 }
330 self.vim_state.pending_g = !self.vim_state.pending_g;
331 should_clear_state = false;
332 }
333
334 KeyCode::Char('v') => {
336 self.input_panel_state.textarea.start_selection();
337 self.vim_state.visual_mode = true;
338 }
339 KeyCode::Char('V') => {
340 self.input_panel_state
341 .textarea
342 .move_cursor(CursorMove::Head);
343 self.input_panel_state.textarea.start_selection();
344 self.input_panel_state.textarea.move_cursor(CursorMove::End);
345 self.vim_state.visual_mode = true;
346 }
347
348 KeyCode::Char('r') => {
350 self.vim_state.replace_mode = true;
351 should_clear_state = false;
352 }
353 KeyCode::Char(ch) if self.vim_state.replace_mode => {
354 self.input_panel_state.textarea.delete_next_char();
355 self.input_panel_state.textarea.insert_char(ch);
356 self.vim_state.replace_mode = false;
357 }
358
359 KeyCode::Char('e') => self.enter_edit_selection_mode(),
361 KeyCode::Char('/') => {
362 self.input_panel_state.clear();
364 self.input_panel_state.insert_str("/");
365 self.input_panel_state.activate_command_fuzzy();
367 self.switch_mode(InputMode::FuzzyFinder);
368
369 let results: Vec<_> = self
371 .command_registry
372 .all_commands()
373 .into_iter()
374 .map(|cmd| {
375 crate::tui::widgets::fuzzy_finder::PickerItem::new(
376 cmd.name.to_string(),
377 format!("/{} ", cmd.name),
378 )
379 })
380 .collect();
381 self.input_panel_state.fuzzy_finder.update_results(results);
382 }
383 KeyCode::Char('!') => {
384 self.input_panel_state.clear();
385 self.input_panel_state
386 .textarea
387 .set_placeholder_text("Enter bash command...");
388 self.switch_mode(InputMode::BashCommand);
389 }
390
391 _ => {
392 should_clear_state = false;
393 }
394 }
395
396 if should_clear_state {
397 self.vim_state.pending_g = false;
398 self.vim_state.pending_operator = None;
399 }
400
401 Ok(false)
402 }
403
404 async fn handle_vim_insert(&mut self, key: KeyEvent) -> Result<bool> {
405 if self.handle_text_manipulation(key)? {
407 return Ok(false);
408 }
409
410 match key.code {
411 KeyCode::Esc => {
412 if self
414 .double_tap_tracker
415 .is_double_tap(KeyCode::Esc, Duration::from_millis(300))
416 {
417 self.input_panel_state.clear();
419 self.double_tap_tracker.clear_key(&KeyCode::Esc);
420 } else {
421 self.double_tap_tracker.record_key(KeyCode::Esc);
423 self.set_mode(InputMode::VimNormal);
424 self.input_panel_state
426 .textarea
427 .move_cursor(CursorMove::Back);
428 }
429 }
430
431 KeyCode::Enter => {
432 let content = self.input_panel_state.content().trim().to_string();
433 if !content.is_empty() {
434 if content.starts_with('!') && content.len() > 1 {
435 let command = content[1..].trim().to_string();
437 self.client
438 .send_command(AppCommand::ExecuteBashCommand { command })
439 .await?;
440 self.input_panel_state.clear();
441 self.set_mode(InputMode::VimNormal);
442 } else if content.starts_with('/') {
443 let old_editing_mode = self.preferences.ui.editing_mode;
445 self.handle_slash_command(content).await?;
446 self.input_panel_state.clear();
447 if self.input_mode == InputMode::VimInsert
450 && self.preferences.ui.editing_mode == old_editing_mode
451 {
452 self.set_mode(InputMode::VimNormal);
453 }
454 } else {
455 self.send_message(content).await?;
457 self.input_panel_state.clear();
458 self.set_mode(InputMode::VimNormal);
459 }
460 } else {
461 self.input_panel_state.handle_input(Input::from(key));
463 }
464 }
465
466 KeyCode::Char('!') => {
467 let content = self.input_panel_state.content();
468 if content.is_empty() {
469 self.input_panel_state
471 .textarea
472 .set_placeholder_text("Enter bash command...");
473 self.switch_mode(InputMode::BashCommand);
474 } else {
475 self.input_panel_state.handle_input(Input::from(key));
477 }
478 }
479
480 KeyCode::Char('@') => {
481 self.input_panel_state.handle_input(Input::from(key));
483 self.input_panel_state.activate_fuzzy();
484 self.switch_mode(InputMode::FuzzyFinder);
485
486 let file_results = self
488 .input_panel_state
489 .file_cache()
490 .fuzzy_search("", Some(20))
491 .await;
492 let picker_items: Vec<_> = file_results
493 .into_iter()
494 .map(|path| {
495 crate::tui::widgets::fuzzy_finder::PickerItem::new(
496 path.clone(),
497 format!("@{path} "),
498 )
499 })
500 .collect();
501 self.input_panel_state
502 .fuzzy_finder
503 .update_results(picker_items);
504 }
505
506 KeyCode::Char('/') => {
507 let content = self.input_panel_state.content();
508 if content.is_empty() {
509 self.input_panel_state.handle_input(Input::from(key));
511 self.input_panel_state.activate_command_fuzzy();
512 self.switch_mode(InputMode::FuzzyFinder);
513
514 let results: Vec<_> = self
516 .command_registry
517 .all_commands()
518 .into_iter()
519 .map(|cmd| {
520 crate::tui::widgets::fuzzy_finder::PickerItem::new(
521 cmd.name.to_string(),
522 format!("/{} ", cmd.name),
523 )
524 })
525 .collect();
526 self.input_panel_state.fuzzy_finder.update_results(results);
527 } else {
528 self.input_panel_state.handle_input(Input::from(key));
530 }
531 }
532
533 KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => {
535 self.set_mode(InputMode::VimNormal);
536 self.input_panel_state
538 .textarea
539 .move_cursor(CursorMove::Back);
540 }
541
542 _ => {
543 self.input_panel_state.handle_input(Input::from(key));
545 }
546 }
547 Ok(false)
548 }
549}