use std::io::Write;
use std::path::{Path, PathBuf};
use taimux_core::tmux;
const POPUP_COND: &str = "[ \"#{client_width}\" -lt 100 ]";
pub fn popup_geometry(client_width: usize) -> (u16, u16) {
if client_width < 100 {
(100, 90)
} else {
(80, 80)
}
}
pub fn popup_cmd(launcher: &str, small: bool) -> String {
let (w, h) = popup_geometry(if small { 80 } else { 100 });
format!(
"display-popup -E -e TAIMUX_POPUP=1 -w {}% -h {}% \"{} pick #{{pane_id}}\"",
w, h, launcher
)
}
pub fn tmux_opt(name: &str, default: &str) -> String {
match tmux::ask(&["show-options", "-gq", name]) {
Some(shown) if !shown.trim().is_empty() => {
tmux::ask(&["show-options", "-gqv", name]).unwrap_or_default()
}
_ => default.to_string(),
}
}
pub fn bind_live(launcher: &str, key: &str, root: &str) -> String {
let (small, large) = (popup_cmd(launcher, true), popup_cmd(launcher, false));
let mut desc = String::new();
if !key.is_empty() && tmux::run(&["bind-key", key, "if-shell", POPUP_COND, &small, &large]) {
desc = format!("prefix + {}", key);
}
if !root.is_empty()
&& tmux::run(&[
"bind-key", "-n", root, "if-shell", POPUP_COND, &small, &large,
])
{
if desc.is_empty() {
desc = root.to_string();
} else {
desc = format!("{} and {}", desc, root);
}
}
desc
}
pub fn plugin_checkout(exe: &Path) -> bool {
let Some(parent) = exe.parent().and_then(|p| p.parent()) else {
return false;
};
let home = std::env::var("HOME").unwrap_or_default();
let xdg = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| format!("{}/.config", home));
[
std::env::var("TMUX_PLUGIN_MANAGER_PATH").unwrap_or_default(),
format!("{}/.tmux/plugins", home),
format!("{}/tmux/plugins", xdg),
]
.iter()
.any(|p| !p.is_empty() && parent == Path::new(p.trim_end_matches('/')))
}
pub fn is_oh_my_tmux(resolved: &Path) -> bool {
let home = std::env::var("HOME").unwrap_or_default();
if resolved.starts_with(format!("{}/.tmux/", home)) {
return true;
}
std::fs::read_to_string(resolved)
.map(|t| t.contains("_apply_configuration"))
.unwrap_or(false)
}
pub fn choose_conf(resolved: &Path, local: &Path) -> PathBuf {
if local.exists() || is_oh_my_tmux(resolved) {
local.to_path_buf()
} else {
resolved.to_path_buf()
}
}
pub fn strip_block(text: &str) -> Option<String> {
let ours = |l: &str| {
l.contains("claude-tmux-locator pick")
|| l.contains("muxhop pick")
|| l.contains("taimux pick")
|| l.starts_with("# claude-tmux-locator")
|| l.starts_with("#claude-tmux-locator")
|| l.starts_with("# muxhop")
|| l.starts_with("#muxhop")
|| l.starts_with("# taimux")
|| l.starts_with("#taimux")
};
if !text.lines().any(ours) {
return None;
}
Some(
text.lines()
.filter(|l| !ours(l))
.map(|l| format!("{}\n", l))
.collect(),
)
}
pub fn insert_block(text: &str, block: &str) -> String {
let mut out = String::new();
let mut done = false;
for l in text.lines() {
out.push_str(l);
out.push('\n');
if !done && l.contains("-- user customizations") {
out.push_str(block);
done = true;
}
}
if !done {
out.push_str(block);
}
out
}
pub fn block_for(launcher: &str, key: &str, root: &str, desc: &str) -> String {
let (small, large) = (popup_cmd(launcher, true), popup_cmd(launcher, false));
let mut b = format!(
"\n# taimux - jump between agent sessions - {} (adaptive popup width)\n",
if desc.is_empty() { "unbound" } else { desc }
);
if !key.is_empty() {
b.push_str(&format!(
"bind-key {} if-shell '{}' '{}' '{}'\n",
key, POPUP_COND, small, large
));
}
if !root.is_empty() {
b.push_str(&format!(
"bind-key -n {} if-shell '{}' '{}' '{}'\n",
root, POPUP_COND, small, large
));
}
b
}
pub fn describe(key: &str, root: &str) -> String {
match (key.is_empty(), root.is_empty()) {
(true, true) => String::new(),
(false, true) => format!("prefix + {}", key),
(true, false) => root.to_string(),
(false, false) => format!("prefix + {} and {}", key, root),
}
}
pub fn bind(exe: &str) -> i32 {
if tmux::ask(&["show-options", "-g"]).is_none() {
eprintln!(
"no tmux server to bind in: run it from inside tmux, or let your plugin manager run it"
);
return 1;
}
let key = tmux_opt("@taimux-key", "a");
let root = tmux_opt("@taimux-root-key", "F1");
let desc = bind_live(exe, &key, &root);
if desc.is_empty() {
println!("nothing bound: @taimux-key and @taimux-root-key are both set to empty");
return 0;
}
println!("bound {} -> {}", desc, exe);
0
}
pub fn install(exe: &str) -> i32 {
let home = std::env::var("HOME").unwrap_or_default();
let bindir = PathBuf::from(&home).join(".local/bin");
let link = bindir.join("taimux");
let _ = std::fs::create_dir_all(&bindir);
let _ = std::fs::remove_file(&link);
if std::os::unix::fs::symlink(exe, &link).is_err() {
eprintln!("could not symlink {}", link.display());
return 1;
}
println!("symlinked {} -> {}", link.display(), exe);
for old in ["cj", "muxhop"] {
let p = bindir.join(old);
if let Ok(t) = std::fs::read_link(&p) {
let t = t.to_string_lossy();
if t.contains("claude-tmux-locator") || t.ends_with("/muxhop") || t.ends_with("/taimux")
{
let _ = std::fs::remove_file(&p);
println!("removed old `{}` symlink", old);
}
}
}
let key = tmux_opt("@taimux-key", "a");
let root = tmux_opt("@taimux-root-key", "F1");
let desc = describe(&key, &root);
let in_tmux = std::env::var("TMUX")
.map(|v| !v.is_empty())
.unwrap_or(false);
if plugin_checkout(Path::new(exe)) {
println!("plugin checkout, so your plugin manager owns the bindings: nothing");
println!("written to your tmux config.");
if in_tmux && !bind_live(exe, &key, &root).is_empty() {
println!("bound {} in the running tmux server", desc);
}
println!("\nDone. Optional: taimux install-hooks, so sessions report their own state.");
return 0;
}
let resolved = std::fs::canonicalize(PathBuf::from(&home).join(".tmux.conf"))
.unwrap_or_else(|_| PathBuf::from(&home).join(".tmux.conf"));
let local = PathBuf::from(&home).join(".tmux.conf.local");
let target = choose_conf(&resolved, &local);
if !target.exists() {
let _ = std::fs::write(&target, "");
}
println!("writing bindings to {}", target.display());
for f in [&resolved, &local] {
if let Ok(text) = std::fs::read_to_string(f) {
if let Some(stripped) = strip_block(&text) {
let _ = std::fs::write(f, stripped);
}
}
}
let text = std::fs::read_to_string(&target).unwrap_or_default();
let body = insert_block(&text, &block_for("taimux", &key, &root, &desc));
if std::fs::write(&target, body).is_err() {
eprintln!("could not write {}", target.display());
return 1;
}
println!(
"bound {} in {}",
if desc.is_empty() { "nothing" } else { &desc },
target.display()
);
if in_tmux && !bind_live("taimux", &key, &root).is_empty() {
println!("bound {} in the running tmux server", desc);
}
if desc.is_empty() {
println!("\nDone. Both key options are set to empty, so no key is bound; run: taimux");
} else {
println!("\nDone. Press {}, or run: taimux", desc);
}
println!("Optional: taimux install-hooks, so sessions report their own state.");
let _ = std::io::stdout().flush();
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_popup_is_wider_on_a_narrow_client() {
assert!(popup_cmd("taimux", true).contains("-w 100%"));
assert!(popup_cmd("taimux", false).contains("-w 80%"));
assert!(popup_cmd("taimux", false).contains("pick #{pane_id}"));
}
#[test]
fn the_spelling_reported_is_the_one_in_the_file() {
let js = br#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/home/p/.local/bin/taimux hook"}]}]}}"#;
assert_eq!(
registered_cmd(js, "/build/target/release/taimux hook"),
"/home/p/.local/bin/taimux hook"
);
assert_eq!(
registered_cmd(br#"{"hooks":{}}"#, "/build/taimux hook"),
"/build/taimux hook"
);
}
#[test]
fn the_keys_read_as_a_sentence() {
assert_eq!(describe("a", "F1"), "prefix + a and F1");
assert_eq!(describe("a", ""), "prefix + a");
assert_eq!(describe("", "F1"), "F1");
assert_eq!(describe("", ""), "");
}
#[test]
fn a_file_without_our_block_is_not_rewritten() {
assert_eq!(strip_block("set -g mouse on\n"), None);
}
#[test]
fn a_past_block_is_stripped_under_any_of_the_old_names() {
let text = "set -g mouse on\n\
# taimux - jump between agent sessions\n\
bind-key a if-shell '…' 'display-popup -E … \"taimux pick\"' '…'\n\
# muxhop\n\
bind-key -n F1 run 'muxhop pick'\n\
bind-key X run 'claude-tmux-locator pick'\n\
set -g status on\n";
let out = strip_block(text).expect("stripped");
assert_eq!(out, "set -g mouse on\nset -g status on\n");
}
#[test]
fn the_block_goes_under_the_user_customizations_heading() {
let text = "# -- user customizations\n# after\n";
let out = insert_block(text, "\nBLOCK\n");
assert_eq!(out, "# -- user customizations\n\nBLOCK\n# after\n");
}
#[test]
fn without_that_heading_it_goes_at_the_end() {
let out = insert_block("set -g mouse on\n", "\nBLOCK\n");
assert_eq!(out, "set -g mouse on\n\nBLOCK\n");
}
#[test]
fn only_the_first_heading_takes_the_block() {
let text = "# -- user customizations\nx\n# -- user customizations\n";
let out = insert_block(text, "\nBLOCK\n");
assert_eq!(out.matches("BLOCK").count(), 1);
}
#[test]
fn oh_my_tmux_gets_the_local_file() {
let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
let d = std::env::temp_dir().join(format!("jminst{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let conf = d.join("tmux.conf");
let local = d.join("tmux.conf.local");
std::fs::write(&conf, "set -g mouse on\n").unwrap();
assert_eq!(choose_conf(&conf, &local), conf);
std::fs::write(&conf, "_apply_configuration() {\n :\n}\n").unwrap();
assert_eq!(choose_conf(&conf, &local), local);
std::fs::write(&conf, "set -g mouse on\n").unwrap();
std::fs::write(&local, "").unwrap();
assert_eq!(choose_conf(&conf, &local), local);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_plugin_checkout_is_recognised() {
let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
let home = std::env::temp_dir().join(format!("jmplug{}", std::process::id()));
std::env::set_var("HOME", &home);
std::env::remove_var("TMUX_PLUGIN_MANAGER_PATH");
std::env::remove_var("XDG_CONFIG_HOME");
let inside = home.join(".tmux/plugins/taimux/taimux");
assert!(plugin_checkout(&inside));
let outside = home.join("workspaces/ai/taimux/taimux");
assert!(!plugin_checkout(&outside));
std::env::set_var("TMUX_PLUGIN_MANAGER_PATH", home.join("elsewhere"));
assert!(plugin_checkout(&home.join("elsewhere/taimux/taimux")));
std::env::remove_var("TMUX_PLUGIN_MANAGER_PATH");
std::env::remove_var("HOME");
let _ = std::fs::remove_dir_all(&home);
}
}
pub fn install_hooks(exe: &str) -> i32 {
const EVENTS: &str =
"SessionStart UserPromptSubmit Stop PermissionRequest SessionEnd PostToolUse PostToolUseFailure";
let home = std::env::var("HOME").unwrap_or_default();
let dir = std::env::var("CLAUDE_CONFIG_DIR").unwrap_or_else(|_| format!("{}/.claude", home));
let settings = PathBuf::from(&dir).join("settings.json");
let cmd = format!("{} hook", exe);
if which("jq").is_none() {
println!("jq not found, so settings.json is left alone. Add this by hand:\n");
println!(" command: {}\n events: {}\n", cmd, EVENTS);
return 1;
}
let _ = std::fs::create_dir_all(&dir);
if !settings.exists() {
let _ = std::fs::write(&settings, "{}\n");
}
let prog = r#"
def ensure($event; $c):
.hooks //= {}
| .hooks[$event] //= []
| if [.hooks[$event][]?.hooks[]?.command] | index($c) then .
else .hooks[$event] += [{hooks: [{type: "command", command: $c}]}]
end;
( [.hooks[]?[]?.hooks[]?.command // empty]
| map(select(test("(^|/)taimux hook$")))
| first ) as $found
| reduce ($events | split(" ")[]) as $e (.; ensure($e; $found // $cmd))
"#;
let out = std::process::Command::new("jq")
.args([
"--indent", "2", "--arg", "cmd", &cmd, "--arg", "events", EVENTS, prog,
])
.arg(&settings)
.output();
let Ok(out) = out else {
eprintln!("could not run jq");
return 1;
};
if !out.status.success() || out.stdout.is_empty() {
println!(
"{} is not valid JSON, so it was left alone.",
settings.display()
);
return 1;
}
let tmp = settings.with_extension(format!("taimux.{}", std::process::id()));
if std::fs::write(&tmp, &out.stdout).is_err() || std::fs::rename(&tmp, &settings).is_err() {
let _ = std::fs::remove_file(&tmp);
eprintln!("could not write {}", settings.display());
return 1;
}
println!(
"registered `{}` for {} in {}",
registered_cmd(&out.stdout, &cmd),
EVENTS,
settings.display()
);
println!("Sessions already running keep reporting nothing until they restart.");
0
}
fn registered_cmd(settings_json: &[u8], fallback: &str) -> String {
let text = String::from_utf8_lossy(settings_json);
let Some(end) = text.find("taimux hook\"") else {
return fallback.to_string();
};
let head = &text[..end + "taimux hook".len()];
match head.rfind('"') {
Some(q) => head[q + 1..].to_string(),
None => fallback.to_string(),
}
}
fn which(name: &str) -> Option<PathBuf> {
use std::os::unix::fs::PermissionsExt;
for d in std::env::var("PATH").unwrap_or_default().split(':') {
if d.is_empty() {
continue;
}
let p = Path::new(d).join(name);
if p.is_file()
&& std::fs::metadata(&p)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
{
return Some(p);
}
}
None
}
#[cfg(test)]
mod which_tests {
use super::*;
#[test]
fn an_executable_on_path_is_found_and_a_plain_file_is_not() {
assert!(which("sh").is_some());
assert!(which("no-such-program-anywhere").is_none());
let d = std::env::temp_dir().join(format!("jmwhich{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("notexec"), "x").unwrap();
let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
let old = std::env::var("PATH").unwrap_or_default();
std::env::set_var("PATH", d.to_string_lossy().as_ref());
assert!(which("notexec").is_none());
std::env::set_var("PATH", old);
let _ = std::fs::remove_dir_all(&d);
}
}