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::{TeamConfig, TeamTask};
8use crate::tmux::PaneInfo;
9
10pub type SharedState = Arc<RwLock<AppState>>;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum InputMode {
16 #[default]
18 Normal,
19 Input,
21 Passthrough,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum SortBy {
28 #[default]
30 Directory,
31 SessionOrder,
33 AgentType,
35 Status,
37 LastUpdate,
39 Team,
41 Repository,
43}
44
45impl SortBy {
46 pub fn next(self) -> Self {
48 match self {
49 SortBy::Directory => SortBy::SessionOrder,
50 SortBy::SessionOrder => SortBy::AgentType,
51 SortBy::AgentType => SortBy::Status,
52 SortBy::Status => SortBy::LastUpdate,
53 SortBy::LastUpdate => SortBy::Team,
54 SortBy::Team => SortBy::Repository,
55 SortBy::Repository => SortBy::Directory,
56 }
57 }
58
59 pub fn display_name(&self) -> &'static str {
61 match self {
62 SortBy::Directory => "Directory",
63 SortBy::SessionOrder => "Session",
64 SortBy::AgentType => "Type",
65 SortBy::Status => "Status",
66 SortBy::LastUpdate => "Updated",
67 SortBy::Team => "Team",
68 SortBy::Repository => "Repository",
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum MonitorScope {
76 #[default]
78 AllSessions,
79 CurrentSession,
81 CurrentWindow,
83}
84
85impl MonitorScope {
86 pub fn next(self) -> Self {
88 match self {
89 MonitorScope::AllSessions => MonitorScope::CurrentSession,
90 MonitorScope::CurrentSession => MonitorScope::CurrentWindow,
91 MonitorScope::CurrentWindow => MonitorScope::AllSessions,
92 }
93 }
94
95 pub fn display_name(&self) -> &'static str {
97 match self {
98 MonitorScope::AllSessions => "All",
99 MonitorScope::CurrentSession => "Session",
100 MonitorScope::CurrentWindow => "Window",
101 }
102 }
103}
104
105pub const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
107
108const MARQUEE_INTERVAL_MS: u64 = 280;
110
111#[derive(Debug, Clone)]
113pub struct MarqueeState {
114 pub offset: usize,
116 pub last_update: std::time::Instant,
118 pub selected_id: Option<String>,
120}
121
122impl Default for MarqueeState {
123 fn default() -> Self {
124 Self {
125 offset: 0,
126 last_update: std::time::Instant::now(),
127 selected_id: None,
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum PlacementType {
135 NewSession,
137 NewWindow,
139 SplitPane,
141}
142
143#[derive(Debug, Clone)]
145pub enum TreeEntry {
146 NewSession,
148 Session { name: String, collapsed: bool },
150 NewWindow { session: String },
152 Window {
154 session: String,
155 index: u32,
156 name: String,
157 collapsed: bool,
158 },
159 SplitPane { target: String },
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum ConfirmAction {
166 KillPane { target: String },
168 RestartAsWrapped { target: String, session_id: String },
170 ProbeAndRestartAsWrapped { target: String, cwd: String },
172}
173
174#[derive(Debug, Clone)]
176pub struct ConfirmationState {
177 pub action: ConfirmAction,
179 pub message: String,
181}
182
183#[derive(Debug, Clone)]
185pub enum DirItem {
186 Header(String),
188 EnterPath,
190 Home,
192 Current,
194 Directory { display: String, path: String },
196}
197
198impl DirItem {
199 pub fn is_selectable(&self) -> bool {
201 !matches!(self, DirItem::Header(_))
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum CreateProcessStep {
208 SelectTarget,
210 SelectDirectory,
212 SelectAgent,
214}
215
216#[derive(Debug, Clone)]
218pub struct CreateProcessState {
219 pub step: CreateProcessStep,
221 pub placement_type: Option<PlacementType>,
223 pub origin_group_key: String,
225 pub target_session: Option<String>,
227 pub target_pane: Option<String>,
229 pub directory: Option<String>,
231 pub cursor: usize,
233 pub input_buffer: String,
235 pub available_panes: Vec<PaneInfo>,
237 pub collapsed_nodes: HashSet<String>,
239 pub tree_entries: Vec<TreeEntry>,
241 pub directory_items: Vec<DirItem>,
243 pub is_input_mode: bool,
245}
246
247#[derive(Debug, Clone)]
249pub struct TeamSnapshot {
250 pub config: TeamConfig,
252 pub tasks: Vec<TeamTask>,
254 pub member_panes: HashMap<String, String>,
256 pub last_scan: chrono::DateTime<chrono::Utc>,
258 pub task_done: usize,
260 pub task_total: usize,
262 pub task_in_progress: usize,
264 pub task_pending: usize,
266}
267
268#[derive(Debug, Default)]
270pub struct InputState {
271 pub mode: InputMode,
273 pub buffer: String,
275 pub cursor_position: usize,
277}
278
279#[derive(Debug)]
281pub struct ViewState {
282 pub show_help: bool,
284 pub help_scroll: u16,
286 pub show_qr: bool,
288 pub show_team_overview: bool,
290 pub show_task_overlay: bool,
292 pub task_overlay_scroll: u16,
294 pub team_overview_scroll: u16,
296 pub preview_scroll: u16,
298 pub spinner_frame: usize,
300 pub last_spinner_update: std::time::Instant,
302 pub marquee_state: MarqueeState,
304}
305
306impl Default for ViewState {
307 fn default() -> Self {
308 Self {
309 show_help: false,
310 help_scroll: 0,
311 show_qr: false,
312 show_team_overview: false,
313 show_task_overlay: false,
314 task_overlay_scroll: 0,
315 team_overview_scroll: 0,
316 preview_scroll: 0,
317 spinner_frame: 0,
318 last_spinner_update: std::time::Instant::now(),
319 marquee_state: MarqueeState::default(),
320 }
321 }
322}
323
324#[derive(Debug, Default)]
326pub struct SelectionState {
327 pub selected_index: usize,
329 pub selected_entry_index: usize,
331 pub selectable_count: usize,
333 pub is_on_create_new: bool,
335 pub collapsed_groups: HashSet<String>,
337}
338
339#[derive(Debug, Default)]
341pub struct WebState {
342 pub token: Option<String>,
344 pub port: u16,
346}
347
348#[derive(Debug)]
350pub struct AppState {
351 pub input: InputState,
354 pub view: ViewState,
356 pub selection: SelectionState,
358 pub web: WebState,
360
361 pub agents: HashMap<String, MonitoredAgent>,
364 pub agent_order: Vec<String>,
366 pub sort_by: SortBy,
368 pub monitor_scope: MonitorScope,
370 pub current_session: Option<String>,
372 pub current_window: Option<u32>,
374 pub teams: HashMap<String, TeamSnapshot>,
376 pub target_to_pane_id: HashMap<String, String>,
378
379 pub create_process: Option<CreateProcessState>,
382 pub confirmation_state: Option<ConfirmationState>,
384 pub error_message: Option<String>,
386 pub last_poll: Option<chrono::DateTime<chrono::Utc>>,
388 pub running: bool,
390
391 pub show_activity_name: bool,
393}
394
395impl AppState {
396 pub fn new() -> Self {
398 Self {
399 input: InputState::default(),
400 view: ViewState::default(),
401 selection: SelectionState::default(),
402 web: WebState {
403 token: None,
404 port: 9876,
405 },
406 agents: HashMap::new(),
407 agent_order: Vec::new(),
408 sort_by: SortBy::Directory,
409 monitor_scope: MonitorScope::default(),
410 current_session: None,
411 current_window: None,
412 teams: HashMap::new(),
413 target_to_pane_id: HashMap::new(),
414 create_process: None,
415 confirmation_state: None,
416 error_message: None,
417 last_poll: None,
418 running: true,
419 show_activity_name: true,
420 }
421 }
422
423 pub fn tick_spinner(&mut self) {
425 let elapsed = self.view.last_spinner_update.elapsed();
426 if elapsed.as_millis() >= 150 {
427 self.view.last_spinner_update = std::time::Instant::now();
428 self.view.spinner_frame = (self.view.spinner_frame + 1) % SPINNER_FRAMES.len();
429 }
430 }
431
432 pub fn spinner_char(&self) -> char {
434 SPINNER_FRAMES[self.view.spinner_frame]
435 }
436
437 pub fn tick_marquee(&mut self) {
439 let elapsed = self.view.marquee_state.last_update.elapsed();
440 if elapsed.as_millis() >= MARQUEE_INTERVAL_MS as u128 {
441 self.view.marquee_state.last_update = std::time::Instant::now();
442 self.view.marquee_state.offset += 1;
443 }
444 }
445
446 pub fn reset_marquee(&mut self, new_id: Option<String>) {
448 if self.view.marquee_state.selected_id != new_id {
449 self.view.marquee_state.offset = 0;
450 self.view.marquee_state.selected_id = new_id;
451 self.view.marquee_state.last_update = std::time::Instant::now();
452 }
453 }
454
455 pub fn marquee_offset(&self) -> usize {
457 self.view.marquee_state.offset
458 }
459
460 pub fn shared() -> SharedState {
462 Arc::new(RwLock::new(Self::new()))
463 }
464
465 pub fn selected_agent(&self) -> Option<&MonitoredAgent> {
467 self.agent_order
468 .get(self.selection.selected_index)
469 .and_then(|id| self.agents.get(id))
470 }
471
472 pub fn selected_agent_mut(&mut self) -> Option<&mut MonitoredAgent> {
474 if let Some(id) = self.agent_order.get(self.selection.selected_index).cloned() {
475 self.agents.get_mut(&id)
476 } else {
477 None
478 }
479 }
480
481 pub fn selected_target(&self) -> Option<&str> {
483 self.agent_order
484 .get(self.selection.selected_index)
485 .map(|s| s.as_str())
486 }
487
488 pub fn update_agents(&mut self, agents: Vec<MonitoredAgent>) {
490 let new_ids: HashSet<String> = agents.iter().map(|a| a.id.clone()).collect();
492 let new_order: Vec<String> = agents.iter().map(|a| a.id.clone()).collect();
494
495 self.agents.retain(|id, _| new_ids.contains(id));
497
498 for agent in agents {
500 let id = agent.id.clone();
501 if let Some(existing) = self.agents.get_mut(&id) {
502 existing.status = agent.status;
504 existing.last_content = agent.last_content;
505 existing.last_content_ansi = agent.last_content_ansi;
506 existing.title = agent.title;
507 existing.last_update = agent.last_update;
508 existing.context_warning = agent.context_warning;
509 existing.cwd = agent.cwd;
511 existing.pid = agent.pid;
512 existing.session = agent.session;
513 existing.window_name = agent.window_name;
514 existing.window_index = agent.window_index;
515 existing.pane_index = agent.pane_index;
516 existing.team_info = agent.team_info;
517 existing.is_virtual = agent.is_virtual;
518 existing.detection_source = agent.detection_source;
519 existing.git_branch = agent.git_branch;
521 existing.git_dirty = agent.git_dirty;
522 existing.is_worktree = agent.is_worktree;
523 existing.git_common_dir = agent.git_common_dir;
524 existing.worktree_name = agent.worktree_name;
525 if !matches!(
528 existing.status,
529 crate::agents::AgentStatus::AwaitingApproval { .. }
530 ) {
531 existing.auto_approve_phase = None;
532 }
533 } else {
534 self.agents.insert(id.clone(), agent);
535 }
536 }
537
538 let old_selected = self.selected_target().map(|s| s.to_string());
540 self.agent_order = new_order;
541
542 self.sort_agents();
544
545 if let Some(old_id) = old_selected {
547 if let Some(new_index) = self.agent_order.iter().position(|id| id == &old_id) {
548 self.selection.selected_index = new_index;
549 }
550 }
551
552 if self.selection.selected_index >= self.agent_order.len() && !self.agent_order.is_empty() {
554 self.selection.selected_index = self.agent_order.len() - 1;
555 }
556
557 self.last_poll = Some(chrono::Utc::now());
558 }
559
560 pub fn cycle_sort(&mut self) {
562 self.sort_by = self.sort_by.next();
563 self.sort_agents();
564 }
565
566 pub fn cycle_monitor_scope(&mut self) {
568 self.monitor_scope = self.monitor_scope.next();
569 }
570
571 fn sort_agents(&mut self) {
573 let agents = &self.agents;
574 self.agent_order.sort_by(|a, b| {
575 let agent_a = agents.get(a);
576 let agent_b = agents.get(b);
577
578 match (agent_a, agent_b) {
579 (Some(a), Some(b)) => match self.sort_by {
580 SortBy::Directory => {
581 a.cwd.cmp(&b.cwd).then_with(|| a.id.cmp(&b.id))
583 }
584 SortBy::SessionOrder => {
585 a.id.cmp(&b.id)
587 }
588 SortBy::AgentType => {
589 a.agent_type
591 .short_name()
592 .cmp(b.agent_type.short_name())
593 .then_with(|| a.id.cmp(&b.id))
594 }
595 SortBy::Status => {
596 let priority_a = Self::status_priority(&a.status);
598 let priority_b = Self::status_priority(&b.status);
599 priority_a.cmp(&priority_b).then_with(|| a.id.cmp(&b.id))
600 }
601 SortBy::LastUpdate => {
602 b.last_update
604 .cmp(&a.last_update)
605 .then_with(|| a.id.cmp(&b.id))
606 }
607 SortBy::Team => {
608 let team_a = a
610 .team_info
611 .as_ref()
612 .map(|t| t.team_name.as_str())
613 .unwrap_or("\u{ffff}"); let team_b = b
615 .team_info
616 .as_ref()
617 .map(|t| t.team_name.as_str())
618 .unwrap_or("\u{ffff}");
619 team_a.cmp(team_b).then_with(|| a.id.cmp(&b.id))
620 }
621 SortBy::Repository => {
622 let key_a = a.git_common_dir.as_deref().unwrap_or(&a.cwd);
625 let key_b = b.git_common_dir.as_deref().unwrap_or(&b.cwd);
626 key_a.cmp(key_b).then_with(|| {
627 let wt_a = a.is_worktree.unwrap_or(false);
629 let wt_b = b.is_worktree.unwrap_or(false);
630 wt_a.cmp(&wt_b).then_with(|| a.cwd.cmp(&b.cwd))
631 })
632 }
633 },
634 (Some(_), None) => std::cmp::Ordering::Less,
635 (None, Some(_)) => std::cmp::Ordering::Greater,
636 (None, None) => std::cmp::Ordering::Equal,
637 }
638 });
639
640 self.nest_team_members();
642 }
643
644 fn nest_team_members(&mut self) {
646 let agents = &self.agents;
647
648 let mut team_leaders: HashMap<String, String> = HashMap::new();
650 let mut team_members: HashMap<String, Vec<(String, String)>> = HashMap::new();
651
652 for id in &self.agent_order {
653 if let Some(agent) = agents.get(id) {
654 if let Some(ref ti) = agent.team_info {
655 if ti.is_lead {
656 team_leaders.insert(ti.team_name.clone(), id.clone());
657 } else {
658 team_members
659 .entry(ti.team_name.clone())
660 .or_default()
661 .push((ti.member_name.clone(), id.clone()));
662 }
663 }
664 }
665 }
666
667 if team_leaders.is_empty() && team_members.is_empty() {
669 return;
670 }
671
672 for (team_name, members) in &team_members {
674 if !team_leaders.contains_key(team_name) {
675 if let Some((_, first_member_id)) = members.first() {
676 team_leaders.insert(team_name.clone(), first_member_id.clone());
677 }
678 }
679 }
680
681 for (team_name, leader_id) in &team_leaders {
683 if let Some(members) = team_members.get_mut(team_name) {
684 members.retain(|(_, id)| id != leader_id);
685 }
686 }
687
688 for members in team_members.values_mut() {
690 members.sort_by(|a, b| a.0.cmp(&b.0));
691 }
692
693 let member_ids: std::collections::HashSet<String> = team_members
695 .values()
696 .flatten()
697 .map(|(_, id)| id.clone())
698 .collect();
699
700 let mut new_order = Vec::with_capacity(self.agent_order.len());
701 for id in &self.agent_order {
702 if member_ids.contains(id) {
704 continue;
705 }
706
707 new_order.push(id.clone());
708
709 if let Some(agent) = agents.get(id) {
711 if let Some(ref ti) = agent.team_info {
712 if let Some(members) = team_members.get(&ti.team_name) {
713 if team_leaders.get(&ti.team_name) == Some(id) {
714 for (_, member_id) in members {
715 if member_id != id {
717 new_order.push(member_id.clone());
718 }
719 }
720 }
721 }
722 }
723 }
724 }
725
726 self.agent_order = new_order;
727 }
728
729 fn status_priority(status: &crate::agents::AgentStatus) -> u8 {
731 match status {
732 crate::agents::AgentStatus::AwaitingApproval { .. } => 0, crate::agents::AgentStatus::Error { .. } => 1,
734 crate::agents::AgentStatus::Processing { .. } => 2,
735 crate::agents::AgentStatus::Idle => 3,
736 crate::agents::AgentStatus::Offline => 4,
737 crate::agents::AgentStatus::Unknown => 5,
738 }
739 }
740
741 pub fn get_group_key(&self, agent: &MonitoredAgent) -> Option<String> {
743 match self.sort_by {
744 SortBy::Directory => Some(agent.display_cwd()),
745 SortBy::SessionOrder => Some(agent.session.clone()),
746 SortBy::AgentType => Some(agent.agent_type.short_name().to_string()),
747 SortBy::Team => Some(
748 agent
749 .team_info
750 .as_ref()
751 .map(|t| format!("Team: {}", t.team_name))
752 .unwrap_or_else(|| "(No Team)".to_string()),
753 ),
754 SortBy::Repository => Some(
755 agent
756 .git_common_dir
757 .as_deref()
758 .map(crate::git::repo_name_from_common_dir)
759 .unwrap_or_else(|| agent.display_cwd()),
760 ),
761 _ => None,
762 }
763 }
764
765 pub fn toggle_group_collapse(&mut self, group_key: &str) {
767 if self.selection.collapsed_groups.contains(group_key) {
768 self.selection.collapsed_groups.remove(group_key);
769 } else {
770 self.selection
771 .collapsed_groups
772 .insert(group_key.to_string());
773 }
774 }
775
776 pub fn is_group_collapsed(&self, group_key: &str) -> bool {
778 self.selection.collapsed_groups.contains(group_key)
779 }
780
781 pub fn select_previous(&mut self) {
783 if self.selection.selected_entry_index > 0 {
784 self.selection.selected_entry_index -= 1;
785 self.view.preview_scroll = 0;
786 self.sync_selected_index_from_entry();
787 self.reset_marquee_for_selection();
788 }
789 }
790
791 pub fn select_next(&mut self) {
793 if self.selection.selectable_count > 0
794 && self.selection.selected_entry_index < self.selection.selectable_count - 1
795 {
796 self.selection.selected_entry_index += 1;
797 self.view.preview_scroll = 0;
798 self.sync_selected_index_from_entry();
799 self.reset_marquee_for_selection();
800 }
801 }
802
803 pub fn select_first(&mut self) {
805 if self.selection.selectable_count > 0 {
806 self.selection.selected_entry_index = 0;
807 self.view.preview_scroll = 0;
808 self.sync_selected_index_from_entry();
809 self.reset_marquee_for_selection();
810 }
811 }
812
813 pub fn select_last(&mut self) {
815 if self.selection.selectable_count > 0 {
816 self.selection.selected_entry_index = self.selection.selectable_count - 1;
817 self.view.preview_scroll = 0;
818 self.sync_selected_index_from_entry();
819 self.reset_marquee_for_selection();
820 }
821 }
822
823 fn reset_marquee_for_selection(&mut self) {
825 let new_id = self.selected_target().map(|s| s.to_string());
826 self.reset_marquee(new_id);
827 }
828
829 fn sync_selected_index_from_entry(&mut self) {
832 if !self.agent_order.is_empty() && self.selection.selected_index >= self.agent_order.len() {
835 self.selection.selected_index = self.agent_order.len() - 1;
836 }
837 }
838
839 pub fn update_selectable_entries(
841 &mut self,
842 selectable_count: usize,
843 agent_index: Option<usize>,
844 ) {
845 self.selection.selectable_count = selectable_count;
846 self.selection.is_on_create_new = agent_index.is_none();
847 if let Some(idx) = agent_index {
848 self.selection.selected_index = idx;
849 }
850 if self.selection.selected_entry_index >= selectable_count && selectable_count > 0 {
852 self.selection.selected_entry_index = selectable_count - 1;
853 }
854 }
855
856 pub fn get_known_directories(&self) -> Vec<String> {
858 let mut dirs: Vec<String> = self.agents.values().map(|a| a.cwd.clone()).collect();
859 dirs.sort();
860 dirs.dedup();
861 dirs
862 }
863
864 pub fn toggle_help(&mut self) {
866 self.view.show_help = !self.view.show_help;
867 if self.view.show_help {
868 self.view.help_scroll = 0;
869 }
870 }
871
872 pub fn toggle_qr(&mut self) {
874 self.view.show_qr = !self.view.show_qr;
875 }
876
877 pub fn init_web(&mut self, token: String, port: u16) {
879 self.web.token = Some(token);
880 self.web.port = port;
881 }
882
883 pub fn get_web_url(&self) -> Option<String> {
888 let token = self.web.token.as_ref()?;
889
890 if let Some(host_ip) = get_wsl_host_ip() {
892 return Some(format!(
893 "http://{}:{}/?token={}",
894 host_ip, self.web.port, token
895 ));
896 }
897
898 if let Ok(ip) = local_ip_address::local_ip() {
900 Some(format!("http://{}:{}/?token={}", ip, self.web.port, token))
901 } else {
902 Some(format!(
903 "http://localhost:{}/?token={}",
904 self.web.port, token
905 ))
906 }
907 }
908
909 pub fn scroll_help_down(&mut self, amount: u16) {
911 self.view.help_scroll = self.view.help_scroll.saturating_add(amount);
912 }
913
914 pub fn scroll_help_up(&mut self, amount: u16) {
916 self.view.help_scroll = self.view.help_scroll.saturating_sub(amount);
917 }
918
919 pub fn scroll_preview_down(&mut self, amount: u16) {
921 self.view.preview_scroll = self.view.preview_scroll.saturating_add(amount);
922 }
923
924 pub fn scroll_preview_up(&mut self, amount: u16) {
926 self.view.preview_scroll = self.view.preview_scroll.saturating_sub(amount);
927 }
928
929 pub fn agents_needing_attention(&self) -> Vec<&MonitoredAgent> {
931 self.agent_order
932 .iter()
933 .filter_map(|id| self.agents.get(id))
934 .filter(|a| a.status.needs_attention())
935 .collect()
936 }
937
938 pub fn attention_count(&self) -> usize {
940 self.agents_needing_attention().len()
941 }
942
943 pub fn set_error(&mut self, message: String) {
945 self.error_message = Some(message);
946 }
947
948 pub fn clear_error(&mut self) {
950 self.error_message = None;
951 }
952
953 pub fn quit(&mut self) {
955 self.running = false;
956 }
957
958 pub fn enter_input_mode(&mut self) {
964 self.input.mode = InputMode::Input;
965 }
966
967 pub fn enter_passthrough_mode(&mut self) {
969 self.input.mode = InputMode::Passthrough;
970 }
971
972 pub fn exit_input_mode(&mut self) {
974 self.input.mode = InputMode::Normal;
975 self.input.buffer.clear();
976 self.input.cursor_position = 0;
977 }
978
979 pub fn is_input_mode(&self) -> bool {
981 self.input.mode == InputMode::Input
982 }
983
984 pub fn is_passthrough_mode(&self) -> bool {
986 self.input.mode == InputMode::Passthrough
987 }
988
989 pub fn get_input(&self) -> &str {
991 &self.input.buffer
992 }
993
994 pub fn get_cursor_position(&self) -> usize {
996 self.input.cursor_position
997 }
998
999 pub fn input_char(&mut self, c: char) {
1001 self.input.buffer.insert(self.input.cursor_position, c);
1002 self.input.cursor_position += c.len_utf8();
1003 }
1004
1005 pub fn input_backspace(&mut self) {
1007 if self.input.cursor_position > 0 {
1008 let prev_char_boundary = self.input.buffer[..self.input.cursor_position]
1010 .char_indices()
1011 .last()
1012 .map(|(i, _)| i)
1013 .unwrap_or(0);
1014 self.input.buffer.remove(prev_char_boundary);
1015 self.input.cursor_position = prev_char_boundary;
1016 }
1017 }
1018
1019 pub fn input_delete(&mut self) {
1021 if self.input.cursor_position < self.input.buffer.len() {
1022 self.input.buffer.remove(self.input.cursor_position);
1023 }
1024 }
1025
1026 pub fn cursor_left(&mut self) {
1028 if self.input.cursor_position > 0 {
1029 self.input.cursor_position = self.input.buffer[..self.input.cursor_position]
1031 .char_indices()
1032 .last()
1033 .map(|(i, _)| i)
1034 .unwrap_or(0);
1035 }
1036 }
1037
1038 pub fn cursor_right(&mut self) {
1040 if self.input.cursor_position < self.input.buffer.len() {
1041 if let Some(c) = self.input.buffer[self.input.cursor_position..]
1043 .chars()
1044 .next()
1045 {
1046 self.input.cursor_position += c.len_utf8();
1047 }
1048 }
1049 }
1050
1051 pub fn cursor_home(&mut self) {
1053 self.input.cursor_position = 0;
1054 }
1055
1056 pub fn cursor_end(&mut self) {
1058 self.input.cursor_position = self.input.buffer.len();
1059 }
1060
1061 pub fn take_input(&mut self) -> String {
1063 let input = std::mem::take(&mut self.input.buffer);
1064 self.input.cursor_position = 0;
1065 input
1066 }
1067
1068 pub fn start_create_process(
1074 &mut self,
1075 group_key: String,
1076 panes: Vec<PaneInfo>,
1077 config: &CreateProcessSettings,
1078 ) {
1079 let known_directories = self.get_known_directories();
1081
1082 let directory_items = build_directory_items(config, known_directories);
1084
1085 let directory = if self.sort_by == SortBy::Directory {
1087 Some(group_key.clone())
1088 } else {
1089 None
1090 };
1091
1092 let target_session = if self.sort_by == SortBy::SessionOrder {
1094 Some(group_key.clone())
1095 } else {
1096 None
1097 };
1098
1099 let target_pane = self.selected_target().map(|s| s.to_string());
1101
1102 let mut collapsed_nodes = HashSet::new();
1104 for pane in &panes {
1105 let window_key = format!("{}:{}", pane.session, pane.window_index);
1107 collapsed_nodes.insert(window_key);
1108 }
1109
1110 let tree_entries = Self::build_tree_entries(&panes, &collapsed_nodes);
1112
1113 self.create_process = Some(CreateProcessState {
1114 step: CreateProcessStep::SelectTarget,
1115 placement_type: None,
1116 origin_group_key: group_key,
1117 target_session,
1118 target_pane,
1119 directory,
1120 cursor: 0,
1121 input_buffer: String::new(),
1122 available_panes: panes,
1123 collapsed_nodes,
1124 tree_entries,
1125 directory_items,
1126 is_input_mode: false,
1127 });
1128 }
1129
1130 fn build_tree_entries(panes: &[PaneInfo], collapsed_nodes: &HashSet<String>) -> Vec<TreeEntry> {
1132 let mut entries = Vec::new();
1133
1134 let mut sessions: Vec<String> = panes.iter().map(|p| p.session.clone()).collect();
1136 sessions.sort();
1137 sessions.dedup();
1138
1139 for session in &sessions {
1141 let session_collapsed = collapsed_nodes.contains(session);
1142 entries.push(TreeEntry::Session {
1143 name: session.clone(),
1144 collapsed: session_collapsed,
1145 });
1146
1147 if !session_collapsed {
1148 let mut windows: Vec<(u32, String)> = panes
1150 .iter()
1151 .filter(|p| &p.session == session)
1152 .map(|p| (p.window_index, p.window_name.clone()))
1153 .collect();
1154 windows.sort_by_key(|(idx, _)| *idx);
1155 windows.dedup_by_key(|(idx, _)| *idx);
1156
1157 for (window_index, window_name) in windows {
1158 let window_key = format!("{}:{}", session, window_index);
1159 let window_collapsed = collapsed_nodes.contains(&window_key);
1160
1161 entries.push(TreeEntry::Window {
1162 session: session.clone(),
1163 index: window_index,
1164 name: window_name,
1165 collapsed: window_collapsed,
1166 });
1167
1168 if !window_collapsed {
1169 let window_panes: Vec<&PaneInfo> = panes
1171 .iter()
1172 .filter(|p| &p.session == session && p.window_index == window_index)
1173 .collect();
1174
1175 for pane in window_panes {
1176 entries.push(TreeEntry::SplitPane {
1177 target: pane.target.clone(),
1178 });
1179 }
1180 }
1181 }
1182
1183 entries.push(TreeEntry::NewWindow {
1185 session: session.clone(),
1186 });
1187 }
1188 }
1189
1190 entries.push(TreeEntry::NewSession);
1192
1193 entries
1194 }
1195
1196 pub fn toggle_tree_node(&mut self, key: &str) {
1198 if let Some(ref mut cs) = self.create_process {
1199 if cs.collapsed_nodes.contains(key) {
1200 cs.collapsed_nodes.remove(key);
1201 } else {
1202 cs.collapsed_nodes.insert(key.to_string());
1203 }
1204 cs.tree_entries = Self::build_tree_entries(&cs.available_panes, &cs.collapsed_nodes);
1206 }
1207 }
1208
1209 pub fn cancel_create_process(&mut self) {
1211 self.create_process = None;
1212 }
1213
1214 pub fn is_create_process_mode(&self) -> bool {
1216 self.create_process.is_some()
1217 }
1218
1219 pub fn show_confirmation(&mut self, action: ConfirmAction, message: String) {
1225 self.confirmation_state = Some(ConfirmationState { action, message });
1226 }
1227
1228 pub fn cancel_confirmation(&mut self) {
1230 self.confirmation_state = None;
1231 }
1232
1233 pub fn is_showing_confirmation(&self) -> bool {
1235 self.confirmation_state.is_some()
1236 }
1237
1238 pub fn get_confirmation_action(&self) -> Option<ConfirmAction> {
1240 self.confirmation_state.as_ref().map(|s| s.action.clone())
1241 }
1242
1243 pub fn create_process_cursor_up(&mut self) {
1245 if let Some(ref mut state) = self.create_process {
1246 if state.cursor > 0 {
1247 state.cursor -= 1;
1248 if state.step == CreateProcessStep::SelectDirectory {
1250 let len = state.directory_items.len();
1251 if len == 0 {
1252 state.cursor = 0;
1253 return;
1254 }
1255 while state.cursor > 0 && !state.directory_items[state.cursor].is_selectable() {
1256 state.cursor -= 1;
1257 }
1258 if !state.directory_items[state.cursor].is_selectable() {
1260 while state.cursor < len
1261 && !state.directory_items[state.cursor].is_selectable()
1262 {
1263 state.cursor += 1;
1264 }
1265 }
1266 }
1267 }
1268 }
1269 }
1270
1271 pub fn create_process_cursor_down(&mut self, max: usize) {
1273 if let Some(ref mut state) = self.create_process {
1274 if state.cursor < max.saturating_sub(1) {
1275 state.cursor += 1;
1276 if state.step == CreateProcessStep::SelectDirectory {
1278 let len = state.directory_items.len();
1279 while state.cursor < len && !state.directory_items[state.cursor].is_selectable()
1280 {
1281 state.cursor += 1;
1282 }
1283 if state.cursor >= len {
1285 state.cursor = len.saturating_sub(1);
1287 while state.cursor > 0
1288 && !state.directory_items[state.cursor].is_selectable()
1289 {
1290 state.cursor -= 1;
1291 }
1292 }
1293 }
1294 }
1295 }
1296 }
1297
1298 pub fn create_process_cursor(&self) -> usize {
1300 self.create_process.as_ref().map(|s| s.cursor).unwrap_or(0)
1301 }
1302}
1303
1304impl Default for AppState {
1305 fn default() -> Self {
1306 Self::new()
1307 }
1308}
1309
1310fn expand_tilde(path: &str) -> String {
1312 if path.starts_with("~/") || path == "~" {
1313 if let Some(home) = dirs::home_dir() {
1314 return path.replacen('~', &home.to_string_lossy(), 1);
1315 }
1316 }
1317 path.to_string()
1318}
1319
1320fn build_directory_items(
1322 config: &CreateProcessSettings,
1323 known_directories: Vec<String>,
1324) -> Vec<DirItem> {
1325 let mut items = vec![DirItem::EnterPath, DirItem::Home, DirItem::Current];
1326
1327 if !config.pinned.is_empty() {
1329 let mut pinned_items: Vec<DirItem> = Vec::new();
1330 for dir in &config.pinned {
1331 let expanded = expand_tilde(dir);
1332 if std::path::Path::new(&expanded).is_dir() {
1333 pinned_items.push(DirItem::Directory {
1334 display: dir.to_string(),
1335 path: expanded,
1336 });
1337 }
1338 }
1339 if !pinned_items.is_empty() {
1340 items.push(DirItem::Header("Pinned".to_string()));
1341 items.extend(pinned_items);
1342 }
1343 }
1344
1345 for base in &config.base_directories {
1347 let expanded = expand_tilde(base);
1348 if let Ok(entries) = std::fs::read_dir(&expanded) {
1349 let mut subdirs: Vec<(String, String)> = entries
1350 .filter_map(|e| e.ok())
1351 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
1352 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
1353 .map(|e| {
1354 let name = e.file_name().to_string_lossy().to_string();
1355 let path = e.path().to_string_lossy().to_string();
1356 (name, path)
1357 })
1358 .collect();
1359 subdirs.sort_by(|a, b| a.0.cmp(&b.0));
1360
1361 if !subdirs.is_empty() {
1362 items.push(DirItem::Header(base.to_string()));
1363 for (name, path) in subdirs {
1364 items.push(DirItem::Directory {
1365 display: name,
1366 path,
1367 });
1368 }
1369 }
1370 }
1371 }
1372
1373 let existing_paths: HashSet<String> = items
1375 .iter()
1376 .filter_map(|item| match item {
1377 DirItem::Directory { path, .. } => Some(path.clone()),
1378 _ => None,
1379 })
1380 .collect();
1381 let unique_known: Vec<String> = known_directories
1382 .into_iter()
1383 .filter(|d| !existing_paths.contains(d))
1384 .collect();
1385 if !unique_known.is_empty() {
1386 items.push(DirItem::Header("Known".to_string()));
1387 for dir in unique_known {
1388 let display = if dir.chars().count() > 40 {
1389 let tail: String = dir
1390 .chars()
1391 .rev()
1392 .take(37)
1393 .collect::<Vec<_>>()
1394 .into_iter()
1395 .rev()
1396 .collect();
1397 format!("...{}", tail)
1398 } else {
1399 dir.clone()
1400 };
1401 items.push(DirItem::Directory { display, path: dir });
1402 }
1403 }
1404
1405 items
1406}
1407
1408fn get_wsl_host_ip() -> Option<String> {
1416 let proc_version = std::fs::read_to_string("/proc/version").ok()?;
1418 if !proc_version.to_lowercase().contains("microsoft")
1419 && !proc_version.to_lowercase().contains("wsl")
1420 {
1421 return None;
1422 }
1423
1424 if is_wsl_mirrored_mode() {
1427 return None; }
1430
1431 let resolv_conf = std::fs::read_to_string("/etc/resolv.conf").ok()?;
1433 for line in resolv_conf.lines() {
1434 let line = line.trim();
1435 if line.starts_with("nameserver") {
1436 if let Some(ip) = line.split_whitespace().nth(1) {
1437 if ip.starts_with("10.255.") || ip.starts_with("127.") {
1439 continue;
1440 }
1441 if ip.parse::<std::net::Ipv4Addr>().is_ok() {
1443 return Some(ip.to_string());
1444 }
1445 }
1446 }
1447 }
1448
1449 None
1450}
1451
1452fn is_wsl_mirrored_mode() -> bool {
1454 if let Ok(home) = std::env::var("USERPROFILE") {
1456 let wslconfig_path = format!("{}\\.wslconfig", home);
1457 if let Ok(content) = std::fs::read_to_string(&wslconfig_path) {
1458 return content.to_lowercase().contains("networkingmode=mirrored");
1459 }
1460 }
1461
1462 if let Ok(entries) = std::fs::read_dir("/mnt/c/Users") {
1464 for entry in entries.flatten() {
1465 let path = entry.path().join(".wslconfig");
1466 if let Ok(content) = std::fs::read_to_string(&path) {
1467 if content.to_lowercase().contains("networkingmode=mirrored") {
1468 return true;
1469 }
1470 }
1471 }
1472 }
1473
1474 false
1475}
1476
1477#[cfg(test)]
1478mod tests {
1479 use super::*;
1480 use crate::agents::{AgentStatus, AgentType};
1481
1482 fn create_test_agent(id: &str) -> MonitoredAgent {
1483 MonitoredAgent::new(
1484 id.to_string(),
1485 AgentType::ClaudeCode,
1486 "Test".to_string(),
1487 "/home".to_string(),
1488 1234,
1489 "main".to_string(),
1490 "window".to_string(),
1491 0,
1492 0,
1493 )
1494 }
1495
1496 #[test]
1497 fn test_new_state() {
1498 let state = AppState::new();
1499 assert!(state.agents.is_empty());
1500 assert!(state.running);
1501 }
1502
1503 #[test]
1504 fn test_update_agents() {
1505 let mut state = AppState::new();
1506 let agents = vec![create_test_agent("main:0.0"), create_test_agent("main:0.1")];
1507
1508 state.update_agents(agents);
1509
1510 assert_eq!(state.agents.len(), 2);
1511 assert_eq!(state.agent_order.len(), 2);
1512 }
1513
1514 #[test]
1515 fn test_selection() {
1516 let mut state = AppState::new();
1517 let agents = vec![
1518 create_test_agent("main:0.0"),
1519 create_test_agent("main:0.1"),
1520 create_test_agent("main:0.2"),
1521 ];
1522 state.update_agents(agents);
1523 state.selection.selectable_count = 4;
1525
1526 assert_eq!(state.selection.selected_entry_index, 0);
1527
1528 state.select_next();
1529 assert_eq!(state.selection.selected_entry_index, 1);
1530
1531 state.select_next();
1532 assert_eq!(state.selection.selected_entry_index, 2);
1533
1534 state.select_next();
1535 assert_eq!(state.selection.selected_entry_index, 3); state.select_next();
1538 assert_eq!(state.selection.selected_entry_index, 3); state.select_previous();
1541 assert_eq!(state.selection.selected_entry_index, 2);
1542
1543 state.select_first();
1544 assert_eq!(state.selection.selected_entry_index, 0);
1545
1546 state.select_last();
1547 assert_eq!(state.selection.selected_entry_index, 3);
1548 }
1549
1550 #[test]
1551 fn test_attention_count() {
1552 let mut state = AppState::new();
1553 let mut agent1 = create_test_agent("main:0.0");
1554 agent1.status = AgentStatus::Idle;
1555
1556 let mut agent2 = create_test_agent("main:0.1");
1557 agent2.status = AgentStatus::AwaitingApproval {
1558 approval_type: crate::agents::ApprovalType::FileEdit,
1559 details: String::new(),
1560 };
1561
1562 state.update_agents(vec![agent1, agent2]);
1563
1564 assert_eq!(state.attention_count(), 1);
1565 }
1566}