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}