1use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use taimux_core::tmux;
26
27const POPUP_COND: &str = "[ \"#{client_width}\" -lt 100 ]";
30
31pub fn popup_geometry(client_width: usize) -> (u16, u16) {
40 if client_width < 100 {
41 (100, 90)
42 } else {
43 (80, 80)
44 }
45}
46
47pub fn popup_cmd(launcher: &str, small: bool) -> String {
59 let (w, h) = popup_geometry(if small { 80 } else { 100 });
60 format!(
61 "display-popup -E -e TAIMUX_POPUP=1 -w {}% -h {}% \"{} pick #{{pane_id}}\"",
62 w, h, launcher
63 )
64}
65
66pub fn tmux_opt(name: &str, default: &str) -> String {
72 match tmux::ask(&["show-options", "-gq", name]) {
73 Some(shown) if !shown.trim().is_empty() => {
74 tmux::ask(&["show-options", "-gqv", name]).unwrap_or_default()
75 }
76 _ => default.to_string(),
77 }
78}
79
80pub fn bind_live(launcher: &str, key: &str, root: &str) -> String {
82 let (small, large) = (popup_cmd(launcher, true), popup_cmd(launcher, false));
83 let mut desc = String::new();
84 if !key.is_empty() && tmux::run(&["bind-key", key, "if-shell", POPUP_COND, &small, &large]) {
85 desc = format!("prefix + {}", key);
86 }
87 if !root.is_empty()
88 && tmux::run(&[
89 "bind-key", "-n", root, "if-shell", POPUP_COND, &small, &large,
90 ])
91 {
92 if desc.is_empty() {
93 desc = root.to_string();
94 } else {
95 desc = format!("{} and {}", desc, root);
96 }
97 }
98 desc
99}
100
101pub fn plugin_checkout(exe: &Path) -> bool {
106 let Some(parent) = exe.parent().and_then(|p| p.parent()) else {
107 return false;
108 };
109 let home = std::env::var("HOME").unwrap_or_default();
110 let xdg = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| format!("{}/.config", home));
111 [
112 std::env::var("TMUX_PLUGIN_MANAGER_PATH").unwrap_or_default(),
113 format!("{}/.tmux/plugins", home),
114 format!("{}/tmux/plugins", xdg),
115 ]
116 .iter()
117 .any(|p| !p.is_empty() && parent == Path::new(p.trim_end_matches('/')))
118}
119
120pub fn is_oh_my_tmux(resolved: &Path) -> bool {
126 let home = std::env::var("HOME").unwrap_or_default();
127 if resolved.starts_with(format!("{}/.tmux/", home)) {
128 return true;
129 }
130 std::fs::read_to_string(resolved)
131 .map(|t| t.contains("_apply_configuration"))
132 .unwrap_or(false)
133}
134
135pub fn choose_conf(resolved: &Path, local: &Path) -> PathBuf {
141 if local.exists() || is_oh_my_tmux(resolved) {
142 local.to_path_buf()
143 } else {
144 resolved.to_path_buf()
145 }
146}
147
148pub fn strip_block(text: &str) -> Option<String> {
154 let ours = |l: &str| {
157 l.contains("claude-tmux-locator pick")
158 || l.contains("muxhop pick")
159 || l.contains("taimux pick")
160 || l.starts_with("# claude-tmux-locator")
161 || l.starts_with("#claude-tmux-locator")
162 || l.starts_with("# muxhop")
163 || l.starts_with("#muxhop")
164 || l.starts_with("# taimux")
165 || l.starts_with("#taimux")
166 };
167 if !text.lines().any(ours) {
168 return None;
169 }
170 Some(
171 text.lines()
172 .filter(|l| !ours(l))
173 .map(|l| format!("{}\n", l))
174 .collect(),
175 )
176}
177
178pub fn insert_block(text: &str, block: &str) -> String {
184 let mut out = String::new();
185 let mut done = false;
186 for l in text.lines() {
187 out.push_str(l);
188 out.push('\n');
189 if !done && l.contains("-- user customizations") {
190 out.push_str(block);
191 done = true;
192 }
193 }
194 if !done {
195 out.push_str(block);
196 }
197 out
198}
199
200pub fn block_for(launcher: &str, key: &str, root: &str, desc: &str) -> String {
202 let (small, large) = (popup_cmd(launcher, true), popup_cmd(launcher, false));
203 let mut b = format!(
204 "\n# taimux - jump between agent sessions - {} (adaptive popup width)\n",
205 if desc.is_empty() { "unbound" } else { desc }
206 );
207 if !key.is_empty() {
208 b.push_str(&format!(
209 "bind-key {} if-shell '{}' '{}' '{}'\n",
210 key, POPUP_COND, small, large
211 ));
212 }
213 if !root.is_empty() {
214 b.push_str(&format!(
216 "bind-key -n {} if-shell '{}' '{}' '{}'\n",
217 root, POPUP_COND, small, large
218 ));
219 }
220 b
221}
222
223pub fn describe(key: &str, root: &str) -> String {
225 match (key.is_empty(), root.is_empty()) {
226 (true, true) => String::new(),
227 (false, true) => format!("prefix + {}", key),
228 (true, false) => root.to_string(),
229 (false, false) => format!("prefix + {} and {}", key, root),
230 }
231}
232
233pub fn bind(exe: &str) -> i32 {
238 if tmux::ask(&["show-options", "-g"]).is_none() {
239 eprintln!(
240 "no tmux server to bind in: run it from inside tmux, or let your plugin manager run it"
241 );
242 return 1;
243 }
244 let key = tmux_opt("@taimux-key", "a");
245 let root = tmux_opt("@taimux-root-key", "F1");
246 let desc = bind_live(exe, &key, &root);
247 if desc.is_empty() {
248 println!("nothing bound: @taimux-key and @taimux-root-key are both set to empty");
249 return 0;
250 }
251 println!("bound {} -> {}", desc, exe);
252 0
253}
254
255pub fn install(exe: &str) -> i32 {
257 let home = std::env::var("HOME").unwrap_or_default();
258 let bindir = PathBuf::from(&home).join(".local/bin");
259 let link = bindir.join("taimux");
260 let _ = std::fs::create_dir_all(&bindir);
261 let _ = std::fs::remove_file(&link);
262 if std::os::unix::fs::symlink(exe, &link).is_err() {
263 eprintln!("could not symlink {}", link.display());
264 return 1;
265 }
266 println!("symlinked {} -> {}", link.display(), exe);
267
268 for old in ["cj", "muxhop"] {
273 let p = bindir.join(old);
274 if let Ok(t) = std::fs::read_link(&p) {
275 let t = t.to_string_lossy();
276 if t.contains("claude-tmux-locator") || t.ends_with("/muxhop") || t.ends_with("/taimux")
277 {
278 let _ = std::fs::remove_file(&p);
279 println!("removed old `{}` symlink", old);
280 }
281 }
282 }
283
284 let key = tmux_opt("@taimux-key", "a");
285 let root = tmux_opt("@taimux-root-key", "F1");
286 let desc = describe(&key, &root);
287 let in_tmux = std::env::var("TMUX")
288 .map(|v| !v.is_empty())
289 .unwrap_or(false);
290
291 if plugin_checkout(Path::new(exe)) {
296 println!("plugin checkout, so your plugin manager owns the bindings: nothing");
297 println!("written to your tmux config.");
298 if in_tmux && !bind_live(exe, &key, &root).is_empty() {
299 println!("bound {} in the running tmux server", desc);
300 }
301 println!("\nDone. Optional: taimux install-hooks, so sessions report their own state.");
302 return 0;
303 }
304
305 let resolved = std::fs::canonicalize(PathBuf::from(&home).join(".tmux.conf"))
306 .unwrap_or_else(|_| PathBuf::from(&home).join(".tmux.conf"));
307 let local = PathBuf::from(&home).join(".tmux.conf.local");
308 let target = choose_conf(&resolved, &local);
309 if !target.exists() {
310 let _ = std::fs::write(&target, "");
311 }
312 println!("writing bindings to {}", target.display());
313
314 for f in [&resolved, &local] {
317 if let Ok(text) = std::fs::read_to_string(f) {
318 if let Some(stripped) = strip_block(&text) {
319 let _ = std::fs::write(f, stripped);
320 }
321 }
322 }
323
324 let text = std::fs::read_to_string(&target).unwrap_or_default();
325 let body = insert_block(&text, &block_for("taimux", &key, &root, &desc));
328 if std::fs::write(&target, body).is_err() {
329 eprintln!("could not write {}", target.display());
330 return 1;
331 }
332 println!(
333 "bound {} in {}",
334 if desc.is_empty() { "nothing" } else { &desc },
335 target.display()
336 );
337
338 if in_tmux && !bind_live("taimux", &key, &root).is_empty() {
339 println!("bound {} in the running tmux server", desc);
340 }
341 if desc.is_empty() {
342 println!("\nDone. Both key options are set to empty, so no key is bound; run: taimux");
343 } else {
344 println!("\nDone. Press {}, or run: taimux", desc);
345 }
346 println!("Optional: taimux install-hooks, so sessions report their own state.");
347 let _ = std::io::stdout().flush();
348 0
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn the_popup_is_wider_on_a_narrow_client() {
357 assert!(popup_cmd("taimux", true).contains("-w 100%"));
358 assert!(popup_cmd("taimux", false).contains("-w 80%"));
359 assert!(popup_cmd("taimux", false).contains("pick #{pane_id}"));
362 }
363
364 #[test]
365 fn the_spelling_reported_is_the_one_in_the_file() {
366 let js = br#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/home/p/.local/bin/taimux hook"}]}]}}"#;
369 assert_eq!(
370 registered_cmd(js, "/build/target/release/taimux hook"),
371 "/home/p/.local/bin/taimux hook"
372 );
373 assert_eq!(
375 registered_cmd(br#"{"hooks":{}}"#, "/build/taimux hook"),
376 "/build/taimux hook"
377 );
378 }
379
380 #[test]
381 fn the_keys_read_as_a_sentence() {
382 assert_eq!(describe("a", "F1"), "prefix + a and F1");
383 assert_eq!(describe("a", ""), "prefix + a");
384 assert_eq!(describe("", "F1"), "F1");
385 assert_eq!(describe("", ""), "");
386 }
387
388 #[test]
391 fn a_file_without_our_block_is_not_rewritten() {
392 assert_eq!(strip_block("set -g mouse on\n"), None);
393 }
394
395 #[test]
398 fn a_past_block_is_stripped_under_any_of_the_old_names() {
399 let text = "set -g mouse on\n\
400 # taimux - jump between agent sessions\n\
401 bind-key a if-shell '…' 'display-popup -E … \"taimux pick\"' '…'\n\
402 # muxhop\n\
403 bind-key -n F1 run 'muxhop pick'\n\
404 bind-key X run 'claude-tmux-locator pick'\n\
405 set -g status on\n";
406 let out = strip_block(text).expect("stripped");
407 assert_eq!(out, "set -g mouse on\nset -g status on\n");
408 }
409
410 #[test]
414 fn the_block_goes_under_the_user_customizations_heading() {
415 let text = "# -- user customizations\n# after\n";
416 let out = insert_block(text, "\nBLOCK\n");
417 assert_eq!(out, "# -- user customizations\n\nBLOCK\n# after\n");
418 }
419
420 #[test]
421 fn without_that_heading_it_goes_at_the_end() {
422 let out = insert_block("set -g mouse on\n", "\nBLOCK\n");
423 assert_eq!(out, "set -g mouse on\n\nBLOCK\n");
424 }
425
426 #[test]
428 fn only_the_first_heading_takes_the_block() {
429 let text = "# -- user customizations\nx\n# -- user customizations\n";
430 let out = insert_block(text, "\nBLOCK\n");
431 assert_eq!(out.matches("BLOCK").count(), 1);
432 }
433
434 #[test]
438 fn oh_my_tmux_gets_the_local_file() {
439 let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
441 let d = std::env::temp_dir().join(format!("jminst{}", std::process::id()));
442 std::fs::create_dir_all(&d).unwrap();
443 let conf = d.join("tmux.conf");
444 let local = d.join("tmux.conf.local");
445
446 std::fs::write(&conf, "set -g mouse on\n").unwrap();
448 assert_eq!(choose_conf(&conf, &local), conf);
449
450 std::fs::write(&conf, "_apply_configuration() {\n :\n}\n").unwrap();
453 assert_eq!(choose_conf(&conf, &local), local);
454
455 std::fs::write(&conf, "set -g mouse on\n").unwrap();
457 std::fs::write(&local, "").unwrap();
458 assert_eq!(choose_conf(&conf, &local), local);
459 let _ = std::fs::remove_dir_all(&d);
460 }
461
462 #[test]
466 fn a_plugin_checkout_is_recognised() {
467 let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
469 let home = std::env::temp_dir().join(format!("jmplug{}", std::process::id()));
470 std::env::set_var("HOME", &home);
471 std::env::remove_var("TMUX_PLUGIN_MANAGER_PATH");
472 std::env::remove_var("XDG_CONFIG_HOME");
473 let inside = home.join(".tmux/plugins/taimux/taimux");
474 assert!(plugin_checkout(&inside));
475 let outside = home.join("workspaces/ai/taimux/taimux");
476 assert!(!plugin_checkout(&outside));
477 std::env::set_var("TMUX_PLUGIN_MANAGER_PATH", home.join("elsewhere"));
479 assert!(plugin_checkout(&home.join("elsewhere/taimux/taimux")));
480 std::env::remove_var("TMUX_PLUGIN_MANAGER_PATH");
481 std::env::remove_var("HOME");
482 let _ = std::fs::remove_dir_all(&home);
483 }
484}
485
486pub fn install_hooks(exe: &str) -> i32 {
504 const EVENTS: &str =
509 "SessionStart UserPromptSubmit Stop PermissionRequest SessionEnd PostToolUse PostToolUseFailure";
510 let home = std::env::var("HOME").unwrap_or_default();
511 let dir = std::env::var("CLAUDE_CONFIG_DIR").unwrap_or_else(|_| format!("{}/.claude", home));
512 let settings = PathBuf::from(&dir).join("settings.json");
513 let cmd = format!("{} hook", exe);
514
515 if which("jq").is_none() {
516 println!("jq not found, so settings.json is left alone. Add this by hand:\n");
517 println!(" command: {}\n events: {}\n", cmd, EVENTS);
518 return 1;
519 }
520 let _ = std::fs::create_dir_all(&dir);
521 if !settings.exists() {
522 let _ = std::fs::write(&settings, "{}\n");
523 }
524 let prog = r#"
525 def ensure($event; $c):
526 .hooks //= {}
527 | .hooks[$event] //= []
528 | if [.hooks[$event][]?.hooks[]?.command] | index($c) then .
529 else .hooks[$event] += [{hooks: [{type: "command", command: $c}]}]
530 end;
531 ( [.hooks[]?[]?.hooks[]?.command // empty]
532 | map(select(test("(^|/)taimux hook$")))
533 | first ) as $found
534 | reduce ($events | split(" ")[]) as $e (.; ensure($e; $found // $cmd))
535 "#;
536 let out = std::process::Command::new("jq")
537 .args([
538 "--indent", "2", "--arg", "cmd", &cmd, "--arg", "events", EVENTS, prog,
539 ])
540 .arg(&settings)
541 .output();
542 let Ok(out) = out else {
543 eprintln!("could not run jq");
544 return 1;
545 };
546 if !out.status.success() || out.stdout.is_empty() {
549 println!(
550 "{} is not valid JSON, so it was left alone.",
551 settings.display()
552 );
553 return 1;
554 }
555 let tmp = settings.with_extension(format!("taimux.{}", std::process::id()));
556 if std::fs::write(&tmp, &out.stdout).is_err() || std::fs::rename(&tmp, &settings).is_err() {
557 let _ = std::fs::remove_file(&tmp);
558 eprintln!("could not write {}", settings.display());
559 return 1;
560 }
561 println!(
562 "registered `{}` for {} in {}",
563 registered_cmd(&out.stdout, &cmd),
564 EVENTS,
565 settings.display()
566 );
567 println!("Sessions already running keep reporting nothing until they restart.");
568 0
569}
570
571fn registered_cmd(settings_json: &[u8], fallback: &str) -> String {
577 let text = String::from_utf8_lossy(settings_json);
578 let Some(end) = text.find("taimux hook\"") else {
579 return fallback.to_string();
580 };
581 let head = &text[..end + "taimux hook".len()];
582 match head.rfind('"') {
583 Some(q) => head[q + 1..].to_string(),
584 None => fallback.to_string(),
585 }
586}
587
588fn which(name: &str) -> Option<PathBuf> {
590 use std::os::unix::fs::PermissionsExt;
591 for d in std::env::var("PATH").unwrap_or_default().split(':') {
592 if d.is_empty() {
593 continue;
594 }
595 let p = Path::new(d).join(name);
596 if p.is_file()
597 && std::fs::metadata(&p)
598 .map(|m| m.permissions().mode() & 0o111 != 0)
599 .unwrap_or(false)
600 {
601 return Some(p);
602 }
603 }
604 None
605}
606
607#[cfg(test)]
608mod which_tests {
609 use super::*;
610
611 #[test]
614 fn an_executable_on_path_is_found_and_a_plain_file_is_not() {
615 assert!(which("sh").is_some());
616 assert!(which("no-such-program-anywhere").is_none());
617 let d = std::env::temp_dir().join(format!("jmwhich{}", std::process::id()));
618 std::fs::create_dir_all(&d).unwrap();
619 std::fs::write(d.join("notexec"), "x").unwrap();
620 let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
621 let old = std::env::var("PATH").unwrap_or_default();
622 std::env::set_var("PATH", d.to_string_lossy().as_ref());
623 assert!(which("notexec").is_none());
624 std::env::set_var("PATH", old);
625 let _ = std::fs::remove_dir_all(&d);
626 }
627}