use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;
use crate::config;
use crate::sessions::Tmux;
pub fn run(file: &Path, session_name: Option<&str>) -> Result<()> {
let tmux = Tmux::default();
let cfg = config::load()?;
let target = resolve_target_session(&tmux, file, session_name)?;
match target {
SessionTarget::Attached(name) => {
eprintln!("[terminal] session '{}' already has an attached client — skipping", name);
Ok(())
}
SessionTarget::Detached(name) => {
eprintln!("[terminal] session '{}' exists but is detached — opening terminal to attach", name);
launch_terminal(&cfg, &name)
}
SessionTarget::Create(name) => {
eprintln!("[terminal] no active session found — creating '{}'", name);
launch_terminal(&cfg, &name)
}
}
}
enum SessionTarget {
Attached(String),
Detached(String),
Create(String),
}
fn resolve_target_session(
tmux: &Tmux,
file: &Path,
explicit: Option<&str>,
) -> Result<SessionTarget> {
if !tmux.running() {
let name = resolve_session_name(file, explicit)?;
return Ok(SessionTarget::Create(name));
}
if let Some(name) = explicit {
return Ok(classify_session(tmux, name));
}
if let Some(active_session) = find_active_project_session(tmux)? {
eprintln!("[terminal] targeting session '{}' (from registry scan)", active_session);
return Ok(classify_session(tmux, &active_session));
}
let name = resolve_session_name(file, None)?;
Ok(SessionTarget::Create(name))
}
fn classify_session(tmux: &Tmux, name: &str) -> SessionTarget {
if is_session_attached(tmux, name) {
SessionTarget::Attached(name.to_string())
} else {
SessionTarget::Detached(name.to_string())
}
}
fn find_active_project_session(tmux: &Tmux) -> Result<Option<String>> {
let registry = crate::sessions::load()?;
for entry in registry.values() {
if tmux.pane_alive(&entry.pane) {
if let Some(session_name) = pane_session_name(tmux, &entry.pane) {
return Ok(Some(session_name));
}
}
}
Ok(None)
}
fn pane_session_name(tmux: &Tmux, pane_id: &str) -> Option<String> {
let output = tmux
.cmd()
.args([
"display-message",
"-t",
pane_id,
"-p",
"#{session_name}",
])
.output()
.ok()?;
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() {
return Some(name);
}
}
None
}
fn launch_terminal(cfg: &config::Config, session_name: &str) -> Result<()> {
let tmux_command = format!("tmux new-session -A -s {}", shell_escape(session_name));
let terminal_cmd = resolve_terminal_command(cfg, &tmux_command)?;
eprintln!("[terminal] launching: {}", terminal_cmd);
spawn_terminal(&terminal_cmd)
}
fn resolve_session_name(_file: &Path, explicit: Option<&str>) -> Result<String> {
if let Some(name) = explicit {
return Ok(name.to_string());
}
Ok("0".to_string())
}
fn is_session_attached(tmux: &Tmux, session: &str) -> bool {
let output = tmux
.cmd()
.args(["list-clients", "-t", session, "-F", "#{client_name}"])
.output();
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
!stdout.trim().is_empty()
}
_ => false,
}
}
fn resolve_terminal_command(cfg: &config::Config, tmux_command: &str) -> Result<String> {
if let Some(ref terminal) = cfg.terminal
&& let Some(ref cmd_template) = terminal.command
{
let resolved = cmd_template.replace("{tmux_command}", tmux_command);
return Ok(resolved);
}
if let Ok(terminal) = std::env::var("TERMINAL")
&& !terminal.is_empty()
{
return Ok(format!("{} -e {}", terminal, tmux_command));
}
anyhow::bail!(
"No terminal configured.\n\
\n\
Add to ~/.config/agent-doc/config.toml:\n\
\n\
[terminal]\n\
command = \"<your-terminal> -- {{tmux_command}}\"\n\
\n\
Examples:\n\
command = \"wezterm start -- {{tmux_command}}\"\n\
command = \"kitty -- {{tmux_command}}\"\n\
command = \"alacritty -e {{tmux_command}}\"\n\
command = \"gnome-terminal -- {{tmux_command}}\"\n\
\n\
Or set the $TERMINAL environment variable."
)
}
fn spawn_terminal(command: &str) -> Result<()> {
let parts: Vec<&str> = command.split_whitespace().collect();
if parts.is_empty() {
anyhow::bail!("terminal command is empty");
}
Command::new(parts[0])
.args(&parts[1..])
.spawn()
.with_context(|| format!("failed to launch terminal: {}", parts[0]))?;
Ok(())
}
fn shell_escape(s: &str) -> String {
if s.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
s.to_string()
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_escape_simple() {
assert_eq!(shell_escape("my-session"), "my-session");
assert_eq!(shell_escape("0"), "0");
}
#[test]
fn shell_escape_special() {
assert_eq!(shell_escape("my session"), "'my session'");
}
#[test]
fn resolve_terminal_from_config() {
let mut cfg = config::Config::default();
cfg.terminal = Some(config::TerminalConfig {
command: Some("wezterm start -- {tmux_command}".to_string()),
});
let result = resolve_terminal_command(&cfg, "tmux new-session -A -s 0").unwrap();
assert_eq!(result, "wezterm start -- tmux new-session -A -s 0");
}
#[test]
fn resolve_terminal_no_config_no_env() {
let cfg = config::Config::default();
unsafe { std::env::remove_var("TERMINAL"); }
let result = resolve_terminal_command(&cfg, "tmux new-session -A -s 0");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("No terminal configured"), "got: {}", err);
}
#[test]
fn classify_session_target() {
let target = SessionTarget::Create("test".to_string());
match target {
SessionTarget::Attached(n) => panic!("expected Create, got Attached({})", n),
SessionTarget::Detached(n) => panic!("expected Create, got Detached({})", n),
SessionTarget::Create(n) => assert_eq!(n, "test"),
}
}
#[test]
fn resolve_session_name_defaults_to_zero() {
let tmp = tempfile::TempDir::new().unwrap();
let doc = tmp.path().join("test.md");
std::fs::write(&doc, "---\ntmux_session: custom-session\n---\n").unwrap();
let name = resolve_session_name(&doc, None).unwrap();
assert_eq!(name, "0", "should default to '0', not read frontmatter tmux_session");
}
#[test]
fn resolve_session_name_uses_explicit_flag() {
let tmp = tempfile::TempDir::new().unwrap();
let doc = tmp.path().join("test.md");
std::fs::write(&doc, "---\ntmux_session: wrong-session\n---\n").unwrap();
let name = resolve_session_name(&doc, Some("correct")).unwrap();
assert_eq!(name, "correct", "explicit flag should take precedence");
}
}