pub mod agent_detect;
pub mod agent_hooks;
pub mod agent_state;
pub mod agent_watch;
pub mod control_mode;
pub mod process_info;
pub mod pty_io;
use anyhow::{Result, anyhow};
use tokio::process::Command;
use tracing::{debug, warn};
use crate::tmux::agent_state::AgentSnapshot;
#[cfg(unix)]
pub const MULTIPLEXER_INSTALL_HINTS: &[&str] =
&["apt install tmux", "brew install tmux", "pacman -S tmux"];
#[cfg(windows)]
pub const MULTIPLEXER_INSTALL_HINTS: &[&str] =
&["winget install psmux", "scoop install psmux", "cargo install psmux"];
#[cfg(unix)]
pub const MULTIPLEXER_NAME: &str = "tmux";
#[cfg(windows)]
pub const MULTIPLEXER_NAME: &str = "psmux";
pub fn check_multiplexer() -> Result<()> {
match which::which("tmux") {
Ok(_) => {
debug!("multiplexer (tmux) found in PATH");
Ok(())
}
Err(_) => {
#[cfg(windows)]
{
if which::which("psmux").is_ok() {
debug!("multiplexer (psmux) found in PATH");
return Ok(());
}
}
let hints = MULTIPLEXER_INSTALL_HINTS.join("\n ");
Err(anyhow!("terminal multiplexer not found in PATH.\nInstall one of:\n {}", hints))
}
}
}
pub async fn new_session(name: &str, cwd: &str, command: Option<&str>) -> Result<bool> {
use crate::tmux::agent_hooks;
let output = Command::new("tmux")
.args(["new-session", "-d", "-s", name, "-c", cwd, "-x", "200", "-y", "50"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("tmux new-session failed: {}", stderr));
}
let mouse_out =
Command::new("tmux").args(["set-option", "-t", name, "mouse", "on"]).output().await?;
if !mouse_out.status.success() {
warn!(
"failed to enable mouse for session {}: {}",
name,
String::from_utf8_lossy(&mouse_out.stderr)
);
}
let mut hook_injected = false;
if let Some(cmd) = command {
if let Some(kind) = agent_hooks::detect_agent_kind(cmd) {
let initial_value = agent_hooks::initial_agent_option_value(kind);
let opt_out = Command::new("tmux")
.args(["set-option", "-t", name, "@omniterm_agent", &initial_value])
.output()
.await?;
if !opt_out.status.success() {
warn!(
"failed to set @omniterm_agent for session {}: {}",
name,
String::from_utf8_lossy(&opt_out.stderr)
);
} else {
debug!("initialized @omniterm_agent for session {}: {}", name, initial_value);
}
let augmented =
agent_hooks::augment_agent_command(cmd).unwrap_or_else(|| cmd.to_string());
send_keys(name, &augmented).await?;
hook_injected = true;
debug!("sent agent command to session {}: {}", name, augmented);
} else {
send_keys(name, cmd).await?;
}
}
debug!("created tmux session: {} (cwd: {}, hook_injected: {})", name, cwd, hook_injected);
Ok(hook_injected)
}
pub async fn kill_session(name: &str) -> Result<()> {
let output = Command::new("tmux").args(["kill-session", "-t", name]).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("tmux kill-session failed: {}", stderr);
}
debug!("killed tmux session: {}", name);
Ok(())
}
pub async fn list_sessions() -> Result<Vec<TmuxSessionInfo>> {
let output = Command::new("tmux")
.args([
"list-sessions",
"-F",
"#{session_attached}|#{session_windows}|#{session_created}|#{@omniterm_agent}|#{pane_current_path}|#{session_name}",
])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout_str = String::from_utf8_lossy(&output.stdout);
if stderr.contains("no server running") || stdout_str.trim().is_empty() {
return Ok(vec![]);
}
return Err(anyhow!("tmux list-sessions failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let sessions = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 6 {
let attached = parts[0] != "0";
let windows: u32 = parts[1].parse().unwrap_or(1);
let created = parts[2].to_string();
let agent_val = parts[3];
let agent_snapshot = agent_state::parse_agent_value(agent_val);
let (agent_kind, agent_state, attention_reason, agent_event, agent_nonce) =
if let Some(snap) = agent_snapshot {
(
Some(snap.agent_kind.as_str().to_string()),
Some(snap.agent_state.as_str().to_string()),
snap.attention_reason.map(|r| r.as_str().to_string()),
snap.agent_event,
snap.agent_nonce,
)
} else {
(None, None, None, None, None)
};
let cwd_raw = parts[4];
let cwd = if cwd_raw.is_empty() { None } else { Some(cwd_raw.to_string()) };
let name = parts[5..].join("|");
Some(TmuxSessionInfo {
name,
attached,
windows,
created,
cwd,
agent_kind,
agent_state,
attention_reason,
agent_event,
agent_nonce,
})
} else {
None
}
})
.collect();
Ok(sessions)
}
pub async fn session_exists(name: &str) -> bool {
Command::new("tmux")
.args(["has-session", "-t", name])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false)
}
pub async fn send_keys(session: &str, keys: &str) -> Result<()> {
let output =
Command::new("tmux").args(["send-keys", "-t", session, keys, "Enter"]).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("tmux send-keys failed: {}", stderr));
}
Ok(())
}
pub async fn pane_cwd(session: &str) -> Result<String> {
let output = Command::new("tmux")
.args(["display-message", "-t", session, "-p", "#{pane_current_path}"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("tmux display-message failed: {}", stderr));
}
let cwd = String::from_utf8_lossy(&output.stdout).trim().to_string();
if cwd.is_empty() {
return Err(anyhow!("tmux session '{}' not found or has no pane", session));
}
Ok(cwd)
}
pub async fn capture_screen(session: &str) -> Result<String> {
let output = Command::new("tmux").args(["capture-pane", "-t", session, "-p"]).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("tmux capture-pane failed: {}", stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub async fn get_session_agent_option(session_name: &str) -> Result<Option<AgentSnapshot>> {
let output = Command::new("tmux")
.args(["show-options", "-t", session_name, "@omniterm_agent"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("unknown option") || stderr.contains("no such option") {
return Ok(None);
}
return Err(anyhow!("tmux show-options failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let value = stdout.strip_prefix("@omniterm_agent ").map(|v| v.trim()).unwrap_or("");
Ok(agent_state::parse_agent_value(value))
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TmuxSessionInfo {
pub name: String,
pub attached: bool,
pub windows: u32,
pub created: String,
pub cwd: Option<String>,
pub agent_kind: Option<String>,
pub agent_state: Option<String>,
pub attention_reason: Option<String>,
pub agent_event: Option<String>,
pub agent_nonce: Option<String>,
}
pub async fn get_mouse_option() -> Result<bool> {
let output = Command::new("tmux").args(["show-options", "-g", "mouse"]).output().await?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.trim().ends_with("on"))
}
pub async fn set_mouse_option(enabled: bool) -> Result<()> {
let value = if enabled { "on" } else { "off" };
let output = Command::new("tmux").args(["set-option", "-g", "mouse", value]).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("tmux set mouse failed: {}", stderr.trim()));
}
Ok(())
}