use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostKind {
Cmux { cli: PathBuf },
AppleScript { app: &'static str },
Opaque,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Host {
pub bundle: PathBuf,
pub kind: HostKind,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Surface {
pub tty: String,
pub workspace: String,
pub title: String,
pub pane: String,
}
pub fn parent_map() -> HashMap<i32, i32> {
Command::new("ps")
.args(["-Ao", "pid=,ppid="])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| parse_parents(&String::from_utf8_lossy(&o.stdout)))
.unwrap_or_default()
}
pub fn parse_parents(text: &str) -> HashMap<i32, i32> {
text.lines()
.filter_map(|l| {
let mut it = l.split_whitespace();
let pid = it.next()?.parse().ok()?;
let ppid = it.next()?.parse().ok()?;
Some((pid, ppid))
})
.collect()
}
pub fn detect_with(pid: i32, parents: &HashMap<i32, i32>) -> Option<Host> {
const MAX_DEPTH: usize = 32;
let mut pid = pid;
for _ in 0..MAX_DEPTH {
let parent = *parents.get(&pid)?;
if parent <= 1 || parent == pid {
return None;
}
pid = parent;
if let Some(exe) = crate::mac::proc::exec_path(pid) {
if let Some(bundle) = bundle_of(&exe) {
let kind = classify(&bundle);
return Some(Host { bundle, kind });
}
}
}
None
}
pub fn detect(pid: i32) -> Option<Host> {
detect_with(pid, &parent_map())
}
fn bundle_of(exe: &Path) -> Option<PathBuf> {
let mut dir = exe.parent();
while let Some(d) = dir {
if d.extension().is_some_and(|e| e == "app") {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
fn classify(bundle: &Path) -> HostKind {
let name = bundle.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
match name.as_str() {
"cmux" => {
let cli = bundle.join("Contents/Resources/bin/cmux");
if cli.exists() {
HostKind::Cmux { cli }
} else {
HostKind::Opaque
}
}
"Terminal" => HostKind::AppleScript { app: "Terminal" },
"iTerm2" | "iTerm" => HostKind::AppleScript { app: "iTerm2" },
_ => HostKind::Opaque,
}
}
pub fn parse_cmux_tree(text: &str) -> Vec<Surface> {
let mut out = Vec::new();
let mut pane = String::new();
let mut workspace = String::new();
for line in text.lines() {
if let Some(rest) = after(line, "workspace ") {
let r: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
if !r.is_empty() {
workspace = r;
}
}
if let Some(rest) = after(line, "pane ") {
let r: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
if !r.is_empty() {
pane = r;
}
}
let Some(rest) = after(line, "tty=") else { continue };
let tty: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
if tty.is_empty() || pane.is_empty() {
continue;
}
let title = line
.split_once('"')
.and_then(|(_, r)| r.split_once('"'))
.map(|(t, _)| t.to_string())
.unwrap_or_default();
out.push(Surface { tty, title, pane: pane.clone(), workspace: workspace.clone() });
}
out
}
fn after<'a>(haystack: &'a str, needle: &str) -> Option<&'a str> {
haystack.find(needle).map(|i| &haystack[i + needle.len()..])
}
pub fn surfaces(host: &Host) -> Vec<Surface> {
match &host.kind {
HostKind::Cmux { cli } => Command::new(cli)
.args(["tree", "--all", "--id-format", "uuids"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| parse_cmux_tree(&String::from_utf8_lossy(&o.stdout)))
.unwrap_or_default(),
_ => Vec::new(),
}
}
pub fn focus(host: &Host, tty: Option<&str>, surfaces: &[Surface]) -> Result<(), String> {
let found = tty.and_then(|t| surfaces.iter().find(|s| s.tty == t)).cloned();
match (&host.kind, found, tty) {
(HostKind::Cmux { cli }, Some(s), _) => {
let out = Command::new(cli)
.args(["focus-pane", "--pane", &s.pane, "--workspace", &s.workspace])
.output();
match out {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
Err(e) => Err(e.to_string()),
}
}
(HostKind::AppleScript { app }, _, Some(tty)) => {
let script = applescript_focus(app, tty);
match Command::new("osascript").args(["-e", &script]).spawn() {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
_ => {
let name = host
.bundle
.file_stem()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "the terminal".into());
match Command::new("open").arg("-a").arg(&host.bundle).spawn() {
Ok(_) => Err(format!("{name} has no way to select a tab — brought it to the front")),
Err(e) => Err(e.to_string()),
}
}
}
}
fn applescript_focus(app: &str, tty: &str) -> String {
let dev = format!("/dev/{tty}");
match app {
"iTerm2" => format!(
r#"tell application "iTerm2"
repeat with w in windows
repeat with t in tabs of w
repeat with s in sessions of t
if tty of s is "{dev}" then
select w
select t
activate
return
end if
end repeat
end repeat
end repeat
end tell"#
),
_ => format!(
r#"tell application "Terminal"
repeat with w in windows
repeat with t in tabs of w
if tty of t is "{dev}" then
set selected of t to true
set index of w to 1
activate
return
end if
end repeat
end repeat
end tell"#
),
}
}
#[cfg(test)]
mod tests {
use super::*;
const TREE: &str = r#"window window:1 [current] ◀ active
├── workspace workspace:1 "Group 1"
│ └── pane pane:1 [focused]
│ └── surface surface:1 [terminal] "…/Documents/Projects/axterio" [selected] tty=ttys011
├── workspace workspace:3 "Ownit Produkce [FE]"
│ ├── pane pane:3
│ │ └── surface surface:3 [terminal] "✳ Switch to dev branch" [selected] tty=ttys010
│ └── pane pane:4 [focused]
│ └── surface surface:4 [terminal] "pnpm storybook" [selected] tty=ttys012
"#;
#[test]
fn every_surface_is_read_with_its_tty_title_and_pane() {
let s = parse_cmux_tree(TREE);
assert_eq!(s.len(), 3);
assert_eq!(s[0], Surface {
tty: "ttys011".into(),
title: "…/Documents/Projects/axterio".into(),
pane: "pane:1".into(),
workspace: "workspace:1".into(),
});
}
#[test]
fn a_surface_belongs_to_the_pane_above_it_not_the_first_one_seen() {
let s = parse_cmux_tree(TREE);
assert_eq!(s[1].pane, "pane:3", "first pane of the workspace");
assert_eq!(s[2].pane, "pane:4", "second pane, not the first");
assert_eq!(s[2].title, "pnpm storybook");
}
const TREE_UUIDS: &str = r#"window 93BDC693-78DF-43EE-8FA6-629E914F6F5C [current] ◀ active
├── workspace 7EFF925B-26FA-46FC-A7A1-3D323C993933 "Group 1"
│ └── pane 631A76B5-FCA4-403C-A182-BD65976F018A [focused]
│ └── surface 63ECCA0C-6DAD-4A22-9C63-5C15DE4EE734 [terminal] "…/Projects/axterio" [selected] tty=ttys011
"#;
#[test]
fn a_surface_carries_the_workspace_that_scopes_its_pane() {
let s = parse_cmux_tree(TREE);
assert_eq!(s[0].workspace, "workspace:1");
assert_eq!(s[1].workspace, "workspace:3", "the second workspace, not the first");
assert_eq!(s[2].workspace, "workspace:3");
}
#[test]
fn a_uuid_pane_ref_is_carried_through_verbatim() {
let s = parse_cmux_tree(TREE_UUIDS);
assert_eq!(s.len(), 1);
assert_eq!(s[0].pane, "631A76B5-FCA4-403C-A182-BD65976F018A");
assert_eq!(s[0].tty, "ttys011");
}
#[test]
fn output_that_is_not_a_tree_yields_nothing_rather_than_guesses() {
for junk in ["", "cmux: not running", "error: socket refused\n"] {
assert!(parse_cmux_tree(junk).is_empty(), "{junk:?}");
}
}
#[test]
fn a_surface_with_no_tty_is_skipped() {
let text = "│ └── pane pane:9 [focused]\n│ └── surface surface:9 [browser] \"docs\"\n";
assert!(parse_cmux_tree(text).is_empty());
}
#[test]
fn the_process_table_parses_into_parent_links() {
let m = parse_parents(" 80411 3012\n 3012 3010\n 3010 831\n");
assert_eq!(m.get(&80411), Some(&3012));
assert_eq!(m.get(&3010), Some(&831), "login's parent is the terminal app");
}
#[test]
fn junk_lines_in_the_process_table_are_skipped() {
let m = parse_parents("PID PPID\nnot numbers\n\n 7 1\n");
assert_eq!(m.len(), 1);
assert_eq!(m.get(&7), Some(&1));
}
#[test]
fn the_walk_stops_rather_than_looping_on_a_cycle() {
let m = HashMap::from([(10, 11), (11, 10)]);
assert_eq!(detect_with(10, &m), None);
}
#[test]
fn the_walk_gives_up_at_the_top_of_the_tree() {
let m = HashMap::from([(10, 2), (2, 1)]);
assert_eq!(detect_with(10, &m), None, "reaching launchd means no host was found");
}
#[test]
fn a_bundle_is_found_from_a_binary_buried_inside_it() {
assert_eq!(
bundle_of(Path::new("/Applications/cmux.app/Contents/MacOS/cmux")),
Some(PathBuf::from("/Applications/cmux.app"))
);
assert_eq!(
bundle_of(Path::new("/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal")),
Some(PathBuf::from("/System/Applications/Utilities/Terminal.app"))
);
}
#[test]
fn a_binary_outside_any_bundle_has_no_host() {
assert_eq!(bundle_of(Path::new("/usr/bin/login")), None);
assert_eq!(bundle_of(Path::new("/opt/homebrew/bin/whirr")), None);
}
#[test]
fn an_unrecognised_app_is_opaque_rather_than_guessed_at() {
assert_eq!(classify(Path::new("/Applications/Ghostty.app")), HostKind::Opaque);
assert_eq!(classify(Path::new("/Applications/Nonesuch.app")), HostKind::Opaque);
}
#[test]
fn the_scriptable_terminals_are_recognised() {
assert_eq!(
classify(Path::new("/System/Applications/Utilities/Terminal.app")),
HostKind::AppleScript { app: "Terminal" }
);
assert_eq!(
classify(Path::new("/Applications/iTerm2.app")),
HostKind::AppleScript { app: "iTerm2" }
);
}
#[test]
fn cmux_without_its_bundled_cli_falls_back_to_opaque() {
assert_eq!(classify(Path::new("/nonexistent/cmux.app")), HostKind::Opaque);
}
#[test]
fn the_focus_script_targets_the_device_not_the_bare_tty_name() {
let s = applescript_focus("Terminal", "ttys004");
assert!(s.contains("/dev/ttys004"), "{s}");
assert!(!s.contains("is \"ttys004\""), "the bare name would never match");
}
}