1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
10use tokio::sync::mpsc;
11
12use crate::events::EventHandler;
13use crate::state::app_state::AppState;
14use crate::tui::actions;
15use crate::tui::app::{DashboardApp, Focus, UiKey};
16use crate::tui::cards::{Activate, InstanceAction, Row};
17use crate::tui::hit::{HitTarget, SegmentId};
18use crate::tui::modal::Modal;
19use crate::tui::uimsg::{ActionOrigin, UiMsg};
20
21pub enum Outcome {
23 Continue,
24 Quit,
25}
26
27pub async fn handle_key(
28 app: &mut DashboardApp,
29 key: KeyEvent,
30 state: &AppState,
31 event_handler: &EventHandler,
32 status_tx: &mpsc::UnboundedSender<String>,
33) -> Outcome {
34 app.dirty = true;
35
36 if app.modal().is_some() {
37 return crate::tui::modal_keys::handle_modal_key(app, key, state).await;
38 }
39
40 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
41 let alt = key.modifiers.contains(KeyModifiers::ALT);
42
43 if app.focus == Focus::ChatInput && !app.input.text().is_empty() && readline_key(app, key) {
48 let text = app.input.text();
49 app.core.update_slash_suggestions(&text);
50 return Outcome::Continue;
51 }
52
53 match key.code {
54 KeyCode::Char('c') | KeyCode::Char('C') if ctrl => return Outcome::Quit,
55 KeyCode::Char('l') | KeyCode::Char('L') if ctrl => {
56 let level = app.core.log_level.cycle();
57 app.core.set_log_level(level);
58 app.push_system(format!("Log level: {}", level.as_str()));
59 return Outcome::Continue;
60 }
61 KeyCode::Char('w') | KeyCode::Char('W') if ctrl => {
62 let mode = state.cycle_web_search_mode().await;
63 app.status.web_search = format!("{mode:?}").to_uppercase();
64 app.push_system(format!("Web search: {mode:?}"));
65 return Outcome::Continue;
66 }
67 KeyCode::Char('h') | KeyCode::Char('H') if ctrl => {
68 let mode = state.cycle_event_handler_mode().await;
69 app.status.handler_mode = format!("{mode:?}").to_uppercase();
70 app.push_system(format!("Handler mode: {mode:?}"));
71 return Outcome::Continue;
72 }
73 KeyCode::Char('e') | KeyCode::Char('E') if ctrl => {
74 let (mode, switched) = state.cycle_scripting_mode().await;
75 if switched {
76 app.status.scripting = mode.as_str().to_string();
77 app.push_system(format!("Scripting: {}", mode.as_str()));
78 }
79 return Outcome::Continue;
80 }
81 KeyCode::Char('t') | KeyCode::Char('T') if ctrl => {
82 app.mouse_capture = !app.mouse_capture;
83 app.push_system(if app.mouse_capture {
84 "Mouse capture ON"
85 } else {
86 "Mouse capture OFF — native text selection works; Ctrl-T to re-enable"
87 });
88 return Outcome::Continue;
89 }
90 KeyCode::F(1) => {
91 app.modals.push(Modal::Help { scroll: 0 });
92 return Outcome::Continue;
93 }
94 KeyCode::Tab if !alt => {
95 cycle_focus(app);
96 return Outcome::Continue;
97 }
98 KeyCode::BackTab => {
99 cycle_focus(app);
100 return Outcome::Continue;
101 }
102 _ => {}
103 }
104
105 match app.focus {
106 Focus::ChatInput => handle_chat_key(app, key, state, event_handler, status_tx).await,
107 Focus::Cards => handle_cards_key(app, key, state).await,
108 Focus::Stream => handle_stream_key(app, key, state).await,
109 }
110}
111
112fn cycle_focus(app: &mut DashboardApp) {
114 app.focus = match app.focus {
115 Focus::Cards => Focus::ChatInput,
116 Focus::Stream | Focus::ChatInput => Focus::Cards,
117 };
118 app.activity.cursor = None;
119 let rows = app.rows();
120 app.clamp_cursor_to(&rows);
121}
122
123async fn handle_chat_key(
124 app: &mut DashboardApp,
125 key: KeyEvent,
126 state: &AppState,
127 event_handler: &EventHandler,
128 status_tx: &mpsc::UnboundedSender<String>,
129) -> Outcome {
130 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
131 let alt = key.modifiers.contains(KeyModifiers::ALT);
132
133 match key.code {
134 KeyCode::Enter if ctrl || alt => {
135 app.input.insert_newline();
136 }
137 KeyCode::Char('n') | KeyCode::Char('N') if ctrl || alt => {
138 app.input.insert_newline();
139 }
140 KeyCode::Enter => {
141 let text = app.input.text();
142 app.input.clear();
143 app.core.slash_suggestions.clear();
144 crate::tui::commands::submit(app, text, state, event_handler, status_tx).await;
145 }
146 KeyCode::PageUp => {
147 app.focus = Focus::Stream;
148 app.activity.scroll_up(10);
149 }
150 KeyCode::Up if app.input.is_on_first_line() => history_previous(app),
151 KeyCode::Down if app.input.is_on_last_line() => history_next(app),
152 KeyCode::Esc => {
153 app.input.clear();
154 app.core.slash_suggestions.clear();
155 }
156 _ => {
157 if !readline_key(app, key) {
158 app.input.handle_key(key.code, key.modifiers);
159 }
160 }
161 }
162 let text = app.input.text();
163 app.core.update_slash_suggestions(&text);
164 Outcome::Continue
165}
166
167fn readline_key(app: &mut DashboardApp, key: KeyEvent) -> bool {
170 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
171 let alt = key.modifiers.contains(KeyModifiers::ALT);
172 match key.code {
173 KeyCode::Char('a') | KeyCode::Char('A') if ctrl => app.input.move_to_start_of_line(),
174 KeyCode::Char('e') | KeyCode::Char('E') if ctrl => app.input.move_to_end_of_line(),
175 KeyCode::Char('k') | KeyCode::Char('K') if ctrl => app.input.delete_to_end_of_line(),
176 KeyCode::Char('u') | KeyCode::Char('U') if ctrl => app.input.delete_line(),
177 KeyCode::Char('w') | KeyCode::Char('W') if ctrl => app.input.delete_word(),
178 KeyCode::Backspace if alt => app.input.delete_word(),
179 KeyCode::Delete if alt => app.input.delete_word_forward(),
180 KeyCode::Left if alt => app.input.move_cursor_word_left(),
181 KeyCode::Right if alt => app.input.move_cursor_word_right(),
182 KeyCode::Char('b') if alt => app.input.move_cursor_word_left(),
183 KeyCode::Char('f') if alt => app.input.move_cursor_word_right(),
184 KeyCode::Char('d') if alt => app.input.delete_word_forward(),
185 _ => return false,
186 }
187 true
188}
189
190fn history_previous(app: &mut DashboardApp) {
193 use crate::cli::input_state::InputState;
194 if app.core.command_history.is_empty() {
195 return;
196 }
197 match app.core.history_position {
198 None => {
199 let current = app.input.text();
200 if !current.is_empty() {
201 app.core.history_temp_input = Some(current);
202 }
203 let pos = app.core.command_history.len() - 1;
204 app.core.history_position = Some(pos);
205 app.input = InputState::from_lines(
206 app.core.command_history[pos]
207 .lines()
208 .map(|s| s.to_string())
209 .collect(),
210 );
211 app.input.move_to_bottom();
212 app.input.move_to_end_of_line();
213 }
214 Some(pos) if pos > 0 => {
215 let new_pos = pos - 1;
216 app.core.history_position = Some(new_pos);
217 app.input = InputState::from_lines(
218 app.core.command_history[new_pos]
219 .lines()
220 .map(|s| s.to_string())
221 .collect(),
222 );
223 app.input.move_to_bottom();
224 app.input.move_to_end_of_line();
225 }
226 _ => {}
227 }
228}
229
230fn history_next(app: &mut DashboardApp) {
231 use crate::cli::input_state::InputState;
232 match app.core.history_position {
233 Some(pos) if pos + 1 < app.core.command_history.len() => {
234 let new_pos = pos + 1;
235 app.core.history_position = Some(new_pos);
236 app.input = InputState::from_lines(
237 app.core.command_history[new_pos]
238 .lines()
239 .map(|s| s.to_string())
240 .collect(),
241 );
242 app.input.move_to_bottom();
243 app.input.move_to_end_of_line();
244 }
245 Some(_) => {
246 app.core.history_position = None;
247 let temp = app.core.history_temp_input.take().unwrap_or_default();
248 app.input = InputState::from_lines(temp.lines().map(|s| s.to_string()).collect());
249 app.input.move_to_bottom();
250 app.input.move_to_end_of_line();
251 }
252 None => {}
253 }
254}
255
256fn move_cursor(app: &mut DashboardApp, rows: &[Row], delta: isize) {
258 let stops: Vec<usize> = rows
259 .iter()
260 .enumerate()
261 .filter(|(_, r)| r.positions() > 0)
262 .map(|(i, _)| i)
263 .collect();
264 if stops.is_empty() {
265 return;
266 }
267 let current = stops
268 .iter()
269 .position(|s| *s == app.cards.row)
270 .unwrap_or_else(|| {
271 stops
272 .partition_point(|s| *s < app.cards.row)
273 .min(stops.len() - 1)
274 });
275 let next = (current as isize + delta).clamp(0, stops.len() as isize - 1) as usize;
276 app.cards.row = stops[next];
277 let positions = rows[app.cards.row].positions();
279 if app.cards.col >= positions {
280 app.cards.col = positions.saturating_sub(1);
281 }
282}
283
284async fn instance_letter(app: &mut DashboardApp, code: KeyCode, state: &AppState) -> bool {
286 if matches!(code, KeyCode::Char('a') | KeyCode::Char('A')) {
287 actions::open_protocol_picker(app, None, state).await;
288 return true;
289 }
290 let Some(key) = app.cursor_key() else {
291 return false;
292 };
293 let action = match (code, key) {
294 (KeyCode::Char('x'), _) => InstanceAction::Stop,
295 (KeyCode::Char('e'), _) => InstanceAction::Edit,
296 (KeyCode::Char('r'), _) => InstanceAction::Rules,
297 (KeyCode::Char('m'), _) => InstanceAction::CycleDriver,
298 (KeyCode::Char('w'), _) => InstanceAction::Wireshark,
299 (KeyCode::Char('d'), _) => InstanceAction::Docs,
300 (KeyCode::Char('c'), UiKey::Server(_)) => InstanceAction::ConnectClient,
301 (KeyCode::Char('n'), UiKey::Client(_)) => InstanceAction::Send,
302 _ => return false,
303 };
304 actions::run(app, key, action, state).await;
305 true
306}
307
308async fn activate(app: &mut DashboardApp, rows: &[Row], state: &AppState) {
310 let Some(row) = rows.get(app.cards.row) else {
311 return;
312 };
313 if let Some(button) = row.button_at(app.cards.col) {
314 let button = button.clone();
315 let Some(key) = row.key else {
316 return;
317 };
318 if button.enabled {
319 actions::run(app, key, button.action, state).await;
320 } else {
321 app.push_system(format!(
322 "[ {} ]: {}",
323 button.label,
324 button.why_disabled.unwrap_or_default()
325 ));
326 }
327 return;
328 }
329 match row.on_enter.clone() {
330 Activate::None => {}
331 Activate::Toggle(node) => app.cards.state.toggle(&node),
332 Activate::ShowAll(node) => app.cards.state.show_all(&node),
333 Activate::Action(action) => {
334 if let Some(key) = row.key {
335 actions::run(app, key, action, state).await;
336 }
337 }
338 Activate::NewInstance => actions::open_protocol_picker(app, None, state).await,
339 }
340}
341
342async fn handle_cards_key(app: &mut DashboardApp, key: KeyEvent, state: &AppState) -> Outcome {
343 let rows = app.rows();
344 app.clamp_cursor_to(&rows);
345 match key.code {
346 KeyCode::Down => move_cursor(app, &rows, 1),
347 KeyCode::Up => move_cursor(app, &rows, -1),
348 KeyCode::PageDown => move_cursor(app, &rows, 10),
349 KeyCode::PageUp => move_cursor(app, &rows, -10),
350 KeyCode::Home => move_cursor(app, &rows, isize::MIN / 2),
351 KeyCode::End => move_cursor(app, &rows, isize::MAX / 2),
352 KeyCode::Right => {
353 let positions = rows.get(app.cards.row).map(|r| r.positions()).unwrap_or(0);
354 if app.cards.col + 1 < positions {
355 app.cards.col += 1;
356 } else if let Some(row) = rows.get(app.cards.row) {
357 if row.expanded == Some(false) {
359 if let Activate::Toggle(node) = &row.on_enter {
360 app.cards.state.open(node);
361 }
362 }
363 }
364 }
365 KeyCode::Left => {
366 if app.cards.col > 0 {
367 app.cards.col -= 1;
368 } else if let Some(row) = rows.get(app.cards.row) {
369 if row.expanded == Some(true) {
372 if let Activate::Toggle(node) = &row.on_enter {
373 app.cards.state.close(node);
374 }
375 } else if row.depth > 0 {
376 if let Some(parent) = rows[..app.cards.row]
377 .iter()
378 .rposition(|r| r.depth < row.depth && r.positions() > 0)
379 {
380 app.cards.row = parent;
381 app.cards.col = 0;
382 }
383 }
384 }
385 }
386 KeyCode::Enter | KeyCode::Char(' ') => activate(app, &rows, state).await,
387 KeyCode::Esc => {
388 app.focus = Focus::ChatInput;
389 }
390 code => {
391 instance_letter(app, code, state).await;
392 }
393 }
394 let rows = app.rows();
395 app.clamp_cursor_to(&rows);
396 Outcome::Continue
397}
398
399async fn handle_stream_key(app: &mut DashboardApp, key: KeyEvent, state: &AppState) -> Outcome {
400 let sequences: Vec<u64> = crate::tui::render::stream::visible_entries(app)
401 .iter()
402 .map(|entry| entry.seq)
403 .collect();
404 let visible = sequences.len();
405 let current = app
406 .activity
407 .cursor
408 .and_then(|seq| sequences.iter().position(|&entry| entry >= seq));
409 match key.code {
410 KeyCode::Esc | KeyCode::End => {
411 app.activity.scroll_to_follow();
412 app.focus = Focus::ChatInput;
413 }
414 KeyCode::Up => {
415 let cursor = match current {
416 None => visible.checked_sub(1),
417 Some(c) => Some(c.saturating_sub(1)),
418 };
419 app.activity.cursor = cursor.and_then(|index| sequences.get(index).copied());
420 if cursor.is_some() && app.activity.scroll == crate::tui::chat::ScrollPos::Follow {
421 app.activity.scroll_up(0);
422 }
423 }
424 KeyCode::Down => match current {
426 Some(c) if c + 1 < visible => app.activity.cursor = sequences.get(c + 1).copied(),
427 _ => {
428 app.activity.scroll_to_follow();
429 app.focus = Focus::ChatInput;
430 }
431 },
432 KeyCode::PageUp => {
433 let cursor = current.unwrap_or(visible).saturating_sub(10);
434 app.activity.cursor = sequences.get(cursor).copied();
435 if app.activity.scroll == crate::tui::chat::ScrollPos::Follow {
436 app.activity.scroll_up(0);
437 }
438 }
439 KeyCode::PageDown => {
440 if let Some(c) = current {
441 if c + 10 < visible {
442 app.activity.cursor = sequences.get(c + 10).copied();
443 } else {
444 app.activity.scroll_to_follow();
445 app.focus = Focus::ChatInput;
446 }
447 }
448 }
449 KeyCode::Home => {
450 if visible > 0 {
451 app.activity.cursor = sequences.first().copied();
452 app.activity.scroll_up(0);
453 }
454 }
455 KeyCode::Char('f') => {
456 app.activity.only_selected = !app.activity.only_selected;
457 app.activity.cursor = None;
458 }
459 KeyCode::Enter => {
460 if let Some(cursor) = app.activity.cursor {
461 let link = crate::tui::render::stream::visible_entries(app)
462 .into_iter()
463 .find(|entry| entry.seq == cursor)
464 .and_then(|e| e.event.link);
465 follow_link(app, link, state).await;
466 }
467 }
468 _ => {}
469 }
470 Outcome::Continue
471}
472
473async fn follow_link(
474 app: &mut DashboardApp,
475 link: Option<crate::tui::activity::Link>,
476 state: &AppState,
477) {
478 use crate::tui::activity::Link;
479 match link {
480 Some(Link::Request(key, id)) => {
481 actions::run(app, key, InstanceAction::OpenRequest(id), state).await;
482 }
483 Some(Link::Intercept(key, id)) => {
484 actions::run(app, key, InstanceAction::Answer(id), state).await;
485 }
486 Some(Link::Instance(key)) => {
487 if app.instance(key).is_some() {
488 app.focus = Focus::Cards;
489 app.focus_card(key);
490 app.activity.scroll_to_follow();
491 } else {
492 app.push_system(format!("{} is gone", key.describe()));
493 }
494 }
495 None => {}
496 }
497}
498
499pub async fn handle_mouse(app: &mut DashboardApp, event: MouseEvent, state: &AppState) -> Outcome {
500 let target = app.hits.hit(event.column, event.row).cloned();
501
502 match event.kind {
503 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
504 let up = event.kind == MouseEventKind::ScrollUp;
505 match target {
506 Some(HitTarget::ChatInput)
507 | Some(HitTarget::Stream)
508 | Some(HitTarget::StreamRow(_)) => {
509 if up {
510 app.focus = Focus::Stream;
511 app.activity.scroll_up(3);
512 } else {
513 app.activity.scroll_down(3);
514 if app.activity.scroll == crate::tui::chat::ScrollPos::Follow
515 && app.focus == Focus::Stream
516 {
517 app.focus = Focus::ChatInput;
518 }
519 }
520 }
521 Some(HitTarget::ModalBody) | Some(HitTarget::ModalRow(_)) => {
522 if let Some(modal) = app.modal_mut() {
523 modal.scroll_by(if up { -3 } else { 3 });
524 }
525 }
526 Some(HitTarget::CardRow { .. }) | Some(HitTarget::Cards) => {
527 let rows = app.rows().len();
528 app.cards.scroll = if up {
529 app.cards.scroll.saturating_sub(3)
530 } else {
531 (app.cards.scroll + 3).min(rows.saturating_sub(1))
532 };
533 }
534 _ => {}
535 }
536 app.dirty = true;
537 return Outcome::Continue;
538 }
539 MouseEventKind::Down(MouseButton::Left) => {}
540 _ => return Outcome::Continue,
541 }
542
543 app.dirty = true;
544 let Some(target) = target else {
545 return Outcome::Continue;
546 };
547
548 if app.modal().is_some() {
551 if let HitTarget::ModalActionButton(action) = target {
552 return crate::tui::modal_keys::run_modal_action(app, action, state).await;
553 }
554 if let HitTarget::ModalRow(index) = target {
555 crate::tui::modal_keys::select_modal_row(app, index);
556 }
557 return Outcome::Continue;
558 }
559
560 match target {
561 HitTarget::ChatInput => app.focus = Focus::ChatInput,
562 HitTarget::Stream => app.focus = Focus::Stream,
563 HitTarget::StreamRow(seq) => {
564 app.focus = Focus::Stream;
565 if app.activity.cursor == Some(seq) {
566 let link = crate::tui::render::stream::visible_entries(app)
567 .into_iter()
568 .find(|entry| entry.seq == seq)
569 .and_then(|e| e.event.link);
570 follow_link(app, link, state).await;
571 } else {
572 app.activity.cursor = Some(seq);
573 if app.activity.scroll == crate::tui::chat::ScrollPos::Follow {
574 app.activity.scroll_up(0);
575 }
576 }
577 }
578 HitTarget::Cards => app.focus = Focus::Cards,
579 HitTarget::CardRow { row, col } => {
580 let rows = app.rows();
581 let Some(target_row) = rows.get(row) else {
582 return Outcome::Continue;
583 };
584 app.focus = Focus::Cards;
585 let was_here = app.cards.row == row && app.cards.col == col;
586 app.cards.row = row;
587 app.cards.col = col;
588 if target_row.button_at(col).is_some() || was_here {
591 activate(app, &rows, state).await;
592 }
593 }
594 HitTarget::StatusSegment(segment) => match segment {
595 SegmentId::LogLevel => {
596 let level = app.core.log_level.cycle();
597 app.core.set_log_level(level);
598 app.push_system(format!("Log level: {}", level.as_str()));
599 }
600 SegmentId::WebSearch => {
601 let mode = state.cycle_web_search_mode().await;
602 app.status.web_search = format!("{mode:?}").to_uppercase();
603 }
604 SegmentId::Handler => {
605 let mode = state.cycle_event_handler_mode().await;
606 app.status.handler_mode = format!("{mode:?}").to_uppercase();
607 }
608 SegmentId::Scripting => {
609 let (mode, switched) = state.cycle_scripting_mode().await;
610 if switched {
611 app.status.scripting = mode.as_str().to_string();
612 }
613 }
614 SegmentId::Help => app.modals.push(Modal::Help { scroll: 0 }),
615 SegmentId::Waiting => actions::answer_next_waiting(app, state),
616 SegmentId::Instances => {
617 app.focus = Focus::Cards;
618 }
619 SegmentId::Model => {
620 let lines = crate::tui::command_exec::execute(
621 crate::events::UserCommand::ShowUsage,
622 state,
623 &dummy_channel(),
624 )
625 .await;
626 let model = if app.status.model.is_empty() {
627 "No model — /model <name> picks one; manual, static and script rules need none."
628 .to_string()
629 } else {
630 format!("Model: {} — /model lists alternatives.", app.status.model)
631 };
632 app.push_system(model);
633 for line in lines {
634 app.push_system(line);
635 }
636 }
637 SegmentId::Backend | SegmentId::Usage => {
638 let lines = crate::tui::command_exec::execute(
639 crate::events::UserCommand::ShowUsage,
640 state,
641 &dummy_channel(),
642 )
643 .await;
644 for line in lines {
645 app.push_system(line);
646 }
647 }
648 },
649 HitTarget::ModalBody
650 | HitTarget::ModalRow(_)
651 | HitTarget::ModalButton(_)
652 | HitTarget::ModalActionButton(_) => {}
653 }
654 let rows = app.rows();
655 app.clamp_cursor_to(&rows);
656 Outcome::Continue
657}
658
659pub fn handle_ui_msg(app: &mut DashboardApp, msg: UiMsg) {
663 app.dirty = true;
664 let (origin, result) = match msg {
665 UiMsg::OpenForm(model) => {
666 app.modals.push(Modal::Form(model));
667 return;
668 }
669 UiMsg::Chat(text) => {
670 app.push_system(text);
671 return;
672 }
673 UiMsg::ActionDone { origin, result } => (origin, result),
674 };
675
676 let matches_origin = matches!(
677 (origin, app.modal()),
678 (ActionOrigin::Form, Some(Modal::Form(_)))
679 | (ActionOrigin::Routing, Some(Modal::Routing(_)))
680 );
681
682 match result {
683 Ok(summary) => {
684 if matches_origin {
685 app.modals.pop();
686 }
687 app.push_system(summary);
688 }
689 Err(error) => {
690 if matches_origin {
691 match app.modals.last_mut() {
692 Some(Modal::Form(form)) => {
693 form.busy = false;
694 form.error = Some(error);
695 }
696 Some(Modal::Routing(model)) => {
697 model.busy = false;
698 model.error = Some(error);
699 }
700 Some(Modal::Composer(composer)) => {
701 composer.busy = false;
702 composer.error = Some(error);
703 }
704 _ => {}
705 }
706 } else {
707 app.push_error(format!("✗ {error}"));
708 }
709 }
710 }
711}
712
713fn dummy_channel() -> mpsc::UnboundedSender<String> {
715 let (tx, _rx) = mpsc::unbounded_channel();
716 tx
717}