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