use anyhow::{Context, Result, anyhow};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{debug, warn};
use crate::cmd::Cmd;
use crate::config::SplitDirection;
use super::handshake::UnixPipeHandshake;
use super::types::{CreateWindowParams, LivePaneInfo};
use super::{Multiplexer, PaneHandshake};
pub struct ZellijBackend {
_private: (),
}
#[derive(Debug, serde::Deserialize)]
struct PaneInfo {
id: u32,
is_plugin: bool,
is_focused: bool,
terminal_command: Option<String>,
#[serde(default)]
pane_command: Option<String>,
#[serde(default)]
pane_cwd: Option<String>,
#[serde(default)]
tab_id: Option<u32>,
#[serde(default)]
tab_name: String,
#[serde(default)]
title: String,
}
#[derive(Debug, serde::Deserialize)]
struct TabInfo {
tab_id: u32, #[allow(dead_code)]
position: u32, name: String,
#[allow(dead_code)]
active: bool,
}
impl TabInfo {
fn tab_id(&self) -> u32 {
self.tab_id
}
}
fn parse_pane_id(pane_id: &str) -> Option<u32> {
pane_id
.strip_prefix("terminal_")
.and_then(|s| s.parse().ok())
}
fn extract_base_command(pane_command: Option<&str>, terminal_command: Option<&str>) -> String {
pane_command
.or(terminal_command)
.and_then(|cmd| cmd.split_whitespace().next())
.unwrap_or("")
.split('/')
.next_back()
.unwrap_or("")
.to_string()
}
fn parse_tab_name_from_output(output: &str) -> Option<String> {
output
.lines()
.find(|l| l.starts_with("name: "))
.map(|l| l["name: ".len()..].to_string())
}
impl Default for ZellijBackend {
fn default() -> Self {
Self::new()
}
}
impl ZellijBackend {
pub fn new() -> Self {
Self { _private: () }
}
fn is_inside_session() -> bool {
std::env::var("ZELLIJ").is_ok()
}
fn session_name() -> Option<String> {
std::env::var("ZELLIJ_SESSION_NAME").ok()
}
fn pane_id_from_env() -> Option<String> {
std::env::var("ZELLIJ_PANE_ID")
.ok()
.map(|id| format!("terminal_{}", id))
}
fn focused_tab_name() -> Option<String> {
let output = Cmd::new("zellij")
.args(&["action", "current-tab-info"])
.run_and_capture_stdout()
.ok()?;
parse_tab_name_from_output(&output)
}
fn list_panes() -> Result<Vec<PaneInfo>> {
let output = Cmd::new("zellij")
.args(&["action", "list-panes", "--json", "--tab", "--command"])
.run_and_capture_stdout()
.context("Failed to list panes")?;
serde_json::from_str(&output).context("Failed to parse list-panes JSON output")
}
fn list_tabs() -> Result<Vec<TabInfo>> {
let output = Cmd::new("zellij")
.args(&["action", "list-tabs", "--json"])
.run_and_capture_stdout()
.context("Failed to list tabs")?;
serde_json::from_str(&output).context("Failed to parse list-tabs JSON output")
}
fn focused_pane_id() -> Result<u32> {
let panes = Self::list_panes()?;
let focused_tab = Self::focused_tab_name();
if let Some(tab_name) = focused_tab {
panes
.iter()
.find(|p| p.is_focused && !p.is_plugin && p.tab_name == tab_name)
.map(|p| p.id)
.ok_or_else(|| anyhow!("No focused terminal pane found in tab '{}'", tab_name))
} else {
panes
.iter()
.find(|p| p.is_focused && !p.is_plugin)
.map(|p| p.id)
.ok_or_else(|| anyhow!("No focused terminal pane found"))
}
}
#[allow(dead_code)]
fn get_tab_id_by_name(name: &str) -> Result<Option<u32>> {
let tabs = Self::list_tabs()?;
Ok(tabs
.into_iter()
.find(|t| t.name == name)
.map(|t| t.tab_id()))
}
}
impl Multiplexer for ZellijBackend {
fn name(&self) -> &'static str {
"zellij"
}
fn supports_preview(&self) -> bool {
false }
fn requires_focus_for_input(&self) -> bool {
true }
fn should_exit_on_jump(&self) -> bool {
false }
fn is_running(&self) -> Result<bool> {
if Self::is_inside_session() {
return Ok(true);
}
Cmd::new("zellij")
.args(&["action", "dump-screen", "/dev/null"])
.run_as_check()
}
fn current_pane_id(&self) -> Option<String> {
Self::pane_id_from_env()
}
fn active_pane_id(&self) -> Option<String> {
Self::focused_pane_id()
.ok()
.map(|id| format!("terminal_{}", id))
}
fn get_client_active_pane_path(&self) -> Result<PathBuf> {
std::env::current_dir().context("Failed to get current directory")
}
fn instance_id(&self) -> String {
Self::session_name().unwrap_or_else(|| "default".to_string())
}
fn create_session(&self, _params: super::types::CreateSessionParams) -> Result<String> {
Err(anyhow!(
"Session mode (--session) is not supported in Zellij. Use window mode instead."
))
}
fn switch_to_session(&self, _prefix: &str, _name: &str) -> Result<()> {
Err(anyhow!(
"Session mode is not supported in Zellij. Use window mode instead."
))
}
fn session_exists(&self, _full_name: &str) -> Result<bool> {
Ok(false)
}
fn kill_session(&self, _full_name: &str) -> Result<()> {
Ok(())
}
fn schedule_session_close(&self, _full_name: &str, _delay: Duration) -> Result<()> {
Err(anyhow!(
"Session mode is not supported in Zellij. Use window mode instead."
))
}
fn get_all_session_names(&self) -> Result<HashSet<String>> {
Ok(HashSet::new())
}
fn wait_until_session_closed(&self, _full_session_name: &str) -> Result<()> {
Err(anyhow!(
"Session mode is not supported in Zellij. Use window mode instead."
))
}
fn run_deferred_script(&self, script: &str) -> Result<()> {
let bg_script = format!("nohup sh -c '{}' >/dev/null 2>&1 &", script);
Cmd::new("sh").args(&["-c", &bg_script]).run()?;
Ok(())
}
fn shell_select_window_cmd(&self, full_name: &str) -> Result<String> {
let tabs = Self::list_tabs()?;
let tab = tabs
.iter()
.find(|t| t.name == full_name)
.ok_or_else(|| anyhow!("Window '{}' not found", full_name))?;
Ok(format!(
"zellij action go-to-tab-by-id {} >/dev/null 2>&1",
tab.tab_id()
))
}
fn shell_kill_window_cmd(&self, full_name: &str) -> Result<String> {
let tabs = Self::list_tabs()?;
let tab = tabs
.iter()
.find(|t| t.name == full_name)
.ok_or_else(|| anyhow!("Window '{}' not found", full_name))?;
Ok(format!(
"zellij action close-tab-by-id {} >/dev/null 2>&1",
tab.tab_id()
))
}
fn shell_switch_session_cmd(&self, _full_name: &str) -> Result<String> {
Err(anyhow!(
"Session mode is not supported in Zellij. Use window mode instead."
))
}
fn shell_kill_session_cmd(&self, _full_name: &str) -> Result<String> {
Err(anyhow!(
"Session mode is not supported in Zellij. Use window mode instead."
))
}
fn create_window(&self, params: CreateWindowParams) -> Result<String> {
let full_name = format!("{}{}", params.prefix, params.name);
let cwd_str = params
.cwd
.to_str()
.ok_or_else(|| anyhow!("Path contains non-UTF8 characters"))?;
if params.after_window.is_some() {
debug!("Zellij does not support window insertion order - ignoring after_window");
}
let tab_id_str = Cmd::new("zellij")
.args(&["action", "new-tab", "--name", &full_name, "--cwd", cwd_str])
.run_and_capture_stdout()
.with_context(|| format!("Failed to create zellij tab '{}'", full_name))?;
let tab_id: u32 = tab_id_str
.trim()
.parse()
.with_context(|| format!("Invalid tab ID from new-tab: '{}'", tab_id_str.trim()))?;
let panes = Self::list_panes()?;
let pane = panes
.iter()
.find(|p| !p.is_plugin && p.tab_id == Some(tab_id))
.ok_or_else(|| anyhow!("No terminal pane found in new tab {}", tab_id))?;
Ok(format!("terminal_{}", pane.id))
}
fn kill_window(&self, full_name: &str) -> Result<()> {
let tabs = Self::list_tabs()?;
if let Some(tab) = tabs.iter().find(|t| t.name == full_name) {
let tab_id = tab.tab_id().to_string();
Cmd::new("zellij")
.args(&["action", "close-tab-by-id", &tab_id])
.run()
.context("Failed to close zellij tab by ID")?;
} else {
warn!("Tab '{}' not found, using fallback close method", full_name);
Cmd::new("zellij")
.args(&["action", "go-to-tab-name", full_name])
.run()
.context("Failed to switch to tab for closing")?;
Cmd::new("zellij")
.args(&["action", "close-tab"])
.run()
.context("Failed to close zellij tab")?;
}
Ok(())
}
fn schedule_window_close(&self, full_name: &str, delay: Duration) -> Result<()> {
let tabs = Self::list_tabs()?;
let tab_id = tabs
.iter()
.find(|t| t.name == full_name)
.map(|t| t.tab_id().to_string());
let delay_secs = delay.as_secs();
let cmd = if let Some(id) = tab_id {
format!(
"sleep {} && zellij action close-tab-by-id {}",
delay_secs, id
)
} else {
format!(
"sleep {} && zellij action go-to-tab-name '{}' && zellij action close-tab",
delay_secs,
full_name.replace('\'', "'\\''")
)
};
std::process::Command::new("sh")
.args(["-c", &cmd])
.spawn()
.context("Failed to spawn delayed close")?;
Ok(())
}
fn select_window(&self, prefix: &str, name: &str) -> Result<()> {
let full_name = format!("{}{}", prefix, name);
let tabs = Self::list_tabs()?;
if let Some(tab) = tabs.iter().find(|t| t.name == full_name) {
let tab_id = tab.tab_id().to_string();
Cmd::new("zellij")
.args(&["action", "go-to-tab-by-id", &tab_id])
.run()
.context("Failed to select zellij tab by ID")?;
} else {
warn!(
"Tab '{}' not found, using fallback select method",
full_name
);
Cmd::new("zellij")
.args(&["action", "go-to-tab-name", &full_name])
.run()
.context("Failed to select zellij tab")?;
}
Ok(())
}
fn window_exists(&self, prefix: &str, name: &str) -> Result<bool> {
let full_name = format!("{}{}", prefix, name);
self.window_exists_by_full_name(&full_name)
}
fn window_exists_by_full_name(&self, full_name: &str) -> Result<bool> {
if !Self::is_inside_session() {
return Ok(false);
}
let tabs = Self::list_tabs()?;
Ok(tabs.iter().any(|t| t.name == full_name))
}
fn current_window_name(&self) -> Result<Option<String>> {
Ok(Self::focused_tab_name())
}
fn get_all_window_names(&self) -> Result<HashSet<String>> {
if !Self::is_inside_session() {
return Ok(HashSet::new());
}
let tabs = Self::list_tabs()?;
Ok(tabs.into_iter().map(|t| t.name).collect())
}
fn filter_active_windows(&self, windows: &[String]) -> Result<Vec<String>> {
let active = self.get_all_window_names()?;
Ok(windows
.iter()
.filter(|w| active.contains(*w))
.cloned()
.collect())
}
fn find_last_window_with_prefix(&self, _prefix: &str) -> Result<Option<String>> {
Ok(None)
}
fn find_last_window_with_base_handle(
&self,
_prefix: &str,
_base_handle: &str,
) -> Result<Option<String>> {
Ok(None)
}
fn wait_until_windows_closed(&self, full_window_names: &[String]) -> Result<()> {
use std::thread;
loop {
let active = self.get_all_window_names()?;
if full_window_names.iter().all(|w| !active.contains(w)) {
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
}
fn select_pane(&self, pane_id: &str) -> Result<()> {
let target_id: u32 =
parse_pane_id(pane_id).ok_or_else(|| anyhow!("Invalid pane_id: {}", pane_id))?;
let focused_tab =
Self::focused_tab_name().ok_or_else(|| anyhow!("Could not determine focused tab"))?;
let all_panes = Self::list_panes()?;
let tab_panes: Vec<_> = all_panes
.iter()
.filter(|p| !p.is_plugin && p.tab_name == focused_tab)
.collect();
let current_idx = tab_panes
.iter()
.position(|p| p.is_focused)
.ok_or_else(|| anyhow!("No focused pane found in current tab"))?;
let target_idx = tab_panes
.iter()
.position(|p| p.id == target_id)
.ok_or_else(|| anyhow!("Target pane {} not found in current tab", pane_id))?;
if current_idx == target_idx {
return Ok(());
}
if target_idx < current_idx {
let steps = current_idx - target_idx;
debug!(
current_idx,
target_idx, steps, "Navigating backwards to focused pane"
);
for _ in 0..steps {
Cmd::new("zellij")
.args(&["action", "focus-previous-pane"])
.run()
.context("Failed to navigate to previous pane")?;
}
} else {
let steps = target_idx - current_idx;
debug!(
current_idx,
target_idx, steps, "Navigating forwards to focused pane"
);
for _ in 0..steps {
Cmd::new("zellij")
.args(&["action", "focus-next-pane"])
.run()
.context("Failed to navigate to next pane")?;
}
}
Ok(())
}
fn switch_to_pane(&self, pane_id: &str, window_hint: Option<&str>) -> Result<()> {
let tab_name = window_hint.ok_or_else(|| {
anyhow!(
"Zellij switch_to_pane requires window_hint (tab name) for pane '{}'",
pane_id
)
})?;
debug!(pane_id, tab_name, "switch_to_pane: switching to tab");
let tabs = Self::list_tabs()?;
if let Some(tab) = tabs.iter().find(|t| t.name == tab_name) {
let tab_id = tab.tab_id().to_string();
Cmd::new("zellij")
.args(&["action", "go-to-tab-by-id", &tab_id])
.run()
.with_context(|| format!("Failed to switch to tab '{}' by ID", tab_name))?;
} else {
Cmd::new("zellij")
.args(&["action", "go-to-tab-name", tab_name])
.run()
.with_context(|| format!("Failed to switch to tab '{}'", tab_name))?;
}
Ok(())
}
fn kill_pane(&self, pane_id: &str) -> Result<()> {
let numeric_id =
parse_pane_id(pane_id).ok_or_else(|| anyhow!("Invalid pane_id format: {}", pane_id))?;
let panes = Self::list_panes().context("Failed to list panes in kill_pane")?;
let tab_id = panes
.iter()
.find(|p| p.id == numeric_id && !p.is_plugin)
.and_then(|p| p.tab_id)
.ok_or_else(|| anyhow!("Pane {} not found or tab_id unavailable", pane_id))?;
Cmd::new("zellij")
.args(&["action", "close-tab-by-id", &tab_id.to_string()])
.run()?;
Ok(())
}
fn respawn_pane(&self, pane_id: &str, cwd: &Path, cmd: Option<&str>) -> Result<String> {
debug!(pane_id, "respawn_pane: starting");
let panes = Self::list_panes().context("Failed to list panes in respawn_pane")?;
let numeric_id: u32 =
parse_pane_id(pane_id).ok_or_else(|| anyhow!("Invalid pane_id format: {}", pane_id))?;
if !panes.iter().any(|p| p.id == numeric_id && !p.is_plugin) {
return Err(anyhow!(
"Pane {} not found. Available panes: {:?}",
pane_id,
panes
.iter()
.map(|p| format!("terminal_{}", p.id))
.collect::<Vec<_>>()
));
}
let cwd_str = cwd
.to_str()
.ok_or_else(|| anyhow!("Path contains non-UTF8 characters"))?;
let combined = if let Some(command) = cmd {
debug!(
pane_id,
command = &command[..command.len().min(100)],
"respawn_pane: sending cd + command"
);
format!("cd '{}' && {}", cwd_str.replace('\'', "'\\''"), command)
} else {
debug!(pane_id, "respawn_pane: sending cd command");
format!("cd '{}'", cwd_str.replace('\'', "'\\''"))
};
Cmd::new("zellij")
.args(&["action", "write-chars", "--pane-id", pane_id, &combined])
.run()?;
Cmd::new("zellij")
.args(&["action", "write", "--pane-id", pane_id, "13"])
.run()?;
debug!(pane_id, "respawn_pane: completed");
Ok(pane_id.to_string())
}
fn capture_pane(&self, _pane_id: &str, _lines: u16) -> Option<String> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let thread_id = std::thread::current().id();
let temp_path = std::env::temp_dir().join(format!(
"zellij_capture_{}_{:?}_{}",
std::process::id(),
thread_id,
timestamp
));
let temp_str = temp_path.to_string_lossy();
if Cmd::new("zellij")
.args(&["action", "dump-screen", &temp_str])
.run()
.is_ok()
{
if let Ok(content) = std::fs::read_to_string(&temp_path) {
let _ = std::fs::remove_file(&temp_path);
return Some(content);
}
let _ = std::fs::remove_file(&temp_path);
}
None
}
fn send_keys(&self, pane_id: &str, command: &str) -> Result<()> {
Cmd::new("zellij")
.args(&["action", "write-chars", "--pane-id", pane_id, command])
.run()
.context("Failed to send keys")?;
Cmd::new("zellij")
.args(&["action", "write", "--pane-id", pane_id, "13"])
.run()
.context("Failed to send Enter")?;
Ok(())
}
fn send_keys_to_agent(&self, pane_id: &str, command: &str, agent: Option<&str>) -> Result<()> {
use super::agent;
let profile = agent::resolve_profile(agent);
if profile.needs_bang_delay() && command.starts_with('!') {
Cmd::new("zellij")
.args(&["action", "write-chars", "--pane-id", pane_id, "!"])
.run()?;
std::thread::sleep(std::time::Duration::from_millis(50));
Cmd::new("zellij")
.args(&["action", "write-chars", "--pane-id", pane_id, &command[1..]])
.run()?;
Cmd::new("zellij")
.args(&["action", "write", "--pane-id", pane_id, "13"])
.run()?;
Ok(())
} else {
self.send_keys(pane_id, command)
}
}
fn send_key(&self, pane_id: &str, key: &str) -> Result<()> {
let code = match key {
"Enter" => "13",
"Escape" => "27",
"Tab" => "9",
_ => {
Cmd::new("zellij")
.args(&["action", "write-chars", "--pane-id", pane_id, key])
.run()
.context("Failed to send key")?;
return Ok(());
}
};
Cmd::new("zellij")
.args(&["action", "write", "--pane-id", pane_id, code])
.run()
.context("Failed to send key")?;
Ok(())
}
fn paste_multiline(&self, pane_id: &str, content: &str) -> Result<()> {
for line in content.lines() {
Cmd::new("zellij")
.args(&["action", "write-chars", "--pane-id", pane_id, line])
.run()?;
Cmd::new("zellij")
.args(&["action", "write", "--pane-id", pane_id, "13"])
.run()?;
}
Ok(())
}
fn clear_pane(&self, pane_id: &str) -> Result<()> {
let result = Cmd::new("zellij")
.args(&["action", "clear", "--pane-id", pane_id])
.run();
if result.is_err() {
Cmd::new("zellij")
.args(&["action", "clear"])
.run()
.context("Failed to clear pane")?;
}
Ok(())
}
fn get_default_shell(&self) -> Result<String> {
std::env::var("SHELL").or_else(|_| Ok("/bin/sh".to_string()))
}
fn create_handshake(&self) -> Result<Box<dyn PaneHandshake>> {
Ok(Box::new(UnixPipeHandshake::new()?))
}
fn set_status(&self, _pane_id: &str, _icon: &str, _auto_clear_on_focus: bool) -> Result<()> {
Ok(())
}
fn clear_status(&self, _pane_id: &str) -> Result<()> {
Ok(())
}
fn ensure_status_format(&self, _pane_id: &str) -> Result<()> {
Ok(())
}
fn split_pane(
&self,
target_pane_id: &str,
direction: &SplitDirection,
cwd: &Path,
_size: Option<u16>,
_percentage: Option<u8>,
command: Option<&str>,
) -> Result<String> {
debug!(
"split_pane: target_pane_id '{}' (note: new-pane splits focused pane only)",
target_pane_id
);
let dir_arg = match direction {
SplitDirection::Horizontal => "right", SplitDirection::Vertical => "down", };
let cwd_str = cwd
.to_str()
.ok_or_else(|| anyhow!("Path contains non-UTF8 characters"))?;
let mut cmd = Cmd::new("zellij").args(&[
"action",
"new-pane",
"--direction",
dir_arg,
"--cwd",
cwd_str,
]);
if let Some(script) = command {
cmd = cmd.args(&["--", "sh", "-c", script]);
}
let pane_id = cmd
.run_and_capture_stdout()
.context("Failed to split pane")?;
Ok(pane_id.trim().to_string())
}
fn get_live_pane_info(&self, pane_id: &str) -> Result<Option<LivePaneInfo>> {
let panes = Self::list_panes()?;
let numeric_id: u32 =
parse_pane_id(pane_id).ok_or_else(|| anyhow!("Invalid pane_id: {}", pane_id))?;
let pane = match panes.iter().find(|p| p.id == numeric_id && !p.is_plugin) {
Some(p) => p,
None => return Ok(None), };
let current_command = extract_base_command(
pane.pane_command.as_deref(),
pane.terminal_command.as_deref(),
);
let current_command = if current_command.is_empty() {
None
} else {
Some(current_command)
};
let working_dir = pane
.pane_cwd
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
Ok(Some(LivePaneInfo {
pid: None, current_command,
working_dir,
title: Some(pane.title.clone()).filter(|t| !t.is_empty()),
session: Self::session_name(),
window: Some(pane.tab_name.clone()).filter(|t| !t.is_empty()),
}))
}
fn validate_agent_alive(&self, state: &crate::state::AgentState) -> Result<bool> {
let pane_info = self.get_live_pane_info(&state.pane_key.pane_id)?;
let pane_info = match pane_info {
Some(info) => info,
None => return Ok(false), };
if let Some(ref live_command) = pane_info.current_command
&& !state.command.is_empty()
&& !live_command.is_empty()
{
let expected_base = state
.command
.split('/')
.next_back()
.unwrap_or(&state.command);
let actual_base = live_command.split('/').next_back().unwrap_or(live_command);
if expected_base != actual_base {
debug!(
"Agent validation: command mismatch - expected '{}', got '{}'",
expected_base, actual_base
);
return Ok(false); }
}
Ok(true) }
fn get_all_live_pane_info(&self) -> Result<std::collections::HashMap<String, LivePaneInfo>> {
use std::collections::HashMap;
let mut result = HashMap::new();
let panes = Self::list_panes()?;
for pane in panes {
if pane.is_plugin {
continue;
}
let pane_id = format!("terminal_{}", pane.id);
let current_command = extract_base_command(
pane.pane_command.as_deref(),
pane.terminal_command.as_deref(),
);
let current_command = if current_command.is_empty() {
None
} else {
Some(current_command)
};
let working_dir = pane
.pane_cwd
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
result.insert(
pane_id,
LivePaneInfo {
pid: None, current_command,
working_dir,
title: Some(pane.title.clone()).filter(|t| !t.is_empty()),
session: Self::session_name(),
window: Some(pane.tab_name.clone()).filter(|t| !t.is_empty()),
},
);
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_pane_id_valid() {
assert_eq!(parse_pane_id("terminal_0"), Some(0));
assert_eq!(parse_pane_id("terminal_1"), Some(1));
assert_eq!(parse_pane_id("terminal_42"), Some(42));
assert_eq!(parse_pane_id("terminal_999"), Some(999));
}
#[test]
fn parse_pane_id_invalid_prefix() {
assert_eq!(parse_pane_id("plugin_1"), None);
assert_eq!(parse_pane_id("pane_1"), None);
assert_eq!(parse_pane_id("1"), None);
assert_eq!(parse_pane_id(""), None);
}
#[test]
fn parse_pane_id_non_numeric() {
assert_eq!(parse_pane_id("terminal_abc"), None);
assert_eq!(parse_pane_id("terminal_"), None);
assert_eq!(parse_pane_id("terminal_1.5"), None);
assert_eq!(parse_pane_id("terminal_-1"), None);
}
#[test]
fn extract_base_command_full_path() {
assert_eq!(extract_base_command(Some("/usr/bin/bash"), None), "bash");
}
#[test]
fn extract_base_command_with_args() {
assert_eq!(
extract_base_command(Some("/usr/bin/bash --login -i"), None),
"bash"
);
}
#[test]
fn extract_base_command_bare_name() {
assert_eq!(extract_base_command(Some("zsh"), None), "zsh");
}
#[test]
fn extract_base_command_prefers_pane_command() {
assert_eq!(extract_base_command(Some("fish"), Some("bash")), "fish");
}
#[test]
fn extract_base_command_falls_back_to_terminal_command() {
assert_eq!(extract_base_command(None, Some("/bin/zsh")), "zsh");
}
#[test]
fn extract_base_command_both_none() {
assert_eq!(extract_base_command(None, None), "");
}
#[test]
fn extract_base_command_empty_strings() {
assert_eq!(extract_base_command(Some(""), None), "");
}
#[test]
fn parse_tab_name_standard() {
let output = "name: Tab #1\nid: 0\nposition: 0\n";
assert_eq!(
parse_tab_name_from_output(output),
Some("Tab #1".to_string())
);
}
#[test]
fn parse_tab_name_custom_name() {
let output = "name: my-worktree\nid: 3\nposition: 2\n";
assert_eq!(
parse_tab_name_from_output(output),
Some("my-worktree".to_string())
);
}
#[test]
fn parse_tab_name_with_spaces() {
let output = "name: My Project Tab\nid: 1\nposition: 0\n";
assert_eq!(
parse_tab_name_from_output(output),
Some("My Project Tab".to_string())
);
}
#[test]
fn parse_tab_name_empty_output() {
assert_eq!(parse_tab_name_from_output(""), None);
}
#[test]
fn parse_tab_name_no_name_field() {
let output = "id: 0\nposition: 0\n";
assert_eq!(parse_tab_name_from_output(output), None);
}
#[test]
fn parse_tab_name_name_field_in_middle() {
let output = "id: 5\nname: middle-tab\nposition: 3\nactive: true\n";
assert_eq!(
parse_tab_name_from_output(output),
Some("middle-tab".to_string())
);
}
#[test]
fn pane_info_deserialize_full() {
let json = r#"{
"id": 5,
"is_plugin": false,
"is_focused": true,
"terminal_command": "/bin/bash",
"pane_command": "/usr/bin/fish",
"pane_cwd": "/home/user/project",
"tab_id": 2,
"tab_name": "my-tab",
"title": "fish"
}"#;
let pane: PaneInfo = serde_json::from_str(json).unwrap();
assert_eq!(pane.id, 5);
assert!(!pane.is_plugin);
assert!(pane.is_focused);
assert_eq!(pane.terminal_command.as_deref(), Some("/bin/bash"));
assert_eq!(pane.pane_command.as_deref(), Some("/usr/bin/fish"));
assert_eq!(pane.pane_cwd.as_deref(), Some("/home/user/project"));
assert_eq!(pane.tab_id, Some(2));
assert_eq!(pane.tab_name, "my-tab");
assert_eq!(pane.title, "fish");
}
#[test]
fn pane_info_deserialize_minimal() {
let json = r#"{
"id": 0,
"is_plugin": true,
"is_focused": false,
"terminal_command": null
}"#;
let pane: PaneInfo = serde_json::from_str(json).unwrap();
assert_eq!(pane.id, 0);
assert!(pane.is_plugin);
assert!(!pane.is_focused);
assert!(pane.terminal_command.is_none());
assert!(pane.pane_command.is_none());
assert!(pane.pane_cwd.is_none());
assert!(pane.tab_id.is_none());
assert_eq!(pane.tab_name, "");
assert_eq!(pane.title, "");
}
#[test]
fn pane_info_deserialize_list() {
let json = r#"[
{"id": 1, "is_plugin": false, "is_focused": true, "terminal_command": "bash", "tab_name": "tab1"},
{"id": 2, "is_plugin": true, "is_focused": false, "terminal_command": null, "tab_name": "tab1"}
]"#;
let panes: Vec<PaneInfo> = serde_json::from_str(json).unwrap();
assert_eq!(panes.len(), 2);
assert_eq!(panes[0].id, 1);
assert!(!panes[0].is_plugin);
assert_eq!(panes[1].id, 2);
assert!(panes[1].is_plugin);
}
#[test]
fn tab_info_deserialize() {
let json = r#"{
"tab_id": 3,
"position": 1,
"name": "workmux-feature",
"active": true
}"#;
let tab: TabInfo = serde_json::from_str(json).unwrap();
assert_eq!(tab.tab_id(), 3);
assert_eq!(tab.position, 1);
assert_eq!(tab.name, "workmux-feature");
assert!(tab.active);
}
#[test]
fn tab_info_deserialize_list() {
let json = r#"[
{"tab_id": 0, "position": 0, "name": "Tab #1", "active": true},
{"tab_id": 1, "position": 1, "name": "my-feature", "active": false}
]"#;
let tabs: Vec<TabInfo> = serde_json::from_str(json).unwrap();
assert_eq!(tabs.len(), 2);
assert_eq!(tabs[0].tab_id(), 0);
assert_eq!(tabs[0].name, "Tab #1");
assert!(tabs[0].active);
assert_eq!(tabs[1].tab_id(), 1);
assert_eq!(tabs[1].name, "my-feature");
assert!(!tabs[1].active);
}
}