use anyhow::{anyhow, Context, Result};
use serde::Serialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use crate::shell_integration::{detect_session_shell, v2_snippet, Shell};
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
pub fn tmux_command(target: Option<&str>, tmux_args: &[&str]) -> Command {
match target {
None => {
let mut cmd = Command::new("tmux");
if let Ok(socket) = std::env::var("MOBUX_TMUX_SOCKET") {
if !socket.is_empty() {
cmd.arg("-L").arg(socket);
}
}
cmd.args(tmux_args);
cmd
}
Some(ssh_target) => {
let mut cmd = Command::new("ssh");
let quoted: Vec<String> = tmux_args.iter().map(|a| shell_quote(a)).collect();
let remote_cmd = format!("tmux {}", quoted.join(" "));
cmd.args([
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=3",
ssh_target,
"--",
&remote_cmd,
]);
cmd
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub name: String,
pub windows: i32,
pub attached: i32,
pub created_unix: i64,
}
fn is_no_server_error(msg: &str) -> bool {
msg.contains("failed to connect to server")
|| msg.contains("no server running")
|| msg.contains("error connecting to")
}
pub async fn list_sessions(target: Option<&str>) -> Result<Vec<Session>> {
let output = tmux_command(
target,
&[
"list-sessions",
"-F",
"#{session_name}\t#{session_windows}\t#{session_attached}\t#{session_created}",
],
)
.output()
.await
.context("failed to execute tmux")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
if is_no_server_error(&msg) {
return Ok(vec![]);
}
return Err(anyhow!("tmux list-sessions failed: {}", msg));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut out = vec![];
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() != 4 {
continue;
}
out.push(Session {
name: parts[0].to_string(),
windows: parts[1].parse().unwrap_or(0),
attached: parts[2].parse().unwrap_or(0),
created_unix: parts[3].parse().unwrap_or(0),
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn new_session_args(name: &str, shell_cmd: Option<&str>, home: Option<&Path>) -> Vec<String> {
let mut args: Vec<String> = vec!["new-session".into(), "-d".into()];
if let Some(home) = home {
args.push("-c".into());
args.push(home.display().to_string());
}
args.push("-s".into());
args.push(name.into());
if let Some(cmd) = shell_cmd {
args.push(cmd.into());
}
args
}
pub async fn new_session(name: &str, target: Option<&str>) -> Result<()> {
let Some(ssh_target) = target else {
let (shell_type, shell_path) = detect_session_shell();
let shell_cmd = prepare_shell_with_osc133(shell_type, &shell_path)?;
let home = home_dir().ok();
let args = new_session_args(name, Some(&shell_cmd), home.as_deref());
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
let output = tmux_command(None, &args_ref)
.output()
.await
.context("failed to execute tmux")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("tmux new-session failed: {}", msg));
}
let _ = tmux_command(
None,
&["set-option", "-t", name, "default-command", &shell_cmd],
)
.output()
.await;
return Ok(());
};
let args = new_session_args(name, None, None);
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
let output = tmux_command(Some(ssh_target), &args_ref)
.output()
.await
.context("failed to execute tmux")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("tmux new-session failed: {}", msg));
}
Ok(())
}
fn prepare_shell_with_osc133(shell: Shell, shell_path: &str) -> Result<String> {
let data_dir = resolve_shell_init_dir()?;
fs::create_dir_all(&data_dir)
.with_context(|| format!("creating shell-init dir: {}", data_dir.display()))?;
match shell {
Shell::Bash => prepare_bash_rcfile(&data_dir, shell_path),
Shell::Zsh => prepare_zsh_zdotdir(&data_dir, shell_path),
Shell::Fish => prepare_fish_command(shell_path),
}
}
fn resolve_shell_init_dir() -> Result<PathBuf> {
let data_dir = if let Ok(override_dir) = env::var("MOBUX_DATA_DIR") {
PathBuf::from(override_dir)
} else {
let dirs = directories::ProjectDirs::from("", "", "mobux")
.ok_or_else(|| anyhow!("could not resolve user home for shell-init dir"))?;
dirs.data_dir().to_path_buf()
};
Ok(data_dir.join("shell-init"))
}
fn prepare_bash_rcfile(shell_init_dir: &Path, shell_path: &str) -> Result<String> {
let rcfile_path = shell_init_dir.join("mobux-bashrc");
let user_bashrc = home_dir()?.join(".bashrc");
let mut content = String::new();
if user_bashrc.exists() {
content.push_str(&format!("source {:?}\n", user_bashrc.display().to_string()));
}
content.push_str(
"
# mobux OSC 133 injection (session-scoped, lazy activation)
_mobux_osc133_ready=0
_mobux_activate_osc133() {
if [[ $_mobux_osc133_ready -eq 0 ]]; then
_mobux_osc133_ready=1
return
fi
unset PROMPT_COMMAND
",
);
content.push_str(v2_snippet(Shell::Bash));
content.push_str(
"
}
PROMPT_COMMAND=_mobux_activate_osc133
",
);
fs::write(&rcfile_path, content)
.with_context(|| format!("writing {}", rcfile_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&rcfile_path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&rcfile_path, perms)?;
}
Ok(format!(
"{} --rcfile {:?}",
shell_path,
rcfile_path.display().to_string()
))
}
fn prepare_zsh_zdotdir(shell_init_dir: &Path, shell_path: &str) -> Result<String> {
let zdotdir = shell_init_dir.join("mobux-zsh");
fs::create_dir_all(&zdotdir)
.with_context(|| format!("creating ZDOTDIR: {}", zdotdir.display()))?;
let zshrc_path = zdotdir.join(".zshrc");
let user_zshrc = home_dir()?.join(".zshrc");
let mut content = String::new();
if user_zshrc.exists() {
content.push_str(&format!("source {:?}\n", user_zshrc.display().to_string()));
}
content.push_str(
"
# mobux OSC 133 injection (session-scoped, lazy activation)
_mobux_osc133_ready=0
_mobux_activate_osc133() {
if [[ $_mobux_osc133_ready -eq 0 ]]; then
_mobux_osc133_ready=1
return
fi
unset -f precmd
",
);
content.push_str(v2_snippet(Shell::Zsh));
content.push_str(
"
}
precmd() { _mobux_activate_osc133 }
",
);
fs::write(&zshrc_path, content).with_context(|| format!("writing {}", zshrc_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&zshrc_path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&zshrc_path, perms)?;
}
Ok(format!(
"ZDOTDIR={:?} {}",
zdotdir.display().to_string(),
shell_path
))
}
fn prepare_fish_command(shell_path: &str) -> Result<String> {
Ok(shell_path.to_string())
}
fn home_dir() -> Result<PathBuf> {
env::var("HOME")
.map(PathBuf::from)
.map_err(|_| anyhow!("HOME not set"))
}
pub async fn kill_session(name: &str, target: Option<&str>) -> Result<()> {
let output = tmux_command(target, &["kill-session", "-t", name])
.output()
.await
.context("failed to execute tmux")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("tmux kill-session failed: {}", msg));
}
Ok(())
}
pub async fn rename_session(old_name: &str, new_name: &str, target: Option<&str>) -> Result<()> {
let output = tmux_command(target, &["rename-session", "-t", old_name, new_name])
.output()
.await
.context("failed to execute tmux")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("tmux rename-session failed: {}", msg));
}
Ok(())
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Pane {
pub id: String,
pub index: String,
pub title: String,
pub active: bool,
}
pub async fn list_panes(session: &str, target: Option<&str>) -> Result<Vec<Pane>> {
let output = tmux_command(
target,
&[
"list-windows",
"-t",
session,
"-F",
"#{window_id}\t#{window_index}\t#{window_name}\t#{window_active}",
],
)
.output()
.await
.context("failed to execute tmux")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("tmux list-windows failed: {}", msg));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut out = vec![];
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() != 4 {
continue;
}
out.push(Pane {
id: parts[0].to_string(),
index: parts[1].to_string(),
title: parts[2].to_string(),
active: parts[3] == "1",
});
}
Ok(out)
}
pub async fn select_pane(session: &str, window_index: &str, target: Option<&str>) -> Result<()> {
let window_target = format!("{}:{}", session, window_index);
let output = tmux_command(target, &["select-window", "-t", &window_target])
.output()
.await
.context("failed to execute tmux")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("tmux select-window failed: {}", msg));
}
Ok(())
}
pub async fn run_command(session: &str, command: &str, target: Option<&str>) -> Result<String> {
let win_target = format!("{}:", session);
let args: Vec<String> = match command {
"new-window" => vec!["new-window".into(), "-t".into(), win_target],
"kill-window" => vec!["kill-window".into(), "-t".into(), win_target],
"split-h" => vec!["split-window".into(), "-h".into(), "-t".into(), win_target],
"split-v" => vec!["split-window".into(), "-v".into(), "-t".into(), win_target],
"next-window" => vec!["next-window".into(), "-t".into(), win_target],
"prev-window" => vec!["previous-window".into(), "-t".into(), win_target],
"next-pane" => vec!["select-pane".into(), "-t".into(), format!("{}:+", session)],
"prev-pane" => vec!["select-pane".into(), "-t".into(), format!("{}:-", session)],
"kill-pane" => vec!["kill-pane".into(), "-t".into(), win_target],
"zoom-pane" => vec!["resize-pane".into(), "-Z".into(), "-t".into(), win_target],
_ => return Err(anyhow!("unknown command: {}", command)),
};
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = tmux_command(target, &args_ref)
.output()
.await
.context("failed to execute tmux")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
if msg.contains("no remaining")
|| msg.contains("session not found")
|| msg.contains("can't find")
|| msg.contains("no current")
{
return Ok(msg);
}
return Err(anyhow!("tmux {} failed: {}", command, msg));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub async fn install_bell_hook(port: u16, token: &str) -> Result<()> {
let hook_cmd = format!(
"run-shell -b 'curl -fsS --max-time 2 \
-H \"X-Mobux-Token: {token}\" \
-X POST \
\"http://127.0.0.1:{port}/internal/trigger?kind=bell&session=#{{hook_session_name}}&window=#{{window_index}}\" \
>/dev/null 2>&1 || true'"
);
let output = Command::new("tmux")
.args(["set-hook", "-g", "alert-bell", &hook_cmd])
.output()
.await
.context("failed to execute tmux set-hook")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("tmux set-hook alert-bell failed: {}", msg));
}
Ok(())
}
pub async fn capture_history(session: &str, lines: i32, target: Option<&str>) -> Result<String> {
let start = format!("-{}", lines);
let output = tmux_command(
target,
&[
"capture-pane",
"-p", "-e", "-S",
&start, "-t",
session,
],
)
.output()
.await
.context("failed to execute tmux capture-pane")?;
if !output.status.success() {
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(anyhow!("tmux capture-pane failed: {}", msg));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_server_error_matches_known_tmux_phrasings() {
assert!(is_no_server_error("failed to connect to server"));
assert!(is_no_server_error(
"no server running on /tmp/tmux-1000/default"
));
assert!(is_no_server_error(
"error connecting to /tmp/tmux-1000/default (No such file or directory)"
));
assert!(!is_no_server_error("unknown command: list-sessionz"));
}
#[test]
fn new_session_starts_in_home_directory() {
let args = new_session_args("work", Some("bash"), Some(Path::new("/home/alice")));
assert_eq!(
args,
vec![
"new-session",
"-d",
"-c",
"/home/alice",
"-s",
"work",
"bash"
]
);
}
#[test]
fn new_session_without_home_omits_start_directory() {
let args = new_session_args("work", Some("bash"), None);
assert_eq!(args, vec!["new-session", "-d", "-s", "work", "bash"]);
}
#[test]
fn new_session_remote_omits_shell_cmd_and_home() {
let args = new_session_args("work", None, None);
assert_eq!(args, vec!["new-session", "-d", "-s", "work"]);
}
fn argv(cmd: &Command) -> Vec<String> {
cmd.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[test]
fn tmux_command_local_is_plain_tmux() {
std::env::remove_var("MOBUX_TMUX_SOCKET");
let cmd = tmux_command(None, &["list-sessions"]);
assert_eq!(
cmd.as_std().get_program().to_string_lossy(),
"tmux",
"no target => plain local tmux"
);
assert_eq!(argv(&cmd), vec!["list-sessions"]);
}
#[test]
fn tmux_command_remote_wraps_in_batch_mode_ssh() {
let cmd = tmux_command(Some("mvhenten@devbox"), &["kill-session", "-t", "work"]);
assert_eq!(cmd.as_std().get_program().to_string_lossy(), "ssh");
assert_eq!(
argv(&cmd),
vec![
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=3",
"mvhenten@devbox",
"--",
"tmux 'kill-session' '-t' 'work'",
]
);
}
#[test]
fn tmux_command_remote_quotes_tab_containing_format_strings() {
let cmd = tmux_command(
Some("devbox"),
&["list-sessions", "-F", "#{session_name}\t#{session_windows}"],
);
let remote_cmd = argv(&cmd).pop().expect("remote command string");
assert_eq!(
remote_cmd,
"tmux 'list-sessions' '-F' '#{session_name}\t#{session_windows}'",
);
assert_eq!(remote_cmd.matches('\'').count(), 6, "3 quoted words");
}
#[test]
fn shell_quote_escapes_embedded_single_quotes() {
assert_eq!(shell_quote("plain"), "'plain'");
assert_eq!(shell_quote("it's"), r"'it'\''s'");
}
}