steer_tui/tui/handlers/
vim.rs1use crate::error::Result;
2use crate::tui::NoticeLevel;
3use crate::tui::Tui;
4use crate::tui::{InputMode, VimOperator};
5use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6use std::time::Duration;
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.cancel_operation().await?;
34 } else {
35 self.switch_mode(InputMode::ConfirmExit);
36 }
37 }
38 KeyCode::Char('r') => {
39 if self.vim_state.pending_operator.is_some() {
40 self.input_panel_state.textarea.redo();
42 self.sync_attachments_from_input_tokens();
43 } else {
44 self.chat_viewport.state_mut().toggle_view_mode();
46 self.chat_viewport.state_mut().scroll_to_bottom();
47 }
48 }
49 KeyCode::Char('u') => {
50 self.chat_viewport.state_mut().scroll_up(10);
51 }
52 KeyCode::Char('d') => {
53 self.chat_viewport.state_mut().scroll_down(10);
54 }
55 _ => {}
56 }
57 return Ok(false);
58 }
59
60 if let Some(operator) = self.vim_state.pending_operator {
62 let mut motion_handled = true;
63 match key.code {
64 KeyCode::Char('w') => {
66 if operator == VimOperator::Change {
67 self.input_panel_state.textarea.delete_next_word();
68 self.sync_attachments_from_input_tokens();
69 while let Some(line) = self
71 .input_panel_state
72 .textarea
73 .lines()
74 .get(self.input_panel_state.textarea.cursor().0)
75 {
76 if let Some(ch) =
77 line.chars().nth(self.input_panel_state.textarea.cursor().1)
78 {
79 if ch == ' ' || ch == '\t' {
80 self.input_panel_state.textarea.delete_next_char();
81 } else {
82 break;
83 }
84 } else {
85 break;
86 }
87 }
88 self.set_mode(InputMode::VimInsert);
89 } else if operator == VimOperator::Delete {
90 self.input_panel_state.textarea.delete_next_word();
91 self.sync_attachments_from_input_tokens();
92 while let Some(line) = self
94 .input_panel_state
95 .textarea
96 .lines()
97 .get(self.input_panel_state.textarea.cursor().0)
98 {
99 if let Some(ch) =
100 line.chars().nth(self.input_panel_state.textarea.cursor().1)
101 {
102 if ch == ' ' || ch == '\t' {
103 self.input_panel_state.textarea.delete_next_char();
104 } else {
105 break;
106 }
107 } else {
108 break;
109 }
110 }
111 }
112 }
113 KeyCode::Char('b') => {
114 if operator == VimOperator::Change {
115 self.input_panel_state.textarea.delete_word();
116 self.sync_attachments_from_input_tokens();
117 self.set_mode(InputMode::VimInsert);
118 } else if operator == VimOperator::Delete {
119 self.input_panel_state.textarea.delete_word();
120 self.sync_attachments_from_input_tokens();
121 }
122 }
123 KeyCode::Char('$') => {
124 if operator == VimOperator::Change {
125 self.input_panel_state.textarea.delete_line_by_end();
126 self.sync_attachments_from_input_tokens();
127 self.set_mode(InputMode::VimInsert);
128 } else if operator == VimOperator::Delete {
129 self.input_panel_state.textarea.delete_line_by_end();
130 self.sync_attachments_from_input_tokens();
131 }
132 }
133 KeyCode::Char('0' | '^') => {
134 if operator == VimOperator::Change {
135 self.input_panel_state.textarea.delete_line_by_head();
136 self.sync_attachments_from_input_tokens();
137 self.set_mode(InputMode::VimInsert);
138 } else if operator == VimOperator::Delete {
139 self.input_panel_state.textarea.delete_line_by_head();
140 self.sync_attachments_from_input_tokens();
141 }
142 }
143 KeyCode::Esc => { }
144 _ => {
145 motion_handled = false;
146 }
147 }
148
149 if motion_handled {
150 self.vim_state.pending_operator = None;
151 return Ok(false);
152 }
153 }
154
155 match key.code {
157 KeyCode::Esc => {
159 if self
161 .double_tap_tracker
162 .is_double_tap(KeyCode::Esc, Duration::from_millis(300))
163 {
164 if self.input_panel_state.content().is_empty() {
165 self.enter_edit_selection_mode();
167 } else {
168 self.input_panel_state.clear();
170 self.sync_attachments_from_input_tokens();
171 }
172 self.double_tap_tracker.clear_key(&KeyCode::Esc);
174
175 self.vim_state.pending_operator = None;
177 self.vim_state.pending_g = false;
178 self.vim_state.replace_mode = false;
179 return Ok(false);
180 }
181
182 let canceling_with_queued_item = self.is_processing && self.queued_count > 0;
186 if canceling_with_queued_item {
187 self.double_tap_tracker.clear_key(&KeyCode::Esc);
188 } else {
189 self.double_tap_tracker.record_key(KeyCode::Esc);
190 }
191
192 if self.vim_state.visual_mode {
193 self.vim_state.visual_mode = false;
194 self.input_panel_state
196 .textarea
197 .move_cursor(CursorMove::Forward);
198 self.input_panel_state
199 .textarea
200 .move_cursor(CursorMove::Back);
201 } else if self.is_processing {
202 self.client.cancel_operation().await?;
203 }
204 self.vim_state.pending_operator = None;
205 self.vim_state.pending_g = false;
206 self.vim_state.replace_mode = false;
207 }
208 KeyCode::Char('d') => {
210 if self.vim_state.pending_operator == Some(VimOperator::Delete) {
211 self.input_panel_state.clear();
213 self.sync_attachments_from_input_tokens();
214 self.vim_state.pending_operator = None;
215 } else {
216 self.vim_state.pending_operator = Some(VimOperator::Delete);
217 should_clear_state = false;
219 }
220 }
221 KeyCode::Char('c') => {
222 if self.vim_state.pending_operator == Some(VimOperator::Change) {
223 self.input_panel_state.clear();
225 self.sync_attachments_from_input_tokens();
226 self.set_mode(InputMode::VimInsert);
227 self.vim_state.pending_operator = None;
228 } else {
229 self.vim_state.pending_operator = Some(VimOperator::Change);
230 should_clear_state = false;
231 }
232 }
233 KeyCode::Char('y') => {
234 if self.vim_state.pending_operator == Some(VimOperator::Yank) {
235 self.input_panel_state.textarea.copy();
237 self.vim_state.pending_operator = None;
238 } else {
239 self.vim_state.pending_operator = Some(VimOperator::Yank);
240 should_clear_state = false;
241 }
242 }
243
244 KeyCode::Char('i') => self.set_mode(InputMode::VimInsert),
246 KeyCode::Char('I') => {
247 self.input_panel_state
248 .textarea
249 .move_cursor(CursorMove::Head);
250 self.set_mode(InputMode::VimInsert);
251 }
252 KeyCode::Char('a') => {
253 self.input_panel_state
254 .textarea
255 .move_cursor(CursorMove::Forward);
256 self.set_mode(InputMode::VimInsert);
257 }
258 KeyCode::Char('A') => {
259 self.input_panel_state.textarea.move_cursor(CursorMove::End);
260 self.set_mode(InputMode::VimInsert);
261 }
262 KeyCode::Char('o') => {
263 self.input_panel_state.textarea.move_cursor(CursorMove::End);
264 self.input_panel_state.insert_str("\n");
265 self.set_mode(InputMode::VimInsert);
266 }
267 KeyCode::Char('O') => {
268 self.input_panel_state
269 .textarea
270 .move_cursor(CursorMove::Head);
271 self.input_panel_state.insert_str("\n");
272 self.input_panel_state.textarea.move_cursor(CursorMove::Up);
273 self.set_mode(InputMode::VimInsert);
274 }
275
276 KeyCode::Char('x') => {
278 self.input_panel_state.textarea.delete_next_char();
279 self.sync_attachments_from_input_tokens();
280 }
281 KeyCode::Char('X') => {
282 self.input_panel_state.textarea.delete_char();
283 self.sync_attachments_from_input_tokens();
284 }
285 KeyCode::Char('D') => {
286 self.input_panel_state.textarea.delete_line_by_end();
287 self.sync_attachments_from_input_tokens();
288 }
289 KeyCode::Char('C') => {
290 self.input_panel_state.textarea.delete_line_by_end();
291 self.sync_attachments_from_input_tokens();
292 self.set_mode(InputMode::VimInsert);
293 }
294 KeyCode::Char('p') => {
295 self.input_panel_state.textarea.paste();
296 self.sync_attachments_from_input_tokens();
297 }
298 KeyCode::Char('u') => {
299 self.input_panel_state.textarea.undo();
300 self.sync_attachments_from_input_tokens();
301 }
302 KeyCode::Char('~') => {
303 let pos = self.input_panel_state.textarea.cursor();
304 let lines = self.input_panel_state.textarea.lines();
305 if let Some(line) = lines.get(pos.0)
306 && let Some(ch) = line.chars().nth(pos.1)
307 {
308 self.input_panel_state.textarea.delete_next_char();
309 let toggled = if ch.is_uppercase() {
310 ch.to_lowercase().to_string()
311 } else {
312 ch.to_uppercase().to_string()
313 };
314 self.input_panel_state.textarea.insert_str(&toggled);
315 self.sync_attachments_from_input_tokens();
316 }
317 }
318 KeyCode::Char('J') => {
319 self.input_panel_state.textarea.move_cursor(CursorMove::End);
320 let pos = self.input_panel_state.textarea.cursor();
321 let lines = self.input_panel_state.textarea.lines();
322 if pos.0 < lines.len() - 1 {
323 self.input_panel_state.textarea.delete_next_char();
324 self.input_panel_state.textarea.insert_char(' ');
325 self.sync_attachments_from_input_tokens();
326 }
327 }
328
329 KeyCode::Char('h') | KeyCode::Left => self
331 .input_panel_state
332 .textarea
333 .move_cursor(CursorMove::Back),
334 KeyCode::Char('l') | KeyCode::Right => self
335 .input_panel_state
336 .textarea
337 .move_cursor(CursorMove::Forward),
338 KeyCode::Char('j') | KeyCode::Down => {
339 self.chat_viewport.state_mut().scroll_down(1);
340 }
341 KeyCode::Char('k') | KeyCode::Up => {
342 self.chat_viewport.state_mut().scroll_up(1);
343 }
344 KeyCode::Char('w') => self
345 .input_panel_state
346 .textarea
347 .move_cursor(CursorMove::WordForward),
348 KeyCode::Char('b') => self
349 .input_panel_state
350 .textarea
351 .move_cursor(CursorMove::WordBack),
352 KeyCode::Char('0' | '^') => self
353 .input_panel_state
354 .textarea
355 .move_cursor(CursorMove::Head),
356 KeyCode::Char('$') => self.input_panel_state.textarea.move_cursor(CursorMove::End),
357 KeyCode::Char('G') => self.chat_viewport.state_mut().scroll_to_bottom(),
358 KeyCode::Char('g') => {
359 if self.vim_state.pending_g {
360 self.chat_viewport.state_mut().scroll_to_top();
361 }
362 self.vim_state.pending_g = !self.vim_state.pending_g;
363 should_clear_state = false;
364 }
365
366 KeyCode::Char('v') => {
368 self.input_panel_state.textarea.start_selection();
369 self.vim_state.visual_mode = true;
370 }
371 KeyCode::Char('V') => {
372 self.input_panel_state
373 .textarea
374 .move_cursor(CursorMove::Head);
375 self.input_panel_state.textarea.start_selection();
376 self.input_panel_state.textarea.move_cursor(CursorMove::End);
377 self.vim_state.visual_mode = true;
378 }
379
380 KeyCode::Char('r') => {
382 self.vim_state.replace_mode = true;
383 should_clear_state = false;
384 }
385 KeyCode::Char(ch) if self.vim_state.replace_mode => {
386 self.input_panel_state.textarea.delete_next_char();
387 self.input_panel_state.textarea.insert_char(ch);
388 self.sync_attachments_from_input_tokens();
389 self.vim_state.replace_mode = false;
390 }
391
392 KeyCode::Char('/') => {
393 self.input_panel_state.clear();
395 self.sync_attachments_from_input_tokens();
396 self.input_panel_state.insert_str("/");
397 self.input_panel_state.activate_command_fuzzy();
399 self.switch_mode(InputMode::FuzzyFinder);
400
401 let results: Vec<_> = self
403 .command_registry
404 .all_commands()
405 .into_iter()
406 .map(|cmd| {
407 crate::tui::widgets::fuzzy_finder::PickerItem::new(
408 cmd.name.clone(),
409 format!("/{} ", cmd.name),
410 )
411 })
412 .collect();
413 self.input_panel_state.fuzzy_finder.update_results(results);
414 }
415 KeyCode::Char('!') => {
416 self.input_panel_state.clear();
417 self.sync_attachments_from_input_tokens();
418 self.input_panel_state
419 .textarea
420 .set_placeholder_text("Enter bash command...");
421 self.switch_mode(InputMode::BashCommand);
422 }
423
424 _ => {
425 should_clear_state = false;
426 }
427 }
428
429 if should_clear_state {
430 self.vim_state.pending_g = false;
431 self.vim_state.pending_operator = None;
432 }
433
434 Ok(false)
435 }
436
437 async fn handle_vim_insert(&mut self, key: KeyEvent) -> Result<bool> {
438 if self.handle_text_manipulation(key)? {
440 return Ok(false);
441 }
442
443 match key.code {
444 KeyCode::Esc => {
445 if self.editing_message_id.is_some() {
446 self.cancel_edit_mode();
447 self.double_tap_tracker.clear_key(&KeyCode::Esc);
448 return Ok(false);
449 }
450 if self
452 .double_tap_tracker
453 .is_double_tap(KeyCode::Esc, Duration::from_millis(300))
454 {
455 self.input_panel_state.clear();
457 self.sync_attachments_from_input_tokens();
458 self.double_tap_tracker.clear_key(&KeyCode::Esc);
459 } else {
460 self.double_tap_tracker.record_key(KeyCode::Esc);
462 self.set_mode(InputMode::VimNormal);
463 self.input_panel_state
465 .textarea
466 .move_cursor(CursorMove::Back);
467 }
468 }
469
470 KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
471 if let Some(head) = &self.queued_head {
472 if let Err(e) = self.client.dequeue_queued_item().await {
473 self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
474 return Ok(false);
475 }
476
477 let content = match head.kind {
478 steer_grpc::client_api::QueuedWorkKind::DirectBash => {
479 format!("!{}", head.content)
480 }
481 _ => head.content.clone(),
482 };
483 self.input_panel_state.replace_content(&content, None);
484 self.sync_attachments_from_input_tokens();
485 }
486 }
487
488 KeyCode::Enter => {
489 let content = self.input_panel_state.content().trim().to_string();
490 if !self.has_pending_send_content() {
491 self.input_panel_state.handle_input(Input::from(key));
493 } else if !self.pending_attachments.is_empty() && content.starts_with('/') {
494 self.push_notice(
495 NoticeLevel::Warn,
496 "Image attachments are only supported for regular prompts.".to_string(),
497 );
498 return Ok(false);
499 } else if content.starts_with('!') && content.len() > 1 {
500 let command = content[1..].trim().to_string();
502 self.client.execute_bash_command(command).await?;
503 self.input_panel_state.clear();
504 self.pending_attachments.clear();
505 self.set_mode(InputMode::VimNormal);
506 } else if content.starts_with('/') {
507 let old_editing_mode = self.preferences.ui.editing_mode;
509 self.handle_slash_command(content).await?;
510 self.input_panel_state.clear();
511 self.sync_attachments_from_input_tokens();
512 self.pending_attachments.clear();
513 if self.input_mode == InputMode::VimInsert
516 && self.preferences.ui.editing_mode == old_editing_mode
517 {
518 self.set_mode(InputMode::VimNormal);
519 }
520 } else {
521 self.send_message(content).await?;
523 self.input_panel_state.clear();
524 self.sync_attachments_from_input_tokens();
525 self.pending_attachments.clear();
526 self.set_mode(InputMode::VimNormal);
527 }
528 }
529
530 KeyCode::Char('!') => {
531 let content = self.input_panel_state.content();
532 if content.is_empty() {
533 self.input_panel_state
535 .textarea
536 .set_placeholder_text("Enter bash command...");
537 self.switch_mode(InputMode::BashCommand);
538 } else {
539 self.input_panel_state.handle_input(Input::from(key));
541 self.sync_attachments_from_input_tokens();
542 }
543 }
544
545 KeyCode::Char('@') => {
546 self.input_panel_state.handle_input(Input::from(key));
548 self.sync_attachments_from_input_tokens();
549 self.input_panel_state.activate_fuzzy();
550 self.switch_mode(InputMode::FuzzyFinder);
551
552 let file_results = self
554 .input_panel_state
555 .file_cache()
556 .fuzzy_search("", Some(20))
557 .await;
558 let picker_items: Vec<_> = file_results
559 .into_iter()
560 .map(|path| {
561 crate::tui::widgets::fuzzy_finder::PickerItem::new(
562 path.clone(),
563 format!("@{path} "),
564 )
565 })
566 .collect();
567 self.input_panel_state
568 .fuzzy_finder
569 .update_results(picker_items);
570 }
571
572 KeyCode::Char('/') => {
573 let content = self.input_panel_state.content();
574 if content.is_empty() {
575 self.input_panel_state.handle_input(Input::from(key));
577 self.sync_attachments_from_input_tokens();
578 self.input_panel_state.activate_command_fuzzy();
579 self.switch_mode(InputMode::FuzzyFinder);
580
581 let results: Vec<_> = self
583 .command_registry
584 .all_commands()
585 .into_iter()
586 .map(|cmd| {
587 crate::tui::widgets::fuzzy_finder::PickerItem::new(
588 cmd.name.clone(),
589 format!("/{} ", cmd.name),
590 )
591 })
592 .collect();
593 self.input_panel_state.fuzzy_finder.update_results(results);
594 } else {
595 self.input_panel_state.handle_input(Input::from(key));
597 self.sync_attachments_from_input_tokens();
598 }
599 }
600
601 KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => {
603 self.set_mode(InputMode::VimNormal);
604 self.input_panel_state
606 .textarea
607 .move_cursor(CursorMove::Back);
608 }
609
610 _ => {
611 self.input_panel_state.handle_input(Input::from(key));
613 self.sync_attachments_from_input_tokens();
614 }
615 }
616 Ok(false)
617 }
618}