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