Skip to main content

stynx_code_tui/event/
event_handler.rs

1use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind};
2
3use crate::dialogs::{open_command_palette, open_file_mention, open_model_picker, open_session_list};
4use crate::state::{
5    AppState, InputMode, ModalKind, PermissionChoice, SelectKind, filter_options,
6};
7
8#[derive(Debug, Clone)]
9pub enum UiAction {
10    Submit(String),
11    CyclePermissionMode,
12    Quit,
13    SelectModel(String),
14    SelectSession(String),
15    PermissionDecision(PermissionChoice),
16    RunCommand(String),
17    ToggleSidebar,
18    InputConfirmed { kind: crate::state::InputKind, value: String },
19    Interrupt,
20    None,
21}
22
23const COMMANDS: &[(&str, &str)] = &[
24    ("/add",         "Pin a file path to every message"),
25    ("/commit",      "Generate a commit message from the diff"),
26    ("/compact",     "Compact context to save tokens"),
27    ("/config",      "Show merged config"),
28    ("/copy",        "Copy last response to clipboard"),
29    ("/diff",        "Show git diff"),
30    ("/effort",      "Set effort: low/medium/high/max"),
31    ("/exit",        "Exit session"),
32    ("/export",      "Export conversation to markdown"),
33    ("/fast",        "Toggle fast mode (haiku)"),
34    ("/files",       "List pinned files"),
35    ("/help",        "Show help"),
36    ("/intern",      "Delegate task to intern (DeepSeek)"),
37    ("/memory",      "Show CLAUDE.md"),
38    ("/mode",        "Cycle permission mode"),
39    ("/model",       "Switch model"),
40    ("/permissions", "Show allow / deny rules"),
41    ("/plan",        "Toggle plan mode"),
42    ("/quit",        "Exit session"),
43    ("/review",      "Review current git diff"),
44    ("/rewind",      "Remove last n exchanges"),
45    ("/skills",      "List available skills"),
46    ("/status",      "Show git status"),
47    ("/think",       "Toggle extended thinking"),
48    ("/usage",       "Show plan usage limits"),
49    ("/version",     "Show version"),
50];
51
52fn handle_ctrl_v(state: &mut AppState) {
53    use crate::clipboard::{PasteOutcome, read_clipboard};
54    match read_clipboard() {
55        PasteOutcome::Image { path } => {
56            let idx = state.input.insert_image_paste(path);
57            state.input.insert_char(' ');
58            state.toasts.success(format!("attached image #{idx}"));
59        }
60        PasteOutcome::Text(text) => {
61            if text.len() > 200 || text.matches('\n').count() > 4 {
62                let lines = text.lines().count().max(1);
63                let chars = text.chars().count();
64                let token = format!("[Pasted {lines} lines, {chars} chars]");
65                for c in token.chars() { state.input.insert_char(c); }
66                state.input.pasted_buffer = Some(text);
67            } else {
68                for c in text.chars() { state.input.insert_char(c); }
69            }
70        }
71        PasteOutcome::Empty => {
72            state.toasts.warn("clipboard empty");
73        }
74    }
75}
76
77fn update_suggestion(state: &mut AppState) {
78    let buf = state.input.buffer.clone();
79    if buf.starts_with('/') && !buf.contains(' ') {
80        let matches: Vec<(String, String)> = COMMANDS
81            .iter()
82            .filter(|(c, _)| c.starts_with(buf.as_str()) && *c != buf.as_str())
83            .map(|(c, d)| ((*c).to_string(), (*d).to_string()))
84            .collect();
85        state.input.suggestion = if matches.len() == 1 {
86            matches[0].0[buf.len()..].to_string()
87        } else {
88            String::new()
89        };
90        state.input.slash_matches = matches;
91        if state.input.slash_selected >= state.input.slash_matches.len() {
92            state.input.slash_selected = 0;
93        }
94    } else {
95        state.input.suggestion = String::new();
96        state.input.slash_matches.clear();
97        state.input.slash_selected = 0;
98    }
99}
100
101fn scroll_up(state: &mut AppState, n: usize) {
102    state.conversation.auto_scroll = false;
103    state.conversation.scroll_offset = state.conversation.scroll_offset.saturating_sub(n);
104}
105
106fn scroll_down(state: &mut AppState, n: usize) {
107    let next = state.conversation.scroll_offset.saturating_add(n);
108    if next >= state.conversation.total_lines {
109        state.conversation.auto_scroll = true;
110    } else {
111        state.conversation.scroll_offset = next;
112    }
113}
114
115fn dispatch_palette(state: &mut AppState, command: &str) -> UiAction {
116    match command {
117        "session.list" => { open_session_list(state); UiAction::None }
118        "session.new" => UiAction::RunCommand("session.new".into()),
119        "session.compact" => UiAction::RunCommand("session.compact".into()),
120        "session.export" => UiAction::RunCommand("session.export".into()),
121        "session.rename" => UiAction::RunCommand("session.rename".into()),
122        "status.show" => UiAction::RunCommand("status.show".into()),
123        "skills.show" => UiAction::RunCommand("skills.show".into()),
124        "model.list" => { open_model_picker(state); UiAction::None }
125        "model.cycle_recent" => UiAction::RunCommand("model.cycle_recent".into()),
126        "mode.cycle" => UiAction::CyclePermissionMode,
127        "sidebar.toggle" => UiAction::ToggleSidebar,
128        "theme.switch" => { crate::dialogs::open_theme_picker(state); UiAction::None }
129        "help.show" => UiAction::RunCommand("help.show".into()),
130        "app.quit" => UiAction::Quit,
131        _ => UiAction::None,
132    }
133}
134
135pub struct EventHandler;
136
137impl EventHandler {
138    pub fn new() -> Self { Self }
139
140    pub fn handle(event: Event, state: &mut AppState) -> UiAction {
141        match event {
142            Event::Mouse(m) => {
143                match m.kind {
144                    MouseEventKind::ScrollUp => scroll_up(state, 3),
145                    MouseEventKind::ScrollDown => scroll_down(state, 3),
146                    _ => {}
147                }
148                return UiAction::None;
149            }
150            Event::Paste(text) => {
151                if text.len() > 200 || text.matches('\n').count() > 4 {
152                    let lines = text.lines().count().max(1);
153                    let chars = text.chars().count();
154                    let token = format!("[Pasted {lines} lines, {chars} chars]");
155                    for c in token.chars() {
156                        state.input.insert_char(c);
157                    }
158                    state.input.pasted_buffer = Some(text);
159                } else {
160                    for c in text.chars() {
161                        state.input.insert_char(c);
162                    }
163                }
164                return UiAction::None;
165            }
166            Event::Key(key) => {
167                if key.kind != KeyEventKind::Press { return UiAction::None; }
168                if state.modal.active.is_some() { return Self::modal_key(key, state); }
169                if state.input.mode == InputMode::Normal {
170                    return Self::normal_mode_key(key, state);
171                }
172                Self::insert_mode_key(key, state)
173            }
174            _ => UiAction::None,
175        }
176    }
177
178    fn insert_mode_key(key: KeyEvent, state: &mut AppState) -> UiAction {
179        match (key.code, key.modifiers) {
180            (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
181                state.modal.open_quit_confirm();
182                UiAction::None
183            }
184            (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
185                open_command_palette(state);
186                UiAction::None
187            }
188            (KeyCode::Char('s'), KeyModifiers::CONTROL) => {
189                open_session_list(state);
190                UiAction::None
191            }
192            (KeyCode::Char('m'), KeyModifiers::CONTROL) => {
193                open_model_picker(state);
194                UiAction::None
195            }
196            (KeyCode::Char('b'), KeyModifiers::CONTROL) => UiAction::ToggleSidebar,
197            (KeyCode::Char('v'), KeyModifiers::CONTROL) => {
198                handle_ctrl_v(state);
199                update_suggestion(state);
200                UiAction::None
201            }
202            (KeyCode::Char('t'), KeyModifiers::CONTROL) => {
203                state.tool_details = !state.tool_details;
204                let label = if state.tool_details { "tool details on" } else { "tool details off" };
205                state.toasts.info(label);
206                UiAction::None
207            }
208            (KeyCode::Esc, _) => {
209                if state.is_streaming {
210                    return UiAction::Interrupt;
211                }
212                if !state.input.slash_matches.is_empty() {
213                    state.input.slash_matches.clear();
214                    state.input.slash_selected = 0;
215                    return UiAction::None;
216                }
217                state.input.mode = InputMode::Normal;
218                UiAction::None
219            }
220            (KeyCode::BackTab, _) => UiAction::CyclePermissionMode,
221            (KeyCode::Tab, _) => { state.input.complete_suggestion(); UiAction::None }
222            (KeyCode::Enter, m) if m.contains(KeyModifiers::SHIFT)
223                || m.contains(KeyModifiers::ALT)
224                || m.contains(KeyModifiers::CONTROL) =>
225            {
226                state.input.insert_char('\n'); UiAction::None
227            }
228            (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
229                state.input.insert_char('\n'); UiAction::None
230            }
231            (KeyCode::Enter, _) => {
232                if !state.input.slash_matches.is_empty() {
233                    let i = state.input.slash_selected.min(state.input.slash_matches.len() - 1);
234                    let cmd = state.input.slash_matches[i].0.clone();
235                    state.input.buffer = format!("{cmd} ");
236                    state.input.cursor_pos = state.input.buffer.len();
237                    state.input.slash_matches.clear();
238                    state.input.slash_selected = 0;
239                    state.input.suggestion.clear();
240                    return UiAction::None;
241                }
242                let display = state.input.buffer.trim().to_string();
243                if display.is_empty() { return UiAction::None; }
244                let real = state.input.expand_for_submit().trim().to_string();
245                state.input.push_history(display);
246                state.input.clear();
247                UiAction::Submit(real)
248            }
249            (KeyCode::Backspace, _) => {
250                state.input.delete_char(); update_suggestion(state); UiAction::None
251            }
252            (KeyCode::Delete, _) => {
253                state.input.delete_char_forward(); update_suggestion(state); UiAction::None
254            }
255            (KeyCode::Left, KeyModifiers::ALT) => { state.input.move_word_left(); UiAction::None }
256            (KeyCode::Right, KeyModifiers::ALT) => { state.input.move_word_right(); UiAction::None }
257            (KeyCode::Left, _) => { state.input.move_cursor_left(); UiAction::None }
258            (KeyCode::Right, _) => {
259                if state.input.cursor_pos == state.input.buffer.len() && !state.input.suggestion.is_empty() {
260                    state.input.complete_suggestion();
261                } else {
262                    state.input.move_cursor_right();
263                }
264                UiAction::None
265            }
266            (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
267                state.input.cursor_pos = 0; UiAction::None
268            }
269            (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
270                state.input.cursor_pos = state.input.buffer.len(); UiAction::None
271            }
272            (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
273                state.input.clear(); update_suggestion(state); UiAction::None
274            }
275            (KeyCode::Char('w'), KeyModifiers::CONTROL) => {
276                state.input.move_word_left(); update_suggestion(state); UiAction::None
277            }
278            (KeyCode::Up, KeyModifiers::SHIFT) => { scroll_up(state, 1); UiAction::None }
279            (KeyCode::Down, KeyModifiers::SHIFT) => { scroll_down(state, 1); UiAction::None }
280            (KeyCode::Char('u'), m) if m.contains(KeyModifiers::CONTROL) && m.contains(KeyModifiers::ALT) => {
281                scroll_up(state, 10); UiAction::None
282            }
283            (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) && m.contains(KeyModifiers::ALT) => {
284                scroll_down(state, 10); UiAction::None
285            }
286            (KeyCode::Up, _) => {
287                if !state.input.slash_matches.is_empty() {
288                    let len = state.input.slash_matches.len();
289                    state.input.slash_selected = if state.input.slash_selected == 0 {
290                        len - 1
291                    } else {
292                        state.input.slash_selected - 1
293                    };
294                } else if state.input.buffer.is_empty() {
295                    scroll_up(state, 3);
296                } else if state.input.line_count() > 1 && state.input.cursor_up_line() {
297                    // moved within multi-line buffer
298                } else {
299                    state.input.history_prev();
300                }
301                UiAction::None
302            }
303            (KeyCode::Down, _) => {
304                if !state.input.slash_matches.is_empty() {
305                    let len = state.input.slash_matches.len();
306                    state.input.slash_selected = (state.input.slash_selected + 1) % len;
307                } else if state.input.buffer.is_empty() {
308                    scroll_down(state, 3);
309                } else if state.input.line_count() > 1 && state.input.cursor_down_line() {
310                    // moved within multi-line buffer
311                } else {
312                    state.input.history_next();
313                }
314                UiAction::None
315            }
316            (KeyCode::PageUp, _) => { scroll_up(state, 20); UiAction::None }
317            (KeyCode::PageDown, _) => { scroll_down(state, 20); UiAction::None }
318            (KeyCode::Char('@'), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
319                state.input.insert_char('@');
320                update_suggestion(state);
321                open_file_mention(state);
322                UiAction::None
323            }
324            (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
325                state.input.insert_char(c); update_suggestion(state); UiAction::None
326            }
327            _ => UiAction::None,
328        }
329    }
330
331    fn normal_mode_key(key: KeyEvent, state: &mut AppState) -> UiAction {
332        match (key.code, key.modifiers) {
333            (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
334                state.modal.open_quit_confirm();
335                UiAction::None
336            }
337            (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
338                open_command_palette(state);
339                UiAction::None
340            }
341            (KeyCode::Char('s'), KeyModifiers::CONTROL) => {
342                open_session_list(state);
343                UiAction::None
344            }
345            (KeyCode::Char('m'), KeyModifiers::CONTROL) => {
346                open_model_picker(state);
347                UiAction::None
348            }
349            (KeyCode::Char('b'), KeyModifiers::CONTROL) => UiAction::ToggleSidebar,
350            (KeyCode::BackTab, _) => UiAction::CyclePermissionMode,
351            (KeyCode::Tab, _) => { state.input.mode = InputMode::Insert; UiAction::None }
352            (KeyCode::Char('i'), _) | (KeyCode::Char('a'), _) => {
353                if key.code == KeyCode::Char('a') { state.input.move_cursor_right(); }
354                state.input.mode = InputMode::Insert; UiAction::None
355            }
356            (KeyCode::Char('I'), _) => {
357                state.input.cursor_pos = 0; state.input.mode = InputMode::Insert; UiAction::None
358            }
359            (KeyCode::Char('A'), _) => {
360                state.input.cursor_pos = state.input.buffer.len(); state.input.mode = InputMode::Insert; UiAction::None
361            }
362            (KeyCode::Char('h'), _) | (KeyCode::Left, _) => { state.input.move_cursor_left(); UiAction::None }
363            (KeyCode::Char('l'), _) | (KeyCode::Right, _) => { state.input.move_cursor_right(); UiAction::None }
364            (KeyCode::Char('0'), _) => { state.input.cursor_pos = 0; UiAction::None }
365            (KeyCode::Char('$'), _) => { state.input.cursor_pos = state.input.buffer.len(); UiAction::None }
366            (KeyCode::Char('w'), _) => { state.input.move_word_right(); UiAction::None }
367            (KeyCode::Char('b'), _) => { state.input.move_word_left(); UiAction::None }
368            (KeyCode::Char('x'), _) => { state.input.delete_char_forward(); UiAction::None }
369            (KeyCode::Char('k'), _) | (KeyCode::Up, _) => { scroll_up(state, 3); UiAction::None }
370            (KeyCode::Char('j'), _) | (KeyCode::Down, _) => { scroll_down(state, 3); UiAction::None }
371            (KeyCode::Char('K'), _) => { state.input.history_prev(); UiAction::None }
372            (KeyCode::Char('J'), _) => { state.input.history_next(); UiAction::None }
373            (KeyCode::Char('u'), KeyModifiers::CONTROL) => { state.input.clear(); UiAction::None }
374            (KeyCode::Char('d'), KeyModifiers::CONTROL) => { scroll_down(state, 20); UiAction::None }
375            (KeyCode::PageUp, _) => { scroll_up(state, 20); UiAction::None }
376            (KeyCode::PageDown, _) => { scroll_down(state, 20); UiAction::None }
377            (KeyCode::Enter, _) => {
378                let text = state.input.buffer.trim().to_string();
379                if text.is_empty() { return UiAction::None; }
380                state.input.push_history(text.clone());
381                state.input.clear();
382                state.input.mode = InputMode::Insert;
383                UiAction::Submit(text)
384            }
385            _ => UiAction::None,
386        }
387    }
388
389    fn modal_key(key: KeyEvent, state: &mut AppState) -> UiAction {
390        if let Some(ModalKind::QuitConfirm) = &state.modal.active {
391            match (key.code, key.modifiers) {
392                (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) | (KeyCode::Enter, _) => {
393                    return UiAction::Quit;
394                }
395                _ => {
396                    state.modal.close();
397                    return UiAction::None;
398                }
399            }
400        }
401        // Special: simple modals that close on any non-typing key.
402        if let Some(ModalKind::Info { .. }) = &state.modal.active {
403            match (key.code, key.modifiers) {
404                (KeyCode::Esc, _) | (KeyCode::Enter, _) | (KeyCode::Char('q'), _) => {
405                    state.modal.close();
406                }
407                _ => {}
408            }
409            return UiAction::None;
410        }
411        if let Some(ModalKind::Input { buffer, kind, .. }) = state.modal.active.as_mut() {
412            match (key.code, key.modifiers) {
413                (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
414                    state.modal.close();
415                    return UiAction::None;
416                }
417                (KeyCode::Enter, _) => {
418                    let value = buffer.trim().to_string();
419                    let k = *kind;
420                    state.modal.close();
421                    if value.is_empty() { return UiAction::None; }
422                    return UiAction::InputConfirmed { kind: k, value };
423                }
424                (KeyCode::Backspace, _) => { buffer.pop(); return UiAction::None; }
425                (KeyCode::Char('u'), KeyModifiers::CONTROL) => { buffer.clear(); return UiAction::None; }
426                (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
427                    buffer.push(c);
428                    return UiAction::None;
429                }
430                _ => return UiAction::None,
431            }
432        }
433        let active = state.modal.active.as_mut();
434        let Some(active) = active else { return UiAction::None; };
435        match active {
436            ModalKind::Info { .. } | ModalKind::Input { .. } | ModalKind::QuitConfirm => UiAction::None,
437            ModalKind::Permission { choice, .. } => match (key.code, key.modifiers) {
438                (KeyCode::Esc, _) => {
439                    state.modal.close();
440                    UiAction::PermissionDecision(PermissionChoice::Reject)
441                }
442                (KeyCode::Left, _) | (KeyCode::Char('h'), _) => {
443                    *choice = match *choice {
444                        PermissionChoice::Once => PermissionChoice::Reject,
445                        PermissionChoice::Always => PermissionChoice::Once,
446                        PermissionChoice::Reject => PermissionChoice::Always,
447                    };
448                    UiAction::None
449                }
450                (KeyCode::Right, _) | (KeyCode::Char('l'), _) => {
451                    *choice = match *choice {
452                        PermissionChoice::Once => PermissionChoice::Always,
453                        PermissionChoice::Always => PermissionChoice::Reject,
454                        PermissionChoice::Reject => PermissionChoice::Once,
455                    };
456                    UiAction::None
457                }
458                (KeyCode::Enter, _) => {
459                    let decision = *choice;
460                    state.modal.close();
461                    UiAction::PermissionDecision(decision)
462                }
463                (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => {
464                    state.modal.close();
465                    UiAction::PermissionDecision(PermissionChoice::Once)
466                }
467                (KeyCode::Char('a'), _) | (KeyCode::Char('A'), _) => {
468                    state.modal.close();
469                    UiAction::PermissionDecision(PermissionChoice::Always)
470                }
471                (KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) => {
472                    state.modal.close();
473                    UiAction::PermissionDecision(PermissionChoice::Reject)
474                }
475                _ => UiAction::None,
476            },
477            ModalKind::Select {
478                query,
479                options,
480                selected,
481                kind,
482                ..
483            } => match (key.code, key.modifiers) {
484                (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
485                    state.modal.close();
486                    UiAction::None
487                }
488                (KeyCode::Up, _) => {
489                    let len = filter_options(options, query).len();
490                    if len > 0 {
491                        *selected = if *selected == 0 { len - 1 } else { *selected - 1 };
492                    }
493                    UiAction::None
494                }
495                (KeyCode::Down, _) => {
496                    let len = filter_options(options, query).len();
497                    if len > 0 {
498                        *selected = (*selected + 1) % len;
499                    }
500                    UiAction::None
501                }
502                (KeyCode::PageUp, _) => {
503                    *selected = selected.saturating_sub(10);
504                    UiAction::None
505                }
506                (KeyCode::PageDown, _) => {
507                    let len = filter_options(options, query).len();
508                    if len > 0 {
509                        *selected = (*selected + 10).min(len - 1);
510                    }
511                    UiAction::None
512                }
513                (KeyCode::Backspace, _) => {
514                    query.pop();
515                    *selected = 0;
516                    UiAction::None
517                }
518                (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
519                    query.push(c);
520                    *selected = 0;
521                    UiAction::None
522                }
523                (KeyCode::Enter, _) => {
524                    let filtered = filter_options(options, query);
525                    let Some(&orig) = filtered.get(*selected) else {
526                        state.modal.close();
527                        return UiAction::None;
528                    };
529                    let value = options[orig].value.clone();
530                    let kind = *kind;
531                    state.modal.close();
532                    match kind {
533                        SelectKind::CommandPalette => dispatch_palette(state, &value),
534                        SelectKind::ModelPicker => UiAction::SelectModel(value),
535                        SelectKind::SessionList => {
536                            if value == "__empty__" {
537                                UiAction::None
538                            } else {
539                                UiAction::SelectSession(value)
540                            }
541                        }
542                        SelectKind::Help => UiAction::None,
543                        SelectKind::SkillPicker => {
544                            if let Some(name) = value.strip_prefix("skill:") {
545                                state.input.buffer = format!("/{name} ");
546                                state.input.cursor_pos = state.input.buffer.len();
547                                state.input.mode = InputMode::Insert;
548                            }
549                            UiAction::None
550                        }
551                        SelectKind::FileMention => {
552                            if value != "__empty__" {
553                                // Buffer already contains "@" — append the path then a space
554                                for c in value.chars() {
555                                    state.input.insert_char(c);
556                                }
557                                state.input.insert_char(' ');
558                            }
559                            state.input.mode = InputMode::Insert;
560                            UiAction::None
561                        }
562                        SelectKind::ThemePicker => {
563                            if crate::theme::set_theme(&value) {
564                                state.toasts.success(format!("theme → {value}"));
565                            }
566                            UiAction::None
567                        }
568                    }
569                }
570                _ => UiAction::None,
571            },
572        }
573    }
574}
575
576impl Default for EventHandler {
577    fn default() -> Self { Self }
578}