use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TmuxTarget {
pub session: String,
#[serde(default)]
pub pane: Option<String>,
}
impl TmuxTarget {
pub fn session(name: impl Into<String>) -> Self {
Self {
session: name.into(),
pane: None,
}
}
pub fn pane(name: impl Into<String>, pane: impl Into<String>) -> Self {
Self {
session: name.into(),
pane: Some(pane.into()),
}
}
pub fn as_target(&self) -> String {
match &self.pane {
Some(p) => p.clone(),
None => self.session.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TmuxCommand {
NewSession {
name: String,
workdir: Option<String>,
idempotent: bool,
command: Option<String>,
},
KillSession {
name: String,
},
RenameSession {
old: String,
new: String,
},
HasSession {
name: String,
},
ListSessions,
ListWindows {
name: String,
},
ListPanes {
name: String,
},
SendKeys {
target: TmuxTarget,
keys: String,
literal: bool,
},
CapturePane {
target: TmuxTarget,
lines: Option<u32>,
},
SetEnvironment {
session: String,
key: String,
value: String,
},
SetGlobalOption {
name: String,
value: String,
},
}
pub const SESSION_LIST_FORMAT: &str = "#{session_name}:#{session_created}:#{session_attached}";
pub const WINDOW_LIST_FORMAT: &str = "#{window_index}:#{window_name}";
pub const PANE_LIST_FORMAT: &str = "#{pane_id}:#{pane_active}";
pub const HISTORY_LIMIT_OPTION: &str = "history-limit";
pub const MOUSE_OPTION: &str = "mouse";
pub const DEFAULT_TMUX_HISTORY_LIMIT: u32 = 100_000;
pub const DEFAULT_TMUX_MOUSE: bool = true;
pub fn tmux_argv(cmd: &TmuxCommand) -> Vec<String> {
match cmd {
TmuxCommand::NewSession {
name,
workdir,
idempotent,
command,
} => {
let mut argv = vec!["new-session".to_string()];
if *idempotent {
argv.push("-A".to_string());
}
argv.push("-d".to_string());
argv.push("-s".to_string());
argv.push(name.clone());
if let Some(dir) = workdir {
argv.push("-c".to_string());
argv.push(dir.clone());
}
if let Some(cmd) = command {
argv.push(cmd.clone());
}
argv
}
TmuxCommand::KillSession { name } => {
vec!["kill-session".into(), "-t".into(), name.clone()]
}
TmuxCommand::RenameSession { old, new } => {
vec![
"rename-session".into(),
"-t".into(),
old.clone(),
new.clone(),
]
}
TmuxCommand::HasSession { name } => {
vec!["has-session".into(), "-t".into(), name.clone()]
}
TmuxCommand::ListSessions => {
vec![
"list-sessions".into(),
"-F".into(),
SESSION_LIST_FORMAT.into(),
]
}
TmuxCommand::ListWindows { name } => {
vec![
"list-windows".into(),
"-t".into(),
name.clone(),
"-F".into(),
WINDOW_LIST_FORMAT.into(),
]
}
TmuxCommand::ListPanes { name } => {
vec![
"list-panes".into(),
"-s".into(),
"-t".into(),
name.clone(),
"-F".into(),
PANE_LIST_FORMAT.into(),
]
}
TmuxCommand::SendKeys {
target,
keys,
literal,
} => {
let mut argv = vec![
"send-keys".to_string(),
"-t".to_string(),
target.as_target(),
];
if *literal {
argv.push("-l".to_string());
}
argv.push(keys.clone());
argv
}
TmuxCommand::CapturePane { target, lines } => {
let mut argv = vec![
"capture-pane".to_string(),
"-t".to_string(),
target.as_target(),
"-p".to_string(),
];
if let Some(n) = lines {
argv.push("-S".to_string());
argv.push(format!("-{n}"));
}
argv
}
TmuxCommand::SetEnvironment {
session,
key,
value,
} => {
vec![
"set-environment".to_string(),
"-t".to_string(),
session.clone(),
key.clone(),
value.clone(),
]
}
TmuxCommand::SetGlobalOption { name, value } => {
vec![
"set-option".to_string(),
"-g".to_string(),
name.clone(),
value.clone(),
]
}
}
}
pub fn scrollback_option_commands(history_limit: u32, mouse: bool) -> Vec<TmuxCommand> {
vec![
TmuxCommand::SetGlobalOption {
name: HISTORY_LIMIT_OPTION.to_string(),
value: history_limit.to_string(),
},
TmuxCommand::SetGlobalOption {
name: MOUSE_OPTION.to_string(),
value: if mouse { "on" } else { "off" }.to_string(),
},
]
}
pub fn managed_session_commands(
name: &str,
workdir: Option<&str>,
history_limit: u32,
mouse: bool,
idempotent: bool,
command: Option<&str>,
) -> Vec<TmuxCommand> {
let mut commands = scrollback_option_commands(history_limit, mouse);
commands.push(TmuxCommand::NewSession {
name: name.to_string(),
workdir: workdir.map(str::to_string),
idempotent,
command: command.map(str::to_string),
});
commands
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_renders_session_and_bare_pane() {
assert_eq!(TmuxTarget::session("s").as_target(), "s");
assert_eq!(TmuxTarget::pane("s", "%2").as_target(), "%2");
}
#[test]
fn new_session_argv_idempotent_with_workdir() {
let argv = tmux_argv(&TmuxCommand::NewSession {
name: "trusty-mpm-1".into(),
workdir: Some("/tmp/proj".into()),
idempotent: true,
command: None,
});
assert_eq!(
argv,
[
"new-session",
"-A",
"-d",
"-s",
"trusty-mpm-1",
"-c",
"/tmp/proj"
]
);
}
#[test]
fn new_session_argv_without_workdir_or_idempotent() {
let argv = tmux_argv(&TmuxCommand::NewSession {
name: "s".into(),
workdir: None,
idempotent: false,
command: None,
});
assert_eq!(argv, ["new-session", "-d", "-s", "s"]);
}
#[test]
fn new_session_argv_with_initial_command_is_trailing_positional() {
let argv = tmux_argv(&TmuxCommand::NewSession {
name: "debug-repl".into(),
workdir: Some("/tmp/proj".into()),
idempotent: false,
command: Some("bash -lc 'run-repl'".into()),
});
assert_eq!(
argv,
[
"new-session",
"-d",
"-s",
"debug-repl",
"-c",
"/tmp/proj",
"bash -lc 'run-repl'"
]
);
}
#[test]
fn send_keys_literal_argv() {
let argv = tmux_argv(&TmuxCommand::SendKeys {
target: TmuxTarget::session("s"),
keys: "claude --help".into(),
literal: true,
});
assert_eq!(argv, ["send-keys", "-t", "s", "-l", "claude --help"]);
}
#[test]
fn rename_session_argv() {
let argv = tmux_argv(&TmuxCommand::RenameSession {
old: "tm-old-01".into(),
new: "tm-new-01".into(),
});
assert_eq!(argv, ["rename-session", "-t", "tm-old-01", "tm-new-01"]);
}
#[test]
fn send_keys_keyname_argv() {
let argv = tmux_argv(&TmuxCommand::SendKeys {
target: TmuxTarget::pane("s", "%1"),
keys: "Enter".into(),
literal: false,
});
assert_eq!(argv, ["send-keys", "-t", "%1", "Enter"]);
}
#[test]
fn send_keys_pane_target_is_bare_id() {
let argv = tmux_argv(&TmuxCommand::SendKeys {
target: TmuxTarget::pane("trusty-mpm-xyz", "%6015"),
keys: "claude --resume".into(),
literal: true,
});
let target_arg = &argv[argv.iter().position(|a| a == "-t").unwrap() + 1];
assert_eq!(target_arg, "%6015", "pane target must be the bare pane id");
assert!(
!target_arg.contains(':'),
"pane target must not be session-qualified: {target_arg}"
);
}
#[test]
fn capture_argv() {
let argv = tmux_argv(&TmuxCommand::CapturePane {
target: TmuxTarget::session("s"),
lines: Some(50),
});
assert_eq!(argv, ["capture-pane", "-t", "s", "-p", "-S", "-50"]);
let argv = tmux_argv(&TmuxCommand::CapturePane {
target: TmuxTarget::session("s"),
lines: None,
});
assert_eq!(argv, ["capture-pane", "-t", "s", "-p"]);
}
#[test]
fn list_sessions_uses_canonical_format() {
let argv = tmux_argv(&TmuxCommand::ListSessions);
assert_eq!(argv, ["list-sessions", "-F", SESSION_LIST_FORMAT]);
}
#[test]
fn list_windows_argv() {
let argv = tmux_argv(&TmuxCommand::ListWindows {
name: "work".into(),
});
assert_eq!(
argv,
["list-windows", "-t", "work", "-F", WINDOW_LIST_FORMAT]
);
}
#[test]
fn list_panes_argv() {
let argv = tmux_argv(&TmuxCommand::ListPanes {
name: "work".into(),
});
assert_eq!(
argv,
["list-panes", "-s", "-t", "work", "-F", PANE_LIST_FORMAT]
);
}
#[test]
fn set_environment_argv() {
let argv = tmux_argv(&TmuxCommand::SetEnvironment {
session: "tmpm-brave-otter".into(),
key: "TM_MANAGED_SESSION_ID".into(),
value: "11111111-2222-3333-4444-555555555555".into(),
});
assert_eq!(
argv,
[
"set-environment",
"-t",
"tmpm-brave-otter",
"TM_MANAGED_SESSION_ID",
"11111111-2222-3333-4444-555555555555"
]
);
}
#[test]
fn set_global_option_argv() {
let argv = tmux_argv(&TmuxCommand::SetGlobalOption {
name: "history-limit".into(),
value: "100000".into(),
});
assert_eq!(argv, ["set-option", "-g", "history-limit", "100000"]);
}
#[test]
fn scrollback_option_commands_uses_configured_values() {
let cmds = scrollback_option_commands(50_000, true);
assert_eq!(cmds.len(), 2);
assert_eq!(
tmux_argv(&cmds[0]),
["set-option", "-g", "history-limit", "50000"]
);
assert_eq!(tmux_argv(&cmds[1]), ["set-option", "-g", "mouse", "on"]);
}
#[test]
fn scrollback_option_commands_mouse_off() {
let cmds = scrollback_option_commands(100_000, false);
assert_eq!(tmux_argv(&cmds[1]), ["set-option", "-g", "mouse", "off"]);
}
#[test]
fn managed_session_commands_orders_options_before_new_session() {
let cmds = managed_session_commands("sess", Some("/tmp"), 100_000, true, true, None);
assert_eq!(cmds.len(), 3);
assert_eq!(
tmux_argv(&cmds[0]),
["set-option", "-g", "history-limit", "100000"]
);
assert_eq!(tmux_argv(&cmds[1]), ["set-option", "-g", "mouse", "on"]);
assert_eq!(
tmux_argv(&cmds[2]),
["new-session", "-A", "-d", "-s", "sess", "-c", "/tmp"]
);
}
#[test]
fn managed_session_commands_idempotent_flag() {
let non_idempotent = managed_session_commands("sess", None, 100_000, true, false, None);
assert_eq!(
tmux_argv(&non_idempotent[2]),
["new-session", "-d", "-s", "sess"]
);
let idempotent = managed_session_commands("sess", None, 100_000, true, true, None);
assert_eq!(
tmux_argv(&idempotent[2]),
["new-session", "-A", "-d", "-s", "sess"]
);
}
#[test]
fn managed_session_commands_with_initial_command() {
let cmds =
managed_session_commands("debug-repl", None, 100_000, true, false, Some("run-repl"));
assert_eq!(
tmux_argv(&cmds[2]),
["new-session", "-d", "-s", "debug-repl", "run-repl"]
);
}
#[test]
fn default_history_limit_is_100_000() {
assert_eq!(DEFAULT_TMUX_HISTORY_LIMIT, 100_000);
}
}