use crate::agents::{AgentStatus, ApprovalType};
use crate::detectors::get_detector;
use super::core::TmaiCore;
use super::types::ApiError;
const MAX_TEXT_LENGTH: usize = 1024;
const ALLOWED_KEYS: &[&str] = &[
"Enter", "Escape", "Space", "Up", "Down", "Left", "Right", "Tab", "BSpace",
];
pub fn has_checkbox_format(choices: &[String]) -> bool {
choices.iter().any(|c| {
let t = c.trim();
t.starts_with("[ ]")
|| t.starts_with("[x]")
|| t.starts_with("[X]")
|| t.starts_with("[×]")
|| t.starts_with("[✔]")
})
}
impl TmaiCore {
fn require_command_sender(
&self,
) -> Result<&std::sync::Arc<crate::command_sender::CommandSender>, ApiError> {
self.command_sender_ref().ok_or(ApiError::NoCommandSender)
}
pub fn approve(&self, target: &str) -> Result<(), ApiError> {
let (is_awaiting, agent_type, is_virtual) = {
let state = self.state().read();
match state.agents.get(target) {
Some(a) => (
matches!(&a.status, AgentStatus::AwaitingApproval { .. }),
a.agent_type.clone(),
a.is_virtual,
),
None => {
return Err(ApiError::AgentNotFound {
target: target.to_string(),
})
}
}
};
if is_virtual {
return Err(ApiError::VirtualAgent {
target: target.to_string(),
});
}
if !is_awaiting {
return Ok(());
}
let cmd = self.require_command_sender()?;
let detector = get_detector(&agent_type);
cmd.send_keys(target, detector.approval_keys())?;
Ok(())
}
pub fn select_choice(&self, target: &str, choice: usize) -> Result<(), ApiError> {
{
let state = self.state().read();
match state.agents.get(target) {
Some(a) if a.is_virtual => {
return Err(ApiError::VirtualAgent {
target: target.to_string(),
});
}
Some(_) => {}
None => {
return Err(ApiError::AgentNotFound {
target: target.to_string(),
});
}
}
}
let question_info = {
let state = self.state().read();
state.agents.get(target).and_then(|agent| {
if let AgentStatus::AwaitingApproval {
approval_type:
ApprovalType::UserQuestion {
choices,
multi_select,
cursor_position,
},
..
} = &agent.status
{
Some((choices.clone(), *multi_select, *cursor_position))
} else {
None
}
})
};
match question_info {
Some((choices, multi_select, cursor_pos))
if choice >= 1 && choice <= choices.len() + 1 =>
{
let cmd = self.require_command_sender()?;
let cursor = if cursor_pos == 0 { 1 } else { cursor_pos };
let steps = choice as i32 - cursor as i32;
let key = if steps > 0 { "Down" } else { "Up" };
for _ in 0..steps.unsigned_abs() {
cmd.send_keys(target, key)?;
}
if !multi_select || has_checkbox_format(&choices) {
cmd.send_keys(target, "Enter")?;
}
Ok(())
}
Some(_) => Err(ApiError::InvalidInput {
message: "Invalid choice number".to_string(),
}),
None => Ok(()),
}
}
pub fn submit_selection(
&self,
target: &str,
selected_choices: &[usize],
) -> Result<(), ApiError> {
{
let state = self.state().read();
match state.agents.get(target) {
Some(a) if a.is_virtual => {
return Err(ApiError::VirtualAgent {
target: target.to_string(),
});
}
Some(_) => {}
None => {
return Err(ApiError::AgentNotFound {
target: target.to_string(),
});
}
}
}
let multi_info = {
let state = self.state().read();
state.agents.get(target).and_then(|agent| {
if let AgentStatus::AwaitingApproval {
approval_type:
ApprovalType::UserQuestion {
choices,
multi_select: true,
cursor_position,
},
..
} = &agent.status
{
Some((choices.clone(), *cursor_position))
} else {
None
}
})
};
match multi_info {
Some((choices, cursor_pos)) => {
let cmd = self.require_command_sender()?;
let is_checkbox = has_checkbox_format(&choices);
if is_checkbox && !selected_choices.is_empty() {
let mut sorted: Vec<usize> = selected_choices
.iter()
.copied()
.filter(|&c| c >= 1 && c <= choices.len())
.collect();
if sorted.is_empty() {
return Err(ApiError::InvalidInput {
message: "No valid choices".to_string(),
});
}
sorted.sort();
let mut current_pos = if cursor_pos == 0 { 1 } else { cursor_pos };
for &choice in &sorted {
let steps = choice as i32 - current_pos as i32;
let key = if steps > 0 { "Down" } else { "Up" };
for _ in 0..steps.unsigned_abs() {
cmd.send_keys(target, key)?;
}
cmd.send_keys(target, "Enter")?;
current_pos = choice;
}
cmd.send_keys(target, "Right")?;
cmd.send_keys(target, "Enter")?;
} else {
let downs_needed = choices.len().saturating_sub(cursor_pos.saturating_sub(1));
for _ in 0..downs_needed {
cmd.send_keys(target, "Down")?;
}
cmd.send_keys(target, "Enter")?;
}
Ok(())
}
None => Ok(()),
}
}
pub async fn send_text(&self, target: &str, text: &str) -> Result<(), ApiError> {
if text.chars().count() > MAX_TEXT_LENGTH {
return Err(ApiError::InvalidInput {
message: format!(
"Text exceeds maximum length of {} characters",
MAX_TEXT_LENGTH
),
});
}
let is_virtual = {
let state = self.state().read();
match state.agents.get(target) {
Some(a) => a.is_virtual,
None => {
return Err(ApiError::AgentNotFound {
target: target.to_string(),
})
}
}
};
if is_virtual {
return Err(ApiError::VirtualAgent {
target: target.to_string(),
});
}
let cmd = self.require_command_sender()?;
cmd.send_keys_literal(target, text)?;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
cmd.send_keys(target, "Enter")?;
self.audit_helper()
.maybe_emit_input(target, "input_text", "api_input", None);
Ok(())
}
pub fn send_key(&self, target: &str, key: &str) -> Result<(), ApiError> {
if !ALLOWED_KEYS.contains(&key) {
return Err(ApiError::InvalidInput {
message: "Invalid key name".to_string(),
});
}
let is_virtual = {
let state = self.state().read();
match state.agents.get(target) {
Some(a) => a.is_virtual,
None => {
return Err(ApiError::AgentNotFound {
target: target.to_string(),
})
}
}
};
if is_virtual {
return Err(ApiError::VirtualAgent {
target: target.to_string(),
});
}
let cmd = self.require_command_sender()?;
cmd.send_keys(target, key)?;
self.audit_helper()
.maybe_emit_input(target, "special_key", "api_input", None);
Ok(())
}
pub fn focus_pane(&self, target: &str) -> Result<(), ApiError> {
{
let state = self.state().read();
match state.agents.get(target) {
Some(a) if a.is_virtual => {
return Err(ApiError::VirtualAgent {
target: target.to_string(),
});
}
Some(_) => {}
None => {
return Err(ApiError::AgentNotFound {
target: target.to_string(),
});
}
}
}
let cmd = self.require_command_sender()?;
cmd.tmux_client().focus_pane(target)?;
Ok(())
}
pub fn kill_pane(&self, target: &str) -> Result<(), ApiError> {
{
let state = self.state().read();
match state.agents.get(target) {
Some(a) if a.is_virtual => {
return Err(ApiError::VirtualAgent {
target: target.to_string(),
});
}
Some(_) => {}
None => {
return Err(ApiError::AgentNotFound {
target: target.to_string(),
});
}
}
}
let cmd = self.require_command_sender()?;
cmd.tmux_client().kill_pane(target)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::{AgentType, MonitoredAgent};
use crate::api::builder::TmaiCoreBuilder;
use crate::config::Settings;
use crate::state::AppState;
fn make_core_with_agents(agents: Vec<MonitoredAgent>) -> TmaiCore {
let state = AppState::shared();
{
let mut s = state.write();
s.update_agents(agents);
}
TmaiCoreBuilder::new(Settings::default())
.with_state(state)
.build()
}
fn test_agent(id: &str, status: AgentStatus) -> MonitoredAgent {
let mut agent = MonitoredAgent::new(
id.to_string(),
AgentType::ClaudeCode,
"Title".to_string(),
"/home/user".to_string(),
100,
"main".to_string(),
"win".to_string(),
0,
0,
);
agent.status = status;
agent
}
#[test]
fn test_has_checkbox_format() {
assert!(has_checkbox_format(&[
"[ ] Option A".to_string(),
"[ ] Option B".to_string(),
]));
assert!(has_checkbox_format(&[
"[x] Option A".to_string(),
"[ ] Option B".to_string(),
]));
assert!(has_checkbox_format(&[
"[✔] Done".to_string(),
"[ ] Not done".to_string(),
]));
assert!(!has_checkbox_format(&[
"Option A".to_string(),
"Option B".to_string(),
]));
assert!(!has_checkbox_format(&[]));
}
#[test]
fn test_approve_not_found() {
let core = TmaiCoreBuilder::new(Settings::default()).build();
let result = core.approve("nonexistent");
assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
}
#[test]
fn test_approve_virtual_agent() {
let mut agent = test_agent(
"main:0.0",
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::FileEdit,
details: "edit foo.rs".to_string(),
},
);
agent.is_virtual = true;
let core = make_core_with_agents(vec![agent]);
let result = core.approve("main:0.0");
assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
}
#[test]
fn test_approve_not_awaiting_is_ok() {
let agent = test_agent("main:0.0", AgentStatus::Idle);
let core = make_core_with_agents(vec![agent]);
let result = core.approve("main:0.0");
assert!(result.is_ok());
}
#[test]
fn test_approve_awaiting_no_command_sender() {
let agent = test_agent(
"main:0.0",
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::ShellCommand,
details: "rm -rf".to_string(),
},
);
let core = make_core_with_agents(vec![agent]);
let result = core.approve("main:0.0");
assert!(matches!(result, Err(ApiError::NoCommandSender)));
}
#[test]
fn test_send_key_invalid() {
let agent = test_agent("main:0.0", AgentStatus::Idle);
let core = make_core_with_agents(vec![agent]);
let result = core.send_key("main:0.0", "Delete");
assert!(matches!(result, Err(ApiError::InvalidInput { .. })));
}
#[test]
fn test_send_key_not_found() {
let core = TmaiCoreBuilder::new(Settings::default()).build();
let result = core.send_key("nonexistent", "Enter");
assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
}
#[test]
fn test_send_key_virtual_agent() {
let mut agent = test_agent("main:0.0", AgentStatus::Idle);
agent.is_virtual = true;
let core = make_core_with_agents(vec![agent]);
let result = core.send_key("main:0.0", "Enter");
assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
}
#[test]
fn test_select_choice_not_in_question() {
let agent = test_agent("main:0.0", AgentStatus::Idle);
let core = make_core_with_agents(vec![agent]);
let result = core.select_choice("main:0.0", 1);
assert!(result.is_ok());
}
#[test]
fn test_select_choice_not_found() {
let core = TmaiCoreBuilder::new(Settings::default()).build();
let result = core.select_choice("nonexistent", 1);
assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
}
#[test]
fn test_select_choice_virtual_agent() {
let mut agent = test_agent("main:0.0", AgentStatus::Idle);
agent.is_virtual = true;
let core = make_core_with_agents(vec![agent]);
let result = core.select_choice("main:0.0", 1);
assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
}
#[test]
fn test_select_choice_invalid_number() {
let agent = test_agent(
"main:0.0",
AgentStatus::AwaitingApproval {
approval_type: ApprovalType::UserQuestion {
choices: vec!["A".to_string(), "B".to_string()],
multi_select: false,
cursor_position: 1,
},
details: "Pick one".to_string(),
},
);
let core = make_core_with_agents(vec![agent]);
let result = core.select_choice("main:0.0", 0);
assert!(matches!(result, Err(ApiError::InvalidInput { .. })));
let result = core.select_choice("main:0.0", 4);
assert!(matches!(result, Err(ApiError::InvalidInput { .. })));
}
#[tokio::test]
async fn test_send_text_too_long() {
let agent = test_agent("main:0.0", AgentStatus::Idle);
let core = make_core_with_agents(vec![agent]);
let long_text = "x".repeat(1025);
let result = core.send_text("main:0.0", &long_text).await;
assert!(matches!(result, Err(ApiError::InvalidInput { .. })));
}
#[tokio::test]
async fn test_send_text_not_found() {
let core = TmaiCoreBuilder::new(Settings::default()).build();
let result = core.send_text("nonexistent", "hello").await;
assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
}
#[tokio::test]
async fn test_send_text_virtual_agent() {
let mut agent = test_agent("main:0.0", AgentStatus::Idle);
agent.is_virtual = true;
let core = make_core_with_agents(vec![agent]);
let result = core.send_text("main:0.0", "hello").await;
assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
}
#[tokio::test]
async fn test_send_text_at_max_length() {
let agent = test_agent("main:0.0", AgentStatus::Idle);
let core = make_core_with_agents(vec![agent]);
let text = "x".repeat(MAX_TEXT_LENGTH);
let result = core.send_text("main:0.0", &text).await;
assert!(!matches!(result, Err(ApiError::InvalidInput { .. })));
}
#[test]
fn test_focus_pane_not_found() {
let core = TmaiCoreBuilder::new(Settings::default()).build();
let result = core.focus_pane("nonexistent");
assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
}
#[test]
fn test_focus_pane_virtual_agent() {
let mut agent = test_agent("main:0.0", AgentStatus::Idle);
agent.is_virtual = true;
let core = make_core_with_agents(vec![agent]);
let result = core.focus_pane("main:0.0");
assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
}
#[test]
fn test_kill_pane_not_found() {
let core = TmaiCoreBuilder::new(Settings::default()).build();
let result = core.kill_pane("nonexistent");
assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
}
#[test]
fn test_kill_pane_virtual_agent() {
let mut agent = test_agent("main:0.0", AgentStatus::Idle);
agent.is_virtual = true;
let core = make_core_with_agents(vec![agent]);
let result = core.kill_pane("main:0.0");
assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
}
#[test]
fn test_submit_selection_not_found() {
let core = TmaiCoreBuilder::new(Settings::default()).build();
let result = core.submit_selection("nonexistent", &[1]);
assert!(matches!(result, Err(ApiError::AgentNotFound { .. })));
}
#[test]
fn test_submit_selection_virtual_agent() {
let mut agent = test_agent("main:0.0", AgentStatus::Idle);
agent.is_virtual = true;
let core = make_core_with_agents(vec![agent]);
let result = core.submit_selection("main:0.0", &[1]);
assert!(matches!(result, Err(ApiError::VirtualAgent { .. })));
}
#[test]
fn test_submit_selection_not_in_multiselect() {
let agent = test_agent("main:0.0", AgentStatus::Idle);
let core = make_core_with_agents(vec![agent]);
let result = core.submit_selection("main:0.0", &[1]);
assert!(result.is_ok());
}
}