use crate::items::Dispatch;
use anyhow::{Context, Result};
use std::process::Command;
pub fn dispatch_for_action(action: &str) -> Option<Dispatch> {
use Dispatch::*;
let d = match action {
"new_workspace" => Cli(vec_into(&["herdr", "workspace", "create", "--focus"])),
"new_worktree" => Cli(vec_into(&["herdr", "worktree", "create", "--focus"])),
"previous_workspace" => PrevWorkspace,
"next_workspace" => NextWorkspace,
"new_tab" => Cli(vec_into(&["herdr", "tab", "create", "--focus"])),
"previous_tab" => PrevTab,
"next_tab" => NextTab,
"split_vertical" => Cli(vec_into(&[
"herdr",
"pane",
"split",
"--direction",
"right",
"--focus",
])),
"split_horizontal" => Cli(vec_into(&[
"herdr",
"pane",
"split",
"--direction",
"down",
"--focus",
])),
"zoom" | "fullscreen" => Cli(vec_into(&[
"herdr",
"pane",
"zoom",
"--current",
"--toggle",
])),
"focus_pane_left" => Cli(vec_into(&["herdr", "pane", "focus", "--direction", "left"])),
"focus_pane_down" => Cli(vec_into(&["herdr", "pane", "focus", "--direction", "down"])),
"focus_pane_up" => Cli(vec_into(&["herdr", "pane", "focus", "--direction", "up"])),
"focus_pane_right" => Cli(vec_into(&[
"herdr",
"pane",
"focus",
"--direction",
"right",
])),
"previous_agent" => PrevAgent,
"next_agent" => NextAgent,
_ => return None,
};
Some(d)
}
pub fn herdr_bin() -> Result<String> {
if let Ok(p) = std::env::var("HERDR_BIN_PATH") {
if !p.is_empty() {
return Ok(p);
}
}
which("herdr").context("could not find `herdr` on PATH (set HERDR_BIN_PATH?)")
}
fn which(cmd: &str) -> Result<String, std::io::Error> {
let path = std::env::var_os("PATH").unwrap_or_default();
for dir in std::env::split_paths(&path) {
let candidate = dir.join(cmd);
if candidate.is_file() {
return Ok(candidate.to_string_lossy().into_owned());
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{cmd} not found on PATH"),
))
}
pub fn run(dispatch: &Dispatch) -> Result<()> {
match dispatch {
Dispatch::Cli(argv) => {
let strs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
run_argv(&strs)?;
}
Dispatch::FocusWorkspace(id) => {
run_argv(&["herdr", "workspace", "focus", id])?;
}
Dispatch::FocusTab(id) => {
run_argv(&["herdr", "tab", "focus", id])?;
}
Dispatch::FocusAgent(target) => {
run_argv(&["herdr", "agent", "focus", target])?;
}
Dispatch::NextWorkspace => {
focus_neighbor("workspace", Neighbor::Next)?;
}
Dispatch::PrevWorkspace => {
focus_neighbor("workspace", Neighbor::Prev)?;
}
Dispatch::NextTab => {
focus_neighbor("tab", Neighbor::Next)?;
}
Dispatch::PrevTab => {
focus_neighbor("tab", Neighbor::Prev)?;
}
Dispatch::NextAgent => {
focus_neighbor("agent", Neighbor::Next)?;
}
Dispatch::PrevAgent => {
focus_neighbor("agent", Neighbor::Prev)?;
}
}
Ok(())
}
#[derive(Clone, Copy)]
enum Neighbor {
Next,
Prev,
}
fn focus_neighbor(kind: &str, neighbor: Neighbor) -> Result<()> {
let entries = list_entries(kind)?;
let ids: Vec<String> = entries.iter().map(|(id, _)| id.clone()).collect();
if ids.len() < 2 {
return Ok(()); }
let current = current_id(kind)?;
let pos = ids.iter().position(|id| id == ¤t).unwrap_or(0);
let target = match neighbor {
Neighbor::Next => (pos + 1) % ids.len(),
Neighbor::Prev => (pos + ids.len() - 1) % ids.len(),
};
let id = &ids[target];
run_argv(&["herdr", kind, "focus", id])
}
pub fn list_entries(kind: &str) -> Result<Vec<(String, String)>> {
let out = Command::new(herdr_bin()?).args([kind, "list"]).output()?;
if !out.status.success() {
anyhow::bail!(
"herdr {kind} list failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let text = String::from_utf8_lossy(&out.stdout);
extract_entries(&text, kind).context("could not parse entries from list output")
}
fn current_id(kind: &str) -> Result<String> {
let out = Command::new(herdr_bin()?).args([kind, "list"]).output()?;
if !out.status.success() {
anyhow::bail!(
"herdr {kind} list failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let text = String::from_utf8_lossy(&out.stdout);
extract_focused_id(&text, kind).context("could not determine focused id from list output")
}
fn extract_entries(text: &str, kind: &str) -> Result<Vec<(String, String)>> {
let id_field = id_field_for_kind(kind);
let v: serde_json::Value = serde_json::from_str(text).context("list output was not JSON")?;
let arr = list_array_for_kind(&v, kind).context("list output had no array")?;
let mut out = Vec::with_capacity(arr.len());
for entry in arr {
let id = entry
.get(id_field)
.and_then(|i| i.as_str())
.map(str::to_string);
let label = match kind {
"agent" => {
let agent = entry
.get("agent")
.and_then(|s| s.as_str())
.unwrap_or("agent");
let cwd = entry
.get("cwd")
.and_then(|s| s.as_str())
.map(|c| {
std::path::Path::new(c)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| c.to_string())
})
.unwrap_or_default();
format!("{agent} · {cwd}")
}
_ => entry
.get("label")
.and_then(|s| s.as_str())
.map(str::to_string)
.unwrap_or_default(),
};
if let Some(id) = id {
out.push((id, label));
}
}
Ok(out)
}
fn extract_focused_id(text: &str, kind: &str) -> Result<String> {
let id_field = id_field_for_kind(kind);
let v: serde_json::Value = serde_json::from_str(text).context("list output was not JSON")?;
let arr = list_array_for_kind(&v, kind).context("list output had no array")?;
let fallback = arr
.iter()
.find_map(|entry| entry.get(id_field).and_then(|id| id.as_str()));
arr.iter()
.find(|entry| entry.get("focused").and_then(|focused| focused.as_bool()) == Some(true))
.and_then(|entry| entry.get(id_field).and_then(|id| id.as_str()))
.or(fallback)
.map(str::to_string)
.context("list output had no id")
}
fn list_array_for_kind<'a>(
v: &'a serde_json::Value,
kind: &str,
) -> Option<&'a Vec<serde_json::Value>> {
let plural = match kind {
"workspace" => "workspaces",
"tab" => "tabs",
"agent" => "agents",
other => other,
};
v.get("result")
.and_then(|r| r.get(plural))
.and_then(|w| w.as_array())
.or_else(|| v.as_array())
}
fn id_field_for_kind(kind: &str) -> &'static str {
match kind {
"workspace" => "workspace_id",
"tab" => "tab_id",
"agent" => "terminal_id",
_ => "id",
}
}
fn run_argv(argv: &[&str]) -> Result<()> {
let mut owned: Vec<String> = argv.iter().map(|s| s.to_string()).collect();
if owned.first().is_some_and(|first| first == "herdr") {
owned[0] = herdr_bin()?;
}
let (cmd, args) = owned.split_first().context("empty argv")?;
Command::new(cmd).args(args).spawn()?.wait()?;
Ok(())
}
fn vec_into(slice: &[&str]) -> Vec<String> {
slice.iter().map(|s| s.to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dispatchable_actions_map_to_herdr_0_7_cli() {
assert!(matches!(
dispatch_for_action("new_workspace"),
Some(Dispatch::Cli(_))
));
assert!(matches!(
dispatch_for_action("split_vertical"),
Some(Dispatch::Cli(ref argv)) if argv == &vec_into(&["herdr", "pane", "split", "--direction", "right", "--focus"])
));
assert!(matches!(
dispatch_for_action("split_horizontal"),
Some(Dispatch::Cli(ref argv)) if argv == &vec_into(&["herdr", "pane", "split", "--direction", "down", "--focus"])
));
assert!(matches!(
dispatch_for_action("focus_pane_left"),
Some(Dispatch::Cli(ref argv)) if argv == &vec_into(&["herdr", "pane", "focus", "--direction", "left"])
));
assert!(matches!(
dispatch_for_action("zoom"),
Some(Dispatch::Cli(ref argv)) if argv == &vec_into(&["herdr", "pane", "zoom", "--current", "--toggle"])
));
}
#[test]
fn id_or_prompt_required_actions_are_reference_only() {
for action in [
"rename_workspace",
"close_workspace",
"rename_tab",
"close_tab",
"rename_pane",
"close_pane",
"cycle_pane_next",
"cycle_pane_previous",
] {
assert!(
dispatch_for_action(action).is_none(),
"{action} should stay reference-only until palette can supply the required target/prompt"
);
}
}
#[test]
fn prev_next_map_to_neighbor_dispatch() {
assert!(matches!(
dispatch_for_action("next_workspace"),
Some(Dispatch::NextWorkspace)
));
assert!(matches!(
dispatch_for_action("previous_tab"),
Some(Dispatch::PrevTab)
));
assert!(matches!(
dispatch_for_action("next_agent"),
Some(Dispatch::NextAgent)
));
}
#[test]
fn keybinding_only_actions_have_no_dispatch() {
for action in [
"help",
"settings",
"detach",
"goto",
"workspace_picker",
"resize_mode",
"toggle_sidebar",
"edit_scrollback",
"reload_config",
] {
assert!(
dispatch_for_action(action).is_none(),
"{action} should be reference-only"
);
}
}
#[test]
fn extract_focused_id_uses_focused_field_before_fallback() {
let ws = r#"{"result":{"workspaces":[{"workspace_id":"w1","label":"one","focused":false},{"workspace_id":"w2","label":"two","focused":true}]}}"#;
assert_eq!(extract_focused_id(ws, "workspace").unwrap(), "w2");
let agents = r#"{"result":{"agents":[{"terminal_id":"term_1","focused":false},{"terminal_id":"term_2","focused":true}]}}"#;
assert_eq!(extract_focused_id(agents, "agent").unwrap(), "term_2");
}
#[test]
fn extract_entries_maps_kind_specific_id_fields() {
let ws = r#"{"result":{"workspaces":[{"workspace_id":"w1","label":"toolbox"}]}}"#;
assert_eq!(
extract_entries(ws, "workspace").unwrap(),
vec![("w1".into(), "toolbox".into())]
);
let tabs = r#"{"result":{"tabs":[{"tab_id":"w1:t1","label":"logs"}]}}"#;
assert_eq!(
extract_entries(tabs, "tab").unwrap(),
vec![("w1:t1".into(), "logs".into())]
);
}
#[test]
fn extract_entries_synthesizes_agent_label() {
let agents = r#"{"result":{"agents":[{"terminal_id":"term_1","agent":"claude","cwd":"/Users/x/toolbox"}]}}"#;
let e = extract_entries(agents, "agent").unwrap();
assert_eq!(e.len(), 1);
assert_eq!(e[0].0, "term_1");
assert_eq!(e[0].1, "claude · toolbox");
}
#[test]
fn extract_entries_falls_back_to_flat_array() {
let flat = r#"[{"workspace_id":"w1","label":"a"}]"#;
assert_eq!(
extract_entries(flat, "workspace").unwrap(),
vec![("w1".into(), "a".into())]
);
}
}