mod app;
mod client;
mod daemon;
mod daemon_ctrl;
mod hooks;
mod layout_tree;
mod panes;
mod runtime;
mod snapshot;
mod ui;
use anyhow::{Result, anyhow};
use crate::cmd::Cmd;
use self::daemon_ctrl::{ensure_daemon_running, kill_daemon, signal_daemon};
use self::hooks::{install_hooks, remove_hooks};
use self::panes::{
create_sidebar_in_window, create_sidebars_in_all_windows, create_sidebars_in_session,
find_sidebar_in_window, kill_all_sidebars_and_restore_layouts, kill_sidebars_in_session,
};
const SIDEBAR_ROLE_VALUE: &str = "sidebar";
const MIN_WIDTH: u16 = 25;
const MAX_WIDTH: u16 = 50;
const SIDEBAR_GLOBAL_OPTIONS: &[&str] = &[
"@workmux_sidebar_enabled",
"@workmux_sidebar_agents",
"@workmux_sleeping_panes",
"@workmux_sidebar_scope",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum SidebarScope {
Off,
Global,
Sessions(std::collections::HashSet<String>),
}
pub(super) fn current_scope() -> SidebarScope {
let raw = Cmd::new("tmux")
.args(&["show-option", "-gqv", "@workmux_sidebar_scope"])
.run_and_capture_stdout()
.ok()
.map(|s| s.trim().to_string())
.unwrap_or_default();
match raw.as_str() {
"" => SidebarScope::Off,
"global" => SidebarScope::Global,
ids => {
let set: std::collections::HashSet<String> =
ids.split_whitespace().map(String::from).collect();
SidebarScope::Sessions(set)
}
}
}
fn set_scope(scope: &SidebarScope) {
match scope {
SidebarScope::Off => {
let _ = Cmd::new("tmux")
.args(&["set-option", "-gu", "@workmux_sidebar_scope"])
.run();
}
SidebarScope::Global => {
let _ = Cmd::new("tmux")
.args(&["set-option", "-g", "@workmux_sidebar_scope", "global"])
.run();
}
SidebarScope::Sessions(ids) => {
let val: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
let val = val.join(" ");
let _ = Cmd::new("tmux")
.args(&["set-option", "-g", "@workmux_sidebar_scope", &val])
.run();
}
}
}
fn get_current_session_id() -> Result<String> {
let s = Cmd::new("tmux")
.args(&["display-message", "-p", "#{session_id}"])
.run_and_capture_stdout()?
.trim()
.to_string();
if s.is_empty() {
return Err(anyhow!("could not detect tmux session"));
}
Ok(s)
}
fn get_window_session_id(window_id: &str) -> Option<String> {
Cmd::new("tmux")
.args(&["display-message", "-t", window_id, "-p", "#{session_id}"])
.run_and_capture_stdout()
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn clear_sidebar_globals() {
for opt in SIDEBAR_GLOBAL_OPTIONS {
let _ = Cmd::new("tmux").args(&["set-option", "-gu", opt]).run();
}
}
fn resolve_width_for(config: &crate::config::Config, tw: u16) -> u16 {
if let Some(ref w) = config.sidebar.width {
return w.resolve(tw).max(10);
}
if tw == 0 {
return MIN_WIDTH;
}
(tw * 10 / 100).clamp(MIN_WIDTH, MAX_WIDTH)
}
pub fn toggle() -> Result<()> {
let config = crate::config::Config::load(None)?;
if std::env::var("TMUX").is_err() {
return Err(anyhow!("Sidebar requires tmux"));
}
if let SidebarScope::Sessions(_) = current_scope() {
kill_all_sidebars_and_restore_layouts();
kill_daemon();
remove_hooks();
clear_sidebar_globals();
}
let current_window = Cmd::new("tmux")
.args(&["display-message", "-p", "#{window_id}"])
.run_and_capture_stdout()?
.trim()
.to_string();
let current_has_sidebar = find_sidebar_in_window(¤t_window).unwrap_or(false);
if current_has_sidebar {
kill_all_sidebars_and_restore_layouts();
kill_daemon();
remove_hooks();
clear_sidebar_globals();
return Ok(());
}
let _ = std::thread::spawn(crate::tips::mark_sidebar_used);
Cmd::new("tmux")
.args(&["set-option", "-g", "@workmux_sidebar_enabled", "1"])
.run()?;
set_scope(&SidebarScope::Global);
ensure_daemon_running()?;
create_sidebars_in_all_windows(&config)?;
install_hooks()?;
Ok(())
}
pub fn toggle_session() -> Result<()> {
let config = crate::config::Config::load(None)?;
if std::env::var("TMUX").is_err() {
return Err(anyhow!("Sidebar requires tmux"));
}
let scope = current_scope();
let session_id = get_current_session_id()?;
if matches!(&scope, SidebarScope::Global) {
return Err(anyhow!(
"Global sidebar is active. Run `workmux sidebar` to disable it first."
));
}
let current_window = Cmd::new("tmux")
.args(&["display-message", "-p", "#{window_id}"])
.run_and_capture_stdout()?
.trim()
.to_string();
let current_has_sidebar = find_sidebar_in_window(¤t_window).unwrap_or(false);
if current_has_sidebar {
kill_sidebars_in_session(&session_id);
if let SidebarScope::Sessions(mut ids) = scope {
ids.remove(&session_id);
if ids.is_empty() {
kill_daemon();
remove_hooks();
clear_sidebar_globals();
} else {
set_scope(&SidebarScope::Sessions(ids));
}
}
return Ok(());
}
let _ = std::thread::spawn(crate::tips::mark_sidebar_used);
Cmd::new("tmux")
.args(&["set-option", "-g", "@workmux_sidebar_enabled", "1"])
.run()?;
let new_scope = match scope {
SidebarScope::Sessions(mut ids) => {
ids.insert(session_id.clone());
SidebarScope::Sessions(ids)
}
_ => {
let mut ids = std::collections::HashSet::new();
ids.insert(session_id.clone());
SidebarScope::Sessions(ids)
}
};
set_scope(&new_scope);
ensure_daemon_running()?;
create_sidebars_in_session(&session_id, &config)?;
install_hooks()?;
Ok(())
}
fn resolve_target_window(window_id: Option<&str>) -> Result<String> {
match window_id {
Some(id) => Ok(id.to_string()),
None => Ok(Cmd::new("tmux")
.args(&["display-message", "-p", "#{window_id}"])
.run_and_capture_stdout()?
.trim()
.to_string()),
}
}
pub fn sync(window_id: Option<&str>) -> Result<()> {
let scope = current_scope();
if matches!(scope, SidebarScope::Off) {
return Ok(());
}
let _ = ensure_daemon_running();
let target = resolve_target_window(window_id)?;
if target.is_empty() {
return Ok(());
}
if let SidebarScope::Sessions(ref ids) = scope {
match get_window_session_id(&target) {
Some(window_sid) if ids.contains(&window_sid) => {}
_ => return Ok(()),
}
}
if find_sidebar_in_window(&target)? {
return Ok(());
}
let config = crate::config::Config::load(None).unwrap_or_default();
let window_w: u16 = Cmd::new("tmux")
.args(&["display-message", "-t", &target, "-p", "#{window_width}"])
.run_and_capture_stdout()
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let width = resolve_width_for(&config, window_w);
create_sidebar_in_window(&target, width)?;
Ok(())
}
pub fn reflow(window_id: Option<&str>) -> Result<()> {
let scope = current_scope();
if matches!(scope, SidebarScope::Off) {
return Ok(());
}
let target = resolve_target_window(window_id)?;
if target.is_empty() {
return Ok(());
}
if let SidebarScope::Sessions(ref ids) = scope {
match get_window_session_id(&target) {
Some(window_sid) if ids.contains(&window_sid) => {}
_ => return Ok(()),
}
}
let output = Cmd::new("tmux")
.args(&[
"list-panes",
"-t",
&target,
"-F",
"#{pane_id} #{@workmux_role}",
])
.run_and_capture_stdout()?;
let sidebar_pane_id = output.lines().find_map(|line| {
let (id, role) = line.split_once(' ')?;
(role.trim() == SIDEBAR_ROLE_VALUE).then(|| id.to_string())
});
let Some(sidebar_pane_id) = sidebar_pane_id else {
return Ok(());
};
let config = crate::config::Config::load(None).unwrap_or_default();
let window_w: u16 = Cmd::new("tmux")
.args(&["display-message", "-t", &target, "-p", "#{window_width}"])
.run_and_capture_stdout()
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let width = resolve_width_for(&config, window_w);
layout_tree::reflow_after_sidebar_add(&target, &sidebar_pane_id, width);
Ok(())
}
pub fn run_daemon() -> Result<()> {
daemon::run()
}
pub fn run_sidebar() -> Result<()> {
runtime::run_sidebar()
}
pub enum NavAction {
Next,
Prev,
Jump(usize),
}
fn compute_nav_target(action: &NavAction, current_idx: Option<usize>, len: usize) -> Option<usize> {
if len == 0 {
return None;
}
Some(match action {
NavAction::Next => {
let i = current_idx.unwrap_or(len - 1);
if i >= len - 1 { 0 } else { i + 1 }
}
NavAction::Prev => {
let i = current_idx.unwrap_or(0);
if i == 0 { len - 1 } else { i - 1 }
}
NavAction::Jump(n) => {
let idx = n - 1;
if idx >= len {
return None;
}
idx
}
})
}
pub fn navigate(action: NavAction) -> Result<()> {
if std::env::var("TMUX").is_err() {
return Err(anyhow!("Sidebar requires tmux"));
}
let agents_str = Cmd::new("tmux")
.args(&["show-option", "-gqv", "@workmux_sidebar_agents"])
.run_and_capture_stdout()
.unwrap_or_default();
let agents_str = agents_str.trim();
if agents_str.is_empty() {
anyhow::bail!("no sidebar agents found (is the sidebar running?)");
}
let panes: Vec<&str> = agents_str.split_whitespace().collect();
if panes.is_empty() {
anyhow::bail!("no sidebar agents found");
}
let current_pane_id = Cmd::new("tmux")
.args(&["display-message", "-p", "#{pane_id}"])
.run_and_capture_stdout()
.unwrap_or_default();
let current_pane_id = current_pane_id.trim();
let current_idx = panes.iter().position(|&pid| pid == current_pane_id);
let len = panes.len();
let target_idx = match &action {
NavAction::Jump(n) => compute_nav_target(&action, current_idx, len)
.ok_or_else(|| anyhow::anyhow!("agent {} out of range (1-{})", n, len))?,
_ => compute_nav_target(&action, current_idx, len)
.expect("len > 0 guarantees a result for Next/Prev"),
};
let target_pane = panes[target_idx];
Cmd::new("tmux")
.args(&["switch-client", "-t", target_pane])
.run()?;
signal_daemon();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_wraps_from_last_to_first() {
assert_eq!(compute_nav_target(&NavAction::Next, Some(2), 3), Some(0));
}
#[test]
fn next_advances_normally() {
assert_eq!(compute_nav_target(&NavAction::Next, Some(0), 3), Some(1));
assert_eq!(compute_nav_target(&NavAction::Next, Some(1), 3), Some(2));
}
#[test]
fn next_without_current_wraps_from_last() {
assert_eq!(compute_nav_target(&NavAction::Next, None, 3), Some(0));
}
#[test]
fn prev_wraps_from_first_to_last() {
assert_eq!(compute_nav_target(&NavAction::Prev, Some(0), 3), Some(2));
}
#[test]
fn prev_goes_back_normally() {
assert_eq!(compute_nav_target(&NavAction::Prev, Some(2), 3), Some(1));
assert_eq!(compute_nav_target(&NavAction::Prev, Some(1), 3), Some(0));
}
#[test]
fn prev_without_current_wraps_to_last() {
assert_eq!(compute_nav_target(&NavAction::Prev, None, 3), Some(2));
}
#[test]
fn jump_converts_1_indexed_to_0_indexed() {
assert_eq!(compute_nav_target(&NavAction::Jump(1), None, 3), Some(0));
assert_eq!(compute_nav_target(&NavAction::Jump(2), None, 3), Some(1));
assert_eq!(compute_nav_target(&NavAction::Jump(3), None, 3), Some(2));
}
#[test]
fn jump_out_of_range_returns_none() {
assert_eq!(compute_nav_target(&NavAction::Jump(4), None, 3), None);
assert_eq!(compute_nav_target(&NavAction::Jump(10), None, 3), None);
}
#[test]
fn empty_list_returns_none() {
assert_eq!(compute_nav_target(&NavAction::Next, None, 0), None);
assert_eq!(compute_nav_target(&NavAction::Prev, None, 0), None);
assert_eq!(compute_nav_target(&NavAction::Jump(1), None, 0), None);
}
#[test]
fn single_agent_next_stays() {
assert_eq!(compute_nav_target(&NavAction::Next, Some(0), 1), Some(0));
}
#[test]
fn single_agent_prev_stays() {
assert_eq!(compute_nav_target(&NavAction::Prev, Some(0), 1), Some(0));
}
}