use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::Utc;
use tokio::time::Duration;
use tracing::{debug, warn};
use crate::control::event::SessionEvent;
use crate::control::id::ControlSessionId;
use crate::core::tmux::TmuxTarget;
use crate::daemon::tmux::TmuxDriver;
use super::{SessionBackend, SessionInput};
pub const TMUX_POLL_INTERVAL_MS: u64 = 200;
const _: () = assert!(TMUX_POLL_INTERVAL_MS > 0, "poll interval must be > 0ms");
pub fn tmux_session_name(session_id: &ControlSessionId) -> String {
if let Some((proj, n)) = session_id.parse_parts() {
format!("tm:{proj}:{n}")
} else {
format!("tm:{}", session_id.as_str())
}
}
pub struct TmuxBackend {
session_id: ControlSessionId,
tmux_name: String,
driver: TmuxDriver,
spawned: bool,
capture_lines: u32,
last_pane_content: String,
}
impl TmuxBackend {
pub fn new(
session_id: ControlSessionId,
workdir: impl Into<std::path::PathBuf>,
prompt_file: Option<std::path::PathBuf>,
claude_cmd: String,
capture_lines: u32,
) -> Result<Self> {
let workdir = workdir.into();
let tmux_name = tmux_session_name(&session_id);
let driver = TmuxDriver::discover()
.with_context(|| format!("tmux unavailable for session {session_id}"))?;
let workdir_str = workdir.to_string_lossy().to_string();
driver
.create_session(&tmux_name, Some(&workdir_str))
.with_context(|| {
format!("failed to create tmux session {tmux_name} in {workdir_str}")
})?;
let cmd_str = if let Some(pf) = prompt_file {
format!("{claude_cmd} --append-system-prompt-file {}", pf.display())
} else {
claude_cmd
};
let target = TmuxTarget::session(&tmux_name);
driver
.send_line(&target, &cmd_str)
.with_context(|| format!("failed to start claude in tmux session {tmux_name}"))?;
debug!(
session_id = %session_id,
tmux_name = %tmux_name,
"tmux backend spawned"
);
Ok(Self {
session_id,
tmux_name,
driver,
spawned: true,
capture_lines,
last_pane_content: String::new(),
})
}
}
#[async_trait]
impl SessionBackend for TmuxBackend {
async fn send(&mut self, msg: SessionInput) -> Result<()> {
let target = TmuxTarget::session(&self.tmux_name);
self.driver
.send_line(&target, &msg.text)
.with_context(|| format!("send-keys to tmux session {} failed", self.tmux_name))
}
async fn recv(&mut self) -> Option<Result<Vec<SessionEvent>>> {
let target = TmuxTarget::session(&self.tmux_name);
match self
.driver
.capture(&target, Some(self.capture_lines))
.with_context(|| format!("capture-pane for tmux session {} failed", self.tmux_name))
{
Ok(output) => {
let is_empty = output.trim().is_empty();
let is_unchanged = !is_empty && output == self.last_pane_content;
if is_empty || is_unchanged {
tokio::time::sleep(Duration::from_millis(TMUX_POLL_INTERVAL_MS)).await;
return Some(Ok(vec![]));
}
self.last_pane_content = output.clone();
let event = SessionEvent::Output {
session_id: self.session_id.clone(),
raw: output,
structured: None, ts: Utc::now(),
};
Some(Ok(vec![event]))
}
Err(e) => {
warn!(
session_id = %self.session_id,
tmux_name = %self.tmux_name,
"tmux capture-pane failed (session may have exited): {e}"
);
None }
}
}
async fn stop(mut self) -> Result<()> {
if self.spawned {
self.spawned = false;
if let Err(e) = self.driver.kill_session(&self.tmux_name) {
warn!(
session_id = %self.session_id,
tmux_name = %self.tmux_name,
"kill_session failed (may already be gone): {e}"
);
}
}
Ok(())
}
}
impl Drop for TmuxBackend {
fn drop(&mut self) {
if self.spawned
&& let Err(e) = self.driver.kill_session(&self.tmux_name)
{
debug!(
session_id = %self.session_id,
"TmuxBackend::drop: kill_session failed: {e}"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tmux_backend_session_name() {
let id = ControlSessionId::new("trusty-tools", 0);
assert_eq!(tmux_session_name(&id), "tm:trusty-tools:0");
let id2 = ControlSessionId::new("my-proj", 3);
assert_eq!(tmux_session_name(&id2), "tm:my-proj:3");
}
#[test]
fn tmux_backend_session_name_hyphenated_project() {
let id = ControlSessionId::new("some-long-project", 7);
assert_eq!(tmux_session_name(&id), "tm:some-long-project:7");
}
#[test]
#[ignore = "only runs when tmux is absent from PATH"]
fn tmux_backend_constructs_without_spawning() {
let id = ControlSessionId::new("proj", 0);
let result = TmuxBackend::new(id, "/tmp", None, "claude".into(), 100);
assert!(result.is_err());
}
}