use anyhow::Result; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; #[derive(Clone, Debug)] pub struct TerminalCommand { pub program: PathBuf, pub args: Vec, pub title: Option, pub fresh_spawn: bool, /// What this spawn is for (e.g. "resume", "selfdev", "swarm-agent"). /// Exported as `JCODE_SPAWN_KIND` to spawn hooks and spawned terminals. pub kind: Option, /// The jcode session this terminal will run, when known. /// Exported as `JCODE_SPAWN_SESSION_ID`. pub session_id: Option, /// Extra metadata env entries (e.g. `JCODE_SPAWN_SWARM_ID`) exported to /// spawn hooks and spawned terminals. Applied after the first-class /// `JCODE_SPAWN_*` keys, so entries here win on key collisions. pub extra_env: Vec<(String, String)>, /// Terminal-identifying env vars captured from the *client* that requested /// this spawn (see [`snapshot_client_terminal_env`]). When set, these /// override the (possibly stale) server-inherited values for the same keys /// and are also exported under a `JCODE_CLIENT_*` prefix so spawn/focus /// hooks can target the terminal the user is actually attached to (#405). pub client_terminal_env: Vec<(String, String)>, } impl TerminalCommand { pub fn new(program: impl Into, args: Vec) -> Self { Self { program: program.into(), args, title: None, fresh_spawn: false, kind: None, session_id: None, extra_env: Vec::new(), client_terminal_env: Vec::new(), } } pub fn title(mut self, title: impl Into) -> Self { self.title = Some(title.into()); self } pub fn fresh_spawn(mut self) -> Self { self.fresh_spawn = true; self } pub fn kind(mut self, kind: impl Into) -> Self { self.kind = Some(kind.into()); self } pub fn session_id(mut self, session_id: impl Into) -> Self { self.session_id = Some(session_id.into()); self } pub fn spawn_env(mut self, key: impl Into, value: impl Into) -> Self { self.extra_env.push((key.into(), value.into())); self } /// Attach the client's terminal-identifying env snapshot (see /// [`snapshot_client_terminal_env`]) so the spawn follows the terminal the /// requesting client is attached to instead of the server's stale env. pub fn client_terminal_env(mut self, env: Vec<(String, String)>) -> Self { self.client_terminal_env = env; self } } /// Terminal/window-manager environment variables that identify *which* /// terminal, multiplexer, or display a client is attached to. /// /// The jcode server process is long-lived and captures these at *its* startup, /// so once a client connects from a different terminal/tmux/zellij session the /// server's copies are stale. Spawn and focus hooks executed by the server then /// target the wrong terminal (see issue #405). To fix this, clients snapshot /// these vars from their own environment and send them to the server, which /// re-exports them to spawn/focus hooks so the hook places windows in the /// terminal the user is actually looking at. /// /// This intentionally covers terminal multiplexers (tmux, screen, zellij), /// terminal emulators (kitty, wezterm, ghostty, iTerm, ...), and the display /// server (X11 `DISPLAY`, Wayland `WAYLAND_DISPLAY`) so window placement and /// routing all follow the connecting client. pub const CLIENT_TERMINAL_ENV_VARS: &[&str] = &[ // Terminal multiplexers "ZELLIJ", "ZELLIJ_SESSION_NAME", "ZELLIJ_PANE_ID", "TMUX", "TMUX_PANE", "STY", // herdr terminal multiplexer (https://herdr.dev), see issue #405 "HERDR_ENV", "HERDR_SOCKET_PATH", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID", "HERDR_BIN_PATH", "HERDR_SESSION", "HERDR_AGENT", // Terminal emulators "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "COLORTERM", "KITTY_PID", "KITTY_WINDOW_ID", "KITTY_LISTEN_ON", "WEZTERM_PANE", "WEZTERM_EXECUTABLE", "WEZTERM_UNIX_SOCKET", "ALACRITTY_WINDOW_ID", "ALACRITTY_SOCKET", "GHOSTTY_RESOURCES_DIR", "GHOSTTY_BIN_DIR", "ITERM_SESSION_ID", "WINDOWID", "HANDTERM_SESSION", "HANDTERM_PID", "WT_SESSION", "WT_PROFILE_ID", // Display / window manager "DISPLAY", "WAYLAND_DISPLAY", ]; /// Snapshot the current process's terminal-identifying env vars (see /// [`CLIENT_TERMINAL_ENV_VARS`]). Only vars that are actually set are included, /// so the map is empty when nothing identifies the terminal. pub fn snapshot_client_terminal_env() -> Vec<(String, String)> { CLIENT_TERMINAL_ENV_VARS .iter() .filter_map(|&key| { std::env::var(key) .ok() .map(|value| (key.to_string(), value)) }) .collect() } #[derive(Clone, Debug, PartialEq, Eq)] pub struct SpawnAttempt { pub terminal: String, pub program: String, pub args: Vec, } pub fn sh_escape(text: &str) -> String { format!("'{}'", text.replace('\'', "'\"'\"'")) } pub fn shell_command(args: &[String]) -> String { #[cfg(unix)] { args.iter() .map(|arg| sh_escape(arg)) .collect::>() .join(" ") } #[cfg(not(unix))] { args.join(" ") } } fn push_unique_terminal(candidates: &mut Vec, term: impl Into) { let term = term.into(); if term.trim().is_empty() { return; } if !candidates.iter().any(|candidate| candidate == &term) { candidates.push(term); } } #[cfg(target_os = "macos")] fn macos_app_installed(app_name: &str) -> bool { let system_app = Path::new("/Applications").join(app_name); if system_app.is_dir() { return true; } if let Some(home) = dirs::home_dir() && home.join("Applications").join(app_name).is_dir() { return true; } false } #[cfg(target_os = "macos")] fn macos_current_terminal_is(term: &str) -> bool { detected_resume_terminal().as_deref() == Some(term) } #[cfg(target_os = "macos")] fn macos_should_try_app_terminal(term: &str) -> bool { match term { "ghostty" => macos_current_terminal_is("ghostty") || macos_app_installed("Ghostty.app"), "kitty" => { macos_current_terminal_is("kitty") || macos_app_installed("kitty.app") || macos_app_installed("Kitty.app") } "wezterm" => { macos_current_terminal_is("wezterm") || macos_app_installed("WezTerm.app") || macos_app_installed("wezterm.app") } "alacritty" => { macos_current_terminal_is("alacritty") || macos_app_installed("Alacritty.app") } "iterm2" => { macos_current_terminal_is("iterm2") || macos_app_installed("iTerm.app") || macos_app_installed("iTerm2.app") } // Apple Terminal ships with every macOS install, so it is the guaranteed // last-resort fallback and is always worth trying. "terminal" => true, _ => true, } } /// Ordered macOS terminal preference list used when spawning a new window. /// /// Earlier entries are preferred. Apple's built-in `Terminal.app` is intentionally /// last because it is the guaranteed fallback that exists on every macOS install, /// while the modern terminals above it are only attempted when actually /// installed (or currently in use). See `macos_should_try_app_terminal`. #[cfg(target_os = "macos")] const MACOS_TERMINAL_PREFERENCE: &[&str] = &[ "ghostty", "kitty", "wezterm", "alacritty", "iterm2", "terminal", ]; #[cfg(unix)] pub fn detected_resume_terminal() -> Option { if std::env::var("HANDTERM_SESSION").is_ok() || std::env::var("HANDTERM_PID").is_ok() { return Some("handterm".to_string()); } if std::env::var("TERM_PROGRAM") .ok() .map(|value| value.eq_ignore_ascii_case("handterm")) .unwrap_or(false) { return Some("handterm".to_string()); } if std::env::var("KITTY_PID").is_ok() { return Some("kitty".to_string()); } if std::env::var("WEZTERM_EXECUTABLE").is_ok() || std::env::var("WEZTERM_PANE").is_ok() { return Some("wezterm".to_string()); } if std::env::var("ALACRITTY_WINDOW_ID").is_ok() { return Some("alacritty".to_string()); } #[cfg(target_os = "macos")] { if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() || std::env::var("GHOSTTY_BIN_DIR").is_ok() { return Some("ghostty".to_string()); } let term_program = std::env::var("TERM_PROGRAM") .ok() .map(|value| value.to_ascii_lowercase()); return match term_program.as_deref() { Some("ghostty") => Some("ghostty".to_string()), Some("kitty") => Some("kitty".to_string()), Some("wezterm") => Some("wezterm".to_string()), Some("alacritty") => Some("alacritty".to_string()), Some("iterm.app") | Some("iterm2") => Some("iterm2".to_string()), Some("apple_terminal") | Some("terminal") => Some("terminal".to_string()), _ => None, }; } #[cfg(not(target_os = "macos"))] { None } } #[cfg(not(unix))] pub fn detected_resume_terminal() -> Option { if std::env::var("WT_SESSION").is_ok() { return Some("wt".to_string()); } if std::env::var("WEZTERM_EXECUTABLE").is_ok() || std::env::var("WEZTERM_PANE").is_ok() { return Some("wezterm".to_string()); } if std::env::var("ALACRITTY_WINDOW_ID").is_ok() { return Some("alacritty".to_string()); } None } #[cfg(unix)] pub fn resume_terminal_candidates() -> Vec { let mut candidates = Vec::new(); if let Ok(term) = std::env::var("JCODE_TERMINAL") { push_unique_terminal(&mut candidates, term); } if let Some(term) = detected_resume_terminal() { push_unique_terminal(&mut candidates, term); } #[cfg(target_os = "macos")] { for &term in MACOS_TERMINAL_PREFERENCE { if macos_should_try_app_terminal(term) { push_unique_terminal(&mut candidates, term); } } } #[cfg(not(target_os = "macos"))] { for term in [ "handterm", "kitty", "wezterm", "alacritty", "gnome-terminal", "konsole", "xterm", "foot", ] { push_unique_terminal(&mut candidates, term); } } candidates } #[cfg(not(unix))] pub fn resume_terminal_candidates() -> Vec { let mut candidates = Vec::new(); if let Ok(term) = std::env::var("JCODE_TERMINAL") { push_unique_terminal(&mut candidates, term); } if let Some(term) = detected_resume_terminal() { push_unique_terminal(&mut candidates, term); } for term in ["wezterm", "wt", "alacritty"] { push_unique_terminal(&mut candidates, term); } candidates } pub fn spawn_command_in_new_terminal_with( command: &TerminalCommand, cwd: &Path, mut spawn_detached: impl FnMut(&mut Command) -> std::io::Result<()>, ) -> Result { let mut last_spawn_error: Option = None; for term in resume_terminal_candidates() { let Some(mut cmd) = build_spawn_command(&term, command, cwd) else { continue; }; match spawn_detached(&mut cmd) { Ok(_) => return Ok(true), Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, Err(err) => last_spawn_error = Some(err), } } if let Some(err) = last_spawn_error { Err(err.into()) } else { Ok(false) } } /// Parse an external spawn-hook command line into argv parts. /// /// Supports basic POSIX-style word splitting: whitespace separates arguments, /// single and double quotes group words, and backslash escapes the next /// character (outside single quotes). Errors on empty input, unterminated /// quotes, and trailing escapes. pub fn parse_hook_command(raw: &str) -> Result> { let mut parts = Vec::new(); let mut current = String::new(); let mut quote: Option = None; let mut escaped = false; let mut token_started = false; for ch in raw.chars() { if escaped { current.push(ch); token_started = true; escaped = false; continue; } if let Some(quote_ch) = quote { if ch == quote_ch { quote = None; } else if ch == '\\' && quote_ch == '"' { escaped = true; } else { current.push(ch); token_started = true; } continue; } match ch { '\\' => { escaped = true; token_started = true; } '\'' | '"' => { quote = Some(ch); token_started = true; } ch if ch.is_whitespace() => { if token_started { parts.push(std::mem::take(&mut current)); token_started = false; } } ch => { current.push(ch); token_started = true; } } } if escaped { anyhow::bail!("spawn hook command ends with an escape character"); } if quote.is_some() { anyhow::bail!("spawn hook command has an unterminated quote"); } if token_started { parts.push(current); } if parts.is_empty() { anyhow::bail!("spawn hook command is empty"); } Ok(parts) } /// Expand a leading `~/` in a hook program path to the user's home directory, /// since the hook is executed directly (no shell) and would otherwise fail. pub fn expand_home(program: &str) -> PathBuf { if let Some(rest) = program.strip_prefix("~/") && let Some(home) = dirs::home_dir() { return home.join(rest); } PathBuf::from(program) } /// The `JCODE_SPAWN_*` metadata env exported to spawn hooks and to terminals /// launched by the built-in fallback: /// /// - `JCODE_SPAWN_KIND`: why this spawn happened ("resume", "selfdev", /// "swarm-agent", ...), when known. /// - `JCODE_SPAWN_SESSION_ID`: the jcode session the window will run. /// - `JCODE_SPAWN_TITLE`: the suggested window/tab title. /// - `JCODE_SPAWN_CWD`: the working directory for the session. /// - `JCODE_SPAWN_PROGRAM`: path of the jcode binary to execute. /// - `JCODE_SPAWN_COMMAND`: the full command line, shell-escaped, for hooks /// (like tmux) that take a single shell-command string. /// /// `TerminalCommand::extra_env` entries (e.g. `JCODE_SPAWN_SWARM_ID`, /// `JCODE_SPAWN_COORDINATOR_SESSION_ID`) are appended last and win collisions. fn spawn_metadata_env(command: &TerminalCommand, cwd: &Path) -> Vec<(String, String)> { let mut env: Vec<(String, String)> = Vec::new(); if let Some(kind) = &command.kind { env.push(("JCODE_SPAWN_KIND".to_string(), kind.clone())); } if let Some(session_id) = &command.session_id { env.push(("JCODE_SPAWN_SESSION_ID".to_string(), session_id.clone())); } if let Some(title) = &command.title { env.push(("JCODE_SPAWN_TITLE".to_string(), title.clone())); } env.push(( "JCODE_SPAWN_CWD".to_string(), cwd.to_string_lossy().into_owned(), )); env.push(( "JCODE_SPAWN_PROGRAM".to_string(), command.program.to_string_lossy().into_owned(), )); env.push(( "JCODE_SPAWN_COMMAND".to_string(), shell_command(&command_parts(command)), )); // Re-export the requesting client's terminal env so spawn/focus hooks use // the client's terminal, not the server's stale startup env (#405). Each // var is exported both under its native name (overriding the inherited // value the spawned process/hook would otherwise see) and under a // `JCODE_CLIENT_` alias so hooks can explicitly distinguish the // client's terminal from the server's. for (key, value) in &command.client_terminal_env { env.push((key.clone(), value.clone())); env.push((format!("JCODE_CLIENT_{key}"), value.clone())); } env.extend(command.extra_env.iter().cloned()); env } /// Build the process invocation for an external spawn hook. /// /// The hook command is parsed shell-style, then the target program and its /// arguments are appended as additional argv entries (the `$TERMINAL -e` /// convention), so `hook --flag` becomes `hook --flag `. /// The hook runs in the session working directory with the full /// `JCODE_SPAWN_*` metadata env set (see [`spawn_metadata_env`]); hooks that /// need a single shell-command string (tmux, kitty `@ launch`) can use /// `$JCODE_SPAWN_COMMAND` instead of the appended argv. pub fn build_hook_spawn_command( hook: &str, command: &TerminalCommand, cwd: &Path, ) -> Result { let parts = parse_hook_command(hook)?; let (program, prefix_args) = parts .split_first() .expect("parse_hook_command guarantees at least one part"); let mut cmd = Command::new(expand_home(program)); cmd.args(prefix_args) .arg(&command.program) .args(&command.args) .current_dir(cwd) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); if command.fresh_spawn { cmd.env("JCODE_FRESH_SPAWN", "1"); } for (key, value) in spawn_metadata_env(command, cwd) { cmd.env(key, value); } Ok(cmd) } fn build_spawn_command(term: &str, command: &TerminalCommand, cwd: &Path) -> Option { let title = command.title.as_deref().unwrap_or("jcode"); let mut cmd = Command::new(term); cmd.current_dir(cwd) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); if command.fresh_spawn { cmd.env("JCODE_FRESH_SPAWN", "1"); } match term { #[cfg(unix)] "handterm" => { let shell = shell_command(&command_parts(command)); cmd.args(["--backend", "gpu", "--exec", &shell]); } #[cfg(target_os = "macos")] "ghostty" => { let shell = shell_command(&command_parts(command)); cmd = Command::new("open"); cmd.current_dir(cwd) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .args(["-na", "Ghostty", "--args", "-e", "/bin/bash", "-lc"]) .arg(shell); if command.fresh_spawn { cmd.env("JCODE_FRESH_SPAWN", "1"); } } "kitty" => { cmd.args(["--title", title, "-e"]) .arg(&command.program) .args(&command.args); } "wezterm" => { cmd.args([ "start", "--always-new-process", "--", command.program.to_string_lossy().as_ref(), ]); cmd.args(&command.args); } "alacritty" => { cmd.args(["--title", title, "-e"]) .arg(&command.program) .args(&command.args); } "gnome-terminal" => { cmd.arg("--title").arg(title); cmd.arg("--").arg(&command.program).args(&command.args); } "konsole" | "xterm" | "foot" => { cmd.args(["-e"]).arg(&command.program).args(&command.args); } #[cfg(target_os = "macos")] "iterm2" => { let shell = shell_command(&command_parts(command)); cmd = Command::new("osascript"); cmd.args([ "-e", &format!( r#"tell application "iTerm2" create window with default profile command "{}" end tell"#, shell.replace('"', "\\\"") ), ]); } #[cfg(target_os = "macos")] "terminal" => { // `open -a Terminal --args ...` does NOT execute the binary with // arguments; it asks Terminal to open the file as a document. On a default // macOS install (where Apple Terminal is the only available terminal), that // means split/resume spawns silently fail to launch jcode. Use AppleScript's // `do script` so the command actually runs in a new Terminal window. cmd = Command::new("osascript"); cmd.args(["-e", &macos_terminal_applescript(command, cwd)]); } #[cfg(not(unix))] "wt" => { cmd.args(["new-tab", "--title", title]); cmd.arg(&command.program).args(&command.args); } _ => return None, } // Export spawn metadata to the terminal process so programs running // inside (shells, multiplexers) can also see why the window was opened. // Note: terminals launched indirectly (macOS `open`/`osascript` paths) do // not inherit this env, matching the existing JCODE_FRESH_SPAWN caveat. for (key, value) in spawn_metadata_env(command, cwd) { cmd.env(key, value); } Some(cmd) } fn command_parts(command: &TerminalCommand) -> Vec { std::iter::once(command.program.to_string_lossy().into_owned()) .chain(command.args.iter().cloned()) .collect() } /// Build the inner `/bin/sh` script that Apple Terminal's `do script` will run. /// /// `do script` always executes in a login shell, so we `cd` into the working /// directory and `exec` the target command (optionally injecting the fresh-spawn /// env var, which would otherwise be lost because the spawned shell does not /// inherit the env of the `osascript` process). #[cfg(any(target_os = "macos", test))] fn macos_terminal_inner_script(command: &TerminalCommand, cwd: &Path) -> String { let shell = shell_command(&command_parts(command)); format!( "cd {} && exec {}{}", sh_escape(&cwd.to_string_lossy()), if command.fresh_spawn { "env JCODE_FRESH_SPAWN=1 " } else { "" }, shell ) } /// Build the full AppleScript passed to `osascript -e` for Apple Terminal. #[cfg(any(target_os = "macos", test))] fn macos_terminal_applescript(command: &TerminalCommand, cwd: &Path) -> String { let inner = macos_terminal_inner_script(command, cwd); // AppleScript string literals are double-quoted, so backslashes and double // quotes from the shell script must be escaped (backslashes first). let escaped = inner.replace('\\', "\\\\").replace('"', "\\\""); format!("tell application \"Terminal\"\n activate\n do script \"{escaped}\"\nend tell") } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; static ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn spawn_metadata_env_reexports_client_terminal_env_with_native_and_client_keys() { // A spawn carrying the requesting client's terminal env (#405) should // export each var both natively (overriding the spawned process's // inherited/stale value) and under a `JCODE_CLIENT_*` alias so hooks can // distinguish the client's terminal from the server's. let command = TerminalCommand::new("/usr/local/bin/jcode", vec!["--resume".to_string()]) .kind("swarm-agent") .session_id("ses_405") .client_terminal_env(vec![ ("ZELLIJ_SESSION_NAME".to_string(), "sessionB".to_string()), ("DISPLAY".to_string(), ":1".to_string()), ]); let env = spawn_metadata_env(&command, Path::new("/tmp/work")); let lookup = |key: &str| { env.iter() .filter(|(k, _)| k == key) .map(|(_, v)| v.clone()) .next_back() }; assert_eq!(lookup("ZELLIJ_SESSION_NAME").as_deref(), Some("sessionB")); assert_eq!( lookup("JCODE_CLIENT_ZELLIJ_SESSION_NAME").as_deref(), Some("sessionB") ); assert_eq!(lookup("DISPLAY").as_deref(), Some(":1")); assert_eq!(lookup("JCODE_CLIENT_DISPLAY").as_deref(), Some(":1")); // The first-class spawn metadata still flows through. assert_eq!(lookup("JCODE_SPAWN_KIND").as_deref(), Some("swarm-agent")); } #[test] fn spawn_metadata_env_without_client_env_has_no_client_keys() { let command = TerminalCommand::new("/usr/local/bin/jcode", vec!["--resume".to_string()]) .kind("resume") .session_id("ses_plain"); let env = spawn_metadata_env(&command, Path::new("/tmp/work")); assert!( !env.iter().any(|(k, _)| k.starts_with("JCODE_CLIENT_")), "no client terminal env should produce no JCODE_CLIENT_* keys" ); } #[test] #[cfg(unix)] fn snapshot_client_terminal_env_captures_set_vars_only() { let _guard = ENV_LOCK.lock().unwrap(); unsafe { std::env::set_var("ZELLIJ_SESSION_NAME", "snapshot-test"); std::env::remove_var("TMUX"); } let snapshot = snapshot_client_terminal_env(); assert!( snapshot .iter() .any(|(k, v)| k == "ZELLIJ_SESSION_NAME" && v == "snapshot-test") ); assert!(!snapshot.iter().any(|(k, _)| k == "TMUX")); unsafe { std::env::remove_var("ZELLIJ_SESSION_NAME"); } } #[test] #[cfg(unix)] fn detected_resume_terminal_recognizes_ghostty_env() { let _guard = ENV_LOCK.lock().unwrap(); unsafe { std::env::remove_var("HANDTERM_SESSION"); std::env::remove_var("HANDTERM_PID"); std::env::remove_var("KITTY_PID"); std::env::remove_var("WEZTERM_EXECUTABLE"); std::env::remove_var("WEZTERM_PANE"); std::env::remove_var("ALACRITTY_WINDOW_ID"); std::env::set_var("GHOSTTY_RESOURCES_DIR", "/tmp/ghostty"); } #[cfg(target_os = "macos")] assert_eq!(detected_resume_terminal().as_deref(), Some("ghostty")); unsafe { std::env::remove_var("GHOSTTY_RESOURCES_DIR"); } } #[test] fn shell_command_quotes_arguments() { let shell = shell_command(&["jcode".to_string(), "it's ok".to_string()]); #[cfg(unix)] assert_eq!(shell, "'jcode' 'it'\"'\"'s ok'"); } #[test] #[cfg(unix)] fn macos_terminal_inner_script_runs_jcode() { let command = TerminalCommand::new( std::path::PathBuf::from("/usr/local/bin/jcode"), vec!["--resume".to_string(), "abc-123".to_string()], ); let script = macos_terminal_inner_script(&command, Path::new("/work/dir")); assert_eq!( script, "cd '/work/dir' && exec '/usr/local/bin/jcode' '--resume' 'abc-123'" ); // Must actually exec jcode, not the broken `open -a Terminal ` form. assert!(script.contains("exec '/usr/local/bin/jcode'")); } #[test] #[cfg(unix)] fn macos_terminal_inner_script_injects_fresh_spawn() { let command = TerminalCommand::new(std::path::PathBuf::from("/usr/local/bin/jcode"), vec![]) .fresh_spawn(); let script = macos_terminal_inner_script(&command, Path::new("/tmp")); assert_eq!( script, "cd '/tmp' && exec env JCODE_FRESH_SPAWN=1 '/usr/local/bin/jcode'" ); } #[test] #[cfg(unix)] fn macos_terminal_applescript_uses_do_script() { let command = TerminalCommand::new( std::path::PathBuf::from("/usr/local/bin/jcode"), vec!["--resume".to_string(), "abc-123".to_string()], ); let applescript = macos_terminal_applescript(&command, Path::new("/work/dir")); assert!(applescript.contains("tell application \"Terminal\"")); assert!(applescript.contains("do script")); // The shell's single quotes survive; AppleScript only escapes \\ and ". assert!(!applescript.contains("exec \\\"")); assert!(applescript.contains("'/usr/local/bin/jcode'")); } // Reproduction for issue #203 part 3: when no terminal emulator can be // spawned, the new-terminal resume path returns Ok(false), which the app // surfaces as "No terminal found. Resume manually:". #[test] fn no_terminal_available_returns_ok_false() { let command = TerminalCommand::new( std::path::PathBuf::from("/usr/local/bin/jcode"), vec!["--resume".to_string(), "abc-123".to_string()], ); let result = spawn_command_in_new_terminal_with(&command, Path::new("/tmp"), |_cmd| { // Simulate every candidate terminal being absent. Err(std::io::Error::from(std::io::ErrorKind::NotFound)) }); assert!(matches!(result, Ok(false))); } #[test] fn parse_hook_command_splits_words_and_quotes() { assert_eq!( parse_hook_command("tmux new-window --").unwrap(), vec!["tmux", "new-window", "--"] ); assert_eq!( parse_hook_command("my-hook --label 'two words'").unwrap(), vec!["my-hook", "--label", "two words"] ); assert_eq!( parse_hook_command(r#"hook "a \"b\" c""#).unwrap(), vec!["hook", r#"a "b" c"#] ); } #[test] fn parse_hook_command_rejects_bad_input() { assert!(parse_hook_command("").is_err()); assert!(parse_hook_command(" ").is_err()); assert!(parse_hook_command("hook 'unterminated").is_err()); assert!(parse_hook_command("hook trailing\\").is_err()); } fn env_value(cmd: &Command, key: &str) -> Option { cmd.get_envs().find_map(|(k, v)| { (k.to_string_lossy() == key).then(|| { v.map(|v| v.to_string_lossy().into_owned()) .unwrap_or_default() }) }) } #[test] fn hook_spawn_command_appends_program_args_and_exports_metadata() { let command = TerminalCommand::new( std::path::PathBuf::from("/usr/local/bin/jcode"), vec!["--resume".to_string(), "ses_abc".to_string()], ) .title("🦊 jcode ses_abc") .kind("swarm-agent") .session_id("ses_abc") .spawn_env("JCODE_SPAWN_SWARM_ID", "swarm-1") .fresh_spawn(); let cmd = build_hook_spawn_command("tmux-hook --flag", &command, Path::new("/work/dir")) .expect("hook command should build"); assert_eq!(cmd.get_program().to_string_lossy(), "tmux-hook"); let args: Vec = cmd .get_args() .map(|a| a.to_string_lossy().into_owned()) .collect(); assert_eq!( args, vec!["--flag", "/usr/local/bin/jcode", "--resume", "ses_abc"] ); assert_eq!( cmd.get_current_dir(), Some(Path::new("/work/dir")), "hook should run in the session working dir" ); assert_eq!( env_value(&cmd, "JCODE_SPAWN_KIND").as_deref(), Some("swarm-agent") ); assert_eq!( env_value(&cmd, "JCODE_SPAWN_SESSION_ID").as_deref(), Some("ses_abc") ); assert_eq!( env_value(&cmd, "JCODE_SPAWN_TITLE").as_deref(), Some("🦊 jcode ses_abc") ); assert_eq!( env_value(&cmd, "JCODE_SPAWN_CWD").as_deref(), Some("/work/dir") ); assert_eq!( env_value(&cmd, "JCODE_SPAWN_PROGRAM").as_deref(), Some("/usr/local/bin/jcode") ); #[cfg(unix)] assert_eq!( env_value(&cmd, "JCODE_SPAWN_COMMAND").as_deref(), Some("'/usr/local/bin/jcode' '--resume' 'ses_abc'") ); assert_eq!( env_value(&cmd, "JCODE_SPAWN_SWARM_ID").as_deref(), Some("swarm-1") ); assert_eq!(env_value(&cmd, "JCODE_FRESH_SPAWN").as_deref(), Some("1")); } #[test] #[cfg(all(unix, not(target_os = "macos")))] fn builtin_terminal_spawn_exports_metadata_env() { let command = TerminalCommand::new( std::path::PathBuf::from("/usr/local/bin/jcode"), vec!["--resume".to_string(), "ses_abc".to_string()], ) .kind("resume") .session_id("ses_abc"); let cmd = build_spawn_command("kitty", &command, Path::new("/work/dir")) .expect("kitty spawn command should build"); assert_eq!( env_value(&cmd, "JCODE_SPAWN_KIND").as_deref(), Some("resume") ); assert_eq!( env_value(&cmd, "JCODE_SPAWN_SESSION_ID").as_deref(), Some("ses_abc") ); } }