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