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, find_sidebar_in_window,
kill_all_sidebars_and_restore_layouts,
};
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_width",
"@workmux_sidebar_agents",
"@workmux_sleeping_panes",
];
fn clear_sidebar_globals() {
for opt in SIDEBAR_GLOBAL_OPTIONS {
let _ = Cmd::new("tmux").args(&["set-option", "-gu", opt]).run();
}
}
fn terminal_width() -> u16 {
Cmd::new("tmux")
.args(&["display-message", "-p", "#{client_width}"])
.run_and_capture_stdout()
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
}
fn resolve_width(config: &crate::config::Config) -> u16 {
resolve_width_for(config, terminal_width())
}
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)?;
let width = resolve_width(&config);
if std::env::var("TMUX").is_err() {
return Err(anyhow!("Sidebar requires tmux"));
}
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);
let width_str = width.to_string();
Cmd::new("tmux")
.args(&["set-option", "-g", "@workmux_sidebar_enabled", "1"])
.run()?;
Cmd::new("tmux")
.args(&["set-option", "-g", "@workmux_sidebar_width", &width_str])
.run()?;
ensure_daemon_running()?;
create_sidebars_in_all_windows(width)?;
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<()> {
if !is_sidebar_enabled() {
return Ok(());
}
let _ = ensure_daemon_running();
let target = resolve_target_window(window_id)?;
if target.is_empty() {
return Ok(());
}
if find_sidebar_in_window(&target)? {
return Ok(());
}
let width = Cmd::new("tmux")
.args(&["show-option", "-gqv", "@workmux_sidebar_width"])
.run_and_capture_stdout()
.ok()
.and_then(|s| s.trim().parse::<u16>().ok())
.unwrap_or_else(|| {
let config = crate::config::Config::load(None).unwrap_or_default();
resolve_width(&config)
});
create_sidebar_in_window(&target, width)?;
Ok(())
}
pub fn reflow(window_id: Option<&str>) -> Result<()> {
if !is_sidebar_enabled() {
return Ok(());
}
let target = resolve_target_window(window_id)?;
if target.is_empty() {
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()
}
fn is_sidebar_enabled() -> bool {
Cmd::new("tmux")
.args(&["show-option", "-gqv", "@workmux_sidebar_enabled"])
.run_and_capture_stdout()
.map(|s| s.trim() == "1")
.unwrap_or(false)
}
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));
}
}