Skip to main content

netget/tui/
keymap.rs

1//! Keyboard and mouse handling for the panes.
2//!
3//! Precedence: an open modal owns everything (`modal_keys`); then the global
4//! toggles; then the focused column. In the management column the arrows do
5//! all the moving — ↑/↓ over rows, ←/→ along a row's buttons — and Enter acts
6//! on whatever the cursor is on. Everything funnels into `actions::run` with
7//! an `InstanceAction`, so a letter, a button and a click do the same thing.
8
9use 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
21/// What the event loop should do after handling an event.
22pub 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    // Readline chords on text being typed come before the global toggles:
44    // Ctrl-E and Ctrl-W mean "end of line" and "delete word" to anyone with
45    // a line in the box, and only cycle scripting / web search once it is
46    // empty.
47    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
112/// Two stops: the management column and the chat box.
113fn 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
167/// The readline chords the legacy TUI's footer had. Returns whether `key`
168/// was one.
169fn 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
190/// Command-history navigation, mirroring the legacy TUI: entering history
191/// stashes the in-progress input, leaving it restores it.
192fn 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
256/// Move the cursor by `delta` stop rows (rows with something to land on).
257fn 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    // Keep the column where it can: a button grid walks vertically.
278    let positions = rows[app.cards.row].positions();
279    if app.cards.col >= positions {
280        app.cards.col = positions.saturating_sub(1);
281    }
282}
283
284/// Letters that act on the card under the cursor.
285async 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
308/// Enter on the cursor: press the button, or act on the label.
309async 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                // → on a folded row unfolds it, like a tree.
358                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                // ← on an open row folds it; on a leaf, steps out to the
370                // row above it that is shallower.
371                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        // ↓ past the newest line walks on into the chat box.
425        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    // Buttons inside a modal are clickable; every other click in a modal is
549    // swallowed so it cannot reach the panes beneath.
550    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            // A button runs on the click. A label needs a second click, so
589            // a mis-click on a peer cannot fold it or open its request.
590            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
659/// Fold the result of a spawned action back into the UI: on success the
660/// originating modal closes and the summary goes to the stream; on failure
661/// the modal stays open showing the error, so the user can fix and retry.
662pub 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
713/// A detached sender for command paths that only need the return value.
714fn dummy_channel() -> mpsc::UnboundedSender<String> {
715    let (tx, _rx) = mpsc::unbounded_channel();
716    tx
717}