Skip to main content

dot/tui/
input.rs

1use std::time::{Duration, Instant};
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
4
5use crate::tui::app::{App, AppMode};
6use crate::tui::widgets::ThinkingLevel;
7
8pub enum InputAction {
9    None,
10    SendMessage(String),
11    Quit,
12    CancelStream,
13    ScrollUp(u16),
14    ScrollDown(u16),
15    ScrollToTop,
16    ScrollToBottom,
17    ClearConversation,
18    NewConversation,
19    OpenModelSelector,
20    OpenAgentSelector,
21    OpenThinkingSelector,
22    OpenSessionSelector,
23    SelectModel { provider: String, model: String },
24    SelectAgent { name: String },
25    ResumeSession { id: String },
26    SetThinkingLevel(u32),
27    ToggleThinking,
28}
29
30pub fn handle_paste(app: &mut App, text: String) -> InputAction {
31    if app.vim_mode && app.mode != AppMode::Insert {
32        return InputAction::None;
33    }
34    if app.is_streaming {
35        return InputAction::None;
36    }
37
38    let trimmed = text.trim_end_matches('\n').to_string();
39    if trimmed.is_empty() {
40        return InputAction::None;
41    }
42
43    if crate::tui::app::is_image_path(trimmed.trim()) {
44        let path = trimmed.trim().trim_matches('"').trim_matches('\'');
45        match app.add_image_attachment(path) {
46            Ok(()) => {}
47            Err(e) => app.error_message = Some(e),
48        }
49        return InputAction::None;
50    }
51
52    app.handle_paste(trimmed);
53    InputAction::None
54}
55
56pub fn handle_key(app: &mut App, key: KeyEvent) -> InputAction {
57    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
58        if app.model_selector.visible {
59            app.model_selector.close();
60            return InputAction::None;
61        }
62        if app.agent_selector.visible {
63            app.agent_selector.close();
64            return InputAction::None;
65        }
66        if app.command_palette.visible {
67            app.command_palette.close();
68            return InputAction::None;
69        }
70        if app.thinking_selector.visible {
71            app.thinking_selector.close();
72            return InputAction::None;
73        }
74        if app.session_selector.visible {
75            app.session_selector.close();
76            return InputAction::None;
77        }
78        if app.help_popup.visible {
79            app.help_popup.close();
80            return InputAction::None;
81        }
82        if app.is_streaming {
83            return InputAction::CancelStream;
84        }
85        if !app.input.is_empty() || !app.attachments.is_empty() {
86            app.input.clear();
87            app.cursor_pos = 0;
88            app.paste_blocks.clear();
89            app.attachments.clear();
90            return InputAction::None;
91        }
92        return InputAction::Quit;
93    }
94
95    if key.code == KeyCode::Esc && app.is_streaming {
96        let now = Instant::now();
97        let is_double = app
98            .last_escape_time
99            .map(|t| t.elapsed() < Duration::from_millis(500))
100            .unwrap_or(false);
101        app.last_escape_time = if is_double { None } else { Some(now) };
102        if is_double {
103            return InputAction::CancelStream;
104        }
105    }
106
107    if app.model_selector.visible {
108        return handle_model_selector(app, key);
109    }
110
111    if app.agent_selector.visible {
112        return handle_agent_selector(app, key);
113    }
114
115    if app.thinking_selector.visible {
116        return handle_thinking_selector(app, key);
117    }
118
119    if app.session_selector.visible {
120        return handle_session_selector(app, key);
121    }
122
123    if app.help_popup.visible {
124        if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
125            app.help_popup.close();
126        }
127        return InputAction::None;
128    }
129
130    if app.command_palette.visible {
131        return handle_command_palette(app, key);
132    }
133
134    if app.vim_mode {
135        match app.mode {
136            AppMode::Normal => handle_normal(app, key),
137            AppMode::Insert => handle_insert(app, key),
138        }
139    } else {
140        handle_simple(app, key)
141    }
142}
143
144fn handle_model_selector(app: &mut App, key: KeyEvent) -> InputAction {
145    match key.code {
146        KeyCode::Esc => {
147            app.model_selector.close();
148            InputAction::None
149        }
150        KeyCode::Up => {
151            app.model_selector.up();
152            InputAction::None
153        }
154        KeyCode::Down | KeyCode::Tab => {
155            app.model_selector.down();
156            InputAction::None
157        }
158        KeyCode::Enter => {
159            if let Some(entry) = app.model_selector.confirm() {
160                app.model_name = entry.model.clone();
161                app.provider_name = entry.provider.clone();
162                InputAction::SelectModel {
163                    provider: entry.provider,
164                    model: entry.model,
165                }
166            } else {
167                InputAction::None
168            }
169        }
170        KeyCode::Backspace => {
171            app.model_selector.query.pop();
172            app.model_selector.apply_filter();
173            InputAction::None
174        }
175        KeyCode::Char(c) => {
176            app.model_selector.query.push(c);
177            app.model_selector.apply_filter();
178            InputAction::None
179        }
180        _ => InputAction::None,
181    }
182}
183
184fn handle_agent_selector(app: &mut App, key: KeyEvent) -> InputAction {
185    match key.code {
186        KeyCode::Esc => {
187            app.agent_selector.close();
188            InputAction::None
189        }
190        KeyCode::Up => {
191            app.agent_selector.up();
192            InputAction::None
193        }
194        KeyCode::Down | KeyCode::Tab => {
195            app.agent_selector.down();
196            InputAction::None
197        }
198        KeyCode::Enter => {
199            if let Some(entry) = app.agent_selector.confirm() {
200                app.agent_name = entry.name.clone();
201                InputAction::SelectAgent { name: entry.name }
202            } else {
203                InputAction::None
204            }
205        }
206        _ => InputAction::None,
207    }
208}
209
210fn handle_thinking_selector(app: &mut App, key: KeyEvent) -> InputAction {
211    match key.code {
212        KeyCode::Esc => {
213            app.thinking_selector.close();
214            InputAction::None
215        }
216        KeyCode::Up => {
217            app.thinking_selector.up();
218            InputAction::None
219        }
220        KeyCode::Down | KeyCode::Tab => {
221            app.thinking_selector.down();
222            InputAction::None
223        }
224        KeyCode::Enter => {
225            if let Some(level) = app.thinking_selector.confirm() {
226                let budget = level.budget_tokens();
227                app.thinking_budget = budget;
228                InputAction::SetThinkingLevel(budget)
229            } else {
230                InputAction::None
231            }
232        }
233        _ => InputAction::None,
234    }
235}
236
237fn handle_session_selector(app: &mut App, key: KeyEvent) -> InputAction {
238    match key.code {
239        KeyCode::Esc => {
240            app.session_selector.close();
241            InputAction::None
242        }
243        KeyCode::Up => {
244            app.session_selector.up();
245            InputAction::None
246        }
247        KeyCode::Down | KeyCode::Tab => {
248            app.session_selector.down();
249            InputAction::None
250        }
251        KeyCode::Enter => {
252            if let Some(id) = app.session_selector.confirm() {
253                InputAction::ResumeSession { id }
254            } else {
255                InputAction::None
256            }
257        }
258        KeyCode::Backspace => {
259            app.session_selector.query.pop();
260            app.session_selector.apply_filter();
261            InputAction::None
262        }
263        KeyCode::Char(c) => {
264            app.session_selector.query.push(c);
265            app.session_selector.apply_filter();
266            InputAction::None
267        }
268        _ => InputAction::None,
269    }
270}
271
272fn handle_command_palette(app: &mut App, key: KeyEvent) -> InputAction {
273    match key.code {
274        KeyCode::Esc => {
275            app.command_palette.close();
276            InputAction::None
277        }
278        KeyCode::Up => {
279            app.command_palette.up();
280            InputAction::None
281        }
282        KeyCode::Down | KeyCode::Tab => {
283            app.command_palette.down();
284            InputAction::None
285        }
286        KeyCode::Enter => {
287            if let Some(cmd_name) = app.command_palette.confirm() {
288                app.input.clear();
289                app.cursor_pos = 0;
290                execute_command(app, cmd_name)
291            } else {
292                InputAction::None
293            }
294        }
295        KeyCode::Backspace => {
296            app.delete_char_before();
297            if app.input.is_empty() || !app.input.starts_with('/') {
298                app.command_palette.close();
299            } else {
300                app.command_palette.update_filter(&app.input);
301            }
302            InputAction::None
303        }
304        KeyCode::Char(c) => {
305            app.insert_char(c);
306            app.command_palette.update_filter(&app.input);
307            if app.command_palette.filtered.is_empty() {
308                app.command_palette.close();
309            }
310            InputAction::None
311        }
312        _ => InputAction::None,
313    }
314}
315
316fn execute_command(app: &mut App, cmd_name: &str) -> InputAction {
317    match cmd_name {
318        "model" => InputAction::OpenModelSelector,
319        "agent" => InputAction::OpenAgentSelector,
320        "thinking" => InputAction::OpenThinkingSelector,
321        "sessions" => InputAction::OpenSessionSelector,
322        "new" => InputAction::NewConversation,
323        "clear" => {
324            app.clear_conversation();
325            InputAction::None
326        }
327        "help" => {
328            app.help_popup.open();
329            InputAction::None
330        }
331        _ => InputAction::None,
332    }
333}
334
335fn handle_normal(app: &mut App, key: KeyEvent) -> InputAction {
336    match key.code {
337        KeyCode::Char('q') => InputAction::Quit,
338        KeyCode::Char('i') | KeyCode::Enter => {
339            app.mode = AppMode::Insert;
340            InputAction::None
341        }
342        KeyCode::Char('j') | KeyCode::Down => InputAction::ScrollDown(1),
343        KeyCode::Char('k') | KeyCode::Up => InputAction::ScrollUp(1),
344        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
345            InputAction::ScrollDown(10)
346        }
347        KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
348            InputAction::ScrollUp(10)
349        }
350        KeyCode::Char('g') => InputAction::ScrollToTop,
351        KeyCode::Char('G') => InputAction::ScrollToBottom,
352        KeyCode::PageUp => InputAction::ScrollUp(20),
353        KeyCode::PageDown => InputAction::ScrollDown(20),
354        KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
355            InputAction::ClearConversation
356        }
357        KeyCode::Tab => InputAction::OpenAgentSelector,
358        KeyCode::Char('t') => InputAction::ToggleThinking,
359        _ => InputAction::None,
360    }
361}
362
363fn handle_insert(app: &mut App, key: KeyEvent) -> InputAction {
364    if key.modifiers.contains(KeyModifiers::CONTROL) {
365        match key.code {
366            KeyCode::Char('t') => return InputAction::OpenThinkingSelector,
367            KeyCode::Char('a') => {
368                app.move_cursor_home();
369                return InputAction::None;
370            }
371            KeyCode::Char('e') => {
372                app.move_cursor_end();
373                return InputAction::None;
374            }
375            KeyCode::Char('w') => {
376                app.delete_word_before();
377                return InputAction::None;
378            }
379            KeyCode::Char('k') => {
380                app.delete_to_end();
381                return InputAction::None;
382            }
383            KeyCode::Char('u') => {
384                app.delete_to_start();
385                return InputAction::None;
386            }
387            _ => {}
388        }
389    }
390
391    if app.is_streaming {
392        if key.code == KeyCode::Esc {
393            app.mode = AppMode::Normal;
394        }
395        return InputAction::None;
396    }
397
398    match key.code {
399        KeyCode::Esc => {
400            app.mode = AppMode::Normal;
401            InputAction::None
402        }
403        KeyCode::Enter => handle_send(app),
404        KeyCode::Char(c) => handle_char_input(app, c),
405        KeyCode::Backspace => handle_backspace(app),
406        KeyCode::Left => {
407            app.move_cursor_left();
408            InputAction::None
409        }
410        KeyCode::Right => {
411            app.move_cursor_right();
412            InputAction::None
413        }
414        KeyCode::Home => {
415            app.move_cursor_home();
416            InputAction::None
417        }
418        KeyCode::End => {
419            app.move_cursor_end();
420            InputAction::None
421        }
422        _ => InputAction::None,
423    }
424}
425
426fn handle_simple(app: &mut App, key: KeyEvent) -> InputAction {
427    if key.modifiers.contains(KeyModifiers::CONTROL) {
428        match key.code {
429            KeyCode::Char('t') => return InputAction::OpenThinkingSelector,
430            KeyCode::Char('a') => {
431                app.move_cursor_home();
432                return InputAction::None;
433            }
434            KeyCode::Char('e') => {
435                app.move_cursor_end();
436                return InputAction::None;
437            }
438            KeyCode::Char('w') => {
439                app.delete_word_before();
440                return InputAction::None;
441            }
442            KeyCode::Char('k') => {
443                app.delete_to_end();
444                return InputAction::None;
445            }
446            KeyCode::Char('u') => {
447                app.delete_to_start();
448                return InputAction::None;
449            }
450            KeyCode::Char('d') => return InputAction::ScrollDown(10),
451            _ => {}
452        }
453    }
454
455    if app.is_streaming {
456        return match key.code {
457            KeyCode::Up => InputAction::ScrollUp(1),
458            KeyCode::Down => InputAction::ScrollDown(1),
459            KeyCode::PageUp => InputAction::ScrollUp(20),
460            KeyCode::PageDown => InputAction::ScrollDown(20),
461            _ => InputAction::None,
462        };
463    }
464
465    match key.code {
466        KeyCode::Esc => InputAction::None,
467        KeyCode::Enter => handle_send(app),
468        KeyCode::Up => InputAction::ScrollUp(1),
469        KeyCode::Down => InputAction::ScrollDown(1),
470        KeyCode::PageUp => InputAction::ScrollUp(20),
471        KeyCode::PageDown => InputAction::ScrollDown(20),
472        KeyCode::Tab => InputAction::OpenAgentSelector,
473        KeyCode::Char(c) => handle_char_input(app, c),
474        KeyCode::Backspace => handle_backspace(app),
475        KeyCode::Left => {
476            app.move_cursor_left();
477            InputAction::None
478        }
479        KeyCode::Right => {
480            app.move_cursor_right();
481            InputAction::None
482        }
483        KeyCode::Home => {
484            app.move_cursor_home();
485            InputAction::None
486        }
487        KeyCode::End => {
488            app.move_cursor_end();
489            InputAction::None
490        }
491        _ => InputAction::None,
492    }
493}
494
495fn handle_send(app: &mut App) -> InputAction {
496    parse_at_references(app);
497    if let Some(msg) = app.take_input() {
498        InputAction::SendMessage(msg)
499    } else {
500        InputAction::None
501    }
502}
503
504fn handle_char_input(app: &mut App, c: char) -> InputAction {
505    app.insert_char(c);
506    if app.input == "/" {
507        app.command_palette.open(&app.input);
508    } else if app.input.starts_with('/') && app.command_palette.visible {
509        app.command_palette.update_filter(&app.input);
510        if app.command_palette.filtered.is_empty() {
511            app.command_palette.close();
512        }
513    }
514    InputAction::None
515}
516
517fn handle_backspace(app: &mut App) -> InputAction {
518    if let Some(pb_idx) = app.paste_block_at_cursor() {
519        app.delete_paste_block(pb_idx);
520    } else {
521        app.delete_char_before();
522    }
523    if app.input.starts_with('/') && !app.input.is_empty() {
524        if !app.command_palette.visible {
525            app.command_palette.open(&app.input);
526        } else {
527            app.command_palette.update_filter(&app.input);
528        }
529    } else if app.command_palette.visible {
530        app.command_palette.close();
531    }
532    InputAction::None
533}
534
535fn rect_contains(r: ratatui::layout::Rect, col: u16, row: u16) -> bool {
536    col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
537}
538
539pub fn handle_mouse(app: &mut App, mouse: MouseEvent) -> InputAction {
540    let col = mouse.column;
541    let row = mouse.row;
542
543    match mouse.kind {
544        MouseEventKind::ScrollUp => {
545            if app.model_selector.visible
546                && let Some(popup) = app.layout.model_selector
547                && rect_contains(popup, col, row)
548            {
549                app.model_selector.up();
550                return InputAction::None;
551            }
552            InputAction::ScrollUp(3)
553        }
554        MouseEventKind::ScrollDown => {
555            if app.model_selector.visible
556                && let Some(popup) = app.layout.model_selector
557                && rect_contains(popup, col, row)
558            {
559                app.model_selector.down();
560                return InputAction::None;
561            }
562            InputAction::ScrollDown(3)
563        }
564        MouseEventKind::Down(MouseButton::Left) => {
565            if app.model_selector.visible
566                && let Some(popup) = app.layout.model_selector
567            {
568                if !rect_contains(popup, col, row) {
569                    app.model_selector.close();
570                }
571                return InputAction::None;
572            }
573
574            if app.agent_selector.visible
575                && let Some(popup) = app.layout.agent_selector
576            {
577                if !rect_contains(popup, col, row) {
578                    app.agent_selector.close();
579                }
580                return InputAction::None;
581            }
582
583            if app.help_popup.visible
584                && let Some(popup) = app.layout.help_popup
585            {
586                if !rect_contains(popup, col, row) {
587                    app.help_popup.close();
588                }
589                return InputAction::None;
590            }
591
592            if app.thinking_selector.visible
593                && let Some(popup) = app.layout.thinking_selector
594                && rect_contains(popup, col, row)
595            {
596                let relative_row = row.saturating_sub(popup.y + 1) as usize;
597                if relative_row < ThinkingLevel::all().len() {
598                    app.thinking_selector.selected = relative_row;
599                    if let Some(level) = app.thinking_selector.confirm() {
600                        let budget = level.budget_tokens();
601                        app.thinking_budget = budget;
602                        return InputAction::SetThinkingLevel(budget);
603                    }
604                }
605            } else if app.thinking_selector.visible
606                && let Some(popup) = app.layout.thinking_selector
607            {
608                if !rect_contains(popup, col, row) {
609                    app.thinking_selector.close();
610                }
611                return InputAction::None;
612            }
613
614            if app.session_selector.visible
615                && let Some(popup) = app.layout.session_selector
616                && !rect_contains(popup, col, row)
617            {
618                app.session_selector.close();
619                return InputAction::None;
620            }
621
622            if app.command_palette.visible
623                && let Some(popup) = app.layout.command_palette
624            {
625                if rect_contains(popup, col, row) {
626                    let relative_row = row.saturating_sub(popup.y) as usize;
627                    if relative_row < app.command_palette.filtered.len() {
628                        app.command_palette.selected = relative_row;
629                        if let Some(cmd_name) = app.command_palette.confirm() {
630                            app.input.clear();
631                            app.cursor_pos = 0;
632                            return execute_command(app, cmd_name);
633                        }
634                    }
635                    return InputAction::None;
636                } else {
637                    app.command_palette.close();
638                    return InputAction::None;
639                }
640            }
641
642            if rect_contains(app.layout.input, col, row) {
643                if app.vim_mode {
644                    app.mode = AppMode::Insert;
645                }
646                let inner_x = col.saturating_sub(app.layout.input.x + 3);
647                let inner_y = row.saturating_sub(app.layout.input.y + 1);
648                let target_offset =
649                    compute_click_cursor_pos(&app.input, inner_x as usize, inner_y as usize);
650                app.cursor_pos = target_offset;
651                InputAction::None
652            } else if rect_contains(app.layout.messages, col, row) {
653                if app.vim_mode && app.mode == AppMode::Insert && app.input.is_empty() {
654                    app.mode = AppMode::Normal;
655                }
656                InputAction::None
657            } else {
658                InputAction::None
659            }
660        }
661        _ => InputAction::None,
662    }
663}
664
665fn parse_at_references(app: &mut App) {
666    let words: Vec<String> = app.input.split_whitespace().map(String::from).collect();
667    for word in &words {
668        if let Some(path) = word.strip_prefix('@')
669            && !path.is_empty()
670            && crate::tui::app::is_image_path(path)
671        {
672            match app.add_image_attachment(path) {
673                Ok(()) => {}
674                Err(e) => {
675                    app.error_message = Some(e);
676                }
677            }
678        }
679    }
680}
681
682fn compute_click_cursor_pos(input: &str, target_col: usize, target_row: usize) -> usize {
683    let mut row: usize = 0;
684    let mut col: usize = 0;
685    let mut byte_pos: usize = 0;
686
687    for ch in input.chars() {
688        if row == target_row && col >= target_col {
689            return byte_pos;
690        }
691        if ch == '\n' {
692            if row == target_row {
693                return byte_pos;
694            }
695            row += 1;
696            col = 0;
697        } else {
698            col += 1;
699        }
700        byte_pos += ch.len_utf8();
701    }
702
703    byte_pos
704}