use anyhow::{Context, Result};
use once_cell::sync::Lazy;
use regex::Regex;
use std::process::Command;
use super::pane::PaneInfo;
static TARGET_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[A-Za-z0-9_.-]+:\d+\.\d+$").expect("Invalid TARGET_PATTERN regex"));
fn validate_target(target: &str) -> Result<()> {
if !TARGET_PATTERN.is_match(target) {
anyhow::bail!("Invalid tmux target format: {}", target);
}
Ok(())
}
#[derive(Clone)]
pub struct TmuxClient {
capture_lines: u32,
}
impl TmuxClient {
pub fn new() -> Self {
Self { capture_lines: 100 }
}
pub fn with_capture_lines(capture_lines: u32) -> Self {
Self { capture_lines }
}
pub fn is_available(&self) -> bool {
Command::new("tmux")
.arg("list-sessions")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn list_sessions(&self) -> Result<Vec<String>> {
let output = Command::new("tmux")
.args(["list-sessions", "-F", "#{session_name}"])
.output()
.context("Failed to execute tmux list-sessions")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux list-sessions failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(|s| s.to_string()).collect())
}
pub fn list_panes(&self) -> Result<Vec<PaneInfo>> {
let output = Command::new("tmux")
.args([
"list-panes",
"-a",
"-F",
"#{session_attached}\t#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{window_name}\t#{pane_current_command}\t#{pane_pid}\t#{pane_title}\t#{pane_current_path}",
])
.output()
.context("Failed to execute tmux list-panes")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux list-panes failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let panes: Vec<PaneInfo> = stdout
.lines()
.filter_map(|line| {
let (attached, rest) = line.split_once('\t')?;
if attached != "0" {
PaneInfo::parse(rest)
} else {
None
}
})
.collect();
Ok(panes)
}
pub fn list_all_panes(&self) -> Result<Vec<PaneInfo>> {
let output = Command::new("tmux")
.args([
"list-panes",
"-a",
"-F",
"#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{window_name}\t#{pane_current_command}\t#{pane_pid}\t#{pane_title}\t#{pane_current_path}",
])
.output()
.context("Failed to execute tmux list-panes")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux list-panes failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let panes: Vec<PaneInfo> = stdout.lines().filter_map(PaneInfo::parse).collect();
Ok(panes)
}
pub fn capture_pane(&self, target: &str) -> Result<String> {
validate_target(target)?;
let start_line = format!("-{}", self.capture_lines);
let output = Command::new("tmux")
.args(["capture-pane", "-p", "-t", target, "-S", &start_line, "-e"])
.output()
.context("Failed to execute tmux capture-pane")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux capture-pane failed for {}: {}", target, stderr);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn capture_pane_plain(&self, target: &str) -> Result<String> {
validate_target(target)?;
let start_line = format!("-{}", self.capture_lines);
let output = Command::new("tmux")
.args(["capture-pane", "-p", "-t", target, "-S", &start_line])
.output()
.context("Failed to execute tmux capture-pane")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux capture-pane failed for {}: {}", target, stderr);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn get_pane_title(&self, target: &str) -> Result<String> {
validate_target(target)?;
let output = Command::new("tmux")
.args(["display-message", "-p", "-t", target, "#{pane_title}"])
.output()
.context("Failed to execute tmux display-message")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux display-message failed for {}: {}", target, stderr);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn send_keys(&self, target: &str, keys: &str) -> Result<()> {
validate_target(target)?;
let output = Command::new("tmux")
.args(["send-keys", "-t", target, keys])
.output()
.context("Failed to execute tmux send-keys")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux send-keys failed for {}: {}", target, stderr);
}
Ok(())
}
pub fn send_keys_literal(&self, target: &str, keys: &str) -> Result<()> {
validate_target(target)?;
let output = Command::new("tmux")
.args(["send-keys", "-t", target, "-l", keys])
.output()
.context("Failed to execute tmux send-keys")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux send-keys failed for {}: {}", target, stderr);
}
Ok(())
}
pub fn send_text_and_enter(&self, target: &str, text: &str) -> Result<()> {
validate_target(target)?;
let output = Command::new("tmux")
.args(["send-keys", "-t", target, "-l", "--", text])
.output()
.context("Failed to execute tmux send-keys (text)")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux send-keys (text) failed for {}: {}", target, stderr);
}
let output = Command::new("tmux")
.args(["send-keys", "-t", target, "Enter"])
.output()
.context("Failed to execute tmux send-keys (Enter)")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux send-keys (Enter) failed for {}: {}", target, stderr);
}
Ok(())
}
pub fn select_pane(&self, target: &str) -> Result<()> {
validate_target(target)?;
let output = Command::new("tmux")
.args(["select-pane", "-t", target])
.output()
.context("Failed to execute tmux select-pane")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux select-pane failed for {}: {}", target, stderr);
}
Ok(())
}
pub fn select_window(&self, target: &str) -> Result<()> {
validate_target(target)?;
let window_target = if let Some(pos) = target.rfind('.') {
&target[..pos]
} else {
target
};
let output = Command::new("tmux")
.args(["select-window", "-t", window_target])
.output()
.context("Failed to execute tmux select-window")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"tmux select-window failed for {}: {}",
window_target,
stderr
);
}
Ok(())
}
pub fn focus_pane(&self, target: &str) -> Result<()> {
self.select_window(target)?;
self.select_pane(target)?;
Ok(())
}
pub fn create_session(&self, name: &str, cwd: &str, window_name: Option<&str>) -> Result<()> {
let mut args = vec!["new-session", "-d", "-s", name, "-c", cwd];
if let Some(wn) = window_name {
args.push("-n");
args.push(wn);
}
let output = Command::new("tmux")
.args(&args)
.output()
.context("Failed to execute tmux new-session")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux new-session failed: {}", stderr);
}
self.open_session_in_wezterm_tab(name);
Ok(())
}
fn open_session_in_wezterm_tab(&self, session_name: &str) {
if std::env::var("WEZTERM_PANE").is_err() {
return; }
let window_id = match Self::get_wezterm_window_id() {
Some(id) => id,
None => return,
};
let _ = Command::new("wezterm")
.args([
"cli",
"spawn",
"--window-id",
&window_id,
"--",
"tmux",
"attach",
"-t",
session_name,
])
.spawn();
}
fn get_wezterm_window_id() -> Option<String> {
let output = Command::new("wezterm")
.args(["cli", "list", "--format", "json"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(panes) = serde_json::from_str::<Vec<serde_json::Value>>(&stdout) {
for pane in &panes {
if let Some(is_active) = pane.get("is_active").and_then(|v| v.as_bool()) {
if is_active {
if let Some(window_id) = pane.get("window_id").and_then(|v| v.as_u64()) {
return Some(window_id.to_string());
}
}
}
}
if let Some(first) = panes.first() {
if let Some(window_id) = first.get("window_id").and_then(|v| v.as_u64()) {
return Some(window_id.to_string());
}
}
}
None
}
pub fn new_window(
&self,
session: &str,
cwd: &str,
window_name: Option<&str>,
) -> Result<String> {
let mut args = vec![
"new-window",
"-d",
"-t",
session,
"-c",
cwd,
"-P",
"-F",
"#{session_name}:#{window_index}.#{pane_index}",
];
if let Some(wn) = window_name {
args.push("-n");
args.push(wn);
}
let output = Command::new("tmux")
.args(&args)
.output()
.context("Failed to execute tmux new-window")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux new-window failed: {}", stderr);
}
let target = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(target)
}
pub fn split_window(&self, session: &str, cwd: &str) -> Result<String> {
let output = Command::new("tmux")
.args([
"split-window",
"-d",
"-t",
session,
"-c",
cwd,
"-P",
"-F",
"#{session_name}:#{window_index}.#{pane_index}",
])
.output()
.context("Failed to execute tmux split-window")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux split-window failed: {}", stderr);
}
let target = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(target)
}
pub fn run_command(&self, target: &str, command: &str) -> Result<()> {
validate_target(target)?;
self.send_keys_literal(target, command)?;
self.send_keys(target, "Enter")?;
Ok(())
}
pub fn run_command_wrapped(&self, target: &str, command: &str) -> Result<()> {
validate_target(target)?;
let tmai_path = std::env::current_exe()
.map(|p| p.display().to_string())
.map(|s| s.strip_suffix(" (deleted)").unwrap_or(&s).to_string())
.unwrap_or_else(|_| "tmai".to_string());
let wrapped_command = format!("\"{}\" wrap {}", tmai_path, command);
self.send_keys_literal(target, &wrapped_command)?;
self.send_keys(target, "Enter")?;
Ok(())
}
pub fn kill_pane(&self, target: &str) -> Result<()> {
validate_target(target)?;
let output = Command::new("tmux")
.args(["kill-pane", "-t", target])
.output()
.context("Failed to execute tmux kill-pane")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux kill-pane failed for {}: {}", target, stderr);
}
Ok(())
}
pub fn get_current_location(&self) -> Result<(String, u32)> {
let output = Command::new("tmux")
.args(["display-message", "-p", "#{session_name}\t#{window_index}"])
.output()
.context("Failed to execute tmux display-message")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux display-message failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
let (session, window_str) = stdout
.split_once('\t')
.context("Invalid tmux display-message output")?;
let window_index = window_str.parse().context("Invalid window index")?;
Ok((session.to_string(), window_index))
}
}
impl Default for TmuxClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = TmuxClient::new();
assert_eq!(client.capture_lines, 100);
let custom_client = TmuxClient::with_capture_lines(200);
assert_eq!(custom_client.capture_lines, 200);
}
#[test]
fn test_validate_target_valid() {
assert!(validate_target("main:0.0").is_ok());
assert!(validate_target("my-session:1.2").is_ok());
assert!(validate_target("my.session:1.2").is_ok());
assert!(validate_target("test_session:10.5").is_ok());
assert!(validate_target("abc123:99.99").is_ok());
}
#[test]
fn test_validate_target_invalid() {
assert!(validate_target("").is_err());
assert!(validate_target("main").is_err());
assert!(validate_target("main:0").is_err());
assert!(validate_target("; rm -rf /").is_err());
assert!(validate_target("main:0.0; echo pwned").is_err());
assert!(validate_target("$(whoami):0.0").is_err());
assert!(validate_target("`whoami`:0.0").is_err());
assert!(validate_target("main:0.0\necho evil").is_err());
assert!(validate_target("../etc/passwd").is_err());
}
}