steer_tui/tui/handlers/
vim.rs

1use 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            // Other special modes that override vim
15            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, // Fallback
22        }
23    }
24
25    async fn handle_vim_normal(&mut self, key: KeyEvent) -> Result<bool> {
26        let mut should_clear_state = true;
27
28        // Handle modified keys first
29        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                        // Redo when operator pending
43                        self.input_panel_state.textarea.redo();
44                    } else {
45                        // Toggle view mode otherwise
46                        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        // Handle operator-pending mode
57        if let Some(operator) = self.vim_state.pending_operator {
58            let mut motion_handled = true;
59            match key.code {
60                // Motions
61                KeyCode::Char('w') => {
62                    if operator == VimOperator::Change {
63                        self.input_panel_state.textarea.delete_next_word();
64                        // Delete trailing whitespace
65                        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                        // Delete trailing whitespace
87                        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 => { /* Cancel operator */ }
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        // Normal mode commands
144        match key.code {
145            // ESC handling: double-tap clears / edits, single tap cancels or records
146            KeyCode::Esc => {
147                // Detect double ESC within 300 ms
148                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                        // Empty input => open edit-previous-message picker
154                        self.enter_edit_selection_mode();
155                    } else {
156                        // Otherwise just clear the buffer
157                        self.input_panel_state.clear();
158                    }
159                    // Clear tracker to avoid triple-tap behaviour
160                    self.double_tap_tracker.clear_key(&KeyCode::Esc);
161
162                    // Reset vim transient state
163                    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                // First Esc press – record and perform normal cancel behaviour
170                self.double_tap_tracker.record_key(KeyCode::Esc);
171
172                if self.vim_state.visual_mode {
173                    self.vim_state.visual_mode = false;
174                    // Cancel selection
175                    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            // New operators
191            KeyCode::Char('d') => {
192                if self.vim_state.pending_operator == Some(VimOperator::Delete) {
193                    // dd - delete line
194                    self.input_panel_state.clear();
195                    self.vim_state.pending_operator = None;
196                } else {
197                    self.vim_state.pending_operator = Some(VimOperator::Delete);
198                    // Keep operator pending until motion key arrives
199                    should_clear_state = false;
200                }
201            }
202            KeyCode::Char('c') => {
203                if self.vim_state.pending_operator == Some(VimOperator::Change) {
204                    // cc - change line
205                    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                    // yy - yank line
216                    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            // Mode changes
225            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            // Text manipulation
257            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            // Movement
302            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            // Visual mode
335            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            // Replace mode
349            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            // Other commands
360            KeyCode::Char('e') => self.enter_edit_selection_mode(),
361            KeyCode::Char('/') => {
362                // Switch to command mode with fuzzy finder like Simple/VimInsert modes
363                self.input_panel_state.clear();
364                self.input_panel_state.insert_str("/");
365                // Activate command fuzzy finder immediately
366                self.input_panel_state.activate_command_fuzzy();
367                self.switch_mode(InputMode::FuzzyFinder);
368
369                // Populate results with all commands initially
370                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        // Try common text manipulation first
406        if self.handle_text_manipulation(key)? {
407            return Ok(false);
408        }
409
410        match key.code {
411            KeyCode::Esc => {
412                // Check for double-tap to clear
413                if self
414                    .double_tap_tracker
415                    .is_double_tap(KeyCode::Esc, Duration::from_millis(300))
416                {
417                    // Double ESC - clear content
418                    self.input_panel_state.clear();
419                    self.double_tap_tracker.clear_key(&KeyCode::Esc);
420                } else {
421                    // Single ESC - return to normal mode
422                    self.double_tap_tracker.record_key(KeyCode::Esc);
423                    self.set_mode(InputMode::VimNormal);
424                    // Move cursor back one position (vim behavior)
425                    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                        // Execute as bash command
436                        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                        // Handle as slash command
444                        let old_editing_mode = self.preferences.ui.editing_mode;
445                        self.handle_slash_command(content).await?;
446                        self.input_panel_state.clear();
447                        // Return to VimNormal only if we're *still* in VimInsert and the
448                        // editing mode hasn’t changed (e.g. not switched into Setup).
449                        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                        // Send as normal message
456                        self.send_message(content).await?;
457                        self.input_panel_state.clear();
458                        self.set_mode(InputMode::VimNormal);
459                    }
460                } else {
461                    // Just insert a newline if empty
462                    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                    // First character - enter bash command mode without inserting '!'
470                    self.input_panel_state
471                        .textarea
472                        .set_placeholder_text("Enter bash command...");
473                    self.switch_mode(InputMode::BashCommand);
474                } else {
475                    // Normal ! character
476                    self.input_panel_state.handle_input(Input::from(key));
477                }
478            }
479
480            KeyCode::Char('@') => {
481                // Activate file fuzzy finder
482                self.input_panel_state.handle_input(Input::from(key));
483                self.input_panel_state.activate_fuzzy();
484                self.switch_mode(InputMode::FuzzyFinder);
485
486                // Immediately show all files (limited to 20)
487                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                    // Activate command fuzzy finder
510                    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                    // Immediately show all commands
515                    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                    // Normal / character
529                    self.input_panel_state.handle_input(Input::from(key));
530                }
531            }
532
533            // Quick escape to normal mode
534            KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => {
535                self.set_mode(InputMode::VimNormal);
536                // Move cursor back one position (vim behavior)
537                self.input_panel_state
538                    .textarea
539                    .move_cursor(CursorMove::Back);
540            }
541
542            _ => {
543                // Normal text input
544                self.input_panel_state.handle_input(Input::from(key));
545            }
546        }
547        Ok(false)
548    }
549}