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}