Skip to main content

steer_tui/tui/handlers/
vim.rs

1use 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            // 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.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                        // Redo when operator pending
41                        self.input_panel_state.textarea.redo();
42                        self.sync_attachments_from_input_tokens();
43                    } else {
44                        // Toggle view mode otherwise
45                        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        // Handle operator-pending mode
61        if let Some(operator) = self.vim_state.pending_operator {
62            let mut motion_handled = true;
63            match key.code {
64                // Motions
65                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                        // Delete trailing whitespace
70                        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                        // Delete trailing whitespace
93                        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 => { /* Cancel operator */ }
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        // Normal mode commands
156        match key.code {
157            // ESC handling: double-tap clears / edits, single tap cancels or records
158            KeyCode::Esc => {
159                // Detect double ESC within 300 ms
160                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                        // Empty input => open edit-previous-message picker
166                        self.enter_edit_selection_mode();
167                    } else {
168                        // Otherwise just clear the buffer
169                        self.input_panel_state.clear();
170                        self.sync_attachments_from_input_tokens();
171                    }
172                    // Clear tracker to avoid triple-tap behaviour
173                    self.double_tap_tracker.clear_key(&KeyCode::Esc);
174
175                    // Reset vim transient state
176                    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                // First Esc press – perform normal cancel behaviour. If this
183                // cancel will pop queued work, avoid arming double-tap so we
184                // don't immediately clear the restored input.
185                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                    // Cancel selection
195                    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            // New operators
209            KeyCode::Char('d') => {
210                if self.vim_state.pending_operator == Some(VimOperator::Delete) {
211                    // dd - delete line
212                    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                    // Keep operator pending until motion key arrives
218                    should_clear_state = false;
219                }
220            }
221            KeyCode::Char('c') => {
222                if self.vim_state.pending_operator == Some(VimOperator::Change) {
223                    // cc - change line
224                    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                    // yy - yank line
236                    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            // Mode changes
245            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            // Text manipulation
277            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            // Movement
330            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            // Visual mode
367            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            // Replace mode
381            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                // Switch to command mode with fuzzy finder like Simple/VimInsert modes
394                self.input_panel_state.clear();
395                self.sync_attachments_from_input_tokens();
396                self.input_panel_state.insert_str("/");
397                // Activate command fuzzy finder immediately
398                self.input_panel_state.activate_command_fuzzy();
399                self.switch_mode(InputMode::FuzzyFinder);
400
401                // Populate results with all commands initially
402                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        // Try common text manipulation first
439        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                // Check for double-tap to clear
451                if self
452                    .double_tap_tracker
453                    .is_double_tap(KeyCode::Esc, Duration::from_millis(300))
454                {
455                    // Double ESC - clear content
456                    self.input_panel_state.clear();
457                    self.sync_attachments_from_input_tokens();
458                    self.double_tap_tracker.clear_key(&KeyCode::Esc);
459                } else {
460                    // Single ESC - return to normal mode
461                    self.double_tap_tracker.record_key(KeyCode::Esc);
462                    self.set_mode(InputMode::VimNormal);
463                    // Move cursor back one position (vim behavior)
464                    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                    // Just insert a newline if empty
492                    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                    // Execute as bash command
501                    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                    // Handle as slash command
508                    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                    // Return to VimNormal only if we're *still* in VimInsert and the
514                    // editing mode hasn’t changed (e.g. not switched into Setup).
515                    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                    // Send as normal message
522                    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                    // First character - enter bash command mode without inserting '!'
534                    self.input_panel_state
535                        .textarea
536                        .set_placeholder_text("Enter bash command...");
537                    self.switch_mode(InputMode::BashCommand);
538                } else {
539                    // Normal ! character
540                    self.input_panel_state.handle_input(Input::from(key));
541                    self.sync_attachments_from_input_tokens();
542                }
543            }
544
545            KeyCode::Char('@') => {
546                // Activate file fuzzy finder
547                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                // Immediately show all files (limited to 20)
553                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                    // Activate command fuzzy finder
576                    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                    // Immediately show all commands
582                    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                    // Normal / character
596                    self.input_panel_state.handle_input(Input::from(key));
597                    self.sync_attachments_from_input_tokens();
598                }
599            }
600
601            // Quick escape to normal mode
602            KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => {
603                self.set_mode(InputMode::VimNormal);
604                // Move cursor back one position (vim behavior)
605                self.input_panel_state
606                    .textarea
607                    .move_cursor(CursorMove::Back);
608            }
609
610            _ => {
611                // Normal text input
612                self.input_panel_state.handle_input(Input::from(key));
613                self.sync_attachments_from_input_tokens();
614            }
615        }
616        Ok(false)
617    }
618}