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}