1use crate::agents::{AgentStatus, ApprovalType};
7use crate::detectors::get_detector;
8
9use super::core::TmaiCore;
10use super::types::ApiError;
11
12const MAX_TEXT_LENGTH: usize = 1024;
14
15const ALLOWED_KEYS: &[&str] = &[
17 "Enter", "Escape", "Space", "Up", "Down", "Left", "Right", "Tab", "BTab", "BSpace",
18];
19
20pub 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 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 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 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 pub fn select_choice(&self, target: &str, choice: usize) -> Result<(), ApiError> {
89 {
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 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 None => Ok(()),
151 }
152 }
153
154 pub fn submit_selection(
158 &self,
159 target: &str,
160 selected_choices: &[usize],
161 ) -> Result<(), ApiError> {
162 {
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 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 cmd.send_keys(target, "Enter")?;
228 current_pos = choice;
229 }
230 cmd.send_keys(target, "Right")?;
232 cmd.send_keys(target, "Enter")?;
233 } else {
234 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 None => Ok(()),
245 }
246 }
247
248 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 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 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 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 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 pub fn focus_pane(&self, target: &str) -> Result<(), ApiError> {
362 {
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 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 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 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 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 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 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 pub async fn delete_worktree(
533 &self,
534 req: &crate::worktree::WorktreeDeleteRequest,
535 ) -> Result<(), ApiError> {
536 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 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 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 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 let window_name = agent_type.short_name();
610 let target = rt.new_window(&session_name, worktree_path, Some(window_name))?;
611
612 let launch_cmd = match agent_type {
614 crate::agents::AgentType::ClaudeCode => {
615 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 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 pub fn get_usage(&self) -> crate::usage::UsageSnapshot {
649 self.state().read().usage.clone()
650 }
651
652 pub fn fetch_usage(&self) {
657 {
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 let tmux_session = self.runtime().and_then(|_rt| {
671 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 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 pub fn kill_pane(&self, target: &str) -> Result<(), ApiError> {
711 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 if let Some(session) = self.pty_registry().get(target) {
732 session.kill();
733 }
734 {
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 pub fn sync_pty_sessions(&self) -> bool {
758 let dead_ids = self.pty_registry().cleanup_dead();
759 let mut changed = false;
760
761 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 agent.status = crate::agents::AgentStatus::Offline;
773 changed = true;
774 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 let hook_state_ref = hook_reg
787 .get(id)
788 .or_else(|| {
789 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 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 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 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 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 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 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 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 let result = core.select_choice("main:0.0", 0);
1012 assert!(matches!(result, Err(ApiError::InvalidInput { .. })));
1013 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 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 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 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 assert!(!state.read().usage.fetching);
1134 }
1135}