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