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