1use parking_lot::RwLock;
2use std::collections::{HashMap, HashSet};
3use std::sync::Arc;
4
5use crate::agents::MonitoredAgent;
6use crate::config::CreateProcessSettings;
7use crate::teams::{AgentDefinition, TeamConfig, TeamTask};
8use crate::tmux::PaneInfo;
9use crate::usage::UsageSnapshot;
10
11pub type SharedState = Arc<RwLock<AppState>>;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum InputMode {
17 #[default]
19 Normal,
20 Input,
22 Passthrough,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum SortBy {
29 #[default]
31 Directory,
32 SessionOrder,
34 AgentType,
36 Status,
38 LastUpdate,
40 Team,
42 Repository,
44}
45
46impl SortBy {
47 pub fn next(self) -> Self {
49 match self {
50 SortBy::Directory => SortBy::SessionOrder,
51 SortBy::SessionOrder => SortBy::AgentType,
52 SortBy::AgentType => SortBy::Status,
53 SortBy::Status => SortBy::LastUpdate,
54 SortBy::LastUpdate => SortBy::Team,
55 SortBy::Team => SortBy::Repository,
56 SortBy::Repository => SortBy::Directory,
57 }
58 }
59
60 pub fn display_name(&self) -> &'static str {
62 match self {
63 SortBy::Directory => "Directory",
64 SortBy::SessionOrder => "Session",
65 SortBy::AgentType => "Type",
66 SortBy::Status => "Status",
67 SortBy::LastUpdate => "Updated",
68 SortBy::Team => "Team",
69 SortBy::Repository => "Repository",
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum MonitorScope {
77 #[default]
79 AllSessions,
80 CurrentSession,
82 CurrentWindow,
84}
85
86impl MonitorScope {
87 pub fn next(self) -> Self {
89 match self {
90 MonitorScope::AllSessions => MonitorScope::CurrentSession,
91 MonitorScope::CurrentSession => MonitorScope::CurrentWindow,
92 MonitorScope::CurrentWindow => MonitorScope::AllSessions,
93 }
94 }
95
96 pub fn display_name(&self) -> &'static str {
98 match self {
99 MonitorScope::AllSessions => "All",
100 MonitorScope::CurrentSession => "Session",
101 MonitorScope::CurrentWindow => "Window",
102 }
103 }
104}
105
106pub const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
108
109const MARQUEE_INTERVAL_MS: u64 = 280;
111
112#[derive(Debug, Clone)]
114pub struct MarqueeState {
115 pub offset: usize,
117 pub last_update: std::time::Instant,
119 pub selected_id: Option<String>,
121}
122
123impl Default for MarqueeState {
124 fn default() -> Self {
125 Self {
126 offset: 0,
127 last_update: std::time::Instant::now(),
128 selected_id: None,
129 }
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum PlacementType {
136 NewSession,
138 NewWindow,
140 SplitPane,
142}
143
144#[derive(Debug, Clone)]
146pub enum TreeEntry {
147 NewSession,
149 Session { name: String, collapsed: bool },
151 NewWindow { session: String },
153 Window {
155 session: String,
156 index: u32,
157 name: String,
158 collapsed: bool,
159 },
160 SplitPane { target: String },
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum ConfirmAction {
167 KillPane { target: String },
169 RestartAsWrapped { target: String, session_id: String },
171 ProbeAndRestartAsWrapped { target: String, cwd: String },
173}
174
175#[derive(Debug, Clone)]
177pub struct ConfirmationState {
178 pub action: ConfirmAction,
180 pub message: String,
182}
183
184#[derive(Debug, Clone)]
186pub enum DirItem {
187 Header(String),
189 EnterPath,
191 Home,
193 Current,
195 Directory { display: String, path: String },
197}
198
199impl DirItem {
200 pub fn is_selectable(&self) -> bool {
202 !matches!(self, DirItem::Header(_))
203 }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum CreateProcessStep {
209 SelectTarget,
211 SelectDirectory,
213 SelectAgent,
215}
216
217#[derive(Debug, Clone)]
219pub struct CreateProcessState {
220 pub step: CreateProcessStep,
222 pub placement_type: Option<PlacementType>,
224 pub origin_group_key: String,
226 pub target_session: Option<String>,
228 pub target_pane: Option<String>,
230 pub directory: Option<String>,
232 pub cursor: usize,
234 pub input_buffer: String,
236 pub available_panes: Vec<PaneInfo>,
238 pub collapsed_nodes: HashSet<String>,
240 pub tree_entries: Vec<TreeEntry>,
242 pub directory_items: Vec<DirItem>,
244 pub is_input_mode: bool,
246}
247
248#[derive(Debug, Clone)]
250pub struct TeamSnapshot {
251 pub config: TeamConfig,
253 pub tasks: Vec<TeamTask>,
255 pub member_panes: HashMap<String, String>,
257 pub last_scan: chrono::DateTime<chrono::Utc>,
259 pub task_done: usize,
261 pub task_total: usize,
263 pub task_in_progress: usize,
265 pub task_pending: usize,
267 pub worktree_names: Vec<String>,
269}
270
271#[derive(Debug, Default)]
273pub struct InputState {
274 pub mode: InputMode,
276 pub buffer: String,
278 pub cursor_position: usize,
280}
281
282#[derive(Debug)]
284pub struct ViewState {
285 pub show_help: bool,
287 pub help_scroll: u16,
289 pub show_qr: bool,
291 pub show_team_overview: bool,
293 pub show_task_overlay: bool,
295 pub task_overlay_scroll: u16,
297 pub team_overview_scroll: u16,
299 pub preview_scroll: u16,
301 pub spinner_frame: usize,
303 pub last_spinner_update: std::time::Instant,
305 pub marquee_state: MarqueeState,
307}
308
309impl Default for ViewState {
310 fn default() -> Self {
311 Self {
312 show_help: false,
313 help_scroll: 0,
314 show_qr: false,
315 show_team_overview: false,
316 show_task_overlay: false,
317 task_overlay_scroll: 0,
318 team_overview_scroll: 0,
319 preview_scroll: 0,
320 spinner_frame: 0,
321 last_spinner_update: std::time::Instant::now(),
322 marquee_state: MarqueeState::default(),
323 }
324 }
325}
326
327#[derive(Debug, Default)]
329pub struct SelectionState {
330 pub selected_index: usize,
332 pub selected_entry_index: usize,
334 pub selectable_count: usize,
336 pub is_on_create_new: bool,
338 pub collapsed_groups: HashSet<String>,
340}
341
342#[derive(Debug, Default)]
344pub struct WebState {
345 pub token: Option<String>,
347 pub port: u16,
349}
350
351#[derive(Debug)]
353pub struct AppState {
354 pub input: InputState,
357 pub view: ViewState,
359 pub selection: SelectionState,
361 pub web: WebState,
363
364 pub agents: HashMap<String, MonitoredAgent>,
367 pub agent_order: Vec<String>,
369 pub sort_by: SortBy,
371 pub monitor_scope: MonitorScope,
373 pub current_session: Option<String>,
375 pub current_window: Option<u32>,
377 pub teams: HashMap<String, TeamSnapshot>,
379 #[allow(dead_code)]
381 pub agent_definitions: Vec<AgentDefinition>,
382 pub target_to_pane_id: HashMap<String, String>,
384
385 pub create_process: Option<CreateProcessState>,
388 pub confirmation_state: Option<ConfirmationState>,
390 pub error_message: Option<String>,
392 pub last_poll: Option<chrono::DateTime<chrono::Utc>>,
394 pub running: bool,
396
397 pub show_activity_name: bool,
399
400 pub notification: Option<(String, std::time::Instant)>,
402
403 pub usage: UsageSnapshot,
405}
406
407impl AppState {
408 pub fn new() -> Self {
410 Self {
411 input: InputState::default(),
412 view: ViewState::default(),
413 selection: SelectionState::default(),
414 web: WebState {
415 token: None,
416 port: 9876,
417 },
418 agents: HashMap::new(),
419 agent_order: Vec::new(),
420 sort_by: SortBy::Directory,
421 monitor_scope: MonitorScope::default(),
422 current_session: None,
423 current_window: None,
424 teams: HashMap::new(),
425 agent_definitions: Vec::new(),
426 target_to_pane_id: HashMap::new(),
427 create_process: None,
428 confirmation_state: None,
429 error_message: None,
430 last_poll: None,
431 running: true,
432 show_activity_name: true,
433 notification: None,
434 usage: UsageSnapshot::default(),
435 }
436 }
437
438 pub fn tick_spinner(&mut self) {
440 let elapsed = self.view.last_spinner_update.elapsed();
441 if elapsed.as_millis() >= 150 {
442 self.view.last_spinner_update = std::time::Instant::now();
443 self.view.spinner_frame = (self.view.spinner_frame + 1) % SPINNER_FRAMES.len();
444 }
445 }
446
447 pub fn spinner_char(&self) -> char {
449 SPINNER_FRAMES[self.view.spinner_frame]
450 }
451
452 pub fn tick_marquee(&mut self) {
454 let elapsed = self.view.marquee_state.last_update.elapsed();
455 if elapsed.as_millis() >= MARQUEE_INTERVAL_MS as u128 {
456 self.view.marquee_state.last_update = std::time::Instant::now();
457 self.view.marquee_state.offset += 1;
458 }
459 }
460
461 pub fn reset_marquee(&mut self, new_id: Option<String>) {
463 if self.view.marquee_state.selected_id != new_id {
464 self.view.marquee_state.offset = 0;
465 self.view.marquee_state.selected_id = new_id;
466 self.view.marquee_state.last_update = std::time::Instant::now();
467 }
468 }
469
470 pub fn marquee_offset(&self) -> usize {
472 self.view.marquee_state.offset
473 }
474
475 pub fn shared() -> SharedState {
477 Arc::new(RwLock::new(Self::new()))
478 }
479
480 pub fn set_notification(&mut self, message: String) {
482 self.notification = Some((message, std::time::Instant::now()));
483 }
484
485 pub fn active_notification(&self) -> Option<&str> {
487 self.notification.as_ref().and_then(|(msg, ts)| {
488 if ts.elapsed().as_secs() < 3 {
489 Some(msg.as_str())
490 } else {
491 None
492 }
493 })
494 }
495
496 pub fn selected_agent(&self) -> Option<&MonitoredAgent> {
498 self.agent_order
499 .get(self.selection.selected_index)
500 .and_then(|id| self.agents.get(id))
501 }
502
503 pub fn selected_agent_mut(&mut self) -> Option<&mut MonitoredAgent> {
505 if let Some(id) = self.agent_order.get(self.selection.selected_index).cloned() {
506 self.agents.get_mut(&id)
507 } else {
508 None
509 }
510 }
511
512 pub fn selected_target(&self) -> Option<&str> {
514 self.agent_order
515 .get(self.selection.selected_index)
516 .map(|s| s.as_str())
517 }
518
519 pub fn update_agents(&mut self, agents: Vec<MonitoredAgent>) {
521 let new_ids: HashSet<String> = agents.iter().map(|a| a.id.clone()).collect();
523 let new_order: Vec<String> = agents.iter().map(|a| a.id.clone()).collect();
525
526 self.agents.retain(|id, _| new_ids.contains(id));
528
529 for agent in agents {
531 let id = agent.id.clone();
532 if let Some(existing) = self.agents.get_mut(&id) {
533 existing.status = agent.status;
535 existing.last_content = agent.last_content;
536 existing.last_content_ansi = agent.last_content_ansi;
537 existing.title = agent.title;
538 existing.last_update = agent.last_update;
539 existing.context_warning = agent.context_warning;
540 existing.cwd = agent.cwd;
542 existing.pid = agent.pid;
543 existing.session = agent.session;
544 existing.window_name = agent.window_name;
545 existing.window_index = agent.window_index;
546 existing.pane_index = agent.pane_index;
547 existing.team_info = agent.team_info;
548 existing.is_virtual = agent.is_virtual;
549 existing.detection_source = agent.detection_source;
550 existing.git_branch = agent.git_branch;
552 existing.git_dirty = agent.git_dirty;
553 existing.is_worktree = agent.is_worktree;
554 existing.git_common_dir = agent.git_common_dir;
555 existing.worktree_name = agent.worktree_name;
556 if !matches!(
559 existing.status,
560 crate::agents::AgentStatus::AwaitingApproval { .. }
561 ) {
562 existing.auto_approve_phase = None;
563 }
564 } else {
565 self.agents.insert(id.clone(), agent);
566 }
567 }
568
569 let old_selected = self.selected_target().map(|s| s.to_string());
571 self.agent_order = new_order;
572
573 self.sort_agents();
575
576 if let Some(old_id) = old_selected {
578 if let Some(new_index) = self.agent_order.iter().position(|id| id == &old_id) {
579 self.selection.selected_index = new_index;
580 }
581 }
582
583 if self.selection.selected_index >= self.agent_order.len() && !self.agent_order.is_empty() {
585 self.selection.selected_index = self.agent_order.len() - 1;
586 }
587
588 self.last_poll = Some(chrono::Utc::now());
589 }
590
591 pub fn cycle_sort(&mut self) {
593 self.sort_by = self.sort_by.next();
594 self.sort_agents();
595 }
596
597 pub fn cycle_monitor_scope(&mut self) {
599 self.monitor_scope = self.monitor_scope.next();
600 }
601
602 fn sort_agents(&mut self) {
604 let agents = &self.agents;
605 self.agent_order.sort_by(|a, b| {
606 let agent_a = agents.get(a);
607 let agent_b = agents.get(b);
608
609 match (agent_a, agent_b) {
610 (Some(a), Some(b)) => match self.sort_by {
611 SortBy::Directory => {
612 a.cwd.cmp(&b.cwd).then_with(|| a.id.cmp(&b.id))
614 }
615 SortBy::SessionOrder => {
616 a.id.cmp(&b.id)
618 }
619 SortBy::AgentType => {
620 a.agent_type
622 .short_name()
623 .cmp(b.agent_type.short_name())
624 .then_with(|| a.id.cmp(&b.id))
625 }
626 SortBy::Status => {
627 let priority_a = Self::status_priority(&a.status);
629 let priority_b = Self::status_priority(&b.status);
630 priority_a.cmp(&priority_b).then_with(|| a.id.cmp(&b.id))
631 }
632 SortBy::LastUpdate => {
633 b.last_update
635 .cmp(&a.last_update)
636 .then_with(|| a.id.cmp(&b.id))
637 }
638 SortBy::Team => {
639 let team_a = a
641 .team_info
642 .as_ref()
643 .map(|t| t.team_name.as_str())
644 .unwrap_or("\u{ffff}"); let team_b = b
646 .team_info
647 .as_ref()
648 .map(|t| t.team_name.as_str())
649 .unwrap_or("\u{ffff}");
650 team_a.cmp(team_b).then_with(|| a.id.cmp(&b.id))
651 }
652 SortBy::Repository => {
653 let key_a = a.git_common_dir.as_deref().unwrap_or(&a.cwd);
656 let key_b = b.git_common_dir.as_deref().unwrap_or(&b.cwd);
657 key_a.cmp(key_b).then_with(|| {
658 let wt_a = a.is_worktree.unwrap_or(false);
660 let wt_b = b.is_worktree.unwrap_or(false);
661 wt_a.cmp(&wt_b).then_with(|| a.cwd.cmp(&b.cwd))
662 })
663 }
664 },
665 (Some(_), None) => std::cmp::Ordering::Less,
666 (None, Some(_)) => std::cmp::Ordering::Greater,
667 (None, None) => std::cmp::Ordering::Equal,
668 }
669 });
670
671 self.nest_team_members();
673 }
674
675 fn nest_team_members(&mut self) {
677 let agents = &self.agents;
678
679 let mut team_leaders: HashMap<String, String> = HashMap::new();
681 let mut team_members: HashMap<String, Vec<(String, String)>> = HashMap::new();
682
683 for id in &self.agent_order {
684 if let Some(agent) = agents.get(id) {
685 if let Some(ref ti) = agent.team_info {
686 if ti.is_lead {
687 team_leaders.insert(ti.team_name.clone(), id.clone());
688 } else {
689 team_members
690 .entry(ti.team_name.clone())
691 .or_default()
692 .push((ti.member_name.clone(), id.clone()));
693 }
694 }
695 }
696 }
697
698 if team_leaders.is_empty() && team_members.is_empty() {
700 return;
701 }
702
703 for (team_name, members) in &team_members {
705 if !team_leaders.contains_key(team_name) {
706 if let Some((_, first_member_id)) = members.first() {
707 team_leaders.insert(team_name.clone(), first_member_id.clone());
708 }
709 }
710 }
711
712 for (team_name, leader_id) in &team_leaders {
714 if let Some(members) = team_members.get_mut(team_name) {
715 members.retain(|(_, id)| id != leader_id);
716 }
717 }
718
719 for members in team_members.values_mut() {
721 members.sort_by(|a, b| a.0.cmp(&b.0));
722 }
723
724 let member_ids: std::collections::HashSet<String> = team_members
726 .values()
727 .flatten()
728 .map(|(_, id)| id.clone())
729 .collect();
730
731 let mut new_order = Vec::with_capacity(self.agent_order.len());
732 for id in &self.agent_order {
733 if member_ids.contains(id) {
735 continue;
736 }
737
738 new_order.push(id.clone());
739
740 if let Some(agent) = agents.get(id) {
742 if let Some(ref ti) = agent.team_info {
743 if let Some(members) = team_members.get(&ti.team_name) {
744 if team_leaders.get(&ti.team_name) == Some(id) {
745 for (_, member_id) in members {
746 if member_id != id {
748 new_order.push(member_id.clone());
749 }
750 }
751 }
752 }
753 }
754 }
755 }
756
757 self.agent_order = new_order;
758 }
759
760 fn status_priority(status: &crate::agents::AgentStatus) -> u8 {
762 match status {
763 crate::agents::AgentStatus::AwaitingApproval { .. } => 0, crate::agents::AgentStatus::Error { .. } => 1,
765 crate::agents::AgentStatus::Processing { .. } => 2,
766 crate::agents::AgentStatus::Idle => 3,
767 crate::agents::AgentStatus::Offline => 4,
768 crate::agents::AgentStatus::Unknown => 5,
769 }
770 }
771
772 pub fn get_group_key(&self, agent: &MonitoredAgent) -> Option<String> {
774 match self.sort_by {
775 SortBy::Directory => Some(agent.display_cwd()),
776 SortBy::SessionOrder => Some(agent.session.clone()),
777 SortBy::AgentType => Some(agent.agent_type.short_name().to_string()),
778 SortBy::Team => Some(
779 agent
780 .team_info
781 .as_ref()
782 .map(|t| format!("Team: {}", t.team_name))
783 .unwrap_or_else(|| "(No Team)".to_string()),
784 ),
785 SortBy::Repository => Some(
786 agent
787 .git_common_dir
788 .as_deref()
789 .map(crate::git::repo_name_from_common_dir)
790 .unwrap_or_else(|| agent.display_cwd()),
791 ),
792 _ => None,
793 }
794 }
795
796 pub fn toggle_group_collapse(&mut self, group_key: &str) {
798 if self.selection.collapsed_groups.contains(group_key) {
799 self.selection.collapsed_groups.remove(group_key);
800 } else {
801 self.selection
802 .collapsed_groups
803 .insert(group_key.to_string());
804 }
805 }
806
807 pub fn is_group_collapsed(&self, group_key: &str) -> bool {
809 self.selection.collapsed_groups.contains(group_key)
810 }
811
812 pub fn select_previous(&mut self) {
814 if self.selection.selected_entry_index > 0 {
815 self.selection.selected_entry_index -= 1;
816 self.view.preview_scroll = 0;
817 self.sync_selected_index_from_entry();
818 self.reset_marquee_for_selection();
819 }
820 }
821
822 pub fn select_next(&mut self) {
824 if self.selection.selectable_count > 0
825 && self.selection.selected_entry_index < self.selection.selectable_count - 1
826 {
827 self.selection.selected_entry_index += 1;
828 self.view.preview_scroll = 0;
829 self.sync_selected_index_from_entry();
830 self.reset_marquee_for_selection();
831 }
832 }
833
834 pub fn select_first(&mut self) {
836 if self.selection.selectable_count > 0 {
837 self.selection.selected_entry_index = 0;
838 self.view.preview_scroll = 0;
839 self.sync_selected_index_from_entry();
840 self.reset_marquee_for_selection();
841 }
842 }
843
844 pub fn select_last(&mut self) {
846 if self.selection.selectable_count > 0 {
847 self.selection.selected_entry_index = self.selection.selectable_count - 1;
848 self.view.preview_scroll = 0;
849 self.sync_selected_index_from_entry();
850 self.reset_marquee_for_selection();
851 }
852 }
853
854 fn reset_marquee_for_selection(&mut self) {
856 let new_id = self.selected_target().map(|s| s.to_string());
857 self.reset_marquee(new_id);
858 }
859
860 fn sync_selected_index_from_entry(&mut self) {
863 if !self.agent_order.is_empty() && self.selection.selected_index >= self.agent_order.len() {
866 self.selection.selected_index = self.agent_order.len() - 1;
867 }
868 }
869
870 pub fn update_selectable_entries(
872 &mut self,
873 selectable_count: usize,
874 agent_index: Option<usize>,
875 ) {
876 self.selection.selectable_count = selectable_count;
877 self.selection.is_on_create_new = agent_index.is_none();
878 if let Some(idx) = agent_index {
879 self.selection.selected_index = idx;
880 }
881 if self.selection.selected_entry_index >= selectable_count && selectable_count > 0 {
883 self.selection.selected_entry_index = selectable_count - 1;
884 }
885 }
886
887 pub fn get_known_directories(&self) -> Vec<String> {
889 let mut dirs: Vec<String> = self.agents.values().map(|a| a.cwd.clone()).collect();
890 dirs.sort();
891 dirs.dedup();
892 dirs
893 }
894
895 pub fn toggle_help(&mut self) {
897 self.view.show_help = !self.view.show_help;
898 if self.view.show_help {
899 self.view.help_scroll = 0;
900 }
901 }
902
903 pub fn toggle_qr(&mut self) {
905 self.view.show_qr = !self.view.show_qr;
906 }
907
908 pub fn init_web(&mut self, token: String, port: u16) {
910 self.web.token = Some(token);
911 self.web.port = port;
912 }
913
914 pub fn get_web_url(&self) -> Option<String> {
919 let token = self.web.token.as_ref()?;
920
921 if let Some(host_ip) = get_wsl_host_ip() {
923 return Some(format!(
924 "http://{}:{}/?token={}",
925 host_ip, self.web.port, token
926 ));
927 }
928
929 if let Ok(ip) = local_ip_address::local_ip() {
931 Some(format!("http://{}:{}/?token={}", ip, self.web.port, token))
932 } else {
933 Some(format!(
934 "http://localhost:{}/?token={}",
935 self.web.port, token
936 ))
937 }
938 }
939
940 pub fn scroll_help_down(&mut self, amount: u16) {
942 self.view.help_scroll = self.view.help_scroll.saturating_add(amount);
943 }
944
945 pub fn scroll_help_up(&mut self, amount: u16) {
947 self.view.help_scroll = self.view.help_scroll.saturating_sub(amount);
948 }
949
950 pub fn scroll_preview_down(&mut self, amount: u16) {
952 self.view.preview_scroll = self.view.preview_scroll.saturating_add(amount);
953 }
954
955 pub fn scroll_preview_up(&mut self, amount: u16) {
957 self.view.preview_scroll = self.view.preview_scroll.saturating_sub(amount);
958 }
959
960 pub fn agents_needing_attention(&self) -> Vec<&MonitoredAgent> {
962 self.agent_order
963 .iter()
964 .filter_map(|id| self.agents.get(id))
965 .filter(|a| a.status.needs_attention())
966 .collect()
967 }
968
969 pub fn attention_count(&self) -> usize {
971 self.agents_needing_attention().len()
972 }
973
974 pub fn set_error(&mut self, message: String) {
976 self.error_message = Some(message);
977 }
978
979 pub fn clear_error(&mut self) {
981 self.error_message = None;
982 }
983
984 pub fn quit(&mut self) {
986 self.running = false;
987 }
988
989 pub fn enter_input_mode(&mut self) {
995 self.input.mode = InputMode::Input;
996 }
997
998 pub fn enter_passthrough_mode(&mut self) {
1000 self.input.mode = InputMode::Passthrough;
1001 }
1002
1003 pub fn exit_input_mode(&mut self) {
1005 self.input.mode = InputMode::Normal;
1006 self.input.buffer.clear();
1007 self.input.cursor_position = 0;
1008 }
1009
1010 pub fn is_input_mode(&self) -> bool {
1012 self.input.mode == InputMode::Input
1013 }
1014
1015 pub fn is_passthrough_mode(&self) -> bool {
1017 self.input.mode == InputMode::Passthrough
1018 }
1019
1020 pub fn get_input(&self) -> &str {
1022 &self.input.buffer
1023 }
1024
1025 pub fn get_cursor_position(&self) -> usize {
1027 self.input.cursor_position
1028 }
1029
1030 pub fn input_char(&mut self, c: char) {
1032 self.input.buffer.insert(self.input.cursor_position, c);
1033 self.input.cursor_position += c.len_utf8();
1034 }
1035
1036 pub fn input_backspace(&mut self) {
1038 if self.input.cursor_position > 0 {
1039 let prev_char_boundary = self.input.buffer[..self.input.cursor_position]
1041 .char_indices()
1042 .last()
1043 .map(|(i, _)| i)
1044 .unwrap_or(0);
1045 self.input.buffer.remove(prev_char_boundary);
1046 self.input.cursor_position = prev_char_boundary;
1047 }
1048 }
1049
1050 pub fn input_delete(&mut self) {
1052 if self.input.cursor_position < self.input.buffer.len() {
1053 self.input.buffer.remove(self.input.cursor_position);
1054 }
1055 }
1056
1057 pub fn cursor_left(&mut self) {
1059 if self.input.cursor_position > 0 {
1060 self.input.cursor_position = self.input.buffer[..self.input.cursor_position]
1062 .char_indices()
1063 .last()
1064 .map(|(i, _)| i)
1065 .unwrap_or(0);
1066 }
1067 }
1068
1069 pub fn cursor_right(&mut self) {
1071 if self.input.cursor_position < self.input.buffer.len() {
1072 if let Some(c) = self.input.buffer[self.input.cursor_position..]
1074 .chars()
1075 .next()
1076 {
1077 self.input.cursor_position += c.len_utf8();
1078 }
1079 }
1080 }
1081
1082 pub fn cursor_home(&mut self) {
1084 self.input.cursor_position = 0;
1085 }
1086
1087 pub fn cursor_end(&mut self) {
1089 self.input.cursor_position = self.input.buffer.len();
1090 }
1091
1092 pub fn take_input(&mut self) -> String {
1094 let input = std::mem::take(&mut self.input.buffer);
1095 self.input.cursor_position = 0;
1096 input
1097 }
1098
1099 pub fn start_create_process(
1105 &mut self,
1106 group_key: String,
1107 panes: Vec<PaneInfo>,
1108 config: &CreateProcessSettings,
1109 ) {
1110 let known_directories = self.get_known_directories();
1112
1113 let directory_items = build_directory_items(config, known_directories);
1115
1116 let directory = if self.sort_by == SortBy::Directory {
1118 Some(group_key.clone())
1119 } else {
1120 None
1121 };
1122
1123 let target_session = if self.sort_by == SortBy::SessionOrder {
1125 Some(group_key.clone())
1126 } else {
1127 None
1128 };
1129
1130 let target_pane = self.selected_target().map(|s| s.to_string());
1132
1133 let mut collapsed_nodes = HashSet::new();
1135 for pane in &panes {
1136 let window_key = format!("{}:{}", pane.session, pane.window_index);
1138 collapsed_nodes.insert(window_key);
1139 }
1140
1141 let tree_entries = Self::build_tree_entries(&panes, &collapsed_nodes);
1143
1144 self.create_process = Some(CreateProcessState {
1145 step: CreateProcessStep::SelectTarget,
1146 placement_type: None,
1147 origin_group_key: group_key,
1148 target_session,
1149 target_pane,
1150 directory,
1151 cursor: 0,
1152 input_buffer: String::new(),
1153 available_panes: panes,
1154 collapsed_nodes,
1155 tree_entries,
1156 directory_items,
1157 is_input_mode: false,
1158 });
1159 }
1160
1161 fn build_tree_entries(panes: &[PaneInfo], collapsed_nodes: &HashSet<String>) -> Vec<TreeEntry> {
1163 let mut entries = Vec::new();
1164
1165 let mut sessions: Vec<String> = panes.iter().map(|p| p.session.clone()).collect();
1167 sessions.sort();
1168 sessions.dedup();
1169
1170 for session in &sessions {
1172 let session_collapsed = collapsed_nodes.contains(session);
1173 entries.push(TreeEntry::Session {
1174 name: session.clone(),
1175 collapsed: session_collapsed,
1176 });
1177
1178 if !session_collapsed {
1179 let mut windows: Vec<(u32, String)> = panes
1181 .iter()
1182 .filter(|p| &p.session == session)
1183 .map(|p| (p.window_index, p.window_name.clone()))
1184 .collect();
1185 windows.sort_by_key(|(idx, _)| *idx);
1186 windows.dedup_by_key(|(idx, _)| *idx);
1187
1188 for (window_index, window_name) in windows {
1189 let window_key = format!("{}:{}", session, window_index);
1190 let window_collapsed = collapsed_nodes.contains(&window_key);
1191
1192 entries.push(TreeEntry::Window {
1193 session: session.clone(),
1194 index: window_index,
1195 name: window_name,
1196 collapsed: window_collapsed,
1197 });
1198
1199 if !window_collapsed {
1200 let window_panes: Vec<&PaneInfo> = panes
1202 .iter()
1203 .filter(|p| &p.session == session && p.window_index == window_index)
1204 .collect();
1205
1206 for pane in window_panes {
1207 entries.push(TreeEntry::SplitPane {
1208 target: pane.target.clone(),
1209 });
1210 }
1211 }
1212 }
1213
1214 entries.push(TreeEntry::NewWindow {
1216 session: session.clone(),
1217 });
1218 }
1219 }
1220
1221 entries.push(TreeEntry::NewSession);
1223
1224 entries
1225 }
1226
1227 pub fn toggle_tree_node(&mut self, key: &str) {
1229 if let Some(ref mut cs) = self.create_process {
1230 if cs.collapsed_nodes.contains(key) {
1231 cs.collapsed_nodes.remove(key);
1232 } else {
1233 cs.collapsed_nodes.insert(key.to_string());
1234 }
1235 cs.tree_entries = Self::build_tree_entries(&cs.available_panes, &cs.collapsed_nodes);
1237 }
1238 }
1239
1240 pub fn cancel_create_process(&mut self) {
1242 self.create_process = None;
1243 }
1244
1245 pub fn is_create_process_mode(&self) -> bool {
1247 self.create_process.is_some()
1248 }
1249
1250 pub fn show_confirmation(&mut self, action: ConfirmAction, message: String) {
1256 self.confirmation_state = Some(ConfirmationState { action, message });
1257 }
1258
1259 pub fn cancel_confirmation(&mut self) {
1261 self.confirmation_state = None;
1262 }
1263
1264 pub fn is_showing_confirmation(&self) -> bool {
1266 self.confirmation_state.is_some()
1267 }
1268
1269 pub fn get_confirmation_action(&self) -> Option<ConfirmAction> {
1271 self.confirmation_state.as_ref().map(|s| s.action.clone())
1272 }
1273
1274 pub fn create_process_cursor_up(&mut self) {
1276 if let Some(ref mut state) = self.create_process {
1277 if state.cursor > 0 {
1278 state.cursor -= 1;
1279 if state.step == CreateProcessStep::SelectDirectory {
1281 let len = state.directory_items.len();
1282 if len == 0 {
1283 state.cursor = 0;
1284 return;
1285 }
1286 while state.cursor > 0 && !state.directory_items[state.cursor].is_selectable() {
1287 state.cursor -= 1;
1288 }
1289 if !state.directory_items[state.cursor].is_selectable() {
1291 while state.cursor < len
1292 && !state.directory_items[state.cursor].is_selectable()
1293 {
1294 state.cursor += 1;
1295 }
1296 }
1297 }
1298 }
1299 }
1300 }
1301
1302 pub fn create_process_cursor_down(&mut self, max: usize) {
1304 if let Some(ref mut state) = self.create_process {
1305 if state.cursor < max.saturating_sub(1) {
1306 state.cursor += 1;
1307 if state.step == CreateProcessStep::SelectDirectory {
1309 let len = state.directory_items.len();
1310 while state.cursor < len && !state.directory_items[state.cursor].is_selectable()
1311 {
1312 state.cursor += 1;
1313 }
1314 if state.cursor >= len {
1316 state.cursor = len.saturating_sub(1);
1318 while state.cursor > 0
1319 && !state.directory_items[state.cursor].is_selectable()
1320 {
1321 state.cursor -= 1;
1322 }
1323 }
1324 }
1325 }
1326 }
1327 }
1328
1329 pub fn create_process_cursor(&self) -> usize {
1331 self.create_process.as_ref().map(|s| s.cursor).unwrap_or(0)
1332 }
1333}
1334
1335impl Default for AppState {
1336 fn default() -> Self {
1337 Self::new()
1338 }
1339}
1340
1341fn expand_tilde(path: &str) -> String {
1343 if path.starts_with("~/") || path == "~" {
1344 if let Some(home) = dirs::home_dir() {
1345 return path.replacen('~', &home.to_string_lossy(), 1);
1346 }
1347 }
1348 path.to_string()
1349}
1350
1351fn build_directory_items(
1353 config: &CreateProcessSettings,
1354 known_directories: Vec<String>,
1355) -> Vec<DirItem> {
1356 let mut items = vec![DirItem::EnterPath, DirItem::Home, DirItem::Current];
1357
1358 if !config.pinned.is_empty() {
1360 let mut pinned_items: Vec<DirItem> = Vec::new();
1361 for dir in &config.pinned {
1362 let expanded = expand_tilde(dir);
1363 if std::path::Path::new(&expanded).is_dir() {
1364 pinned_items.push(DirItem::Directory {
1365 display: dir.to_string(),
1366 path: expanded,
1367 });
1368 }
1369 }
1370 if !pinned_items.is_empty() {
1371 items.push(DirItem::Header("Pinned".to_string()));
1372 items.extend(pinned_items);
1373 }
1374 }
1375
1376 for base in &config.base_directories {
1378 let expanded = expand_tilde(base);
1379 if let Ok(entries) = std::fs::read_dir(&expanded) {
1380 let mut subdirs: Vec<(String, String)> = entries
1381 .filter_map(|e| e.ok())
1382 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
1383 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
1384 .map(|e| {
1385 let name = e.file_name().to_string_lossy().to_string();
1386 let path = e.path().to_string_lossy().to_string();
1387 (name, path)
1388 })
1389 .collect();
1390 subdirs.sort_by(|a, b| a.0.cmp(&b.0));
1391
1392 if !subdirs.is_empty() {
1393 items.push(DirItem::Header(base.to_string()));
1394 for (name, path) in subdirs {
1395 items.push(DirItem::Directory {
1396 display: name,
1397 path,
1398 });
1399 }
1400 }
1401 }
1402 }
1403
1404 let existing_paths: HashSet<String> = items
1406 .iter()
1407 .filter_map(|item| match item {
1408 DirItem::Directory { path, .. } => Some(path.clone()),
1409 _ => None,
1410 })
1411 .collect();
1412 let unique_known: Vec<String> = known_directories
1413 .into_iter()
1414 .filter(|d| !existing_paths.contains(d))
1415 .collect();
1416 if !unique_known.is_empty() {
1417 items.push(DirItem::Header("Known".to_string()));
1418 for dir in unique_known {
1419 let display = if dir.chars().count() > 40 {
1420 let tail: String = dir
1421 .chars()
1422 .rev()
1423 .take(37)
1424 .collect::<Vec<_>>()
1425 .into_iter()
1426 .rev()
1427 .collect();
1428 format!("...{}", tail)
1429 } else {
1430 dir.clone()
1431 };
1432 items.push(DirItem::Directory { display, path: dir });
1433 }
1434 }
1435
1436 items
1437}
1438
1439fn get_wsl_host_ip() -> Option<String> {
1447 let proc_version = std::fs::read_to_string("/proc/version").ok()?;
1449 if !proc_version.to_lowercase().contains("microsoft")
1450 && !proc_version.to_lowercase().contains("wsl")
1451 {
1452 return None;
1453 }
1454
1455 if is_wsl_mirrored_mode() {
1458 return None; }
1461
1462 let resolv_conf = std::fs::read_to_string("/etc/resolv.conf").ok()?;
1464 for line in resolv_conf.lines() {
1465 let line = line.trim();
1466 if line.starts_with("nameserver") {
1467 if let Some(ip) = line.split_whitespace().nth(1) {
1468 if ip.starts_with("10.255.") || ip.starts_with("127.") {
1470 continue;
1471 }
1472 if ip.parse::<std::net::Ipv4Addr>().is_ok() {
1474 return Some(ip.to_string());
1475 }
1476 }
1477 }
1478 }
1479
1480 None
1481}
1482
1483fn is_wsl_mirrored_mode() -> bool {
1485 if let Ok(home) = std::env::var("USERPROFILE") {
1487 let wslconfig_path = format!("{}\\.wslconfig", home);
1488 if let Ok(content) = std::fs::read_to_string(&wslconfig_path) {
1489 return content.to_lowercase().contains("networkingmode=mirrored");
1490 }
1491 }
1492
1493 if let Ok(entries) = std::fs::read_dir("/mnt/c/Users") {
1495 for entry in entries.flatten() {
1496 let path = entry.path().join(".wslconfig");
1497 if let Ok(content) = std::fs::read_to_string(&path) {
1498 if content.to_lowercase().contains("networkingmode=mirrored") {
1499 return true;
1500 }
1501 }
1502 }
1503 }
1504
1505 false
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510 use super::*;
1511 use crate::agents::{AgentStatus, AgentType};
1512
1513 fn create_test_agent(id: &str) -> MonitoredAgent {
1514 MonitoredAgent::new(
1515 id.to_string(),
1516 AgentType::ClaudeCode,
1517 "Test".to_string(),
1518 "/home".to_string(),
1519 1234,
1520 "main".to_string(),
1521 "window".to_string(),
1522 0,
1523 0,
1524 )
1525 }
1526
1527 #[test]
1528 fn test_new_state() {
1529 let state = AppState::new();
1530 assert!(state.agents.is_empty());
1531 assert!(state.running);
1532 }
1533
1534 #[test]
1535 fn test_update_agents() {
1536 let mut state = AppState::new();
1537 let agents = vec![create_test_agent("main:0.0"), create_test_agent("main:0.1")];
1538
1539 state.update_agents(agents);
1540
1541 assert_eq!(state.agents.len(), 2);
1542 assert_eq!(state.agent_order.len(), 2);
1543 }
1544
1545 #[test]
1546 fn test_selection() {
1547 let mut state = AppState::new();
1548 let agents = vec![
1549 create_test_agent("main:0.0"),
1550 create_test_agent("main:0.1"),
1551 create_test_agent("main:0.2"),
1552 ];
1553 state.update_agents(agents);
1554 state.selection.selectable_count = 4;
1556
1557 assert_eq!(state.selection.selected_entry_index, 0);
1558
1559 state.select_next();
1560 assert_eq!(state.selection.selected_entry_index, 1);
1561
1562 state.select_next();
1563 assert_eq!(state.selection.selected_entry_index, 2);
1564
1565 state.select_next();
1566 assert_eq!(state.selection.selected_entry_index, 3); state.select_next();
1569 assert_eq!(state.selection.selected_entry_index, 3); state.select_previous();
1572 assert_eq!(state.selection.selected_entry_index, 2);
1573
1574 state.select_first();
1575 assert_eq!(state.selection.selected_entry_index, 0);
1576
1577 state.select_last();
1578 assert_eq!(state.selection.selected_entry_index, 3);
1579 }
1580
1581 #[test]
1582 fn test_attention_count() {
1583 let mut state = AppState::new();
1584 let mut agent1 = create_test_agent("main:0.0");
1585 agent1.status = AgentStatus::Idle;
1586
1587 let mut agent2 = create_test_agent("main:0.1");
1588 agent2.status = AgentStatus::AwaitingApproval {
1589 approval_type: crate::agents::ApprovalType::FileEdit,
1590 details: String::new(),
1591 };
1592
1593 state.update_agents(vec![agent1, agent2]);
1594
1595 assert_eq!(state.attention_count(), 1);
1596 }
1597}