From 8edbc5bac353ede46babdf712700ff03285c3360 Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 06:25:08 +0300 Subject: [PATCH 01/14] feat(config): add windows-mode session settings Add session mode and session-prefix configuration so session commands can target either panes or per-worktree windows. Layer global and repo-local TOML files field-by-field and ignore malformed files without dropping valid config. --- src/config.rs | 398 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 357 insertions(+), 41 deletions(-) diff --git a/src/config.rs b/src/config.rs index b51e466..8464cca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,16 @@ use anyhow::Result; +use clap::ValueEnum; use serde::{Deserialize, Serialize}; use std::path::Path; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, Default)] +#[serde(rename_all = "lowercase")] +pub enum SessionMode { + #[default] + Panes, + Windows, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Config { #[serde(default)] @@ -10,8 +19,12 @@ pub struct Config { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionConfig { + #[serde(default)] + pub mode: SessionMode, #[serde(default = "default_panes")] pub panes: u8, + #[serde(default = "default_session_prefix")] + pub session_prefix: String, #[serde(default = "default_agent_cmd")] pub agent_cmd: String, #[serde(default = "default_editor_cmd")] @@ -22,6 +35,10 @@ fn default_panes() -> u8 { 2 } +fn default_session_prefix() -> String { + "wt-".to_string() +} + fn default_agent_cmd() -> String { "claude".to_string() } @@ -33,65 +50,57 @@ fn default_editor_cmd() -> String { impl Default for SessionConfig { fn default() -> Self { Self { + mode: SessionMode::default(), panes: default_panes(), + session_prefix: default_session_prefix(), agent_cmd: default_agent_cmd(), editor_cmd: default_editor_cmd(), } } } +impl SessionConfig { + /// Compute the tmux session name for a worktree in windows mode by + /// prepending `session_prefix`. An empty prefix returns the worktree + /// name unchanged (opt-in by the user). + pub fn session_name_for(&self, worktree: &str) -> String { + format!("{}{}", self.session_prefix, worktree) + } +} + impl Config { /// Load config with precedence: .wt.toml > ~/.wt/config.toml > defaults pub fn load() -> Self { - let mut config = Config::default(); - - // Load global config from ~/.wt/config.toml - if let Some(home) = dirs::home_dir() { - let global_path = home.join(".wt").join("config.toml"); - if let Ok(contents) = std::fs::read_to_string(&global_path) { - if let Ok(global_config) = toml::from_str::(&contents) { - config = global_config; - } - } - } - - // Load repo-local config from .wt.toml (overrides global) - if let Ok(contents) = std::fs::read_to_string(".wt.toml") { - if let Ok(local_config) = toml::from_str::(&contents) { - config.merge(local_config); - } - } - - config + let global = dirs::home_dir().map(|home| home.join(".wt").join("config.toml")); + Self::load_layered(global.as_deref(), Some(Path::new(".wt.toml"))) } /// Load config for a specific repo path pub fn load_for_repo(repo_path: &Path) -> Self { - let mut config = Config::default(); - - // Load global config from ~/.wt/config.toml - if let Some(home) = dirs::home_dir() { - let global_path = home.join(".wt").join("config.toml"); - if let Ok(contents) = std::fs::read_to_string(&global_path) { - if let Ok(global_config) = toml::from_str::(&contents) { - config = global_config; - } - } - } + let global = dirs::home_dir().map(|home| home.join(".wt").join("config.toml")); + let local = repo_path.join(".wt.toml"); + Self::load_layered(global.as_deref(), Some(&local)) + } - // Load repo-local config from .wt.toml (overrides global) - let local_path = repo_path.join(".wt.toml"); - if let Ok(contents) = std::fs::read_to_string(&local_path) { - if let Ok(local_config) = toml::from_str::(&contents) { - config.merge(local_config); + /// Merge the two TOML files field-by-field (local wins) and then + /// deserialize into `Config`. This preserves fields set in the global + /// file when the local file only sets a subset of keys in the same + /// section. + /// + /// Each file is validated as a `Config` in isolation before its table + /// is merged in, so a malformed or type-invalid file is skipped (with a + /// warning on stderr) and does not poison the other file's values. + fn load_layered(global: Option<&Path>, local: Option<&Path>) -> Self { + let mut merged = toml::Table::new(); + for path in [global, local].into_iter().flatten() { + if let Some(table) = load_valid_config_table(path) { + deep_merge_tables(&mut merged, table); } } - config - } - - fn merge(&mut self, other: Config) { - self.session = other.session; + toml::Value::Table(merged) + .try_into::() + .unwrap_or_default() } /// Get effective pane count (flag override if provided) @@ -109,6 +118,52 @@ impl Config { } } +/// Recursively merge `overlay` into `base`. When both contain a table under +/// the same key, they are merged together so that keys the overlay omits +/// keep the base's value; all other value kinds are replaced wholesale. +fn deep_merge_tables(base: &mut toml::Table, overlay: toml::Table) { + for (key, overlay_value) in overlay { + match (base.get_mut(&key), overlay_value) { + (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => { + deep_merge_tables(base_table, overlay_table); + } + (_, value) => { + base.insert(key, value); + } + } + } +} + +/// Read one config file and return its parsed `toml::Table` only if it +/// both parses as TOML and deserializes cleanly into `Config`. A missing +/// file is silent (expected); a malformed file logs a warning and returns +/// `None` so the other layer remains intact. +fn load_valid_config_table(path: &Path) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + let table: toml::Table = match toml::from_str(&contents) { + Ok(table) => table, + Err(error) => { + eprintln!( + "wt: warning: ignoring malformed TOML at {}: {}", + path.display(), + error + ); + return None; + } + }; + + if let Err(error) = toml::Value::Table(table.clone()).try_into::() { + eprintln!( + "wt: warning: ignoring invalid config at {}: {}", + path.display(), + error + ); + return None; + } + + Some(table) +} + #[cfg(test)] mod tests { use super::*; @@ -156,4 +211,265 @@ panes = 3 assert_eq!(config.session.agent_cmd, "claude"); assert_eq!(config.session.editor_cmd, "nvim"); } + + #[test] + fn test_default_mode_is_panes() { + let config = Config::default(); + assert_eq!(config.session.mode, SessionMode::Panes); + } + + #[test] + fn test_parse_mode_panes() { + let toml_str = r#" +[session] +mode = "panes" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.session.mode, SessionMode::Panes); + } + + #[test] + fn test_parse_mode_windows() { + let toml_str = r#" +[session] +mode = "windows" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.session.mode, SessionMode::Windows); + } + + #[test] + fn test_mode_missing_uses_default() { + let toml_str = r#" +[session] +panes = 3 +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.session.mode, SessionMode::Panes); + } + + #[test] + fn test_default_session_prefix() { + let config = Config::default(); + assert_eq!(config.session.session_prefix, "wt-"); + } + + #[test] + fn test_session_name_for_default_prefix() { + let config = Config::default(); + assert_eq!( + config.session.session_name_for("detect-pii"), + "wt-detect-pii" + ); + } + + #[test] + fn test_session_name_for_empty_prefix() { + let mut config = Config::default(); + config.session.session_prefix = String::new(); + assert_eq!(config.session.session_name_for("detect-pii"), "detect-pii"); + } + + #[test] + fn test_session_name_for_custom_prefix() { + let mut config = Config::default(); + config.session.session_prefix = "proj/".to_string(); + assert_eq!(config.session.session_name_for("foo"), "proj/foo"); + } + + #[test] + fn test_parse_session_prefix_empty_string() { + let toml_str = r#" +[session] +session_prefix = "" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.session.session_prefix, ""); + } + + #[test] + fn test_deep_merge_tables_preserves_unshadowed_keys() { + let mut base: toml::Table = toml::from_str( + r#" +[session] +agent_cmd = "aider" +panes = 2 +"#, + ) + .unwrap(); + let overlay: toml::Table = toml::from_str( + r#" +[session] +mode = "windows" +"#, + ) + .unwrap(); + + deep_merge_tables(&mut base, overlay); + + let session = base + .get("session") + .and_then(|value| value.as_table()) + .unwrap(); + assert_eq!(session.get("agent_cmd").unwrap().as_str(), Some("aider")); + assert_eq!(session.get("panes").unwrap().as_integer(), Some(2)); + assert_eq!(session.get("mode").unwrap().as_str(), Some("windows")); + } + + #[test] + fn test_deep_merge_tables_overlay_scalar_replaces() { + let mut base: toml::Table = toml::from_str( + r#" +[session] +panes = 2 +"#, + ) + .unwrap(); + let overlay: toml::Table = toml::from_str( + r#" +[session] +panes = 3 +"#, + ) + .unwrap(); + + deep_merge_tables(&mut base, overlay); + + let session = base + .get("session") + .and_then(|value| value.as_table()) + .unwrap(); + assert_eq!(session.get("panes").unwrap().as_integer(), Some(3)); + } + + #[test] + fn test_load_layered_partial_local_preserves_global_fields() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let global = dir.path().join("global.toml"); + let local = dir.path().join("local.toml"); + + writeln!( + std::fs::File::create(&global).unwrap(), + "[session]\nagent_cmd = \"aider\"\npanes = 3\n" + ) + .unwrap(); + writeln!( + std::fs::File::create(&local).unwrap(), + "[session]\nmode = \"windows\"\nsession_prefix = \"\"\n" + ) + .unwrap(); + + let config = Config::load_layered(Some(&global), Some(&local)); + assert_eq!(config.session.mode, SessionMode::Windows); + assert_eq!(config.session.session_prefix, ""); + assert_eq!(config.session.agent_cmd, "aider"); + assert_eq!(config.session.panes, 3); + } + + #[test] + fn test_load_layered_local_overrides_scalar() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let global = dir.path().join("global.toml"); + let local = dir.path().join("local.toml"); + + writeln!( + std::fs::File::create(&global).unwrap(), + "[session]\npanes = 2\nagent_cmd = \"aider\"\n" + ) + .unwrap(); + writeln!( + std::fs::File::create(&local).unwrap(), + "[session]\npanes = 3\n" + ) + .unwrap(); + + let config = Config::load_layered(Some(&global), Some(&local)); + assert_eq!(config.session.panes, 3); + assert_eq!(config.session.agent_cmd, "aider"); + } + + #[test] + fn test_load_layered_returns_default_when_both_missing() { + let config = Config::load_layered(None, None); + assert_eq!(config.session.mode, SessionMode::Panes); + assert_eq!(config.session.panes, 2); + assert_eq!(config.session.agent_cmd, "claude"); + } + + #[test] + fn test_load_layered_invalid_local_preserves_global() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let global = dir.path().join("global.toml"); + let local = dir.path().join("local.toml"); + + writeln!( + std::fs::File::create(&global).unwrap(), + "[session]\nagent_cmd = \"aider\"\npanes = 3\n" + ) + .unwrap(); + writeln!( + std::fs::File::create(&local).unwrap(), + "[session]\npanes = \"two\"\n" + ) + .unwrap(); + + let config = Config::load_layered(Some(&global), Some(&local)); + assert_eq!(config.session.agent_cmd, "aider"); + assert_eq!(config.session.panes, 3); + } + + #[test] + fn test_load_layered_invalid_global_preserves_local() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let global = dir.path().join("global.toml"); + let local = dir.path().join("local.toml"); + + writeln!( + std::fs::File::create(&global).unwrap(), + "[session]\nmode = \"invalid\"\n" + ) + .unwrap(); + writeln!( + std::fs::File::create(&local).unwrap(), + "[session]\nagent_cmd = \"aider\"\n" + ) + .unwrap(); + + let config = Config::load_layered(Some(&global), Some(&local)); + assert_eq!(config.session.agent_cmd, "aider"); + assert_eq!(config.session.mode, SessionMode::Panes); + } + + #[test] + fn test_load_layered_both_invalid_returns_defaults() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let global = dir.path().join("global.toml"); + let local = dir.path().join("local.toml"); + + writeln!( + std::fs::File::create(&global).unwrap(), + "[session]\npanes = \"two\"\n" + ) + .unwrap(); + writeln!( + std::fs::File::create(&local).unwrap(), + "[session]\nmode = \"invalid\"\n" + ) + .unwrap(); + + let config = Config::load_layered(Some(&global), Some(&local)); + assert_eq!(config.session.mode, SessionMode::Panes); + assert_eq!(config.session.panes, 2); + assert_eq!(config.session.agent_cmd, "claude"); + } } From d8f510ecfd4c0db7cdfd0637d3abf78802ed728f Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 06:25:08 +0300 Subject: [PATCH 02/14] feat(tmux): add helpers for multi-session layouts Add enter, attachment, kill-session, live-session discovery, and per-worktree window setup helpers so wt can manage panes and per-worktree sessions through one tmux wrapper. --- src/tmux_manager.rs | 180 +++++++++++++++++++++++++++++++------------- 1 file changed, 128 insertions(+), 52 deletions(-) diff --git a/src/tmux_manager.rs b/src/tmux_manager.rs index c69c48b..67d9e6c 100644 --- a/src/tmux_manager.rs +++ b/src/tmux_manager.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use std::collections::HashSet; use std::path::Path; use std::process::Command; @@ -42,20 +43,18 @@ impl TmuxManager { } } - /// Check if tmux is available on the system + /// Check if tmux is available on the system. pub fn is_available() -> bool { Command::new("tmux") .arg("-V") .output() - .map(|o| o.status.success()) + .map(|output| output.status.success()) .unwrap_or(false) } - /// Check if we're currently inside this tmux session + /// Check if we're currently inside this tmux session. pub fn is_inside_session(&self) -> bool { if let Ok(tmux_var) = std::env::var("TMUX") { - // TMUX env var format: /tmp/tmux-1000/default,12345,0 - // We need to check if we're in the right session if let Ok(output) = Command::new("tmux") .args(["display-message", "-p", "#{session_name}"]) .output() @@ -65,28 +64,53 @@ impl TmuxManager { return current_session.trim() == self.session_name; } } - // If we can't determine, but TMUX is set, assume we might be inside + !tmux_var.is_empty() } else { false } } - /// Check if we're inside any tmux session + /// Check if we're inside any tmux session. pub fn is_inside_tmux() -> bool { std::env::var("TMUX").is_ok() } - /// Check if the session already exists + /// Check if the session already exists. pub fn session_exists(&self) -> Result { let output = Command::new("tmux") .args(["has-session", "-t", &self.session_name]) .output() .context("Failed to check tmux session")?; + Ok(output.status.success()) } - /// Create a new session with an initial window + /// Whether a client is currently attached to this session. + pub fn is_attached(&self) -> Result { + let output = Command::new("tmux") + .args([ + "display-message", + "-t", + &self.session_name, + "-p", + "#{session_attached}", + ]) + .output() + .context("Failed to query session attachment")?; + + if !output.status.success() { + return Ok(false); + } + + let count: u32 = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(0); + Ok(count > 0) + } + + /// Create a new session with an initial window. pub fn create_session(&self, window_name: &str, cwd: &Path) -> Result<()> { let output = Command::new("tmux") .args([ @@ -108,10 +132,11 @@ impl TmuxManager { String::from_utf8_lossy(&output.stderr) ); } + Ok(()) } - /// Attach to the session (blocking) + /// Attach to the session (blocking). pub fn attach(&self) -> Result<()> { let status = Command::new("tmux") .args(["attach-session", "-t", &self.session_name]) @@ -121,10 +146,63 @@ impl TmuxManager { if !status.success() { anyhow::bail!("Failed to attach to session"); } + Ok(()) } - /// Create a new window in the session + /// Enter the session, switching client if already inside tmux. + pub fn enter(&self) -> Result<()> { + if Self::is_inside_tmux() { + let status = Command::new("tmux") + .args(["switch-client", "-t", &self.session_name]) + .status() + .context("Failed to switch tmux client")?; + + if !status.success() { + anyhow::bail!("Failed to switch client to session '{}'", self.session_name); + } + + Ok(()) + } else { + self.attach() + } + } + + /// Kill the whole session. + pub fn kill_session(&self) -> Result<()> { + let output = Command::new("tmux") + .args(["kill-session", "-t", &self.session_name]) + .output() + .context("Failed to kill tmux session")?; + + if !output.status.success() { + anyhow::bail!( + "Failed to kill session: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) + } + + /// All currently-live tmux session names. + pub fn live_session_names() -> Result> { + let output = Command::new("tmux") + .args(["list-sessions", "-F", "#{session_name}"]) + .output() + .context("Failed to list tmux sessions")?; + + if !output.status.success() { + return Ok(HashSet::new()); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|name| name.to_string()) + .collect()) + } + + /// Create a new window in the session. pub fn create_window(&self, name: &str, cwd: &Path) -> Result { let output = Command::new("tmux") .args([ @@ -157,7 +235,7 @@ impl TmuxManager { Ok(index) } - /// Kill a window by name + /// Kill a window by name. pub fn kill_window(&self, name: &str) -> Result<()> { let target = format!("{}:{}", self.session_name, name); let output = Command::new("tmux") @@ -171,10 +249,11 @@ impl TmuxManager { String::from_utf8_lossy(&output.stderr) ); } + Ok(()) } - /// Switch to a window by name (when inside the session) + /// Switch to a window by name. pub fn select_window(&self, name: &str) -> Result<()> { let target = format!("{}:{}", self.session_name, name); let output = Command::new("tmux") @@ -188,10 +267,11 @@ impl TmuxManager { String::from_utf8_lossy(&output.stderr) ); } + Ok(()) } - /// List all windows in the session + /// List all windows in the session. pub fn list_windows(&self) -> Result> { let output = Command::new("tmux") .args([ @@ -209,15 +289,17 @@ impl TmuxManager { } let stdout = String::from_utf8_lossy(&output.stdout); - let windows: Vec = stdout + let windows = stdout .lines() .filter_map(|line| { let parts: Vec<&str> = line.split('|').collect(); if parts.len() != 4 { return None; } + let name = parts[1].to_string(); let agent_status = self.get_agent_status(&name).unwrap_or(AgentStatus::Unknown); + Some(TmuxWindow { index: parts[0].parse().ok()?, name, @@ -231,7 +313,7 @@ impl TmuxManager { Ok(windows) } - /// Get the agent status for a window (checks pane 0) + /// Get the agent status for a window (checks pane 0). fn get_agent_status(&self, window: &str) -> Result { let target = format!("{}:{}.0", self.session_name, window); let output = Command::new("tmux") @@ -250,10 +332,8 @@ impl TmuxManager { } let cmd = String::from_utf8_lossy(&output.stdout).trim().to_string(); - - // Common shells indicate idle, anything else is active let shells = ["bash", "zsh", "sh", "fish", "ksh", "tcsh", "dash"]; - if shells.iter().any(|s| cmd == *s) { + if shells.iter().any(|shell| cmd == *shell) { Ok(AgentStatus::Idle) } else if cmd.is_empty() { Ok(AgentStatus::Unknown) @@ -262,7 +342,7 @@ impl TmuxManager { } } - /// Split the current pane horizontally (left/right) + /// Split the current pane horizontally (left/right). pub fn split_window_horizontal(&self, window: &str, cwd: &Path) -> Result<()> { let target = format!("{}:{}", self.session_name, window); let output = Command::new("tmux") @@ -283,10 +363,11 @@ impl TmuxManager { String::from_utf8_lossy(&output.stderr) ); } + Ok(()) } - /// Split the current pane vertically (top/bottom) + /// Split the current pane vertically (top/bottom). pub fn split_window_vertical(&self, window: &str, cwd: &Path) -> Result<()> { let target = format!("{}:{}", self.session_name, window); let output = Command::new("tmux") @@ -307,10 +388,11 @@ impl TmuxManager { String::from_utf8_lossy(&output.stderr) ); } + Ok(()) } - /// Select a specific pane in a window + /// Select a specific pane in a window. pub fn select_pane(&self, window: &str, pane: u32) -> Result<()> { let target = format!("{}:{}.{}", self.session_name, window, pane); let output = Command::new("tmux") @@ -324,10 +406,11 @@ impl TmuxManager { String::from_utf8_lossy(&output.stderr) ); } + Ok(()) } - /// Send keys to a specific pane + /// Send keys to a specific pane. pub fn send_keys(&self, window: &str, pane: u32, keys: &str) -> Result<()> { let target = format!("{}:{}.{}", self.session_name, window, pane); let output = Command::new("tmux") @@ -341,12 +424,11 @@ impl TmuxManager { String::from_utf8_lossy(&output.stderr) ); } + Ok(()) } - /// Setup the worktree layout based on pane count - /// 2 panes: agent left, terminal right - /// 3 panes: agent top-left, terminal bottom-left, editor right + /// Setup the worktree layout based on pane count. pub fn setup_worktree_layout( &self, window: &str, @@ -354,45 +436,42 @@ impl TmuxManager { panes: u8, config: &SessionConfig, ) -> Result<()> { - // Window starts with 1 pane (pane 0) - // Split horizontally: pane 0 (left), pane 1 (right) self.split_window_horizontal(window, cwd)?; if panes == 3 { - // Select left pane and split vertically - // After horizontal split, pane 0 is left, pane 1 is right self.select_pane(window, 0)?; self.split_window_vertical(window, cwd)?; - // Now: pane 0 = top-left (agent), pane 1 = bottom-left (terminal), pane 2 = right (editor) - // Actually after the split, the new pane gets a new number, so: - // pane 0 = top-left, pane 2 = bottom-left (new from split), pane 1 = right - // We need to reorder our understanding: - // After split-window -h: pane 0 (left), pane 1 (right) - // After select-pane 0 + split-window -v: pane 0 (top-left), pane 2 (bottom-left), pane 1 (right) - - // Send commands to panes: - // Pane 0 (top-left): agent - // Pane 2 (bottom-left): terminal (user shell, no command needed) - // Pane 1 (right): editor self.send_keys(window, 0, &config.agent_cmd)?; self.send_keys(window, 1, &config.editor_cmd)?; - // Pane 2 is terminal, leave it at shell prompt - - // Focus on terminal pane self.select_pane(window, 2)?; } else { - // 2 panes: pane 0 = left (agent), pane 1 = right (terminal) self.send_keys(window, 0, &config.agent_cmd)?; - // Pane 1 is terminal, leave it at shell prompt - - // Focus on terminal pane self.select_pane(window, 1)?; } Ok(()) } - /// Get session name + /// Setup a per-worktree session's windows (windows mode). + pub fn setup_worktree_windows( + &self, + cwd: &Path, + panes: u8, + config: &SessionConfig, + ) -> Result<()> { + self.send_keys("agent", 0, &config.agent_cmd)?; + self.create_window("shell", cwd)?; + + if panes == 3 { + self.create_window("edit", cwd)?; + self.send_keys("edit", 0, &config.editor_cmd)?; + } + + self.select_window("shell")?; + Ok(()) + } + + /// Get session name. pub fn session_name(&self) -> &str { &self.session_name } @@ -404,10 +483,7 @@ mod tests { #[test] fn test_is_available() { - // This test will pass if tmux is installed, fail if not - // That's expected behavior for a system tool check let available = TmuxManager::is_available(); - // Just ensure it doesn't panic assert!(available || !available); } From ffb2bce29ef71b6ff45ea2b1f285b9c6b3a5645a Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 06:25:08 +0300 Subject: [PATCH 03/14] feat(session): add state-backed windows mode Move session command handling into a dedicated module and persist windows-mode sessions so wt can add, list, attach, and remove per-worktree tmux sessions without relying on name scanning. Preserve windows-mode state when panes-mode sessions are cleaned up. --- src/main.rs | 271 +------------------------ src/session.rs | 175 +++++++++++++++- src/session_cmd.rs | 494 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 667 insertions(+), 273 deletions(-) create mode 100644 src/session_cmd.rs diff --git a/src/main.rs b/src/main.rs index eb4014d..156a000 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,14 @@ +mod session_cmd; + use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use dialoguer::Select; use std::path::{Path, PathBuf}; use std::process::Command; -use wt::config::Config; -use wt::session::SessionState; +use session_cmd::{run_session, SessionAction}; +use wt::config::SessionMode; use wt::shell::spawn_wt_shell; -use wt::tmux_manager::TmuxManager; use wt::worktree_manager::{ check_not_in_worktree, ensure_worktrees_in_gitignore, get_current_worktree_name, WorktreeManager, @@ -70,42 +71,14 @@ enum Commands { Which, /// Manage tmux session with multiple worktree windows Session { + /// Override session layout mode for this invocation + #[arg(long, value_enum)] + mode: Option, #[command(subcommand)] action: Option, }, } -#[derive(Subcommand)] -enum SessionAction { - /// List worktrees in the session - Ls, - /// Add a worktree to the session - Add { - /// Name for the worktree - name: String, - /// Base branch to create from - #[arg(short, default_value = "main")] - base: String, - /// Override pane count (2 or 3) - #[arg(long)] - panes: Option, - /// Create status window with live agent status - #[arg(long)] - watch: bool, - }, - /// Remove a worktree from the session - Rm { - /// Name of the worktree to remove - name: String, - }, - /// Watch session status (live-updating display) - Watch { - /// Refresh interval in seconds - #[arg(short, default_value = "2")] - interval: u64, - }, -} - fn get_repo_root() -> Result { let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) @@ -176,7 +149,7 @@ fn main() -> Result<()> { Commands::Ls => cmd_ls(&config), Commands::Rm { name } => cmd_rm(&config, name), Commands::Which => cmd_which(&config.root), - Commands::Session { action } => cmd_session(&config, action), + Commands::Session { mode, action } => run_session(&config, mode, action), } } @@ -431,231 +404,3 @@ fn cmd_use(config: &RepoConfig, name: Option) -> Result<()> { spawn_wt_shell(&wt_info.path, &wt_info.task_id, &wt_info.branch)?; Ok(()) } - -const SESSION_NAME: &str = "wt"; - -fn cmd_session(config: &RepoConfig, action: Option) -> Result<()> { - if !TmuxManager::is_available() { - eprintln!("tmux not found. Falling back to interactive picker..."); - return cmd_ls(config); - } - - let wt_config = Config::load_for_repo(&config.root); - let tmux = TmuxManager::new(SESSION_NAME); - - match action { - None => cmd_session_attach(&tmux), - Some(SessionAction::Ls) => cmd_session_ls(&tmux), - Some(SessionAction::Add { - name, - base, - panes, - watch, - }) => cmd_session_add(config, &tmux, &wt_config, &name, &base, panes, watch), - Some(SessionAction::Rm { name }) => cmd_session_rm(&tmux, &name), - Some(SessionAction::Watch { interval }) => cmd_session_watch(&tmux, interval), - } -} - -fn cmd_session_attach(tmux: &TmuxManager) -> Result<()> { - if !tmux.session_exists()? { - eprintln!("No session found. Use 'wt session add ' to create one."); - return Ok(()); - } - - if tmux.is_inside_session() { - eprintln!("Already inside session. Use 'wt session ls' to list windows."); - return Ok(()); - } - - tmux.attach()?; - Ok(()) -} - -fn cmd_session_ls(tmux: &TmuxManager) -> Result<()> { - if !tmux.session_exists()? { - eprintln!("No session found."); - return Ok(()); - } - - let windows = tmux.list_windows()?; - if windows.is_empty() { - eprintln!("No worktrees in session."); - return Ok(()); - } - - for window in &windows { - // Skip the status window in listing - if window.name == "status" { - continue; - } - let active_marker = if window.active { "*" } else { " " }; - println!( - "{} [{}] {} ({}) [{} panes]", - active_marker, window.index, window.name, window.agent_status, window.pane_count - ); - } - - Ok(()) -} - -fn cmd_session_add( - config: &RepoConfig, - tmux: &TmuxManager, - wt_config: &Config, - name: &str, - base: &str, - panes_override: Option, - watch: bool, -) -> Result<()> { - check_not_in_worktree(&config.root)?; - - let manager = WorktreeManager::new(config.root.clone())?; - ensure_worktrees_in_gitignore(&config.root, &config.worktree_dir)?; - std::fs::create_dir_all(&config.worktree_dir)?; - - // Check if worktree already exists - let existing = manager.get_worktree_info(name)?; - let worktree_path = if let Some(info) = existing { - eprintln!("Using existing worktree: {}", name); - info.path - } else { - eprintln!("Creating worktree: {}", name); - manager.create_worktree(name, base, &config.worktree_dir)? - }; - - let panes = wt_config.effective_panes(panes_override); - let inside_session = tmux.is_inside_session(); - - // Create or get session - let session_exists = tmux.session_exists()?; - if !session_exists { - eprintln!("Creating tmux session: {}", SESSION_NAME); - if watch { - // Create session with status window first - tmux.create_session("status", &config.root)?; - tmux.send_keys("status", 0, "wt session watch")?; - tmux.create_window(name, &worktree_path)?; - } else { - // Create session with worktree as first window - tmux.create_session(name, &worktree_path)?; - } - tmux.setup_worktree_layout(name, &worktree_path, panes, &wt_config.session)?; - } else { - let windows = tmux.list_windows()?; - - // Add status window if --watch and not present - if watch && !windows.iter().any(|w| w.name == "status") { - tmux.create_window("status", &config.root)?; - tmux.send_keys("status", 0, "wt session watch")?; - } - - // Check if worktree window already exists - if windows.iter().any(|w| w.name == name) { - eprintln!("Window '{}' already exists in session.", name); - if inside_session { - tmux.select_window(name)?; - } - } else { - eprintln!("Adding window: {} ({} panes)", name, panes); - tmux.create_window(name, &worktree_path)?; - tmux.setup_worktree_layout(name, &worktree_path, panes, &wt_config.session)?; - } - } - - // Save session state - let mut state = SessionState::load()?.unwrap_or_else(|| SessionState::new(SESSION_NAME)); - state.add_worktree(name, 0, panes, worktree_path); - state.sync_with_tmux(tmux)?; - state.save()?; - - if inside_session { - // Already inside, just switch to the window - tmux.select_window(name)?; - } else { - eprintln!("Attaching to session..."); - tmux.attach()?; - } - - Ok(()) -} - -fn cmd_session_rm(tmux: &TmuxManager, name: &str) -> Result<()> { - if !tmux.session_exists()? { - eprintln!("No session found."); - return Ok(()); - } - - let windows = tmux.list_windows()?; - if !windows.iter().any(|w| w.name == name) { - eprintln!("Window '{}' not found in session.", name); - return Ok(()); - } - - tmux.kill_window(name)?; - eprintln!("Removed window: {}", name); - - // Update session state - if let Some(mut state) = SessionState::load()? { - state.remove_worktree(name); - state.sync_with_tmux(tmux)?; - state.save()?; - } - - // Check if session is now empty (excluding status window) - let remaining: Vec<_> = tmux - .list_windows()? - .into_iter() - .filter(|w| w.name != "status") - .collect(); - if remaining.is_empty() { - eprintln!("Session is empty."); - SessionState::clear()?; - } - - Ok(()) -} - -fn cmd_session_watch(tmux: &TmuxManager, interval: u64) -> Result<()> { - use std::io::Write; - - if !tmux.session_exists()? { - eprintln!("No session found."); - return Ok(()); - } - - let interval_duration = std::time::Duration::from_secs(interval); - - loop { - // Clear screen and move cursor to top - print!("\x1B[2J\x1B[H"); - std::io::stdout().flush()?; - - println!("wt session status (refresh: {}s)\n", interval); - - let windows = tmux.list_windows()?; - let worktrees: Vec<_> = windows.iter().filter(|w| w.name != "status").collect(); - - if worktrees.is_empty() { - println!(" No worktrees in session."); - } else { - for window in &worktrees { - let status_icon = match window.agent_status { - wt::tmux_manager::AgentStatus::Active => "\x1B[32m●\x1B[0m", // green dot - wt::tmux_manager::AgentStatus::Idle => "\x1B[90m○\x1B[0m", // gray circle - wt::tmux_manager::AgentStatus::Unknown => "\x1B[33m?\x1B[0m", // yellow ? - }; - let active_marker = if window.active { " ←" } else { "" }; - println!( - " {} [{}] {}{} ({} panes)", - status_icon, window.index, window.name, active_marker, window.pane_count - ); - } - } - - println!("\n\x1B[90m● active ○ idle ? unknown\x1B[0m"); - println!("\x1B[90mPress Ctrl+C to exit\x1B[0m"); - - std::thread::sleep(interval_duration); - } -} diff --git a/src/session.rs b/src/session.rs index a9dc081..60a8200 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use crate::config::Config; @@ -10,6 +10,10 @@ use crate::tmux_manager::TmuxManager; pub struct SessionState { pub session_name: String, pub worktrees: HashMap, + /// Windows-mode sessions keyed by worktree name. Empty for panes-only + /// users, and absent from pre-windows-mode state files. + #[serde(default)] + pub windows_sessions: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -19,11 +23,19 @@ pub struct WindowInfo { pub worktree_path: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WindowsSessionInfo { + pub session_name: String, + pub worktree_path: PathBuf, + pub windows: Vec, +} + impl SessionState { pub fn new(session_name: &str) -> Self { Self { session_name: session_name.to_string(), worktrees: HashMap::new(), + windows_sessions: HashMap::new(), } } @@ -40,7 +52,6 @@ impl SessionState { } let contents = std::fs::read_to_string(&path).context("Failed to read sessions.json")?; - let state: SessionState = serde_json::from_str(&contents).context("Failed to parse sessions.json")?; @@ -54,7 +65,6 @@ impl SessionState { serde_json::to_string_pretty(self).context("Failed to serialize session state")?; std::fs::write(&path, contents).context("Failed to write sessions.json")?; - Ok(()) } @@ -85,17 +95,13 @@ impl SessionState { self.worktrees.contains_key(name) } - /// Sync session state with actual tmux windows - /// Removes entries for windows that no longer exist + /// Sync session state with actual tmux windows. pub fn sync_with_tmux(&mut self, tmux: &TmuxManager) -> Result<()> { let windows = tmux.list_windows()?; - let window_names: std::collections::HashSet<_> = - windows.iter().map(|w| w.name.clone()).collect(); + let window_names: HashSet<_> = windows.iter().map(|window| window.name.clone()).collect(); - // Remove worktrees that no longer have windows self.worktrees.retain(|name, _| window_names.contains(name)); - // Update pane counts for window in &windows { if let Some(info) = self.worktrees.get_mut(&window.name) { info.pane_count = window.pane_count as u8; @@ -105,7 +111,7 @@ impl SessionState { Ok(()) } - /// Clear the session state + /// Clear the session state file. pub fn clear() -> Result<()> { let path = Self::state_file_path()?; if path.exists() { @@ -113,6 +119,34 @@ impl SessionState { } Ok(()) } + + /// Whether the state holds no panes-mode or windows-mode entries. + pub fn is_empty(&self) -> bool { + self.worktrees.is_empty() && self.windows_sessions.is_empty() + } + + /// Drop all panes-mode entries while preserving windows-mode state. + pub fn clear_panes_state(&mut self) { + self.worktrees.clear(); + } + + /// Upsert a windows-mode session association. + pub fn add_windows_session(&mut self, worktree: &str, info: WindowsSessionInfo) { + self.windows_sessions.insert(worktree.to_string(), info); + } + + /// Remove a windows-mode session association. + pub fn remove_windows_session(&mut self, worktree: &str) -> Option { + self.windows_sessions.remove(worktree) + } +} + +/// Drop windows-mode entries whose tmux session is no longer live. +pub fn retain_live_sessions( + entries: &mut HashMap, + live: &HashSet, +) { + entries.retain(|_, info| live.contains(&info.session_name)); } #[cfg(test)] @@ -124,6 +158,7 @@ mod tests { let state = SessionState::new("wt"); assert_eq!(state.session_name, "wt"); assert!(state.worktrees.is_empty()); + assert!(state.windows_sessions.is_empty()); } #[test] @@ -153,4 +188,124 @@ mod tests { assert_eq!(loaded.session_name, "wt"); assert!(loaded.has_worktree("feature-1")); } + + #[test] + fn test_add_remove_windows_session() { + let mut state = SessionState::new("wt"); + let info = WindowsSessionInfo { + session_name: "wt-feature".to_string(), + worktree_path: PathBuf::from("/path/to/feature"), + windows: vec!["agent".into(), "shell".into()], + }; + + state.add_windows_session("feature", info.clone()); + + assert_eq!(state.windows_sessions.get("feature"), Some(&info)); + assert_eq!(state.remove_windows_session("feature"), Some(info)); + assert!(state.windows_sessions.is_empty()); + } + + #[test] + fn test_windows_session_serde_round_trip() { + let mut state = SessionState::new("wt"); + state.add_windows_session( + "feature", + WindowsSessionInfo { + session_name: "wt-feature".to_string(), + worktree_path: PathBuf::from("/path/to/feature"), + windows: vec!["agent".into(), "shell".into(), "edit".into()], + }, + ); + + let json = serde_json::to_string(&state).unwrap(); + let loaded: SessionState = serde_json::from_str(&json).unwrap(); + + assert_eq!(loaded.windows_sessions, state.windows_sessions); + } + + #[test] + fn test_deserialize_legacy_state_without_windows_sessions() { + let legacy = r#"{ + "session_name": "wt", + "worktrees": {} + }"#; + + let state: SessionState = serde_json::from_str(legacy).unwrap(); + assert_eq!(state.session_name, "wt"); + assert!(state.windows_sessions.is_empty()); + } + + #[test] + fn test_retain_live_sessions_drops_stale_entries() { + let mut entries = HashMap::new(); + entries.insert( + "alive".to_string(), + WindowsSessionInfo { + session_name: "wt-alive".to_string(), + worktree_path: PathBuf::from("/p/alive"), + windows: vec!["agent".into(), "shell".into()], + }, + ); + entries.insert( + "stale".to_string(), + WindowsSessionInfo { + session_name: "wt-stale".to_string(), + worktree_path: PathBuf::from("/p/stale"), + windows: vec!["agent".into(), "shell".into()], + }, + ); + + let live: HashSet = ["wt-alive".to_string()].into_iter().collect(); + retain_live_sessions(&mut entries, &live); + + assert_eq!(entries.len(), 1); + assert!(entries.contains_key("alive")); + assert!(!entries.contains_key("stale")); + } + + #[test] + fn test_clear_panes_state_preserves_windows_sessions() { + let mut state = SessionState::new("wt"); + state.add_worktree("feature", 1, 2, PathBuf::from("/path/feature")); + state.add_windows_session( + "other", + WindowsSessionInfo { + session_name: "wt-other".to_string(), + worktree_path: PathBuf::from("/path/other"), + windows: vec!["agent".into(), "shell".into()], + }, + ); + + state.clear_panes_state(); + + assert!(state.worktrees.is_empty()); + assert!(state.windows_sessions.contains_key("other")); + assert!(!state.is_empty()); + } + + #[test] + fn test_is_empty() { + let mut state = SessionState::new("wt"); + assert!(state.is_empty()); + state.add_worktree("feature", 1, 2, PathBuf::from("/path/feature")); + assert!(!state.is_empty()); + state.clear_panes_state(); + assert!(state.is_empty()); + } + + #[test] + fn test_retain_live_sessions_empty_live_set_clears_all() { + let mut entries = HashMap::new(); + entries.insert( + "foo".to_string(), + WindowsSessionInfo { + session_name: "wt-foo".to_string(), + worktree_path: PathBuf::from("/p/foo"), + windows: vec![], + }, + ); + + retain_live_sessions(&mut entries, &HashSet::new()); + assert!(entries.is_empty()); + } } diff --git a/src/session_cmd.rs b/src/session_cmd.rs new file mode 100644 index 0000000..518b287 --- /dev/null +++ b/src/session_cmd.rs @@ -0,0 +1,494 @@ +use anyhow::Result; +use clap::Subcommand; +use dialoguer::Select; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +use crate::{cmd_ls, RepoConfig}; +use wt::config::{Config, SessionMode}; +use wt::session::{retain_live_sessions, SessionState, WindowsSessionInfo}; +use wt::tmux_manager::{AgentStatus, TmuxManager}; +use wt::worktree_manager::{check_not_in_worktree, ensure_worktrees_in_gitignore, WorktreeManager}; + +const SESSION_NAME: &str = "wt"; + +#[derive(Subcommand)] +pub(crate) enum SessionAction { + /// List worktrees in the session + Ls, + /// Add a worktree to the session + Add { + /// Name for the worktree + name: String, + /// Base branch to create from + #[arg(short, default_value = "main")] + base: String, + /// Override pane count (2 or 3) + #[arg(long)] + panes: Option, + /// Create status window with live agent status + #[arg(long)] + watch: bool, + }, + /// Remove a worktree from the session + Rm { + /// Name of the worktree to remove + name: String, + }, + /// Watch session status (live-updating display) + Watch { + /// Refresh interval in seconds + #[arg(short, default_value = "2")] + interval: u64, + }, +} + +struct SessionCmdContext<'a> { + repo: &'a RepoConfig, + config: Config, + mode: SessionMode, +} + +impl<'a> SessionCmdContext<'a> { + fn new(repo: &'a RepoConfig, mode_override: Option) -> Self { + let config = Config::load_for_repo(&repo.root); + let mode = mode_override.unwrap_or(config.session.mode); + + Self { repo, config, mode } + } + + fn effective_panes(&self, panes_override: Option) -> u8 { + self.config.effective_panes(panes_override) + } +} + +pub(crate) fn run_session( + repo: &RepoConfig, + mode_override: Option, + action: Option, +) -> Result<()> { + if !TmuxManager::is_available() { + eprintln!("tmux not found. Falling back to interactive picker..."); + return cmd_ls(repo); + } + + let context = SessionCmdContext::new(repo, mode_override); + + match action { + None => match context.mode { + SessionMode::Panes => cmd_session_attach(&TmuxManager::new(SESSION_NAME)), + SessionMode::Windows => cmd_session_attach_windows(), + }, + Some(SessionAction::Ls) => match context.mode { + SessionMode::Panes => cmd_session_ls(&TmuxManager::new(SESSION_NAME)), + SessionMode::Windows => cmd_session_ls_windows(), + }, + Some(SessionAction::Add { + name, + base, + panes, + watch, + }) => match context.mode { + SessionMode::Panes => cmd_session_add_panes(&context, &name, &base, panes, watch), + SessionMode::Windows => cmd_session_add_windows(&context, &name, &base, panes, watch), + }, + Some(SessionAction::Rm { name }) => match context.mode { + SessionMode::Panes => cmd_session_rm_panes(&TmuxManager::new(SESSION_NAME), &name), + SessionMode::Windows => cmd_session_rm_windows(&context, &name), + }, + Some(SessionAction::Watch { interval }) => match context.mode { + SessionMode::Panes => cmd_session_watch(&TmuxManager::new(SESSION_NAME), interval), + SessionMode::Windows => { + eprintln!( + "'wt session watch' is not yet supported in windows mode. \ + Use 'wt session ls' to inspect status per session." + ); + Ok(()) + } + }, + } +} + +fn ensure_worktree_path( + context: &SessionCmdContext<'_>, + name: &str, + base: &str, +) -> Result { + check_not_in_worktree(&context.repo.root)?; + + let manager = WorktreeManager::new(context.repo.root.clone())?; + ensure_worktrees_in_gitignore(&context.repo.root, &context.repo.worktree_dir)?; + std::fs::create_dir_all(&context.repo.worktree_dir)?; + + match manager.get_worktree_info(name)? { + Some(info) => { + eprintln!("Using existing worktree: {}", name); + Ok(info.path) + } + None => { + eprintln!("Creating worktree: {}", name); + manager.create_worktree(name, base, &context.repo.worktree_dir) + } + } +} + +fn cmd_session_attach(tmux: &TmuxManager) -> Result<()> { + if !tmux.session_exists()? { + eprintln!("No session found. Use 'wt session add ' to create one."); + return Ok(()); + } + + if tmux.is_inside_session() { + eprintln!("Already inside session. Use 'wt session ls' to list windows."); + return Ok(()); + } + + tmux.enter() +} + +fn cmd_session_ls(tmux: &TmuxManager) -> Result<()> { + if !tmux.session_exists()? { + eprintln!("No session found."); + return Ok(()); + } + + let windows = tmux.list_windows()?; + if windows.is_empty() { + eprintln!("No worktrees in session."); + return Ok(()); + } + + for window in &windows { + if window.name == "status" { + continue; + } + + let active_marker = if window.active { "*" } else { " " }; + println!( + "{} [{}] {} ({}) [{} panes]", + active_marker, window.index, window.name, window.agent_status, window.pane_count + ); + } + + Ok(()) +} + +fn cmd_session_add_panes( + context: &SessionCmdContext<'_>, + name: &str, + base: &str, + panes_override: Option, + watch: bool, +) -> Result<()> { + let tmux = TmuxManager::new(SESSION_NAME); + let worktree_path = ensure_worktree_path(context, name, base)?; + let panes = context.effective_panes(panes_override); + let inside_session = tmux.is_inside_session(); + + if !tmux.session_exists()? { + eprintln!("Creating tmux session: {}", SESSION_NAME); + if watch { + tmux.create_session("status", &context.repo.root)?; + tmux.send_keys("status", 0, "wt session watch")?; + tmux.create_window(name, &worktree_path)?; + } else { + tmux.create_session(name, &worktree_path)?; + } + tmux.setup_worktree_layout(name, &worktree_path, panes, &context.config.session)?; + } else { + let windows = tmux.list_windows()?; + + if watch && !windows.iter().any(|window| window.name == "status") { + tmux.create_window("status", &context.repo.root)?; + tmux.send_keys("status", 0, "wt session watch")?; + } + + if windows.iter().any(|window| window.name == name) { + eprintln!("Window '{}' already exists in session.", name); + if inside_session { + tmux.select_window(name)?; + } + } else { + eprintln!("Adding window: {} ({} panes)", name, panes); + tmux.create_window(name, &worktree_path)?; + tmux.setup_worktree_layout(name, &worktree_path, panes, &context.config.session)?; + } + } + + let mut state = SessionState::load()?.unwrap_or_else(|| SessionState::new(SESSION_NAME)); + state.add_worktree(name, 0, panes, worktree_path); + state.sync_with_tmux(&tmux)?; + state.save()?; + + if inside_session { + tmux.select_window(name)?; + } else { + eprintln!("Entering session..."); + tmux.enter()?; + } + + Ok(()) +} + +fn cmd_session_rm_panes(tmux: &TmuxManager, name: &str) -> Result<()> { + if !tmux.session_exists()? { + eprintln!("No session found."); + return Ok(()); + } + + let windows = tmux.list_windows()?; + if !windows.iter().any(|window| window.name == name) { + eprintln!("Window '{}' not found in session.", name); + return Ok(()); + } + + tmux.kill_window(name)?; + eprintln!("Removed window: {}", name); + + let remaining: Vec<_> = tmux + .list_windows()? + .into_iter() + .filter(|window| window.name != "status") + .collect(); + let session_drained = remaining.is_empty(); + if session_drained { + eprintln!("Session is empty."); + } + + if let Some(mut state) = SessionState::load()? { + if session_drained { + state.clear_panes_state(); + } else { + state.remove_worktree(name); + state.sync_with_tmux(tmux)?; + } + save_state_or_clear_if_empty(&state)?; + } + + Ok(()) +} + +fn cmd_session_add_windows( + context: &SessionCmdContext<'_>, + name: &str, + base: &str, + panes_override: Option, + watch: bool, +) -> Result<()> { + if watch { + eprintln!("Note: --watch is ignored in windows mode."); + } + + let worktree_path = ensure_worktree_path(context, name, base)?; + let panes = context.effective_panes(panes_override); + let session_name = context.config.session.session_name_for(name); + let tmux = TmuxManager::new(&session_name); + + if tmux.session_exists()? { + eprintln!("Using existing session: {}", session_name); + } else { + eprintln!( + "Creating tmux session: {} ({} windows)", + session_name, panes + ); + tmux.create_session("agent", &worktree_path)?; + tmux.setup_worktree_windows(&worktree_path, panes, &context.config.session)?; + } + + persist_windows_session(name, &session_name, &worktree_path, panes)?; + tmux.enter() +} + +fn cmd_session_attach_windows() -> Result<()> { + let Some(state) = load_pruned_state()? else { + eprintln!("No worktree sessions found. Use 'wt session add ' to create one."); + return Ok(()); + }; + + if state.windows_sessions.is_empty() { + eprintln!("No worktree sessions found. Use 'wt session add ' to create one."); + return Ok(()); + } + + let entries = sorted_windows_sessions(&state); + if !std::io::stderr().is_terminal() { + for (_, info) in &entries { + println!("{}", info.session_name); + } + return Ok(()); + } + + let items: Vec = entries + .iter() + .map(|(_, info)| info.session_name.clone()) + .chain(std::iter::once("← cancel".to_string())) + .collect(); + + eprintln!("Select worktree session:"); + let selection = Select::new().items(&items).default(0).interact()?; + if items[selection] == "← cancel" { + return Ok(()); + } + + TmuxManager::new(&items[selection]).enter() +} + +fn cmd_session_ls_windows() -> Result<()> { + let Some(state) = load_pruned_state()? else { + eprintln!("No worktree sessions found. Use 'wt session add ' to create one."); + return Ok(()); + }; + + if state.windows_sessions.is_empty() { + eprintln!("No worktree sessions found. Use 'wt session add ' to create one."); + return Ok(()); + } + + for (_, info) in sorted_windows_sessions(&state) { + let tmux = TmuxManager::new(&info.session_name); + let attached = tmux.is_attached().unwrap_or(false); + let agent_status = tmux + .list_windows() + .ok() + .and_then(|windows| windows.into_iter().find(|window| window.name == "agent")) + .map(|window| window.agent_status) + .unwrap_or(AgentStatus::Unknown); + let marker = if attached { "*" } else { " " }; + println!("{} {} (agent: {})", marker, info.session_name, agent_status); + } + + Ok(()) +} + +fn cmd_session_rm_windows(context: &SessionCmdContext<'_>, name: &str) -> Result<()> { + let mut state = SessionState::load()?; + + let session_name = state + .as_ref() + .and_then(|loaded| loaded.windows_sessions.get(name)) + .map(|info| info.session_name.clone()) + .unwrap_or_else(|| context.config.session.session_name_for(name)); + + let tmux = TmuxManager::new(&session_name); + let session_existed = tmux.session_exists()?; + + if session_existed { + tmux.kill_session()?; + eprintln!("Killed session: {}", session_name); + } else { + eprintln!("Session '{}' not found.", session_name); + } + + if let Some(loaded) = state.as_mut() { + let removed = loaded.remove_windows_session(name).is_some(); + prune_windows_state(loaded); + save_state_or_clear_if_empty(loaded)?; + if removed && !session_existed { + eprintln!("Removed stale state entry for '{}'.", name); + } + } + + Ok(()) +} + +fn cmd_session_watch(tmux: &TmuxManager, interval: u64) -> Result<()> { + use std::io::Write; + + if !tmux.session_exists()? { + eprintln!("No session found."); + return Ok(()); + } + + let interval_duration = std::time::Duration::from_secs(interval); + + loop { + print!("\x1B[2J\x1B[H"); + std::io::stdout().flush()?; + + println!("wt session status (refresh: {}s)\n", interval); + + let windows = tmux.list_windows()?; + let worktrees: Vec<_> = windows + .iter() + .filter(|window| window.name != "status") + .collect(); + + if worktrees.is_empty() { + println!(" No worktrees in session."); + } else { + for window in &worktrees { + let status_icon = match window.agent_status { + AgentStatus::Active => "\x1B[32m●\x1B[0m", + AgentStatus::Idle => "\x1B[90m○\x1B[0m", + AgentStatus::Unknown => "\x1B[33m?\x1B[0m", + }; + let active_marker = if window.active { " ←" } else { "" }; + println!( + " {} [{}] {}{} ({} panes)", + status_icon, window.index, window.name, active_marker, window.pane_count + ); + } + } + + println!("\n\x1B[90m● active ○ idle ? unknown\x1B[0m"); + println!("\x1B[90mPress Ctrl+C to exit\x1B[0m"); + + std::thread::sleep(interval_duration); + } +} + +fn persist_windows_session( + worktree_name: &str, + session_name: &str, + worktree_path: &Path, + panes: u8, +) -> Result<()> { + let mut state = SessionState::load()?.unwrap_or_else(|| SessionState::new(SESSION_NAME)); + + let windows = if panes == 3 { + vec!["agent".to_string(), "shell".to_string(), "edit".to_string()] + } else { + vec!["agent".to_string(), "shell".to_string()] + }; + + state.add_windows_session( + worktree_name, + WindowsSessionInfo { + session_name: session_name.to_string(), + worktree_path: worktree_path.to_path_buf(), + windows, + }, + ); + prune_windows_state(&mut state); + state.save() +} + +fn load_pruned_state() -> Result> { + let Some(mut state) = SessionState::load()? else { + return Ok(None); + }; + + prune_windows_state(&mut state); + save_state_or_clear_if_empty(&state)?; + Ok(Some(state)) +} + +fn prune_windows_state(state: &mut SessionState) { + if let Ok(live) = TmuxManager::live_session_names() { + retain_live_sessions(&mut state.windows_sessions, &live); + } +} + +fn save_state_or_clear_if_empty(state: &SessionState) -> Result<()> { + if state.is_empty() { + SessionState::clear() + } else { + state.save() + } +} + +fn sorted_windows_sessions(state: &SessionState) -> Vec<(&String, &WindowsSessionInfo)> { + let mut entries: Vec<_> = state.windows_sessions.iter().collect(); + entries.sort_by(|left, right| left.1.session_name.cmp(&right.1.session_name)); + entries +} From 6949cb4f357b37c62fdd295c48e582a031dfde45 Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 06:25:08 +0300 Subject: [PATCH 04/14] docs(session): document panes and windows layouts Document the new --mode flow, state-backed windows discovery, and the panes-only watch behavior so users can choose layouts without guessing how session lookup works. --- README.md | 93 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index d50ab30..e2f7c83 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Git worktrees solve this—but the commands are verbose, cleanup is manual, and ~/myrepo/.worktrees/bugfix/ # agent 3 fixing that bug ``` -**Session mode** manages all your agents in one tmux session with a live status dashboard showing which agents are active or idle. +**Session mode** manages your agents in tmux, either as panes in one shared session or as separate per-worktree sessions. ## Installation @@ -124,20 +124,22 @@ wt use [name] Enter existing workspace wt ls Interactive workspace picker wt rm [name] Remove workspace (interactive if no name) wt which Print current workspace name -wt session Attach to tmux session (see Session Mode) -wt session ls List workspaces in session (with agent status) -wt session add Add workspace to session +wt session [--mode M] Enter tmux session(s) (see Session Mode) +wt session [--mode M] ls List workspaces in session +wt session [--mode M] add [-b base] base: defaults to main - [--panes 2|3] override pane count - [--watch] add status window with live agent status -wt session rm Remove workspace from session -wt session watch [-i N] Live status dashboard (or use --watch above) + [--panes 2|3] override pane count (panes mode) / window count (windows mode) + [--watch] add status window with live agent status (panes mode only) +wt session [--mode M] rm +wt session [--mode M] watch [-i N] wt -d Custom worktree directory (default: .worktrees) + +M = panes | windows ``` ## Session Mode -Manage multiple workspaces in a tmux session with dedicated panes for AI agents, terminal, and optionally an editor. +Manage multiple workspaces in tmux with dedicated agent, terminal, and optional editor surfaces. ```bash # Add workspaces to session @@ -146,35 +148,30 @@ $ wt session add feature/payments # List workspaces with agent status $ wt session ls -* [0] feature/auth (active) [2 panes] # agent running - [1] feature/payments (idle) [2 panes] # agent at shell prompt +* [0] feature/auth (active) [2 panes] # panes mode + +# Or switch the whole invocation to windows mode +$ wt session --mode windows add feature/review +$ wt session --mode windows ls + wt-feature-review (agent: idle) -# Attach to session (or switch if detached) +# Enter tmux session(s) $ wt session # Remove workspace from session $ wt session rm feature/auth - -# Commands work from inside the session too -# (switches windows instead of attaching) ``` -### Status Window +### Layout Modes -Use `--watch` to add a status window showing all workspaces and their agent status: +`wt` supports two tmux layouts. -```bash -wt session add feature/auth --watch -``` +#### Panes mode (default) -- `●` green = agent active (running a command) -- `○` gray = agent idle (at shell prompt) +All worktrees live in one shared tmux session named `wt`, one window per +worktree, split into 2 or 3 panes. -Or run `wt session watch` manually in any pane. - -### Pane Layouts - -**2 panes (default):** +**2 panes:** ``` +---------------------------+---------------------------+ | | | @@ -195,18 +192,52 @@ Or run `wt session watch` manually in any pane. +---------------------------+---------------------------+ ``` +Use `--watch` to add a status window showing all workspaces and their agent status: + +```bash +wt session add feature/auth --watch +``` + +- `●` green = agent active +- `○` gray = agent idle + +Or run `wt session watch` manually in any pane. + +#### Windows mode + +Each worktree gets its own tmux session with one window per role. This is useful +on narrow screens or when you prefer window navigation over pane navigation. + +- 2 windows: `agent`, `shell` +- 3 windows: `agent`, `shell`, `edit` + +Session names default to `wt-` and are configurable with +`session_prefix`. + +Discovery in windows mode is state-backed: `wt` records sessions created via +`wt session add` in `~/.wt/sessions.json`, and `wt session`, `wt session ls`, and +`wt session rm` operate from that stored state. Stale entries are pruned when the +corresponding tmux session no longer exists. + +Because discovery is state-backed, `session_prefix = ""` only changes naming. It +does not cause `wt` to pick up unrelated tmux sessions. + +`wt session watch` and `--watch` are currently panes-mode only. + ### Configuration Create `~/.wt/config.toml` for global settings or `.wt.toml` in repo root for per-repo settings: ```toml [session] -panes = 2 # 2 or 3 (default: 2) -agent_cmd = "claude" # command for agent pane -editor_cmd = "nvim" # command for editor pane (when panes=3) +mode = "panes" # "panes" (default) or "windows" +panes = 2 # 2 or 3; also used as window count in windows mode +session_prefix = "wt-" # prepended to windows-mode session names +agent_cmd = "claude" # command for agent pane/window +editor_cmd = "nvim" # command for editor pane/window (when panes=3) ``` -Precedence: `--panes` flag > `.wt.toml` > `~/.wt/config.toml` > defaults +Precedence: `--mode` / `--panes` flags > `.wt.toml` > `~/.wt/config.toml` > defaults ### Navigation From 184f9a3d1cd98c570838f63a3d902b2be000301b Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 06:25:08 +0300 Subject: [PATCH 05/14] fix(tmux): target next free window index Use 'session:' when creating a new tmux window so tmux allocates the next unused index instead of resolving the target to window 0. Add a regression test for the target syntax to prevent 'index 0 in use' when adding windows to an existing session. --- src/tmux_manager.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/tmux_manager.rs b/src/tmux_manager.rs index 67d9e6c..ca370b3 100644 --- a/src/tmux_manager.rs +++ b/src/tmux_manager.rs @@ -204,11 +204,12 @@ impl TmuxManager { /// Create a new window in the session. pub fn create_window(&self, name: &str, cwd: &Path) -> Result { + let target = self.next_window_target(); let output = Command::new("tmux") .args([ "new-window", "-t", - &self.session_name, + &target, "-n", name, "-c", @@ -235,6 +236,11 @@ impl TmuxManager { Ok(index) } + /// Target the next unused window index in this session. + fn next_window_target(&self) -> String { + format!("{}:", self.session_name) + } + /// Kill a window by name. pub fn kill_window(&self, name: &str) -> Result<()> { let target = format!("{}:{}", self.session_name, name); @@ -492,4 +498,10 @@ mod tests { let manager = TmuxManager::new("test-session"); assert_eq!(manager.session_name(), "test-session"); } + + #[test] + fn test_next_window_target_uses_next_free_index_syntax() { + let manager = TmuxManager::new("wt"); + assert_eq!(manager.next_window_target(), "wt:"); + } } From 295ac5e0739d0ace5b403e3dcf6bf4b74e8bf461 Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 06:25:08 +0300 Subject: [PATCH 06/14] test(tmux): cover creating a second window Add an ignored tmux regression test that creates a session and then adds a second window, covering the bug where new-window targeted window 0 instead of the next free slot. Reuse a small cleanup helper across the existing ignored tmux tests. --- tests/tmux_test.rs | 57 +++++++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/tests/tmux_test.rs b/tests/tmux_test.rs index 4ede043..5657eb7 100644 --- a/tests/tmux_test.rs +++ b/tests/tmux_test.rs @@ -25,6 +25,12 @@ fn setup_test_repo() -> (TempDir, PathBuf) { (temp_dir, repo_path) } +fn kill_tmux_session(session_name: &str) { + let _ = Command::new("tmux") + .args(["kill-session", "-t", session_name]) + .output(); +} + #[test] #[ignore] fn test_tmux_session_lifecycle() { @@ -38,9 +44,7 @@ fn test_tmux_session_lifecycle() { let (_temp_dir, repo_path) = setup_test_repo(); // Cleanup any existing test session - let _ = Command::new("tmux") - .args(["kill-session", "-t", session_name]) - .output(); + kill_tmux_session(session_name); // Test session creation assert!(!tmux.session_exists().unwrap()); @@ -63,9 +67,7 @@ fn test_tmux_session_lifecycle() { assert_eq!(windows.len(), 1); // Cleanup - let _ = Command::new("tmux") - .args(["kill-session", "-t", session_name]) - .output(); + kill_tmux_session(session_name); } #[test] @@ -81,9 +83,7 @@ fn test_tmux_pane_layout_2_panes() { let (_temp_dir, repo_path) = setup_test_repo(); // Cleanup any existing test session - let _ = Command::new("tmux") - .args(["kill-session", "-t", session_name]) - .output(); + kill_tmux_session(session_name); let config = SessionConfig::default(); tmux.create_session("test-window", &repo_path).unwrap(); @@ -94,9 +94,7 @@ fn test_tmux_pane_layout_2_panes() { assert_eq!(windows[0].pane_count, 2); // Cleanup - let _ = Command::new("tmux") - .args(["kill-session", "-t", session_name]) - .output(); + kill_tmux_session(session_name); } #[test] @@ -112,9 +110,7 @@ fn test_tmux_pane_layout_3_panes() { let (_temp_dir, repo_path) = setup_test_repo(); // Cleanup any existing test session - let _ = Command::new("tmux") - .args(["kill-session", "-t", session_name]) - .output(); + kill_tmux_session(session_name); let config = SessionConfig::default(); tmux.create_session("test-window", &repo_path).unwrap(); @@ -125,9 +121,34 @@ fn test_tmux_pane_layout_3_panes() { assert_eq!(windows[0].pane_count, 3); // Cleanup - let _ = Command::new("tmux") - .args(["kill-session", "-t", session_name]) - .output(); + kill_tmux_session(session_name); +} + +#[test] +#[ignore] +fn test_tmux_create_window_uses_next_free_index() { + if !TmuxManager::is_available() { + eprintln!("tmux not available, skipping test"); + return; + } + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let session_name = format!("wt-test-next-window-{}", std::process::id()); + let tmux = TmuxManager::new(&session_name); + + kill_tmux_session(&session_name); + + tmux.create_session("first-window", temp_dir.path()) + .unwrap(); + tmux.create_window("second-window", temp_dir.path()) + .unwrap(); + + let windows = tmux.list_windows().unwrap(); + assert_eq!(windows.len(), 2); + assert!(windows.iter().any(|window| window.name == "first-window")); + assert!(windows.iter().any(|window| window.name == "second-window")); + + kill_tmux_session(&session_name); } #[test] From 7d4d7f566fc8b67af653b2ce423ec640210ab192 Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 06:51:35 +0300 Subject: [PATCH 07/14] docs(session): clarify windows-mode invocation and navigation Clarify that --mode only applies to the command it is passed to so README examples do not imply sticky windows-mode behavior. Also explain that tmux window navigation in windows mode stays within one worktree session and that switching worktrees uses wt session again. --- README.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e2f7c83..a356383 100644 --- a/README.md +++ b/README.md @@ -155,13 +155,21 @@ $ wt session --mode windows add feature/review $ wt session --mode windows ls wt-feature-review (agent: idle) -# Enter tmux session(s) +# Enter the default panes session $ wt session -# Remove workspace from session +# Remove a panes-mode workspace $ wt session rm feature/auth + +# Enter or remove a windows-mode worktree session +$ wt session --mode windows +$ wt session --mode windows rm feature/review ``` +`--mode` only affects the command it is passed to. Set `mode = "windows"` +in config if you want windows mode to be the default for bare +`wt session` commands. + ### Layout Modes `wt` supports two tmux layouts. @@ -243,9 +251,14 @@ Precedence: `--mode` / `--panes` flags > `.wt.toml` > `~/.wt/config.toml` > defa Standard tmux keybindings: - `C-b` + arrow keys — switch panes -- `C-b n` / `C-b p` — next/previous window +- `C-b n` / `C-b p` — next/previous window in the current tmux session - `C-b d` — detach from session +In windows mode, `C-b n` / `C-b p` only moves between the `agent`, `shell`, +and `edit` windows for one worktree. To switch to a different worktree +session, run `wt session --mode windows` again; from inside tmux, `wt` uses +`switch-client` instead of nesting tmux sessions. + ### Environment Variables Inside a workspace shell: From 5eb8414ead63ba2773dd8cbf3d8ee5ad2ea9e10d Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 07:13:27 +0300 Subject: [PATCH 08/14] fix(session): clarify rm guidance across layouts Probe panes and windows session state before returning from rm so the user sees when the chosen layout does not contain the target worktree. Keep removal mode-specific, but suggest the correct command for the other layout and make stale windows-mode cleanup explicit. --- src/session_cmd.rs | 203 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 197 insertions(+), 6 deletions(-) diff --git a/src/session_cmd.rs b/src/session_cmd.rs index 518b287..ba74e3d 100644 --- a/src/session_cmd.rs +++ b/src/session_cmd.rs @@ -49,6 +49,16 @@ struct SessionCmdContext<'a> { mode: SessionMode, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct SessionRmProbe { + worktree_exists: bool, + panes_session_exists: bool, + panes_has_worktree: bool, + windows_session_name: String, + windows_session_tracked: bool, + windows_session_live: bool, +} + impl<'a> SessionCmdContext<'a> { fn new(repo: &'a RepoConfig, mode_override: Option) -> Self { let config = Config::load_for_repo(&repo.root); @@ -93,7 +103,7 @@ pub(crate) fn run_session( SessionMode::Windows => cmd_session_add_windows(&context, &name, &base, panes, watch), }, Some(SessionAction::Rm { name }) => match context.mode { - SessionMode::Panes => cmd_session_rm_panes(&TmuxManager::new(SESSION_NAME), &name), + SessionMode::Panes => cmd_session_rm_panes(&context, &name), SessionMode::Windows => cmd_session_rm_windows(&context, &name), }, Some(SessionAction::Watch { interval }) => match context.mode { @@ -230,15 +240,19 @@ fn cmd_session_add_panes( Ok(()) } -fn cmd_session_rm_panes(tmux: &TmuxManager, name: &str) -> Result<()> { +fn cmd_session_rm_panes(context: &SessionCmdContext<'_>, name: &str) -> Result<()> { + let tmux = TmuxManager::new(SESSION_NAME); + if !tmux.session_exists()? { eprintln!("No session found."); + print_rm_hint(SessionMode::Panes, name, &probe_session_rm(context, name)?); return Ok(()); } let windows = tmux.list_windows()?; if !windows.iter().any(|window| window.name == name) { eprintln!("Window '{}' not found in session.", name); + print_rm_hint(SessionMode::Panes, name, &probe_session_rm(context, name)?); return Ok(()); } @@ -260,7 +274,7 @@ fn cmd_session_rm_panes(tmux: &TmuxManager, name: &str) -> Result<()> { state.clear_panes_state(); } else { state.remove_worktree(name); - state.sync_with_tmux(tmux)?; + state.sync_with_tmux(&tmux)?; } save_state_or_clear_if_empty(&state)?; } @@ -361,6 +375,7 @@ fn cmd_session_ls_windows() -> Result<()> { } fn cmd_session_rm_windows(context: &SessionCmdContext<'_>, name: &str) -> Result<()> { + let probe = probe_session_rm(context, name)?; let mut state = SessionState::load()?; let session_name = state @@ -375,8 +390,6 @@ fn cmd_session_rm_windows(context: &SessionCmdContext<'_>, name: &str) -> Result if session_existed { tmux.kill_session()?; eprintln!("Killed session: {}", session_name); - } else { - eprintln!("Session '{}' not found.", session_name); } if let Some(loaded) = state.as_mut() { @@ -384,10 +397,29 @@ fn cmd_session_rm_windows(context: &SessionCmdContext<'_>, name: &str) -> Result prune_windows_state(loaded); save_state_or_clear_if_empty(loaded)?; if removed && !session_existed { - eprintln!("Removed stale state entry for '{}'.", name); + eprintln!( + "Removed stale windows-mode entry for '{}' (session '{}').", + name, session_name + ); + if probe.panes_has_worktree { + print_rm_hint(SessionMode::Windows, name, &probe); + } else if probe.worktree_exists { + eprintln!( + "Worktree '{}' still exists. Use 'wt rm {}' to remove the \ + worktree or 'wt session --mode windows add {}' to add it \ + again.", + name, name, name + ); + } + return Ok(()); } } + if !session_existed { + eprintln!("Session '{}' not found.", session_name); + print_rm_hint(SessionMode::Windows, name, &probe); + } + Ok(()) } @@ -492,3 +524,162 @@ fn sorted_windows_sessions(state: &SessionState) -> Vec<(&String, &WindowsSessio entries.sort_by(|left, right| left.1.session_name.cmp(&right.1.session_name)); entries } + +fn probe_session_rm(context: &SessionCmdContext<'_>, name: &str) -> Result { + let manager = WorktreeManager::new(context.repo.root.clone())?; + let panes_tmux = TmuxManager::new(SESSION_NAME); + let panes_session_exists = panes_tmux.session_exists()?; + let panes_has_worktree = if panes_session_exists { + panes_tmux + .list_windows()? + .into_iter() + .any(|window| window.name == name) + } else { + false + }; + + let state = SessionState::load()?; + let tracked_windows_session_name = state + .as_ref() + .and_then(|loaded| loaded.windows_sessions.get(name)) + .map(|info| info.session_name.clone()); + let windows_session_tracked = tracked_windows_session_name.is_some(); + let windows_session_name = tracked_windows_session_name + .unwrap_or_else(|| context.config.session.session_name_for(name)); + let windows_session_live = TmuxManager::new(&windows_session_name).session_exists()?; + + Ok(SessionRmProbe { + worktree_exists: manager.worktree_exists(name), + panes_session_exists, + panes_has_worktree, + windows_session_name, + windows_session_tracked, + windows_session_live, + }) +} + +fn rm_hint(mode: SessionMode, name: &str, probe: &SessionRmProbe) -> Option { + match mode { + SessionMode::Panes => { + if probe.windows_session_live { + let availability = if probe.windows_session_tracked { + "tracked in windows mode" + } else { + "available in windows mode" + }; + Some(format!( + "'{}' is {} as session '{}'. Try: wt session --mode windows rm {}", + name, availability, probe.windows_session_name, name + )) + } else if probe.worktree_exists { + Some(format!( + "Worktree '{}' exists but is not in the shared panes session. \ + Use 'wt rm {}' to remove the worktree or 'wt session --mode \ + panes add {}' to add it.", + name, name, name + )) + } else { + None + } + } + SessionMode::Windows => { + if probe.panes_has_worktree { + Some(format!( + "'{}' is in the shared panes session. Try: wt session --mode \ + panes rm {}", + name, name + )) + } else if probe.worktree_exists && !probe.windows_session_tracked { + Some(format!( + "Worktree '{}' exists but is not tracked in windows mode. Use \ + 'wt rm {}' to remove the worktree or 'wt session --mode \ + windows add {}' to add it.", + name, name, name + )) + } else { + None + } + } + } +} + +fn print_rm_hint(mode: SessionMode, name: &str, probe: &SessionRmProbe) { + if let Some(hint) = rm_hint(mode, name, probe) { + eprintln!("{}", hint); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn probe() -> SessionRmProbe { + SessionRmProbe { + windows_session_name: "wt-demo".to_string(), + ..SessionRmProbe::default() + } + } + + #[test] + fn test_rm_hint_points_panes_removal_to_windows_mode() { + let mut probe = probe(); + probe.windows_session_tracked = true; + probe.windows_session_live = true; + + assert_eq!( + rm_hint(SessionMode::Panes, "demo", &probe), + Some( + "'demo' is tracked in windows mode as session 'wt-demo'. Try: wt session --mode windows rm demo" + .to_string() + ) + ); + } + + #[test] + fn test_rm_hint_points_windows_removal_to_panes_mode() { + let mut probe = probe(); + probe.panes_has_worktree = true; + + assert_eq!( + rm_hint(SessionMode::Windows, "demo", &probe), + Some( + "'demo' is in the shared panes session. Try: wt session --mode panes rm demo" + .to_string() + ) + ); + } + + #[test] + fn test_rm_hint_explains_untracked_windows_worktree() { + let mut probe = probe(); + probe.worktree_exists = true; + + assert_eq!( + rm_hint(SessionMode::Windows, "demo", &probe), + Some( + "Worktree 'demo' exists but is not tracked in windows mode. Use 'wt rm demo' to remove the worktree or 'wt session --mode windows add demo' to add it." + .to_string() + ) + ); + } + + #[test] + fn test_rm_hint_explains_missing_panes_membership() { + let mut probe = probe(); + probe.worktree_exists = true; + + assert_eq!( + rm_hint(SessionMode::Panes, "demo", &probe), + Some( + "Worktree 'demo' exists but is not in the shared panes session. Use 'wt rm demo' to remove the worktree or 'wt session --mode panes add demo' to add it." + .to_string() + ) + ); + } + + #[test] + fn test_rm_hint_is_empty_when_nothing_matches() { + assert_eq!(rm_hint(SessionMode::Panes, "demo", &probe()), None); + assert_eq!(rm_hint(SessionMode::Windows, "demo", &probe()), None); + } +} From 32d7070cd2485bcaf0817567532c7061c9b29f29 Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 08:40:12 +0300 Subject: [PATCH 09/14] refactor(session): centralize windows state loading Centralize the windows-mode empty-state message and the load-plus-prune flow so attach and ls share the same behavior. This removes duplicate empty checks without changing session command behavior. --- src/session_cmd.rs | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/session_cmd.rs b/src/session_cmd.rs index ba74e3d..40a6859 100644 --- a/src/session_cmd.rs +++ b/src/session_cmd.rs @@ -11,6 +11,8 @@ use wt::tmux_manager::{AgentStatus, TmuxManager}; use wt::worktree_manager::{check_not_in_worktree, ensure_worktrees_in_gitignore, WorktreeManager}; const SESSION_NAME: &str = "wt"; +const NO_WINDOWS_SESSIONS_MSG: &str = + "No worktree sessions found. Use 'wt session add ' to create one."; #[derive(Subcommand)] pub(crate) enum SessionAction { @@ -314,16 +316,10 @@ fn cmd_session_add_windows( } fn cmd_session_attach_windows() -> Result<()> { - let Some(state) = load_pruned_state()? else { - eprintln!("No worktree sessions found. Use 'wt session add ' to create one."); + let Some(state) = load_windows_state_or_report_empty()? else { return Ok(()); }; - if state.windows_sessions.is_empty() { - eprintln!("No worktree sessions found. Use 'wt session add ' to create one."); - return Ok(()); - } - let entries = sorted_windows_sessions(&state); if !std::io::stderr().is_terminal() { for (_, info) in &entries { @@ -348,16 +344,10 @@ fn cmd_session_attach_windows() -> Result<()> { } fn cmd_session_ls_windows() -> Result<()> { - let Some(state) = load_pruned_state()? else { - eprintln!("No worktree sessions found. Use 'wt session add ' to create one."); + let Some(state) = load_windows_state_or_report_empty()? else { return Ok(()); }; - if state.windows_sessions.is_empty() { - eprintln!("No worktree sessions found. Use 'wt session add ' to create one."); - return Ok(()); - } - for (_, info) in sorted_windows_sessions(&state) { let tmux = TmuxManager::new(&info.session_name); let attached = tmux.is_attached().unwrap_or(false); @@ -495,7 +485,7 @@ fn persist_windows_session( state.save() } -fn load_pruned_state() -> Result> { +fn load_windows_state() -> Result> { let Some(mut state) = SessionState::load()? else { return Ok(None); }; @@ -505,6 +495,20 @@ fn load_pruned_state() -> Result> { Ok(Some(state)) } +fn load_windows_state_or_report_empty() -> Result> { + let Some(state) = load_windows_state()? else { + eprintln!("{}", NO_WINDOWS_SESSIONS_MSG); + return Ok(None); + }; + + if state.windows_sessions.is_empty() { + eprintln!("{}", NO_WINDOWS_SESSIONS_MSG); + return Ok(None); + } + + Ok(Some(state)) +} + fn prune_windows_state(state: &mut SessionState) { if let Ok(live) = TmuxManager::live_session_names() { retain_live_sessions(&mut state.windows_sessions, &live); From 912c9273b9cb01a0e28949b362b98d5ffab1f5a1 Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 08:48:52 +0300 Subject: [PATCH 10/14] refactor(session): extract panes-mode tmux helpers Extract shared panes-mode tmux setup so the command dispatcher and panes add/remove flows stop rebuilding the same session wrapper and status-window logic inline. This keeps the panes paths shorter without changing behavior. --- src/session_cmd.rs | 55 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/session_cmd.rs b/src/session_cmd.rs index 40a6859..5bcb4b7 100644 --- a/src/session_cmd.rs +++ b/src/session_cmd.rs @@ -88,11 +88,17 @@ pub(crate) fn run_session( match action { None => match context.mode { - SessionMode::Panes => cmd_session_attach(&TmuxManager::new(SESSION_NAME)), + SessionMode::Panes => { + let tmux = panes_tmux(); + cmd_session_attach(&tmux) + } SessionMode::Windows => cmd_session_attach_windows(), }, Some(SessionAction::Ls) => match context.mode { - SessionMode::Panes => cmd_session_ls(&TmuxManager::new(SESSION_NAME)), + SessionMode::Panes => { + let tmux = panes_tmux(); + cmd_session_ls(&tmux) + } SessionMode::Windows => cmd_session_ls_windows(), }, Some(SessionAction::Add { @@ -109,7 +115,10 @@ pub(crate) fn run_session( SessionMode::Windows => cmd_session_rm_windows(&context, &name), }, Some(SessionAction::Watch { interval }) => match context.mode { - SessionMode::Panes => cmd_session_watch(&TmuxManager::new(SESSION_NAME), interval), + SessionMode::Panes => { + let tmux = panes_tmux(); + cmd_session_watch(&tmux, interval) + } SessionMode::Windows => { eprintln!( "'wt session watch' is not yet supported in windows mode. \ @@ -144,6 +153,30 @@ fn ensure_worktree_path( } } +fn panes_tmux() -> TmuxManager { + TmuxManager::new(SESSION_NAME) +} + +fn create_status_window_session(tmux: &TmuxManager, repo_root: &Path) -> Result<()> { + tmux.create_session("status", repo_root)?; + tmux.send_keys("status", 0, "wt session watch")?; + Ok(()) +} + +fn ensure_status_window(tmux: &TmuxManager, repo_root: &Path) -> Result<()> { + if tmux + .list_windows()? + .iter() + .any(|window| window.name == "status") + { + return Ok(()); + } + + tmux.create_window("status", repo_root)?; + tmux.send_keys("status", 0, "wt session watch")?; + Ok(()) +} + fn cmd_session_attach(tmux: &TmuxManager) -> Result<()> { if !tmux.session_exists()? { eprintln!("No session found. Use 'wt session add ' to create one."); @@ -192,7 +225,7 @@ fn cmd_session_add_panes( panes_override: Option, watch: bool, ) -> Result<()> { - let tmux = TmuxManager::new(SESSION_NAME); + let tmux = panes_tmux(); let worktree_path = ensure_worktree_path(context, name, base)?; let panes = context.effective_panes(panes_override); let inside_session = tmux.is_inside_session(); @@ -200,21 +233,19 @@ fn cmd_session_add_panes( if !tmux.session_exists()? { eprintln!("Creating tmux session: {}", SESSION_NAME); if watch { - tmux.create_session("status", &context.repo.root)?; - tmux.send_keys("status", 0, "wt session watch")?; + create_status_window_session(&tmux, &context.repo.root)?; tmux.create_window(name, &worktree_path)?; } else { tmux.create_session(name, &worktree_path)?; } tmux.setup_worktree_layout(name, &worktree_path, panes, &context.config.session)?; } else { - let windows = tmux.list_windows()?; - - if watch && !windows.iter().any(|window| window.name == "status") { - tmux.create_window("status", &context.repo.root)?; - tmux.send_keys("status", 0, "wt session watch")?; + if watch { + ensure_status_window(&tmux, &context.repo.root)?; } + let windows = tmux.list_windows()?; + if windows.iter().any(|window| window.name == name) { eprintln!("Window '{}' already exists in session.", name); if inside_session { @@ -243,7 +274,7 @@ fn cmd_session_add_panes( } fn cmd_session_rm_panes(context: &SessionCmdContext<'_>, name: &str) -> Result<()> { - let tmux = TmuxManager::new(SESSION_NAME); + let tmux = panes_tmux(); if !tmux.session_exists()? { eprintln!("No session found."); From b53c5b38a9b95cc2a9f32126ee13a917e499d66a Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 08:50:29 +0300 Subject: [PATCH 11/14] refactor(session): simplify remove guidance helpers Drop unused probe state and split panes and windows hint generation into focused helpers. This keeps the remove guidance logic easier to follow while preserving the same user-facing advice and tests. --- src/session_cmd.rs | 105 ++++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 53 deletions(-) diff --git a/src/session_cmd.rs b/src/session_cmd.rs index 5bcb4b7..fc92810 100644 --- a/src/session_cmd.rs +++ b/src/session_cmd.rs @@ -54,7 +54,6 @@ struct SessionCmdContext<'a> { #[derive(Debug, Clone, Default, PartialEq, Eq)] struct SessionRmProbe { worktree_exists: bool, - panes_session_exists: bool, panes_has_worktree: bool, windows_session_name: String, windows_session_tracked: bool, @@ -563,8 +562,7 @@ fn sorted_windows_sessions(state: &SessionState) -> Vec<(&String, &WindowsSessio fn probe_session_rm(context: &SessionCmdContext<'_>, name: &str) -> Result { let manager = WorktreeManager::new(context.repo.root.clone())?; let panes_tmux = TmuxManager::new(SESSION_NAME); - let panes_session_exists = panes_tmux.session_exists()?; - let panes_has_worktree = if panes_session_exists { + let panes_has_worktree = if panes_tmux.session_exists()? { panes_tmux .list_windows()? .into_iter() @@ -585,7 +583,6 @@ fn probe_session_rm(context: &SessionCmdContext<'_>, name: &str) -> Result, name: &str) -> Result Option { - match mode { - SessionMode::Panes => { - if probe.windows_session_live { - let availability = if probe.windows_session_tracked { - "tracked in windows mode" - } else { - "available in windows mode" - }; - Some(format!( - "'{}' is {} as session '{}'. Try: wt session --mode windows rm {}", - name, availability, probe.windows_session_name, name - )) - } else if probe.worktree_exists { - Some(format!( - "Worktree '{}' exists but is not in the shared panes session. \ - Use 'wt rm {}' to remove the worktree or 'wt session --mode \ - panes add {}' to add it.", - name, name, name - )) - } else { - None - } - } - SessionMode::Windows => { - if probe.panes_has_worktree { - Some(format!( - "'{}' is in the shared panes session. Try: wt session --mode \ - panes rm {}", - name, name - )) - } else if probe.worktree_exists && !probe.windows_session_tracked { - Some(format!( - "Worktree '{}' exists but is not tracked in windows mode. Use \ - 'wt rm {}' to remove the worktree or 'wt session --mode \ - windows add {}' to add it.", - name, name, name - )) - } else { - None - } - } +fn panes_rm_hint(name: &str, probe: &SessionRmProbe) -> Option { + if probe.windows_session_live { + let availability = if probe.windows_session_tracked { + "tracked in windows mode" + } else { + "available in windows mode" + }; + Some(format!( + "'{}' is {} as session '{}'. Try: wt session --mode windows rm {}", + name, availability, probe.windows_session_name, name + )) + } else if probe.worktree_exists { + Some(format!( + "Worktree '{}' exists but is not in the shared panes session. \ + Use 'wt rm {}' to remove the worktree or 'wt session --mode \ + panes add {}' to add it.", + name, name, name + )) + } else { + None + } +} + +fn windows_rm_hint(name: &str, probe: &SessionRmProbe) -> Option { + if probe.panes_has_worktree { + Some(format!( + "'{}' is in the shared panes session. Try: wt session --mode \ + panes rm {}", + name, name + )) + } else if probe.worktree_exists && !probe.windows_session_tracked { + Some(format!( + "Worktree '{}' exists but is not tracked in windows mode. Use \ + 'wt rm {}' to remove the worktree or 'wt session --mode \ + windows add {}' to add it.", + name, name, name + )) + } else { + None } } fn print_rm_hint(mode: SessionMode, name: &str, probe: &SessionRmProbe) { - if let Some(hint) = rm_hint(mode, name, probe) { + let hint = match mode { + SessionMode::Panes => panes_rm_hint(name, probe), + SessionMode::Windows => windows_rm_hint(name, probe), + }; + + if let Some(hint) = hint { eprintln!("{}", hint); } } @@ -662,7 +661,7 @@ mod tests { probe.windows_session_live = true; assert_eq!( - rm_hint(SessionMode::Panes, "demo", &probe), + panes_rm_hint("demo", &probe), Some( "'demo' is tracked in windows mode as session 'wt-demo'. Try: wt session --mode windows rm demo" .to_string() @@ -676,7 +675,7 @@ mod tests { probe.panes_has_worktree = true; assert_eq!( - rm_hint(SessionMode::Windows, "demo", &probe), + windows_rm_hint("demo", &probe), Some( "'demo' is in the shared panes session. Try: wt session --mode panes rm demo" .to_string() @@ -690,7 +689,7 @@ mod tests { probe.worktree_exists = true; assert_eq!( - rm_hint(SessionMode::Windows, "demo", &probe), + windows_rm_hint("demo", &probe), Some( "Worktree 'demo' exists but is not tracked in windows mode. Use 'wt rm demo' to remove the worktree or 'wt session --mode windows add demo' to add it." .to_string() @@ -704,7 +703,7 @@ mod tests { probe.worktree_exists = true; assert_eq!( - rm_hint(SessionMode::Panes, "demo", &probe), + panes_rm_hint("demo", &probe), Some( "Worktree 'demo' exists but is not in the shared panes session. Use 'wt rm demo' to remove the worktree or 'wt session --mode panes add demo' to add it." .to_string() @@ -714,7 +713,7 @@ mod tests { #[test] fn test_rm_hint_is_empty_when_nothing_matches() { - assert_eq!(rm_hint(SessionMode::Panes, "demo", &probe()), None); - assert_eq!(rm_hint(SessionMode::Windows, "demo", &probe()), None); + assert_eq!(panes_rm_hint("demo", &probe()), None); + assert_eq!(windows_rm_hint("demo", &probe()), None); } } From 45e2b4b390f94f23ec6c9673927eab48e2dd3212 Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 08:52:23 +0300 Subject: [PATCH 12/14] refactor(session): factor windows layout helpers Factor the windows layout names and agent-window status lookup into small helpers so windows-mode listing and persistence stop duplicating role strings inline. Add a unit test for the layout-name helper. --- src/session_cmd.rs | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/session_cmd.rs b/src/session_cmd.rs index fc92810..2ca1ece 100644 --- a/src/session_cmd.rs +++ b/src/session_cmd.rs @@ -381,12 +381,7 @@ fn cmd_session_ls_windows() -> Result<()> { for (_, info) in sorted_windows_sessions(&state) { let tmux = TmuxManager::new(&info.session_name); let attached = tmux.is_attached().unwrap_or(false); - let agent_status = tmux - .list_windows() - .ok() - .and_then(|windows| windows.into_iter().find(|window| window.name == "agent")) - .map(|window| window.agent_status) - .unwrap_or(AgentStatus::Unknown); + let agent_status = agent_window_status(&tmux); let marker = if attached { "*" } else { " " }; println!("{} {} (agent: {})", marker, info.session_name, agent_status); } @@ -497,18 +492,12 @@ fn persist_windows_session( ) -> Result<()> { let mut state = SessionState::load()?.unwrap_or_else(|| SessionState::new(SESSION_NAME)); - let windows = if panes == 3 { - vec!["agent".to_string(), "shell".to_string(), "edit".to_string()] - } else { - vec!["agent".to_string(), "shell".to_string()] - }; - state.add_windows_session( worktree_name, WindowsSessionInfo { session_name: session_name.to_string(), worktree_path: worktree_path.to_path_buf(), - windows, + windows: windows_layout_names(panes), }, ); prune_windows_state(&mut state); @@ -559,6 +548,22 @@ fn sorted_windows_sessions(state: &SessionState) -> Vec<(&String, &WindowsSessio entries } +fn windows_layout_names(panes: u8) -> Vec { + if panes == 3 { + vec!["agent".to_string(), "shell".to_string(), "edit".to_string()] + } else { + vec!["agent".to_string(), "shell".to_string()] + } +} + +fn agent_window_status(tmux: &TmuxManager) -> AgentStatus { + tmux.list_windows() + .ok() + .and_then(|windows| windows.into_iter().find(|window| window.name == "agent")) + .map(|window| window.agent_status) + .unwrap_or(AgentStatus::Unknown) +} + fn probe_session_rm(context: &SessionCmdContext<'_>, name: &str) -> Result { let manager = WorktreeManager::new(context.repo.root.clone())?; let panes_tmux = TmuxManager::new(SESSION_NAME); @@ -716,4 +721,16 @@ mod tests { assert_eq!(panes_rm_hint("demo", &probe()), None); assert_eq!(windows_rm_hint("demo", &probe()), None); } + + #[test] + fn test_windows_layout_names_match_pane_count() { + assert_eq!( + windows_layout_names(2), + vec!["agent".to_string(), "shell".to_string()] + ); + assert_eq!( + windows_layout_names(3), + vec!["agent".to_string(), "shell".to_string(), "edit".to_string()] + ); + } } From 445f8da32f0b89f09fbe99d04c982d2bc0be850f Mon Sep 17 00:00:00 2001 From: Ukang'a Dickson Date: Wed, 22 Apr 2026 08:54:56 +0300 Subject: [PATCH 13/14] chore(git): ignore .codex workspace file --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1721eb4..c25755f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ Cargo.lock # Test worktree directories ../wt-trees/ .worktrees + +# Local workspace metadata +.codex From bb7f65bc7340571e7d87ffa53201a8d83b5302cc Mon Sep 17 00:00:00 2001 From: Peter Lubell-Doughtie <50706+pld@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:07:01 -0400 Subject: [PATCH 14/14] Update README.md --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index a356383..f3fbf0c 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,23 @@ wt session [--mode M] rm wt session [--mode M] watch [-i N] wt -d Custom worktree directory (default: .worktrees) +wt new [] Create workspace and enter it, name defaults to current branch + [-b ] Defaults to main + [--print-path] Output path only (for scripts) +wt use Enter existing workspace +wt ls Interactive workspace picker +wt rm Remove workspace (interactive if no name) +wt which Print current workspace name +wt session [--mode M] Enter tmux session(s) (see Session Mode) +wt session [--mode M] ls List workspaces in session +wt session [--mode M] add Add a named session + [-b ] Defaults to main + [--panes 2|3] Override pane count (panes mode) / window count (windows mode) + [--watch] Add status window with live agent status (panes mode only) +wt session [--mode M] rm Remove a named session +wt session [--mode M] watch [-i N] Watch all the sessions +wt -d Custom worktree directory (default: .worktrees) + M = panes | windows ```