Skip to main content

tmai_core/api/
actions.rs

1//! Action methods on [`TmaiCore`].
2//!
3//! These methods perform side-effects (send keys, focus panes, etc.) and
4//! centralise logic that was previously duplicated across TUI and Web.
5
6use crate::agents::{AgentStatus, ApprovalType};
7use crate::detectors::get_detector;
8
9use super::core::TmaiCore;
10use super::types::ApiError;
11
12/// Maximum text length for send_text
13const MAX_TEXT_LENGTH: usize = 1024;
14
15/// Allowed special key names for send_key
16const ALLOWED_KEYS: &[&str] = &[
17    "Enter", "Escape", "Space", "Up", "Down", "Left", "Right", "Tab", "BTab", "BSpace",
18];
19
20/// Check if choices use checkbox format ([ ], [x], [X], [×], [✔])
21pub fn has_checkbox_format(choices: &[String]) -> bool {
22    choices.iter().any(|c| {
23        let t = c.trim();
24        t.starts_with("[ ]")
25            || t.starts_with("[x]")
26            || t.starts_with("[X]")
27            || t.starts_with("[×]")
28            || t.starts_with("[✔]")
29    })
30}
31
32impl TmaiCore {
33    // =========================================================
34    // Helper: get command sender or error
35    // =========================================================
36
37    /// Return the command sender, or `ApiError::NoCommandSender`
38    fn require_command_sender(
39        &self,
40    ) -> Result<&std::sync::Arc<crate::command_sender::CommandSender>, ApiError> {
41        self.command_sender_ref().ok_or(ApiError::NoCommandSender)
42    }
43
44    // =========================================================
45    // Agent actions
46    // =========================================================
47
48    /// Approve an agent action (send approval keys based on agent type).
49    ///
50    /// Returns `Ok(())` if approval was sent or the agent was already not awaiting.
51    pub fn approve(&self, target: &str) -> Result<(), ApiError> {
52        let (is_awaiting, agent_type, is_virtual) = {
53            let state = self.state().read();
54            match state.agents.get(target) {
55                Some(a) => (
56                    matches!(&a.status, AgentStatus::AwaitingApproval { .. }),
57                    a.agent_type.clone(),
58                    a.is_virtual,
59                ),
60                None => {
61                    return Err(ApiError::AgentNotFound {
62                        target: target.to_string(),
63                    })
64                }
65            }
66        };
67
68        if is_virtual {
69            return Err(ApiError::VirtualAgent {
70                target: target.to_string(),
71            });
72        }
73
74        if !is_awaiting {
75            // Already handled — idempotent success
76            return Ok(());
77        }
78
79        let cmd = self.require_command_sender()?;
80        let detector = get_detector(&agent_type);
81        cmd.send_keys(target, detector.approval_keys())?;
82        Ok(())
83    }
84
85    /// Select a choice for a UserQuestion prompt.
86    ///
87    /// `choice` is 1-indexed (1 = first option, N+1 = "Other").
88    pub fn select_choice(&self, target: &str, choice: usize) -> Result<(), ApiError> {
89        // Virtual agents cannot receive key input
90        {
91            let state = self.state().read();
92            match state.agents.get(target) {
93                Some(a) if a.is_virtual => {
94                    return Err(ApiError::VirtualAgent {
95                        target: target.to_string(),
96                    });
97                }
98                Some(_) => {}
99                None => {
100                    return Err(ApiError::AgentNotFound {
101                        target: target.to_string(),
102                    });
103                }
104            }
105        }
106
107        let question_info = {
108            let state = self.state().read();
109            state.agents.get(target).and_then(|agent| {
110                if let AgentStatus::AwaitingApproval {
111                    approval_type:
112                        ApprovalType::UserQuestion {
113                            choices,
114                            multi_select,
115                            cursor_position,
116                        },
117                    ..
118                } = &agent.status
119                {
120                    Some((choices.clone(), *multi_select, *cursor_position))
121                } else {
122                    None
123                }
124            })
125        };
126
127        match question_info {
128            Some((choices, multi_select, cursor_pos))
129                if choice >= 1 && choice <= choices.len() + 1 =>
130            {
131                let cmd = self.require_command_sender()?;
132                let cursor = if cursor_pos == 0 { 1 } else { cursor_pos };
133                let steps = choice as i32 - cursor as i32;
134                let key = if steps > 0 { "Down" } else { "Up" };
135                for _ in 0..steps.unsigned_abs() {
136                    cmd.send_keys(target, key)?;
137                }
138
139                // Confirm: single-select always, multi-select only for checkbox toggle
140                if !multi_select || has_checkbox_format(&choices) {
141                    cmd.send_keys(target, "Enter")?;
142                }
143
144                Ok(())
145            }
146            Some(_) => Err(ApiError::InvalidInput {
147                message: "Invalid choice number".to_string(),
148            }),
149            // Agent exists but not in UserQuestion state — idempotent Ok
150            None => Ok(()),
151        }
152    }
153
154    /// Submit multi-select choices (checkbox or legacy format).
155    ///
156    /// `selected_choices` is a list of 1-indexed choice numbers.
157    pub fn submit_selection(
158        &self,
159        target: &str,
160        selected_choices: &[usize],
161    ) -> Result<(), ApiError> {
162        // Virtual agents cannot receive key input
163        {
164            let state = self.state().read();
165            match state.agents.get(target) {
166                Some(a) if a.is_virtual => {
167                    return Err(ApiError::VirtualAgent {
168                        target: target.to_string(),
169                    });
170                }
171                Some(_) => {}
172                None => {
173                    return Err(ApiError::AgentNotFound {
174                        target: target.to_string(),
175                    });
176                }
177            }
178        }
179
180        let multi_info = {
181            let state = self.state().read();
182            state.agents.get(target).and_then(|agent| {
183                if let AgentStatus::AwaitingApproval {
184                    approval_type:
185                        ApprovalType::UserQuestion {
186                            choices,
187                            multi_select: true,
188                            cursor_position,
189                        },
190                    ..
191                } = &agent.status
192                {
193                    Some((choices.clone(), *cursor_position))
194                } else {
195                    None
196                }
197            })
198        };
199
200        match multi_info {
201            Some((choices, cursor_pos)) => {
202                let cmd = self.require_command_sender()?;
203                let is_checkbox = has_checkbox_format(&choices);
204
205                if is_checkbox && !selected_choices.is_empty() {
206                    // Checkbox format: navigate to each selected choice and toggle
207                    let mut sorted: Vec<usize> = selected_choices
208                        .iter()
209                        .copied()
210                        .filter(|&c| c >= 1 && c <= choices.len())
211                        .collect();
212                    if sorted.is_empty() {
213                        return Err(ApiError::InvalidInput {
214                            message: "No valid choices".to_string(),
215                        });
216                    }
217                    sorted.sort();
218                    let mut current_pos = if cursor_pos == 0 { 1 } else { cursor_pos };
219
220                    for &choice in &sorted {
221                        let steps = choice as i32 - current_pos as i32;
222                        let key = if steps > 0 { "Down" } else { "Up" };
223                        for _ in 0..steps.unsigned_abs() {
224                            cmd.send_keys(target, key)?;
225                        }
226                        // Enter to toggle checkbox
227                        cmd.send_keys(target, "Enter")?;
228                        current_pos = choice;
229                    }
230                    // Right + Enter to submit
231                    cmd.send_keys(target, "Right")?;
232                    cmd.send_keys(target, "Enter")?;
233                } else {
234                    // Legacy format: navigate past all choices then Enter
235                    let downs_needed = choices.len().saturating_sub(cursor_pos.saturating_sub(1));
236                    for _ in 0..downs_needed {
237                        cmd.send_keys(target, "Down")?;
238                    }
239                    cmd.send_keys(target, "Enter")?;
240                }
241                Ok(())
242            }
243            // Agent exists but not in multi-select UserQuestion state — idempotent Ok
244            None => Ok(()),
245        }
246    }
247
248    /// Send text input to an agent followed by Enter.
249    ///
250    /// Includes a 50ms delay between text and Enter to prevent paste-burst issues.
251    pub async fn send_text(&self, target: &str, text: &str) -> Result<(), ApiError> {
252        if text.chars().count() > MAX_TEXT_LENGTH {
253            return Err(ApiError::InvalidInput {
254                message: format!(
255                    "Text exceeds maximum length of {} characters",
256                    MAX_TEXT_LENGTH
257                ),
258            });
259        }
260
261        let is_virtual = {
262            let state = self.state().read();
263            match state.agents.get(target) {
264                Some(a) => a.is_virtual,
265                None => {
266                    return Err(ApiError::AgentNotFound {
267                        target: target.to_string(),
268                    })
269                }
270            }
271        };
272
273        if is_virtual {
274            return Err(ApiError::VirtualAgent {
275                target: target.to_string(),
276            });
277        }
278
279        let cmd = self.require_command_sender()?;
280        cmd.send_keys_literal(target, text)?;
281        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
282        cmd.send_keys(target, "Enter")?;
283
284        self.audit_helper()
285            .maybe_emit_input(target, "input_text", "api_input", None);
286
287        Ok(())
288    }
289
290    /// Send a special key to an agent (whitelist-validated).
291    pub fn send_key(&self, target: &str, key: &str) -> Result<(), ApiError> {
292        if !ALLOWED_KEYS.contains(&key) {
293            return Err(ApiError::InvalidInput {
294                message: "Invalid key name".to_string(),
295            });
296        }
297
298        let (is_virtual, has_pty) = {
299            let state = self.state().read();
300            match state.agents.get(target) {
301                Some(a) => (a.is_virtual, a.pty_session_id.is_some()),
302                None => {
303                    return Err(ApiError::AgentNotFound {
304                        target: target.to_string(),
305                    })
306                }
307            }
308        };
309
310        if is_virtual {
311            return Err(ApiError::VirtualAgent {
312                target: target.to_string(),
313            });
314        }
315
316        // PTY-spawned agents: write directly to PTY session
317        if has_pty {
318            if let Some(session) = self.pty_registry().get(target) {
319                let data = crate::utils::keys::tmux_key_to_bytes(key);
320                session.write_input(&data).map_err(ApiError::CommandError)?;
321            } else {
322                // PTY session gone — agent may have exited
323                return Err(ApiError::CommandError(anyhow::anyhow!(
324                    "PTY session not found for agent"
325                )));
326            }
327        } else {
328            let cmd = self.require_command_sender()?;
329            cmd.send_keys(target, key)?;
330        }
331
332        self.audit_helper()
333            .maybe_emit_input(target, "special_key", "api_input", None);
334
335        Ok(())
336    }
337
338    /// Toggle per-agent auto-approve override.
339    ///
340    /// - `None` → follow global setting (default)
341    /// - `Some(true)` → force enabled for this agent
342    /// - `Some(false)` → force disabled for this agent
343    pub fn set_auto_approve_override(
344        &self,
345        target: &str,
346        enabled: Option<bool>,
347    ) -> Result<(), ApiError> {
348        let mut state = self.state().write();
349        match state.agents.get_mut(target) {
350            Some(agent) => {
351                agent.auto_approve_override = enabled;
352                Ok(())
353            }
354            None => Err(ApiError::AgentNotFound {
355                target: target.to_string(),
356            }),
357        }
358    }
359
360    /// Focus on a specific pane in tmux
361    pub fn focus_pane(&self, target: &str) -> Result<(), ApiError> {
362        // Validate agent exists and is not virtual
363        {
364            let state = self.state().read();
365            match state.agents.get(target) {
366                Some(a) if a.is_virtual => {
367                    return Err(ApiError::VirtualAgent {
368                        target: target.to_string(),
369                    });
370                }
371                Some(_) => {}
372                None => {
373                    return Err(ApiError::AgentNotFound {
374                        target: target.to_string(),
375                    });
376                }
377            }
378        }
379
380        let cmd = self.require_command_sender()?;
381        cmd.runtime().focus_pane(target)?;
382        Ok(())
383    }
384
385    /// Request a fresh-session code review for a specific agent.
386    ///
387    /// Directly launches a review session in a new tmux window (blocking I/O
388    /// is offloaded to `spawn_blocking`). Works regardless of `review.enabled`.
389    pub fn request_review(&self, target: &str) -> Result<(), ApiError> {
390        let (cwd, branch) = {
391            let state = self.state().read();
392            match state.agents.get(target) {
393                Some(a) => (a.cwd.clone(), a.git_branch.clone()),
394                None => {
395                    return Err(ApiError::AgentNotFound {
396                        target: target.to_string(),
397                    })
398                }
399            }
400        };
401
402        let request = crate::review::ReviewRequest {
403            target: target.to_string(),
404            cwd,
405            branch,
406            base_branch: self.settings().review.base_branch.clone(),
407            last_message: None,
408        };
409
410        let settings = self.settings().review.clone();
411        let event_tx = self.event_sender();
412        let req_target = request.target.clone();
413
414        tokio::task::spawn_blocking(move || {
415            match crate::review::service::launch_review(&request, &settings, None) {
416                Ok((review_target, output_file)) => {
417                    tracing::info!(
418                        source_target = %req_target,
419                        review_target = %review_target,
420                        output = %output_file.display(),
421                        "Review session launched"
422                    );
423                    let _ = event_tx.send(super::events::CoreEvent::ReviewLaunched {
424                        source_target: req_target,
425                        review_target,
426                    });
427                }
428                Err(e) => {
429                    tracing::warn!(target = %req_target, %e, "Failed to launch review");
430                }
431            }
432        });
433
434        Ok(())
435    }
436
437    // =========================================================
438    // Worktree actions
439    // =========================================================
440
441    /// List all worktrees from state as owned snapshots
442    pub fn list_worktrees(&self) -> Vec<super::types::WorktreeSnapshot> {
443        let state = self.state().read();
444        let mut snapshots = Vec::new();
445        for repo in &state.worktree_info {
446            for wt in &repo.worktrees {
447                snapshots.push(super::types::WorktreeSnapshot::from_detail(
448                    &repo.repo_name,
449                    &repo.repo_path,
450                    wt,
451                ));
452            }
453        }
454        snapshots
455    }
456
457    /// Create a new git worktree, then optionally run setup commands
458    pub async fn create_worktree(
459        &self,
460        req: &crate::worktree::WorktreeCreateRequest,
461    ) -> Result<crate::worktree::types::WorktreeCreateResult, ApiError> {
462        let result = crate::worktree::create_worktree(req).await?;
463
464        // Emit event
465        let _ = self
466            .event_sender()
467            .send(super::events::CoreEvent::WorktreeCreated {
468                target: result.path.clone(),
469                worktree: Some(crate::hooks::types::WorktreeInfo {
470                    name: Some(result.branch.clone()),
471                    path: Some(result.path.clone()),
472                    branch: Some(result.branch.clone()),
473                    original_repo: Some(req.repo_path.clone()),
474                }),
475            });
476
477        // Spawn setup commands in background if configured
478        let setup_commands = self.settings().worktree.setup_commands.clone();
479        if !setup_commands.is_empty() {
480            let timeout = self.settings().worktree.setup_timeout_secs;
481            let wt_path = result.path.clone();
482            let branch = result.branch.clone();
483            let event_tx = self.event_sender();
484            tokio::spawn(async move {
485                match crate::worktree::run_setup_commands(&wt_path, &setup_commands, timeout).await
486                {
487                    Ok(()) => {
488                        tracing::info!(
489                            worktree = wt_path,
490                            branch = branch,
491                            "Worktree setup completed"
492                        );
493                        let _ = event_tx.send(super::events::CoreEvent::WorktreeSetupCompleted {
494                            worktree_path: wt_path,
495                            branch,
496                        });
497                    }
498                    Err(e) => {
499                        tracing::warn!(
500                            worktree = wt_path,
501                            branch = branch,
502                            error = %e,
503                            "Worktree setup failed"
504                        );
505                        let _ = event_tx.send(super::events::CoreEvent::WorktreeSetupFailed {
506                            worktree_path: wt_path,
507                            branch,
508                            error: e,
509                        });
510                    }
511                }
512            });
513        }
514
515        Ok(result)
516    }
517
518    /// Fetch full diff for a worktree (on-demand, for diff viewer)
519    pub async fn get_worktree_diff(
520        &self,
521        worktree_path: &str,
522        base_branch: &str,
523    ) -> Result<(Option<String>, Option<crate::git::DiffSummary>), ApiError> {
524        let diff = crate::git::fetch_full_diff(worktree_path, base_branch).await;
525        let summary = crate::git::fetch_diff_stat(worktree_path, base_branch).await;
526        Ok((diff, summary))
527    }
528
529    /// Delete a git worktree
530    ///
531    /// Checks for running agents and uncommitted changes before removal.
532    pub async fn delete_worktree(
533        &self,
534        req: &crate::worktree::WorktreeDeleteRequest,
535    ) -> Result<(), ApiError> {
536        // Check for running agents in this worktree (skip if force)
537        if !req.force {
538            let state = self.state().read();
539            let worktree_path = std::path::Path::new(&req.repo_path)
540                .join(".claude")
541                .join("worktrees")
542                .join(&req.worktree_name);
543            let wt_path_str = worktree_path.to_string_lossy().to_string();
544
545            for repo in &state.worktree_info {
546                for wt in &repo.worktrees {
547                    if wt.path == wt_path_str && wt.agent_target.is_some() {
548                        return Err(ApiError::WorktreeError(
549                            crate::worktree::WorktreeOpsError::AgentStillRunning(
550                                req.worktree_name.clone(),
551                            ),
552                        ));
553                    }
554                }
555            }
556        }
557
558        crate::worktree::delete_worktree(req).await?;
559
560        // Emit event
561        let worktree_path = std::path::Path::new(&req.repo_path)
562            .join(".claude")
563            .join("worktrees")
564            .join(&req.worktree_name)
565            .to_string_lossy()
566            .to_string();
567        let _ = self
568            .event_sender()
569            .send(super::events::CoreEvent::WorktreeRemoved {
570                target: worktree_path,
571                worktree: Some(crate::hooks::types::WorktreeInfo {
572                    name: Some(req.worktree_name.clone()),
573                    path: None,
574                    branch: None,
575                    original_repo: Some(req.repo_path.clone()),
576                }),
577            });
578
579        Ok(())
580    }
581
582    /// Launch an agent in a worktree via tmux
583    ///
584    /// Creates a new tmux window in the worktree directory and starts the agent.
585    /// Returns the new pane target identifier.
586    pub fn launch_agent_in_worktree(
587        &self,
588        worktree_path: &str,
589        agent_type: &crate::agents::AgentType,
590        session: Option<&str>,
591    ) -> Result<String, ApiError> {
592        let cmd = self.require_command_sender()?;
593        let rt = cmd.runtime();
594
595        // Determine session to use (prefer first agent in display order for determinism)
596        let session_name = session
597            .map(|s| s.to_string())
598            .or_else(|| {
599                let state = self.state().read();
600                state
601                    .agent_order
602                    .first()
603                    .and_then(|key| state.agents.get(key))
604                    .map(|a| a.session.clone())
605            })
606            .unwrap_or_else(|| "main".to_string());
607
608        // Create a new window in the worktree directory
609        let window_name = agent_type.short_name();
610        let target = rt.new_window(&session_name, worktree_path, Some(window_name))?;
611
612        // Build the launch command based on agent type
613        let launch_cmd = match agent_type {
614            crate::agents::AgentType::ClaudeCode => {
615                // Extract worktree name from path for --worktree flag
616                let wt_name = crate::git::extract_claude_worktree_name(worktree_path);
617                match wt_name {
618                    Some(name) if crate::git::is_valid_worktree_name(&name) => {
619                        format!("claude --worktree {}", name)
620                    }
621                    _ => "claude".to_string(),
622                }
623            }
624            crate::agents::AgentType::CodexCli => "codex".to_string(),
625            crate::agents::AgentType::GeminiCli => "gemini".to_string(),
626            crate::agents::AgentType::OpenCode => "opencode".to_string(),
627            crate::agents::AgentType::Custom(name) => name.clone(),
628        };
629
630        // Run via tmai wrap for PTY monitoring
631        rt.run_command_wrapped(&target, &launch_cmd)?;
632
633        tracing::info!(
634            worktree = worktree_path,
635            agent = %agent_type.short_name(),
636            target = %target,
637            "Launched agent in worktree"
638        );
639
640        Ok(target)
641    }
642
643    // =========================================================
644    // Usage actions
645    // =========================================================
646
647    /// Get the cached usage snapshot from state.
648    pub fn get_usage(&self) -> crate::usage::UsageSnapshot {
649        self.state().read().usage.clone()
650    }
651
652    /// Start a background usage fetch.
653    ///
654    /// If a fetch is already in progress, this is a no-op.
655    /// On completion, updates state and emits `CoreEvent::UsageUpdated`.
656    pub fn fetch_usage(&self) {
657        // Check and set fetching flag atomically
658        {
659            let mut state = self.state().write();
660            if state.usage.fetching {
661                return;
662            }
663            state.usage.fetching = true;
664        }
665
666        let state = self.state().clone();
667        let event_tx = self.event_sender();
668
669        // Determine if tmux is available by checking runtime
670        let tmux_session = self.runtime().and_then(|_rt| {
671            // If runtime supports tmux, try to get a session name from agents
672            let s = self.state().read();
673            s.agent_order
674                .first()
675                .and_then(|key| s.agents.get(key))
676                .map(|a| a.session.clone())
677        });
678
679        tokio::spawn(async move {
680            let result = crate::usage::fetch_usage_auto(tmux_session.as_deref()).await;
681
682            let mut s = state.write();
683            match result {
684                Ok(snapshot) => {
685                    s.usage = snapshot;
686                    s.usage.fetching = false;
687                    s.usage.error = None;
688                }
689                Err(e) => {
690                    tracing::warn!("Usage fetch failed: {e}");
691                    s.usage.fetching = false;
692                    s.usage.error = Some(e.to_string());
693                }
694            }
695            drop(s);
696            let _ = event_tx.send(super::events::CoreEvent::UsageUpdated);
697        });
698    }
699
700    /// Auto-fetch usage on startup if enabled in settings.
701    pub fn start_initial_usage_fetch(&self) {
702        let settings = self.settings();
703        if settings.usage.enabled {
704            tracing::info!("Usage monitoring enabled — starting initial fetch");
705            self.fetch_usage();
706        }
707    }
708
709    /// Kill a specific agent (PTY session or tmux pane)
710    pub fn kill_pane(&self, target: &str) -> Result<(), ApiError> {
711        // Validate agent exists and is not virtual
712        let has_pty = {
713            let state = self.state().read();
714            match state.agents.get(target) {
715                Some(a) if a.is_virtual => {
716                    return Err(ApiError::VirtualAgent {
717                        target: target.to_string(),
718                    });
719                }
720                Some(a) => a.pty_session_id.is_some(),
721                None => {
722                    return Err(ApiError::AgentNotFound {
723                        target: target.to_string(),
724                    });
725                }
726            }
727        };
728
729        if has_pty {
730            // PTY-spawned agent: kill the child process
731            if let Some(session) = self.pty_registry().get(target) {
732                session.kill();
733            }
734            // Remove from agent list
735            {
736                let mut state = self.state().write();
737                state.agents.remove(target);
738                state.agent_order.retain(|id| id != target);
739            }
740            self.notify_agents_updated();
741            Ok(())
742        } else {
743            let cmd = self.require_command_sender()?;
744            cmd.runtime().kill_pane(target)?;
745            Ok(())
746        }
747    }
748
749    /// Sync PTY-spawned agent statuses with actual PTY session liveness
750    /// and hook registry state.
751    ///
752    /// - Hook status available: apply hook-derived status (highest fidelity)
753    /// - Running sessions without hooks: set to `Processing`
754    /// - Dead sessions: set to `Offline` and clean up from registry
755    ///
756    /// Returns true if any agent status was changed.
757    pub fn sync_pty_sessions(&self) -> bool {
758        let dead_ids = self.pty_registry().cleanup_dead();
759        let mut changed = false;
760
761        // Read hook states for PTY agents
762        let hook_reg = self.hook_registry().read();
763
764        let mut state = self.state().write();
765        for (id, agent) in state.agents.iter_mut() {
766            if agent.pty_session_id.is_none() {
767                continue;
768            }
769
770            if dead_ids.contains(id) {
771                // Process exited — set Offline and clean up mappings
772                agent.status = crate::agents::AgentStatus::Offline;
773                changed = true;
774                // Remove from session_pane_map to prevent stale routing
775                if let Some(sid) = &agent.pty_session_id {
776                    let mut spm = self.session_pane_map().write();
777                    spm.remove(sid);
778                }
779                continue;
780            }
781
782            // Try to apply hook-derived status.
783            // PTY agent's ID is a session_id (UUID), but HookRegistry keys are
784            // pane_ids from resolve_pane_id(). Try: direct match, then
785            // session_pane_map lookup, then scan by session_id.
786            let hook_state_ref = hook_reg
787                .get(id)
788                .or_else(|| {
789                    // Lookup via session_pane_map (session_id → pane_id)
790                    let spm = self.session_pane_map().read();
791                    let sid = agent.pty_session_id.as_deref().unwrap_or(id);
792                    spm.get(sid).and_then(|pane_id| hook_reg.get(pane_id))
793                })
794                .or_else(|| {
795                    // Scan HookRegistry for matching session_id
796                    let sid = agent.pty_session_id.as_deref().unwrap_or(id);
797                    hook_reg.values().find(|hs| hs.session_id == sid)
798                });
799            if let Some(hook_state) = hook_state_ref {
800                let new_status = crate::hooks::handler::hook_status_to_agent_status(hook_state);
801                if agent.status != new_status {
802                    agent.status = new_status;
803                    agent.detection_source = crate::agents::DetectionSource::HttpHook;
804                    changed = true;
805                }
806                // Update last_content from activity log
807                let activity = crate::hooks::handler::format_activity_log(&hook_state.activity_log);
808                if !activity.is_empty() && agent.last_content != activity {
809                    agent.last_content = activity;
810                    changed = true;
811                }
812                continue;
813            }
814
815            // No hook state — detect status from PTY scrollback (capture-pane equivalent)
816            if let Some(session) = self.pty_registry().get(id) {
817                let snapshot = session.scrollback_snapshot();
818                let raw_text = String::from_utf8_lossy(&snapshot);
819                // Take last ~4KB for detection (equivalent to capture-pane last N lines)
820                let tail = if raw_text.len() > 4096 {
821                    let start = raw_text.floor_char_boundary(raw_text.len() - 4096);
822                    &raw_text[start..]
823                } else {
824                    &raw_text
825                };
826                let content = crate::utils::strip_ansi(tail);
827                let detector = crate::detectors::get_detector(&agent.agent_type);
828                let new_status = detector.detect_status("", &content);
829                if agent.status != new_status {
830                    agent.status = new_status;
831                    agent.detection_source = crate::agents::DetectionSource::CapturePane;
832                    changed = true;
833                }
834                // Update last_content for preview
835                if agent.last_content != content {
836                    agent.last_content = content;
837                    changed = true;
838                }
839            }
840        }
841
842        changed
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use crate::agents::{AgentType, MonitoredAgent};
850    use crate::api::builder::TmaiCoreBuilder;
851    use crate::config::Settings;
852    use crate::state::AppState;
853
854    fn make_core_with_agents(agents: Vec<MonitoredAgent>) -> TmaiCore {
855        let state = AppState::shared();
856        {
857            let mut s = state.write();
858            s.update_agents(agents);
859        }
860        TmaiCoreBuilder::new(Settings::default())
861            .with_state(state)
862            .build()
863    }
864
865    fn test_agent(id: &str, status: AgentStatus) -> MonitoredAgent {
866        let mut agent = MonitoredAgent::new(
867            id.to_string(),
868            AgentType::ClaudeCode,
869            "Title".to_string(),
870            "/home/user".to_string(),
871            100,
872            "main".to_string(),
873            "win".to_string(),
874            0,
875            0,
876        );
877        agent.status = status;
878        agent
879    }
880
881    #[test]
882    fn test_has_checkbox_format() {
883        assert!(has_checkbox_format(&[
884            "[ ] Option A".to_string(),
885            "[ ] Option B".to_string(),
886        ]));
887        assert!(has_checkbox_format(&[
888            "[x] Option A".to_string(),
889            "[ ] Option B".to_string(),
890        ]));
891        assert!(has_checkbox_format(&[
892            "[✔] Done".to_string(),
893            "[ ] Not done".to_string(),
894        ]));
895        assert!(!has_checkbox_format(&[
896            "Option A".to_string(),
897            "Option B".to_string(),
898        ]));
899        assert!(!has_checkbox_format(&[]));
900    }
901
902    #[test]
903    fn test_approve_not_found() {
904        let core = TmaiCoreBuilder::new(Settings::default()).build();
905        let result = core.approve("nonexistent");
906        assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
907    }
908
909    #[test]
910    fn test_approve_virtual_agent() {
911        let mut agent = test_agent(
912            "main:0.0",
913            AgentStatus::AwaitingApproval {
914                approval_type: ApprovalType::FileEdit,
915                details: "edit foo.rs".to_string(),
916            },
917        );
918        agent.is_virtual = true;
919        let core = make_core_with_agents(vec![agent]);
920        let result = core.approve("main:0.0");
921        assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
922    }
923
924    #[test]
925    fn test_approve_not_awaiting_is_ok() {
926        let agent = test_agent("main:0.0", AgentStatus::Idle);
927        let core = make_core_with_agents(vec![agent]);
928        // No command sender, but should return Ok since not awaiting
929        let result = core.approve("main:0.0");
930        assert!(result.is_ok());
931    }
932
933    #[test]
934    fn test_approve_awaiting_no_command_sender() {
935        let agent = test_agent(
936            "main:0.0",
937            AgentStatus::AwaitingApproval {
938                approval_type: ApprovalType::ShellCommand,
939                details: "rm -rf".to_string(),
940            },
941        );
942        let core = make_core_with_agents(vec![agent]);
943        let result = core.approve("main:0.0");
944        assert!(matches!(result, Err(ApiError::NoCommandSender)));
945    }
946
947    #[test]
948    fn test_send_key_invalid() {
949        let agent = test_agent("main:0.0", AgentStatus::Idle);
950        let core = make_core_with_agents(vec![agent]);
951        let result = core.send_key("main:0.0", "Delete");
952        assert!(matches!(result, Err(ApiError::InvalidInput { .. })));
953    }
954
955    #[test]
956    fn test_send_key_not_found() {
957        let core = TmaiCoreBuilder::new(Settings::default()).build();
958        let result = core.send_key("nonexistent", "Enter");
959        assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
960    }
961
962    #[test]
963    fn test_send_key_virtual_agent() {
964        let mut agent = test_agent("main:0.0", AgentStatus::Idle);
965        agent.is_virtual = true;
966        let core = make_core_with_agents(vec![agent]);
967        let result = core.send_key("main:0.0", "Enter");
968        assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
969    }
970
971    #[test]
972    fn test_select_choice_not_in_question() {
973        let agent = test_agent("main:0.0", AgentStatus::Idle);
974        let core = make_core_with_agents(vec![agent]);
975        // Agent exists but not in UserQuestion state — idempotent Ok
976        let result = core.select_choice("main:0.0", 1);
977        assert!(result.is_ok());
978    }
979
980    #[test]
981    fn test_select_choice_not_found() {
982        let core = TmaiCoreBuilder::new(Settings::default()).build();
983        let result = core.select_choice("nonexistent", 1);
984        assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
985    }
986
987    #[test]
988    fn test_select_choice_virtual_agent() {
989        let mut agent = test_agent("main:0.0", AgentStatus::Idle);
990        agent.is_virtual = true;
991        let core = make_core_with_agents(vec![agent]);
992        let result = core.select_choice("main:0.0", 1);
993        assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
994    }
995
996    #[test]
997    fn test_select_choice_invalid_number() {
998        let agent = test_agent(
999            "main:0.0",
1000            AgentStatus::AwaitingApproval {
1001                approval_type: ApprovalType::UserQuestion {
1002                    choices: vec!["A".to_string(), "B".to_string()],
1003                    multi_select: false,
1004                    cursor_position: 1,
1005                },
1006                details: "Pick one".to_string(),
1007            },
1008        );
1009        let core = make_core_with_agents(vec![agent]);
1010        // choice 0 is invalid (1-indexed)
1011        let result = core.select_choice("main:0.0", 0);
1012        assert!(matches!(result, Err(ApiError::InvalidInput { .. })));
1013        // choice 4 is invalid (only 2 choices + 1 Other = max 3)
1014        let result = core.select_choice("main:0.0", 4);
1015        assert!(matches!(result, Err(ApiError::InvalidInput { .. })));
1016    }
1017
1018    #[tokio::test]
1019    async fn test_send_text_too_long() {
1020        let agent = test_agent("main:0.0", AgentStatus::Idle);
1021        let core = make_core_with_agents(vec![agent]);
1022        let long_text = "x".repeat(1025);
1023        let result = core.send_text("main:0.0", &long_text).await;
1024        assert!(matches!(result, Err(ApiError::InvalidInput { .. })));
1025    }
1026
1027    #[tokio::test]
1028    async fn test_send_text_not_found() {
1029        let core = TmaiCoreBuilder::new(Settings::default()).build();
1030        let result = core.send_text("nonexistent", "hello").await;
1031        assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
1032    }
1033
1034    #[tokio::test]
1035    async fn test_send_text_virtual_agent() {
1036        let mut agent = test_agent("main:0.0", AgentStatus::Idle);
1037        agent.is_virtual = true;
1038        let core = make_core_with_agents(vec![agent]);
1039        let result = core.send_text("main:0.0", "hello").await;
1040        assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
1041    }
1042
1043    #[tokio::test]
1044    async fn test_send_text_at_max_length() {
1045        let agent = test_agent("main:0.0", AgentStatus::Idle);
1046        let core = make_core_with_agents(vec![agent]);
1047        // MAX_TEXT_LENGTH chars exactly should pass validation (fail at NoCommandSender)
1048        let text = "x".repeat(MAX_TEXT_LENGTH);
1049        let result = core.send_text("main:0.0", &text).await;
1050        assert!(!matches!(result, Err(ApiError::InvalidInput { .. })));
1051    }
1052
1053    #[test]
1054    fn test_focus_pane_not_found() {
1055        let core = TmaiCoreBuilder::new(Settings::default()).build();
1056        let result = core.focus_pane("nonexistent");
1057        assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
1058    }
1059
1060    #[test]
1061    fn test_focus_pane_virtual_agent() {
1062        let mut agent = test_agent("main:0.0", AgentStatus::Idle);
1063        agent.is_virtual = true;
1064        let core = make_core_with_agents(vec![agent]);
1065        let result = core.focus_pane("main:0.0");
1066        assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
1067    }
1068
1069    #[test]
1070    fn test_kill_pane_not_found() {
1071        let core = TmaiCoreBuilder::new(Settings::default()).build();
1072        let result = core.kill_pane("nonexistent");
1073        assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
1074    }
1075
1076    #[test]
1077    fn test_kill_pane_virtual_agent() {
1078        let mut agent = test_agent("main:0.0", AgentStatus::Idle);
1079        agent.is_virtual = true;
1080        let core = make_core_with_agents(vec![agent]);
1081        let result = core.kill_pane("main:0.0");
1082        assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
1083    }
1084
1085    #[test]
1086    fn test_submit_selection_not_found() {
1087        let core = TmaiCoreBuilder::new(Settings::default()).build();
1088        let result = core.submit_selection("nonexistent", &[1]);
1089        assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
1090    }
1091
1092    #[test]
1093    fn test_submit_selection_virtual_agent() {
1094        let mut agent = test_agent("main:0.0", AgentStatus::Idle);
1095        agent.is_virtual = true;
1096        let core = make_core_with_agents(vec![agent]);
1097        let result = core.submit_selection("main:0.0", &[1]);
1098        assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
1099    }
1100
1101    #[test]
1102    fn test_submit_selection_not_in_multiselect() {
1103        let agent = test_agent("main:0.0", AgentStatus::Idle);
1104        let core = make_core_with_agents(vec![agent]);
1105        // Agent exists but not in multi-select state — idempotent Ok
1106        let result = core.submit_selection("main:0.0", &[1]);
1107        assert!(result.is_ok());
1108    }
1109
1110    #[tokio::test]
1111    async fn test_initial_usage_fetch_sets_fetching_when_enabled() {
1112        let mut settings = Settings::default();
1113        settings.usage.enabled = true;
1114        let state = AppState::shared();
1115        let core = TmaiCoreBuilder::new(settings)
1116            .with_state(state.clone())
1117            .build();
1118        // Should set fetching=true since usage is enabled
1119        core.start_initial_usage_fetch();
1120        assert!(state.read().usage.fetching);
1121    }
1122
1123    #[test]
1124    fn test_initial_usage_fetch_noop_when_disabled() {
1125        let mut settings = Settings::default();
1126        settings.usage.enabled = false;
1127        let state = AppState::shared();
1128        let core = TmaiCoreBuilder::new(settings)
1129            .with_state(state.clone())
1130            .build();
1131        core.start_initial_usage_fetch();
1132        // Should not set fetching since usage is disabled
1133        assert!(!state.read().usage.fetching);
1134    }
1135}