Skip to main content

zeph_tui/app/
keys.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5
6pub(super) const SCROLL_STEP_PAGE: usize = 10;
7
8use crate::app::action::{Action, CursorMove, ElicitationEdit, PaletteEdit, ScrollDir, VertDir};
9use crate::app::reducer::{reduce, run_effects};
10use crate::command::TuiCommand;
11use crate::file_picker::{FileIndex, FilePickerState};
12use crate::layout::truncate_to_width;
13
14use super::{
15    AgentViewTarget, App, ChatMessage, InputMode, MessageRole, Panel, PasteState, oneshot,
16};
17
18impl App {
19    /// Main keyboard entry point. Decodes `key` into an `Action` and routes it
20    /// through `reduce → run_effects` (INV-R1). Modal layers and legacy handlers
21    /// that cannot be trivially expressed as a single `Action` are routed through
22    /// `Action::*` variants that the reducer already handles.
23    pub(super) fn handle_key(&mut self, key: KeyEvent) {
24        if let Some(action) = self.decode_key(key) {
25            let effects = reduce(self, action);
26            run_effects(self, effects);
27        }
28    }
29
30    /// Decode a `KeyEvent` into the corresponding `Action`, or `None` if the event
31    /// has no effect (e.g. an unrecognised key in a modal that ignores it).
32    #[allow(clippy::too_many_lines)]
33    fn decode_key(&self, key: KeyEvent) -> Option<Action> {
34        // Global: Ctrl-C always quits.
35        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
36            return Some(Action::Quit);
37        }
38
39        // Help overlay: only '?' and Esc close it.
40        if self.show_help {
41            return match key.code {
42                KeyCode::Char('?') | KeyCode::Esc => Some(Action::SetHelp(false)),
43                _ => None,
44            };
45        }
46
47        // Confirm dialog
48        if self.confirm_state.is_some() {
49            return Self::decode_confirm_key(key);
50        }
51
52        // Elicitation dialog
53        if self.elicitation_state.is_some() {
54            return Self::decode_elicitation_key(key);
55        }
56
57        // Command palette
58        if self.command_palette.is_some() {
59            return Self::decode_palette_key(key);
60        }
61
62        // File picker
63        if self.file_picker_state.is_some() {
64            return Self::decode_file_picker_key(key);
65        }
66
67        // Transcript search (issue #6023): routed mode-agnostically at the top level
68        // (unlike reverse-search, which is Insert-only) so Ctrl+F works whether it was
69        // opened from Normal or Insert mode, and so the two overlays are mutually
70        // exclusive — while this one is open, all keys route here, so Ctrl+R cannot
71        // open reverse-search underneath it (the inverse is guarded by the Ctrl+F
72        // open-arms' `reverse_search.is_none()` check).
73        if self.transcript_search.is_some() {
74            return Self::decode_transcript_search_key(key);
75        }
76
77        match self.sessions.current().input_mode {
78            InputMode::Normal => self.decode_normal_key(key),
79            InputMode::Insert => self.decode_insert_key(key),
80        }
81    }
82
83    fn decode_confirm_key(key: KeyEvent) -> Option<Action> {
84        match key.code {
85            KeyCode::Char('y' | 'Y') | KeyCode::Enter => Some(Action::ConfirmRespond(true)),
86            KeyCode::Char('n' | 'N') | KeyCode::Esc => Some(Action::ConfirmRespond(false)),
87            _ => None,
88        }
89    }
90
91    fn decode_elicitation_key(key: KeyEvent) -> Option<Action> {
92        match key.code {
93            KeyCode::Esc => Some(Action::ElicitationCancel),
94            KeyCode::Enter => Some(Action::ElicitationSubmit),
95            KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
96                Some(Action::ElicitationField(ElicitationEdit::PrevField))
97            }
98            KeyCode::Tab => Some(Action::ElicitationField(ElicitationEdit::NextField)),
99            KeyCode::BackTab => Some(Action::ElicitationField(ElicitationEdit::PrevField)),
100            KeyCode::Up => Some(Action::ElicitationField(ElicitationEdit::EnumPrev)),
101            KeyCode::Down => Some(Action::ElicitationField(ElicitationEdit::EnumNext)),
102            KeyCode::Char(' ') => Some(Action::ElicitationField(ElicitationEdit::ToggleBool)),
103            KeyCode::Char(c) => Some(Action::ElicitationField(ElicitationEdit::PushChar(c))),
104            KeyCode::Backspace => Some(Action::ElicitationField(ElicitationEdit::PopChar)),
105            _ => None,
106        }
107    }
108
109    fn decode_palette_key(key: KeyEvent) -> Option<Action> {
110        match key.code {
111            KeyCode::Esc => Some(Action::CloseCommandPalette),
112            KeyCode::Enter => Some(Action::PaletteAccept),
113            KeyCode::Up => Some(Action::PaletteMove(VertDir::Up)),
114            KeyCode::Down => Some(Action::PaletteMove(VertDir::Down)),
115            KeyCode::Backspace => Some(Action::PaletteInput(PaletteEdit::PopChar)),
116            KeyCode::Char(c) => Some(Action::PaletteInput(PaletteEdit::PushChar(c))),
117            _ => None,
118        }
119    }
120
121    fn decode_file_picker_key(key: KeyEvent) -> Option<Action> {
122        match key.code {
123            KeyCode::Esc => Some(Action::CloseFilePicker),
124            KeyCode::Enter | KeyCode::Tab => Some(Action::FilePickerAccept),
125            KeyCode::Up => Some(Action::FilePickerMove(VertDir::Up)),
126            KeyCode::Down => Some(Action::FilePickerMove(VertDir::Down)),
127            KeyCode::Char(c) => Some(Action::FilePickerInput(PaletteEdit::PushChar(c))),
128            KeyCode::Backspace => Some(Action::FilePickerInput(PaletteEdit::PopChar)),
129            _ => None,
130        }
131    }
132
133    #[allow(clippy::too_many_lines)] // large match over all TuiCommand variants
134    pub(super) fn execute_command(&mut self, cmd: TuiCommand) {
135        match cmd {
136            TuiCommand::ViewConfig
137            | TuiCommand::ViewAutonomy
138            | TuiCommand::SandboxStatus
139            | TuiCommand::TafcStatus => {
140                if let Some(ref tx) = self.command_tx {
141                    // try_send: capacity 16, user-triggered one at a time — overflow not possible in practice
142                    let _ = tx.try_send(cmd);
143                } else {
144                    self.push_system_message(
145                        "Config not available (no command channel).".to_owned(),
146                    );
147                }
148            }
149            TuiCommand::Quit => {
150                self.should_quit = true;
151            }
152            TuiCommand::Help => {
153                self.show_help = true;
154            }
155            TuiCommand::ToggleTheme => {
156                self.cycle_theme();
157                self.push_system_message(format!("Theme: {}", self.active_theme_name()));
158            }
159            TuiCommand::SetTheme(name) => {
160                let name = name.clone();
161                match self.apply_theme(&name) {
162                    Ok(true) => {
163                        // Preset applied immediately.
164                        self.push_system_message(format!(
165                            "Theme switched to: {}",
166                            self.active_theme_name()
167                        ));
168                    }
169                    Ok(false) => {
170                        // User file load dispatched; confirmation arrives via poll_pending_theme.
171                    }
172                    Err(e) => {
173                        self.push_system_message(format!("Theme error: {e}"));
174                    }
175                }
176            }
177            TuiCommand::SetMotion(m) => {
178                self.motion = m;
179                let label = match m {
180                    zeph_config::Motion::Full => "full (wave animation)",
181                    zeph_config::Motion::Minimal => "minimal (breeze spinner)",
182                    zeph_config::Motion::Off => "off (static)",
183                };
184                self.push_system_message(format!("Motion set to: {label}"));
185            }
186            TuiCommand::SessionBrowser => {
187                // Dispatched as a normal user-input command (like AgentList/AgentStatus below)
188                // rather than routed through `command_tx` — `/history` needs real access to
189                // `ctx.messages` (the agent's own message state), which only the session/debug
190                // command registry provides; `forward_tui_commands` (`command_tx` path) only
191                // handles TUI-local, agent-state-free commands (spec-068 §13.7).
192                let _ = self.user_input_tx.try_send("/history".to_owned());
193            }
194            TuiCommand::AgentList => {
195                let _ = self.user_input_tx.try_send("/agent list".to_owned());
196            }
197            TuiCommand::AgentStatus => {
198                let _ = self.user_input_tx.try_send("/agent status".to_owned());
199            }
200            TuiCommand::AgentCancelPrompt => self.prefill_input("/agent cancel "),
201            TuiCommand::AgentSpawnPrompt => self.prefill_input("/agent spawn "),
202            TuiCommand::AgentsShow => self.prefill_input("/agents show "),
203            TuiCommand::AgentsCreate => self.prefill_input("/agents create "),
204            TuiCommand::AgentsEdit => self.prefill_input("/agents edit "),
205            TuiCommand::AgentsDelete => self.prefill_input("/agents delete "),
206            TuiCommand::CocoonStatus => {
207                self.push_system_message("Querying Cocoon sidecar...".to_owned());
208                let _ = self.user_input_tx.try_send("/cocoon status".to_owned());
209            }
210            TuiCommand::CocoonModels => {
211                self.push_system_message("Querying Cocoon models...".to_owned());
212                let _ = self.user_input_tx.try_send("/cocoon models".to_owned());
213            }
214            TuiCommand::CopyLastAssistant => {
215                if let Some(text) = self.last_assistant_content_pub() {
216                    match self.clipboard.copy(&text) {
217                        Ok(()) => self.push_system_message(
218                            "Last assistant message copied to clipboard".to_owned(),
219                        ),
220                        Err(e) => {
221                            self.push_system_message(format!("Copy failed: {e}"));
222                        }
223                    }
224                } else {
225                    self.push_system_message("No assistant message to copy.".to_owned());
226                }
227            }
228            TuiCommand::CopyLastCodeBlock(n) => {
229                let blocks = self.last_assistant_code_blocks_pub();
230                let text = if blocks.is_empty() {
231                    None
232                } else if n == 0 {
233                    blocks.last().cloned()
234                } else {
235                    blocks.get(n.saturating_sub(1)).cloned()
236                };
237                if let Some(text) = text {
238                    match self.clipboard.copy(&text) {
239                        Ok(()) => {
240                            self.push_system_message("Code block copied to clipboard".to_owned());
241                        }
242                        Err(e) => {
243                            self.push_system_message(format!("Copy failed: {e}"));
244                        }
245                    }
246                } else {
247                    self.push_system_message("No code block found.".to_owned());
248                }
249            }
250            // Mouse toggle: route through reduce() so SetMouseCapture effect is queued.
251            TuiCommand::SetMouse(b) => {
252                use crate::app::reducer::{reduce, run_effects};
253                let effects = reduce(self, crate::app::action::Action::SetMouse(b));
254                run_effects(self, effects);
255            }
256            TuiCommand::ToggleMouse => {
257                use crate::app::reducer::{reduce, run_effects};
258                let cur = self.mouse_enabled;
259                let effects = reduce(self, crate::app::action::Action::SetMouse(!cur));
260                run_effects(self, effects);
261            }
262            cmd => self.execute_plan_graph_command(cmd),
263        }
264    }
265
266    fn execute_plan_graph_command(&mut self, cmd: TuiCommand) {
267        if self.handle_graph_command(&cmd) {
268            return;
269        }
270        if self.handle_experiment_command(&cmd) {
271            return;
272        }
273        if self.handle_plugin_command(&cmd) {
274            return;
275        }
276        if self.handle_knowledge_command(&cmd) {
277            return;
278        }
279        self.handle_acp_command(cmd);
280    }
281
282    fn handle_graph_command(&mut self, cmd: &TuiCommand) -> bool {
283        match cmd {
284            TuiCommand::GraphStats => {
285                self.push_system_message("Loading graph stats...".to_owned());
286                let _ = self.user_input_tx.try_send("/graph".to_owned());
287            }
288            TuiCommand::GraphEntities => {
289                self.push_system_message("Loading graph entities...".to_owned());
290                let _ = self.user_input_tx.try_send("/graph entities".to_owned());
291            }
292            TuiCommand::GraphCommunities => {
293                self.push_system_message("Loading graph communities...".to_owned());
294                let _ = self.user_input_tx.try_send("/graph communities".to_owned());
295            }
296            TuiCommand::GraphFactsPrompt => self.prefill_input("/graph facts "),
297            TuiCommand::GraphBackfillPrompt => self.prefill_input("/graph backfill"),
298            _ => return false,
299        }
300        true
301    }
302
303    fn handle_experiment_command(&mut self, cmd: &TuiCommand) -> bool {
304        match cmd {
305            TuiCommand::ExperimentStart => self.prefill_input("/experiment start "),
306            _ => return false,
307        }
308        true
309    }
310
311    fn handle_plugin_command(&mut self, cmd: &TuiCommand) -> bool {
312        match cmd {
313            TuiCommand::PluginList => {
314                self.push_system_message("Loading plugins...".to_owned());
315                let _ = self.user_input_tx.try_send("/plugins list".to_owned());
316            }
317            TuiCommand::PluginAdd => self.prefill_input("/plugins add "),
318            TuiCommand::PluginRemove => self.prefill_input("/plugins remove "),
319            TuiCommand::PluginListOverlay => {
320                self.push_system_message("Loading plugin overlay...".to_owned());
321                let _ = self.user_input_tx.try_send("/plugins overlay".to_owned());
322            }
323            TuiCommand::SessionSwitchNext
324            | TuiCommand::SessionSwitchPrev
325            | TuiCommand::SessionClose => self.try_switch(cmd),
326            _ => return false,
327        }
328        true
329    }
330
331    fn handle_knowledge_command(&mut self, cmd: &TuiCommand) -> bool {
332        match cmd {
333            TuiCommand::KnowledgeStatus => {
334                self.push_system_message("Loading knowledge ingest status...".to_owned());
335                let _ = self.user_input_tx.try_send("/knowledge status".to_owned());
336            }
337            TuiCommand::KnowledgeRollbackPrompt => {
338                self.prefill_input("/knowledge rollback ");
339            }
340            _ => return false,
341        }
342        true
343    }
344
345    fn handle_acp_command(&mut self, cmd: TuiCommand) -> bool {
346        match cmd {
347            TuiCommand::AcpDirsList => {
348                self.push_system_message("Querying ACP runtime...".to_owned());
349                let _ = self.user_input_tx.try_send("/acp dirs".to_owned());
350            }
351            TuiCommand::AcpAuthMethodsView => {
352                self.push_system_message("Querying ACP runtime...".to_owned());
353                let _ = self.user_input_tx.try_send("/acp auth-methods".to_owned());
354            }
355            TuiCommand::AcpStatus => {
356                self.push_system_message("Querying ACP runtime...".to_owned());
357                let _ = self.user_input_tx.try_send("/acp status".to_owned());
358            }
359            TuiCommand::SubagentSpawn { command } => {
360                if command.is_empty() {
361                    self.prefill_input("/subagent spawn ");
362                } else {
363                    let _ = self
364                        .user_input_tx
365                        .try_send(format!("/subagent spawn {command}"));
366                }
367            }
368            TuiCommand::LspStatus => {
369                self.push_system_message("Checking LSP context injection status...".to_owned());
370                let _ = self.user_input_tx.try_send("/lsp".to_owned());
371            }
372            _ => return false,
373        }
374        true
375    }
376
377    /// Handle a session switch or close command, blocking when a modal with a response channel
378    /// is open (would deadlock the agent's `confirm()`/`elicit()` call if dismissed silently).
379    fn try_switch(&mut self, cmd: &TuiCommand) {
380        if self.confirm_state.is_some() || self.elicitation_state.is_some() {
381            self.push_system_message(
382                "Resolve the current confirmation dialog before switching sessions.".to_owned(),
383            );
384            return;
385        }
386        // Pure-UI overlays carry no response channel — safe to dismiss silently.
387        self.command_palette = None;
388        self.file_picker_state = None;
389        self.slash_autocomplete = None;
390        let prev = self.sessions.active();
391        match cmd {
392            TuiCommand::SessionSwitchNext => self.sessions.switch_next(),
393            TuiCommand::SessionSwitchPrev => self.sessions.switch_prev(),
394            TuiCommand::SessionClose => {
395                let active = self.sessions.active();
396                if !self.sessions.close(active) {
397                    self.push_system_message("Cannot close the last remaining session.".to_owned());
398                }
399            }
400            _ => {}
401        }
402        // Only invalidate render cache when the active slot actually changed.
403        if self.sessions.active() != prev {
404            self.sessions.current_mut().render_cache.clear();
405        }
406    }
407
408    fn parse_session_slash(text: &str) -> Option<TuiCommand> {
409        let tokens: Vec<&str> = text.split_whitespace().collect();
410        match tokens.as_slice() {
411            [cmd, "next"] if cmd.eq_ignore_ascii_case("/session") => {
412                Some(TuiCommand::SessionSwitchNext)
413            }
414            [cmd, "prev"] if cmd.eq_ignore_ascii_case("/session") => {
415                Some(TuiCommand::SessionSwitchPrev)
416            }
417            [cmd, "close"] if cmd.eq_ignore_ascii_case("/session") => {
418                Some(TuiCommand::SessionClose)
419            }
420            [cmd, "dirs"] if cmd.eq_ignore_ascii_case("/acp") => Some(TuiCommand::AcpDirsList),
421            [cmd, "auth-methods"] if cmd.eq_ignore_ascii_case("/acp") => {
422                Some(TuiCommand::AcpAuthMethodsView)
423            }
424            [cmd, "status"] if cmd.eq_ignore_ascii_case("/acp") => Some(TuiCommand::AcpStatus),
425            [cmd, "spawn", rest @ ..] if cmd.eq_ignore_ascii_case("/subagent") => {
426                Some(TuiCommand::SubagentSpawn {
427                    command: rest.join(" "),
428                })
429            }
430            [cmd] if cmd.eq_ignore_ascii_case("/copy") => Some(TuiCommand::CopyLastAssistant),
431            [cmd] if cmd.eq_ignore_ascii_case("/copyblock") => {
432                Some(TuiCommand::CopyLastCodeBlock(0))
433            }
434            [cmd, n] if cmd.eq_ignore_ascii_case("/copyblock") => {
435                let idx = n.parse::<usize>().unwrap_or(0);
436                Some(TuiCommand::CopyLastCodeBlock(idx))
437            }
438            // /theme — list presets (bare command, token count == 1)
439            [cmd] if cmd.eq_ignore_ascii_case("/theme") => Some(TuiCommand::ListThemes),
440            // /theme <name> — switch to named theme (any non-empty name token)
441            [cmd, name] if cmd.eq_ignore_ascii_case("/theme") && !name.is_empty() => {
442                Some(TuiCommand::SetTheme((*name).to_owned()))
443            }
444            // /motion <full|minimal|off> — set animation budget at runtime
445            [cmd, level]
446                if cmd.eq_ignore_ascii_case("/motion")
447                    && matches!(
448                        level.to_ascii_lowercase().as_str(),
449                        "full" | "minimal" | "off"
450                    ) =>
451            {
452                let m = match level.to_ascii_lowercase().as_str() {
453                    "minimal" => zeph_config::Motion::Minimal,
454                    "off" => zeph_config::Motion::Off,
455                    _ => zeph_config::Motion::Full,
456                };
457                Some(TuiCommand::SetMotion(m))
458            }
459            // /mouse on|off|toggle — opt-in mouse capture (#5103)
460            [cmd] if cmd.eq_ignore_ascii_case("/mouse") => Some(TuiCommand::ToggleMouse),
461            [cmd, "on"] if cmd.eq_ignore_ascii_case("/mouse") => Some(TuiCommand::SetMouse(true)),
462            [cmd, "off"] if cmd.eq_ignore_ascii_case("/mouse") => Some(TuiCommand::SetMouse(false)),
463            _ => None,
464        }
465    }
466
467    /// Public(crate) wrapper so the reducer can call `parse_session_slash` across modules.
468    pub(crate) fn parse_session_slash_pub(text: &str) -> Option<TuiCommand> {
469        Self::parse_session_slash(text)
470    }
471
472    fn prefill_input(&mut self, prefix: &str) {
473        self.sessions.current_mut().input.clear();
474        self.sessions.current_mut().input.push_str(prefix);
475        self.sessions.current_mut().cursor_position = self.sessions.current().input.len();
476    }
477
478    pub(crate) fn format_skill_list(&self) -> String {
479        if self.metrics.active_skills.is_empty() {
480            return "No skills loaded.".to_owned();
481        }
482        let lines: Vec<String> = self
483            .metrics
484            .active_skills
485            .iter()
486            .map(|s| format!("  - {s}"))
487            .collect();
488        format!(
489            "Loaded skills ({}):\n{}",
490            self.metrics.active_skills.len(),
491            lines.join("\n")
492        )
493    }
494
495    pub(crate) fn format_mcp_list(&self) -> String {
496        if self.metrics.active_mcp_tools.is_empty() {
497            return "No MCP tools available.".to_owned();
498        }
499        let lines: Vec<String> = self
500            .metrics
501            .active_mcp_tools
502            .iter()
503            .map(|t| format!("  - {t}"))
504            .collect();
505        format!(
506            "MCP servers: {}  Tools ({}):\n{}",
507            self.metrics.mcp_server_count,
508            self.metrics.active_mcp_tools.len(),
509            lines.join("\n")
510        )
511    }
512
513    pub(crate) fn format_memory_stats(&self) -> String {
514        let vector_status = if self.metrics.qdrant_available {
515            format!("{} (connected)", self.metrics.vector_backend)
516        } else if !self.metrics.vector_backend.is_empty() {
517            format!("{} (offline)", self.metrics.vector_backend)
518        } else {
519            "none".into()
520        };
521        format!(
522            "Memory stats:\n  SQLite messages: {}\n  Vector store: {vector_status}\n  Embeddings generated: {}",
523            self.metrics.sqlite_message_count, self.metrics.embeddings_generated,
524        )
525    }
526
527    pub(crate) fn format_cost_stats(&self) -> String {
528        use std::fmt::Write as _;
529        let cps_line = match self.metrics.cost_cps_cents {
530            Some(cps) => format!("\n  CPS: ${:.4}", cps / 100.0),
531            None => String::new(),
532        };
533        let mut out = format!(
534            "Cost:\n  Spent: ${:.4}{}\n  Successful tasks today: {}\n  Prompt tokens: {}\n  Completion tokens: {}\n  Total tokens: {}\n  Cache read: {}\n  Cache creation: {}",
535            self.metrics.cost_spent_cents / 100.0,
536            cps_line,
537            self.metrics.cost_successful_tasks,
538            self.metrics.prompt_tokens,
539            self.metrics.completion_tokens,
540            self.metrics.total_tokens,
541            self.metrics.cache_read_tokens,
542            self.metrics.cache_creation_tokens,
543        );
544        if !self.metrics.provider_cost_breakdown.is_empty() {
545            let _ = write!(out, "\n\nPer-provider breakdown:");
546            let _ = write!(
547                out,
548                "\n  {:<16} {:<28} {:>8} {:>9} {:>9} {:>8} {:>8}",
549                "Provider", "Model", "Input", "Cache-R", "Cache-W", "Output", "Cost"
550            );
551            for (name, usage) in &self.metrics.provider_cost_breakdown {
552                let model_display = truncate_to_width(&usage.model, 26);
553                let _ = write!(
554                    out,
555                    "\n  {:<16} {:<28} {:>8} {:>9} {:>9} {:>8} {:>8}",
556                    name,
557                    model_display,
558                    usage.input_tokens,
559                    usage.cache_read_tokens,
560                    usage.cache_write_tokens,
561                    usage.output_tokens,
562                    format!("${:.4}", usage.cost_cents / 100.0),
563                );
564            }
565            let _ = write!(
566                out,
567                "\n\n  Note: excludes subsystem calls (compaction, graph extraction, planning)"
568            );
569        }
570        out
571    }
572
573    pub(crate) fn format_latency_stats(&self) -> String {
574        use std::fmt::Write as _;
575
576        if self.metrics.timing_sample_count == 0 {
577            return "No turn-timing samples recorded yet.".to_owned();
578        }
579        let avg = &self.metrics.avg_turn_timings;
580        let max = &self.metrics.max_turn_timings;
581        let mut out = format!(
582            "Turn latency (rolling avg/max over last {} turn(s)):\n  {:<10} {:>9} {:>9}",
583            self.metrics.timing_sample_count, "phase", "avg", "max"
584        );
585        for (label, avg_ms, max_ms) in [
586            ("context", avg.prepare_context_ms, max.prepare_context_ms),
587            ("llm", avg.llm_chat_ms, max.llm_chat_ms),
588            ("tool", avg.tool_exec_ms, max.tool_exec_ms),
589            ("persist", avg.persist_message_ms, max.persist_message_ms),
590        ] {
591            let _ = write!(out, "\n  {label:<10} {avg_ms:>7}ms {max_ms:>7}ms");
592        }
593
594        let c = &self.metrics.classifier;
595        let tasks = [
596            ("injection", &c.injection),
597            ("pii", &c.pii),
598            ("feedback", &c.feedback),
599        ];
600        if tasks.iter().any(|(_, t)| t.call_count > 0) {
601            let _ = write!(out, "\n\nClassifier latency (p50/p95):");
602            for (label, task) in tasks {
603                if task.call_count == 0 {
604                    continue;
605                }
606                let p50 = task
607                    .p50_ms
608                    .map_or_else(|| "-".to_owned(), |v| format!("{v}ms"));
609                let p95 = task
610                    .p95_ms
611                    .map_or_else(|| "-".to_owned(), |v| format!("{v}ms"));
612                let _ = write!(
613                    out,
614                    "\n  {label:<10} calls:{:<5} p50:{p50:>6} p95:{p95:>6}",
615                    task.call_count
616                );
617            }
618        } else {
619            out.push_str("\n\nClassifier latency: no samples recorded yet.");
620        }
621        out
622    }
623
624    pub(crate) fn format_tool_list(&self) -> String {
625        if self.metrics.active_mcp_tools.is_empty() {
626            return "No tools available.".to_owned();
627        }
628        let lines: Vec<String> = self
629            .metrics
630            .active_mcp_tools
631            .iter()
632            .map(|t| format!("  - {t}"))
633            .collect();
634        format!(
635            "Available tools ({}):\n{}",
636            self.metrics.active_mcp_tools.len(),
637            lines.join("\n")
638        )
639    }
640
641    pub(crate) fn format_scheduler_list(&self) -> String {
642        if self.metrics.scheduled_tasks.is_empty() {
643            return "No scheduled tasks.".to_owned();
644        }
645        let lines: Vec<String> = self
646            .metrics
647            .scheduled_tasks
648            .iter()
649            .map(|t| {
650                let next = if t[3].is_empty() {
651                    "—".to_owned()
652                } else {
653                    t[3].clone()
654                };
655                format!("  {:30}  {:15}  {:8}  {}", t[0], t[1], t[2], next)
656            })
657            .collect();
658        format!(
659            "Scheduled tasks ({}):\n  {:30}  {:15}  {:8}  {}\n{}",
660            self.metrics.scheduled_tasks.len(),
661            "NAME",
662            "KIND",
663            "MODE",
664            "NEXT RUN",
665            lines.join("\n")
666        )
667    }
668
669    pub(crate) fn format_router_stats(&self) -> String {
670        if self.metrics.router_thompson_stats.is_empty() {
671            return "Router: no Thompson state available.\n\
672                (Thompson strategy not active, or no LLM calls made yet)"
673                .to_owned();
674        }
675        let total_mean: f64 = self
676            .metrics
677            .router_thompson_stats
678            .iter()
679            .map(|(_, a, b)| a / (a + b))
680            .sum();
681        let lines: Vec<String> = self
682            .metrics
683            .router_thompson_stats
684            .iter()
685            .map(|(name, alpha, beta)| {
686                let mean = alpha / (alpha + beta);
687                let pct = if total_mean > 0.0 {
688                    mean / total_mean * 100.0
689                } else {
690                    0.0
691                };
692                format!("  {name:<28}  α={alpha:.2}  β={beta:.2}  Mean={pct:.1}%")
693            })
694            .collect();
695        let n = self.metrics.router_thompson_stats.len();
696        format!(
697            "Thompson Sampling state ({n} providers):\n{}",
698            lines.join("\n")
699        )
700    }
701
702    fn push_system_message(&mut self, content: String) {
703        self.sessions.current_mut().show_splash = false;
704        self.sessions
705            .current_mut()
706            .messages
707            .push(ChatMessage::new(MessageRole::System, content));
708        self.sessions.current_mut().scroll_offset = 0;
709    }
710
711    /// Returns true if there are security events within the last 60 seconds.
712    #[must_use]
713    pub fn has_recent_security_events(&self) -> bool {
714        let now = std::time::SystemTime::now()
715            .duration_since(std::time::UNIX_EPOCH)
716            .unwrap_or_default()
717            .as_secs();
718        self.metrics
719            .security_events
720            .back()
721            .is_some_and(|ev| now.saturating_sub(ev.timestamp) <= 60)
722    }
723
724    /// Decode a key event while the `SubAgents` panel has focus or a subagent
725    /// transcript is active. Returns `Some(Action)` when the key is consumed.
726    fn decode_subagent_panel_key(&self, key: KeyEvent) -> Option<Action> {
727        if self.active_panel == Panel::SubAgents {
728            match key.code {
729                KeyCode::Char('j') | KeyCode::Down => {
730                    return Some(Action::Dispatch(TuiCommand::SubagentSidebarDown));
731                }
732                KeyCode::Char('k') | KeyCode::Up => {
733                    return Some(Action::Dispatch(TuiCommand::SubagentSidebarUp));
734                }
735                KeyCode::Enter => {
736                    if let Some(idx) = self.subagent_sidebar.selected()
737                        && let Some(sa) = self.metrics.sub_agents.get(idx)
738                    {
739                        let target = AgentViewTarget::SubAgent {
740                            id: sa.id.clone(),
741                            name: sa.name.clone(),
742                        };
743                        return Some(Action::SetViewTarget(target));
744                    }
745                    return None;
746                }
747                KeyCode::Esc => {
748                    return Some(Action::SetActivePanel(Panel::Chat));
749                }
750                _ => {}
751            }
752        }
753        // Esc while viewing a subagent transcript returns to Main.
754        if key.code == KeyCode::Esc && !self.sessions.current().view_target.is_main() {
755            return Some(Action::SetViewTarget(AgentViewTarget::Main));
756        }
757        None
758    }
759
760    /// Decode a key event while the read-only `Settings` panel has focus (issue #6024).
761    /// Mirrors [`decode_subagent_panel_key`]: `Left`/`Right`/`h`/`l` switch tabs,
762    /// `j`/`k`/`Down`/`Up` move the row selection, `Esc` returns to `Chat`. No mutation
763    /// keys — v1 is read-only.
764    fn decode_settings_panel_key(&self, key: KeyEvent) -> Option<Action> {
765        if self.active_panel != Panel::Settings {
766            return None;
767        }
768        match key.code {
769            KeyCode::Left | KeyCode::Char('h') => Some(Action::SettingsTabPrev),
770            KeyCode::Right | KeyCode::Char('l') => Some(Action::SettingsTabNext),
771            KeyCode::Down | KeyCode::Char('j') => Some(Action::SettingsSelectMove(VertDir::Down)),
772            KeyCode::Up | KeyCode::Char('k') => Some(Action::SettingsSelectMove(VertDir::Up)),
773            KeyCode::Esc => Some(Action::SetActivePanel(Panel::Chat)),
774            _ => None,
775        }
776    }
777
778    #[allow(clippy::too_many_lines)]
779    fn decode_normal_key(&self, key: KeyEvent) -> Option<Action> {
780        if let Some(a) = self.decode_subagent_panel_key(key) {
781            return Some(a);
782        }
783        if let Some(a) = self.decode_settings_panel_key(key) {
784            return Some(a);
785        }
786        match key.code {
787            KeyCode::Esc if self.is_agent_busy() => Some(Action::CancelAgent),
788            KeyCode::Char('q') => Some(Action::Quit),
789            KeyCode::Char('H') => Some(Action::Dispatch(TuiCommand::SessionBrowser)),
790            KeyCode::Char('i') => Some(Action::EnterInsert),
791            KeyCode::Char(':') => Some(Action::OpenCommandPalette),
792            KeyCode::Up | KeyCode::Char('k') => Some(Action::ScrollLines(-1)),
793            KeyCode::Down | KeyCode::Char('j') => Some(Action::ScrollLines(1)),
794            KeyCode::PageUp => Some(Action::ScrollPage(ScrollDir::Up)),
795            KeyCode::PageDown => Some(Action::ScrollPage(ScrollDir::Down)),
796            KeyCode::Home => Some(Action::ScrollToTop),
797            KeyCode::End => Some(Action::ScrollToBottom),
798            KeyCode::Char('d') => Some(Action::ToggleSidePanels),
799            KeyCode::Char('e') => Some(Action::ToggleToolExpanded),
800            KeyCode::Char('c') => Some(Action::CycleToolDensity),
801            KeyCode::Tab => Some(Action::CyclePanelFocus),
802            KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
803                Some(Action::ClearTranscript)
804            }
805            // Ctrl+F (transcript search, issue #6023) must be checked BEFORE the plain
806            // `f`->Fleet arm below, which is itself guarded with `!CONTROL` so it no
807            // longer swallows Ctrl+F (mirrors the Ctrl+L precedent above).
808            KeyCode::Char('f')
809                if key.modifiers.contains(KeyModifiers::CONTROL)
810                    && self.reverse_search.is_none() =>
811            {
812                Some(Action::OpenTranscriptSearch)
813            }
814            KeyCode::Char('?') => Some(Action::SetHelp(true)),
815            KeyCode::Char('p') => Some(Action::TogglePlanView),
816            KeyCode::Char('f') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
817                Some(Action::SetActivePanel(Panel::Fleet))
818            }
819            KeyCode::Char('D') => Some(Action::SetActivePanel(Panel::Durable)),
820            KeyCode::Char('S') => Some(Action::SetActivePanel(Panel::Settings)),
821            KeyCode::Char('a') => Some(Action::SetActivePanel(Panel::SubAgents)),
822            KeyCode::Char('t') => Some(Action::ToggleTaskPanel),
823            KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
824                Some(Action::CopyLastAssistant)
825            }
826            KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
827                Some(Action::CopyLastCodeBlock(0))
828            }
829            KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => {
830                Some(Action::TogglePanelCollapse(0))
831            }
832            KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => {
833                Some(Action::TogglePanelCollapse(1))
834            }
835            KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => {
836                Some(Action::TogglePanelCollapse(2))
837            }
838            KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => {
839                Some(Action::TogglePanelCollapse(3))
840            }
841            _ => None,
842        }
843    }
844
845    /// Returns the byte offset of the char at the given char index.
846    pub(super) fn byte_offset_of_char(&self, char_idx: usize) -> usize {
847        self.sessions
848            .current()
849            .input
850            .char_indices()
851            .nth(char_idx)
852            .map_or(self.sessions.current().input.len(), |(i, _)| i)
853    }
854
855    pub(super) fn char_count(&self) -> usize {
856        self.sessions.current().input.chars().count()
857    }
858
859    pub(super) fn prev_word_boundary(&self) -> usize {
860        let chars: Vec<char> = self.sessions.current().input.chars().collect();
861        let mut pos = self.sessions.current().cursor_position;
862        while pos > 0 && !chars[pos - 1].is_alphanumeric() {
863            pos -= 1;
864        }
865        while pos > 0 && chars[pos - 1].is_alphanumeric() {
866            pos -= 1;
867        }
868        pos
869    }
870
871    pub(super) fn next_word_boundary(&self) -> usize {
872        let chars: Vec<char> = self.sessions.current().input.chars().collect();
873        let len = chars.len();
874        let mut pos = self.sessions.current().cursor_position;
875        while pos < len && chars[pos].is_alphanumeric() {
876            pos += 1;
877        }
878        while pos < len && !chars[pos].is_alphanumeric() {
879            pos += 1;
880        }
881        pos
882    }
883
884    pub(super) fn handle_paste(&mut self, text: &str) {
885        if self.sessions.current().input_mode != InputMode::Insert {
886            return;
887        }
888        self.slash_autocomplete = None;
889        let byte_offset = self.byte_offset_of_char(self.sessions.current().cursor_position);
890        self.sessions
891            .current_mut()
892            .input
893            .insert_str(byte_offset, text);
894        self.sessions.current_mut().cursor_position += text.chars().count();
895
896        let line_count = text.matches('\n').count() + 1;
897        if line_count >= 2 {
898            // Replace any existing paste indicator — new paste supersedes the old one.
899            self.sessions.current_mut().paste_state = Some(PasteState {
900                line_count,
901                byte_len: text.len(),
902            });
903        } else {
904            self.sessions.current_mut().paste_state = None;
905        }
906    }
907
908    fn decode_insert_key(&self, key: KeyEvent) -> Option<Action> {
909        // Reverse-search dispatch is checked BEFORE slash-autocomplete so that
910        // printable chars (including '/') typed into the search query are not
911        // stolen by the autocomplete trigger (C4).
912        if self.reverse_search.is_some() {
913            return Self::decode_reverse_search_key(key);
914        }
915        if self.slash_autocomplete.is_some() {
916            return Self::decode_slash_autocomplete_key(key);
917        }
918        if let Some(a) = Self::decode_insert_text_key(key) {
919            return Some(a);
920        }
921        if let Some(a) = Self::decode_insert_delete_key(key) {
922            return Some(a);
923        }
924        if let Some(a) = Self::decode_insert_scroll_key(key) {
925            return Some(a);
926        }
927        if let Some(a) = Self::decode_insert_history_key(key) {
928            return Some(a);
929        }
930        if let Some(a) = Self::decode_insert_cursor_key(key) {
931            return Some(a);
932        }
933        self.decode_insert_control_key(key)
934    }
935
936    fn decode_insert_scroll_key(key: KeyEvent) -> Option<Action> {
937        match key.code {
938            KeyCode::PageUp => Some(Action::ScrollPage(ScrollDir::Up)),
939            KeyCode::PageDown => Some(Action::ScrollPage(ScrollDir::Down)),
940            _ => None,
941        }
942    }
943
944    /// Insert a newline character at the current cursor position.
945    ///
946    /// Shared body for `Shift+Enter` and `Ctrl+J`.
947    pub(super) fn insert_newline_at_cursor(&mut self) {
948        self.sessions.current_mut().paste_state = None;
949        let byte_offset = self.byte_offset_of_char(self.sessions.current().cursor_position);
950        self.sessions.current_mut().input.insert(byte_offset, '\n');
951        self.sessions.current_mut().cursor_position += 1;
952    }
953
954    fn decode_insert_text_key(key: KeyEvent) -> Option<Action> {
955        match key.code {
956            KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
957                Some(Action::InsertNewline)
958            }
959            KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
960                Some(Action::InsertNewline)
961            }
962            KeyCode::Enter => Some(Action::SubmitInput),
963            KeyCode::Esc => Some(Action::EnterNormal),
964            _ => None,
965        }
966    }
967
968    fn decode_insert_delete_key(key: KeyEvent) -> Option<Action> {
969        match key.code {
970            KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
971                Some(Action::DeleteWordBackward)
972            }
973            KeyCode::Backspace => Some(Action::DeleteCharBackward),
974            KeyCode::Delete => Some(Action::DeleteCharForward),
975            _ => None,
976        }
977    }
978
979    fn decode_insert_history_key(key: KeyEvent) -> Option<Action> {
980        match key.code {
981            KeyCode::Up => Some(Action::HistoryPrev),
982            KeyCode::Down => Some(Action::HistoryNext),
983            _ => None,
984        }
985    }
986
987    fn decode_insert_cursor_key(key: KeyEvent) -> Option<Action> {
988        match key.code {
989            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => {
990                Some(Action::MoveCursor(CursorMove::WordLeft))
991            }
992            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => {
993                Some(Action::MoveCursor(CursorMove::WordRight))
994            }
995            KeyCode::Left => Some(Action::MoveCursor(CursorMove::Left)),
996            KeyCode::Right => Some(Action::MoveCursor(CursorMove::Right)),
997            KeyCode::Home => Some(Action::MoveCursor(CursorMove::Home)),
998            KeyCode::End => Some(Action::MoveCursor(CursorMove::End)),
999            _ => None,
1000        }
1001    }
1002
1003    fn decode_insert_control_key(&self, key: KeyEvent) -> Option<Action> {
1004        match key.code {
1005            KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1006                Some(Action::MoveCursor(CursorMove::Home))
1007            }
1008            KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1009                Some(Action::MoveCursor(CursorMove::End))
1010            }
1011            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1012                Some(Action::ClearInput)
1013            }
1014            KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1015                // /clear-queue is a user-input command, not an Action mutation.
1016                Some(Action::Dispatch(TuiCommand::SendClearQueue))
1017            }
1018            KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1019                Some(Action::CopyLastAssistant)
1020            }
1021            KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1022                Some(Action::CopyLastCodeBlock(0))
1023            }
1024            KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => {
1025                Some(Action::TogglePanelCollapse(0))
1026            }
1027            KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => {
1028                Some(Action::TogglePanelCollapse(1))
1029            }
1030            KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => {
1031                Some(Action::TogglePanelCollapse(2))
1032            }
1033            KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => {
1034                Some(Action::TogglePanelCollapse(3))
1035            }
1036            // Ignore Ctrl+R when slash autocomplete is open — mutual exclusion.
1037            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1038                if self.slash_autocomplete.is_none() {
1039                    Some(Action::OpenReverseSearch)
1040                } else {
1041                    None
1042                }
1043            }
1044            // Ctrl+F (transcript search, issue #6023): must precede the `Char(c)`
1045            // catch-all below, which has no modifier guard and would otherwise insert
1046            // a literal 'f' into the input. Mutual exclusion with Ctrl+R mirrors the
1047            // arm above.
1048            KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1049                if self.slash_autocomplete.is_none() {
1050                    Some(Action::OpenTranscriptSearch)
1051                } else {
1052                    None
1053                }
1054            }
1055            KeyCode::Char('@') => Some(Action::OpenFilePicker),
1056            KeyCode::Char(c) => Some(Action::InsertChar(c)),
1057            _ => None,
1058        }
1059    }
1060
1061    fn decode_slash_autocomplete_key(key: KeyEvent) -> Option<Action> {
1062        match key.code {
1063            KeyCode::Esc => Some(Action::CloseSlashAutocomplete),
1064            KeyCode::Tab => Some(Action::SlashAutocompleteAccept),
1065            KeyCode::Enter => {
1066                // Accept and immediately submit.
1067                Some(Action::SlashAutocompleteAcceptAndSubmit)
1068            }
1069            KeyCode::Down => Some(Action::SlashAutocompleteMove(VertDir::Down)),
1070            KeyCode::Up | KeyCode::BackTab => Some(Action::SlashAutocompleteMove(VertDir::Up)),
1071            KeyCode::Backspace => Some(Action::SlashAutocompletePopChar),
1072            KeyCode::Char(c) => Some(Action::SlashAutocompletePushChar(c)),
1073            _ => None,
1074        }
1075    }
1076
1077    fn decode_reverse_search_key(key: KeyEvent) -> Option<Action> {
1078        let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1079        let is_alt = key.modifiers.contains(KeyModifiers::ALT);
1080        match key.code {
1081            KeyCode::Esc => Some(Action::CloseReverseSearch),
1082            KeyCode::Enter => Some(Action::ReverseSearchAccept),
1083            KeyCode::Char('r') if is_ctrl => Some(Action::ReverseSearchNext),
1084            KeyCode::Char('s') if is_ctrl => Some(Action::ReverseSearchPrev),
1085            KeyCode::Backspace => Some(Action::ReverseSearchInput(PaletteEdit::PopChar)),
1086            KeyCode::Char(c) if !is_ctrl && !is_alt => {
1087                Some(Action::ReverseSearchInput(PaletteEdit::PushChar(c)))
1088            }
1089            _ => None,
1090        }
1091    }
1092
1093    /// Decode a key event while the transcript-search overlay is open (issue #6023).
1094    /// Mirrors [`decode_reverse_search_key`]: `Esc` cancels, `Enter` accepts,
1095    /// `Ctrl+F`/`Down` advance to the next match, `Up` moves to the previous match.
1096    fn decode_transcript_search_key(key: KeyEvent) -> Option<Action> {
1097        let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1098        let is_alt = key.modifiers.contains(KeyModifiers::ALT);
1099        match key.code {
1100            KeyCode::Esc => Some(Action::CloseTranscriptSearch),
1101            KeyCode::Enter => Some(Action::TranscriptSearchAccept),
1102            KeyCode::Char('f') if is_ctrl => Some(Action::TranscriptSearchNext),
1103            KeyCode::Down => Some(Action::TranscriptSearchNext),
1104            KeyCode::Up => Some(Action::TranscriptSearchPrev),
1105            KeyCode::Backspace => Some(Action::TranscriptSearchInput(PaletteEdit::PopChar)),
1106            KeyCode::Char(c) if !is_ctrl && !is_alt => {
1107                Some(Action::TranscriptSearchInput(PaletteEdit::PushChar(c)))
1108            }
1109            _ => None,
1110        }
1111    }
1112
1113    pub(super) fn handle_history_up(&mut self) {
1114        self.sessions.current_mut().paste_state = None;
1115        if self.sessions.current().input.is_empty()
1116            && self.pending_count > 0
1117            && self.sessions.current().history_index.is_none()
1118        {
1119            if let Some(last) = self.sessions.current_mut().input_history.pop() {
1120                self.sessions.current_mut().input = last;
1121                self.sessions.current_mut().cursor_position = self.char_count();
1122                self.pending_count -= 1;
1123                self.queued_count = self.queued_count.saturating_sub(1);
1124                self.editing_queued = true;
1125                if let Some(pos) = self
1126                    .sessions
1127                    .current_mut()
1128                    .messages
1129                    .iter()
1130                    .rposition(|m| m.role == MessageRole::User)
1131                {
1132                    self.sessions.current_mut().messages.remove(pos);
1133                }
1134                let _ = self.user_input_tx.try_send("/drop-last-queued".to_owned());
1135            }
1136            return;
1137        }
1138        match self.sessions.current().history_index {
1139            None => {
1140                if self.sessions.current().input_history.is_empty() {
1141                    return;
1142                }
1143                self.sessions.current_mut().draft_input = self.sessions.current().input.clone();
1144                let prefix = &self.sessions.current().draft_input;
1145                let found = self
1146                    .sessions
1147                    .current()
1148                    .input_history
1149                    .iter()
1150                    .rposition(|e| prefix.is_empty() || e.starts_with(prefix));
1151                let Some(idx) = found else { return };
1152                self.sessions.current_mut().history_index = Some(idx);
1153                let text = self.sessions.current().input_history[idx].clone();
1154                self.sessions.current_mut().input = text;
1155            }
1156            Some(i) => {
1157                let prefix = &self.sessions.current().draft_input;
1158                let found = self.sessions.current().input_history[..i]
1159                    .iter()
1160                    .rposition(|e| prefix.is_empty() || e.starts_with(prefix));
1161                let Some(idx) = found else { return };
1162                self.sessions.current_mut().history_index = Some(idx);
1163                let text = self.sessions.current().input_history[idx].clone();
1164                self.sessions.current_mut().input = text;
1165            }
1166        }
1167        self.sessions.current_mut().cursor_position = self.char_count();
1168    }
1169
1170    pub(super) fn open_file_picker(&mut self) {
1171        use std::sync::Arc;
1172
1173        let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1174        let needs_rebuild = self.file_index.as_ref().is_none_or(FileIndex::is_stale);
1175        if needs_rebuild && self.pending_file_index.is_none() {
1176            self.sessions.current_mut().status_label = Some("indexing files...".to_owned());
1177            // Status change counts as progress so the wave animates (never reads Stalled).
1178            self.last_progress_at = std::time::Instant::now();
1179            let pending = if let Some(sup) = &self.task_supervisor {
1180                let handle = sup.spawn_blocking(Arc::from("tui.file_index.build"), move || {
1181                    FileIndex::build(&root)
1182                });
1183                super::PendingFileIndex::Supervised(handle)
1184            } else {
1185                // EXEMPT: supervisor not wired (test environments); bare spawn is acceptable here
1186                // because the oneshot receiver is stored in pending_file_index and polled every tick.
1187                let (tx, rx) = oneshot::channel();
1188                tokio::task::spawn_blocking(move || {
1189                    let _ = tx.send(FileIndex::build(&root));
1190                });
1191                super::PendingFileIndex::Bare(rx)
1192            };
1193            self.pending_file_index = Some(pending);
1194            return;
1195        }
1196        if let Some(idx) = &self.file_index {
1197            self.file_picker_state = Some(FilePickerState::new(idx));
1198        }
1199    }
1200
1201    /// Checks if the background file index build has completed and, if so,
1202    /// installs the result and opens the picker.
1203    pub fn poll_pending_file_index(&mut self) {
1204        let Some(pending) = self.pending_file_index.take() else {
1205            return;
1206        };
1207        let poll_result = match pending {
1208            super::PendingFileIndex::Supervised(handle) => match handle.try_join() {
1209                Ok(Ok(idx)) => Some(Ok(idx)),
1210                Ok(Err(_)) => Some(Err(())),
1211                Err(handle) => {
1212                    self.pending_file_index = Some(super::PendingFileIndex::Supervised(handle));
1213                    return;
1214                }
1215            },
1216            super::PendingFileIndex::Bare(mut rx) => match rx.try_recv() {
1217                Ok(idx) => Some(Ok(idx)),
1218                Err(oneshot::error::TryRecvError::Empty) => {
1219                    self.pending_file_index = Some(super::PendingFileIndex::Bare(rx));
1220                    return;
1221                }
1222                Err(oneshot::error::TryRecvError::Closed) => Some(Err(())),
1223            },
1224        };
1225        match poll_result {
1226            Some(Ok(idx)) => {
1227                let picker = FilePickerState::new(&idx);
1228                self.file_index = Some(idx);
1229                self.file_picker_state = Some(picker);
1230                self.sessions.current_mut().status_label = None;
1231            }
1232            Some(Err(())) | None => {
1233                self.sessions.current_mut().status_label = None;
1234            }
1235        }
1236    }
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use tokio::sync::mpsc;
1242
1243    use super::*;
1244    use crate::event::AgentEvent;
1245    use crate::types::MessageRole;
1246
1247    fn make_app() -> (App, mpsc::Receiver<String>, mpsc::Sender<AgentEvent>) {
1248        let (user_tx, user_rx) = mpsc::channel(16);
1249        let (agent_tx, agent_rx) = mpsc::channel(16);
1250        let mut app = App::new(user_tx, agent_rx);
1251        app.sessions.current_mut().messages.clear();
1252        (app, user_rx, agent_tx)
1253    }
1254
1255    #[test]
1256    fn last_assistant_content_returns_none_when_empty() {
1257        let (app, _rx, _tx) = make_app();
1258        assert_eq!(app.last_assistant_content_pub(), None);
1259    }
1260
1261    #[test]
1262    fn last_assistant_content_returns_none_when_only_user_messages() {
1263        let (mut app, _rx, _tx) = make_app();
1264        app.sessions
1265            .current_mut()
1266            .messages
1267            .push(ChatMessage::new(MessageRole::User, "hello"));
1268        assert_eq!(app.last_assistant_content_pub(), None);
1269    }
1270
1271    #[test]
1272    fn last_assistant_content_returns_latest() {
1273        let (mut app, _rx, _tx) = make_app();
1274        app.sessions
1275            .current_mut()
1276            .messages
1277            .push(ChatMessage::new(MessageRole::Assistant, "first"));
1278        app.sessions
1279            .current_mut()
1280            .messages
1281            .push(ChatMessage::new(MessageRole::User, "follow-up"));
1282        app.sessions
1283            .current_mut()
1284            .messages
1285            .push(ChatMessage::new(MessageRole::Assistant, "second"));
1286        assert_eq!(app.last_assistant_content_pub(), Some("second".to_owned()));
1287    }
1288
1289    #[test]
1290    fn slash_copy_parses_to_copy_last_assistant() {
1291        assert_eq!(
1292            App::parse_session_slash("/copy"),
1293            Some(TuiCommand::CopyLastAssistant)
1294        );
1295    }
1296
1297    #[test]
1298    fn slash_copy_case_insensitive() {
1299        assert_eq!(
1300            App::parse_session_slash("/COPY"),
1301            Some(TuiCommand::CopyLastAssistant)
1302        );
1303    }
1304
1305    #[test]
1306    fn slash_unknown_returns_none() {
1307        assert_eq!(App::parse_session_slash("/unknown"), None);
1308    }
1309
1310    #[test]
1311    fn slash_theme_bare_lists_themes() {
1312        assert_eq!(
1313            App::parse_session_slash("/theme"),
1314            Some(TuiCommand::ListThemes)
1315        );
1316    }
1317
1318    #[test]
1319    fn slash_theme_with_name_sets_theme() {
1320        assert_eq!(
1321            App::parse_session_slash("/theme zephyr"),
1322            Some(TuiCommand::SetTheme("zephyr".to_owned()))
1323        );
1324    }
1325
1326    #[test]
1327    fn slash_theme_trailing_space_lists_themes() {
1328        assert_eq!(
1329            App::parse_session_slash("/theme "),
1330            Some(TuiCommand::ListThemes)
1331        );
1332    }
1333
1334    // ── #5983 SandboxStatus/TafcStatus dispatch (was silently dropped) ──────────
1335
1336    #[test]
1337    fn execute_command_forwards_sandbox_status_through_command_tx() {
1338        let (mut app, _user_rx, _agent_tx) = make_app();
1339        let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
1340        app.command_tx = Some(cmd_tx);
1341
1342        app.execute_command(TuiCommand::SandboxStatus);
1343
1344        let forwarded = cmd_rx.try_recv().expect("command must be forwarded");
1345        assert_eq!(forwarded, TuiCommand::SandboxStatus);
1346        assert!(
1347            app.sessions.current().messages.is_empty(),
1348            "must not fall back to a stub system message when command_tx is wired"
1349        );
1350    }
1351
1352    #[test]
1353    fn execute_command_forwards_tafc_status_through_command_tx() {
1354        let (mut app, _user_rx, _agent_tx) = make_app();
1355        let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
1356        app.command_tx = Some(cmd_tx);
1357
1358        app.execute_command(TuiCommand::TafcStatus);
1359
1360        let forwarded = cmd_rx.try_recv().expect("command must be forwarded");
1361        assert_eq!(forwarded, TuiCommand::TafcStatus);
1362        assert!(app.sessions.current().messages.is_empty());
1363    }
1364
1365    #[test]
1366    fn execute_command_sandbox_status_falls_back_without_command_tx() {
1367        // No command_tx wired (e.g. constructed without `with_command_tx`) — must report
1368        // via a system message instead of silently dropping the command.
1369        let (mut app, _user_rx, _agent_tx) = make_app();
1370        assert!(app.command_tx.is_none());
1371
1372        app.execute_command(TuiCommand::SandboxStatus);
1373
1374        let msg = &app.sessions.current().messages.last().unwrap().content;
1375        assert!(msg.contains("not available"));
1376    }
1377
1378    // ── #6420 SessionBrowser dispatches /history as user input ──────────────────
1379
1380    #[test]
1381    fn execute_command_session_browser_dispatches_history_as_user_input() {
1382        let (mut app, mut user_rx, _agent_tx) = make_app();
1383
1384        app.execute_command(TuiCommand::SessionBrowser);
1385
1386        let forwarded = user_rx.try_recv().expect("must forward as user input");
1387        assert_eq!(forwarded, "/history");
1388    }
1389
1390    // ── Ctrl+F / Ctrl+R key-decode routing (issue #6023) ────────────────────────
1391    //
1392    // SC-001 of spec 060 explicitly asks for a regression test proving Ctrl+R is
1393    // unaffected by the new Ctrl+F binding, plus the edge case of the two overlays
1394    // being mutually exclusive. These decode `KeyEvent`s directly through the private
1395    // `decode_key` entry point (accessible from this submodule) rather than the full
1396    // `handle_key` -> `reduce` -> `run_effects` pipeline, isolating the routing logic.
1397
1398    fn ctrl_key(c: char) -> KeyEvent {
1399        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
1400    }
1401
1402    fn plain_key(c: char) -> KeyEvent {
1403        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
1404    }
1405
1406    #[test]
1407    fn ctrl_f_in_normal_mode_opens_transcript_search_not_fleet() {
1408        let (mut app, _user_rx, _agent_tx) = make_app();
1409        app.sessions.current_mut().input_mode = InputMode::Normal;
1410
1411        let action = app.decode_key(ctrl_key('f'));
1412
1413        assert_eq!(action, Some(Action::OpenTranscriptSearch));
1414    }
1415
1416    #[test]
1417    fn plain_f_in_normal_mode_still_opens_fleet() {
1418        // Regression: the `!CONTROL` guard added to the plain-`f` arm must not affect
1419        // unmodified `f` — it must still open the Fleet panel exactly as before #6023.
1420        let (mut app, _user_rx, _agent_tx) = make_app();
1421        app.sessions.current_mut().input_mode = InputMode::Normal;
1422
1423        let action = app.decode_key(plain_key('f'));
1424
1425        assert_eq!(action, Some(Action::SetActivePanel(Panel::Fleet)));
1426    }
1427
1428    #[test]
1429    fn plain_t_in_normal_mode_toggles_task_panel() {
1430        let (mut app, _user_rx, _agent_tx) = make_app();
1431        app.sessions.current_mut().input_mode = InputMode::Normal;
1432
1433        let action = app.decode_key(plain_key('t'));
1434
1435        assert_eq!(action, Some(Action::ToggleTaskPanel));
1436    }
1437
1438    #[test]
1439    fn ctrl_f_in_insert_mode_opens_transcript_search_not_literal_char() {
1440        let (mut app, _user_rx, _agent_tx) = make_app();
1441        app.sessions.current_mut().input_mode = InputMode::Insert;
1442
1443        let action = app.decode_key(ctrl_key('f'));
1444
1445        assert_eq!(
1446            action,
1447            Some(Action::OpenTranscriptSearch),
1448            "must not fall through to the InsertChar('f') catch-all"
1449        );
1450    }
1451
1452    #[test]
1453    fn ctrl_r_in_insert_mode_still_opens_reverse_search() {
1454        // SC-001 regression: Ctrl+R behavior must be completely unaffected by #6023.
1455        let (mut app, _user_rx, _agent_tx) = make_app();
1456        app.sessions.current_mut().input_mode = InputMode::Insert;
1457
1458        let action = app.decode_key(ctrl_key('r'));
1459
1460        assert_eq!(action, Some(Action::OpenReverseSearch));
1461    }
1462
1463    #[test]
1464    fn ctrl_f_is_noop_while_reverse_search_is_open() {
1465        // Mutual exclusion (spec 060 edge-case table): opening transcript search while
1466        // ReverseSearchState is already open must not succeed.
1467        let (mut app, _user_rx, _agent_tx) = make_app();
1468        app.sessions.current_mut().input_mode = InputMode::Insert;
1469        app.reverse_search = Some(crate::widgets::reverse_search::ReverseSearchState::new(&[]));
1470
1471        let action = app.decode_key(ctrl_key('f'));
1472
1473        assert_eq!(
1474            action, None,
1475            "Ctrl+F must not open transcript search while reverse-search is active"
1476        );
1477    }
1478
1479    #[test]
1480    fn ctrl_r_is_noop_while_transcript_search_is_open() {
1481        // Inverse of the above: once transcript search is open, ALL keys route to its
1482        // own decoder (top-level `decode_key` short-circuit), so Ctrl+R cannot open
1483        // reverse-search underneath it.
1484        let (mut app, _user_rx, _agent_tx) = make_app();
1485        app.transcript_search =
1486            Some(crate::widgets::transcript_search::TranscriptSearchState::new(0));
1487
1488        let action = app.decode_key(ctrl_key('r'));
1489
1490        assert_eq!(
1491            action, None,
1492            "Ctrl+R must not open reverse-search while transcript search is active"
1493        );
1494    }
1495
1496    #[test]
1497    fn esc_closes_transcript_search_when_open() {
1498        let (mut app, _user_rx, _agent_tx) = make_app();
1499        app.transcript_search =
1500            Some(crate::widgets::transcript_search::TranscriptSearchState::new(0));
1501
1502        let action = app.decode_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1503
1504        assert_eq!(action, Some(Action::CloseTranscriptSearch));
1505    }
1506}