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::{AgentDefinition, TeamConfig, TeamTask};
8use crate::tmux::PaneInfo;
9use crate::usage::UsageSnapshot;
10
11/// Shared state type alias
12pub type SharedState = Arc<RwLock<AppState>>;
13
14/// Input mode for the application
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum InputMode {
17    /// Normal navigation mode
18    #[default]
19    Normal,
20    /// Text input mode
21    Input,
22    /// Passthrough mode - keys are sent directly to the target pane
23    Passthrough,
24}
25
26/// Sort method for agent list
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum SortBy {
29    /// Sort by working directory (default)
30    #[default]
31    Directory,
32    /// Default order (session:window.pane)
33    SessionOrder,
34    /// Sort by agent type
35    AgentType,
36    /// Sort by status (attention needed first)
37    Status,
38    /// Sort by last update time
39    LastUpdate,
40    /// Sort by team
41    Team,
42    /// Sort by git repository (groups main + worktrees together)
43    Repository,
44}
45
46impl SortBy {
47    /// Get the next sort method in cycle
48    pub fn next(self) -> Self {
49        match self {
50            SortBy::Directory => SortBy::SessionOrder,
51            SortBy::SessionOrder => SortBy::AgentType,
52            SortBy::AgentType => SortBy::Status,
53            SortBy::Status => SortBy::LastUpdate,
54            SortBy::LastUpdate => SortBy::Team,
55            SortBy::Team => SortBy::Repository,
56            SortBy::Repository => SortBy::Directory,
57        }
58    }
59
60    /// Get display name for the sort method
61    pub fn display_name(&self) -> &'static str {
62        match self {
63            SortBy::Directory => "Directory",
64            SortBy::SessionOrder => "Session",
65            SortBy::AgentType => "Type",
66            SortBy::Status => "Status",
67            SortBy::LastUpdate => "Updated",
68            SortBy::Team => "Team",
69            SortBy::Repository => "Repository",
70        }
71    }
72}
73
74/// Monitor scope for filtering panes
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum MonitorScope {
77    /// Monitor all attached sessions
78    #[default]
79    AllSessions,
80    /// Monitor current session only
81    CurrentSession,
82    /// Monitor current window only
83    CurrentWindow,
84}
85
86impl MonitorScope {
87    /// Get the next scope in cycle
88    pub fn next(self) -> Self {
89        match self {
90            MonitorScope::AllSessions => MonitorScope::CurrentSession,
91            MonitorScope::CurrentSession => MonitorScope::CurrentWindow,
92            MonitorScope::CurrentWindow => MonitorScope::AllSessions,
93        }
94    }
95
96    /// Get display name for the scope
97    pub fn display_name(&self) -> &'static str {
98        match self {
99            MonitorScope::AllSessions => "All",
100            MonitorScope::CurrentSession => "Session",
101            MonitorScope::CurrentWindow => "Window",
102        }
103    }
104}
105
106/// Spinner frames for processing animation
107pub const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
108
109/// Marquee scroll interval in milliseconds
110const MARQUEE_INTERVAL_MS: u64 = 280;
111
112/// State for marquee text scrolling animation
113#[derive(Debug, Clone)]
114pub struct MarqueeState {
115    /// Current scroll offset (in characters)
116    pub offset: usize,
117    /// Last update timestamp
118    pub last_update: std::time::Instant,
119    /// ID of the selected item (to detect selection change)
120    pub selected_id: Option<String>,
121}
122
123impl Default for MarqueeState {
124    fn default() -> Self {
125        Self {
126            offset: 0,
127            last_update: std::time::Instant::now(),
128            selected_id: None,
129        }
130    }
131}
132
133/// Placement type for creating new AI process
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum PlacementType {
136    /// Create a new tmux session + window
137    NewSession,
138    /// Create a new window in existing session
139    NewWindow,
140    /// Split existing window to add a pane
141    SplitPane,
142}
143
144/// Tree entry for the tree-style target selection UI
145#[derive(Debug, Clone)]
146pub enum TreeEntry {
147    /// Create a new session
148    NewSession,
149    /// Session node (collapsible)
150    Session { name: String, collapsed: bool },
151    /// Create a new window in a session
152    NewWindow { session: String },
153    /// Window node (collapsible)
154    Window {
155        session: String,
156        index: u32,
157        name: String,
158        collapsed: bool,
159    },
160    /// Split a pane
161    SplitPane { target: String },
162}
163
164/// Action to confirm before executing
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum ConfirmAction {
167    /// Kill a tmux pane
168    KillPane { target: String },
169    /// Restart a non-IPC agent as PTY-wrapped (session ID already known)
170    RestartAsWrapped { target: String, session_id: String },
171    /// Send a probe marker to identify session, then restart as wrapped
172    ProbeAndRestartAsWrapped { target: String, cwd: String },
173}
174
175/// State for confirmation dialog
176#[derive(Debug, Clone)]
177pub struct ConfirmationState {
178    /// Action to execute on confirmation
179    pub action: ConfirmAction,
180    /// Message to display
181    pub message: String,
182}
183
184/// An item in the directory selection list
185#[derive(Debug, Clone)]
186pub enum DirItem {
187    /// Section header (not selectable, cursor skips)
188    Header(String),
189    /// "Enter path..." action
190    EnterPath,
191    /// Home directory
192    Home,
193    /// Current directory
194    Current,
195    /// A selectable directory with display name and full path
196    Directory { display: String, path: String },
197}
198
199impl DirItem {
200    /// Whether this item is selectable (non-header)
201    pub fn is_selectable(&self) -> bool {
202        !matches!(self, DirItem::Header(_))
203    }
204}
205
206/// Step in the create process flow
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum CreateProcessStep {
209    /// Select target from tree (combined placement + target selection)
210    SelectTarget,
211    /// Select directory
212    SelectDirectory,
213    /// Select AI agent type
214    SelectAgent,
215}
216
217/// State for the create process flow
218#[derive(Debug, Clone)]
219pub struct CreateProcessState {
220    /// Current step in the flow
221    pub step: CreateProcessStep,
222    /// Selected placement type
223    pub placement_type: Option<PlacementType>,
224    /// Group key that initiated the flow (directory path or session name)
225    pub origin_group_key: String,
226    /// Selected tmux session name (for NewWindow)
227    pub target_session: Option<String>,
228    /// Target pane to split (for SplitPane, session:window.pane format)
229    pub target_pane: Option<String>,
230    /// Selected directory path
231    pub directory: Option<String>,
232    /// Cursor position in the popup list
233    pub cursor: usize,
234    /// Input buffer for directory path entry
235    pub input_buffer: String,
236    /// Available panes list (cached)
237    pub available_panes: Vec<PaneInfo>,
238    /// Collapsed node keys (session name or "session:window_index")
239    pub collapsed_nodes: HashSet<String>,
240    /// Tree entries (cached, rebuilt when collapsed_nodes changes)
241    pub tree_entries: Vec<TreeEntry>,
242    /// Directory selection items (includes headers, pinned, base, known)
243    pub directory_items: Vec<DirItem>,
244    /// Whether in path input mode
245    pub is_input_mode: bool,
246}
247
248/// Snapshot of a team's state at a point in time
249#[derive(Debug, Clone)]
250pub struct TeamSnapshot {
251    /// Team configuration
252    pub config: TeamConfig,
253    /// Current tasks
254    pub tasks: Vec<TeamTask>,
255    /// Mapping of member_name → pane target
256    pub member_panes: HashMap<String, String>,
257    /// When this snapshot was last updated
258    pub last_scan: chrono::DateTime<chrono::Utc>,
259    /// Pre-computed completed task count
260    pub task_done: usize,
261    /// Pre-computed total task count
262    pub task_total: usize,
263    /// Pre-computed in-progress task count
264    pub task_in_progress: usize,
265    /// Pre-computed pending task count
266    pub task_pending: usize,
267    /// Worktree names used by this team's members
268    pub worktree_names: Vec<String>,
269}
270
271/// Input-related state
272#[derive(Debug, Default)]
273pub struct InputState {
274    /// Current input mode
275    pub mode: InputMode,
276    /// Input buffer for text entry
277    pub buffer: String,
278    /// Cursor position in input buffer (byte offset)
279    pub cursor_position: usize,
280}
281
282/// UI view state (overlays, scroll, animations)
283#[derive(Debug)]
284pub struct ViewState {
285    /// Whether help screen is shown
286    pub show_help: bool,
287    /// Help screen scroll offset
288    pub help_scroll: u16,
289    /// Whether QR code screen is shown
290    pub show_qr: bool,
291    /// Whether the team overview screen is shown
292    pub show_team_overview: bool,
293    /// Whether the task overlay is shown
294    pub show_task_overlay: bool,
295    /// Task overlay scroll offset
296    pub task_overlay_scroll: u16,
297    /// Team overview scroll offset
298    pub team_overview_scroll: u16,
299    /// Preview scroll offset
300    pub preview_scroll: u16,
301    /// Spinner animation frame counter
302    pub spinner_frame: usize,
303    /// Last spinner update time
304    pub last_spinner_update: std::time::Instant,
305    /// Marquee animation state for selected item
306    pub marquee_state: MarqueeState,
307}
308
309impl Default for ViewState {
310    fn default() -> Self {
311        Self {
312            show_help: false,
313            help_scroll: 0,
314            show_qr: false,
315            show_team_overview: false,
316            show_task_overlay: false,
317            task_overlay_scroll: 0,
318            team_overview_scroll: 0,
319            preview_scroll: 0,
320            spinner_frame: 0,
321            last_spinner_update: std::time::Instant::now(),
322            marquee_state: MarqueeState::default(),
323        }
324    }
325}
326
327/// Selection/navigation state
328#[derive(Debug, Default)]
329pub struct SelectionState {
330    /// Currently selected agent index
331    pub selected_index: usize,
332    /// Selected entry index (for UI navigation including CreateNew entries)
333    pub selected_entry_index: usize,
334    /// Total selectable entries count (cached)
335    pub selectable_count: usize,
336    /// Whether CreateNew entry is currently selected
337    pub is_on_create_new: bool,
338    /// Collapsed group keys (for group header folding)
339    pub collapsed_groups: HashSet<String>,
340}
341
342/// Web-related state
343#[derive(Debug, Default)]
344pub struct WebState {
345    /// Web server authentication token
346    pub token: Option<String>,
347    /// Web server port
348    pub port: u16,
349}
350
351/// Application state
352#[derive(Debug)]
353pub struct AppState {
354    // Sub-states
355    /// Input-related state
356    pub input: InputState,
357    /// UI view state (overlays, scroll, animations)
358    pub view: ViewState,
359    /// Selection/navigation state
360    pub selection: SelectionState,
361    /// Web-related state
362    pub web: WebState,
363
364    // Core domain (unchanged)
365    /// All monitored agents by target ID
366    pub agents: HashMap<String, MonitoredAgent>,
367    /// Order of agents for display
368    pub agent_order: Vec<String>,
369    /// Current sort method
370    pub sort_by: SortBy,
371    /// Monitor scope for filtering panes
372    pub monitor_scope: MonitorScope,
373    /// Current session name (for scope display)
374    pub current_session: Option<String>,
375    /// Current window index (for scope display)
376    pub current_window: Option<u32>,
377    /// Team snapshots by team name
378    pub teams: HashMap<String, TeamSnapshot>,
379    /// Agent definitions from `.claude/agents/*.md`
380    #[allow(dead_code)]
381    pub agent_definitions: Vec<AgentDefinition>,
382    /// Mapping of tmux target (e.g. "main:0.1") to pane_id (e.g. "5") for IPC
383    pub target_to_pane_id: HashMap<String, String>,
384
385    // Dialog/mode state (unchanged)
386    /// Create process flow state (None if not in create mode)
387    pub create_process: Option<CreateProcessState>,
388    /// Confirmation dialog state (None if not showing)
389    pub confirmation_state: Option<ConfirmationState>,
390    /// Error message to display
391    pub error_message: Option<String>,
392    /// Last poll timestamp
393    pub last_poll: Option<chrono::DateTime<chrono::Utc>>,
394    /// Whether the app is running
395    pub running: bool,
396
397    /// Show activity name (tool/verb) during Processing instead of generic "Processing"
398    pub show_activity_name: bool,
399
400    /// Temporary notification message (auto-expires)
401    pub notification: Option<(String, std::time::Instant)>,
402
403    /// Claude Code subscription usage snapshot
404    pub usage: UsageSnapshot,
405}
406
407impl AppState {
408    /// Create a new application state
409    pub fn new() -> Self {
410        Self {
411            input: InputState::default(),
412            view: ViewState::default(),
413            selection: SelectionState::default(),
414            web: WebState {
415                token: None,
416                port: 9876,
417            },
418            agents: HashMap::new(),
419            agent_order: Vec::new(),
420            sort_by: SortBy::Directory,
421            monitor_scope: MonitorScope::default(),
422            current_session: None,
423            current_window: None,
424            teams: HashMap::new(),
425            agent_definitions: Vec::new(),
426            target_to_pane_id: HashMap::new(),
427            create_process: None,
428            confirmation_state: None,
429            error_message: None,
430            last_poll: None,
431            running: true,
432            show_activity_name: true,
433            notification: None,
434            usage: UsageSnapshot::default(),
435        }
436    }
437
438    /// Advance the spinner animation frame (time-based, ~150ms per frame)
439    pub fn tick_spinner(&mut self) {
440        let elapsed = self.view.last_spinner_update.elapsed();
441        if elapsed.as_millis() >= 150 {
442            self.view.last_spinner_update = std::time::Instant::now();
443            self.view.spinner_frame = (self.view.spinner_frame + 1) % SPINNER_FRAMES.len();
444        }
445    }
446
447    /// Get the current spinner character
448    pub fn spinner_char(&self) -> char {
449        SPINNER_FRAMES[self.view.spinner_frame]
450    }
451
452    /// Advance the marquee scroll offset (time-based)
453    pub fn tick_marquee(&mut self) {
454        let elapsed = self.view.marquee_state.last_update.elapsed();
455        if elapsed.as_millis() >= MARQUEE_INTERVAL_MS as u128 {
456            self.view.marquee_state.last_update = std::time::Instant::now();
457            self.view.marquee_state.offset += 1;
458        }
459    }
460
461    /// Reset marquee state when selection changes
462    pub fn reset_marquee(&mut self, new_id: Option<String>) {
463        if self.view.marquee_state.selected_id != new_id {
464            self.view.marquee_state.offset = 0;
465            self.view.marquee_state.selected_id = new_id;
466            self.view.marquee_state.last_update = std::time::Instant::now();
467        }
468    }
469
470    /// Get the current marquee scroll offset
471    pub fn marquee_offset(&self) -> usize {
472        self.view.marquee_state.offset
473    }
474
475    /// Create a shared state
476    pub fn shared() -> SharedState {
477        Arc::new(RwLock::new(Self::new()))
478    }
479
480    /// Set a temporary notification message (auto-expires after 3 seconds)
481    pub fn set_notification(&mut self, message: String) {
482        self.notification = Some((message, std::time::Instant::now()));
483    }
484
485    /// Get the current notification message if it hasn't expired (3 second TTL)
486    pub fn active_notification(&self) -> Option<&str> {
487        self.notification.as_ref().and_then(|(msg, ts)| {
488            if ts.elapsed().as_secs() < 3 {
489                Some(msg.as_str())
490            } else {
491                None
492            }
493        })
494    }
495
496    /// Get the currently selected agent
497    pub fn selected_agent(&self) -> Option<&MonitoredAgent> {
498        self.agent_order
499            .get(self.selection.selected_index)
500            .and_then(|id| self.agents.get(id))
501    }
502
503    /// Get a mutable reference to the selected agent
504    pub fn selected_agent_mut(&mut self) -> Option<&mut MonitoredAgent> {
505        if let Some(id) = self.agent_order.get(self.selection.selected_index).cloned() {
506            self.agents.get_mut(&id)
507        } else {
508            None
509        }
510    }
511
512    /// Get the selected agent's target ID
513    pub fn selected_target(&self) -> Option<&str> {
514        self.agent_order
515            .get(self.selection.selected_index)
516            .map(|s| s.as_str())
517    }
518
519    /// Update agents from a new list
520    pub fn update_agents(&mut self, agents: Vec<MonitoredAgent>) {
521        // Use HashSet for O(1) lookup instead of Vec::contains O(n)
522        let new_ids: HashSet<String> = agents.iter().map(|a| a.id.clone()).collect();
523        // Also collect as Vec for agent_order (preserves input order)
524        let new_order: Vec<String> = agents.iter().map(|a| a.id.clone()).collect();
525
526        // Remove agents that no longer exist (O(n) instead of O(n²))
527        self.agents.retain(|id, _| new_ids.contains(id));
528
529        // Update or add new agents
530        for agent in agents {
531            let id = agent.id.clone();
532            if let Some(existing) = self.agents.get_mut(&id) {
533                // Update status and content
534                existing.status = agent.status;
535                existing.last_content = agent.last_content;
536                existing.last_content_ansi = agent.last_content_ansi;
537                existing.title = agent.title;
538                existing.last_update = agent.last_update;
539                existing.context_warning = agent.context_warning;
540                // Update meta information
541                existing.cwd = agent.cwd;
542                existing.pid = agent.pid;
543                existing.session = agent.session;
544                existing.window_name = agent.window_name;
545                existing.window_index = agent.window_index;
546                existing.pane_index = agent.pane_index;
547                existing.team_info = agent.team_info;
548                existing.is_virtual = agent.is_virtual;
549                existing.detection_source = agent.detection_source;
550                // Git info (set by poller's update_git_info / apply_cached_git_info)
551                existing.git_branch = agent.git_branch;
552                existing.git_dirty = agent.git_dirty;
553                existing.is_worktree = agent.is_worktree;
554                existing.git_common_dir = agent.git_common_dir;
555                existing.worktree_name = agent.worktree_name;
556                // Preserve auto_approve_phase from service, but clear it when
557                // agent is no longer awaiting approval (state has transitioned)
558                if !matches!(
559                    existing.status,
560                    crate::agents::AgentStatus::AwaitingApproval { .. }
561                ) {
562                    existing.auto_approve_phase = None;
563                }
564            } else {
565                self.agents.insert(id.clone(), agent);
566            }
567        }
568
569        // Update order, preserving selection if possible
570        let old_selected = self.selected_target().map(|s| s.to_string());
571        self.agent_order = new_order;
572
573        // Apply current sort
574        self.sort_agents();
575
576        // Try to preserve selection
577        if let Some(old_id) = old_selected {
578            if let Some(new_index) = self.agent_order.iter().position(|id| id == &old_id) {
579                self.selection.selected_index = new_index;
580            }
581        }
582
583        // Ensure selection is valid
584        if self.selection.selected_index >= self.agent_order.len() && !self.agent_order.is_empty() {
585            self.selection.selected_index = self.agent_order.len() - 1;
586        }
587
588        self.last_poll = Some(chrono::Utc::now());
589    }
590
591    /// Cycle through sort methods
592    pub fn cycle_sort(&mut self) {
593        self.sort_by = self.sort_by.next();
594        self.sort_agents();
595    }
596
597    /// Cycle through monitor scopes
598    pub fn cycle_monitor_scope(&mut self) {
599        self.monitor_scope = self.monitor_scope.next();
600    }
601
602    /// Sort agent_order based on current sort_by setting
603    fn sort_agents(&mut self) {
604        let agents = &self.agents;
605        self.agent_order.sort_by(|a, b| {
606            let agent_a = agents.get(a);
607            let agent_b = agents.get(b);
608
609            match (agent_a, agent_b) {
610                (Some(a), Some(b)) => match self.sort_by {
611                    SortBy::Directory => {
612                        // Sort by cwd, then by id
613                        a.cwd.cmp(&b.cwd).then_with(|| a.id.cmp(&b.id))
614                    }
615                    SortBy::SessionOrder => {
616                        // session:window.pane order
617                        a.id.cmp(&b.id)
618                    }
619                    SortBy::AgentType => {
620                        // Sort by agent type name, then by id
621                        a.agent_type
622                            .short_name()
623                            .cmp(b.agent_type.short_name())
624                            .then_with(|| a.id.cmp(&b.id))
625                    }
626                    SortBy::Status => {
627                        // Sort by status priority (attention needed first)
628                        let priority_a = Self::status_priority(&a.status);
629                        let priority_b = Self::status_priority(&b.status);
630                        priority_a.cmp(&priority_b).then_with(|| a.id.cmp(&b.id))
631                    }
632                    SortBy::LastUpdate => {
633                        // Sort by last update (most recent first)
634                        b.last_update
635                            .cmp(&a.last_update)
636                            .then_with(|| a.id.cmp(&b.id))
637                    }
638                    SortBy::Team => {
639                        // Sort by team name (no-team agents last), then by member name
640                        let team_a = a
641                            .team_info
642                            .as_ref()
643                            .map(|t| t.team_name.as_str())
644                            .unwrap_or("\u{ffff}"); // Sort no-team last
645                        let team_b = b
646                            .team_info
647                            .as_ref()
648                            .map(|t| t.team_name.as_str())
649                            .unwrap_or("\u{ffff}");
650                        team_a.cmp(team_b).then_with(|| a.id.cmp(&b.id))
651                    }
652                    SortBy::Repository => {
653                        // Sort by git common_dir (groups main + worktrees together)
654                        // Non-git agents fall back to cwd
655                        let key_a = a.git_common_dir.as_deref().unwrap_or(&a.cwd);
656                        let key_b = b.git_common_dir.as_deref().unwrap_or(&b.cwd);
657                        key_a.cmp(key_b).then_with(|| {
658                            // Within same repo: main first, then worktrees alphabetically
659                            let wt_a = a.is_worktree.unwrap_or(false);
660                            let wt_b = b.is_worktree.unwrap_or(false);
661                            wt_a.cmp(&wt_b).then_with(|| a.cwd.cmp(&b.cwd))
662                        })
663                    }
664                },
665                (Some(_), None) => std::cmp::Ordering::Less,
666                (None, Some(_)) => std::cmp::Ordering::Greater,
667                (None, None) => std::cmp::Ordering::Equal,
668            }
669        });
670
671        // Post-sort: nest team members under their leader
672        self.nest_team_members();
673    }
674
675    /// Reorder agent_order so team members appear directly after their leader
676    fn nest_team_members(&mut self) {
677        let agents = &self.agents;
678
679        // Collect team info: team_name → (leader_id, [(member_name, member_id)])
680        let mut team_leaders: HashMap<String, String> = HashMap::new();
681        let mut team_members: HashMap<String, Vec<(String, String)>> = HashMap::new();
682
683        for id in &self.agent_order {
684            if let Some(agent) = agents.get(id) {
685                if let Some(ref ti) = agent.team_info {
686                    if ti.is_lead {
687                        team_leaders.insert(ti.team_name.clone(), id.clone());
688                    } else {
689                        team_members
690                            .entry(ti.team_name.clone())
691                            .or_default()
692                            .push((ti.member_name.clone(), id.clone()));
693                    }
694                }
695            }
696        }
697
698        // If no teams found, skip
699        if team_leaders.is_empty() && team_members.is_empty() {
700            return;
701        }
702
703        // For teams without a detected leader, use the first member as implicit leader
704        for (team_name, members) in &team_members {
705            if !team_leaders.contains_key(team_name) {
706                if let Some((_, first_member_id)) = members.first() {
707                    team_leaders.insert(team_name.clone(), first_member_id.clone());
708                }
709            }
710        }
711
712        // Remove implicit leaders from team_members so they don't get skipped
713        for (team_name, leader_id) in &team_leaders {
714            if let Some(members) = team_members.get_mut(team_name) {
715                members.retain(|(_, id)| id != leader_id);
716            }
717        }
718
719        // Sort members by name for stable ordering
720        for members in team_members.values_mut() {
721            members.sort_by(|a, b| a.0.cmp(&b.0));
722        }
723
724        // Build new order: for each item, if it's a leader, insert its members right after
725        let member_ids: std::collections::HashSet<String> = team_members
726            .values()
727            .flatten()
728            .map(|(_, id)| id.clone())
729            .collect();
730
731        let mut new_order = Vec::with_capacity(self.agent_order.len());
732        for id in &self.agent_order {
733            // Skip members here (they'll be inserted after their leader)
734            if member_ids.contains(id) {
735                continue;
736            }
737
738            new_order.push(id.clone());
739
740            // If this is a leader, insert members after it
741            if let Some(agent) = agents.get(id) {
742                if let Some(ref ti) = agent.team_info {
743                    if let Some(members) = team_members.get(&ti.team_name) {
744                        if team_leaders.get(&ti.team_name) == Some(id) {
745                            for (_, member_id) in members {
746                                // Don't add the leader again if it happens to be in members list
747                                if member_id != id {
748                                    new_order.push(member_id.clone());
749                                }
750                            }
751                        }
752                    }
753                }
754            }
755        }
756
757        self.agent_order = new_order;
758    }
759
760    /// Get priority for status sorting (lower = higher priority)
761    fn status_priority(status: &crate::agents::AgentStatus) -> u8 {
762        match status {
763            crate::agents::AgentStatus::AwaitingApproval { .. } => 0, // Highest priority
764            crate::agents::AgentStatus::Error { .. } => 1,
765            crate::agents::AgentStatus::Processing { .. } => 2,
766            crate::agents::AgentStatus::Idle => 3,
767            crate::agents::AgentStatus::Offline => 4,
768            crate::agents::AgentStatus::Unknown => 5,
769        }
770    }
771
772    /// Get the current group key for an agent (for display headers)
773    pub fn get_group_key(&self, agent: &MonitoredAgent) -> Option<String> {
774        match self.sort_by {
775            SortBy::Directory => Some(agent.display_cwd()),
776            SortBy::SessionOrder => Some(agent.session.clone()),
777            SortBy::AgentType => Some(agent.agent_type.short_name().to_string()),
778            SortBy::Team => Some(
779                agent
780                    .team_info
781                    .as_ref()
782                    .map(|t| format!("Team: {}", t.team_name))
783                    .unwrap_or_else(|| "(No Team)".to_string()),
784            ),
785            SortBy::Repository => Some(
786                agent
787                    .git_common_dir
788                    .as_deref()
789                    .map(crate::git::repo_name_from_common_dir)
790                    .unwrap_or_else(|| agent.display_cwd()),
791            ),
792            _ => None,
793        }
794    }
795
796    /// Toggle collapse state for a group
797    pub fn toggle_group_collapse(&mut self, group_key: &str) {
798        if self.selection.collapsed_groups.contains(group_key) {
799            self.selection.collapsed_groups.remove(group_key);
800        } else {
801            self.selection
802                .collapsed_groups
803                .insert(group_key.to_string());
804        }
805    }
806
807    /// Check if a group is collapsed
808    pub fn is_group_collapsed(&self, group_key: &str) -> bool {
809        self.selection.collapsed_groups.contains(group_key)
810    }
811
812    /// Move selection up
813    pub fn select_previous(&mut self) {
814        if self.selection.selected_entry_index > 0 {
815            self.selection.selected_entry_index -= 1;
816            self.view.preview_scroll = 0;
817            self.sync_selected_index_from_entry();
818            self.reset_marquee_for_selection();
819        }
820    }
821
822    /// Move selection down
823    pub fn select_next(&mut self) {
824        if self.selection.selectable_count > 0
825            && self.selection.selected_entry_index < self.selection.selectable_count - 1
826        {
827            self.selection.selected_entry_index += 1;
828            self.view.preview_scroll = 0;
829            self.sync_selected_index_from_entry();
830            self.reset_marquee_for_selection();
831        }
832    }
833
834    /// Select first entry
835    pub fn select_first(&mut self) {
836        if self.selection.selectable_count > 0 {
837            self.selection.selected_entry_index = 0;
838            self.view.preview_scroll = 0;
839            self.sync_selected_index_from_entry();
840            self.reset_marquee_for_selection();
841        }
842    }
843
844    /// Select last entry
845    pub fn select_last(&mut self) {
846        if self.selection.selectable_count > 0 {
847            self.selection.selected_entry_index = self.selection.selectable_count - 1;
848            self.view.preview_scroll = 0;
849            self.sync_selected_index_from_entry();
850            self.reset_marquee_for_selection();
851        }
852    }
853
854    /// Reset marquee state based on current selection
855    fn reset_marquee_for_selection(&mut self) {
856        let new_id = self.selected_target().map(|s| s.to_string());
857        self.reset_marquee(new_id);
858    }
859
860    /// Sync selected_index from selected_entry_index
861    /// This maps the entry index back to agent_order index for preview display
862    fn sync_selected_index_from_entry(&mut self) {
863        // This will be properly synced when build_entries is called during render
864        // For now, just ensure selected_index stays valid
865        if !self.agent_order.is_empty() && self.selection.selected_index >= self.agent_order.len() {
866            self.selection.selected_index = self.agent_order.len() - 1;
867        }
868    }
869
870    /// Update selectable count and sync entry index
871    pub fn update_selectable_entries(
872        &mut self,
873        selectable_count: usize,
874        agent_index: Option<usize>,
875    ) {
876        self.selection.selectable_count = selectable_count;
877        self.selection.is_on_create_new = agent_index.is_none();
878        if let Some(idx) = agent_index {
879            self.selection.selected_index = idx;
880        }
881        // Ensure entry index is valid
882        if self.selection.selected_entry_index >= selectable_count && selectable_count > 0 {
883            self.selection.selected_entry_index = selectable_count - 1;
884        }
885    }
886
887    /// Get all unique directories from current agents
888    pub fn get_known_directories(&self) -> Vec<String> {
889        let mut dirs: Vec<String> = self.agents.values().map(|a| a.cwd.clone()).collect();
890        dirs.sort();
891        dirs.dedup();
892        dirs
893    }
894
895    /// Toggle help screen
896    pub fn toggle_help(&mut self) {
897        self.view.show_help = !self.view.show_help;
898        if self.view.show_help {
899            self.view.help_scroll = 0;
900        }
901    }
902
903    /// Toggle QR code screen
904    pub fn toggle_qr(&mut self) {
905        self.view.show_qr = !self.view.show_qr;
906    }
907
908    /// Initialize web settings
909    pub fn init_web(&mut self, token: String, port: u16) {
910        self.web.token = Some(token);
911        self.web.port = port;
912    }
913
914    /// Get web URL for QR code
915    ///
916    /// In WSL environments, returns Windows host IP instead of WSL internal IP,
917    /// since external devices (phones) cannot access WSL's internal network directly.
918    pub fn get_web_url(&self) -> Option<String> {
919        let token = self.web.token.as_ref()?;
920
921        // Try to get Windows host IP if running in WSL
922        if let Some(host_ip) = get_wsl_host_ip() {
923            return Some(format!(
924                "http://{}:{}/?token={}",
925                host_ip, self.web.port, token
926            ));
927        }
928
929        // Fall back to local IP detection
930        if let Ok(ip) = local_ip_address::local_ip() {
931            Some(format!("http://{}:{}/?token={}", ip, self.web.port, token))
932        } else {
933            Some(format!(
934                "http://localhost:{}/?token={}",
935                self.web.port, token
936            ))
937        }
938    }
939
940    /// Scroll help screen down
941    pub fn scroll_help_down(&mut self, amount: u16) {
942        self.view.help_scroll = self.view.help_scroll.saturating_add(amount);
943    }
944
945    /// Scroll help screen up
946    pub fn scroll_help_up(&mut self, amount: u16) {
947        self.view.help_scroll = self.view.help_scroll.saturating_sub(amount);
948    }
949
950    /// Scroll preview down
951    pub fn scroll_preview_down(&mut self, amount: u16) {
952        self.view.preview_scroll = self.view.preview_scroll.saturating_add(amount);
953    }
954
955    /// Scroll preview up
956    pub fn scroll_preview_up(&mut self, amount: u16) {
957        self.view.preview_scroll = self.view.preview_scroll.saturating_sub(amount);
958    }
959
960    /// Get agents that need attention (awaiting approval or error)
961    pub fn agents_needing_attention(&self) -> Vec<&MonitoredAgent> {
962        self.agent_order
963            .iter()
964            .filter_map(|id| self.agents.get(id))
965            .filter(|a| a.status.needs_attention())
966            .collect()
967    }
968
969    /// Get count of agents needing attention
970    pub fn attention_count(&self) -> usize {
971        self.agents_needing_attention().len()
972    }
973
974    /// Set error message
975    pub fn set_error(&mut self, message: String) {
976        self.error_message = Some(message);
977    }
978
979    /// Clear error message
980    pub fn clear_error(&mut self) {
981        self.error_message = None;
982    }
983
984    /// Stop the application
985    pub fn quit(&mut self) {
986        self.running = false;
987    }
988
989    // =========================================
990    // Input mode methods
991    // =========================================
992
993    /// Enter input mode
994    pub fn enter_input_mode(&mut self) {
995        self.input.mode = InputMode::Input;
996    }
997
998    /// Enter passthrough mode
999    pub fn enter_passthrough_mode(&mut self) {
1000        self.input.mode = InputMode::Passthrough;
1001    }
1002
1003    /// Exit input mode and clear buffer
1004    pub fn exit_input_mode(&mut self) {
1005        self.input.mode = InputMode::Normal;
1006        self.input.buffer.clear();
1007        self.input.cursor_position = 0;
1008    }
1009
1010    /// Check if in input mode
1011    pub fn is_input_mode(&self) -> bool {
1012        self.input.mode == InputMode::Input
1013    }
1014
1015    /// Check if in passthrough mode
1016    pub fn is_passthrough_mode(&self) -> bool {
1017        self.input.mode == InputMode::Passthrough
1018    }
1019
1020    /// Get the input buffer
1021    pub fn get_input(&self) -> &str {
1022        &self.input.buffer
1023    }
1024
1025    /// Get cursor position
1026    pub fn get_cursor_position(&self) -> usize {
1027        self.input.cursor_position
1028    }
1029
1030    /// Insert a character at cursor position
1031    pub fn input_char(&mut self, c: char) {
1032        self.input.buffer.insert(self.input.cursor_position, c);
1033        self.input.cursor_position += c.len_utf8();
1034    }
1035
1036    /// Delete character before cursor (backspace)
1037    pub fn input_backspace(&mut self) {
1038        if self.input.cursor_position > 0 {
1039            // Find the previous character boundary
1040            let prev_char_boundary = self.input.buffer[..self.input.cursor_position]
1041                .char_indices()
1042                .last()
1043                .map(|(i, _)| i)
1044                .unwrap_or(0);
1045            self.input.buffer.remove(prev_char_boundary);
1046            self.input.cursor_position = prev_char_boundary;
1047        }
1048    }
1049
1050    /// Delete character at cursor (delete key)
1051    pub fn input_delete(&mut self) {
1052        if self.input.cursor_position < self.input.buffer.len() {
1053            self.input.buffer.remove(self.input.cursor_position);
1054        }
1055    }
1056
1057    /// Move cursor left
1058    pub fn cursor_left(&mut self) {
1059        if self.input.cursor_position > 0 {
1060            // Find the previous character boundary
1061            self.input.cursor_position = self.input.buffer[..self.input.cursor_position]
1062                .char_indices()
1063                .last()
1064                .map(|(i, _)| i)
1065                .unwrap_or(0);
1066        }
1067    }
1068
1069    /// Move cursor right
1070    pub fn cursor_right(&mut self) {
1071        if self.input.cursor_position < self.input.buffer.len() {
1072            // Find the next character boundary
1073            if let Some(c) = self.input.buffer[self.input.cursor_position..]
1074                .chars()
1075                .next()
1076            {
1077                self.input.cursor_position += c.len_utf8();
1078            }
1079        }
1080    }
1081
1082    /// Move cursor to start
1083    pub fn cursor_home(&mut self) {
1084        self.input.cursor_position = 0;
1085    }
1086
1087    /// Move cursor to end
1088    pub fn cursor_end(&mut self) {
1089        self.input.cursor_position = self.input.buffer.len();
1090    }
1091
1092    /// Take the input buffer content and clear it
1093    pub fn take_input(&mut self) -> String {
1094        let input = std::mem::take(&mut self.input.buffer);
1095        self.input.cursor_position = 0;
1096        input
1097    }
1098
1099    // =========================================
1100    // Create process methods
1101    // =========================================
1102
1103    /// Start create process flow from a group
1104    pub fn start_create_process(
1105        &mut self,
1106        group_key: String,
1107        panes: Vec<PaneInfo>,
1108        config: &CreateProcessSettings,
1109    ) {
1110        // Get known directories from current agents
1111        let known_directories = self.get_known_directories();
1112
1113        // Build directory items from config + known dirs
1114        let directory_items = build_directory_items(config, known_directories);
1115
1116        // Pre-select directory if sorted by Directory
1117        let directory = if self.sort_by == SortBy::Directory {
1118            Some(group_key.clone())
1119        } else {
1120            None
1121        };
1122
1123        // Pre-select session if sorted by SessionOrder
1124        let target_session = if self.sort_by == SortBy::SessionOrder {
1125            Some(group_key.clone())
1126        } else {
1127            None
1128        };
1129
1130        // Get currently selected pane target for SplitPane
1131        let target_pane = self.selected_target().map(|s| s.to_string());
1132
1133        // Initialize collapsed_nodes: sessions expanded, windows collapsed
1134        let mut collapsed_nodes = HashSet::new();
1135        for pane in &panes {
1136            // Collapse all windows by default
1137            let window_key = format!("{}:{}", pane.session, pane.window_index);
1138            collapsed_nodes.insert(window_key);
1139        }
1140
1141        // Build tree entries
1142        let tree_entries = Self::build_tree_entries(&panes, &collapsed_nodes);
1143
1144        self.create_process = Some(CreateProcessState {
1145            step: CreateProcessStep::SelectTarget,
1146            placement_type: None,
1147            origin_group_key: group_key,
1148            target_session,
1149            target_pane,
1150            directory,
1151            cursor: 0,
1152            input_buffer: String::new(),
1153            available_panes: panes,
1154            collapsed_nodes,
1155            tree_entries,
1156            directory_items,
1157            is_input_mode: false,
1158        });
1159    }
1160
1161    /// Build tree entries from panes and collapsed state
1162    fn build_tree_entries(panes: &[PaneInfo], collapsed_nodes: &HashSet<String>) -> Vec<TreeEntry> {
1163        let mut entries = Vec::new();
1164
1165        // Group panes by session, then by window
1166        let mut sessions: Vec<String> = panes.iter().map(|p| p.session.clone()).collect();
1167        sessions.sort();
1168        sessions.dedup();
1169
1170        // Existing sessions/windows/panes first (current location options)
1171        for session in &sessions {
1172            let session_collapsed = collapsed_nodes.contains(session);
1173            entries.push(TreeEntry::Session {
1174                name: session.clone(),
1175                collapsed: session_collapsed,
1176            });
1177
1178            if !session_collapsed {
1179                // Collect windows in this session
1180                let mut windows: Vec<(u32, String)> = panes
1181                    .iter()
1182                    .filter(|p| &p.session == session)
1183                    .map(|p| (p.window_index, p.window_name.clone()))
1184                    .collect();
1185                windows.sort_by_key(|(idx, _)| *idx);
1186                windows.dedup_by_key(|(idx, _)| *idx);
1187
1188                for (window_index, window_name) in windows {
1189                    let window_key = format!("{}:{}", session, window_index);
1190                    let window_collapsed = collapsed_nodes.contains(&window_key);
1191
1192                    entries.push(TreeEntry::Window {
1193                        session: session.clone(),
1194                        index: window_index,
1195                        name: window_name,
1196                        collapsed: window_collapsed,
1197                    });
1198
1199                    if !window_collapsed {
1200                        // Add panes under this window
1201                        let window_panes: Vec<&PaneInfo> = panes
1202                            .iter()
1203                            .filter(|p| &p.session == session && p.window_index == window_index)
1204                            .collect();
1205
1206                        for pane in window_panes {
1207                            entries.push(TreeEntry::SplitPane {
1208                                target: pane.target.clone(),
1209                            });
1210                        }
1211                    }
1212                }
1213
1214                // Add "New Window" at the bottom of the session
1215                entries.push(TreeEntry::NewWindow {
1216                    session: session.clone(),
1217                });
1218            }
1219        }
1220
1221        // Add "New Session" at the bottom
1222        entries.push(TreeEntry::NewSession);
1223
1224        entries
1225    }
1226
1227    /// Toggle a node's collapsed state in the create process tree
1228    pub fn toggle_tree_node(&mut self, key: &str) {
1229        if let Some(ref mut cs) = self.create_process {
1230            if cs.collapsed_nodes.contains(key) {
1231                cs.collapsed_nodes.remove(key);
1232            } else {
1233                cs.collapsed_nodes.insert(key.to_string());
1234            }
1235            // Rebuild tree entries
1236            cs.tree_entries = Self::build_tree_entries(&cs.available_panes, &cs.collapsed_nodes);
1237        }
1238    }
1239
1240    /// Cancel create process flow
1241    pub fn cancel_create_process(&mut self) {
1242        self.create_process = None;
1243    }
1244
1245    /// Check if in create process mode
1246    pub fn is_create_process_mode(&self) -> bool {
1247        self.create_process.is_some()
1248    }
1249
1250    // =========================================
1251    // Confirmation dialog methods
1252    // =========================================
1253
1254    /// Show a confirmation dialog
1255    pub fn show_confirmation(&mut self, action: ConfirmAction, message: String) {
1256        self.confirmation_state = Some(ConfirmationState { action, message });
1257    }
1258
1259    /// Cancel the confirmation dialog
1260    pub fn cancel_confirmation(&mut self) {
1261        self.confirmation_state = None;
1262    }
1263
1264    /// Check if confirmation dialog is showing
1265    pub fn is_showing_confirmation(&self) -> bool {
1266        self.confirmation_state.is_some()
1267    }
1268
1269    /// Get the confirmation action (for execution)
1270    pub fn get_confirmation_action(&self) -> Option<ConfirmAction> {
1271        self.confirmation_state.as_ref().map(|s| s.action.clone())
1272    }
1273
1274    /// Move cursor up in create process popup (skips headers in directory step)
1275    pub fn create_process_cursor_up(&mut self) {
1276        if let Some(ref mut state) = self.create_process {
1277            if state.cursor > 0 {
1278                state.cursor -= 1;
1279                // Skip headers when in directory selection step
1280                if state.step == CreateProcessStep::SelectDirectory {
1281                    let len = state.directory_items.len();
1282                    if len == 0 {
1283                        state.cursor = 0;
1284                        return;
1285                    }
1286                    while state.cursor > 0 && !state.directory_items[state.cursor].is_selectable() {
1287                        state.cursor -= 1;
1288                    }
1289                    // If we landed on a header at position 0, move forward
1290                    if !state.directory_items[state.cursor].is_selectable() {
1291                        while state.cursor < len
1292                            && !state.directory_items[state.cursor].is_selectable()
1293                        {
1294                            state.cursor += 1;
1295                        }
1296                    }
1297                }
1298            }
1299        }
1300    }
1301
1302    /// Move cursor down in create process popup (skips headers in directory step)
1303    pub fn create_process_cursor_down(&mut self, max: usize) {
1304        if let Some(ref mut state) = self.create_process {
1305            if state.cursor < max.saturating_sub(1) {
1306                state.cursor += 1;
1307                // Skip headers when in directory selection step
1308                if state.step == CreateProcessStep::SelectDirectory {
1309                    let len = state.directory_items.len();
1310                    while state.cursor < len && !state.directory_items[state.cursor].is_selectable()
1311                    {
1312                        state.cursor += 1;
1313                    }
1314                    // Clamp to last valid item
1315                    if state.cursor >= len {
1316                        // Find last selectable item
1317                        state.cursor = len.saturating_sub(1);
1318                        while state.cursor > 0
1319                            && !state.directory_items[state.cursor].is_selectable()
1320                        {
1321                            state.cursor -= 1;
1322                        }
1323                    }
1324                }
1325            }
1326        }
1327    }
1328
1329    /// Get create process cursor position
1330    pub fn create_process_cursor(&self) -> usize {
1331        self.create_process.as_ref().map(|s| s.cursor).unwrap_or(0)
1332    }
1333}
1334
1335impl Default for AppState {
1336    fn default() -> Self {
1337        Self::new()
1338    }
1339}
1340
1341/// Expand `~` prefix to the user's home directory
1342fn expand_tilde(path: &str) -> String {
1343    if path.starts_with("~/") || path == "~" {
1344        if let Some(home) = dirs::home_dir() {
1345            return path.replacen('~', &home.to_string_lossy(), 1);
1346        }
1347    }
1348    path.to_string()
1349}
1350
1351/// Build the directory selection items list from config and known directories
1352fn build_directory_items(
1353    config: &CreateProcessSettings,
1354    known_directories: Vec<String>,
1355) -> Vec<DirItem> {
1356    let mut items = vec![DirItem::EnterPath, DirItem::Home, DirItem::Current];
1357
1358    // Pinned directories
1359    if !config.pinned.is_empty() {
1360        let mut pinned_items: Vec<DirItem> = Vec::new();
1361        for dir in &config.pinned {
1362            let expanded = expand_tilde(dir);
1363            if std::path::Path::new(&expanded).is_dir() {
1364                pinned_items.push(DirItem::Directory {
1365                    display: dir.to_string(),
1366                    path: expanded,
1367                });
1368            }
1369        }
1370        if !pinned_items.is_empty() {
1371            items.push(DirItem::Header("Pinned".to_string()));
1372            items.extend(pinned_items);
1373        }
1374    }
1375
1376    // Base directories (scan subdirectories)
1377    for base in &config.base_directories {
1378        let expanded = expand_tilde(base);
1379        if let Ok(entries) = std::fs::read_dir(&expanded) {
1380            let mut subdirs: Vec<(String, String)> = entries
1381                .filter_map(|e| e.ok())
1382                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
1383                .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
1384                .map(|e| {
1385                    let name = e.file_name().to_string_lossy().to_string();
1386                    let path = e.path().to_string_lossy().to_string();
1387                    (name, path)
1388                })
1389                .collect();
1390            subdirs.sort_by(|a, b| a.0.cmp(&b.0));
1391
1392            if !subdirs.is_empty() {
1393                items.push(DirItem::Header(base.to_string()));
1394                for (name, path) in subdirs {
1395                    items.push(DirItem::Directory {
1396                        display: name,
1397                        path,
1398                    });
1399                }
1400            }
1401        }
1402    }
1403
1404    // Known directories (from running agents), excluding already-listed paths
1405    let existing_paths: HashSet<String> = items
1406        .iter()
1407        .filter_map(|item| match item {
1408            DirItem::Directory { path, .. } => Some(path.clone()),
1409            _ => None,
1410        })
1411        .collect();
1412    let unique_known: Vec<String> = known_directories
1413        .into_iter()
1414        .filter(|d| !existing_paths.contains(d))
1415        .collect();
1416    if !unique_known.is_empty() {
1417        items.push(DirItem::Header("Known".to_string()));
1418        for dir in unique_known {
1419            let display = if dir.chars().count() > 40 {
1420                let tail: String = dir
1421                    .chars()
1422                    .rev()
1423                    .take(37)
1424                    .collect::<Vec<_>>()
1425                    .into_iter()
1426                    .rev()
1427                    .collect();
1428                format!("...{}", tail)
1429            } else {
1430                dir.clone()
1431            };
1432            items.push(DirItem::Directory { display, path: dir });
1433        }
1434    }
1435
1436    items
1437}
1438
1439/// Detect if running in WSL and return the appropriate external IP
1440///
1441/// WSL2 has two networking modes:
1442/// - NAT mode (default): External devices cannot access WSL directly, need Windows host IP
1443/// - Mirrored mode: WSL shares Windows network, WSL IP is directly accessible
1444///
1445/// This function detects the mode and returns the appropriate IP.
1446fn get_wsl_host_ip() -> Option<String> {
1447    // Check if running in WSL by reading /proc/version
1448    let proc_version = std::fs::read_to_string("/proc/version").ok()?;
1449    if !proc_version.to_lowercase().contains("microsoft")
1450        && !proc_version.to_lowercase().contains("wsl")
1451    {
1452        return None;
1453    }
1454
1455    // Check if mirrored networking mode is enabled
1456    // In mirrored mode, WSL's own IP is directly accessible from external devices
1457    if is_wsl_mirrored_mode() {
1458        // Use local_ip_address to get WSL's IP (which is the same as Windows in mirrored mode)
1459        return None; // Let the caller use local_ip_address
1460    }
1461
1462    // NAT mode: Windows host IP is typically the nameserver in /etc/resolv.conf
1463    let resolv_conf = std::fs::read_to_string("/etc/resolv.conf").ok()?;
1464    for line in resolv_conf.lines() {
1465        let line = line.trim();
1466        if line.starts_with("nameserver") {
1467            if let Some(ip) = line.split_whitespace().nth(1) {
1468                // Skip internal IPs (systemd-resolved, localhost, etc.)
1469                if ip.starts_with("10.255.") || ip.starts_with("127.") {
1470                    continue;
1471                }
1472                // Validate it looks like an IP address
1473                if ip.parse::<std::net::Ipv4Addr>().is_ok() {
1474                    return Some(ip.to_string());
1475                }
1476            }
1477        }
1478    }
1479
1480    None
1481}
1482
1483/// Check if WSL is running in mirrored networking mode
1484fn is_wsl_mirrored_mode() -> bool {
1485    // Check .wslconfig in common locations
1486    if let Ok(home) = std::env::var("USERPROFILE") {
1487        let wslconfig_path = format!("{}\\.wslconfig", home);
1488        if let Ok(content) = std::fs::read_to_string(&wslconfig_path) {
1489            return content.to_lowercase().contains("networkingmode=mirrored");
1490        }
1491    }
1492
1493    // Try Windows user directories via /mnt/c
1494    if let Ok(entries) = std::fs::read_dir("/mnt/c/Users") {
1495        for entry in entries.flatten() {
1496            let path = entry.path().join(".wslconfig");
1497            if let Ok(content) = std::fs::read_to_string(&path) {
1498                if content.to_lowercase().contains("networkingmode=mirrored") {
1499                    return true;
1500                }
1501            }
1502        }
1503    }
1504
1505    false
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510    use super::*;
1511    use crate::agents::{AgentStatus, AgentType};
1512
1513    fn create_test_agent(id: &str) -> MonitoredAgent {
1514        MonitoredAgent::new(
1515            id.to_string(),
1516            AgentType::ClaudeCode,
1517            "Test".to_string(),
1518            "/home".to_string(),
1519            1234,
1520            "main".to_string(),
1521            "window".to_string(),
1522            0,
1523            0,
1524        )
1525    }
1526
1527    #[test]
1528    fn test_new_state() {
1529        let state = AppState::new();
1530        assert!(state.agents.is_empty());
1531        assert!(state.running);
1532    }
1533
1534    #[test]
1535    fn test_update_agents() {
1536        let mut state = AppState::new();
1537        let agents = vec![create_test_agent("main:0.0"), create_test_agent("main:0.1")];
1538
1539        state.update_agents(agents);
1540
1541        assert_eq!(state.agents.len(), 2);
1542        assert_eq!(state.agent_order.len(), 2);
1543    }
1544
1545    #[test]
1546    fn test_selection() {
1547        let mut state = AppState::new();
1548        let agents = vec![
1549            create_test_agent("main:0.0"),
1550            create_test_agent("main:0.1"),
1551            create_test_agent("main:0.2"),
1552        ];
1553        state.update_agents(agents);
1554        // Simulate selectable count: 3 agents + 1 CreateNew = 4
1555        state.selection.selectable_count = 4;
1556
1557        assert_eq!(state.selection.selected_entry_index, 0);
1558
1559        state.select_next();
1560        assert_eq!(state.selection.selected_entry_index, 1);
1561
1562        state.select_next();
1563        assert_eq!(state.selection.selected_entry_index, 2);
1564
1565        state.select_next();
1566        assert_eq!(state.selection.selected_entry_index, 3); // CreateNew entry
1567
1568        state.select_next();
1569        assert_eq!(state.selection.selected_entry_index, 3); // Can't go past end
1570
1571        state.select_previous();
1572        assert_eq!(state.selection.selected_entry_index, 2);
1573
1574        state.select_first();
1575        assert_eq!(state.selection.selected_entry_index, 0);
1576
1577        state.select_last();
1578        assert_eq!(state.selection.selected_entry_index, 3);
1579    }
1580
1581    #[test]
1582    fn test_attention_count() {
1583        let mut state = AppState::new();
1584        let mut agent1 = create_test_agent("main:0.0");
1585        agent1.status = AgentStatus::Idle;
1586
1587        let mut agent2 = create_test_agent("main:0.1");
1588        agent2.status = AgentStatus::AwaitingApproval {
1589            approval_type: crate::agents::ApprovalType::FileEdit,
1590            details: String::new(),
1591        };
1592
1593        state.update_agents(vec![agent1, agent2]);
1594
1595        assert_eq!(state.attention_count(), 1);
1596    }
1597}