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