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