1use std::sync::Arc;
9
10use tokio::sync::{Notify, mpsc, oneshot, watch};
11use tracing::debug;
12use zeph_common::task_supervisor::TaskSupervisor;
13
14use crate::command::TuiCommand;
15use crate::event::AgentEvent;
16use crate::file_picker::{FileIndex, FilePickerState};
17use crate::hyperlink::HyperlinkSpan;
18use crate::metrics::MetricsSnapshot;
19use crate::session::SessionRegistry;
20use crate::widgets::command_palette::CommandPaletteState;
21use crate::widgets::slash_autocomplete::SlashAutocompleteState;
22
23pub use crate::render_cache::{RenderCache, RenderCacheEntry, RenderCacheKey, content_hash};
24pub use crate::types::{ChatMessage, InputMode, MessageRole};
25
26use crate::types::PasteState;
27
28const MAX_INPUT_HISTORY: usize = 500;
30const MAX_VISIBLE_INPUT_LINES: u16 = 3;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Panel {
46 Chat,
48 Skills,
50 Memory,
52 Resources,
54 SubAgents,
56 Tasks,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum AgentViewTarget {
78 Main,
80 SubAgent {
82 id: String,
84 name: String,
86 },
87}
88
89impl AgentViewTarget {
90 #[must_use]
102 pub fn is_main(&self) -> bool {
103 matches!(self, Self::Main)
104 }
105
106 #[must_use]
118 pub fn subagent_id(&self) -> Option<&str> {
119 if let Self::SubAgent { id, .. } = self {
120 Some(id)
121 } else {
122 None
123 }
124 }
125
126 #[must_use]
138 pub fn subagent_name(&self) -> Option<&str> {
139 if let Self::SubAgent { name, .. } = self {
140 Some(name)
141 } else {
142 None
143 }
144 }
145}
146
147#[derive(Debug, Clone)]
167pub struct TuiTranscriptEntry {
168 pub role: String,
169 pub content: String,
170 pub tool_name: Option<zeph_common::ToolName>,
171 pub timestamp: Option<String>,
172}
173
174impl TuiTranscriptEntry {
175 #[must_use]
198 pub fn to_chat_message(&self) -> ChatMessage {
199 let role = match self.role.as_str() {
200 "user" => MessageRole::User,
201 "assistant" => MessageRole::Assistant,
202 "tool" => MessageRole::Tool,
203 _ => MessageRole::System,
204 };
205 let mut msg = ChatMessage::new(role, self.content.clone());
206 if let Some(ref name) = self.tool_name {
207 msg.tool_name = Some(name.clone());
208 }
209 if let Some(ref ts) = self.timestamp {
210 msg.timestamp.clone_from(ts);
211 }
212 msg
213 }
214}
215
216pub struct TranscriptCache {
221 pub agent_id: String,
223 pub entries: Vec<TuiTranscriptEntry>,
225 pub turns_at_load: u32,
227 pub total_in_file: usize,
229}
230
231pub struct SubAgentSidebarState {
246 pub list_state: ratatui::widgets::ListState,
248}
249
250impl SubAgentSidebarState {
251 #[must_use]
262 pub fn new() -> Self {
263 Self {
264 list_state: ratatui::widgets::ListState::default(),
265 }
266 }
267
268 pub fn select_next(&mut self, count: usize) {
272 if count == 0 {
273 return;
274 }
275 let next = match self.list_state.selected() {
276 Some(i) => (i + 1).min(count - 1),
277 None => 0,
278 };
279 self.list_state.select(Some(next));
280 }
281
282 pub fn select_prev(&mut self, count: usize) {
286 if count == 0 {
287 return;
288 }
289 let prev = match self.list_state.selected() {
290 Some(0) | None => 0,
291 Some(i) => i - 1,
292 };
293 self.list_state.select(Some(prev));
294 }
295
296 pub fn clamp(&mut self, count: usize) {
298 if count == 0 {
299 self.list_state.select(None);
300 } else if self.list_state.selected().is_some_and(|i| i >= count) {
301 self.list_state.select(Some(count - 1));
302 }
303 }
304
305 #[must_use]
318 pub fn selected(&self) -> Option<usize> {
319 self.list_state.selected()
320 }
321}
322
323impl Default for SubAgentSidebarState {
324 fn default() -> Self {
325 Self::new()
326 }
327}
328
329pub struct ConfirmState {
330 pub prompt: String,
331 pub response_tx: Option<oneshot::Sender<bool>>,
332}
333
334pub struct ElicitationState {
335 pub dialog: crate::widgets::elicitation::ElicitationDialogState,
336 pub response_tx: Option<oneshot::Sender<zeph_core::channel::ElicitationResponse>>,
337}
338
339#[allow(clippy::struct_excessive_bools)] pub struct App {
364 pub(crate) sessions: SessionRegistry,
366
367 show_side_panels: bool,
369 show_help: bool,
370 pub metrics: MetricsSnapshot,
371 metrics_rx: Option<watch::Receiver<MetricsSnapshot>>,
372 active_panel: Panel,
373 tool_expanded: bool,
374 compact_tools: bool,
375 show_source_labels: bool,
376 throbber_state: throbber_widgets_tui::ThrobberState,
377 confirm_state: Option<ConfirmState>,
378 elicitation_state: Option<ElicitationState>,
379 command_palette: Option<CommandPaletteState>,
380 command_tx: Option<mpsc::Sender<TuiCommand>>,
381 file_picker_state: Option<FilePickerState>,
382 file_index: Option<FileIndex>,
383 slash_autocomplete: Option<SlashAutocompleteState>,
384 pub should_quit: bool,
385 user_input_tx: mpsc::Sender<String>,
386 agent_event_rx: mpsc::Receiver<AgentEvent>,
387 queued_count: usize,
389 pending_count: usize,
390 editing_queued: bool,
391 hyperlinks: Vec<HyperlinkSpan>,
392 cancel_signal: Option<Arc<Notify>>,
393 pending_file_index: Option<oneshot::Receiver<FileIndex>>,
394 pub subagent_sidebar: SubAgentSidebarState,
396 task_supervisor: Option<TaskSupervisor>,
398 show_task_panel: bool,
400 cached_task_snapshots: Vec<zeph_common::task_supervisor::TaskSnapshot>,
405}
406
407impl App {
408 #[must_use]
431 pub fn new(
432 user_input_tx: mpsc::Sender<String>,
433 agent_event_rx: mpsc::Receiver<AgentEvent>,
434 ) -> Self {
435 Self {
436 sessions: SessionRegistry::bootstrap(),
437 show_side_panels: true,
438 show_help: false,
439 metrics: MetricsSnapshot::default(),
440 metrics_rx: None,
441 active_panel: Panel::Chat,
442 tool_expanded: false,
443 compact_tools: false,
444 show_source_labels: false,
445 throbber_state: throbber_widgets_tui::ThrobberState::default(),
446 confirm_state: None,
447 elicitation_state: None,
448 command_palette: None,
449 command_tx: None,
450 file_picker_state: None,
451 file_index: None,
452 slash_autocomplete: None,
453 should_quit: false,
454 user_input_tx,
455 agent_event_rx,
456 queued_count: 0,
457 pending_count: 0,
458 editing_queued: false,
459 hyperlinks: Vec::new(),
460 cancel_signal: None,
461 pending_file_index: None,
462 subagent_sidebar: SubAgentSidebarState::new(),
463 task_supervisor: None,
464 show_task_panel: false,
465 cached_task_snapshots: Vec::new(),
466 }
467 }
468
469 #[must_use]
473 pub fn show_splash(&self) -> bool {
474 self.sessions.current().show_splash
475 }
476
477 #[must_use]
482 pub fn show_side_panels(&self) -> bool {
483 self.show_side_panels
484 }
485
486 #[must_use]
488 pub fn plan_view_active(&self) -> bool {
489 self.sessions.current().plan_view_active
490 }
491
492 #[must_use]
496 pub fn render_cache(&self) -> &RenderCache {
497 &self.sessions.current().render_cache
498 }
499
500 pub fn render_cache_mut(&mut self) -> &mut RenderCache {
502 &mut self.sessions.current_mut().render_cache
503 }
504
505 #[must_use]
507 pub fn view_target(&self) -> &AgentViewTarget {
508 &self.sessions.current().view_target
509 }
510
511 #[must_use]
513 pub fn transcript_cache(&self) -> Option<&TranscriptCache> {
514 self.sessions.current().transcript_cache.as_ref()
515 }
516
517 pub fn load_history(&mut self, messages: &[(&str, &str)]) {
524 const TOOL_SUFFIX: &str = "\n```";
525
526 for &(role_str, content) in messages {
527 if role_str == "user"
528 && let Some((tool_name, body)) = parse_tool_output(content, TOOL_SUFFIX)
529 {
530 self.sessions
531 .current_mut()
532 .messages
533 .push(ChatMessage::new(MessageRole::Tool, body).with_tool(tool_name.into()));
534 continue;
535 }
536
537 let role = match role_str {
538 "user" => MessageRole::User,
539 "assistant" => {
540 if is_tool_use_only(content) {
541 continue;
542 }
543 MessageRole::Assistant
544 }
545 _ => continue,
546 };
547 if role == MessageRole::User {
548 self.sessions
549 .current_mut()
550 .input_history
551 .push(content.to_owned());
552 }
553 self.sessions
554 .current_mut()
555 .messages
556 .push(ChatMessage::new(role, content));
557 }
558 self.trim_messages();
560 if !self.sessions.current().messages.is_empty() {
561 self.sessions.current_mut().show_splash = false;
562 }
563 }
564
565 #[must_use]
580 pub fn with_cancel_signal(mut self, signal: Arc<Notify>) -> Self {
581 self.cancel_signal = Some(signal);
582 self
583 }
584
585 #[must_use]
602 pub fn with_metrics_rx(mut self, rx: watch::Receiver<MetricsSnapshot>) -> Self {
603 self.metrics = rx.borrow().clone();
604 self.metrics_rx = Some(rx);
605 self
606 }
607
608 #[must_use]
622 pub fn with_command_tx(mut self, tx: mpsc::Sender<TuiCommand>) -> Self {
623 self.command_tx = Some(tx);
624 self
625 }
626
627 #[must_use]
648 pub fn with_task_supervisor(mut self, supervisor: TaskSupervisor) -> Self {
649 self.task_supervisor = Some(supervisor);
650 self
651 }
652
653 pub(crate) fn refresh_task_snapshots(&mut self) {
658 self.cached_task_snapshots = self
659 .task_supervisor
660 .as_ref()
661 .map(TaskSupervisor::snapshot)
662 .unwrap_or_default();
663 }
664
665 #[must_use]
671 pub fn supervisor_activity_label(&self) -> Option<String> {
672 self.task_supervisor.as_ref()?;
673 let mut active = self
674 .cached_task_snapshots
675 .iter()
676 .filter(|t| {
677 matches!(
678 t.status,
679 zeph_common::task_supervisor::TaskStatus::Running
680 | zeph_common::task_supervisor::TaskStatus::Restarting { .. }
681 )
682 })
683 .filter(|t| !t.name.starts_with("mem-"))
684 .peekable();
685 let first = active.next()?;
686 let label = if active.peek().is_none() {
687 first.name.to_string()
688 } else {
689 let extra = active.count() + 1; format!("{} +{} more", first.name, extra)
691 };
692 let truncated: String = label.chars().take(38).collect();
694 Some(truncated)
695 }
696
697 pub fn set_cancel_signal(&mut self, signal: Arc<Notify>) {
702 self.cancel_signal = Some(signal);
703 }
704
705 pub fn set_metrics_rx(&mut self, rx: watch::Receiver<MetricsSnapshot>) {
710 self.metrics = rx.borrow().clone();
711 self.metrics_rx = Some(rx);
712 }
713
714 pub fn poll_metrics(&mut self) {
719 if let Some(ref mut rx) = self.metrics_rx
720 && rx.has_changed().unwrap_or(false)
721 {
722 let new_metrics = rx.borrow_and_update().clone();
723 let new_graph_id = new_metrics
726 .orchestration_graph
727 .as_ref()
728 .map(|s| &s.graph_id);
729 let old_graph_id = self
730 .metrics
731 .orchestration_graph
732 .as_ref()
733 .map(|s| &s.graph_id);
734 if new_graph_id != old_graph_id && new_graph_id.is_some() {
735 self.sessions.current_mut().plan_view_active = false;
736 }
737 self.metrics = new_metrics;
738 }
739 let count = self.metrics.sub_agents.len();
741 self.subagent_sidebar.clamp(count);
742 self.maybe_reload_transcript();
744 }
745
746 pub fn set_view_target(&mut self, target: AgentViewTarget) {
749 if self.sessions.current().view_target == target {
750 return;
751 }
752 self.sessions.current_mut().view_target = target;
753 self.sessions.current_mut().render_cache.clear();
754 self.sessions.current_mut().scroll_offset = 0;
755 self.sessions.current_mut().transcript_cache = None;
756 self.sessions.current_mut().pending_transcript = None;
757 if let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target {
759 let id = id.clone();
760 self.start_transcript_load(&id);
761 }
762 }
763
764 fn start_transcript_load(&mut self, agent_id: &str) {
766 let transcript_path = self
768 .metrics
769 .sub_agents
770 .iter()
771 .find(|sa| sa.id == agent_id)
772 .and_then(|sa| sa.transcript_dir.as_deref())
773 .map(|dir| std::path::PathBuf::from(dir).join(format!("{agent_id}.jsonl")));
774
775 let Some(path) = transcript_path else {
776 return;
777 };
778
779 let (tx, rx) = oneshot::channel();
780 self.sessions.current_mut().pending_transcript = Some(rx);
781 let is_active = self
783 .metrics
784 .sub_agents
785 .iter()
786 .find(|sa| sa.id == agent_id)
787 .is_some_and(|sa| matches!(sa.state.as_str(), "working" | "submitted"));
788
789 tokio::task::spawn_blocking(move || {
790 let result = load_transcript_file(&path, is_active);
791 let _ = tx.send(result);
792 });
793 }
794
795 pub fn poll_pending_transcript(&mut self) {
797 let Some(rx) = self.sessions.current_mut().pending_transcript.as_mut() else {
798 return;
799 };
800 match rx.try_recv() {
801 Ok((entries, total)) => {
802 self.sessions.current_mut().pending_transcript = None;
803 let turns_at_load = self
804 .sessions
805 .current()
806 .view_target
807 .subagent_id()
808 .and_then(|id| self.metrics.sub_agents.iter().find(|sa| sa.id == id))
809 .map_or(0, |sa| sa.turns_used);
810 if let AgentViewTarget::SubAgent { ref id, .. } =
811 self.sessions.current().view_target.clone()
812 {
813 self.sessions.current_mut().transcript_cache = Some(TranscriptCache {
814 agent_id: id.clone(),
815 entries,
816 turns_at_load,
817 total_in_file: total,
818 });
819 }
820 self.sessions.current_mut().render_cache.clear();
821 }
822 Err(oneshot::error::TryRecvError::Empty) => {}
823 Err(oneshot::error::TryRecvError::Closed) => {
824 self.sessions.current_mut().pending_transcript = None;
825 }
826 }
827 }
828
829 fn maybe_reload_transcript(&mut self) {
831 let AgentViewTarget::SubAgent { ref id, .. } = self.sessions.current().view_target.clone()
832 else {
833 return;
834 };
835 if self.sessions.current().pending_transcript.is_some() {
837 return;
838 }
839 let current_turns = self
840 .metrics
841 .sub_agents
842 .iter()
843 .find(|sa| sa.id == *id)
844 .map_or(0, |sa| sa.turns_used);
845 let cached_turns = self
846 .sessions
847 .current()
848 .transcript_cache
849 .as_ref()
850 .map_or(0, |c| c.turns_at_load);
851 if current_turns > cached_turns {
852 let agent_id = id.to_owned();
853 self.start_transcript_load(&agent_id);
854 }
855 }
856
857 #[must_use]
864 pub fn visible_messages(&self) -> Vec<ChatMessage> {
865 let slot = self.sessions.current();
866 if slot.view_target.is_main() {
867 return slot.messages.clone();
868 }
869 if let Some(ref cache) = slot.transcript_cache {
870 return cache
871 .entries
872 .iter()
873 .map(TuiTranscriptEntry::to_chat_message)
874 .collect();
875 }
876 if slot.pending_transcript.is_some() {
877 return vec![ChatMessage::new(
878 MessageRole::System,
879 "Loading transcript...".to_owned(),
880 )];
881 }
882 let name = slot.view_target.subagent_name().unwrap_or("unknown");
883 vec![ChatMessage::new(
884 MessageRole::System,
885 format!("Transcript not available for {name}."),
886 )]
887 }
888
889 #[must_use]
891 pub fn transcript_truncation_info(&self) -> Option<String> {
892 let cache = self.sessions.current().transcript_cache.as_ref()?;
893 if cache.total_in_file > TRANSCRIPT_MAX_ENTRIES {
894 Some(format!(
895 "[showing last {TRANSCRIPT_MAX_ENTRIES} of {} messages]",
896 cache.total_in_file
897 ))
898 } else {
899 None
900 }
901 }
902
903 fn trim_messages(&mut self) {
908 self.sessions.current_mut().trim_messages();
909 }
910
911 #[must_use]
916 pub fn messages(&self) -> &[ChatMessage] {
917 &self.sessions.current().messages
918 }
919
920 #[must_use]
922 pub fn input(&self) -> &str {
923 &self.sessions.current().input
924 }
925
926 #[must_use]
928 pub fn input_mode(&self) -> InputMode {
929 self.sessions.current().input_mode
930 }
931
932 #[must_use]
934 pub fn cursor_position(&self) -> usize {
935 self.sessions.current().cursor_position
936 }
937
938 #[must_use]
940 pub(crate) fn desired_input_height(&self) -> u16 {
941 let content_lines = self.input_line_count().min(MAX_VISIBLE_INPUT_LINES);
942 content_lines.saturating_add(2)
943 }
944
945 #[must_use]
947 pub(crate) fn input_line_count(&self) -> u16 {
948 if self.sessions.current().paste_state.is_some()
949 || (self.sessions.current().input.is_empty()
950 && matches!(self.sessions.current().input_mode, InputMode::Insert))
951 {
952 1
953 } else {
954 u16::try_from(self.sessions.current().input.matches('\n').count() + 1)
955 .unwrap_or(u16::MAX)
956 }
957 }
958
959 #[must_use]
963 pub fn scroll_offset(&self) -> usize {
964 self.sessions.current().scroll_offset
965 }
966
967 fn auto_scroll(&mut self) {
969 if self.sessions.current().scroll_offset <= 1 {
970 self.sessions.current_mut().scroll_offset = 0;
971 }
972 }
973
974 #[must_use]
976 pub fn tool_expanded(&self) -> bool {
977 self.tool_expanded
978 }
979
980 #[must_use]
985 pub fn paste_state(&self) -> Option<&PasteState> {
986 self.sessions.current().paste_state.as_ref()
987 }
988
989 #[must_use]
991 pub fn compact_tools(&self) -> bool {
992 self.compact_tools
993 }
994
995 #[must_use]
997 pub fn show_source_labels(&self) -> bool {
998 self.show_source_labels
999 }
1000
1001 pub fn set_show_source_labels(&mut self, v: bool) {
1006 if self.show_source_labels != v {
1007 self.show_source_labels = v;
1008 self.sessions.current_mut().render_cache.clear();
1009 }
1010 }
1011
1012 pub fn set_hyperlinks(&mut self, links: Vec<HyperlinkSpan>) {
1017 self.hyperlinks = links;
1018 }
1019
1020 pub fn take_hyperlinks(&mut self) -> Vec<HyperlinkSpan> {
1024 std::mem::take(&mut self.hyperlinks)
1025 }
1026
1027 #[must_use]
1032 pub fn status_label(&self) -> Option<&str> {
1033 self.sessions.current().status_label.as_deref()
1034 }
1035
1036 #[must_use]
1040 pub fn queued_count(&self) -> usize {
1041 self.queued_count.max(self.pending_count)
1042 }
1043
1044 #[must_use]
1046 pub fn editing_queued(&self) -> bool {
1047 self.editing_queued
1048 }
1049
1050 #[must_use]
1054 pub fn is_agent_busy(&self) -> bool {
1055 self.sessions.current().status_label.is_some()
1056 || self
1057 .sessions
1058 .current()
1059 .messages
1060 .last()
1061 .is_some_and(|m| m.streaming)
1062 }
1063
1064 #[must_use]
1066 pub fn has_running_tool(&self) -> bool {
1067 self.sessions
1068 .current()
1069 .messages
1070 .last()
1071 .is_some_and(|m| m.role == MessageRole::Tool && m.streaming)
1072 }
1073
1074 #[must_use]
1078 pub fn throbber_state(&self) -> &throbber_widgets_tui::ThrobberState {
1079 &self.throbber_state
1080 }
1081
1082 pub fn throbber_state_mut(&mut self) -> &mut throbber_widgets_tui::ThrobberState {
1086 &mut self.throbber_state
1087 }
1088}
1089
1090mod draw;
1091mod events;
1092mod keys;
1093
1094pub const TRANSCRIPT_MAX_ENTRIES: usize = 200;
1096
1097fn load_transcript_file(
1104 path: &std::path::Path,
1105 is_active: bool,
1106) -> (Vec<TuiTranscriptEntry>, usize) {
1107 let Ok(content) = std::fs::read_to_string(path) else {
1108 return (Vec::new(), 0);
1109 };
1110
1111 let lines: Vec<&str> = content.lines().collect();
1112 let total = lines.len();
1113 if total == 0 {
1114 return (Vec::new(), 0);
1115 }
1116
1117 let parse_end = if is_active && total > 0 {
1119 let last = lines[total - 1].trim();
1120 if last.ends_with('}') {
1122 total
1123 } else {
1124 total - 1
1125 }
1126 } else {
1127 total
1128 };
1129
1130 let entries: Vec<TuiTranscriptEntry> = lines[..parse_end]
1131 .iter()
1132 .filter_map(|line| {
1133 let line = line.trim();
1134 if line.is_empty() {
1135 return None;
1136 }
1137 let v: serde_json::Value = serde_json::from_str(line).ok()?;
1140 let (role, content, tool_name, timestamp) = if let Some(msg) = v.get("message") {
1144 let role = msg
1145 .get("role")
1146 .and_then(|r| r.as_str())
1147 .unwrap_or("system")
1148 .to_owned();
1149 let content = msg
1151 .get("parts")
1152 .and_then(|p| p.as_array())
1153 .and_then(|arr| arr.first())
1154 .and_then(|part| part.get("content"))
1155 .and_then(|c| c.as_str())
1156 .or_else(|| msg.get("content").and_then(|c| c.as_str()))
1157 .unwrap_or("")
1158 .to_owned();
1159 let tool_name = msg
1160 .get("tool_name")
1161 .and_then(|t| t.as_str())
1162 .map(zeph_common::ToolName::new);
1163 let timestamp = v
1164 .get("timestamp")
1165 .and_then(|t| t.as_str())
1166 .map(ToOwned::to_owned);
1167 (role, content, tool_name, timestamp)
1168 } else {
1169 let role = v
1171 .get("role")
1172 .and_then(|r| r.as_str())
1173 .unwrap_or("system")
1174 .to_owned();
1175 let content = v
1176 .get("content")
1177 .and_then(|c| c.as_str())
1178 .unwrap_or("")
1179 .to_owned();
1180 let tool_name = v
1181 .get("tool_name")
1182 .and_then(|t| t.as_str())
1183 .map(zeph_common::ToolName::new);
1184 let timestamp = v
1185 .get("timestamp")
1186 .and_then(|t| t.as_str())
1187 .map(ToOwned::to_owned);
1188 (role, content, tool_name, timestamp)
1189 };
1190
1191 if content.is_empty() && tool_name.is_none() {
1192 return None;
1193 }
1194
1195 Some(TuiTranscriptEntry {
1196 role,
1197 content,
1198 tool_name,
1199 timestamp,
1200 })
1201 })
1202 .collect();
1203
1204 let truncated: Vec<TuiTranscriptEntry> = if entries.len() > TRANSCRIPT_MAX_ENTRIES {
1206 entries
1207 .into_iter()
1208 .rev()
1209 .take(TRANSCRIPT_MAX_ENTRIES)
1210 .rev()
1211 .collect()
1212 } else {
1213 entries
1214 };
1215
1216 (truncated, total)
1217}
1218
1219fn format_security_report(metrics: &MetricsSnapshot) -> String {
1220 use crate::metrics::SecurityEventCategory;
1221
1222 let n = metrics.security_events.len();
1223 if n == 0 {
1224 return "Security event history (0 events)\n\nNo events recorded.".to_owned();
1225 }
1226
1227 let mut lines = vec![format!("Security event history ({n} events):")];
1228 for ev in &metrics.security_events {
1229 #[allow(clippy::cast_possible_wrap)]
1230 let ts = chrono::DateTime::from_timestamp(ev.timestamp as i64, 0).map_or_else(
1231 || "??:??:??".to_owned(),
1232 |dt| {
1233 dt.with_timezone(&chrono::Local)
1234 .format("%H:%M:%S")
1235 .to_string()
1236 },
1237 );
1238 let cat = match ev.category {
1239 SecurityEventCategory::InjectionFlag => "INJECTION_FLAG ",
1240 SecurityEventCategory::InjectionBlocked => "INJECT_BLOCKED ",
1241 SecurityEventCategory::ExfiltrationBlock => "EXFIL_BLOCK ",
1242 SecurityEventCategory::Quarantine => "QUARANTINE ",
1243 SecurityEventCategory::Truncation => "TRUNCATION ",
1244 SecurityEventCategory::RateLimit => "RATE_LIMIT ",
1245 SecurityEventCategory::MemoryValidation => "MEM_VALIDATION ",
1246 SecurityEventCategory::PreExecutionBlock => "PRE_EXEC_BLOCK ",
1247 SecurityEventCategory::PreExecutionWarn => "PRE_EXEC_WARN ",
1248 SecurityEventCategory::ResponseVerification => "RESP_VERIFY ",
1249 SecurityEventCategory::CausalIpiFlag => "CAUSAL_IPI ",
1250 SecurityEventCategory::CrossBoundaryMcpToAcp => "CROSS_BOUNDARY ",
1251 SecurityEventCategory::VigilFlag => "VIGIL_FLAG ",
1252 };
1253 lines.push(format!(" [{ts}] {cat} {:<20} {}", ev.source, ev.detail));
1254 }
1255 lines.push(String::new());
1256 lines.push("Totals:".to_owned());
1257 lines.push(format!(
1258 " Sanitizer runs: {} | Flags: {} | Truncations: {}",
1259 metrics.sanitizer_runs, metrics.sanitizer_injection_flags, metrics.sanitizer_truncations,
1260 ));
1261 lines.push(format!(
1262 " Quarantine: {} ({} failures)",
1263 metrics.quarantine_invocations, metrics.quarantine_failures,
1264 ));
1265 lines.push(format!(
1266 " Exfiltration: {} images | {} URLs | {} memory",
1267 metrics.exfiltration_images_blocked,
1268 metrics.exfiltration_tool_urls_flagged,
1269 metrics.exfiltration_memory_guards,
1270 ));
1271 lines.join("\n")
1272}
1273
1274fn is_tool_use_only(content: &str) -> bool {
1275 let trimmed = content.trim();
1276 if trimmed.is_empty() {
1277 return false;
1278 }
1279 let mut rest = trimmed;
1280 while let Some(start) = rest.find("[tool_use: ") {
1281 if !rest[..start].trim().is_empty() {
1282 return false;
1283 }
1284 let after = &rest[start + "[tool_use: ".len()..];
1285 let Some(end) = after.find(']') else {
1286 return false;
1287 };
1288 rest = after[end + 1..].trim_start();
1289 }
1290 rest.is_empty()
1291}
1292
1293fn parse_tool_output(content: &str, suffix: &str) -> Option<(String, String)> {
1294 if let Some(rest) = content.strip_prefix("[tool output: ")
1296 && let Some(header_end) = rest.find("]\n```\n")
1297 {
1298 let name = rest[..header_end].to_owned();
1299 let body_start = header_end + "]\n```\n".len();
1300 let body_part = &rest[body_start..];
1301 let body = body_part.strip_suffix(suffix).unwrap_or(body_part);
1302 return Some((name, body.to_owned()));
1303 }
1304 if let Some(rest) = content.strip_prefix("[tool output]\n```\n") {
1306 let body = rest.strip_suffix(suffix).unwrap_or(rest);
1307 let name = if body.starts_with("$ ") {
1308 "bash"
1309 } else {
1310 "tool"
1311 };
1312 return Some((name.to_owned(), body.to_owned()));
1313 }
1314 if let Some(rest) = content.strip_prefix("[tool_result: ") {
1316 let body = rest.find("]\n").map_or("", |i| &rest[i + 2..]);
1317 let name = if body.contains("$ ") { "bash" } else { "tool" };
1318 return Some((name.to_owned(), body.to_owned()));
1319 }
1320 None
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325 use super::*;
1326 use crate::event::{AgentEvent, AppEvent};
1327 use crate::session::MAX_TUI_MESSAGES;
1328 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1329
1330 fn make_app() -> (App, mpsc::Receiver<String>, mpsc::Sender<AgentEvent>) {
1331 let (user_tx, user_rx) = mpsc::channel(16);
1332 let (agent_tx, agent_rx) = mpsc::channel(16);
1333 let mut app = App::new(user_tx, agent_rx);
1334 app.sessions.current_mut().messages.clear();
1335 (app, user_rx, agent_tx)
1336 }
1337
1338 #[test]
1339 fn initial_state() {
1340 let (app, _rx, _tx) = make_app();
1341 assert!(app.input().is_empty());
1342 assert_eq!(app.input_mode(), InputMode::Insert);
1343 assert!(app.messages().is_empty());
1344 assert!(app.show_splash());
1345 assert!(!app.should_quit);
1346 }
1347
1348 #[test]
1349 fn ctrl_c_quits() {
1350 let (mut app, _rx, _tx) = make_app();
1351 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
1352 app.handle_event(AppEvent::Key(key));
1353 assert!(app.should_quit);
1354 }
1355
1356 #[test]
1357 fn insert_mode_typing() {
1358 let (mut app, _rx, _tx) = make_app();
1359 app.sessions.current_mut().input_mode = InputMode::Insert;
1360 let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
1361 app.handle_event(AppEvent::Key(key));
1362 assert_eq!(app.input(), "a");
1363 assert_eq!(app.cursor_position(), 1);
1364 }
1365
1366 #[test]
1367 fn escape_switches_to_normal() {
1368 let (mut app, _rx, _tx) = make_app();
1369 app.sessions.current_mut().input_mode = InputMode::Insert;
1370 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1371 app.handle_event(AppEvent::Key(key));
1372 assert_eq!(app.input_mode(), InputMode::Normal);
1373 }
1374
1375 #[test]
1376 fn i_enters_insert_mode() {
1377 let (mut app, _rx, _tx) = make_app();
1378 app.sessions.current_mut().input_mode = InputMode::Normal;
1379 let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
1380 app.handle_event(AppEvent::Key(key));
1381 assert_eq!(app.input_mode(), InputMode::Insert);
1382 }
1383
1384 #[test]
1385 fn q_quits_in_normal_mode() {
1386 let (mut app, _rx, _tx) = make_app();
1387 app.sessions.current_mut().input_mode = InputMode::Normal;
1388 let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
1389 app.handle_event(AppEvent::Key(key));
1390 assert!(app.should_quit);
1391 }
1392
1393 #[test]
1394 fn backspace_deletes_char() {
1395 let (mut app, _rx, _tx) = make_app();
1396 app.sessions.current_mut().input_mode = InputMode::Insert;
1397 app.sessions.current_mut().input = "ab".into();
1398 app.sessions.current_mut().cursor_position = 2;
1399 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
1400 app.handle_event(AppEvent::Key(key));
1401 assert_eq!(app.input(), "a");
1402 assert_eq!(app.cursor_position(), 1);
1403 }
1404
1405 #[test]
1406 fn enter_submits_input() {
1407 let (mut app, mut rx, _tx) = make_app();
1408 app.sessions.current_mut().input_mode = InputMode::Insert;
1409 app.sessions.current_mut().input = "hello".into();
1410 app.sessions.current_mut().cursor_position = 5;
1411 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
1412 app.handle_event(AppEvent::Key(key));
1413 assert!(app.input().is_empty());
1414 assert_eq!(app.messages().len(), 1);
1415 assert_eq!(app.messages()[0].content, "hello");
1416
1417 let sent = rx.try_recv().unwrap();
1418 assert_eq!(sent, "hello");
1419 }
1420
1421 #[test]
1422 fn empty_enter_does_not_submit() {
1423 let (mut app, mut rx, _tx) = make_app();
1424 app.sessions.current_mut().input_mode = InputMode::Insert;
1425 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
1426 app.handle_event(AppEvent::Key(key));
1427 assert!(app.messages().is_empty());
1428 assert!(rx.try_recv().is_err());
1429 }
1430
1431 #[test]
1432 fn agent_chunk_creates_streaming_message() {
1433 let (mut app, _rx, _tx) = make_app();
1434 app.handle_agent_event(AgentEvent::Chunk("hel".into()));
1435 assert_eq!(app.messages().len(), 1);
1436 assert!(app.messages()[0].streaming);
1437 assert_eq!(app.messages()[0].content, "hel");
1438
1439 app.handle_agent_event(AgentEvent::Chunk("lo".into()));
1440 assert_eq!(app.messages().len(), 1);
1441 assert_eq!(app.messages()[0].content, "hello");
1442 }
1443
1444 #[test]
1445 fn agent_flush_stops_streaming() {
1446 let (mut app, _rx, _tx) = make_app();
1447 app.handle_agent_event(AgentEvent::Chunk("test".into()));
1448 assert!(app.messages()[0].streaming);
1449 app.handle_agent_event(AgentEvent::Flush);
1450 assert!(!app.messages()[0].streaming);
1451 }
1452
1453 #[test]
1454 fn agent_full_message() {
1455 let (mut app, _rx, _tx) = make_app();
1456 app.handle_agent_event(AgentEvent::FullMessage("done".into()));
1457 assert_eq!(app.messages().len(), 1);
1458 assert!(!app.messages()[0].streaming);
1459 assert_eq!(app.messages()[0].content, "done");
1460 }
1461
1462 #[test]
1463 fn full_message_skips_tool_output_new_format() {
1464 let (mut app, _rx, _tx) = make_app();
1465 app.handle_agent_event(AgentEvent::FullMessage(
1466 "[tool output: bash]\n```\n$ echo hi\nhi\n```".into(),
1467 ));
1468 assert!(app.messages().is_empty());
1469 }
1470
1471 #[test]
1472 fn scroll_in_normal_mode() {
1473 let (mut app, _rx, _tx) = make_app();
1474 app.sessions.current_mut().input_mode = InputMode::Normal;
1475 let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
1476 app.handle_event(AppEvent::Key(up));
1477 assert_eq!(app.scroll_offset(), 1);
1478
1479 let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
1480 app.handle_event(AppEvent::Key(down));
1481 assert_eq!(app.scroll_offset(), 0);
1482 }
1483
1484 #[test]
1485 fn tab_cycles_panels() {
1486 let (mut app, _rx, _tx) = make_app();
1487 app.sessions.current_mut().input_mode = InputMode::Normal;
1488 assert_eq!(app.active_panel, Panel::Chat);
1489
1490 let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
1491 app.handle_event(AppEvent::Key(tab));
1492 assert_eq!(app.active_panel, Panel::Skills);
1493
1494 app.handle_event(AppEvent::Key(tab));
1495 assert_eq!(app.active_panel, Panel::Memory);
1496
1497 app.handle_event(AppEvent::Key(tab));
1498 assert_eq!(app.active_panel, Panel::Resources);
1499
1500 app.handle_event(AppEvent::Key(tab));
1501 assert_eq!(app.active_panel, Panel::SubAgents);
1502
1503 app.handle_event(AppEvent::Key(tab));
1504 assert_eq!(app.active_panel, Panel::Chat);
1505 }
1506
1507 #[test]
1508 fn ctrl_u_clears_input() {
1509 let (mut app, _rx, _tx) = make_app();
1510 app.sessions.current_mut().input_mode = InputMode::Insert;
1511 app.sessions.current_mut().input = "some text".into();
1512 app.sessions.current_mut().cursor_position = 9;
1513 let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
1514 app.handle_event(AppEvent::Key(key));
1515 assert!(app.input().is_empty());
1516 assert_eq!(app.cursor_position(), 0);
1517 }
1518
1519 #[test]
1520 fn cursor_movement() {
1521 let (mut app, _rx, _tx) = make_app();
1522 app.sessions.current_mut().input_mode = InputMode::Insert;
1523 app.sessions.current_mut().input = "abc".into();
1524 app.sessions.current_mut().cursor_position = 1;
1525
1526 let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
1527 app.handle_event(AppEvent::Key(left));
1528 assert_eq!(app.cursor_position(), 0);
1529
1530 app.handle_event(AppEvent::Key(left));
1532 assert_eq!(app.cursor_position(), 0);
1533
1534 let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
1535 app.handle_event(AppEvent::Key(right));
1536 assert_eq!(app.cursor_position(), 1);
1537
1538 let home = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
1539 app.handle_event(AppEvent::Key(home));
1540 assert_eq!(app.cursor_position(), 0);
1541
1542 let end = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
1543 app.handle_event(AppEvent::Key(end));
1544 assert_eq!(app.cursor_position(), 3);
1545 }
1546
1547 #[test]
1548 fn delete_key_removes_char_at_cursor() {
1549 let (mut app, _rx, _tx) = make_app();
1550 app.sessions.current_mut().input_mode = InputMode::Insert;
1551 app.sessions.current_mut().input = "abc".into();
1552 app.sessions.current_mut().cursor_position = 1;
1553 let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE);
1554 app.handle_event(AppEvent::Key(key));
1555 assert_eq!(app.input(), "ac");
1556 assert_eq!(app.cursor_position(), 1);
1557 }
1558
1559 #[test]
1560 fn unicode_input_insert_and_delete() {
1561 let (mut app, _rx, _tx) = make_app();
1562 app.sessions.current_mut().input_mode = InputMode::Insert;
1563
1564 for c in "\u{00e9}a\u{1f600}".chars() {
1566 let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
1567 app.handle_event(AppEvent::Key(key));
1568 }
1569 assert_eq!(app.input(), "\u{00e9}a\u{1f600}");
1570 assert_eq!(app.cursor_position(), 3);
1571
1572 let bs = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
1574 app.handle_event(AppEvent::Key(bs));
1575 assert_eq!(app.input(), "\u{00e9}a");
1576 assert_eq!(app.cursor_position(), 2);
1577
1578 let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
1580 app.handle_event(AppEvent::Key(left));
1581 assert_eq!(app.cursor_position(), 1);
1582
1583 let del = KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE);
1584 app.handle_event(AppEvent::Key(del));
1585 assert_eq!(app.input(), "\u{00e9}");
1586 assert_eq!(app.cursor_position(), 1);
1587
1588 let end = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
1590 app.handle_event(AppEvent::Key(end));
1591 assert_eq!(app.cursor_position(), 1);
1592 }
1593
1594 #[test]
1595 fn confirm_request_sets_state() {
1596 let (mut app, _rx, _tx) = make_app();
1597 let (tx, _rx) = tokio::sync::oneshot::channel();
1598 app.handle_agent_event(AgentEvent::ConfirmRequest {
1599 prompt: "delete?".into(),
1600 response_tx: tx,
1601 });
1602 assert!(app.confirm_state.is_some());
1603 assert_eq!(app.confirm_state.as_ref().unwrap().prompt, "delete?");
1604 }
1605
1606 #[test]
1607 fn confirm_modal_y_sends_true() {
1608 let (mut app, _rx, _tx) = make_app();
1609 let (tx, mut rx) = tokio::sync::oneshot::channel();
1610 app.confirm_state = Some(ConfirmState {
1611 prompt: "proceed?".into(),
1612 response_tx: Some(tx),
1613 });
1614 let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
1615 app.handle_event(AppEvent::Key(key));
1616 assert!(app.confirm_state.is_none());
1617 assert!(rx.try_recv().unwrap());
1618 }
1619
1620 #[test]
1621 fn confirm_modal_enter_sends_true() {
1622 let (mut app, _rx, _tx) = make_app();
1623 let (tx, mut rx) = tokio::sync::oneshot::channel();
1624 app.confirm_state = Some(ConfirmState {
1625 prompt: "proceed?".into(),
1626 response_tx: Some(tx),
1627 });
1628 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
1629 app.handle_event(AppEvent::Key(key));
1630 assert!(app.confirm_state.is_none());
1631 assert!(rx.try_recv().unwrap());
1632 }
1633
1634 #[test]
1635 fn confirm_modal_n_sends_false() {
1636 let (mut app, _rx, _tx) = make_app();
1637 let (tx, mut rx) = tokio::sync::oneshot::channel();
1638 app.confirm_state = Some(ConfirmState {
1639 prompt: "delete?".into(),
1640 response_tx: Some(tx),
1641 });
1642 let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
1643 app.handle_event(AppEvent::Key(key));
1644 assert!(app.confirm_state.is_none());
1645 assert!(!rx.try_recv().unwrap());
1646 }
1647
1648 #[test]
1649 fn confirm_modal_escape_sends_false() {
1650 let (mut app, _rx, _tx) = make_app();
1651 let (tx, mut rx) = tokio::sync::oneshot::channel();
1652 app.confirm_state = Some(ConfirmState {
1653 prompt: "delete?".into(),
1654 response_tx: Some(tx),
1655 });
1656 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1657 app.handle_event(AppEvent::Key(key));
1658 assert!(app.confirm_state.is_none());
1659 assert!(!rx.try_recv().unwrap());
1660 }
1661
1662 #[test]
1663 fn try_switch_blocked_by_confirm_modal() {
1664 let (mut app, _rx, _tx) = make_app();
1665 let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
1666 app.confirm_state = Some(ConfirmState {
1667 prompt: "ok?".into(),
1668 response_tx: Some(tx),
1669 });
1670 let prev_active = app.sessions.active();
1671 app.execute_command(TuiCommand::SessionSwitchNext);
1672 assert_eq!(app.sessions.active(), prev_active);
1673 assert!(
1674 app.sessions
1675 .current()
1676 .messages
1677 .iter()
1678 .any(|m| m.content.contains("Resolve"))
1679 );
1680 }
1681
1682 #[test]
1683 fn try_switch_blocked_by_elicitation_modal() {
1684 let (mut app, _rx, _tx) = make_app();
1685 let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
1686 let req = zeph_core::channel::ElicitationRequest {
1687 server_name: "test".into(),
1688 message: "test".into(),
1689 fields: vec![],
1690 };
1691 app.elicitation_state = Some(ElicitationState {
1692 dialog: crate::widgets::elicitation::ElicitationDialogState::new(req),
1693 response_tx: Some(tx),
1694 });
1695 let prev_active = app.sessions.active();
1696 app.execute_command(TuiCommand::SessionSwitchPrev);
1697 assert_eq!(app.sessions.active(), prev_active);
1698 assert!(
1699 app.sessions
1700 .current()
1701 .messages
1702 .iter()
1703 .any(|m| m.content.contains("Resolve"))
1704 );
1705 }
1706
1707 #[test]
1708 fn try_switch_close_refused_on_last_slot() {
1709 let (mut app, _rx, _tx) = make_app();
1710 app.execute_command(TuiCommand::SessionClose);
1711 assert!(
1712 app.sessions
1713 .current()
1714 .messages
1715 .iter()
1716 .any(|m| m.content.contains("Cannot close"))
1717 );
1718 }
1719
1720 #[test]
1721 fn confirm_modal_blocks_other_keys() {
1722 let (mut app, _rx, _tx) = make_app();
1723 let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
1724 app.sessions.current_mut().input_mode = InputMode::Insert;
1725 app.confirm_state = Some(ConfirmState {
1726 prompt: "test?".into(),
1727 response_tx: Some(tx),
1728 });
1729 let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
1730 app.handle_event(AppEvent::Key(key));
1731 assert!(app.input().is_empty());
1732 assert!(app.confirm_state.is_some());
1733 }
1734
1735 #[test]
1736 fn shift_enter_inserts_newline() {
1737 let (mut app, mut rx, _tx) = make_app();
1738 app.sessions.current_mut().input_mode = InputMode::Insert;
1739 app.sessions.current_mut().input = "hello".into();
1740 app.sessions.current_mut().cursor_position = 5;
1741 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
1742 app.handle_event(AppEvent::Key(key));
1743 assert_eq!(app.input(), "hello\n");
1744 assert_eq!(app.cursor_position(), 6);
1745 assert!(app.messages().is_empty());
1746 assert!(rx.try_recv().is_err());
1747 }
1748
1749 #[test]
1750 fn ctrl_j_inserts_newline() {
1751 let (mut app, mut rx, _tx) = make_app();
1752 app.sessions.current_mut().input_mode = InputMode::Insert;
1753 app.sessions.current_mut().input = "hello".into();
1754 app.sessions.current_mut().cursor_position = 5;
1755 let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL);
1756 app.handle_event(AppEvent::Key(key));
1757 assert_eq!(app.input(), "hello\n");
1758 assert_eq!(app.cursor_position(), 6);
1759 assert!(app.messages().is_empty());
1760 assert!(rx.try_recv().is_err());
1761 }
1762
1763 #[test]
1764 fn shift_enter_mid_input() {
1765 let (mut app, _rx, _tx) = make_app();
1766 app.sessions.current_mut().input_mode = InputMode::Insert;
1767 app.sessions.current_mut().input = "ab".into();
1768 app.sessions.current_mut().cursor_position = 1;
1769 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
1770 app.handle_event(AppEvent::Key(key));
1771 assert_eq!(app.input(), "a\nb");
1772 assert_eq!(app.cursor_position(), 2);
1773 }
1774
1775 #[test]
1776 fn d_toggles_side_panels() {
1777 let (mut app, _rx, _tx) = make_app();
1778 app.sessions.current_mut().input_mode = InputMode::Normal;
1779 assert!(app.show_side_panels());
1780
1781 let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
1782 app.handle_event(AppEvent::Key(key));
1783 assert!(!app.show_side_panels());
1784
1785 app.handle_event(AppEvent::Key(key));
1786 assert!(app.show_side_panels());
1787 }
1788
1789 #[test]
1790 fn mouse_scroll_up() {
1791 let (mut app, _rx, _tx) = make_app();
1792 assert_eq!(app.scroll_offset(), 0);
1793 app.handle_event(AppEvent::MouseScroll(1));
1794 assert_eq!(app.scroll_offset(), 1);
1795 app.handle_event(AppEvent::MouseScroll(1));
1796 assert_eq!(app.scroll_offset(), 2);
1797 }
1798
1799 #[test]
1800 fn mouse_scroll_down() {
1801 let (mut app, _rx, _tx) = make_app();
1802 app.sessions.current_mut().scroll_offset = 5;
1803 app.handle_event(AppEvent::MouseScroll(-1));
1804 assert_eq!(app.scroll_offset(), 4);
1805 app.handle_event(AppEvent::MouseScroll(-1));
1806 assert_eq!(app.scroll_offset(), 3);
1807 }
1808
1809 #[test]
1810 fn mouse_scroll_down_saturates_at_zero() {
1811 let (mut app, _rx, _tx) = make_app();
1812 app.sessions.current_mut().scroll_offset = 1;
1813 app.handle_event(AppEvent::MouseScroll(-1));
1814 assert_eq!(app.scroll_offset(), 0);
1815 app.handle_event(AppEvent::MouseScroll(-1));
1816 assert_eq!(app.scroll_offset(), 0);
1817 }
1818
1819 #[test]
1820 fn mouse_scroll_during_confirm_blocked() {
1821 let (mut app, _rx, _tx) = make_app();
1822 let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
1823 app.confirm_state = Some(ConfirmState {
1824 prompt: "test?".into(),
1825 response_tx: Some(tx),
1826 });
1827 app.sessions.current_mut().scroll_offset = 5;
1828 app.handle_event(AppEvent::MouseScroll(1));
1829 assert_eq!(app.scroll_offset(), 5);
1830 app.handle_event(AppEvent::MouseScroll(-1));
1831 assert_eq!(app.scroll_offset(), 5);
1832 }
1833
1834 #[test]
1835 fn load_history_recognizes_tool_output_new_format() {
1836 let (mut app, _rx, _tx) = make_app();
1837 app.load_history(&[
1838 ("user", "hello"),
1839 ("assistant", "hi there"),
1840 ("user", "[tool output: bash]\n```\n$ echo hello\nhello\n```"),
1841 ("assistant", "done"),
1842 ]);
1843 assert_eq!(app.messages().len(), 4);
1844 assert_eq!(app.messages()[0].role, MessageRole::User);
1845 assert_eq!(app.messages()[1].role, MessageRole::Assistant);
1846 assert_eq!(app.messages()[2].role, MessageRole::Tool);
1847 assert_eq!(
1848 app.messages()[2]
1849 .tool_name
1850 .as_ref()
1851 .map(zeph_common::ToolName::as_str),
1852 Some("bash")
1853 );
1854 assert_eq!(app.messages()[2].content, "$ echo hello\nhello");
1855 assert_eq!(app.messages()[3].role, MessageRole::Assistant);
1856 }
1857
1858 #[test]
1859 fn load_history_recognizes_legacy_tool_output() {
1860 let (mut app, _rx, _tx) = make_app();
1861 app.load_history(&[("user", "[tool output]\n```\n$ ls\nfile.txt\n```")]);
1862 assert_eq!(app.messages().len(), 1);
1863 assert_eq!(app.messages()[0].role, MessageRole::Tool);
1864 assert_eq!(
1865 app.messages()[0]
1866 .tool_name
1867 .as_ref()
1868 .map(zeph_common::ToolName::as_str),
1869 Some("bash")
1870 );
1871 assert_eq!(app.messages()[0].content, "$ ls\nfile.txt");
1872 }
1873
1874 #[test]
1875 fn load_history_legacy_non_bash_tool() {
1876 let (mut app, _rx, _tx) = make_app();
1877 app.load_history(&[(
1878 "user",
1879 "[tool output]\n```\n[mcp:github:list]\nresults\n```",
1880 )]);
1881 assert_eq!(app.messages().len(), 1);
1882 assert_eq!(app.messages()[0].role, MessageRole::Tool);
1883 assert_eq!(
1884 app.messages()[0]
1885 .tool_name
1886 .as_ref()
1887 .map(zeph_common::ToolName::as_str),
1888 Some("tool")
1889 );
1890 }
1891
1892 #[test]
1893 fn load_history_recognizes_tool_result_format() {
1894 let (mut app, _rx, _tx) = make_app();
1895 app.load_history(&[("user", "[tool_result: toolu_abc]\n$ echo hello\nhello")]);
1896 assert_eq!(app.messages().len(), 1);
1897 assert_eq!(app.messages()[0].role, MessageRole::Tool);
1898 assert_eq!(
1899 app.messages()[0]
1900 .tool_name
1901 .as_ref()
1902 .map(zeph_common::ToolName::as_str),
1903 Some("bash")
1904 );
1905 assert_eq!(app.messages()[0].content, "$ echo hello\nhello");
1906 }
1907
1908 #[test]
1909 fn load_history_hides_tool_use_only_messages() {
1910 let (mut app, _rx, _tx) = make_app();
1911 app.load_history(&[
1912 ("user", "hello"),
1913 (
1914 "assistant",
1915 "[tool_use: bash(toolu_01AfnYMrx3Ub13LLQ1Py3nfg)]",
1916 ),
1917 ("assistant", "here is the result"),
1918 ]);
1919 assert_eq!(app.messages().len(), 2);
1920 assert_eq!(app.messages()[0].role, MessageRole::User);
1921 assert_eq!(app.messages()[1].role, MessageRole::Assistant);
1922 assert_eq!(app.messages()[1].content, "here is the result");
1923 }
1924
1925 #[test]
1926 fn load_history_keeps_assistant_with_text_and_tool_use() {
1927 let (mut app, _rx, _tx) = make_app();
1928 app.load_history(&[("assistant", "Let me check. [tool_use: bash(toolu_abc)]")]);
1929 assert_eq!(app.messages().len(), 1);
1930 assert_eq!(app.messages()[0].role, MessageRole::Assistant);
1931 }
1932
1933 #[test]
1934 fn is_tool_use_only_multiple_tags() {
1935 assert!(is_tool_use_only(
1936 "[tool_use: bash(id1)] [tool_use: read(id2)]"
1937 ));
1938 assert!(!is_tool_use_only("text [tool_use: bash(id1)]"));
1939 assert!(!is_tool_use_only(""));
1940 }
1941
1942 #[test]
1943 fn tool_output_without_prior_tool_start_creates_tool_message_with_diff() {
1944 let (mut app, _rx, _tx) = make_app();
1945 let diff = zeph_core::DiffData {
1946 file_path: "src/lib.rs".into(),
1947 old_content: "fn old() {}".into(),
1948 new_content: "fn new() {}".into(),
1949 };
1950 app.handle_agent_event(AgentEvent::ToolOutput {
1951 tool_name: "edit".into(),
1952 command: "[tool output: edit]\n```\nok\n```".into(),
1953 output: "[tool output: edit]\n```\nok\n```".into(),
1954 success: true,
1955 diff: Some(diff),
1956 filter_stats: None,
1957 kept_lines: None,
1958 });
1959
1960 assert_eq!(app.messages().len(), 1);
1961 let msg = &app.messages()[0];
1962 assert_eq!(msg.role, MessageRole::Tool);
1963 assert!(!msg.streaming);
1964 assert!(msg.diff_data.is_some());
1965 }
1966
1967 #[test]
1968 fn tool_output_without_diff_does_not_create_spurious_message() {
1969 let (mut app, _rx, _tx) = make_app();
1970 app.handle_agent_event(AgentEvent::ToolOutput {
1971 tool_name: "read".into(),
1972 command: "[tool output: read]\n```\ncontent\n```".into(),
1973 output: "[tool output: read]\n```\ncontent\n```".into(),
1974 success: true,
1975 diff: None,
1976 filter_stats: None,
1977 kept_lines: None,
1978 });
1979
1980 assert!(app.messages().is_empty());
1982 }
1983
1984 #[test]
1985 fn show_help_defaults_to_false() {
1986 let (app, _rx, _tx) = make_app();
1987 assert!(!app.show_help);
1988 }
1989
1990 #[test]
1991 fn question_mark_in_normal_mode_opens_help() {
1992 let (mut app, _rx, _tx) = make_app();
1993 app.sessions.current_mut().input_mode = InputMode::Normal;
1994 let key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
1995 app.handle_event(AppEvent::Key(key));
1996 assert!(app.show_help);
1997 }
1998
1999 #[test]
2000 fn question_mark_toggles_help_closed() {
2001 let (mut app, _rx, _tx) = make_app();
2002 app.show_help = true;
2003 let key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
2004 app.handle_event(AppEvent::Key(key));
2005 assert!(!app.show_help);
2006 }
2007
2008 #[test]
2009 fn esc_closes_help_popup() {
2010 let (mut app, _rx, _tx) = make_app();
2011 app.show_help = true;
2012 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2013 app.handle_event(AppEvent::Key(key));
2014 assert!(!app.show_help);
2015 }
2016
2017 #[test]
2018 fn other_keys_ignored_when_help_open() {
2019 let (mut app, _rx, _tx) = make_app();
2020 app.sessions.current_mut().input_mode = InputMode::Insert;
2021 app.show_help = true;
2022
2023 let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
2025 app.handle_event(AppEvent::Key(key));
2026 assert!(app.input().is_empty());
2027 assert!(app.show_help);
2028
2029 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2031 app.handle_event(AppEvent::Key(key));
2032 assert!(app.messages().is_empty());
2033 assert!(app.show_help);
2034 }
2035
2036 #[test]
2037 fn help_popup_does_not_block_ctrl_c() {
2038 let (mut app, _rx, _tx) = make_app();
2039 app.show_help = true;
2040 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
2041 app.handle_event(AppEvent::Key(key));
2042 assert!(app.should_quit);
2043 }
2044
2045 #[test]
2046 fn question_mark_in_insert_mode_does_not_open_help() {
2047 let (mut app, _rx, _tx) = make_app();
2048 app.sessions.current_mut().input_mode = InputMode::Insert;
2049 let key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
2050 app.handle_event(AppEvent::Key(key));
2051 assert!(!app.show_help);
2052 assert_eq!(app.input(), "?");
2053 }
2054
2055 #[tokio::test]
2056 async fn esc_in_normal_mode_cancels_when_busy() {
2057 let (mut app, _rx, _tx) = make_app();
2058 let notify = Arc::new(Notify::new());
2059 let notify_waiter = Arc::clone(¬ify);
2060 let handle = tokio::spawn(async move {
2061 notify_waiter.notified().await;
2062 true
2063 });
2064 tokio::task::yield_now().await;
2065
2066 app = app.with_cancel_signal(Arc::clone(¬ify));
2067 app.sessions.current_mut().input_mode = InputMode::Normal;
2068 app.sessions.current_mut().status_label = Some("Thinking...".into());
2069 assert!(app.is_agent_busy());
2070
2071 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2072 app.handle_event(AppEvent::Key(key));
2073 let result = tokio::time::timeout(std::time::Duration::from_millis(100), handle).await;
2074 assert!(result.is_ok(), "notify should have been triggered");
2075 }
2076
2077 #[test]
2078 fn esc_in_normal_mode_does_not_cancel_when_idle() {
2079 let (mut app, _rx, _tx) = make_app();
2080 let notify = Arc::new(Notify::new());
2081 app = app.with_cancel_signal(notify);
2082 app.sessions.current_mut().input_mode = InputMode::Normal;
2083 assert!(!app.is_agent_busy());
2084
2085 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2086 app.handle_event(AppEvent::Key(key));
2087 }
2089
2090 #[test]
2091 fn up_with_empty_input_and_queued_recalls_from_history() {
2092 let (mut app, mut rx, _tx) = make_app();
2093 app.sessions.current_mut().input_mode = InputMode::Insert;
2094 app.pending_count = 2;
2095 app.sessions
2096 .current_mut()
2097 .input_history
2098 .push("queued msg".into());
2099 app.sessions
2100 .current_mut()
2101 .messages
2102 .push(ChatMessage::new(MessageRole::User, "queued msg"));
2103
2104 let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
2105 app.handle_event(AppEvent::Key(key));
2106
2107 assert_eq!(app.input(), "queued msg");
2108 assert_eq!(app.cursor_position(), 10);
2109 assert!(app.editing_queued());
2110 assert_eq!(app.queued_count(), 1);
2111 assert!(app.sessions.current_mut().input_history.is_empty());
2112 assert!(app.messages().is_empty());
2113 let sent = rx.try_recv().unwrap();
2114 assert_eq!(sent, "/drop-last-queued");
2115 }
2116
2117 #[test]
2118 fn up_with_non_empty_input_navigates_history() {
2119 let (mut app, mut rx, _tx) = make_app();
2120 app.sessions.current_mut().input_mode = InputMode::Insert;
2121 app.pending_count = 2;
2122 app.sessions.current_mut().input = "hello".into();
2123 app.sessions.current_mut().cursor_position = 5;
2124 app.sessions
2125 .current_mut()
2126 .input_history
2127 .push("hello world".into());
2128
2129 let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
2130 app.handle_event(AppEvent::Key(key));
2131
2132 assert!(rx.try_recv().is_err());
2133 assert_eq!(app.input(), "hello world");
2134 }
2135
2136 #[test]
2137 fn submit_input_resets_editing_queued() {
2138 let (mut app, _rx, _tx) = make_app();
2139 app.editing_queued = true;
2140 app.sessions.current_mut().input = "some text".into();
2141 app.sessions.current_mut().cursor_position = 9;
2142 app.submit_input();
2143 assert!(!app.editing_queued());
2144 }
2145
2146 #[test]
2147 fn desired_input_height_caps_at_three_visible_lines() {
2148 let (mut app, _rx, _tx) = make_app();
2149 app.sessions.current_mut().input_mode = InputMode::Insert;
2150 app.sessions.current_mut().input = "one\ntwo\nthree\nfour".into();
2151 app.sessions.current_mut().cursor_position = app.char_count();
2152
2153 assert_eq!(app.input_line_count(), 4);
2154 assert_eq!(app.desired_input_height(), 5);
2155 }
2156
2157 mod integration {
2158 use super::*;
2159 use crate::test_utils::test_terminal;
2160
2161 fn draw_app(app: &mut App, width: u16, height: u16) -> String {
2162 let mut terminal = test_terminal(width, height);
2163 terminal.draw(|frame| app.draw(frame)).unwrap();
2164 let buf = terminal.backend().buffer().clone();
2165 let mut output = String::new();
2166 for y in 0..buf.area.height {
2167 for x in 0..buf.area.width {
2168 output.push_str(buf[(x, y)].symbol());
2169 }
2170 output.push('\n');
2171 }
2172 output
2173 }
2174
2175 #[test]
2176 fn submit_message_appears_in_chat() {
2177 let (mut app, _rx, _tx) = make_app();
2178 app.sessions.current_mut().input_mode = InputMode::Insert;
2179 app.sessions.current_mut().input = "hello world".into();
2180 app.sessions.current_mut().cursor_position = 11;
2181 let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2182 app.handle_event(AppEvent::Key(enter));
2183
2184 let output = draw_app(&mut app, 80, 24);
2185 assert!(output.contains("hello world"));
2186 }
2187
2188 #[test]
2189 fn help_overlay_renders() {
2190 let (mut app, _rx, _tx) = make_app();
2191 app.sessions.current_mut().input_mode = InputMode::Normal;
2192 let key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
2193 app.handle_event(AppEvent::Key(key));
2194
2195 let output = draw_app(&mut app, 80, 30);
2196 assert!(output.contains("Help"));
2197 assert!(output.contains("quit"));
2198 }
2199
2200 #[test]
2201 fn help_overlay_closes() {
2202 let (mut app, _rx, _tx) = make_app();
2203 app.sessions.current_mut().input_mode = InputMode::Normal;
2204 let open = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
2205 app.handle_event(AppEvent::Key(open));
2206 let close = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2207 app.handle_event(AppEvent::Key(close));
2208
2209 let output = draw_app(&mut app, 80, 30);
2210 assert!(!output.contains("Help — press"));
2211 }
2212
2213 #[test]
2214 fn confirm_dialog_renders() {
2215 let (mut app, _rx, _tx) = make_app();
2216 let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
2217 app.confirm_state = Some(ConfirmState {
2218 prompt: "Execute rm -rf?".into(),
2219 response_tx: Some(tx),
2220 });
2221
2222 let output = draw_app(&mut app, 60, 20);
2223 assert!(output.contains("Confirm"));
2224 assert!(output.contains("Execute rm -rf?"));
2225 assert!(output.contains("[Y]es / [N]o"));
2226 }
2227
2228 #[test]
2229 fn confirm_dialog_disappears_after_response() {
2230 let (mut app, _rx, _tx) = make_app();
2231 let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
2232 app.confirm_state = Some(ConfirmState {
2233 prompt: "Delete?".into(),
2234 response_tx: Some(tx),
2235 });
2236 let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
2237 app.handle_event(AppEvent::Key(key));
2238
2239 let output = draw_app(&mut app, 60, 20);
2240 assert!(!output.contains("[Y]es / [N]o"));
2241 }
2242
2243 #[test]
2244 fn side_panels_toggle_off() {
2245 let (mut app, _rx, _tx) = make_app();
2246 app.sessions.current_mut().input_mode = InputMode::Normal;
2247
2248 let before = draw_app(&mut app, 120, 40);
2249 assert!(before.contains("Skills"));
2250 assert!(before.contains("Memory"));
2251
2252 let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
2253 app.handle_event(AppEvent::Key(key));
2254
2255 let after = draw_app(&mut app, 120, 40);
2256 assert!(!after.contains("Skills ("));
2257 }
2258
2259 #[test]
2260 fn splash_shown_initially() {
2261 let (mut app, _rx, _tx) = make_app();
2262 let output = draw_app(&mut app, 80, 24);
2263 assert!(output.contains("Type a message to start."));
2264 }
2265
2266 #[test]
2267 fn splash_disappears_after_submit() {
2268 let (mut app, _rx, _tx) = make_app();
2269 app.sessions.current_mut().input_mode = InputMode::Insert;
2270 app.sessions.current_mut().input = "hi".into();
2271 app.sessions.current_mut().cursor_position = 2;
2272 let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2273 app.handle_event(AppEvent::Key(enter));
2274
2275 assert!(
2276 !app.sessions.current_mut().show_splash,
2277 "splash should be hidden after submit"
2278 );
2279 }
2280
2281 #[test]
2282 fn markdown_link_produces_hyperlink_span() {
2283 let (mut app, _rx, _tx) = make_app();
2284 app.sessions.current_mut().show_splash = false;
2285 app.sessions.current_mut().messages.push(ChatMessage::new(
2286 MessageRole::Assistant,
2287 "See [docs](https://docs.rs) for details",
2288 ));
2289
2290 let _ = draw_app(&mut app, 80, 24);
2291 let links = app.take_hyperlinks();
2292 let doc_link = links.iter().find(|s| s.url == "https://docs.rs");
2293 assert!(
2294 doc_link.is_some(),
2295 "expected hyperlink span for markdown link, got: {links:?}"
2296 );
2297 }
2298
2299 #[test]
2300 fn bare_url_still_produces_hyperlink_span() {
2301 let (mut app, _rx, _tx) = make_app();
2302 app.sessions.current_mut().show_splash = false;
2303 app.sessions.current_mut().messages.push(ChatMessage::new(
2304 MessageRole::Assistant,
2305 "Visit https://example.com today",
2306 ));
2307
2308 let _ = draw_app(&mut app, 80, 24);
2309 let links = app.take_hyperlinks();
2310 let bare = links.iter().find(|s| s.url == "https://example.com");
2311 assert!(
2312 bare.is_some(),
2313 "expected hyperlink span for bare URL, got: {links:?}"
2314 );
2315 }
2316 }
2317
2318 #[test]
2319 fn prev_word_boundary_from_middle_of_word() {
2320 let (mut app, _rx, _tx) = make_app();
2321 app.sessions.current_mut().input = "hello world".into();
2322 app.sessions.current_mut().cursor_position = 8;
2323 assert_eq!(app.prev_word_boundary(), 6);
2324 }
2325
2326 #[test]
2327 fn prev_word_boundary_from_start_of_second_word() {
2328 let (mut app, _rx, _tx) = make_app();
2329 app.sessions.current_mut().input = "hello world".into();
2330 app.sessions.current_mut().cursor_position = 6;
2331 assert_eq!(app.prev_word_boundary(), 0);
2332 }
2333
2334 #[test]
2335 fn prev_word_boundary_at_zero_stays_zero() {
2336 let (mut app, _rx, _tx) = make_app();
2337 app.sessions.current_mut().input = "hello world".into();
2338 app.sessions.current_mut().cursor_position = 0;
2339 assert_eq!(app.prev_word_boundary(), 0);
2340 }
2341
2342 #[test]
2343 fn next_word_boundary_from_middle_of_first_word() {
2344 let (mut app, _rx, _tx) = make_app();
2345 app.sessions.current_mut().input = "hello world".into();
2346 app.sessions.current_mut().cursor_position = 2;
2347 assert_eq!(app.next_word_boundary(), 6);
2348 }
2349
2350 #[test]
2351 fn next_word_boundary_from_start_of_second_word() {
2352 let (mut app, _rx, _tx) = make_app();
2353 app.sessions.current_mut().input = "hello world".into();
2354 app.sessions.current_mut().cursor_position = 6;
2355 assert_eq!(app.next_word_boundary(), 11);
2356 }
2357
2358 #[test]
2359 fn next_word_boundary_at_end_stays_at_end() {
2360 let (mut app, _rx, _tx) = make_app();
2361 app.sessions.current_mut().input = "hello world".into();
2362 app.sessions.current_mut().cursor_position = 11;
2363 assert_eq!(app.next_word_boundary(), 11);
2364 }
2365
2366 #[test]
2367 fn prev_word_boundary_unicode() {
2368 let (mut app, _rx, _tx) = make_app();
2369 app.sessions.current_mut().input = "привет мир".into();
2371 app.sessions.current_mut().cursor_position = 9;
2372 assert_eq!(app.prev_word_boundary(), 7);
2373 }
2374
2375 #[test]
2376 fn next_word_boundary_unicode() {
2377 let (mut app, _rx, _tx) = make_app();
2378 app.sessions.current_mut().input = "привет мир".into();
2380 app.sessions.current_mut().cursor_position = 2;
2381 assert_eq!(app.next_word_boundary(), 7);
2382 }
2383
2384 #[test]
2385 fn alt_left_moves_to_prev_word_boundary() {
2386 let (mut app, _rx, _tx) = make_app();
2387 app.sessions.current_mut().input_mode = InputMode::Insert;
2388 app.sessions.current_mut().input = "hello world".into();
2389 app.sessions.current_mut().cursor_position = 8;
2390 let key = KeyEvent::new(KeyCode::Left, KeyModifiers::ALT);
2391 app.handle_event(AppEvent::Key(key));
2392 assert_eq!(app.cursor_position(), 6);
2393 }
2394
2395 #[test]
2396 fn alt_right_moves_to_next_word_boundary() {
2397 let (mut app, _rx, _tx) = make_app();
2398 app.sessions.current_mut().input_mode = InputMode::Insert;
2399 app.sessions.current_mut().input = "hello world".into();
2400 app.sessions.current_mut().cursor_position = 2;
2401 let key = KeyEvent::new(KeyCode::Right, KeyModifiers::ALT);
2402 app.handle_event(AppEvent::Key(key));
2403 assert_eq!(app.cursor_position(), 6);
2404 }
2405
2406 #[test]
2407 fn ctrl_a_moves_cursor_to_start() {
2408 let (mut app, _rx, _tx) = make_app();
2409 app.sessions.current_mut().input_mode = InputMode::Insert;
2410 app.sessions.current_mut().input = "hello world".into();
2411 app.sessions.current_mut().cursor_position = 7;
2412 let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
2413 app.handle_event(AppEvent::Key(key));
2414 assert_eq!(app.cursor_position(), 0);
2415 }
2416
2417 #[test]
2418 fn ctrl_e_moves_cursor_to_end() {
2419 let (mut app, _rx, _tx) = make_app();
2420 app.sessions.current_mut().input_mode = InputMode::Insert;
2421 app.sessions.current_mut().input = "hello world".into();
2422 app.sessions.current_mut().cursor_position = 3;
2423 let key = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL);
2424 app.handle_event(AppEvent::Key(key));
2425 assert_eq!(app.cursor_position(), 11);
2426 }
2427
2428 #[test]
2429 fn alt_backspace_deletes_to_prev_word_boundary() {
2430 let (mut app, _rx, _tx) = make_app();
2431 app.sessions.current_mut().input_mode = InputMode::Insert;
2432 app.sessions.current_mut().input = "hello world".into();
2433 app.sessions.current_mut().cursor_position = 11;
2434 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
2435 app.handle_event(AppEvent::Key(key));
2436 assert_eq!(app.input(), "hello ");
2437 assert_eq!(app.cursor_position(), 6);
2438 }
2439
2440 #[test]
2441 fn alt_backspace_at_boundary_deletes_word_and_space() {
2442 let (mut app, _rx, _tx) = make_app();
2443 app.sessions.current_mut().input_mode = InputMode::Insert;
2444 app.sessions.current_mut().input = "hello world".into();
2445 app.sessions.current_mut().cursor_position = 6;
2446 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
2447 app.handle_event(AppEvent::Key(key));
2448 assert_eq!(app.input(), "world");
2449 assert_eq!(app.cursor_position(), 0);
2450 }
2451
2452 #[test]
2453 fn alt_backspace_at_zero_is_noop() {
2454 let (mut app, _rx, _tx) = make_app();
2455 app.sessions.current_mut().input_mode = InputMode::Insert;
2456 app.sessions.current_mut().input = "hello".into();
2457 app.sessions.current_mut().cursor_position = 0;
2458 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
2459 app.handle_event(AppEvent::Key(key));
2460 assert_eq!(app.input(), "hello");
2461 assert_eq!(app.cursor_position(), 0);
2462 }
2463
2464 mod proptest_cursor {
2465 use super::*;
2466 use proptest::prelude::*;
2467
2468 proptest! {
2469 #![proptest_config(ProptestConfig::with_cases(500))]
2470
2471 #[test]
2472 fn word_boundaries_stay_in_bounds(
2473 input in "\\PC{0,100}",
2474 cursor in 0usize..=100,
2475 ) {
2476 let (mut app, _rx, _tx) = make_app();
2477 app.sessions.current_mut().input = input;
2478 let len = app.char_count();
2479 app.sessions.current_mut().cursor_position = cursor.min(len);
2480
2481 let prev = app.prev_word_boundary();
2482 prop_assert!(prev <= app.sessions.current_mut().cursor_position, "prev {prev} > cursor {}", app.sessions.current_mut().cursor_position);
2483
2484 let next = app.next_word_boundary();
2485 prop_assert!(next >= app.sessions.current_mut().cursor_position, "next {next} < cursor {}", app.sessions.current_mut().cursor_position);
2486 prop_assert!(next <= len, "next {next} > len {len}");
2487 }
2488
2489 #[test]
2490 fn alt_backspace_keeps_valid_state(
2491 input in "\\PC{0,50}",
2492 cursor in 0usize..=50,
2493 ) {
2494 let (mut app, _rx, _tx) = make_app();
2495 app.sessions.current_mut().input_mode = InputMode::Insert;
2496 app.sessions.current_mut().input = input;
2497 let len = app.char_count();
2498 app.sessions.current_mut().cursor_position = cursor.min(len);
2499
2500 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT);
2501 app.handle_event(AppEvent::Key(key));
2502
2503 prop_assert!(app.cursor_position() <= app.char_count());
2504 }
2505 }
2506 }
2507
2508 mod render_cache_tests {
2509 use super::*;
2510 use ratatui::text::{Line, Span};
2511
2512 fn make_key(content_hash: u64, width: u16) -> RenderCacheKey {
2513 RenderCacheKey {
2514 content_hash,
2515 terminal_width: width,
2516 tool_expanded: false,
2517 compact_tools: false,
2518 show_labels: false,
2519 }
2520 }
2521
2522 #[test]
2523 fn get_returns_none_when_empty() {
2524 let cache = RenderCache::default();
2525 let key = make_key(1, 80);
2526 assert!(cache.get(0, &key).is_none());
2527 }
2528
2529 #[test]
2530 fn put_and_get_returns_cached_lines() {
2531 let mut cache = RenderCache::default();
2532 let key = make_key(42, 80);
2533 let lines = vec![Line::from(Span::raw("hello"))];
2534 cache.put(0, key, lines.clone(), vec![]);
2535 let (result, _) = cache.get(0, &key).unwrap();
2536 assert_eq!(result.len(), 1);
2537 assert_eq!(result[0].spans[0].content, "hello");
2538 }
2539
2540 #[test]
2541 fn get_returns_none_on_key_mismatch() {
2542 let mut cache = RenderCache::default();
2543 let key1 = make_key(1, 80);
2544 let key2 = make_key(2, 80);
2545 let lines = vec![Line::from(Span::raw("a"))];
2546 cache.put(0, key1, lines, vec![]);
2547 assert!(cache.get(0, &key2).is_none());
2548 }
2549
2550 #[test]
2551 fn get_returns_none_on_width_mismatch() {
2552 let mut cache = RenderCache::default();
2553 let key80 = make_key(1, 80);
2554 let key100 = make_key(1, 100);
2555 let lines = vec![Line::from(Span::raw("b"))];
2556 cache.put(0, key80, lines, vec![]);
2557 assert!(cache.get(0, &key100).is_none());
2558 }
2559
2560 #[test]
2561 fn invalidate_clears_single_entry() {
2562 let mut cache = RenderCache::default();
2563 let key = make_key(1, 80);
2564 let lines = vec![Line::from(Span::raw("x"))];
2565 cache.put(0, key, lines, vec![]);
2566 assert!(cache.get(0, &key).is_some());
2567 cache.invalidate(0);
2568 assert!(cache.get(0, &key).is_none());
2569 }
2570
2571 #[test]
2572 fn invalidate_out_of_bounds_is_noop() {
2573 let mut cache = RenderCache::default();
2574 cache.invalidate(99);
2575 }
2576
2577 #[test]
2578 fn clear_removes_all_entries() {
2579 let mut cache = RenderCache::default();
2580 let key0 = make_key(1, 80);
2581 let key1 = make_key(2, 80);
2582 cache.put(0, key0, vec![Line::from(Span::raw("a"))], vec![]);
2583 cache.put(1, key1, vec![Line::from(Span::raw("b"))], vec![]);
2584 cache.clear();
2585 assert!(cache.get(0, &key0).is_none());
2586 assert!(cache.get(1, &key1).is_none());
2587 }
2588
2589 #[test]
2590 fn put_grows_entries_for_non_contiguous_index() {
2591 let mut cache = RenderCache::default();
2592 let key = make_key(5, 80);
2593 let lines = vec![Line::from(Span::raw("z"))];
2594 cache.put(5, key, lines, vec![]);
2595 let (result, _) = cache.get(5, &key).unwrap();
2596 assert_eq!(result[0].spans[0].content, "z");
2597 }
2598 }
2599
2600 mod try_recv_tests {
2601 use super::*;
2602
2603 #[test]
2604 fn try_recv_returns_empty_when_no_events() {
2605 let (mut app, _rx, _tx) = make_app();
2606 let result = app.try_recv_agent_event();
2607 assert!(matches!(result, Err(mpsc::error::TryRecvError::Empty)));
2608 }
2609
2610 #[test]
2611 fn try_recv_returns_event_when_available() {
2612 let (mut app, _rx, tx) = make_app();
2613 tx.try_send(AgentEvent::Typing).unwrap();
2614 let result = app.try_recv_agent_event();
2615 assert!(result.is_ok());
2616 assert!(matches!(result.unwrap(), AgentEvent::Typing));
2617 }
2618
2619 #[test]
2620 fn try_recv_returns_disconnected_when_sender_dropped() {
2621 let (mut app, _rx, tx) = make_app();
2622 drop(tx);
2623 let result = app.try_recv_agent_event();
2624 assert!(matches!(
2625 result,
2626 Err(mpsc::error::TryRecvError::Disconnected)
2627 ));
2628 }
2629 }
2630
2631 mod command_palette_tests {
2632 use super::*;
2633
2634 #[test]
2635 fn colon_in_normal_mode_opens_palette() {
2636 let (mut app, _rx, _tx) = make_app();
2637 app.sessions.current_mut().input_mode = InputMode::Normal;
2638 assert!(app.command_palette.is_none());
2639
2640 let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE);
2641 app.handle_event(AppEvent::Key(key));
2642 assert!(app.command_palette.is_some());
2643 }
2644
2645 #[test]
2646 fn esc_closes_palette() {
2647 let (mut app, _rx, _tx) = make_app();
2648 app.sessions.current_mut().input_mode = InputMode::Normal;
2649 app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2650
2651 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2652 app.handle_event(AppEvent::Key(key));
2653 assert!(app.command_palette.is_none());
2654 }
2655
2656 #[test]
2657 fn palette_intercepts_all_keys_except_ctrl_c() {
2658 let (mut app, _rx, _tx) = make_app();
2659 app.sessions.current_mut().input_mode = InputMode::Insert;
2660 app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2661
2662 let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
2664 app.handle_event(AppEvent::Key(key));
2665 assert!(app.input().is_empty());
2666 let palette = app.command_palette.as_ref().unwrap();
2667 assert_eq!(palette.query, "s");
2668 }
2669
2670 #[test]
2671 fn enter_on_selected_dispatches_command_locally() {
2672 let (mut app, _rx, _tx) = make_app();
2673 app.sessions.current_mut().input_mode = InputMode::Normal;
2674 let colon = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE);
2676 app.handle_event(AppEvent::Key(colon));
2677 assert!(app.command_palette.is_some());
2678
2679 let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2681 app.handle_event(AppEvent::Key(enter));
2682 assert!(app.command_palette.is_none());
2683 assert!(!app.messages().is_empty());
2685 assert_eq!(app.messages().last().unwrap().role, MessageRole::System);
2686 }
2687
2688 #[test]
2689 fn typing_in_palette_filters_commands() {
2690 let (mut app, _rx, _tx) = make_app();
2691 app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2692
2693 let m = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
2694 let c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
2695 let p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
2696 app.handle_event(AppEvent::Key(m));
2697 app.handle_event(AppEvent::Key(c));
2698 app.handle_event(AppEvent::Key(p));
2699
2700 let palette = app.command_palette.as_ref().unwrap();
2701 assert_eq!(palette.query, "mcp");
2702 assert!(
2704 palette.filtered.iter().any(|e| e.id == "mcp:list"),
2705 "mcp:list must be in filtered results"
2706 );
2707 assert_eq!(
2708 palette.filtered[0].id, "mcp:list",
2709 "mcp:list must rank first"
2710 );
2711 }
2712
2713 #[test]
2714 fn backspace_in_palette_removes_char() {
2715 let (mut app, _rx, _tx) = make_app();
2716 app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2717
2718 let s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
2719 app.handle_event(AppEvent::Key(s));
2720 assert_eq!(app.command_palette.as_ref().unwrap().query, "s");
2721
2722 let bs = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
2723 app.handle_event(AppEvent::Key(bs));
2724 assert!(app.command_palette.as_ref().unwrap().query.is_empty());
2725 }
2726
2727 #[test]
2728 fn command_result_event_adds_system_message() {
2729 let (mut app, _rx, _tx) = make_app();
2730 app.handle_agent_event(AgentEvent::CommandResult {
2731 command_id: "skill:list".to_owned(),
2732 output: "No skills loaded.".to_owned(),
2733 });
2734 assert_eq!(app.messages().len(), 1);
2735 assert_eq!(app.messages()[0].role, MessageRole::System);
2736 assert_eq!(app.messages()[0].content, "No skills loaded.");
2737 assert!(app.command_palette.is_none());
2738 }
2739
2740 #[test]
2741 fn command_result_closes_palette_if_open() {
2742 let (mut app, _rx, _tx) = make_app();
2743 app.command_palette = Some(crate::widgets::command_palette::CommandPaletteState::new());
2744 app.handle_agent_event(AgentEvent::CommandResult {
2745 command_id: "view:config".to_owned(),
2746 output: "config output".to_owned(),
2747 });
2748 assert!(app.command_palette.is_none());
2749 }
2750
2751 #[test]
2752 fn colon_in_insert_mode_types_colon() {
2753 let (mut app, _rx, _tx) = make_app();
2754 app.sessions.current_mut().input_mode = InputMode::Insert;
2755 let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE);
2756 app.handle_event(AppEvent::Key(key));
2757 assert!(app.command_palette.is_none());
2758 assert_eq!(app.input(), ":");
2759 }
2760
2761 #[test]
2762 fn enter_with_empty_filter_does_not_panic() {
2763 let (mut app, _rx, _tx) = make_app();
2764 let mut palette = crate::widgets::command_palette::CommandPaletteState::new();
2765 for c in "xxxxxxxxxx".chars() {
2767 palette.push_char(c);
2768 }
2769 assert!(palette.filtered.is_empty());
2770 app.command_palette = Some(palette);
2771
2772 let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2773 app.handle_event(AppEvent::Key(enter));
2774 assert!(app.command_palette.is_none());
2776 }
2777
2778 #[test]
2779 fn execute_view_config_with_command_tx_sends_command() {
2780 let (mut app, _rx, _tx) = make_app();
2781 let (cmd_tx, mut cmd_rx) = mpsc::channel::<TuiCommand>(16);
2782 app.command_tx = Some(cmd_tx);
2783
2784 app.execute_command(TuiCommand::ViewConfig);
2785
2786 let received = cmd_rx.try_recv().expect("command should be sent");
2787 assert_eq!(received, TuiCommand::ViewConfig);
2788 assert!(
2789 app.messages().is_empty(),
2790 "no system message when channel present"
2791 );
2792 }
2793
2794 #[test]
2795 fn execute_view_autonomy_with_command_tx_sends_command() {
2796 let (mut app, _rx, _tx) = make_app();
2797 let (cmd_tx, mut cmd_rx) = mpsc::channel::<TuiCommand>(16);
2798 app.command_tx = Some(cmd_tx);
2799
2800 app.execute_command(TuiCommand::ViewAutonomy);
2801
2802 let received = cmd_rx.try_recv().expect("command should be sent");
2803 assert_eq!(received, TuiCommand::ViewAutonomy);
2804 assert!(
2805 app.messages().is_empty(),
2806 "no system message when channel present"
2807 );
2808 }
2809
2810 #[test]
2811 fn execute_view_config_without_command_tx_adds_fallback_message() {
2812 let (mut app, _rx, _tx) = make_app();
2813 assert!(app.command_tx.is_none());
2814
2815 app.execute_command(TuiCommand::ViewConfig);
2816
2817 assert_eq!(app.messages().len(), 1);
2818 assert!(app.messages()[0].content.contains("no command channel"));
2819 }
2820
2821 #[test]
2822 fn execute_security_events_no_events_shows_history_header() {
2823 let (mut app, _rx, _tx) = make_app();
2824 app.execute_command(TuiCommand::SecurityEvents);
2825 assert_eq!(app.messages().len(), 1);
2826 assert!(app.messages()[0].content.contains("Security event history"));
2827 }
2828
2829 #[test]
2830 fn execute_security_events_with_events_shows_all() {
2831 use zeph_common::SecurityEventCategory;
2832 use zeph_core::metrics::SecurityEvent;
2833
2834 let (mut app, _rx, _tx) = make_app();
2835 app.metrics.security_events.push_back(SecurityEvent::new(
2836 SecurityEventCategory::InjectionFlag,
2837 "web_scrape",
2838 "Detected pattern: ignore previous",
2839 ));
2840 app.execute_command(TuiCommand::SecurityEvents);
2841 let content = &app.messages()[0].content;
2842 assert!(content.contains("web_scrape"));
2843 assert!(content.contains("INJECTION_FLAG"));
2844 }
2845
2846 #[test]
2847 fn has_recent_security_events_false_when_no_events() {
2848 let (app, _rx, _tx) = make_app();
2849 assert!(!app.has_recent_security_events());
2850 }
2851
2852 #[test]
2853 fn has_recent_security_events_true_when_recent() {
2854 use zeph_common::SecurityEventCategory;
2855 use zeph_core::metrics::SecurityEvent;
2856
2857 let (mut app, _rx, _tx) = make_app();
2858 app.metrics.security_events.push_back(SecurityEvent::new(
2860 SecurityEventCategory::Truncation,
2861 "tool",
2862 "truncated",
2863 ));
2864 assert!(app.has_recent_security_events());
2865 }
2866
2867 #[test]
2868 fn has_recent_security_events_false_when_event_older_than_60s() {
2869 use zeph_common::SecurityEventCategory;
2870 use zeph_core::metrics::SecurityEvent;
2871
2872 let (mut app, _rx, _tx) = make_app();
2873 let now = std::time::SystemTime::now()
2874 .duration_since(std::time::UNIX_EPOCH)
2875 .unwrap_or_default()
2876 .as_secs();
2877 let mut ev = SecurityEvent::new(SecurityEventCategory::Truncation, "tool", "old");
2878 ev.timestamp = now.saturating_sub(120);
2880 app.metrics.security_events.push_back(ev);
2881 assert!(!app.has_recent_security_events());
2882 }
2883 }
2884
2885 mod file_picker_tests {
2886 use std::fs;
2887
2888 use super::*;
2889 use crate::file_picker::FileIndex;
2890
2891 fn make_app_with_index() -> (App, mpsc::Receiver<String>, mpsc::Sender<AgentEvent>) {
2892 let (app, rx, tx) = make_app();
2893 (app, rx, tx)
2894 }
2895
2896 fn build_temp_index(files: &[&str]) -> (FileIndex, tempfile::TempDir) {
2897 let dir = tempfile::tempdir().unwrap();
2898 for &f in files {
2899 let path = dir.path().join(f);
2900 if let Some(parent) = path.parent() {
2901 fs::create_dir_all(parent).unwrap();
2902 }
2903 fs::write(&path, "").unwrap();
2904 }
2905 let idx = FileIndex::build(dir.path());
2906 (idx, dir)
2907 }
2908
2909 fn open_picker_with_index(app: &mut App, idx: &FileIndex) {
2910 let dir = tempfile::tempdir().unwrap();
2911 let path = dir.path().to_owned();
2912 drop(dir.keep());
2913 app.file_index = Some(FileIndex::build(&path));
2914 app.file_picker_state = Some(crate::file_picker::FilePickerState::new(idx));
2916 }
2917
2918 #[test]
2919 fn at_sign_opens_picker_and_does_not_insert_into_input() {
2920 let (mut app, _rx, _tx) = make_app_with_index();
2921 let (idx, _dir) = build_temp_index(&["a.rs"]);
2924 app.file_index = Some(idx);
2925 app.sessions.current_mut().input_mode = InputMode::Insert;
2926 let key = KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE);
2927 app.handle_event(AppEvent::Key(key));
2928 assert!(
2929 !app.sessions.current_mut().input.contains('@'),
2930 "@ should not be in input after opening picker"
2931 );
2932 assert!(
2933 app.file_picker_state.is_some(),
2934 "file_picker_state should be Some after @"
2935 );
2936 }
2937
2938 #[test]
2939 fn esc_dismisses_picker() {
2940 let (mut app, _rx, _tx) = make_app_with_index();
2941 let (idx, _dir) = build_temp_index(&["a.rs", "b.rs"]);
2942 open_picker_with_index(&mut app, &idx);
2943 assert!(app.file_picker_state.is_some());
2944
2945 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2946 app.handle_event(AppEvent::Key(key));
2947 assert!(app.file_picker_state.is_none());
2948 assert!(app.sessions.current_mut().input.is_empty());
2949 }
2950
2951 #[test]
2952 fn enter_inserts_selected_path_and_closes_picker() {
2953 let (mut app, _rx, _tx) = make_app_with_index();
2954 let (idx, _dir) = build_temp_index(&["src/main.rs"]);
2955 open_picker_with_index(&mut app, &idx);
2956
2957 let selected = app
2958 .file_picker_state
2959 .as_ref()
2960 .unwrap()
2961 .selected_path()
2962 .map(ToOwned::to_owned)
2963 .unwrap();
2964
2965 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
2966 app.handle_event(AppEvent::Key(key));
2967
2968 assert!(app.file_picker_state.is_none());
2969 assert!(
2970 app.sessions.current_mut().input.contains(&selected),
2971 "input should contain selected path"
2972 );
2973 assert_eq!(
2974 app.sessions.current_mut().cursor_position,
2975 selected.chars().count()
2976 );
2977 }
2978
2979 #[test]
2980 fn tab_inserts_selected_path_and_closes_picker() {
2981 let (mut app, _rx, _tx) = make_app_with_index();
2982 let (idx, _dir) = build_temp_index(&["README.md"]);
2983 open_picker_with_index(&mut app, &idx);
2984
2985 let selected = app
2986 .file_picker_state
2987 .as_ref()
2988 .unwrap()
2989 .selected_path()
2990 .map(ToOwned::to_owned)
2991 .unwrap();
2992
2993 let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
2994 app.handle_event(AppEvent::Key(key));
2995
2996 assert!(app.file_picker_state.is_none());
2997 assert!(app.sessions.current_mut().input.contains(&selected));
2998 }
2999
3000 #[test]
3001 fn enter_with_no_matches_closes_picker_without_modifying_input() {
3002 let (mut app, _rx, _tx) = make_app_with_index();
3003 let (idx, _dir) = build_temp_index(&["a.rs"]);
3004 open_picker_with_index(&mut app, &idx);
3005
3006 let state = app.file_picker_state.as_mut().unwrap();
3007 state.update_query("xyznotfound");
3008
3009 assert!(app.file_picker_state.as_ref().unwrap().matches().is_empty());
3010
3011 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
3012 app.handle_event(AppEvent::Key(key));
3013
3014 assert!(app.file_picker_state.is_none());
3015 assert!(
3016 app.sessions.current_mut().input.is_empty(),
3017 "input must be unchanged"
3018 );
3019 }
3020
3021 #[test]
3022 fn down_key_advances_selection() {
3023 let (mut app, _rx, _tx) = make_app_with_index();
3024 let (idx, _dir) = build_temp_index(&["a.rs", "b.rs", "c.rs"]);
3025 open_picker_with_index(&mut app, &idx);
3026
3027 assert_eq!(app.file_picker_state.as_ref().unwrap().selected, 0);
3028
3029 let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
3030 app.handle_event(AppEvent::Key(key));
3031 assert_eq!(app.file_picker_state.as_ref().unwrap().selected, 1);
3032 }
3033
3034 #[test]
3035 fn up_key_wraps_selection_to_last() {
3036 let (mut app, _rx, _tx) = make_app_with_index();
3037 let (idx, _dir) = build_temp_index(&["a.rs", "b.rs", "c.rs"]);
3038 open_picker_with_index(&mut app, &idx);
3039
3040 let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
3041 app.handle_event(AppEvent::Key(key));
3042 let state = app.file_picker_state.as_ref().unwrap();
3043 assert_eq!(state.selected, state.matches().len() - 1);
3044 }
3045
3046 #[test]
3047 fn typing_filters_matches() {
3048 let (mut app, _rx, _tx) = make_app_with_index();
3049 let (idx, _dir) = build_temp_index(&["src/main.rs", "src/lib.rs"]);
3050 open_picker_with_index(&mut app, &idx);
3051
3052 let initial_count = app.file_picker_state.as_ref().unwrap().matches().len();
3053
3054 let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
3055 app.handle_event(AppEvent::Key(key));
3056
3057 let filtered_count = app.file_picker_state.as_ref().unwrap().matches().len();
3058 assert!(filtered_count <= initial_count);
3059 assert_eq!(app.file_picker_state.as_ref().unwrap().query, "m");
3060 }
3061
3062 #[test]
3063 fn backspace_with_nonempty_query_removes_char() {
3064 let (mut app, _rx, _tx) = make_app_with_index();
3065 let (idx, _dir) = build_temp_index(&["a.rs"]);
3066 open_picker_with_index(&mut app, &idx);
3067
3068 app.file_picker_state.as_mut().unwrap().update_query("ma");
3069
3070 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
3071 app.handle_event(AppEvent::Key(key));
3072
3073 assert!(app.file_picker_state.is_some());
3074 assert_eq!(app.file_picker_state.as_ref().unwrap().query, "m");
3075 }
3076
3077 #[test]
3078 fn backspace_on_empty_query_dismisses_picker() {
3079 let (mut app, _rx, _tx) = make_app_with_index();
3080 let (idx, _dir) = build_temp_index(&["a.rs"]);
3081 open_picker_with_index(&mut app, &idx);
3082
3083 assert!(app.file_picker_state.as_ref().unwrap().query.is_empty());
3084
3085 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
3086 app.handle_event(AppEvent::Key(key));
3087
3088 assert!(app.file_picker_state.is_none());
3089 }
3090
3091 #[test]
3092 fn picker_blocks_other_keys() {
3093 let (mut app, _rx, _tx) = make_app_with_index();
3094 let (idx, _dir) = build_temp_index(&["a.rs"]);
3095 open_picker_with_index(&mut app, &idx);
3096
3097 app.sessions.current_mut().input = "hello".into();
3098 app.sessions.current_mut().cursor_position = 5;
3099 let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
3100 app.handle_event(AppEvent::Key(key));
3101 assert_eq!(
3102 app.sessions.current_mut().input,
3103 "hello",
3104 "input should be unchanged while picker is open"
3105 );
3106 }
3107
3108 #[test]
3109 fn enter_inserts_at_cursor_mid_input() {
3110 let (mut app, _rx, _tx) = make_app_with_index();
3111 let (idx, _dir) = build_temp_index(&["src/lib.rs"]);
3112 open_picker_with_index(&mut app, &idx);
3113
3114 app.sessions.current_mut().input = "ab".into();
3115 app.sessions.current_mut().cursor_position = 1;
3116
3117 let selected = app
3118 .file_picker_state
3119 .as_ref()
3120 .unwrap()
3121 .selected_path()
3122 .map(ToOwned::to_owned)
3123 .unwrap();
3124
3125 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
3126 app.handle_event(AppEvent::Key(key));
3127
3128 assert!(app.sessions.current_mut().input.contains(&selected));
3129 assert!(app.sessions.current_mut().input.starts_with('a'));
3130 assert!(app.sessions.current_mut().input.ends_with('b'));
3131 }
3132
3133 #[tokio::test]
3134 async fn poll_pending_file_index_installs_index_and_opens_picker() {
3135 let (user_tx, _user_rx) = tokio::sync::mpsc::channel(1);
3136 let (_agent_tx, agent_rx) = tokio::sync::mpsc::channel(1);
3137 let mut app = App::new(user_tx, agent_rx);
3138
3139 let (tx, rx) = tokio::sync::oneshot::channel();
3141 let (idx, _dir) = build_temp_index(&["foo.rs"]);
3142 let _ = tx.send(idx);
3143 app.pending_file_index = Some(rx);
3144 app.sessions.current_mut().status_label = Some("indexing files...".to_owned());
3145
3146 tokio::task::yield_now().await;
3148
3149 app.poll_pending_file_index();
3150
3151 assert!(app.file_index.is_some(), "file_index should be installed");
3152 assert!(
3153 app.file_picker_state.is_some(),
3154 "picker should open after index ready"
3155 );
3156 assert!(
3157 app.sessions.current_mut().status_label.is_none(),
3158 "status should be cleared after index ready"
3159 );
3160 assert!(
3161 app.pending_file_index.is_none(),
3162 "pending handle should be consumed"
3163 );
3164 }
3165
3166 #[tokio::test]
3167 async fn poll_pending_file_index_noop_when_none() {
3168 let (user_tx, _user_rx) = tokio::sync::mpsc::channel(1);
3169 let (_agent_tx, agent_rx) = tokio::sync::mpsc::channel(1);
3170 let mut app = App::new(user_tx, agent_rx);
3171
3172 app.poll_pending_file_index();
3174
3175 assert!(app.file_index.is_none());
3176 assert!(app.file_picker_state.is_none());
3177 }
3178
3179 #[tokio::test]
3180 async fn poll_pending_file_index_clears_on_closed_sender() {
3181 let (user_tx, _user_rx) = tokio::sync::mpsc::channel(1);
3182 let (_agent_tx, agent_rx) = tokio::sync::mpsc::channel(1);
3183 let mut app = App::new(user_tx, agent_rx);
3184
3185 let (tx, rx) = tokio::sync::oneshot::channel::<crate::file_picker::FileIndex>();
3186 drop(tx);
3188 app.pending_file_index = Some(rx);
3189 app.sessions.current_mut().status_label = Some("indexing files...".to_owned());
3190
3191 app.poll_pending_file_index();
3192
3193 assert!(
3194 app.pending_file_index.is_none(),
3195 "closed handle should be consumed"
3196 );
3197 assert!(
3198 app.sessions.current_mut().status_label.is_none(),
3199 "status should be cleared on closed sender"
3200 );
3201 }
3202 }
3203
3204 #[test]
3205 fn draw_header_shows_1m_ctx_badge_when_extended_context() {
3206 use crate::test_utils::render_to_string;
3207
3208 let (mut app, _rx, _tx) = make_app();
3209 app.metrics.provider_name = "claude".into();
3210 app.metrics.model_name = "claude-sonnet-4-6".into();
3211 app.metrics.extended_context = true;
3212
3213 let output = render_to_string(80, 1, |frame, area| {
3214 app.draw_header(frame, area);
3215 });
3216 assert!(
3217 output.contains("[1M CTX]"),
3218 "header must contain [1M CTX] badge when extended_context is true; got: {output:?}"
3219 );
3220 }
3221
3222 #[test]
3223 fn draw_header_no_badge_without_extended_context() {
3224 use crate::test_utils::render_to_string;
3225
3226 let (mut app, _rx, _tx) = make_app();
3227 app.metrics.provider_name = "claude".into();
3228 app.metrics.model_name = "claude-sonnet-4-6".into();
3229 app.metrics.extended_context = false;
3230
3231 let output = render_to_string(80, 1, |frame, area| {
3232 app.draw_header(frame, area);
3233 });
3234 assert!(
3235 !output.contains("[1M CTX]"),
3236 "header must not contain [1M CTX] badge when extended_context is false; got: {output:?}"
3237 );
3238 }
3239
3240 #[test]
3243 fn with_metrics_rx_reads_initial_value() {
3244 use tokio::sync::watch;
3245 use zeph_core::metrics::MetricsSnapshot;
3246
3247 let (user_tx, agent_rx) = {
3248 let (u, _ur) = mpsc::channel(4);
3249 let (_at, ar) = mpsc::channel(4);
3250 (u, ar)
3251 };
3252 let initial = MetricsSnapshot {
3253 graph_entities_total: 42,
3254 graph_edges_total: 7,
3255 graph_communities_total: 3,
3256 ..MetricsSnapshot::default()
3257 };
3258
3259 let (tx, rx) = watch::channel(initial);
3260 let app = App::new(user_tx, agent_rx).with_metrics_rx(rx);
3261
3262 assert_eq!(app.metrics.graph_entities_total, 42);
3263 assert_eq!(app.metrics.graph_edges_total, 7);
3264 assert_eq!(app.metrics.graph_communities_total, 3);
3265
3266 drop(tx);
3267 }
3268
3269 #[test]
3273 fn tool_output_with_prior_tool_start_no_chunks_appends_output() {
3274 let (mut app, _rx, _tx) = make_app();
3275 app.handle_agent_event(AgentEvent::ToolStart {
3277 tool_name: "bash".into(),
3278 command: "ls -la".into(),
3279 });
3280 app.handle_agent_event(AgentEvent::ToolOutput {
3282 tool_name: "bash".into(),
3283 command: "ls -la".into(),
3284 output: "file1\nfile2\n".into(),
3285 success: true,
3286 diff: None,
3287 filter_stats: None,
3288 kept_lines: None,
3289 });
3290
3291 assert_eq!(app.messages().len(), 1);
3292 let msg = &app.messages()[0];
3293 assert_eq!(msg.content, "$ ls -la\nfile1\nfile2\n");
3294 assert!(!msg.streaming);
3295 }
3296
3297 #[test]
3298 fn tool_output_with_prior_tool_start_and_chunks_does_not_duplicate() {
3299 let (mut app, _rx, _tx) = make_app();
3300 app.handle_agent_event(AgentEvent::ToolStart {
3302 tool_name: "bash".into(),
3303 command: "echo hello".into(),
3304 });
3305 app.handle_agent_event(AgentEvent::ToolOutputChunk {
3307 tool_name: "bash".into(),
3308 command: "echo hello".into(),
3309 chunk: "hello\n".into(),
3310 });
3311 app.handle_agent_event(AgentEvent::ToolOutput {
3313 tool_name: "bash".into(),
3314 command: "echo hello".into(),
3315 output: "hello\n".into(),
3316 success: true,
3317 diff: None,
3318 filter_stats: None,
3319 kept_lines: None,
3320 });
3321
3322 assert_eq!(app.messages().len(), 1);
3323 let msg = &app.messages()[0];
3324 assert_eq!(msg.content, "$ echo hello\nhello\n");
3326 assert!(!msg.streaming);
3327 }
3328
3329 #[test]
3332 fn agent_view_target_main_is_main() {
3333 assert!(AgentViewTarget::Main.is_main());
3334 assert!(AgentViewTarget::Main.subagent_id().is_none());
3335 assert!(AgentViewTarget::Main.subagent_name().is_none());
3336 }
3337
3338 #[test]
3339 fn agent_view_target_subagent_accessors() {
3340 let t = AgentViewTarget::SubAgent {
3341 id: "abc".into(),
3342 name: "Worker".into(),
3343 };
3344 assert!(!t.is_main());
3345 assert_eq!(t.subagent_id(), Some("abc"));
3346 assert_eq!(t.subagent_name(), Some("Worker"));
3347 }
3348
3349 #[test]
3352 fn sidebar_select_next_advances() {
3353 let mut s = SubAgentSidebarState::new();
3354 assert!(s.selected().is_none());
3356 s.select_next(3);
3357 assert_eq!(s.selected(), Some(0));
3358 s.select_next(3);
3359 assert_eq!(s.selected(), Some(1));
3360 s.select_next(3);
3361 assert_eq!(s.selected(), Some(2));
3362 s.select_next(3);
3364 assert_eq!(s.selected(), Some(2));
3365 }
3366
3367 #[test]
3368 fn sidebar_select_next_noop_when_empty() {
3369 let mut s = SubAgentSidebarState::new();
3370 s.select_next(0);
3371 assert!(s.selected().is_none());
3372 }
3373
3374 #[test]
3375 fn sidebar_select_prev_decrements() {
3376 let mut s = SubAgentSidebarState::new();
3377 s.list_state.select(Some(2));
3378 s.select_prev(3);
3379 assert_eq!(s.selected(), Some(1));
3380 s.select_prev(3);
3381 assert_eq!(s.selected(), Some(0));
3382 s.select_prev(3);
3384 assert_eq!(s.selected(), Some(0));
3385 }
3386
3387 #[test]
3388 fn sidebar_select_prev_from_none_goes_to_zero() {
3389 let mut s = SubAgentSidebarState::new();
3390 s.select_prev(3);
3391 assert_eq!(s.selected(), Some(0));
3392 }
3393
3394 #[test]
3395 fn sidebar_select_prev_noop_when_empty() {
3396 let mut s = SubAgentSidebarState::new();
3397 s.select_prev(0);
3398 assert!(s.selected().is_none());
3399 }
3400
3401 #[test]
3402 fn sidebar_clamp_removes_selection_when_empty() {
3403 let mut s = SubAgentSidebarState::new();
3404 s.list_state.select(Some(2));
3405 s.clamp(0);
3406 assert!(s.selected().is_none());
3407 }
3408
3409 #[test]
3410 fn sidebar_clamp_reduces_out_of_bounds_selection() {
3411 let mut s = SubAgentSidebarState::new();
3412 s.list_state.select(Some(5));
3413 s.clamp(3); assert_eq!(s.selected(), Some(2));
3415 }
3416
3417 #[test]
3418 fn sidebar_clamp_leaves_valid_selection_unchanged() {
3419 let mut s = SubAgentSidebarState::new();
3420 s.list_state.select(Some(1));
3421 s.clamp(3);
3422 assert_eq!(s.selected(), Some(1));
3423 }
3424
3425 #[test]
3428 fn transcript_entry_to_chat_message_role_mapping() {
3429 let cases = [
3430 ("user", MessageRole::User),
3431 ("assistant", MessageRole::Assistant),
3432 ("tool", MessageRole::Tool),
3433 ("system", MessageRole::System),
3434 ("unknown_role", MessageRole::System),
3435 ];
3436 for (role_str, expected) in cases {
3437 let entry = TuiTranscriptEntry {
3438 role: role_str.into(),
3439 content: "hello".into(),
3440 tool_name: None,
3441 timestamp: None,
3442 };
3443 let msg = entry.to_chat_message();
3444 assert_eq!(msg.role, expected, "role_str={role_str}");
3445 }
3446 }
3447
3448 #[test]
3449 fn transcript_entry_to_chat_message_copies_tool_name_and_timestamp() {
3450 let entry = TuiTranscriptEntry {
3451 role: "tool".into(),
3452 content: "result".into(),
3453 tool_name: Some("bash".into()),
3454 timestamp: Some("12:34".into()),
3455 };
3456 let msg = entry.to_chat_message();
3457 assert_eq!(
3458 msg.tool_name.as_ref().map(zeph_common::ToolName::as_str),
3459 Some("bash")
3460 );
3461 assert_eq!(msg.timestamp, "12:34");
3462 assert_eq!(msg.content, "result");
3463 }
3464
3465 #[test]
3468 fn load_transcript_file_returns_empty_for_nonexistent_path() {
3469 let (entries, total) =
3470 load_transcript_file(std::path::Path::new("/nonexistent/path/x.jsonl"), false);
3471 assert!(entries.is_empty());
3472 assert_eq!(total, 0);
3473 }
3474
3475 #[test]
3476 fn load_transcript_file_parses_flat_format() {
3477 let tmp = tempfile::NamedTempFile::new().unwrap();
3478 std::fs::write(
3479 tmp.path(),
3480 r#"{"role":"user","content":"hello"}
3481{"role":"assistant","content":"world"}
3482"#,
3483 )
3484 .unwrap();
3485 let (entries, total) = load_transcript_file(tmp.path(), false);
3486 assert_eq!(total, 2);
3487 assert_eq!(entries.len(), 2);
3488 assert_eq!(entries[0].role, "user");
3489 assert_eq!(entries[0].content, "hello");
3490 assert_eq!(entries[1].role, "assistant");
3491 assert_eq!(entries[1].content, "world");
3492 }
3493
3494 #[test]
3495 fn load_transcript_file_parses_nested_format() {
3496 let tmp = tempfile::NamedTempFile::new().unwrap();
3497 std::fs::write(
3498 tmp.path(),
3499 r#"{"seq":1,"timestamp":"12:00","message":{"role":"user","parts":[{"content":"hi"}]}}
3500"#,
3501 )
3502 .unwrap();
3503 let (entries, total) = load_transcript_file(tmp.path(), false);
3504 assert_eq!(total, 1);
3505 assert_eq!(entries.len(), 1);
3506 assert_eq!(entries[0].role, "user");
3507 assert_eq!(entries[0].content, "hi");
3508 assert_eq!(entries[0].timestamp.as_deref(), Some("12:00"));
3509 }
3510
3511 #[test]
3512 fn load_transcript_file_skips_partial_last_line_when_active() {
3513 let tmp = tempfile::NamedTempFile::new().unwrap();
3514 std::fs::write(
3516 tmp.path(),
3517 r#"{"role":"user","content":"complete"}
3518{"role":"assistant","content":"incomplet"#,
3519 )
3520 .unwrap();
3521 let (entries, total) = load_transcript_file(tmp.path(), true);
3522 assert_eq!(total, 2); assert_eq!(entries.len(), 1);
3525 assert_eq!(entries[0].content, "complete");
3526 }
3527
3528 #[test]
3529 fn load_transcript_file_keeps_partial_last_line_when_inactive() {
3530 let tmp = tempfile::NamedTempFile::new().unwrap();
3531 std::fs::write(
3533 tmp.path(),
3534 r#"{"role":"user","content":"complete"}
3535{"role":"assistant","content":"also complete"}
3536"#,
3537 )
3538 .unwrap();
3539 let (entries, total) = load_transcript_file(tmp.path(), false);
3541 assert_eq!(total, 2);
3542 assert_eq!(entries.len(), 2);
3543 }
3544
3545 #[test]
3546 fn load_transcript_file_skips_empty_content_without_tool_name() {
3547 let tmp = tempfile::NamedTempFile::new().unwrap();
3548 std::fs::write(
3549 tmp.path(),
3550 r#"{"role":"user","content":""}
3551{"role":"assistant","content":"real"}
3552"#,
3553 )
3554 .unwrap();
3555 let (entries, _total) = load_transcript_file(tmp.path(), false);
3556 assert_eq!(entries.len(), 1);
3558 assert_eq!(entries[0].content, "real");
3559 }
3560
3561 #[test]
3562 fn load_transcript_file_keeps_empty_content_with_tool_name() {
3563 let tmp = tempfile::NamedTempFile::new().unwrap();
3564 std::fs::write(
3565 tmp.path(),
3566 r#"{"role":"tool","content":"","tool_name":"bash"}
3567"#,
3568 )
3569 .unwrap();
3570 let (entries, _total) = load_transcript_file(tmp.path(), false);
3571 assert_eq!(entries.len(), 1);
3572 assert_eq!(
3573 entries[0]
3574 .tool_name
3575 .as_ref()
3576 .map(zeph_common::ToolName::as_str),
3577 Some("bash")
3578 );
3579 }
3580
3581 #[test]
3582 fn load_transcript_file_truncates_to_max_entries() {
3583 let tmp = tempfile::NamedTempFile::new().unwrap();
3584 let extra = 5;
3586 let count = TRANSCRIPT_MAX_ENTRIES + extra;
3587 let content: String = (0..count).fold(String::new(), |mut acc, i| {
3588 use std::fmt::Write;
3589 let _ = writeln!(acc, "{{\"role\":\"user\",\"content\":\"msg{i}\"}}");
3590 acc
3591 });
3592 std::fs::write(tmp.path(), &content).unwrap();
3593 let (entries, total) = load_transcript_file(tmp.path(), false);
3594 assert_eq!(total, count);
3595 assert_eq!(entries.len(), TRANSCRIPT_MAX_ENTRIES);
3596 assert_eq!(entries[0].content, format!("msg{extra}"));
3598 assert_eq!(
3599 entries[TRANSCRIPT_MAX_ENTRIES - 1].content,
3600 format!("msg{}", count - 1)
3601 );
3602 }
3603
3604 #[test]
3607 fn transcript_truncation_info_returns_none_when_no_cache() {
3608 let (app, _rx, _tx) = make_app();
3609 assert!(app.transcript_truncation_info().is_none());
3610 }
3611
3612 #[test]
3613 fn transcript_truncation_info_returns_none_when_not_truncated() {
3614 let (mut app, _rx, _tx) = make_app();
3615 app.sessions.current_mut().transcript_cache = Some(TranscriptCache {
3616 agent_id: "a".into(),
3617 entries: vec![],
3618 turns_at_load: 1,
3619 total_in_file: TRANSCRIPT_MAX_ENTRIES,
3620 });
3621 assert!(app.transcript_truncation_info().is_none());
3622 }
3623
3624 #[test]
3625 fn transcript_truncation_info_returns_message_when_truncated() {
3626 let (mut app, _rx, _tx) = make_app();
3627 let total = TRANSCRIPT_MAX_ENTRIES + 50;
3628 app.sessions.current_mut().transcript_cache = Some(TranscriptCache {
3629 agent_id: "a".into(),
3630 entries: vec![],
3631 turns_at_load: 1,
3632 total_in_file: total,
3633 });
3634 let info = app.transcript_truncation_info().unwrap();
3635 assert!(info.contains(&total.to_string()), "info={info}");
3636 assert!(
3637 info.contains(&TRANSCRIPT_MAX_ENTRIES.to_string()),
3638 "info={info}"
3639 );
3640 }
3641
3642 #[test]
3645 fn visible_messages_returns_main_messages_when_in_main_view() {
3646 let (mut app, _rx, _tx) = make_app();
3647 app.sessions
3648 .current_mut()
3649 .messages
3650 .push(ChatMessage::new(MessageRole::User, String::from("hello")));
3651 let msgs = app.visible_messages();
3652 assert_eq!(msgs.len(), 1);
3653 assert_eq!(msgs[0].content, "hello");
3654 }
3655
3656 #[test]
3657 fn visible_messages_returns_transcript_when_cache_present() {
3658 let (mut app, _rx, _tx) = make_app();
3659 app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
3660 id: "x".into(),
3661 name: "X".into(),
3662 };
3663 app.sessions.current_mut().transcript_cache = Some(TranscriptCache {
3664 agent_id: "x".into(),
3665 entries: vec![TuiTranscriptEntry {
3666 role: "user".into(),
3667 content: "from transcript".into(),
3668 tool_name: None,
3669 timestamp: None,
3670 }],
3671 turns_at_load: 1,
3672 total_in_file: 1,
3673 });
3674 let msgs = app.visible_messages();
3675 assert_eq!(msgs.len(), 1);
3676 assert_eq!(msgs[0].content, "from transcript");
3677 }
3678
3679 #[test]
3680 fn visible_messages_returns_loading_placeholder_when_pending() {
3681 let (mut app, _rx, _tx) = make_app();
3682 app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
3683 id: "x".into(),
3684 name: "X".into(),
3685 };
3686 let (_tx2, rx2) = tokio::sync::oneshot::channel::<(Vec<TuiTranscriptEntry>, usize)>();
3688 app.sessions.current_mut().pending_transcript = Some(rx2);
3689 let msgs = app.visible_messages();
3690 assert_eq!(msgs.len(), 1);
3691 assert!(
3692 msgs[0].content.contains("Loading"),
3693 "content={}",
3694 msgs[0].content
3695 );
3696 }
3697
3698 #[test]
3699 fn visible_messages_returns_unavailable_when_no_cache_and_no_pending() {
3700 let (mut app, _rx, _tx) = make_app();
3701 app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
3702 id: "x".into(),
3703 name: "MyAgent".into(),
3704 };
3705 let msgs = app.visible_messages();
3706 assert_eq!(msgs.len(), 1);
3707 assert!(
3708 msgs[0].content.contains("MyAgent"),
3709 "content={}",
3710 msgs[0].content
3711 );
3712 }
3713
3714 #[test]
3717 fn set_view_target_same_target_is_noop() {
3718 let (mut app, _rx, _tx) = make_app();
3719 app.sessions.current_mut().scroll_offset = 5;
3720 app.set_view_target(AgentViewTarget::Main);
3722 assert_eq!(app.sessions.current_mut().scroll_offset, 5);
3724 }
3725
3726 #[test]
3727 fn set_view_target_clears_cache_and_scroll_on_switch() {
3728 let (mut app, _rx, _tx) = make_app();
3729 app.sessions.current_mut().scroll_offset = 10;
3730 app.sessions.current_mut().transcript_cache = Some(TranscriptCache {
3731 agent_id: "a".into(),
3732 entries: vec![],
3733 turns_at_load: 1,
3734 total_in_file: 1,
3735 });
3736 app.sessions.current_mut().view_target = AgentViewTarget::SubAgent {
3738 id: "a".into(),
3739 name: "A".into(),
3740 };
3741 app.set_view_target(AgentViewTarget::Main);
3742 assert_eq!(app.sessions.current_mut().scroll_offset, 0);
3743 assert!(app.sessions.current_mut().transcript_cache.is_none());
3744 }
3745
3746 mod slash_autocomplete_tests {
3747 use super::*;
3748
3749 #[test]
3750 fn slash_on_empty_input_opens_autocomplete() {
3751 let (mut app, _rx, _tx) = make_app();
3752 app.sessions.current_mut().input_mode = InputMode::Insert;
3753 assert!(app.slash_autocomplete.is_none());
3754
3755 let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
3756 app.handle_event(AppEvent::Key(key));
3757 assert!(app.slash_autocomplete.is_some());
3758 assert_eq!(app.input(), "/");
3759 }
3760
3761 #[test]
3762 fn no_open_mid_input() {
3763 let (mut app, _rx, _tx) = make_app();
3764 app.sessions.current_mut().input_mode = InputMode::Insert;
3765 app.sessions.current_mut().input = "hello ".to_owned();
3766 app.sessions.current_mut().cursor_position = 6;
3767
3768 let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
3769 app.handle_event(AppEvent::Key(key));
3770 assert!(app.slash_autocomplete.is_none());
3771 }
3772
3773 #[test]
3774 fn esc_dismisses_autocomplete() {
3775 let (mut app, _rx, _tx) = make_app();
3776 app.sessions.current_mut().input_mode = InputMode::Insert;
3777 app.slash_autocomplete =
3778 Some(crate::widgets::slash_autocomplete::SlashAutocompleteState::new());
3779 app.sessions.current_mut().input = "/sk".to_owned();
3780 app.sessions.current_mut().cursor_position = 3;
3781
3782 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
3783 app.handle_event(AppEvent::Key(key));
3784 assert!(app.slash_autocomplete.is_none());
3785 assert_eq!(app.input(), "/sk");
3787 }
3788
3789 #[test]
3790 fn at_char_while_autocomplete_open_does_not_open_file_picker() {
3791 let (mut app, _rx, _tx) = make_app();
3792 app.sessions.current_mut().input_mode = InputMode::Insert;
3793 app.slash_autocomplete =
3794 Some(crate::widgets::slash_autocomplete::SlashAutocompleteState::new());
3795 app.sessions.current_mut().input = "/".to_owned();
3796 app.sessions.current_mut().cursor_position = 1;
3797
3798 let key = KeyEvent::new(KeyCode::Char('@'), KeyModifiers::NONE);
3799 app.handle_event(AppEvent::Key(key));
3800 assert!(app.file_picker_state.is_none());
3801 }
3802
3803 #[test]
3804 fn backspace_removes_slash_and_dismisses() {
3805 let (mut app, _rx, _tx) = make_app();
3806 app.sessions.current_mut().input_mode = InputMode::Insert;
3807 app.slash_autocomplete =
3808 Some(crate::widgets::slash_autocomplete::SlashAutocompleteState::new());
3809 app.sessions.current_mut().input = "/".to_owned();
3810 app.sessions.current_mut().cursor_position = 1;
3811
3812 let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
3813 app.handle_event(AppEvent::Key(key));
3814 assert!(app.slash_autocomplete.is_none());
3815 assert!(app.input().is_empty());
3816 }
3817 }
3818
3819 #[test]
3822 fn trim_messages_no_trim_when_within_limit() {
3823 let (mut app, _rx, _tx) = make_app();
3824 for i in 0..10 {
3825 app.sessions
3826 .current_mut()
3827 .messages
3828 .push(ChatMessage::new(MessageRole::User, format!("msg {i}")));
3829 }
3830 app.sessions.current_mut().scroll_offset = 5;
3831 app.trim_messages();
3832 assert_eq!(app.sessions.current_mut().messages.len(), 10);
3833 assert_eq!(app.sessions.current_mut().scroll_offset, 5);
3834 }
3835
3836 #[test]
3837 fn trim_messages_evicts_excess_and_adjusts_scroll() {
3838 let (mut app, _rx, _tx) = make_app();
3839 let over = MAX_TUI_MESSAGES + 10;
3840 for i in 0..over {
3841 app.sessions
3842 .current_mut()
3843 .messages
3844 .push(ChatMessage::new(MessageRole::User, format!("msg {i}")));
3845 }
3846 app.sessions.current_mut().scroll_offset = 20;
3847 app.trim_messages();
3848 assert_eq!(app.sessions.current_mut().messages.len(), MAX_TUI_MESSAGES);
3849 assert_eq!(app.sessions.current_mut().scroll_offset, 10); }
3851
3852 #[test]
3853 fn trim_messages_scroll_saturates_at_zero() {
3854 let (mut app, _rx, _tx) = make_app();
3855 let over = MAX_TUI_MESSAGES + 50;
3856 for i in 0..over {
3857 app.sessions
3858 .current_mut()
3859 .messages
3860 .push(ChatMessage::new(MessageRole::User, format!("msg {i}")));
3861 }
3862 app.sessions.current_mut().scroll_offset = 10; app.trim_messages();
3864 assert_eq!(app.sessions.current_mut().messages.len(), MAX_TUI_MESSAGES);
3865 assert_eq!(app.sessions.current_mut().scroll_offset, 0); }
3867
3868 #[test]
3869 fn supervisor_activity_label_no_supervisor_returns_none() {
3870 let (app, _rx, _tx) = make_app();
3871 assert!(app.supervisor_activity_label().is_none());
3872 }
3873
3874 #[tokio::test]
3875 async fn supervisor_activity_label_single_active_task() {
3876 use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
3877
3878 let cancel = tokio_util::sync::CancellationToken::new();
3880 let sup = TaskSupervisor::new(cancel.clone());
3881 sup.spawn(TaskDescriptor {
3882 name: "config-watcher",
3883 restart: RestartPolicy::RunOnce,
3884 factory: || async { std::future::pending::<()>().await },
3885 });
3886
3887 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3889
3890 let (mut app, _rx, _tx) = make_app();
3891 app = app.with_task_supervisor(sup);
3892 app.refresh_task_snapshots();
3893
3894 let label = app.supervisor_activity_label();
3895 assert!(label.is_some(), "expected Some label for active task");
3896 assert!(
3897 label.as_deref().unwrap().contains("config-watcher"),
3898 "label should contain task name: {label:?}"
3899 );
3900
3901 cancel.cancel();
3902 }
3903
3904 #[tokio::test]
3905 async fn supervisor_activity_label_multiple_tasks_shows_more() {
3906 use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
3907
3908 let cancel = tokio_util::sync::CancellationToken::new();
3909 let sup = TaskSupervisor::new(cancel.clone());
3910 for name in &["task-a", "task-b", "task-c"] {
3911 sup.spawn(TaskDescriptor {
3912 name,
3913 restart: RestartPolicy::RunOnce,
3914 factory: || async { std::future::pending::<()>().await },
3915 });
3916 }
3917
3918 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3919
3920 let (mut app, _rx, _tx) = make_app();
3921 app = app.with_task_supervisor(sup);
3922 app.refresh_task_snapshots();
3923
3924 let label = app
3925 .supervisor_activity_label()
3926 .expect("expected Some label");
3927 assert!(
3928 label.contains('+') || label.contains("more"),
3929 "expected '+N more' for multiple tasks, got: {label:?}"
3930 );
3931
3932 cancel.cancel();
3933 }
3934
3935 #[test]
3936 fn paste_inserts_text_in_insert_mode() {
3937 let (mut app, _rx, _tx) = make_app();
3938 app.handle_event(AppEvent::Paste("hello".to_owned()));
3939 assert_eq!(app.input(), "hello");
3940 assert_eq!(app.cursor_position(), 5);
3941 }
3942
3943 #[test]
3944 fn paste_at_mid_cursor_inserts_at_position() {
3945 let (mut app, _rx, _tx) = make_app();
3946 app.handle_event(AppEvent::Paste("ac".to_owned()));
3947 let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
3949 app.handle_event(AppEvent::Key(left));
3950 app.handle_event(AppEvent::Paste("b".to_owned()));
3951 assert_eq!(app.input(), "abc");
3952 assert_eq!(app.cursor_position(), 2);
3953 }
3954
3955 #[test]
3956 fn paste_multiline_inserts_newlines() {
3957 let (mut app, _rx, _tx) = make_app();
3958 app.handle_event(AppEvent::Paste("line1\nline2".to_owned()));
3959 assert_eq!(app.input(), "line1\nline2");
3960 assert_eq!(app.cursor_position(), 11);
3961 }
3962
3963 #[test]
3964 fn paste_in_normal_mode_ignored() {
3965 let (mut app, _rx, _tx) = make_app();
3966 app.sessions.current_mut().input_mode = InputMode::Normal;
3967 app.handle_event(AppEvent::Paste("should not appear".to_owned()));
3968 assert!(app.input().is_empty());
3969 }
3970
3971 #[test]
3972 fn paste_clears_slash_autocomplete() {
3973 let (mut app, _rx, _tx) = make_app();
3974 app.slash_autocomplete =
3975 Some(crate::widgets::slash_autocomplete::SlashAutocompleteState::new());
3976 app.handle_event(AppEvent::Paste("text".to_owned()));
3977 assert!(app.slash_autocomplete.is_none());
3978 assert_eq!(app.input(), "text");
3979 }
3980
3981 #[test]
3982 fn supervisor_activity_label_truncates_at_utf8_boundary() {
3983 let long_name: String = "あ".repeat(50); let truncated: String = long_name.chars().take(38).collect();
3990 assert_eq!(truncated.chars().count(), 38, "should truncate to 38 chars");
3991 assert!(
3992 truncated.is_char_boundary(truncated.len()),
3993 "must be valid UTF-8"
3994 );
3995 let _ = &long_name[..truncated.len()];
3997 }
3998
3999 #[test]
4000 fn paste_state_set_for_multiline() {
4001 let (mut app, _rx, _tx) = make_app();
4002 app.sessions.current_mut().input_mode = InputMode::Insert;
4003 app.handle_event(AppEvent::Paste("line1\nline2\nline3".to_owned()));
4004 let ps = app.paste_state().expect("paste_state should be Some");
4005 assert_eq!(ps.line_count, 3);
4006 assert_eq!(ps.byte_len, "line1\nline2\nline3".len());
4007 }
4008
4009 #[test]
4010 fn paste_state_none_for_single_line() {
4011 let (mut app, _rx, _tx) = make_app();
4012 app.sessions.current_mut().input_mode = InputMode::Insert;
4013 app.handle_event(AppEvent::Paste("single line".to_owned()));
4014 assert!(app.paste_state().is_none());
4015 }
4016
4017 #[test]
4018 fn paste_state_cleared_on_char() {
4019 let (mut app, _rx, _tx) = make_app();
4020 app.sessions.current_mut().input_mode = InputMode::Insert;
4021 app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4022 assert!(app.paste_state().is_some());
4023 app.handle_event(AppEvent::Key(KeyEvent::new(
4024 KeyCode::Char('x'),
4025 KeyModifiers::NONE,
4026 )));
4027 assert!(app.paste_state().is_none());
4028 }
4029
4030 #[test]
4031 fn paste_state_cleared_on_backspace() {
4032 let (mut app, _rx, _tx) = make_app();
4033 app.sessions.current_mut().input_mode = InputMode::Insert;
4034 app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4035 assert!(app.paste_state().is_some());
4036 app.handle_event(AppEvent::Key(KeyEvent::new(
4037 KeyCode::Backspace,
4038 KeyModifiers::NONE,
4039 )));
4040 assert!(app.paste_state().is_none());
4041 }
4042
4043 #[test]
4044 fn paste_state_cleared_on_ctrl_u() {
4045 let (mut app, _rx, _tx) = make_app();
4046 app.sessions.current_mut().input_mode = InputMode::Insert;
4047 app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4048 assert!(app.paste_state().is_some());
4049 app.handle_event(AppEvent::Key(KeyEvent::new(
4050 KeyCode::Char('u'),
4051 KeyModifiers::CONTROL,
4052 )));
4053 assert!(app.paste_state().is_none());
4054 assert!(
4055 app.input().is_empty(),
4056 "Ctrl+U must also clear input buffer"
4057 );
4058 }
4059
4060 #[test]
4061 fn paste_state_cleared_on_shift_enter() {
4062 let (mut app, _rx, _tx) = make_app();
4063 app.sessions.current_mut().input_mode = InputMode::Insert;
4064 app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4065 assert!(app.paste_state().is_some());
4066 app.handle_event(AppEvent::Key(KeyEvent::new(
4067 KeyCode::Enter,
4068 KeyModifiers::SHIFT,
4069 )));
4070 assert!(app.paste_state().is_none());
4071 }
4072
4073 #[test]
4074 fn paste_state_cleared_on_navigation() {
4075 let (mut app, _rx, _tx) = make_app();
4076 app.sessions.current_mut().input_mode = InputMode::Insert;
4077
4078 app.handle_event(AppEvent::Paste("a\nb".to_owned()));
4080 assert!(app.paste_state().is_some());
4081 app.handle_event(AppEvent::Key(KeyEvent::new(
4082 KeyCode::Left,
4083 KeyModifiers::NONE,
4084 )));
4085 assert!(app.paste_state().is_none(), "Left must clear paste_state");
4086
4087 app.handle_event(AppEvent::Paste("c\nd".to_owned()));
4089 assert!(app.paste_state().is_some());
4090 app.handle_event(AppEvent::Key(KeyEvent::new(
4091 KeyCode::Home,
4092 KeyModifiers::NONE,
4093 )));
4094 assert!(app.paste_state().is_none(), "Home must clear paste_state");
4095 }
4096
4097 #[test]
4098 fn paste_state_consumed_on_submit() {
4099 let (mut app, _rx, _tx) = make_app();
4100 app.sessions.current_mut().input_mode = InputMode::Insert;
4101 app.handle_event(AppEvent::Paste("line1\nline2\nline3\nline4".to_owned()));
4102 assert!(app.paste_state().is_some());
4103 app.handle_event(AppEvent::Key(KeyEvent::new(
4104 KeyCode::Enter,
4105 KeyModifiers::NONE,
4106 )));
4107 assert!(
4108 app.paste_state().is_none(),
4109 "paste_state cleared after submit"
4110 );
4111 assert_eq!(app.messages().len(), 1);
4112 assert_eq!(
4113 app.messages()[0].paste_line_count,
4114 Some(4),
4115 "paste_line_count must be set on submitted message"
4116 );
4117 }
4118}