Skip to main content

tmai_core/state/
store.rs

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
10/// Shared state type alias
11pub type SharedState = Arc<RwLock<AppState>>;
12
13/// Input mode for the application
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum InputMode {
16    /// Normal navigation mode
17    #[default]
18    Normal,
19    /// Text input mode
20    Input,
21    /// Passthrough mode - keys are sent directly to the target pane
22    Passthrough,
23}
24
25/// Sort method for agent list
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum SortBy {
28    /// Sort by working directory (default)
29    #[default]
30    Directory,
31    /// Default order (session:window.pane)
32    SessionOrder,
33    /// Sort by agent type
34    AgentType,
35    /// Sort by status (attention needed first)
36    Status,
37    /// Sort by last update time
38    LastUpdate,
39    /// Sort by team
40    Team,
41    /// Sort by git repository (groups main + worktrees together)
42    Repository,
43}
44
45impl SortBy {
46    /// Get the next sort method in cycle
47    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    /// Get display name for the sort method
60    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/// Monitor scope for filtering panes
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum MonitorScope {
76    /// Monitor all attached sessions
77    #[default]
78    AllSessions,
79    /// Monitor current session only
80    CurrentSession,
81    /// Monitor current window only
82    CurrentWindow,
83}
84
85impl MonitorScope {
86    /// Get the next scope in cycle
87    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    /// Get display name for the scope
96    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
105/// Spinner frames for processing animation
106pub const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
107
108/// Marquee scroll interval in milliseconds
109const MARQUEE_INTERVAL_MS: u64 = 280;
110
111/// State for marquee text scrolling animation
112#[derive(Debug, Clone)]
113pub struct MarqueeState {
114    /// Current scroll offset (in characters)
115    pub offset: usize,
116    /// Last update timestamp
117    pub last_update: std::time::Instant,
118    /// ID of the selected item (to detect selection change)
119    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/// Placement type for creating new AI process
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum PlacementType {
135    /// Create a new tmux session + window
136    NewSession,
137    /// Create a new window in existing session
138    NewWindow,
139    /// Split existing window to add a pane
140    SplitPane,
141}
142
143/// Tree entry for the tree-style target selection UI
144#[derive(Debug, Clone)]
145pub enum TreeEntry {
146    /// Create a new session
147    NewSession,
148    /// Session node (collapsible)
149    Session { name: String, collapsed: bool },
150    /// Create a new window in a session
151    NewWindow { session: String },
152    /// Window node (collapsible)
153    Window {
154        session: String,
155        index: u32,
156        name: String,
157        collapsed: bool,
158    },
159    /// Split a pane
160    SplitPane { target: String },
161}
162
163/// Action to confirm before executing
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum ConfirmAction {
166    /// Kill a tmux pane
167    KillPane { target: String },
168    /// Restart a non-IPC agent as PTY-wrapped (session ID already known)
169    RestartAsWrapped { target: String, session_id: String },
170    /// Send a probe marker to identify session, then restart as wrapped
171    ProbeAndRestartAsWrapped { target: String, cwd: String },
172}
173
174/// State for confirmation dialog
175#[derive(Debug, Clone)]
176pub struct ConfirmationState {
177    /// Action to execute on confirmation
178    pub action: ConfirmAction,
179    /// Message to display
180    pub message: String,
181}
182
183/// An item in the directory selection list
184#[derive(Debug, Clone)]
185pub enum DirItem {
186    /// Section header (not selectable, cursor skips)
187    Header(String),
188    /// "Enter path..." action
189    EnterPath,
190    /// Home directory
191    Home,
192    /// Current directory
193    Current,
194    /// A selectable directory with display name and full path
195    Directory { display: String, path: String },
196}
197
198impl DirItem {
199    /// Whether this item is selectable (non-header)
200    pub fn is_selectable(&self) -> bool {
201        !matches!(self, DirItem::Header(_))
202    }
203}
204
205/// Step in the create process flow
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum CreateProcessStep {
208    /// Select target from tree (combined placement + target selection)
209    SelectTarget,
210    /// Select directory
211    SelectDirectory,
212    /// Select AI agent type
213    SelectAgent,
214}
215
216/// State for the create process flow
217#[derive(Debug, Clone)]
218pub struct CreateProcessState {
219    /// Current step in the flow
220    pub step: CreateProcessStep,
221    /// Selected placement type
222    pub placement_type: Option<PlacementType>,
223    /// Group key that initiated the flow (directory path or session name)
224    pub origin_group_key: String,
225    /// Selected tmux session name (for NewWindow)
226    pub target_session: Option<String>,
227    /// Target pane to split (for SplitPane, session:window.pane format)
228    pub target_pane: Option<String>,
229    /// Selected directory path
230    pub directory: Option<String>,
231    /// Cursor position in the popup list
232    pub cursor: usize,
233    /// Input buffer for directory path entry
234    pub input_buffer: String,
235    /// Available panes list (cached)
236    pub available_panes: Vec<PaneInfo>,
237    /// Collapsed node keys (session name or "session:window_index")
238    pub collapsed_nodes: HashSet<String>,
239    /// Tree entries (cached, rebuilt when collapsed_nodes changes)
240    pub tree_entries: Vec<TreeEntry>,
241    /// Directory selection items (includes headers, pinned, base, known)
242    pub directory_items: Vec<DirItem>,
243    /// Whether in path input mode
244    pub is_input_mode: bool,
245}
246
247/// Snapshot of a team's state at a point in time
248#[derive(Debug, Clone)]
249pub struct TeamSnapshot {
250    /// Team configuration
251    pub config: TeamConfig,
252    /// Current tasks
253    pub tasks: Vec<TeamTask>,
254    /// Mapping of member_name → pane target
255    pub member_panes: HashMap<String, String>,
256    /// When this snapshot was last updated
257    pub last_scan: chrono::DateTime<chrono::Utc>,
258    /// Pre-computed completed task count
259    pub task_done: usize,
260    /// Pre-computed total task count
261    pub task_total: usize,
262    /// Pre-computed in-progress task count
263    pub task_in_progress: usize,
264    /// Pre-computed pending task count
265    pub task_pending: usize,
266}
267
268/// Input-related state
269#[derive(Debug, Default)]
270pub struct InputState {
271    /// Current input mode
272    pub mode: InputMode,
273    /// Input buffer for text entry
274    pub buffer: String,
275    /// Cursor position in input buffer (byte offset)
276    pub cursor_position: usize,
277}
278
279/// UI view state (overlays, scroll, animations)
280#[derive(Debug)]
281pub struct ViewState {
282    /// Whether help screen is shown
283    pub show_help: bool,
284    /// Help screen scroll offset
285    pub help_scroll: u16,
286    /// Whether QR code screen is shown
287    pub show_qr: bool,
288    /// Whether the team overview screen is shown
289    pub show_team_overview: bool,
290    /// Whether the task overlay is shown
291    pub show_task_overlay: bool,
292    /// Task overlay scroll offset
293    pub task_overlay_scroll: u16,
294    /// Team overview scroll offset
295    pub team_overview_scroll: u16,
296    /// Preview scroll offset
297    pub preview_scroll: u16,
298    /// Spinner animation frame counter
299    pub spinner_frame: usize,
300    /// Last spinner update time
301    pub last_spinner_update: std::time::Instant,
302    /// Marquee animation state for selected item
303    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/// Selection/navigation state
325#[derive(Debug, Default)]
326pub struct SelectionState {
327    /// Currently selected agent index
328    pub selected_index: usize,
329    /// Selected entry index (for UI navigation including CreateNew entries)
330    pub selected_entry_index: usize,
331    /// Total selectable entries count (cached)
332    pub selectable_count: usize,
333    /// Whether CreateNew entry is currently selected
334    pub is_on_create_new: bool,
335    /// Collapsed group keys (for group header folding)
336    pub collapsed_groups: HashSet<String>,
337}
338
339/// Web-related state
340#[derive(Debug, Default)]
341pub struct WebState {
342    /// Web server authentication token
343    pub token: Option<String>,
344    /// Web server port
345    pub port: u16,
346}
347
348/// Application state
349#[derive(Debug)]
350pub struct AppState {
351    // Sub-states
352    /// Input-related state
353    pub input: InputState,
354    /// UI view state (overlays, scroll, animations)
355    pub view: ViewState,
356    /// Selection/navigation state
357    pub selection: SelectionState,
358    /// Web-related state
359    pub web: WebState,
360
361    // Core domain (unchanged)
362    /// All monitored agents by target ID
363    pub agents: HashMap<String, MonitoredAgent>,
364    /// Order of agents for display
365    pub agent_order: Vec<String>,
366    /// Current sort method
367    pub sort_by: SortBy,
368    /// Monitor scope for filtering panes
369    pub monitor_scope: MonitorScope,
370    /// Current session name (for scope display)
371    pub current_session: Option<String>,
372    /// Current window index (for scope display)
373    pub current_window: Option<u32>,
374    /// Team snapshots by team name
375    pub teams: HashMap<String, TeamSnapshot>,
376    /// Mapping of tmux target (e.g. "main:0.1") to pane_id (e.g. "5") for IPC
377    pub target_to_pane_id: HashMap<String, String>,
378
379    // Dialog/mode state (unchanged)
380    /// Create process flow state (None if not in create mode)
381    pub create_process: Option<CreateProcessState>,
382    /// Confirmation dialog state (None if not showing)
383    pub confirmation_state: Option<ConfirmationState>,
384    /// Error message to display
385    pub error_message: Option<String>,
386    /// Last poll timestamp
387    pub last_poll: Option<chrono::DateTime<chrono::Utc>>,
388    /// Whether the app is running
389    pub running: bool,
390
391    /// Show activity name (tool/verb) during Processing instead of generic "Processing"
392    pub show_activity_name: bool,
393}
394
395impl AppState {
396    /// Create a new application state
397    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    /// Advance the spinner animation frame (time-based, ~150ms per frame)
424    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    /// Get the current spinner character
433    pub fn spinner_char(&self) -> char {
434        SPINNER_FRAMES[self.view.spinner_frame]
435    }
436
437    /// Advance the marquee scroll offset (time-based)
438    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    /// Reset marquee state when selection changes
447    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    /// Get the current marquee scroll offset
456    pub fn marquee_offset(&self) -> usize {
457        self.view.marquee_state.offset
458    }
459
460    /// Create a shared state
461    pub fn shared() -> SharedState {
462        Arc::new(RwLock::new(Self::new()))
463    }
464
465    /// Get the currently selected agent
466    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    /// Get a mutable reference to the selected agent
473    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    /// Get the selected agent's target ID
482    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    /// Update agents from a new list
489    pub fn update_agents(&mut self, agents: Vec<MonitoredAgent>) {
490        // Use HashSet for O(1) lookup instead of Vec::contains O(n)
491        let new_ids: HashSet<String> = agents.iter().map(|a| a.id.clone()).collect();
492        // Also collect as Vec for agent_order (preserves input order)
493        let new_order: Vec<String> = agents.iter().map(|a| a.id.clone()).collect();
494
495        // Remove agents that no longer exist (O(n) instead of O(n²))
496        self.agents.retain(|id, _| new_ids.contains(id));
497
498        // Update or add new agents
499        for agent in agents {
500            let id = agent.id.clone();
501            if let Some(existing) = self.agents.get_mut(&id) {
502                // Update status and content
503                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                // Update meta information
510                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                // Git info (set by poller's update_git_info / apply_cached_git_info)
520                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                // Preserve auto_approve_phase from service, but clear it when
526                // agent is no longer awaiting approval (state has transitioned)
527                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        // Update order, preserving selection if possible
539        let old_selected = self.selected_target().map(|s| s.to_string());
540        self.agent_order = new_order;
541
542        // Apply current sort
543        self.sort_agents();
544
545        // Try to preserve selection
546        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        // Ensure selection is valid
553        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    /// Cycle through sort methods
561    pub fn cycle_sort(&mut self) {
562        self.sort_by = self.sort_by.next();
563        self.sort_agents();
564    }
565
566    /// Cycle through monitor scopes
567    pub fn cycle_monitor_scope(&mut self) {
568        self.monitor_scope = self.monitor_scope.next();
569    }
570
571    /// Sort agent_order based on current sort_by setting
572    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                        // Sort by cwd, then by id
582                        a.cwd.cmp(&b.cwd).then_with(|| a.id.cmp(&b.id))
583                    }
584                    SortBy::SessionOrder => {
585                        // session:window.pane order
586                        a.id.cmp(&b.id)
587                    }
588                    SortBy::AgentType => {
589                        // Sort by agent type name, then by id
590                        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                        // Sort by status priority (attention needed first)
597                        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                        // Sort by last update (most recent first)
603                        b.last_update
604                            .cmp(&a.last_update)
605                            .then_with(|| a.id.cmp(&b.id))
606                    }
607                    SortBy::Team => {
608                        // Sort by team name (no-team agents last), then by member name
609                        let team_a = a
610                            .team_info
611                            .as_ref()
612                            .map(|t| t.team_name.as_str())
613                            .unwrap_or("\u{ffff}"); // Sort no-team last
614                        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                        // Sort by git common_dir (groups main + worktrees together)
623                        // Non-git agents fall back to cwd
624                        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                            // Within same repo: main first, then worktrees alphabetically
628                            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        // Post-sort: nest team members under their leader
641        self.nest_team_members();
642    }
643
644    /// Reorder agent_order so team members appear directly after their leader
645    fn nest_team_members(&mut self) {
646        let agents = &self.agents;
647
648        // Collect team info: team_name → (leader_id, [(member_name, member_id)])
649        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 no teams found, skip
668        if team_leaders.is_empty() && team_members.is_empty() {
669            return;
670        }
671
672        // For teams without a detected leader, use the first member as implicit leader
673        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        // Remove implicit leaders from team_members so they don't get skipped
682        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        // Sort members by name for stable ordering
689        for members in team_members.values_mut() {
690            members.sort_by(|a, b| a.0.cmp(&b.0));
691        }
692
693        // Build new order: for each item, if it's a leader, insert its members right after
694        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            // Skip members here (they'll be inserted after their leader)
703            if member_ids.contains(id) {
704                continue;
705            }
706
707            new_order.push(id.clone());
708
709            // If this is a leader, insert members after it
710            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                                // Don't add the leader again if it happens to be in members list
716                                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    /// Get priority for status sorting (lower = higher priority)
730    fn status_priority(status: &crate::agents::AgentStatus) -> u8 {
731        match status {
732            crate::agents::AgentStatus::AwaitingApproval { .. } => 0, // Highest priority
733            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    /// Get the current group key for an agent (for display headers)
742    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    /// Toggle collapse state for a group
766    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    /// Check if a group is collapsed
777    pub fn is_group_collapsed(&self, group_key: &str) -> bool {
778        self.selection.collapsed_groups.contains(group_key)
779    }
780
781    /// Move selection up
782    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    /// Move selection down
792    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    /// Select first entry
804    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    /// Select last entry
814    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    /// Reset marquee state based on current selection
824    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    /// Sync selected_index from selected_entry_index
830    /// This maps the entry index back to agent_order index for preview display
831    fn sync_selected_index_from_entry(&mut self) {
832        // This will be properly synced when build_entries is called during render
833        // For now, just ensure selected_index stays valid
834        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    /// Update selectable count and sync entry index
840    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        // Ensure entry index is valid
851        if self.selection.selected_entry_index >= selectable_count && selectable_count > 0 {
852            self.selection.selected_entry_index = selectable_count - 1;
853        }
854    }
855
856    /// Get all unique directories from current agents
857    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    /// Toggle help screen
865    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    /// Toggle QR code screen
873    pub fn toggle_qr(&mut self) {
874        self.view.show_qr = !self.view.show_qr;
875    }
876
877    /// Initialize web settings
878    pub fn init_web(&mut self, token: String, port: u16) {
879        self.web.token = Some(token);
880        self.web.port = port;
881    }
882
883    /// Get web URL for QR code
884    ///
885    /// In WSL environments, returns Windows host IP instead of WSL internal IP,
886    /// since external devices (phones) cannot access WSL's internal network directly.
887    pub fn get_web_url(&self) -> Option<String> {
888        let token = self.web.token.as_ref()?;
889
890        // Try to get Windows host IP if running in WSL
891        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        // Fall back to local IP detection
899        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    /// Scroll help screen down
910    pub fn scroll_help_down(&mut self, amount: u16) {
911        self.view.help_scroll = self.view.help_scroll.saturating_add(amount);
912    }
913
914    /// Scroll help screen up
915    pub fn scroll_help_up(&mut self, amount: u16) {
916        self.view.help_scroll = self.view.help_scroll.saturating_sub(amount);
917    }
918
919    /// Scroll preview down
920    pub fn scroll_preview_down(&mut self, amount: u16) {
921        self.view.preview_scroll = self.view.preview_scroll.saturating_add(amount);
922    }
923
924    /// Scroll preview up
925    pub fn scroll_preview_up(&mut self, amount: u16) {
926        self.view.preview_scroll = self.view.preview_scroll.saturating_sub(amount);
927    }
928
929    /// Get agents that need attention (awaiting approval or error)
930    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    /// Get count of agents needing attention
939    pub fn attention_count(&self) -> usize {
940        self.agents_needing_attention().len()
941    }
942
943    /// Set error message
944    pub fn set_error(&mut self, message: String) {
945        self.error_message = Some(message);
946    }
947
948    /// Clear error message
949    pub fn clear_error(&mut self) {
950        self.error_message = None;
951    }
952
953    /// Stop the application
954    pub fn quit(&mut self) {
955        self.running = false;
956    }
957
958    // =========================================
959    // Input mode methods
960    // =========================================
961
962    /// Enter input mode
963    pub fn enter_input_mode(&mut self) {
964        self.input.mode = InputMode::Input;
965    }
966
967    /// Enter passthrough mode
968    pub fn enter_passthrough_mode(&mut self) {
969        self.input.mode = InputMode::Passthrough;
970    }
971
972    /// Exit input mode and clear buffer
973    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    /// Check if in input mode
980    pub fn is_input_mode(&self) -> bool {
981        self.input.mode == InputMode::Input
982    }
983
984    /// Check if in passthrough mode
985    pub fn is_passthrough_mode(&self) -> bool {
986        self.input.mode == InputMode::Passthrough
987    }
988
989    /// Get the input buffer
990    pub fn get_input(&self) -> &str {
991        &self.input.buffer
992    }
993
994    /// Get cursor position
995    pub fn get_cursor_position(&self) -> usize {
996        self.input.cursor_position
997    }
998
999    /// Insert a character at cursor position
1000    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    /// Delete character before cursor (backspace)
1006    pub fn input_backspace(&mut self) {
1007        if self.input.cursor_position > 0 {
1008            // Find the previous character boundary
1009            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    /// Delete character at cursor (delete key)
1020    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    /// Move cursor left
1027    pub fn cursor_left(&mut self) {
1028        if self.input.cursor_position > 0 {
1029            // Find the previous character boundary
1030            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    /// Move cursor right
1039    pub fn cursor_right(&mut self) {
1040        if self.input.cursor_position < self.input.buffer.len() {
1041            // Find the next character boundary
1042            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    /// Move cursor to start
1052    pub fn cursor_home(&mut self) {
1053        self.input.cursor_position = 0;
1054    }
1055
1056    /// Move cursor to end
1057    pub fn cursor_end(&mut self) {
1058        self.input.cursor_position = self.input.buffer.len();
1059    }
1060
1061    /// Take the input buffer content and clear it
1062    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    // =========================================
1069    // Create process methods
1070    // =========================================
1071
1072    /// Start create process flow from a group
1073    pub fn start_create_process(
1074        &mut self,
1075        group_key: String,
1076        panes: Vec<PaneInfo>,
1077        config: &CreateProcessSettings,
1078    ) {
1079        // Get known directories from current agents
1080        let known_directories = self.get_known_directories();
1081
1082        // Build directory items from config + known dirs
1083        let directory_items = build_directory_items(config, known_directories);
1084
1085        // Pre-select directory if sorted by Directory
1086        let directory = if self.sort_by == SortBy::Directory {
1087            Some(group_key.clone())
1088        } else {
1089            None
1090        };
1091
1092        // Pre-select session if sorted by SessionOrder
1093        let target_session = if self.sort_by == SortBy::SessionOrder {
1094            Some(group_key.clone())
1095        } else {
1096            None
1097        };
1098
1099        // Get currently selected pane target for SplitPane
1100        let target_pane = self.selected_target().map(|s| s.to_string());
1101
1102        // Initialize collapsed_nodes: sessions expanded, windows collapsed
1103        let mut collapsed_nodes = HashSet::new();
1104        for pane in &panes {
1105            // Collapse all windows by default
1106            let window_key = format!("{}:{}", pane.session, pane.window_index);
1107            collapsed_nodes.insert(window_key);
1108        }
1109
1110        // Build tree entries
1111        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    /// Build tree entries from panes and collapsed state
1131    fn build_tree_entries(panes: &[PaneInfo], collapsed_nodes: &HashSet<String>) -> Vec<TreeEntry> {
1132        let mut entries = Vec::new();
1133
1134        // Group panes by session, then by window
1135        let mut sessions: Vec<String> = panes.iter().map(|p| p.session.clone()).collect();
1136        sessions.sort();
1137        sessions.dedup();
1138
1139        // Existing sessions/windows/panes first (current location options)
1140        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                // Collect windows in this session
1149                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                        // Add panes under this window
1170                        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                // Add "New Window" at the bottom of the session
1184                entries.push(TreeEntry::NewWindow {
1185                    session: session.clone(),
1186                });
1187            }
1188        }
1189
1190        // Add "New Session" at the bottom
1191        entries.push(TreeEntry::NewSession);
1192
1193        entries
1194    }
1195
1196    /// Toggle a node's collapsed state in the create process tree
1197    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            // Rebuild tree entries
1205            cs.tree_entries = Self::build_tree_entries(&cs.available_panes, &cs.collapsed_nodes);
1206        }
1207    }
1208
1209    /// Cancel create process flow
1210    pub fn cancel_create_process(&mut self) {
1211        self.create_process = None;
1212    }
1213
1214    /// Check if in create process mode
1215    pub fn is_create_process_mode(&self) -> bool {
1216        self.create_process.is_some()
1217    }
1218
1219    // =========================================
1220    // Confirmation dialog methods
1221    // =========================================
1222
1223    /// Show a confirmation dialog
1224    pub fn show_confirmation(&mut self, action: ConfirmAction, message: String) {
1225        self.confirmation_state = Some(ConfirmationState { action, message });
1226    }
1227
1228    /// Cancel the confirmation dialog
1229    pub fn cancel_confirmation(&mut self) {
1230        self.confirmation_state = None;
1231    }
1232
1233    /// Check if confirmation dialog is showing
1234    pub fn is_showing_confirmation(&self) -> bool {
1235        self.confirmation_state.is_some()
1236    }
1237
1238    /// Get the confirmation action (for execution)
1239    pub fn get_confirmation_action(&self) -> Option<ConfirmAction> {
1240        self.confirmation_state.as_ref().map(|s| s.action.clone())
1241    }
1242
1243    /// Move cursor up in create process popup (skips headers in directory step)
1244    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                // Skip headers when in directory selection step
1249                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 we landed on a header at position 0, move forward
1259                    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    /// Move cursor down in create process popup (skips headers in directory step)
1272    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                // Skip headers when in directory selection step
1277                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                    // Clamp to last valid item
1284                    if state.cursor >= len {
1285                        // Find last selectable item
1286                        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    /// Get create process cursor position
1299    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
1310/// Expand `~` prefix to the user's home directory
1311fn 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
1320/// Build the directory selection items list from config and known directories
1321fn 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    // Pinned directories
1328    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    // Base directories (scan subdirectories)
1346    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    // Known directories (from running agents), excluding already-listed paths
1374    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
1408/// Detect if running in WSL and return the appropriate external IP
1409///
1410/// WSL2 has two networking modes:
1411/// - NAT mode (default): External devices cannot access WSL directly, need Windows host IP
1412/// - Mirrored mode: WSL shares Windows network, WSL IP is directly accessible
1413///
1414/// This function detects the mode and returns the appropriate IP.
1415fn get_wsl_host_ip() -> Option<String> {
1416    // Check if running in WSL by reading /proc/version
1417    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    // Check if mirrored networking mode is enabled
1425    // In mirrored mode, WSL's own IP is directly accessible from external devices
1426    if is_wsl_mirrored_mode() {
1427        // Use local_ip_address to get WSL's IP (which is the same as Windows in mirrored mode)
1428        return None; // Let the caller use local_ip_address
1429    }
1430
1431    // NAT mode: Windows host IP is typically the nameserver in /etc/resolv.conf
1432    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                // Skip internal IPs (systemd-resolved, localhost, etc.)
1438                if ip.starts_with("10.255.") || ip.starts_with("127.") {
1439                    continue;
1440                }
1441                // Validate it looks like an IP address
1442                if ip.parse::<std::net::Ipv4Addr>().is_ok() {
1443                    return Some(ip.to_string());
1444                }
1445            }
1446        }
1447    }
1448
1449    None
1450}
1451
1452/// Check if WSL is running in mirrored networking mode
1453fn is_wsl_mirrored_mode() -> bool {
1454    // Check .wslconfig in common locations
1455    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    // Try Windows user directories via /mnt/c
1463    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        // Simulate selectable count: 3 agents + 1 CreateNew = 4
1524        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); // CreateNew entry
1536
1537        state.select_next();
1538        assert_eq!(state.selection.selected_entry_index, 3); // Can't go past end
1539
1540        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}