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 diff --git a/README.md b/README.md index d50ab30..f3fbf0c 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,39 @@ 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) + +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 ``` ## 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 +165,38 @@ $ 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 the default panes session $ wt session -# Remove workspace from session +# Remove a panes-mode workspace $ wt session rm feature/auth -# Commands work from inside the session too -# (switches windows instead of attaching) +# Enter or remove a windows-mode worktree session +$ wt session --mode windows +$ wt session --mode windows rm feature/review ``` -### Status Window +`--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. -Use `--watch` to add a status window showing all workspaces and their agent status: +### Layout Modes -```bash -wt session add feature/auth --watch -``` +`wt` supports two tmux layouts. -- `●` green = agent active (running a command) -- `○` gray = agent idle (at shell prompt) +#### Panes mode (default) -Or run `wt session watch` manually in any pane. +All worktrees live in one shared tmux session named `wt`, one window per +worktree, split into 2 or 3 panes. -### Pane Layouts - -**2 panes (default):** +**2 panes:** ``` +---------------------------+---------------------------+ | | | @@ -195,26 +217,65 @@ 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 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: 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"); + } } 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..2ca1ece --- /dev/null +++ b/src/session_cmd.rs @@ -0,0 +1,736 @@ +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"; +const NO_WINDOWS_SESSIONS_MSG: &str = + "No worktree sessions found. Use 'wt session add ' to create one."; + +#[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, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct SessionRmProbe { + worktree_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); + 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 => { + let tmux = panes_tmux(); + cmd_session_attach(&tmux) + } + SessionMode::Windows => cmd_session_attach_windows(), + }, + Some(SessionAction::Ls) => match context.mode { + SessionMode::Panes => { + let tmux = panes_tmux(); + cmd_session_ls(&tmux) + } + 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(&context, &name), + SessionMode::Windows => cmd_session_rm_windows(&context, &name), + }, + Some(SessionAction::Watch { interval }) => match context.mode { + SessionMode::Panes => { + let tmux = panes_tmux(); + cmd_session_watch(&tmux, 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 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."); + 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 = 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(); + + if !tmux.session_exists()? { + eprintln!("Creating tmux session: {}", SESSION_NAME); + if 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 { + 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 { + 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(context: &SessionCmdContext<'_>, name: &str) -> Result<()> { + let tmux = panes_tmux(); + + 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(()); + } + + 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_windows_state_or_report_empty()? else { + 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_windows_state_or_report_empty()? else { + 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 = agent_window_status(&tmux); + 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 probe = probe_session_rm(context, name)?; + 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); + } + + 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 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(()) +} + +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)); + + state.add_windows_session( + worktree_name, + WindowsSessionInfo { + session_name: session_name.to_string(), + worktree_path: worktree_path.to_path_buf(), + windows: windows_layout_names(panes), + }, + ); + prune_windows_state(&mut state); + state.save() +} + +fn load_windows_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 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); + } +} + +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 +} + +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); + let panes_has_worktree = if panes_tmux.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_has_worktree, + windows_session_name, + windows_session_tracked, + windows_session_live, + }) +} + +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) { + 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); + } +} + +#[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!( + 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() + ) + ); + } + + #[test] + fn test_rm_hint_points_windows_removal_to_panes_mode() { + let mut probe = probe(); + probe.panes_has_worktree = true; + + assert_eq!( + windows_rm_hint("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!( + 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() + ) + ); + } + + #[test] + fn test_rm_hint_explains_missing_panes_membership() { + let mut probe = probe(); + probe.worktree_exists = true; + + assert_eq!( + 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() + ) + ); + } + + #[test] + fn test_rm_hint_is_empty_when_nothing_matches() { + 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()] + ); + } +} diff --git a/src/tmux_manager.rs b/src/tmux_manager.rs index c69c48b..ca370b3 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,16 +146,70 @@ impl TmuxManager { if !status.success() { anyhow::bail!("Failed to attach to session"); } + + Ok(()) + } + + /// 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(()) } - /// Create a new window in the session + /// 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 target = self.next_window_target(); let output = Command::new("tmux") .args([ "new-window", "-t", - &self.session_name, + &target, "-n", name, "-c", @@ -157,7 +236,12 @@ impl TmuxManager { Ok(index) } - /// Kill a window by name + /// 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); let output = Command::new("tmux") @@ -171,10 +255,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 +273,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 +295,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 +319,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 +338,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 +348,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 +369,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 +394,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 +412,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 +430,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 +442,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 +489,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); } @@ -416,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:"); + } } 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]