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