use anyhow::Context;
use serde_json::Value;
use std::process::Command;
pub struct Terminal {
pub handle: String,
}
fn orca() -> Command {
let bin = std::env::var("ORCA_CLI_COMMAND").unwrap_or_else(|_| "orca".into());
Command::new(bin)
}
fn run_json(mut cmd: Command) -> anyhow::Result<Value> {
cmd.arg("--json");
let out = cmd.output().context("run orca")?;
if !out.status.success() {
anyhow::bail!("orca failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(serde_json::from_slice(&out.stdout).context("parse orca --json output")?)
}
pub fn create(title: &str, cwd: &std::path::Path, env_task: &str) -> anyhow::Result<Terminal> {
create_with_opts(title, cwd, env_task, &CreateOpts::default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CreateOpts {
pub focus: bool,
}
impl Default for CreateOpts {
fn default() -> Self {
let mode = std::env::var("SWARM_FOCUS").unwrap_or_default();
Self {
focus: mode == "all" || mode == "new",
}
}
}
pub fn create_argv(title: &str, focus: bool, worktree_dir: Option<&std::path::Path>) -> Vec<String> {
let mut argv = vec!["terminal".to_string(), "create".to_string()];
if let Some(dir) = worktree_dir {
let canon = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
argv.push("--worktree".to_string());
argv.push(format!("path:{}", canon.display()));
}
argv.push("--title".to_string());
argv.push(title.to_string());
if focus {
argv.push("--focus".to_string());
}
argv.push("--command".to_string());
argv
}
pub fn create_with_opts(
title: &str,
cwd: &std::path::Path,
env_task: &str,
opts: &CreateOpts,
) -> anyhow::Result<Terminal> {
if std::env::var("SWARM_STUB_AGENT").as_deref() == Ok("1") {
return Ok(Terminal {
handle: format!("stub-{title}-{env_task}"),
});
}
let mut cmd = orca();
cmd.args(create_argv(title, opts.focus, Some(cwd)));
cmd.arg(session_command(cwd, env_task));
let v = run_json(cmd)?;
let handle = parse_terminal_handle(&v).unwrap_or_default();
if handle.is_empty() {
anyhow::bail!("orca terminal create returned no handle: {v}");
}
Ok(Terminal { handle })
}
pub fn rename(handle: &str, title: &str) -> anyhow::Result<()> {
let mut cmd = orca();
cmd.args(["terminal", "rename", "--terminal", handle, "--title", title]);
run_json(cmd)?;
Ok(())
}
pub fn focus_argv(handle: &str) -> Vec<String> {
vec![
"terminal".to_string(),
"switch".to_string(),
"--terminal".to_string(),
handle.to_string(),
]
}
pub fn focus(handle: &str) -> anyhow::Result<()> {
let mut cmd = orca();
cmd.args(focus_argv(handle));
run_json(cmd)?;
Ok(())
}
pub fn close(handle: &str) -> anyhow::Result<()> {
let mut cmd = orca();
cmd.args(["terminal", "close", "--terminal", handle]);
run_json(cmd)?;
Ok(())
}
pub fn kill_pi(handle: &str) -> anyhow::Result<()> {
send(handle, "kill -TERM $(pgrep -P $$ pi 2>/dev/null || pgrep pi | head -1) 2>/dev/null; exit", true)
}
pub fn send(handle: &str, text: &str, enter: bool) -> anyhow::Result<()> {
let mut cmd = orca();
cmd.args(send_argv(handle, text, enter));
run_json(cmd)?;
Ok(())
}
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub fn parse_terminal_handle(v: &Value) -> Option<String> {
for ptr in ["/result/terminal/handle", "/terminal/handle"] {
if let Some(h) = v.pointer(ptr).and_then(|h| h.as_str()) {
if !h.is_empty() {
return Some(h.to_string());
}
}
}
if let Some(h) = v.get("handle").and_then(|h| h.as_str()) {
if !h.is_empty() {
return Some(h.to_string());
}
}
if let Some(h) = v
.get("terminal")
.and_then(|h| h.as_str().or_else(|| h.get("handle")?.as_str()))
{
if !h.is_empty() {
return Some(h.to_string());
}
}
None
}
pub fn session_command(workspace_dir: &std::path::Path, task_id: &str) -> String {
format!(
"cd {} && ONLYNE_SWARM_TASK={} pi",
shell_escape(&workspace_dir.to_string_lossy()),
shell_escape(task_id),
)
}
pub fn send_argv(handle: &str, text: &str, enter: bool) -> Vec<String> {
let mut argv = vec![
"terminal".to_string(),
"send".to_string(),
"--terminal".to_string(),
handle.to_string(),
"--text".to_string(),
text.to_string(),
];
if enter {
argv.push("--enter".to_string());
}
argv
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn handle_shapes() {
let v = json!({"ok": true, "result": {"terminal": {"handle": "term_abc"}}});
assert_eq!(parse_terminal_handle(&v).as_deref(), Some("term_abc"));
let v = json!({"terminal": {"handle": "term_x"}});
assert_eq!(parse_terminal_handle(&v).as_deref(), Some("term_x"));
let v = json!({"handle": "term_h"});
assert_eq!(parse_terminal_handle(&v).as_deref(), Some("term_h"));
let v = json!({"terminal": "term_s"});
assert_eq!(parse_terminal_handle(&v).as_deref(), Some("term_s"));
assert_eq!(parse_terminal_handle(&json!({"ok": false})), None);
assert_eq!(parse_terminal_handle(&json!({"handle": ""})), None);
}
#[test]
fn session_command_quotes_paths_and_task() {
let cmd = session_command(
std::path::Path::new("/tmp/a b/c"),
"task-1",
);
assert!(cmd.starts_with("cd '/tmp/a b/c'"), "{cmd}");
assert!(cmd.contains("ONLYNE_SWARM_TASK='task-1'"), "{cmd}");
assert!(cmd.ends_with(" pi"), "{cmd}");
assert!(!cmd.contains("-ne"), "{cmd}");
let cmd = session_command(std::path::Path::new("/o'b"), "t");
assert!(cmd.contains("'/o'\\''b'"), "{cmd}");
}
#[test]
fn create_argv_focus_is_opt_in() {
assert_eq!(
create_argv("swarm:a:12345678", false, None),
vec!["terminal", "create", "--title", "swarm:a:12345678", "--command"]
);
assert_eq!(
create_argv("swarm:a:12345678", true, None).last().map(String::as_str),
Some("--command")
);
assert!(create_argv("t", true, None).contains(&"--focus".to_string()));
}
#[test]
fn create_argv_pins_tab_to_workspace_node() {
let argv = create_argv(
"swarm:model:12345678",
false,
Some(std::path::Path::new("/tmp")),
);
let flat: Vec<&str> = argv.iter().map(String::as_str).collect();
let i = flat.iter().position(|a| *a == "--worktree").expect("has --worktree");
assert!(flat[i + 1].starts_with("path:"), "{}", flat[i + 1]);
assert!(flat[i + 1].ends_with("/tmp") || flat[i + 1].ends_with("/private/tmp"), "{}", flat[i + 1]);
assert!(i < flat.iter().position(|a| *a == "--title").unwrap());
}
#[test]
fn focus_argv_shape() {
assert_eq!(
focus_argv("term_abc"),
vec!["terminal", "switch", "--terminal", "term_abc"]
);
}
#[test]
fn send_argv_shape() {
assert_eq!(
send_argv("h1", "hi", false),
vec!["terminal", "send", "--terminal", "h1", "--text", "hi"]
);
assert_eq!(
send_argv("h1", "hi", true).last().map(String::as_str),
Some("--enter")
);
}
}