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
6use crate::command::TuiCommand;
7use crate::file_picker::{FileIndex, FilePickerState};
8use crate::widgets::command_palette::CommandPaletteState;
9use crate::widgets::slash_autocomplete::{SlashAutocompleteState, command_id_to_slash_form};
10
11use super::{
12    AgentViewTarget, App, ChatMessage, InputMode, MAX_INPUT_HISTORY, MessageRole, Panel,
13    PasteState, format_security_report, oneshot,
14};
15
16impl App {
17    pub(super) fn handle_key(&mut self, key: KeyEvent) {
18        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
19            self.should_quit = true;
20            return;
21        }
22
23        if self.show_help {
24            match key.code {
25                KeyCode::Char('?') | KeyCode::Esc => self.show_help = false,
26                _ => {}
27            }
28            return;
29        }
30
31        if self.confirm_state.is_some() {
32            self.handle_confirm_key(key);
33            return;
34        }
35
36        if self.elicitation_state.is_some() {
37            self.handle_elicitation_key(key);
38            return;
39        }
40
41        if self.command_palette.is_some() {
42            self.handle_palette_key(key);
43            return;
44        }
45
46        if self.file_picker_state.is_some() {
47            self.handle_file_picker_key(key);
48            return;
49        }
50
51        match self.sessions.current().input_mode {
52            InputMode::Normal => self.handle_normal_key(key),
53            InputMode::Insert => self.handle_insert_key(key),
54        }
55    }
56
57    fn handle_confirm_key(&mut self, key: KeyEvent) {
58        let response = match key.code {
59            KeyCode::Char('y' | 'Y') | KeyCode::Enter => Some(true),
60            KeyCode::Char('n' | 'N') | KeyCode::Esc => Some(false),
61            _ => None,
62        };
63        if let Some(answer) = response
64            && let Some(mut state) = self.confirm_state.take()
65            && let Some(tx) = state.response_tx.take()
66        {
67            let _ = tx.send(answer);
68        }
69    }
70
71    fn handle_elicitation_key(&mut self, key: KeyEvent) {
72        use crossterm::event::KeyModifiers;
73        use zeph_core::channel::ElicitationResponse;
74
75        let Some(state) = self.elicitation_state.as_mut() else {
76            return;
77        };
78
79        match key.code {
80            KeyCode::Esc => {
81                // Cancel — always dismisses regardless of vi-mode
82                if let Some(mut st) = self.elicitation_state.take()
83                    && let Some(tx) = st.response_tx.take()
84                {
85                    let _ = tx.send(ElicitationResponse::Cancelled);
86                }
87            }
88            KeyCode::Enter => {
89                if let Some(value) = state.dialog.build_submission()
90                    && let Some(mut st) = self.elicitation_state.take()
91                    && let Some(tx) = st.response_tx.take()
92                {
93                    let _ = tx.send(ElicitationResponse::Accepted(value));
94                }
95                // If build_submission returns None (required field empty), stay open
96            }
97            KeyCode::Tab => {
98                if key.modifiers.contains(KeyModifiers::SHIFT) {
99                    state.dialog.prev_field();
100                } else {
101                    state.dialog.next_field();
102                }
103            }
104            KeyCode::BackTab => {
105                state.dialog.prev_field();
106            }
107            KeyCode::Up => {
108                state.dialog.enum_prev();
109            }
110            KeyCode::Down => {
111                state.dialog.enum_next();
112            }
113            KeyCode::Char(' ') => {
114                state.dialog.toggle_bool();
115            }
116            KeyCode::Char(c) => {
117                state.dialog.push_char(c);
118            }
119            KeyCode::Backspace => {
120                state.dialog.pop_char();
121            }
122            _ => {}
123        }
124    }
125
126    fn handle_palette_key(&mut self, key: KeyEvent) {
127        let Some(palette) = self.command_palette.as_mut() else {
128            return;
129        };
130        match key.code {
131            KeyCode::Esc => {
132                self.command_palette = None;
133            }
134            KeyCode::Enter => {
135                if let Some(entry) = palette.selected_entry() {
136                    let cmd = entry.command.clone();
137                    self.execute_command(cmd);
138                }
139                self.command_palette = None;
140            }
141            KeyCode::Up => {
142                palette.move_up();
143            }
144            KeyCode::Down => {
145                palette.move_down();
146            }
147            KeyCode::Backspace => {
148                palette.pop_char();
149            }
150            KeyCode::Char(c) => {
151                palette.push_char(c);
152            }
153            _ => {}
154        }
155    }
156
157    #[allow(clippy::too_many_lines)] // large match over all TuiCommand variants
158    pub(super) fn execute_command(&mut self, cmd: TuiCommand) {
159        match cmd {
160            TuiCommand::SkillList => self.push_system_message(self.format_skill_list()),
161            TuiCommand::McpList => self.push_system_message(self.format_mcp_list()),
162            TuiCommand::MemoryStats => self.push_system_message(self.format_memory_stats()),
163            TuiCommand::ViewCost => self.push_system_message(self.format_cost_stats()),
164            TuiCommand::ViewTools => self.push_system_message(self.format_tool_list()),
165            TuiCommand::ViewConfig | TuiCommand::ViewAutonomy => {
166                if let Some(ref tx) = self.command_tx {
167                    // try_send: capacity 16, user-triggered one at a time — overflow not possible in practice
168                    let _ = tx.try_send(cmd);
169                } else {
170                    self.push_system_message(
171                        "Config not available (no command channel).".to_owned(),
172                    );
173                }
174            }
175            TuiCommand::Quit => {
176                self.should_quit = true;
177            }
178            TuiCommand::Help => {
179                self.show_help = true;
180            }
181            TuiCommand::NewSession => {
182                self.sessions.current_mut().messages.clear();
183                self.push_system_message("New conversation started.".to_owned());
184            }
185            TuiCommand::ToggleTheme => {
186                self.push_system_message("Theme switching is not yet implemented.".to_owned());
187            }
188            TuiCommand::SessionBrowser => {
189                if let Some(ref tx) = self.command_tx {
190                    let _ = tx.try_send(cmd);
191                } else {
192                    self.push_system_message(
193                        "Session browser not available (no command channel).".to_owned(),
194                    );
195                }
196            }
197            TuiCommand::DaemonConnect | TuiCommand::DaemonDisconnect | TuiCommand::DaemonStatus => {
198                self.push_system_message(
199                    "Daemon commands are not yet implemented in this mode.".to_owned(),
200                );
201            }
202            TuiCommand::ViewFilters => {
203                self.push_system_message(
204                    "Filter statistics are displayed in the Resources panel.".to_owned(),
205                );
206            }
207            TuiCommand::Ingest => {
208                self.push_system_message(
209                    "Use: zeph ingest <path> [--chunk-size N] [--collection NAME]".to_owned(),
210                );
211            }
212            TuiCommand::GatewayStatus => {
213                self.push_system_message(
214                    "Gateway status is not yet available in TUI mode.".to_owned(),
215                );
216            }
217            TuiCommand::AgentList => {
218                let _ = self.user_input_tx.try_send("/agent list".to_owned());
219            }
220            TuiCommand::AgentStatus => {
221                let _ = self.user_input_tx.try_send("/agent status".to_owned());
222            }
223            TuiCommand::AgentCancelPrompt => self.prefill_input("/agent cancel "),
224            TuiCommand::AgentSpawnPrompt => self.prefill_input("/agent spawn "),
225            TuiCommand::AgentsShow => self.prefill_input("/agents show "),
226            TuiCommand::AgentsCreate => self.prefill_input("/agents create "),
227            TuiCommand::AgentsEdit => self.prefill_input("/agents edit "),
228            TuiCommand::AgentsDelete => self.prefill_input("/agents delete "),
229            TuiCommand::SchedulerList => self.push_system_message(self.format_scheduler_list()),
230            TuiCommand::RouterStats => self.push_system_message(self.format_router_stats()),
231            TuiCommand::SecurityEvents => {
232                self.push_system_message(format_security_report(&self.metrics));
233            }
234            TuiCommand::TaskPanel => {
235                self.show_task_panel = !self.show_task_panel;
236            }
237            TuiCommand::FleetPanel => {
238                self.active_panel = Panel::Fleet;
239            }
240            TuiCommand::CocoonStatus => {
241                self.push_system_message("Querying Cocoon sidecar...".to_owned());
242                let _ = self.user_input_tx.try_send("/cocoon status".to_owned());
243            }
244            TuiCommand::CocoonModels => {
245                self.push_system_message("Querying Cocoon models...".to_owned());
246                let _ = self.user_input_tx.try_send("/cocoon models".to_owned());
247            }
248            TuiCommand::CopyLastAssistant => {
249                if let Some(text) = self.last_assistant_content() {
250                    match self.clipboard.copy(&text) {
251                        Ok(()) => self.push_system_message(
252                            "Last assistant message copied to clipboard".to_owned(),
253                        ),
254                        Err(e) => {
255                            self.push_system_message(format!("Copy failed: {e}"));
256                        }
257                    }
258                } else {
259                    self.push_system_message("No assistant message to copy.".to_owned());
260                }
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_plan_command(&cmd) {
268            return;
269        }
270        if self.handle_graph_command(&cmd) {
271            return;
272        }
273        if self.handle_experiment_command(&cmd) {
274            return;
275        }
276        if self.handle_memory_command(&cmd) {
277            return;
278        }
279        if self.handle_plugin_command(&cmd) {
280            return;
281        }
282        self.handle_acp_command(cmd);
283    }
284
285    fn handle_plan_command(&mut self, cmd: &TuiCommand) -> bool {
286        match cmd {
287            TuiCommand::PlanStatus => {
288                let _ = self.user_input_tx.try_send("/plan status".to_owned());
289            }
290            TuiCommand::PlanConfirm => {
291                let _ = self.user_input_tx.try_send("/plan confirm".to_owned());
292            }
293            TuiCommand::PlanCancel => {
294                let _ = self.user_input_tx.try_send("/plan cancel".to_owned());
295            }
296            TuiCommand::PlanList => {
297                let _ = self.user_input_tx.try_send("/plan list".to_owned());
298            }
299            TuiCommand::PlanToggleView => {
300                self.sessions.current_mut().plan_view_active =
301                    !self.sessions.current().plan_view_active;
302            }
303            _ => return false,
304        }
305        true
306    }
307
308    fn handle_graph_command(&mut self, cmd: &TuiCommand) -> bool {
309        match cmd {
310            TuiCommand::GraphStats => {
311                self.push_system_message("Loading graph stats...".to_owned());
312                let _ = self.user_input_tx.try_send("/graph".to_owned());
313            }
314            TuiCommand::GraphEntities => {
315                self.push_system_message("Loading graph entities...".to_owned());
316                let _ = self.user_input_tx.try_send("/graph entities".to_owned());
317            }
318            TuiCommand::GraphCommunities => {
319                self.push_system_message("Loading graph communities...".to_owned());
320                let _ = self.user_input_tx.try_send("/graph communities".to_owned());
321            }
322            TuiCommand::GraphFactsPrompt => self.prefill_input("/graph facts "),
323            TuiCommand::GraphBackfillPrompt => self.prefill_input("/graph backfill"),
324            _ => return false,
325        }
326        true
327    }
328
329    fn handle_experiment_command(&mut self, cmd: &TuiCommand) -> bool {
330        match cmd {
331            TuiCommand::ExperimentStart => self.prefill_input("/experiment start "),
332            TuiCommand::ExperimentStop => {
333                let _ = self.user_input_tx.try_send("/experiment stop".to_owned());
334            }
335            TuiCommand::ExperimentStatus => {
336                let _ = self.user_input_tx.try_send("/experiment status".to_owned());
337            }
338            TuiCommand::ExperimentReport => {
339                let _ = self.user_input_tx.try_send("/experiment report".to_owned());
340            }
341            TuiCommand::ExperimentBest => {
342                let _ = self.user_input_tx.try_send("/experiment best".to_owned());
343            }
344            _ => return false,
345        }
346        true
347    }
348
349    fn handle_memory_command(&mut self, cmd: &TuiCommand) -> bool {
350        match cmd {
351            TuiCommand::ServerCompactionStatus => {
352                let _ = self.user_input_tx.try_send("/server-compaction".to_owned());
353            }
354            TuiCommand::ViewGuidelines => {
355                let _ = self.user_input_tx.try_send("/guidelines".to_owned());
356            }
357            TuiCommand::ForgettingSweep => {
358                let _ = self.user_input_tx.try_send("/forgetting-sweep".to_owned());
359            }
360            TuiCommand::TrajectoryStats => {
361                let _ = self.user_input_tx.try_send("/memory trajectory".to_owned());
362            }
363            TuiCommand::MemoryTreeStats => {
364                let _ = self.user_input_tx.try_send("/memory tree".to_owned());
365            }
366            _ => return false,
367        }
368        true
369    }
370
371    fn handle_plugin_command(&mut self, cmd: &TuiCommand) -> bool {
372        match cmd {
373            TuiCommand::PluginList => {
374                self.push_system_message("Loading plugins...".to_owned());
375                let _ = self.user_input_tx.try_send("/plugins list".to_owned());
376            }
377            TuiCommand::PluginAdd => self.prefill_input("/plugins add "),
378            TuiCommand::PluginRemove => self.prefill_input("/plugins remove "),
379            TuiCommand::PluginListOverlay => {
380                self.push_system_message("Loading plugin overlay...".to_owned());
381                let _ = self.user_input_tx.try_send("/plugins overlay".to_owned());
382            }
383            TuiCommand::SessionSwitchNext
384            | TuiCommand::SessionSwitchPrev
385            | TuiCommand::SessionClose => self.try_switch(cmd),
386            _ => return false,
387        }
388        true
389    }
390
391    fn handle_acp_command(&mut self, cmd: TuiCommand) -> bool {
392        match cmd {
393            TuiCommand::AcpDirsList => {
394                self.push_system_message("Querying ACP runtime...".to_owned());
395                let _ = self.user_input_tx.try_send("/acp dirs".to_owned());
396            }
397            TuiCommand::AcpAuthMethodsView => {
398                self.push_system_message("Querying ACP runtime...".to_owned());
399                let _ = self.user_input_tx.try_send("/acp auth-methods".to_owned());
400            }
401            TuiCommand::AcpStatus => {
402                self.push_system_message("Querying ACP runtime...".to_owned());
403                let _ = self.user_input_tx.try_send("/acp status".to_owned());
404            }
405            TuiCommand::SubagentSpawn { command } => {
406                if command.is_empty() {
407                    self.prefill_input("/subagent spawn ");
408                } else {
409                    let _ = self
410                        .user_input_tx
411                        .try_send(format!("/subagent spawn {command}"));
412                }
413            }
414            TuiCommand::LspStatus => {
415                self.push_system_message("Checking LSP context injection status...".to_owned());
416                let _ = self.user_input_tx.try_send("/lsp".to_owned());
417            }
418            TuiCommand::ViewLog => {
419                let _ = self.user_input_tx.try_send("/log".to_owned());
420            }
421            TuiCommand::MigrateConfig => {
422                self.push_system_message(
423                    "To preview missing config parameters, run:\n  zeph migrate-config --diff\n\
424                     To apply changes in-place:\n  zeph migrate-config --in-place"
425                        .to_owned(),
426                );
427            }
428            _ => return false,
429        }
430        true
431    }
432
433    /// Handle a session switch or close command, blocking when a modal with a response channel
434    /// is open (would deadlock the agent's `confirm()`/`elicit()` call if dismissed silently).
435    fn try_switch(&mut self, cmd: &TuiCommand) {
436        if self.confirm_state.is_some() || self.elicitation_state.is_some() {
437            self.push_system_message(
438                "Resolve the current confirmation dialog before switching sessions.".to_owned(),
439            );
440            return;
441        }
442        // Pure-UI overlays carry no response channel — safe to dismiss silently.
443        self.command_palette = None;
444        self.file_picker_state = None;
445        self.slash_autocomplete = None;
446        let prev = self.sessions.active();
447        match cmd {
448            TuiCommand::SessionSwitchNext => self.sessions.switch_next(),
449            TuiCommand::SessionSwitchPrev => self.sessions.switch_prev(),
450            TuiCommand::SessionClose => {
451                let active = self.sessions.active();
452                if !self.sessions.close(active) {
453                    self.push_system_message("Cannot close the last remaining session.".to_owned());
454                }
455            }
456            _ => {}
457        }
458        // Only invalidate render cache when the active slot actually changed.
459        if self.sessions.active() != prev {
460            self.sessions.current_mut().render_cache.clear();
461        }
462    }
463
464    fn parse_session_slash(text: &str) -> Option<TuiCommand> {
465        let tokens: Vec<&str> = text.split_whitespace().collect();
466        match tokens.as_slice() {
467            [cmd, "next"] if cmd.eq_ignore_ascii_case("/session") => {
468                Some(TuiCommand::SessionSwitchNext)
469            }
470            [cmd, "prev"] if cmd.eq_ignore_ascii_case("/session") => {
471                Some(TuiCommand::SessionSwitchPrev)
472            }
473            [cmd, "close"] if cmd.eq_ignore_ascii_case("/session") => {
474                Some(TuiCommand::SessionClose)
475            }
476            [cmd, "dirs"] if cmd.eq_ignore_ascii_case("/acp") => Some(TuiCommand::AcpDirsList),
477            [cmd, "auth-methods"] if cmd.eq_ignore_ascii_case("/acp") => {
478                Some(TuiCommand::AcpAuthMethodsView)
479            }
480            [cmd, "status"] if cmd.eq_ignore_ascii_case("/acp") => Some(TuiCommand::AcpStatus),
481            [cmd, "spawn", rest @ ..] if cmd.eq_ignore_ascii_case("/subagent") => {
482                Some(TuiCommand::SubagentSpawn {
483                    command: rest.join(" "),
484                })
485            }
486            [cmd] if cmd.eq_ignore_ascii_case("/copy") => Some(TuiCommand::CopyLastAssistant),
487            _ => None,
488        }
489    }
490
491    fn prefill_input(&mut self, prefix: &str) {
492        self.sessions.current_mut().input.clear();
493        self.sessions.current_mut().input.push_str(prefix);
494        self.sessions.current_mut().cursor_position = self.sessions.current().input.len();
495    }
496
497    fn format_skill_list(&self) -> String {
498        if self.metrics.active_skills.is_empty() {
499            return "No skills loaded.".to_owned();
500        }
501        let lines: Vec<String> = self
502            .metrics
503            .active_skills
504            .iter()
505            .map(|s| format!("  - {s}"))
506            .collect();
507        format!(
508            "Loaded skills ({}):\n{}",
509            self.metrics.active_skills.len(),
510            lines.join("\n")
511        )
512    }
513
514    fn format_mcp_list(&self) -> String {
515        if self.metrics.active_mcp_tools.is_empty() {
516            return "No MCP tools available.".to_owned();
517        }
518        let lines: Vec<String> = self
519            .metrics
520            .active_mcp_tools
521            .iter()
522            .map(|t| format!("  - {t}"))
523            .collect();
524        format!(
525            "MCP servers: {}  Tools ({}):\n{}",
526            self.metrics.mcp_server_count,
527            self.metrics.active_mcp_tools.len(),
528            lines.join("\n")
529        )
530    }
531
532    fn format_memory_stats(&self) -> String {
533        let vector_status = if self.metrics.qdrant_available {
534            format!("{} (connected)", self.metrics.vector_backend)
535        } else if !self.metrics.vector_backend.is_empty() {
536            format!("{} (offline)", self.metrics.vector_backend)
537        } else {
538            "none".into()
539        };
540        format!(
541            "Memory stats:\n  SQLite messages: {}\n  Vector store: {vector_status}\n  Embeddings generated: {}",
542            self.metrics.sqlite_message_count, self.metrics.embeddings_generated,
543        )
544    }
545
546    fn format_cost_stats(&self) -> String {
547        use std::fmt::Write as _;
548        let cps_line = match self.metrics.cost_cps_cents {
549            Some(cps) => format!("\n  CPS: ${:.4}", cps / 100.0),
550            None => String::new(),
551        };
552        let mut out = format!(
553            "Cost:\n  Spent: ${:.4}{}\n  Successful tasks today: {}\n  Prompt tokens: {}\n  Completion tokens: {}\n  Total tokens: {}\n  Cache read: {}\n  Cache creation: {}",
554            self.metrics.cost_spent_cents / 100.0,
555            cps_line,
556            self.metrics.cost_successful_tasks,
557            self.metrics.prompt_tokens,
558            self.metrics.completion_tokens,
559            self.metrics.total_tokens,
560            self.metrics.cache_read_tokens,
561            self.metrics.cache_creation_tokens,
562        );
563        if !self.metrics.provider_cost_breakdown.is_empty() {
564            let _ = write!(out, "\n\nPer-provider breakdown:");
565            let _ = write!(
566                out,
567                "\n  {:<16} {:<28} {:>8} {:>9} {:>9} {:>8} {:>8}",
568                "Provider", "Model", "Input", "Cache-R", "Cache-W", "Output", "Cost"
569            );
570            for (name, usage) in &self.metrics.provider_cost_breakdown {
571                let model_display = if usage.model.chars().count() > 26 {
572                    format!("{}…", usage.model.chars().take(25).collect::<String>())
573                } else {
574                    usage.model.clone()
575                };
576                let _ = write!(
577                    out,
578                    "\n  {:<16} {:<28} {:>8} {:>9} {:>9} {:>8} {:>8}",
579                    name,
580                    model_display,
581                    usage.input_tokens,
582                    usage.cache_read_tokens,
583                    usage.cache_write_tokens,
584                    usage.output_tokens,
585                    format!("${:.4}", usage.cost_cents / 100.0),
586                );
587            }
588            let _ = write!(
589                out,
590                "\n\n  Note: excludes subsystem calls (compaction, graph extraction, planning)"
591            );
592        }
593        out
594    }
595
596    fn format_tool_list(&self) -> String {
597        if self.metrics.active_mcp_tools.is_empty() {
598            return "No tools available.".to_owned();
599        }
600        let lines: Vec<String> = self
601            .metrics
602            .active_mcp_tools
603            .iter()
604            .map(|t| format!("  - {t}"))
605            .collect();
606        format!(
607            "Available tools ({}):\n{}",
608            self.metrics.active_mcp_tools.len(),
609            lines.join("\n")
610        )
611    }
612
613    fn format_scheduler_list(&self) -> String {
614        if self.metrics.scheduled_tasks.is_empty() {
615            return "No scheduled tasks.".to_owned();
616        }
617        let lines: Vec<String> = self
618            .metrics
619            .scheduled_tasks
620            .iter()
621            .map(|t| {
622                let next = if t[3].is_empty() {
623                    "—".to_owned()
624                } else {
625                    t[3].clone()
626                };
627                format!("  {:30}  {:15}  {:8}  {}", t[0], t[1], t[2], next)
628            })
629            .collect();
630        format!(
631            "Scheduled tasks ({}):\n  {:30}  {:15}  {:8}  {}\n{}",
632            self.metrics.scheduled_tasks.len(),
633            "NAME",
634            "KIND",
635            "MODE",
636            "NEXT RUN",
637            lines.join("\n")
638        )
639    }
640
641    fn format_router_stats(&self) -> String {
642        if self.metrics.router_thompson_stats.is_empty() {
643            return "Router: no Thompson state available.\n\
644                (Thompson strategy not active, or no LLM calls made yet)"
645                .to_owned();
646        }
647        let total_mean: f64 = self
648            .metrics
649            .router_thompson_stats
650            .iter()
651            .map(|(_, a, b)| a / (a + b))
652            .sum();
653        let lines: Vec<String> = self
654            .metrics
655            .router_thompson_stats
656            .iter()
657            .map(|(name, alpha, beta)| {
658                let mean = alpha / (alpha + beta);
659                let pct = if total_mean > 0.0 {
660                    mean / total_mean * 100.0
661                } else {
662                    0.0
663                };
664                format!("  {name:<28}  α={alpha:.2}  β={beta:.2}  Mean={pct:.1}%")
665            })
666            .collect();
667        let n = self.metrics.router_thompson_stats.len();
668        format!(
669            "Thompson Sampling state ({n} providers):\n{}",
670            lines.join("\n")
671        )
672    }
673
674    fn push_system_message(&mut self, content: String) {
675        self.sessions.current_mut().show_splash = false;
676        self.sessions
677            .current_mut()
678            .messages
679            .push(ChatMessage::new(MessageRole::System, content));
680        self.sessions.current_mut().scroll_offset = 0;
681    }
682
683    /// Return the content of the last assistant message in the current session,
684    /// or `None` if no assistant message exists yet.
685    fn last_assistant_content(&self) -> Option<String> {
686        self.sessions
687            .current()
688            .messages
689            .iter()
690            .rev()
691            .find(|m| m.role == MessageRole::Assistant)
692            .map(|m| m.content.clone())
693    }
694
695    /// Returns true if there are security events within the last 60 seconds.
696    #[must_use]
697    pub fn has_recent_security_events(&self) -> bool {
698        let now = std::time::SystemTime::now()
699            .duration_since(std::time::UNIX_EPOCH)
700            .unwrap_or_default()
701            .as_secs();
702        self.metrics
703            .security_events
704            .back()
705            .is_some_and(|ev| now.saturating_sub(ev.timestamp) <= 60)
706    }
707
708    /// Handle keys specific to the `SubAgents` panel and transcript view.
709    /// Returns `true` if the key was consumed.
710    fn handle_subagent_panel_key(&mut self, key: KeyEvent) -> bool {
711        if self.active_panel == Panel::SubAgents {
712            match key.code {
713                KeyCode::Char('j') | KeyCode::Down => {
714                    let count = self.metrics.sub_agents.len();
715                    self.subagent_sidebar.select_next(count);
716                    return true;
717                }
718                KeyCode::Char('k') | KeyCode::Up => {
719                    let count = self.metrics.sub_agents.len();
720                    self.subagent_sidebar.select_prev(count);
721                    return true;
722                }
723                KeyCode::Enter => {
724                    if let Some(idx) = self.subagent_sidebar.selected()
725                        && let Some(sa) = self.metrics.sub_agents.get(idx)
726                    {
727                        let target = AgentViewTarget::SubAgent {
728                            id: sa.id.clone(),
729                            name: sa.name.clone(),
730                        };
731                        self.set_view_target(target);
732                    }
733                    return true;
734                }
735                KeyCode::Esc => {
736                    self.active_panel = Panel::Chat;
737                    return true;
738                }
739                _ => {}
740            }
741        }
742        // Esc while viewing a subagent transcript returns to Main.
743        if key.code == KeyCode::Esc && !self.sessions.current().view_target.is_main() {
744            self.set_view_target(AgentViewTarget::Main);
745            return true;
746        }
747        false
748    }
749
750    fn handle_normal_key(&mut self, key: KeyEvent) {
751        if self.handle_subagent_panel_key(key) {
752            return;
753        }
754        match key.code {
755            KeyCode::Esc if self.is_agent_busy() => {
756                if let Some(ref signal) = self.cancel_signal {
757                    signal.notify_waiters();
758                }
759            }
760            KeyCode::Char('q') => self.should_quit = true,
761            KeyCode::Char('H') => self.execute_command(TuiCommand::SessionBrowser),
762            KeyCode::Char('i') => self.sessions.current_mut().input_mode = InputMode::Insert,
763            KeyCode::Char(':') => {
764                self.command_palette = Some(CommandPaletteState::new());
765            }
766            KeyCode::Up | KeyCode::Char('k') => {
767                self.sessions.current_mut().scroll_offset =
768                    self.sessions.current().scroll_offset.saturating_add(1);
769            }
770            KeyCode::Down | KeyCode::Char('j') => {
771                self.sessions.current_mut().scroll_offset =
772                    self.sessions.current().scroll_offset.saturating_sub(1);
773            }
774            KeyCode::PageUp => {
775                self.sessions.current_mut().scroll_offset =
776                    self.sessions.current().scroll_offset.saturating_add(10);
777            }
778            KeyCode::PageDown => {
779                self.sessions.current_mut().scroll_offset =
780                    self.sessions.current().scroll_offset.saturating_sub(10);
781            }
782            KeyCode::Home => {
783                self.sessions.current_mut().scroll_offset =
784                    if let Some(cache) = &self.sessions.current().transcript_cache {
785                        cache.entries.len()
786                    } else {
787                        self.sessions.current().messages.len()
788                    };
789            }
790            KeyCode::End => {
791                self.sessions.current_mut().scroll_offset = 0;
792            }
793            KeyCode::Char('d') => {
794                self.show_side_panels = !self.show_side_panels;
795            }
796            KeyCode::Char('e') => {
797                self.tool_expanded = !self.tool_expanded;
798                self.sessions.current_mut().render_cache.clear();
799            }
800            KeyCode::Char('c') => {
801                self.tool_density = self.tool_density.cycle();
802                self.sessions.current_mut().render_cache.clear();
803            }
804            KeyCode::Tab => {
805                self.active_panel = match self.active_panel {
806                    Panel::Chat => Panel::Skills,
807                    Panel::Skills => Panel::Memory,
808                    Panel::Memory => Panel::Resources,
809                    Panel::Resources => Panel::SubAgents,
810                    Panel::SubAgents | Panel::Tasks => Panel::Fleet,
811                    Panel::Fleet => Panel::Chat,
812                };
813            }
814            KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
815                if self.sessions.current().view_target.is_main() {
816                    self.sessions.current_mut().messages.clear();
817                }
818                self.sessions.current_mut().render_cache.clear();
819                self.sessions.current_mut().scroll_offset = 0;
820            }
821            KeyCode::Char('?') => {
822                self.show_help = true;
823            }
824            KeyCode::Char('p') => {
825                self.sessions.current_mut().plan_view_active =
826                    !self.sessions.current().plan_view_active;
827            }
828            KeyCode::Char('f') => {
829                self.active_panel = Panel::Fleet;
830            }
831            KeyCode::Char('a') => {
832                self.active_panel = Panel::SubAgents;
833                // Auto-select first agent if nothing selected yet.
834                if self.subagent_sidebar.selected().is_none() && !self.metrics.sub_agents.is_empty()
835                {
836                    self.subagent_sidebar.list_state.select(Some(0));
837                }
838            }
839            KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
840                self.execute_command(TuiCommand::CopyLastAssistant);
841            }
842            _ => {}
843        }
844    }
845
846    /// Returns the byte offset of the char at the given char index.
847    fn byte_offset_of_char(&self, char_idx: usize) -> usize {
848        self.sessions
849            .current()
850            .input
851            .char_indices()
852            .nth(char_idx)
853            .map_or(self.sessions.current().input.len(), |(i, _)| i)
854    }
855
856    pub(super) fn char_count(&self) -> usize {
857        self.sessions.current().input.chars().count()
858    }
859
860    pub(super) fn prev_word_boundary(&self) -> usize {
861        let chars: Vec<char> = self.sessions.current().input.chars().collect();
862        let mut pos = self.sessions.current().cursor_position;
863        while pos > 0 && !chars[pos - 1].is_alphanumeric() {
864            pos -= 1;
865        }
866        while pos > 0 && chars[pos - 1].is_alphanumeric() {
867            pos -= 1;
868        }
869        pos
870    }
871
872    pub(super) fn next_word_boundary(&self) -> usize {
873        let chars: Vec<char> = self.sessions.current().input.chars().collect();
874        let len = chars.len();
875        let mut pos = self.sessions.current().cursor_position;
876        while pos < len && chars[pos].is_alphanumeric() {
877            pos += 1;
878        }
879        while pos < len && !chars[pos].is_alphanumeric() {
880            pos += 1;
881        }
882        pos
883    }
884
885    pub(super) fn handle_paste(&mut self, text: &str) {
886        if self.sessions.current().input_mode != InputMode::Insert {
887            return;
888        }
889        self.slash_autocomplete = None;
890        let byte_offset = self.byte_offset_of_char(self.sessions.current().cursor_position);
891        self.sessions
892            .current_mut()
893            .input
894            .insert_str(byte_offset, text);
895        self.sessions.current_mut().cursor_position += text.chars().count();
896
897        let line_count = text.matches('\n').count() + 1;
898        if line_count >= 2 {
899            // Replace any existing paste indicator — new paste supersedes the old one.
900            self.sessions.current_mut().paste_state = Some(PasteState {
901                line_count,
902                byte_len: text.len(),
903            });
904        } else {
905            self.sessions.current_mut().paste_state = None;
906        }
907    }
908
909    fn handle_insert_key(&mut self, key: KeyEvent) {
910        // Reverse-search dispatch is checked BEFORE slash-autocomplete so that
911        // printable chars (including '/') typed into the search query are not
912        // stolen by the autocomplete trigger (C4).
913        if self.reverse_search.is_some() {
914            self.handle_reverse_search_key(key);
915            return;
916        }
917        if self.slash_autocomplete.is_some() {
918            self.handle_slash_autocomplete_key(key);
919            return;
920        }
921        if self.handle_insert_text_keys(key) {
922            return;
923        }
924        if self.handle_insert_delete_keys(key) {
925            return;
926        }
927        if self.handle_insert_history_keys(key) {
928            return;
929        }
930        if self.handle_insert_cursor_keys(key) {
931            return;
932        }
933        self.handle_insert_control_keys(key);
934    }
935
936    /// Insert a newline character at the current cursor position.
937    ///
938    /// Shared body for `Shift+Enter` and `Ctrl+J`.
939    fn insert_newline_at_cursor(&mut self) {
940        self.sessions.current_mut().paste_state = None;
941        let byte_offset = self.byte_offset_of_char(self.sessions.current().cursor_position);
942        self.sessions.current_mut().input.insert(byte_offset, '\n');
943        self.sessions.current_mut().cursor_position += 1;
944    }
945
946    /// Handle text insertion keys: Enter (submit), Shift+Enter / Ctrl+J (newline), Esc.
947    ///
948    /// Returns `true` when the key was handled.
949    fn handle_insert_text_keys(&mut self, key: KeyEvent) -> bool {
950        match key.code {
951            KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
952                self.insert_newline_at_cursor();
953            }
954            KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
955                self.insert_newline_at_cursor();
956            }
957            KeyCode::Enter => self.submit_input(),
958            KeyCode::Esc => self.sessions.current_mut().input_mode = InputMode::Normal,
959            _ => return false,
960        }
961        true
962    }
963
964    /// Handle delete keys: Backspace (with or without Alt), Delete.
965    ///
966    /// Returns `true` when the key was handled.
967    fn handle_insert_delete_keys(&mut self, key: KeyEvent) -> bool {
968        match key.code {
969            KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
970                // First edit keystroke after paste reveals raw text; subsequent keystrokes edit normally.
971                self.sessions.current_mut().paste_state = None;
972                let boundary = self.prev_word_boundary();
973                if boundary < self.sessions.current().cursor_position {
974                    let start = self.byte_offset_of_char(boundary);
975                    let end = self.byte_offset_of_char(self.sessions.current().cursor_position);
976                    self.sessions.current_mut().input.drain(start..end);
977                    self.sessions.current_mut().cursor_position = boundary;
978                }
979            }
980            KeyCode::Backspace => {
981                // First edit keystroke after paste reveals raw text; subsequent keystrokes edit normally.
982                self.sessions.current_mut().paste_state = None;
983                if self.sessions.current().cursor_position > 0 {
984                    let byte_offset =
985                        self.byte_offset_of_char(self.sessions.current().cursor_position - 1);
986                    self.sessions.current_mut().input.remove(byte_offset);
987                    self.sessions.current_mut().cursor_position -= 1;
988                }
989            }
990            KeyCode::Delete => {
991                // First edit keystroke after paste reveals raw text; subsequent keystrokes edit normally.
992                self.sessions.current_mut().paste_state = None;
993                if self.sessions.current().cursor_position < self.char_count() {
994                    let byte_offset =
995                        self.byte_offset_of_char(self.sessions.current().cursor_position);
996                    self.sessions.current_mut().input.remove(byte_offset);
997                }
998            }
999            _ => return false,
1000        }
1001        true
1002    }
1003
1004    /// Handle history navigation keys: Up, Down.
1005    ///
1006    /// Returns `true` when the key was handled.
1007    fn handle_insert_history_keys(&mut self, key: KeyEvent) -> bool {
1008        match key.code {
1009            KeyCode::Up => {
1010                self.handle_history_up();
1011            }
1012            KeyCode::Down => {
1013                self.sessions.current_mut().paste_state = None;
1014                let Some(i) = self.sessions.current().history_index else {
1015                    return true;
1016                };
1017                let prefix = &self.sessions.current().draft_input;
1018                let found = self.sessions.current().input_history[i + 1..]
1019                    .iter()
1020                    .position(|e| prefix.is_empty() || e.starts_with(prefix))
1021                    .map(|offset| i + 1 + offset);
1022                if let Some(idx) = found {
1023                    self.sessions.current_mut().history_index = Some(idx);
1024                    let text = self.sessions.current().input_history[idx].clone();
1025                    self.sessions.current_mut().input = text;
1026                } else {
1027                    self.sessions.current_mut().history_index = None;
1028                    self.sessions.current_mut().input =
1029                        std::mem::take(&mut self.sessions.current_mut().draft_input);
1030                }
1031                self.sessions.current_mut().cursor_position = self.char_count();
1032            }
1033            _ => return false,
1034        }
1035        true
1036    }
1037
1038    /// Handle cursor movement keys: Left, Right (with optional Alt), Home, End.
1039    ///
1040    /// Returns `true` when the key was handled.
1041    fn handle_insert_cursor_keys(&mut self, key: KeyEvent) -> bool {
1042        match key.code {
1043            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => {
1044                self.sessions.current_mut().paste_state = None;
1045                self.sessions.current_mut().cursor_position = self.prev_word_boundary();
1046            }
1047            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => {
1048                self.sessions.current_mut().paste_state = None;
1049                self.sessions.current_mut().cursor_position = self.next_word_boundary();
1050            }
1051            KeyCode::Left => {
1052                self.sessions.current_mut().paste_state = None;
1053                self.sessions.current_mut().cursor_position =
1054                    self.sessions.current().cursor_position.saturating_sub(1);
1055            }
1056            KeyCode::Right => {
1057                self.sessions.current_mut().paste_state = None;
1058                if self.sessions.current().cursor_position < self.char_count() {
1059                    self.sessions.current_mut().cursor_position += 1;
1060                }
1061            }
1062            KeyCode::Home => {
1063                self.sessions.current_mut().paste_state = None;
1064                self.sessions.current_mut().cursor_position = 0;
1065            }
1066            KeyCode::End => {
1067                self.sessions.current_mut().paste_state = None;
1068                self.sessions.current_mut().cursor_position = self.char_count();
1069            }
1070            _ => return false,
1071        }
1072        true
1073    }
1074
1075    /// Handle Ctrl-key shortcuts and character insertion (including slash autocomplete trigger).
1076    ///
1077    /// Returns `true` when the key was handled; `false` for unrecognised keys.
1078    fn handle_insert_control_keys(&mut self, key: KeyEvent) -> bool {
1079        match key.code {
1080            KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1081                self.sessions.current_mut().paste_state = None;
1082                self.sessions.current_mut().cursor_position = 0;
1083            }
1084            KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1085                self.sessions.current_mut().paste_state = None;
1086                self.sessions.current_mut().cursor_position = self.char_count();
1087            }
1088            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1089                self.sessions.current_mut().paste_state = None;
1090                self.sessions.current_mut().input.clear();
1091                self.sessions.current_mut().cursor_position = 0;
1092            }
1093            KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1094                let _ = self.user_input_tx.try_send("/clear-queue".to_owned());
1095            }
1096            KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1097                self.execute_command(TuiCommand::CopyLastAssistant);
1098            }
1099            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1100                // Ignore Ctrl+R when slash autocomplete is open — mutual exclusion.
1101                if self.slash_autocomplete.is_none() {
1102                    let history = self.sessions.current().input_history.clone();
1103                    self.reverse_search = Some(
1104                        crate::widgets::reverse_search::ReverseSearchState::new(&history),
1105                    );
1106                }
1107            }
1108            KeyCode::Char('@') => {
1109                self.open_file_picker();
1110            }
1111            KeyCode::Char(c) => {
1112                // First edit keystroke after paste reveals raw text; subsequent keystrokes edit normally.
1113                self.sessions.current_mut().paste_state = None;
1114                let was_empty = self.sessions.current().input.is_empty();
1115                let byte_offset = self.byte_offset_of_char(self.sessions.current().cursor_position);
1116                self.sessions.current_mut().input.insert(byte_offset, c);
1117                self.sessions.current_mut().cursor_position += 1;
1118                if c == '/' && was_empty {
1119                    self.slash_autocomplete = Some(SlashAutocompleteState::new());
1120                }
1121            }
1122            _ => return false,
1123        }
1124        true
1125    }
1126
1127    fn handle_slash_autocomplete_key(&mut self, key: KeyEvent) {
1128        let Some(state) = self.slash_autocomplete.as_mut() else {
1129            return;
1130        };
1131        match key.code {
1132            KeyCode::Esc => {
1133                self.slash_autocomplete = None;
1134            }
1135            KeyCode::Tab | KeyCode::Enter => {
1136                let entry = state.selected_entry().map(|e| e.id);
1137                self.slash_autocomplete = None;
1138                if let Some(id) = entry {
1139                    let slash_form = command_id_to_slash_form(id);
1140                    self.sessions.current_mut().input = slash_form;
1141                    self.sessions.current_mut().cursor_position = self.char_count();
1142                }
1143                if key.code == KeyCode::Enter {
1144                    self.submit_input();
1145                }
1146            }
1147            KeyCode::Down => {
1148                if let Some(s) = self.slash_autocomplete.as_mut() {
1149                    s.move_down();
1150                }
1151            }
1152            KeyCode::Up | KeyCode::BackTab => {
1153                if let Some(s) = self.slash_autocomplete.as_mut() {
1154                    s.move_up();
1155                }
1156            }
1157            KeyCode::Backspace => {
1158                let dismiss = self
1159                    .slash_autocomplete
1160                    .as_mut()
1161                    .is_none_or(SlashAutocompleteState::pop_char);
1162                if dismiss {
1163                    self.sessions.current_mut().input.clear();
1164                    self.sessions.current_mut().cursor_position = 0;
1165                    self.slash_autocomplete = None;
1166                } else {
1167                    let query = self
1168                        .slash_autocomplete
1169                        .as_ref()
1170                        .map_or(String::new(), |s| s.query.clone());
1171                    self.sessions.current_mut().input = format!("/{query}");
1172                    self.sessions.current_mut().cursor_position = self.char_count();
1173                    if self
1174                        .slash_autocomplete
1175                        .as_ref()
1176                        .is_none_or(|s| s.filtered.is_empty())
1177                    {
1178                        self.slash_autocomplete = None;
1179                    }
1180                }
1181            }
1182            KeyCode::Char(c) => {
1183                if let Some(s) = self.slash_autocomplete.as_mut() {
1184                    s.push_char(c);
1185                }
1186                let query = self
1187                    .slash_autocomplete
1188                    .as_ref()
1189                    .map_or(String::new(), |s| s.query.clone());
1190                self.sessions.current_mut().input = format!("/{query}");
1191                self.sessions.current_mut().cursor_position = self.char_count();
1192                if self
1193                    .slash_autocomplete
1194                    .as_ref()
1195                    .is_none_or(|s| s.filtered.is_empty())
1196                {
1197                    self.slash_autocomplete = None;
1198                }
1199            }
1200            _ => {}
1201        }
1202    }
1203
1204    fn handle_reverse_search_key(&mut self, key: KeyEvent) {
1205        let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1206        let is_alt = key.modifiers.contains(KeyModifiers::ALT);
1207        match key.code {
1208            KeyCode::Esc => {
1209                self.reverse_search = None;
1210            }
1211            KeyCode::Enter => {
1212                let selected = self.reverse_search.as_ref().and_then(|s| {
1213                    let hist = &self.sessions.current().input_history;
1214                    s.selected_entry(hist).map(str::to_owned)
1215                });
1216                self.reverse_search = None;
1217                if let Some(text) = selected {
1218                    self.sessions.current_mut().input = text;
1219                    self.sessions.current_mut().cursor_position = self.char_count();
1220                }
1221            }
1222            KeyCode::Char('r') if is_ctrl => {
1223                if let Some(s) = self.reverse_search.as_mut() {
1224                    s.select_next();
1225                }
1226            }
1227            KeyCode::Backspace => {
1228                let history = self.sessions.current().input_history.clone();
1229                if let Some(s) = self.reverse_search.as_mut() {
1230                    s.pop_char(&history);
1231                }
1232            }
1233            KeyCode::Char(c) if !is_ctrl && !is_alt => {
1234                let history = self.sessions.current().input_history.clone();
1235                if let Some(s) = self.reverse_search.as_mut() {
1236                    s.push_char(c, &history);
1237                }
1238            }
1239            _ => {}
1240        }
1241    }
1242
1243    fn handle_history_up(&mut self) {
1244        self.sessions.current_mut().paste_state = None;
1245        if self.sessions.current().input.is_empty()
1246            && self.pending_count > 0
1247            && self.sessions.current().history_index.is_none()
1248        {
1249            if let Some(last) = self.sessions.current_mut().input_history.pop() {
1250                self.sessions.current_mut().input = last;
1251                self.sessions.current_mut().cursor_position = self.char_count();
1252                self.pending_count -= 1;
1253                self.queued_count = self.queued_count.saturating_sub(1);
1254                self.editing_queued = true;
1255                if let Some(pos) = self
1256                    .sessions
1257                    .current_mut()
1258                    .messages
1259                    .iter()
1260                    .rposition(|m| m.role == MessageRole::User)
1261                {
1262                    self.sessions.current_mut().messages.remove(pos);
1263                }
1264                let _ = self.user_input_tx.try_send("/drop-last-queued".to_owned());
1265            }
1266            return;
1267        }
1268        match self.sessions.current().history_index {
1269            None => {
1270                if self.sessions.current().input_history.is_empty() {
1271                    return;
1272                }
1273                self.sessions.current_mut().draft_input = self.sessions.current().input.clone();
1274                let prefix = &self.sessions.current().draft_input;
1275                let found = self
1276                    .sessions
1277                    .current()
1278                    .input_history
1279                    .iter()
1280                    .rposition(|e| prefix.is_empty() || e.starts_with(prefix));
1281                let Some(idx) = found else { return };
1282                self.sessions.current_mut().history_index = Some(idx);
1283                let text = self.sessions.current().input_history[idx].clone();
1284                self.sessions.current_mut().input = text;
1285            }
1286            Some(i) => {
1287                let prefix = &self.sessions.current().draft_input;
1288                let found = self.sessions.current().input_history[..i]
1289                    .iter()
1290                    .rposition(|e| prefix.is_empty() || e.starts_with(prefix));
1291                let Some(idx) = found else { return };
1292                self.sessions.current_mut().history_index = Some(idx);
1293                let text = self.sessions.current().input_history[idx].clone();
1294                self.sessions.current_mut().input = text;
1295            }
1296        }
1297        self.sessions.current_mut().cursor_position = self.char_count();
1298    }
1299
1300    fn open_file_picker(&mut self) {
1301        let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1302        let needs_rebuild = self.file_index.as_ref().is_none_or(FileIndex::is_stale);
1303        if needs_rebuild && self.pending_file_index.is_none() {
1304            self.sessions.current_mut().status_label = Some("indexing files...".to_owned());
1305            let (tx, rx) = oneshot::channel();
1306            tokio::task::spawn_blocking(move || {
1307                let _ = tx.send(FileIndex::build(&root));
1308            });
1309            self.pending_file_index = Some(rx);
1310            return;
1311        }
1312        if let Some(idx) = &self.file_index {
1313            self.file_picker_state = Some(FilePickerState::new(idx));
1314        }
1315    }
1316
1317    /// Checks if the background file index build has completed and, if so,
1318    /// installs the result and opens the picker.
1319    pub fn poll_pending_file_index(&mut self) {
1320        let Some(rx) = self.pending_file_index.as_mut() else {
1321            return;
1322        };
1323        match rx.try_recv() {
1324            Ok(idx) => {
1325                let picker = FilePickerState::new(&idx);
1326                self.file_index = Some(idx);
1327                self.file_picker_state = Some(picker);
1328                self.pending_file_index = None;
1329                self.sessions.current_mut().status_label = None;
1330            }
1331            Err(oneshot::error::TryRecvError::Empty) => {}
1332            Err(oneshot::error::TryRecvError::Closed) => {
1333                self.pending_file_index = None;
1334                self.sessions.current_mut().status_label = None;
1335            }
1336        }
1337    }
1338
1339    fn handle_file_picker_key(&mut self, key: KeyEvent) {
1340        let Some(state) = self.file_picker_state.as_mut() else {
1341            return;
1342        };
1343        match key.code {
1344            KeyCode::Esc => {
1345                self.file_picker_state = None;
1346            }
1347            KeyCode::Enter | KeyCode::Tab => {
1348                if let Some(path) = state.selected_path().map(ToOwned::to_owned) {
1349                    let byte_offset =
1350                        self.byte_offset_of_char(self.sessions.current().cursor_position);
1351                    self.sessions
1352                        .current_mut()
1353                        .input
1354                        .insert_str(byte_offset, &path);
1355                    self.sessions.current_mut().cursor_position += path.chars().count();
1356                }
1357                self.file_picker_state = None;
1358            }
1359            KeyCode::Up => {
1360                state.move_selection(-1);
1361            }
1362            KeyCode::Down => {
1363                state.move_selection(1);
1364            }
1365            KeyCode::Char(c) => {
1366                state.push_char(c);
1367            }
1368            KeyCode::Backspace if !state.pop_char() => {
1369                self.file_picker_state = None;
1370            }
1371            _ => {}
1372        }
1373    }
1374
1375    pub(super) fn submit_input(&mut self) {
1376        let text = self.sessions.current().input.trim().to_string();
1377        if text.is_empty() {
1378            return;
1379        }
1380        // Intercept /session slash commands before forwarding to the agent.
1381        if let Some(cmd) = Self::parse_session_slash(&text) {
1382            self.sessions.current_mut().input.clear();
1383            self.sessions.current_mut().cursor_position = 0;
1384            self.execute_command(cmd);
1385            return;
1386        }
1387        self.sessions.current_mut().show_splash = false;
1388        self.sessions.current_mut().input_history.push(text.clone());
1389        if self.sessions.current().input_history.len() > MAX_INPUT_HISTORY {
1390            let excess = self.sessions.current().input_history.len() - MAX_INPUT_HISTORY;
1391            self.sessions.current_mut().input_history.drain(0..excess);
1392        }
1393        self.sessions.current_mut().history_index = None;
1394        self.sessions.current_mut().draft_input.clear();
1395        let paste_lines = self
1396            .sessions
1397            .current_mut()
1398            .paste_state
1399            .take()
1400            .map(|p| p.line_count);
1401        let mut msg = ChatMessage::new(MessageRole::User, text.clone());
1402        msg.paste_line_count = paste_lines;
1403        self.sessions.current_mut().messages.push(msg);
1404        self.trim_messages();
1405        self.sessions.current_mut().input.clear();
1406        self.sessions.current_mut().cursor_position = 0;
1407        self.sessions.current_mut().scroll_offset = 0;
1408        self.editing_queued = false;
1409        self.pending_count += 1;
1410
1411        // Non-blocking send; capacity 32 — silent drop if agent loop is saturated.
1412        // Message is visible in chat but not processed; acceptable for interactive TUI.
1413        let _ = self.user_input_tx.try_send(text);
1414    }
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use tokio::sync::mpsc;
1420
1421    use super::*;
1422    use crate::event::AgentEvent;
1423    use crate::types::MessageRole;
1424
1425    fn make_app() -> (App, mpsc::Receiver<String>, mpsc::Sender<AgentEvent>) {
1426        let (user_tx, user_rx) = mpsc::channel(16);
1427        let (agent_tx, agent_rx) = mpsc::channel(16);
1428        let mut app = App::new(user_tx, agent_rx);
1429        app.sessions.current_mut().messages.clear();
1430        (app, user_rx, agent_tx)
1431    }
1432
1433    #[test]
1434    fn last_assistant_content_returns_none_when_empty() {
1435        let (app, _rx, _tx) = make_app();
1436        assert_eq!(app.last_assistant_content(), None);
1437    }
1438
1439    #[test]
1440    fn last_assistant_content_returns_none_when_only_user_messages() {
1441        let (mut app, _rx, _tx) = make_app();
1442        app.sessions
1443            .current_mut()
1444            .messages
1445            .push(ChatMessage::new(MessageRole::User, "hello"));
1446        assert_eq!(app.last_assistant_content(), None);
1447    }
1448
1449    #[test]
1450    fn last_assistant_content_returns_latest() {
1451        let (mut app, _rx, _tx) = make_app();
1452        app.sessions
1453            .current_mut()
1454            .messages
1455            .push(ChatMessage::new(MessageRole::Assistant, "first"));
1456        app.sessions
1457            .current_mut()
1458            .messages
1459            .push(ChatMessage::new(MessageRole::User, "follow-up"));
1460        app.sessions
1461            .current_mut()
1462            .messages
1463            .push(ChatMessage::new(MessageRole::Assistant, "second"));
1464        assert_eq!(app.last_assistant_content(), Some("second".to_owned()));
1465    }
1466
1467    #[test]
1468    fn slash_copy_parses_to_copy_last_assistant() {
1469        assert_eq!(
1470            App::parse_session_slash("/copy"),
1471            Some(TuiCommand::CopyLastAssistant)
1472        );
1473    }
1474
1475    #[test]
1476    fn slash_copy_case_insensitive() {
1477        assert_eq!(
1478            App::parse_session_slash("/COPY"),
1479            Some(TuiCommand::CopyLastAssistant)
1480        );
1481    }
1482
1483    #[test]
1484    fn slash_unknown_returns_none() {
1485        assert_eq!(App::parse_session_slash("/unknown"), None);
1486    }
1487}