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        "tools.focus" => {
128            state.tool_history.focused = true;
129            if state.tool_history.selected.is_none() {
130                let total = crate::widgets::tool_history::flat_rows(state).len();
131                if total > 0 { state.tool_history.selected = Some(total - 1); }
132            }
133            UiAction::None
134        }
135        "theme.switch" => { crate::dialogs::open_theme_picker(state); UiAction::None }
136        "help.show" => UiAction::RunCommand("help.show".into()),
137        "app.quit" => UiAction::Quit,
138        _ => UiAction::None,
139    }
140}
141
142pub struct EventHandler;
143
144impl EventHandler {
145    pub fn new() -> Self { Self }
146
147    pub fn handle(event: Event, state: &mut AppState) -> UiAction {
148        match event {
149            Event::Mouse(m) => {
150                match m.kind {
151                    MouseEventKind::ScrollUp => scroll_up(state, 3),
152                    MouseEventKind::ScrollDown => scroll_down(state, 3),
153                    _ => {}
154                }
155                return UiAction::None;
156            }
157            Event::Paste(text) => {
158                let text = sanitize_paste(&text);
159                if text.is_empty() { return UiAction::None; }
160                if text.len() > 200 || text.matches('\n').count() > 4 {
161                    let lines = text.lines().count().max(1);
162                    let chars = text.chars().count();
163                    let token = format!("[Pasted {lines} lines, {chars} chars]");
164                    for c in token.chars() {
165                        state.input.insert_char(c);
166                    }
167                    state.input.pasted_buffer = Some(text);
168                } else {
169                    for c in text.chars() {
170                        state.input.insert_char(c);
171                    }
172                }
173                return UiAction::None;
174            }
175            Event::Key(key) => {
176                if key.kind != KeyEventKind::Press { return UiAction::None; }
177                if state.modal.active.is_some() { return Self::modal_key(key, state); }
178                if state.tool_history.focused && !key.modifiers.contains(KeyModifiers::CONTROL) {
179                    return Self::tool_history_key(key, state);
180                }
181                if state.input.mode == InputMode::Normal {
182                    return Self::normal_mode_key(key, state);
183                }
184                Self::insert_mode_key(key, state)
185            }
186            _ => UiAction::None,
187        }
188    }
189
190    fn insert_mode_key(key: KeyEvent, state: &mut AppState) -> UiAction {
191        match (key.code, key.modifiers) {
192            (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
193                state.modal.open_quit_confirm();
194                UiAction::None
195            }
196            (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
197                open_command_palette(state);
198                UiAction::None
199            }
200            (KeyCode::Char('s'), KeyModifiers::CONTROL) => {
201                open_session_list(state);
202                UiAction::None
203            }
204            (KeyCode::Char('m'), KeyModifiers::CONTROL) => {
205                open_model_picker(state);
206                UiAction::None
207            }
208            (KeyCode::Char('t'), KeyModifiers::CONTROL) => {
209                state.tool_history.focused = !state.tool_history.focused;
210                if state.tool_history.focused && state.tool_history.selected.is_none() {
211                    let total = crate::widgets::tool_history::flat_rows(state).len();
212                    if total > 0 { state.tool_history.selected = Some(total - 1); }
213                }
214                UiAction::None
215            }
216            (KeyCode::Char('v'), KeyModifiers::CONTROL) => {
217                handle_ctrl_v(state);
218                update_suggestion(state);
219                UiAction::None
220            }
221            (KeyCode::Esc, _) => {
222                if state.is_streaming {
223                    return UiAction::Interrupt;
224                }
225                if !state.input.slash_matches.is_empty() {
226                    state.input.slash_matches.clear();
227                    state.input.slash_selected = 0;
228                    return UiAction::None;
229                }
230                state.input.mode = InputMode::Normal;
231                UiAction::None
232            }
233            (KeyCode::BackTab, _) => UiAction::CyclePermissionMode,
234            (KeyCode::Tab, _) => { state.input.complete_suggestion(); UiAction::None }
235            (KeyCode::Enter, m) if m.contains(KeyModifiers::SHIFT)
236                || m.contains(KeyModifiers::ALT)
237                || m.contains(KeyModifiers::CONTROL) =>
238            {
239                state.input.insert_char('\n'); UiAction::None
240            }
241            (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
242                state.input.insert_char('\n'); UiAction::None
243            }
244            (KeyCode::Enter, _) => {
245                if !state.input.slash_matches.is_empty() {
246                    let i = state.input.slash_selected.min(state.input.slash_matches.len() - 1);
247                    let cmd = state.input.slash_matches[i].0.clone();
248                    state.input.buffer = format!("{cmd} ");
249                    state.input.cursor_pos = state.input.buffer.len();
250                    state.input.slash_matches.clear();
251                    state.input.slash_selected = 0;
252                    state.input.suggestion.clear();
253                    return UiAction::None;
254                }
255                let display = state.input.buffer.trim().to_string();
256                if display.is_empty() { return UiAction::None; }
257                let real = state.input.expand_for_submit().trim().to_string();
258                state.input.push_history(display);
259                state.input.clear();
260                UiAction::Submit(real)
261            }
262            (KeyCode::Backspace, _) => {
263                state.input.delete_char(); update_suggestion(state); UiAction::None
264            }
265            (KeyCode::Delete, _) => {
266                state.input.delete_char_forward(); update_suggestion(state); UiAction::None
267            }
268            (KeyCode::Left, KeyModifiers::ALT) => { state.input.move_word_left(); UiAction::None }
269            (KeyCode::Right, KeyModifiers::ALT) => { state.input.move_word_right(); UiAction::None }
270            (KeyCode::Left, _) => { state.input.move_cursor_left(); UiAction::None }
271            (KeyCode::Right, _) => {
272                if state.input.cursor_pos == state.input.buffer.len() && !state.input.suggestion.is_empty() {
273                    state.input.complete_suggestion();
274                } else {
275                    state.input.move_cursor_right();
276                }
277                UiAction::None
278            }
279            (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
280                state.input.cursor_pos = 0; UiAction::None
281            }
282            (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
283                state.input.cursor_pos = state.input.buffer.len(); UiAction::None
284            }
285            (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
286                state.input.clear(); update_suggestion(state); UiAction::None
287            }
288            (KeyCode::Char('w'), KeyModifiers::CONTROL) => {
289                state.input.move_word_left(); update_suggestion(state); UiAction::None
290            }
291            (KeyCode::Up, KeyModifiers::SHIFT) => { state.input.history_prev(); UiAction::None }
292            (KeyCode::Down, KeyModifiers::SHIFT) => { state.input.history_next(); UiAction::None }
293            (KeyCode::Char('u'), m) if m.contains(KeyModifiers::CONTROL) && m.contains(KeyModifiers::ALT) => {
294                scroll_up(state, 10); UiAction::None
295            }
296            (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) && m.contains(KeyModifiers::ALT) => {
297                scroll_down(state, 10); UiAction::None
298            }
299            (KeyCode::Up, _) => {
300                if !state.input.slash_matches.is_empty() {
301                    let len = state.input.slash_matches.len();
302                    state.input.slash_selected = if state.input.slash_selected == 0 {
303                        len - 1
304                    } else {
305                        state.input.slash_selected - 1
306                    };
307                } else if state.input.buffer.is_empty() {
308                    scroll_up(state, 3);
309                } else if state.input.line_count() > 1 && state.input.cursor_up_line() {
310
311                } else {
312                    state.input.history_prev();
313                }
314                UiAction::None
315            }
316            (KeyCode::Down, _) => {
317                if !state.input.slash_matches.is_empty() {
318                    let len = state.input.slash_matches.len();
319                    state.input.slash_selected = (state.input.slash_selected + 1) % len;
320                } else if state.input.buffer.is_empty() {
321                    scroll_down(state, 3);
322                } else if state.input.line_count() > 1 && state.input.cursor_down_line() {
323
324                } else {
325                    state.input.history_next();
326                }
327                UiAction::None
328            }
329            (KeyCode::PageUp, _) => { scroll_up(state, 20); UiAction::None }
330            (KeyCode::PageDown, _) => { scroll_down(state, 20); UiAction::None }
331            (KeyCode::Char('@'), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
332                state.input.insert_char('@');
333                update_suggestion(state);
334                open_file_mention(state);
335                UiAction::None
336            }
337            (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
338                state.input.insert_char(c); update_suggestion(state); UiAction::None
339            }
340            _ => UiAction::None,
341        }
342    }
343
344    fn tool_history_key(key: KeyEvent, state: &mut AppState) -> UiAction {
345        use crate::widgets::tool_history::flat_rows;
346        let total = flat_rows(state).len();
347        match key.code {
348            KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
349                if state.tool_history.detail_open {
350                    state.tool_history.detail_open = false;
351                } else {
352                    state.tool_history.focused = false;
353                }
354                UiAction::None
355            }
356            KeyCode::Up | KeyCode::Char('k') => {
357                if total == 0 { return UiAction::None; }
358                let cur = state.tool_history.selected.unwrap_or(0);
359                state.tool_history.selected = Some(cur.saturating_sub(1));
360                UiAction::None
361            }
362            KeyCode::Down | KeyCode::Char('j') => {
363                if total == 0 { return UiAction::None; }
364                let cur = state.tool_history.selected.unwrap_or(total - 1);
365                state.tool_history.selected = Some((cur + 1).min(total - 1));
366                UiAction::None
367            }
368            KeyCode::Char('g') => {
369                if total > 0 { state.tool_history.selected = Some(0); }
370                UiAction::None
371            }
372            KeyCode::Char('G') => {
373                if total > 0 { state.tool_history.selected = Some(total - 1); }
374                UiAction::None
375            }
376            KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
377                if state.tool_history.selected.is_some() {
378                    state.tool_history.detail_open = true;
379                }
380                UiAction::None
381            }
382            _ => UiAction::None,
383        }
384    }
385
386    fn normal_mode_key(key: KeyEvent, state: &mut AppState) -> UiAction {
387        match (key.code, key.modifiers) {
388            (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
389                state.modal.open_quit_confirm();
390                UiAction::None
391            }
392            (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
393                open_command_palette(state);
394                UiAction::None
395            }
396            (KeyCode::Char('s'), KeyModifiers::CONTROL) => {
397                open_session_list(state);
398                UiAction::None
399            }
400            (KeyCode::Char('m'), KeyModifiers::CONTROL) => {
401                open_model_picker(state);
402                UiAction::None
403            }
404            (KeyCode::Char('b'), KeyModifiers::CONTROL) => UiAction::None,
405            (KeyCode::BackTab, _) => UiAction::CyclePermissionMode,
406            (KeyCode::Tab, _) => { state.input.mode = InputMode::Insert; UiAction::None }
407            (KeyCode::Char('i'), _) | (KeyCode::Char('a'), _) => {
408                if key.code == KeyCode::Char('a') { state.input.move_cursor_right(); }
409                state.input.mode = InputMode::Insert; UiAction::None
410            }
411            (KeyCode::Char('I'), _) => {
412                state.input.cursor_pos = 0; state.input.mode = InputMode::Insert; UiAction::None
413            }
414            (KeyCode::Char('A'), _) => {
415                state.input.cursor_pos = state.input.buffer.len(); state.input.mode = InputMode::Insert; UiAction::None
416            }
417            (KeyCode::Char('h'), _) | (KeyCode::Left, _) => { state.input.move_cursor_left(); UiAction::None }
418            (KeyCode::Char('l'), _) | (KeyCode::Right, _) => { state.input.move_cursor_right(); UiAction::None }
419            (KeyCode::Char('0'), _) => { state.input.cursor_pos = 0; UiAction::None }
420            (KeyCode::Char('$'), _) => { state.input.cursor_pos = state.input.buffer.len(); UiAction::None }
421            (KeyCode::Char('w'), _) => { state.input.move_word_right(); UiAction::None }
422            (KeyCode::Char('b'), _) => { state.input.move_word_left(); UiAction::None }
423            (KeyCode::Char('x'), _) => { state.input.delete_char_forward(); UiAction::None }
424            (KeyCode::Char('k'), _) | (KeyCode::Up, _) => { scroll_up(state, 3); UiAction::None }
425            (KeyCode::Char('j'), _) | (KeyCode::Down, _) => { scroll_down(state, 3); UiAction::None }
426            (KeyCode::Char('K'), _) => { state.input.history_prev(); UiAction::None }
427            (KeyCode::Char('J'), _) => { state.input.history_next(); UiAction::None }
428            (KeyCode::Char('u'), KeyModifiers::CONTROL) => { state.input.clear(); UiAction::None }
429            (KeyCode::Char('d'), KeyModifiers::CONTROL) => { scroll_down(state, 20); UiAction::None }
430            (KeyCode::PageUp, _) => { scroll_up(state, 20); UiAction::None }
431            (KeyCode::PageDown, _) => { scroll_down(state, 20); UiAction::None }
432            (KeyCode::Enter, _) => {
433                let text = state.input.buffer.trim().to_string();
434                if text.is_empty() { return UiAction::None; }
435                state.input.push_history(text.clone());
436                state.input.clear();
437                state.input.mode = InputMode::Insert;
438                UiAction::Submit(text)
439            }
440            _ => UiAction::None,
441        }
442    }
443
444    fn modal_key(key: KeyEvent, state: &mut AppState) -> UiAction {
445        if let Some(ModalKind::QuitConfirm) = &state.modal.active {
446            match (key.code, key.modifiers) {
447                (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) | (KeyCode::Enter, _) => {
448                    return UiAction::Quit;
449                }
450                _ => {
451                    state.modal.close();
452                    return UiAction::None;
453                }
454            }
455        }
456
457        if let Some(ModalKind::Info { .. }) = &state.modal.active {
458            match (key.code, key.modifiers) {
459                (KeyCode::Esc, _) | (KeyCode::Enter, _) | (KeyCode::Char('q'), _) => {
460                    state.modal.close();
461                }
462                _ => {}
463            }
464            return UiAction::None;
465        }
466        if let Some(ModalKind::Input { buffer, kind, .. }) = state.modal.active.as_mut() {
467            match (key.code, key.modifiers) {
468                (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
469                    state.modal.close();
470                    return UiAction::None;
471                }
472                (KeyCode::Enter, _) => {
473                    let value = buffer.trim().to_string();
474                    let k = *kind;
475                    state.modal.close();
476                    if value.is_empty() { return UiAction::None; }
477                    return UiAction::InputConfirmed { kind: k, value };
478                }
479                (KeyCode::Backspace, _) => { buffer.pop(); return UiAction::None; }
480                (KeyCode::Char('u'), KeyModifiers::CONTROL) => { buffer.clear(); return UiAction::None; }
481                (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
482                    buffer.push(c);
483                    return UiAction::None;
484                }
485                _ => return UiAction::None,
486            }
487        }
488        let active = state.modal.active.as_mut();
489        let Some(active) = active else { return UiAction::None; };
490        match active {
491            ModalKind::Info { .. } | ModalKind::Input { .. } | ModalKind::QuitConfirm => UiAction::None,
492            ModalKind::Permission { choice, .. } => match (key.code, key.modifiers) {
493                (KeyCode::Esc, _) => {
494                    state.modal.close();
495                    UiAction::PermissionDecision(PermissionChoice::Reject)
496                }
497                (KeyCode::Left, _) | (KeyCode::Char('h'), _) => {
498                    *choice = match *choice {
499                        PermissionChoice::Once => PermissionChoice::Reject,
500                        PermissionChoice::Always => PermissionChoice::Once,
501                        PermissionChoice::Reject => PermissionChoice::Always,
502                    };
503                    UiAction::None
504                }
505                (KeyCode::Right, _) | (KeyCode::Char('l'), _) => {
506                    *choice = match *choice {
507                        PermissionChoice::Once => PermissionChoice::Always,
508                        PermissionChoice::Always => PermissionChoice::Reject,
509                        PermissionChoice::Reject => PermissionChoice::Once,
510                    };
511                    UiAction::None
512                }
513                (KeyCode::Enter, _) => {
514                    let decision = *choice;
515                    state.modal.close();
516                    UiAction::PermissionDecision(decision)
517                }
518                (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => {
519                    state.modal.close();
520                    UiAction::PermissionDecision(PermissionChoice::Once)
521                }
522                (KeyCode::Char('a'), _) | (KeyCode::Char('A'), _) => {
523                    state.modal.close();
524                    UiAction::PermissionDecision(PermissionChoice::Always)
525                }
526                (KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) => {
527                    state.modal.close();
528                    UiAction::PermissionDecision(PermissionChoice::Reject)
529                }
530                _ => UiAction::None,
531            },
532            ModalKind::Select {
533                query,
534                options,
535                selected,
536                kind,
537                ..
538            } => match (key.code, key.modifiers) {
539                (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
540                    state.modal.close();
541                    UiAction::None
542                }
543                (KeyCode::Up, _) => {
544                    let len = filter_options(options, query).len();
545                    if len > 0 {
546                        *selected = if *selected == 0 { len - 1 } else { *selected - 1 };
547                    }
548                    UiAction::None
549                }
550                (KeyCode::Down, _) => {
551                    let len = filter_options(options, query).len();
552                    if len > 0 {
553                        *selected = (*selected + 1) % len;
554                    }
555                    UiAction::None
556                }
557                (KeyCode::PageUp, _) => {
558                    *selected = selected.saturating_sub(10);
559                    UiAction::None
560                }
561                (KeyCode::PageDown, _) => {
562                    let len = filter_options(options, query).len();
563                    if len > 0 {
564                        *selected = (*selected + 10).min(len - 1);
565                    }
566                    UiAction::None
567                }
568                (KeyCode::Backspace, _) => {
569                    query.pop();
570                    *selected = 0;
571                    UiAction::None
572                }
573                (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
574                    query.push(c);
575                    *selected = 0;
576                    UiAction::None
577                }
578                (KeyCode::Enter, _) => {
579                    let filtered = filter_options(options, query);
580                    let Some(&orig) = filtered.get(*selected) else {
581                        state.modal.close();
582                        return UiAction::None;
583                    };
584                    let value = options[orig].value.clone();
585                    let kind = *kind;
586                    state.modal.close();
587                    match kind {
588                        SelectKind::CommandPalette => dispatch_palette(state, &value),
589                        SelectKind::ModelPicker => UiAction::SelectModel(value),
590                        SelectKind::SessionList => {
591                            if value == "__empty__" {
592                                UiAction::None
593                            } else {
594                                UiAction::SelectSession(value)
595                            }
596                        }
597                        SelectKind::Help => UiAction::None,
598                        SelectKind::SkillPicker => {
599                            if let Some(name) = value.strip_prefix("skill:") {
600                                state.input.buffer = format!("/{name} ");
601                                state.input.cursor_pos = state.input.buffer.len();
602                                state.input.mode = InputMode::Insert;
603                            }
604                            UiAction::None
605                        }
606                        SelectKind::FileMention => {
607                            if value != "__empty__" {
608
609                                for c in value.chars() {
610                                    state.input.insert_char(c);
611                                }
612                                state.input.insert_char(' ');
613                            }
614                            state.input.mode = InputMode::Insert;
615                            UiAction::None
616                        }
617                        SelectKind::ThemePicker => {
618                            if crate::theme::set_theme(&value) {
619                                state.toasts.success(format!("theme → {value}"));
620                            }
621                            UiAction::None
622                        }
623                    }
624                }
625                _ => UiAction::None,
626            },
627        }
628    }
629}
630
631impl Default for EventHandler {
632    fn default() -> Self { Self }
633}
634
635fn sanitize_paste(text: &str) -> String {
636    let mut out = String::with_capacity(text.len());
637    let bytes = text.as_bytes();
638    let mut i = 0;
639    while i < bytes.len() {
640        if bytes[i] == 0x1b && i + 1 < bytes.len() {
641            let next = bytes[i + 1];
642            if next == b'[' || next == b']' || next == b'O' || next == b'P' {
643                i += 2;
644                while i < bytes.len() {
645                    let b = bytes[i];
646                    let terminator = match next {
647                        b'[' | b'O' => b.is_ascii_alphabetic() || b == b'~',
648                        b']' => b == 0x07 || b == 0x1b,
649                        b'P' => b == 0x1b,
650                        _ => false,
651                    };
652                    i += 1;
653                    if terminator {
654                        if next == b']' && i < bytes.len() && bytes[i - 1] == 0x1b && bytes[i] == b'\\' {
655                            i += 1;
656                        }
657                        break;
658                    }
659                }
660                continue;
661            }
662            if next == 0x1b {
663                i += 1;
664                continue;
665            }
666        }
667        let c = bytes[i];
668        if c == b'\n' || c == b'\t' || c == b'\r' || c >= 0x20 {
669            if c >= 0x80 {
670                let mut j = i + 1;
671                while j < bytes.len() && bytes[j] >= 0x80 && bytes[j] < 0xC0 { j += 1; }
672                if let Ok(s) = std::str::from_utf8(&bytes[i..j]) {
673                    out.push_str(s);
674                }
675                i = j;
676                continue;
677            }
678            out.push(c as char);
679        }
680        i += 1;
681    }
682    out
683}