use std::process::{Command, Stdio};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Place {
SplitRight,
SplitDown,
Tab,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Backend {
Tmux,
Ghostty,
Kitty,
WezTerm,
}
impl Backend {
fn name(self) -> &'static str {
match self {
Backend::Tmux => "tmux",
Backend::Ghostty => "ghostty",
Backend::Kitty => "kitty",
Backend::WezTerm => "wezterm",
}
}
}
fn detect(env: impl Fn(&str) -> Option<String>) -> Option<Backend> {
if env("TMUX").is_some() {
return Some(Backend::Tmux);
}
let program = env("TERM_PROGRAM").unwrap_or_default();
if program.eq_ignore_ascii_case("ghostty") {
return Some(Backend::Ghostty);
}
if env("KITTY_WINDOW_ID").is_some() || env("TERM").as_deref() == Some("xterm-kitty") {
return Some(Backend::Kitty);
}
if env("WEZTERM_PANE").is_some() || program.eq_ignore_ascii_case("wezterm") {
return Some(Backend::WezTerm);
}
None
}
pub fn backend() -> Option<&'static str> {
detect(|k| std::env::var(k).ok()).map(Backend::name)
}
pub fn open_beside(place: Place, argv: &[String]) -> Result<(), String> {
let Some(backend) = detect(|k| std::env::var(k).ok()) else {
return Err("no splits here — Ghostty, tmux, kitty or WezTerm".to_string());
};
let mut cmd = command_for(backend, place, argv);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match cmd.spawn() {
Ok(_) => Ok(()),
Err(e) => Err(format!("{} failed: {e}", backend.name())),
}
}
fn command_for(backend: Backend, place: Place, argv: &[String]) -> Command {
match backend {
Backend::Tmux => {
let mut c = Command::new("tmux");
match place {
Place::SplitRight => c.args(["split-window", "-h"]),
Place::SplitDown => c.args(["split-window", "-v"]),
Place::Tab => c.arg("new-window"),
};
c.args(argv);
c
}
Backend::Ghostty => {
let mut c = Command::new("osascript");
c.arg("-e").arg(ghostty_script(place, argv));
c
}
Backend::Kitty => {
let location = match place {
Place::SplitRight => "vsplit",
Place::SplitDown => "hsplit",
Place::Tab => "tab",
};
let mut c = Command::new("kitten");
c.args(["@", "launch", "--type=window"])
.arg(format!("--location={location}"))
.arg("--cwd=current")
.args(argv);
c
}
Backend::WezTerm => {
let mut c = Command::new("wezterm");
c.arg("cli");
match place {
Place::SplitRight => c.args(["split-pane", "--right"]),
Place::SplitDown => c.args(["split-pane", "--bottom"]),
Place::Tab => c.arg("spawn"),
};
c.arg("--").args(argv);
c
}
}
}
fn ghostty_script(place: Place, argv: &[String]) -> String {
let command = applescript_string(&shell_join(argv));
let open = match place {
Place::SplitRight => "split t direction right with configuration cfg",
Place::SplitDown => "split t direction down with configuration cfg",
Place::Tab => "new tab in front window with configuration cfg",
};
format!(
"tell application \"Ghostty\"\n\
set t to focused terminal of selected tab of front window\n\
set cfg to new surface configuration\n\
set command of cfg to {command}\n\
{open}\n\
end tell"
)
}
fn shell_quote(word: &str) -> String {
let plain = !word.is_empty()
&& word
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./=:@%+,~".contains(c));
if plain {
return word.to_string();
}
format!("'{}'", word.replace('\'', "'\\''"))
}
fn shell_join(argv: &[String]) -> String {
argv.iter()
.map(|w| shell_quote(w))
.collect::<Vec<_>>()
.join(" ")
}
fn applescript_string(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |k| {
pairs
.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| v.to_string())
}
}
fn argv(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn each_terminal_is_recognised() {
assert_eq!(
detect(env(&[("TERM_PROGRAM", "ghostty")])),
Some(Backend::Ghostty)
);
assert_eq!(
detect(env(&[("KITTY_WINDOW_ID", "1")])),
Some(Backend::Kitty)
);
assert_eq!(
detect(env(&[("TERM", "xterm-kitty")])),
Some(Backend::Kitty)
);
assert_eq!(
detect(env(&[("WEZTERM_PANE", "0")])),
Some(Backend::WezTerm)
);
assert_eq!(
detect(env(&[("TERM_PROGRAM", "WezTerm")])),
Some(Backend::WezTerm)
);
assert_eq!(detect(env(&[("TERM_PROGRAM", "Apple_Terminal")])), None);
assert_eq!(detect(env(&[])), None);
}
#[test]
fn tmux_wins_over_the_terminal_it_runs_in() {
assert_eq!(
detect(env(&[
("TMUX", "/tmp/tmux-501/default,1,0"),
("TERM_PROGRAM", "ghostty")
])),
Some(Backend::Tmux)
);
}
#[test]
fn shell_quoting_leaves_plain_words_alone_and_wraps_the_rest() {
assert_eq!(
shell_quote("/usr/local/bin/catcher"),
"/usr/local/bin/catcher"
);
assert_eq!(shell_quote("a b"), "'a b'");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
assert_eq!(shell_quote(""), "''");
}
#[test]
fn applescript_escapes_quotes_and_backslashes() {
assert_eq!(
applescript_string(r#"say "hi" \ bye"#),
r#""say \"hi\" \\ bye""#
);
}
#[test]
fn the_ghostty_script_quotes_a_path_with_a_space_and_an_apostrophe() {
let s = ghostty_script(
Place::SplitRight,
&argv(&[
"/opt/catcher",
"--root",
"/Users/g/my notes",
"/Users/g/my notes/it's.md",
]),
);
assert!(s.contains(
r#"set command of cfg to "/opt/catcher --root '/Users/g/my notes' '/Users/g/my notes/it'\\''s.md'""#
), "{s}");
assert!(s.contains("split t direction right with configuration cfg"));
assert!(s.starts_with("tell application \"Ghostty\"\n"));
assert!(s.ends_with("\nend tell"));
}
#[test]
fn the_ghostty_script_has_a_down_and_a_tab_form() {
let down = ghostty_script(Place::SplitDown, &argv(&["catcher"]));
assert!(down.contains("split t direction down with configuration cfg"));
let tab = ghostty_script(Place::Tab, &argv(&["catcher"]));
assert!(tab.contains("new tab in front window with configuration cfg"));
assert!(!tab.contains("split t"));
}
#[test]
fn the_other_backends_get_argv_as_separate_words() {
let words = |c: &Command| -> Vec<String> {
std::iter::once(c.get_program().to_string_lossy().into_owned())
.chain(c.get_args().map(|a| a.to_string_lossy().into_owned()))
.collect()
};
let a = argv(&["/opt/catcher", "/n/a b.md"]);
assert_eq!(
words(&command_for(Backend::Tmux, Place::SplitDown, &a)),
argv(&["tmux", "split-window", "-v", "/opt/catcher", "/n/a b.md"])
);
assert_eq!(
words(&command_for(Backend::Tmux, Place::Tab, &a)),
argv(&["tmux", "new-window", "/opt/catcher", "/n/a b.md"])
);
assert_eq!(
words(&command_for(Backend::Kitty, Place::SplitRight, &a)),
argv(&[
"kitten",
"@",
"launch",
"--type=window",
"--location=vsplit",
"--cwd=current",
"/opt/catcher",
"/n/a b.md"
])
);
assert_eq!(
words(&command_for(Backend::WezTerm, Place::SplitRight, &a)),
argv(&[
"wezterm",
"cli",
"split-pane",
"--right",
"--",
"/opt/catcher",
"/n/a b.md"
])
);
assert_eq!(
words(&command_for(Backend::WezTerm, Place::Tab, &a)),
argv(&["wezterm", "cli", "spawn", "--", "/opt/catcher", "/n/a b.md"])
);
}
}