diff --git a/README.md b/README.md index f3fbf0c..aff64c8 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ 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 (panes mode) / window count (windows mode) + [--agent-cmd ] override agent command for this invocation [--watch] add status window with live agent status (panes mode only) wt session [--mode M] rm wt session [--mode M] watch [-i N] @@ -146,6 +147,7 @@ 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) + [--agent-cmd ] Override agent command for this invocation [--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 @@ -162,6 +164,7 @@ Manage multiple workspaces in tmux with dedicated agent, terminal, and optional # Add workspaces to session $ wt session add feature/auth $ wt session add feature/payments +$ wt session add feature/review --agent-cmd "aider --fast" # List workspaces with agent status $ wt session ls @@ -249,6 +252,12 @@ does not cause `wt` to pick up unrelated tmux sessions. `wt session watch` and `--watch` are currently panes-mode only. +`--agent-cmd` is a one-off override for `session.agent_cmd` and only affects the +`wt session add` invocation it is passed to. It does not update config, and it +only applies when creating a new panes-mode window or windows-mode session. +Re-running `wt session add` against an existing tmux target keeps that target's +current command. + ### Configuration Create `~/.wt/config.toml` for global settings or `.wt.toml` in repo root for per-repo settings: @@ -262,7 +271,7 @@ agent_cmd = "claude" # command for agent pane/window editor_cmd = "nvim" # command for editor pane/window (when panes=3) ``` -Precedence: `--mode` / `--panes` flags > `.wt.toml` > `~/.wt/config.toml` > defaults +Precedence: `--mode` / `--panes` / `--agent-cmd` flags > `.wt.toml` > `~/.wt/config.toml` > defaults ### Navigation diff --git a/src/config.rs b/src/config.rs index 8464cca..4a35abd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -66,6 +66,16 @@ impl SessionConfig { pub fn session_name_for(&self, worktree: &str) -> String { format!("{}{}", self.session_prefix, worktree) } + + /// Return a copy of the session config with a one-off agent command + /// override applied. + pub fn with_agent_cmd_override(&self, agent_cmd_override: Option<&str>) -> Self { + let mut config = self.clone(); + if let Some(agent_cmd) = agent_cmd_override { + config.agent_cmd = agent_cmd.to_string(); + } + config + } } impl Config { @@ -212,6 +222,39 @@ panes = 3 assert_eq!(config.session.editor_cmd, "nvim"); } + #[test] + fn test_with_agent_cmd_override_uses_override_when_present() { + let config = SessionConfig::default(); + + let effective = config.with_agent_cmd_override(Some("aider --fast")); + + assert_eq!(effective.agent_cmd, "aider --fast"); + assert_eq!(effective.editor_cmd, config.editor_cmd); + assert_eq!(effective.session_prefix, config.session_prefix); + } + + #[test] + fn test_with_agent_cmd_override_trims_nothing_and_does_not_mutate_original() { + let mut config = SessionConfig::default(); + config.agent_cmd = "claude --resume".to_string(); + + let effective = config.with_agent_cmd_override(Some("aider --fast --model sonnet")); + + assert_eq!(effective.agent_cmd, "aider --fast --model sonnet"); + assert_eq!(config.agent_cmd, "claude --resume"); + } + + #[test] + fn test_with_agent_cmd_override_preserves_config_when_absent() { + let config = SessionConfig::default(); + + let effective = config.with_agent_cmd_override(None); + + assert_eq!(effective.agent_cmd, config.agent_cmd); + assert_eq!(effective.editor_cmd, config.editor_cmd); + assert_eq!(effective.session_prefix, config.session_prefix); + } + #[test] fn test_default_mode_is_panes() { let config = Config::default(); @@ -392,6 +435,65 @@ panes = 3 assert_eq!(config.session.agent_cmd, "aider"); } + #[test] + fn test_load_layered_local_agent_cmd_overrides_global_agent_cmd() { + 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 = \"claude --resume\"\npanes = 2\n" + ) + .unwrap(); + writeln!( + std::fs::File::create(&local).unwrap(), + "[session]\nagent_cmd = \"aider --fast\"\n" + ) + .unwrap(); + + let config = Config::load_layered(Some(&global), Some(&local)); + assert_eq!(config.session.agent_cmd, "aider --fast"); + assert_eq!(config.session.panes, 2); + } + + #[test] + fn test_full_precedence_chain_cli_beats_local_beats_global_beats_default() { + 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 = \"opencode\"\n" + ) + .unwrap(); + writeln!( + std::fs::File::create(&local).unwrap(), + "[session]\nagent_cmd = \"claude --resume\"\n" + ) + .unwrap(); + + let loaded = Config::load_layered(Some(&global), Some(&local)); + assert_eq!(loaded.session.agent_cmd, "claude --resume"); + + let with_override = loaded.session.with_agent_cmd_override(Some("aider --fast")); + assert_eq!(with_override.agent_cmd, "aider --fast"); + + let without_override = loaded.session.with_agent_cmd_override(None); + assert_eq!(without_override.agent_cmd, "claude --resume"); + + let global_only = Config::load_layered(Some(&global), None); + assert_eq!(global_only.session.agent_cmd, "opencode"); + + let no_files = Config::load_layered(None, None); + assert_eq!(no_files.session.agent_cmd, default_agent_cmd()); + } + #[test] fn test_load_layered_returns_default_when_both_missing() { let config = Config::load_layered(None, None); diff --git a/src/main.rs b/src/main.rs index 31628ea..af8289b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -424,3 +424,61 @@ fn cmd_use(config: &RepoConfig, name: Option) -> Result<()> { spawn_wt_shell(&wt_info.path, &wt_info.task_id, &wt_info.branch)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cli_session_add_without_agent_cmd_leaves_override_unset() { + let cli = Cli::try_parse_from(["wt", "session", "add", "demo"]) + .expect("session add should parse without --agent-cmd"); + + match cli.command { + Commands::Session { + action: Some(SessionAction::Add { agent_cmd, .. }), + .. + } => { + assert_eq!(agent_cmd, None); + } + _ => panic!("expected session add command"), + } + } + + #[test] + fn test_cli_parses_session_add_agent_cmd() { + let cli = Cli::try_parse_from([ + "wt", + "session", + "--mode", + "windows", + "add", + "demo", + "--agent-cmd", + "aider --fast", + ]) + .expect("session add should accept --agent-cmd"); + + match cli.command { + Commands::Session { + mode, + action: + Some(SessionAction::Add { + name, + base, + panes, + agent_cmd, + watch, + }), + } => { + assert_eq!(mode, Some(SessionMode::Windows)); + assert_eq!(name, "demo"); + assert_eq!(base, "main"); + assert_eq!(panes, None); + assert_eq!(agent_cmd.as_deref(), Some("aider --fast")); + assert!(!watch); + } + _ => panic!("expected session add command"), + } + } +} diff --git a/src/session_cmd.rs b/src/session_cmd.rs index dc2cfad..d765847 100644 --- a/src/session_cmd.rs +++ b/src/session_cmd.rs @@ -28,6 +28,9 @@ pub(crate) enum SessionAction { /// Override pane count (2 or 3) #[arg(long)] panes: Option, + /// Override agent command for this invocation when creating the agent pane/window + #[arg(long, value_name = "CMD")] + agent_cmd: Option, /// Create status window with live agent status #[arg(long)] watch: bool, @@ -71,6 +74,15 @@ impl<'a> SessionCmdContext<'a> { fn effective_panes(&self, panes_override: Option) -> u8 { self.config.effective_panes(panes_override) } + + fn effective_session_config( + &self, + agent_cmd_override: Option<&str>, + ) -> wt::config::SessionConfig { + self.config + .session + .with_agent_cmd_override(agent_cmd_override) + } } pub(crate) fn run_session( @@ -104,10 +116,15 @@ pub(crate) fn run_session( name, base, panes, + agent_cmd, 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), + SessionMode::Panes => { + cmd_session_add_panes(&context, &name, &base, panes, agent_cmd.as_deref(), watch) + } + SessionMode::Windows => { + cmd_session_add_windows(&context, &name, &base, panes, agent_cmd.as_deref(), watch) + } }, Some(SessionAction::Rm { name }) => match context.mode { SessionMode::Panes => cmd_session_rm_panes(&context, &name), @@ -237,18 +254,37 @@ fn cmd_session_ls(tmux: &TmuxManager) -> Result<()> { Ok(()) } +fn note_agent_cmd_override(agent_cmd: Option<&str>) { + if let Some(cmd) = agent_cmd { + eprintln!("Using one-off agent command override: {}", cmd); + } +} + +fn note_agent_cmd_ignored(agent_cmd: Option<&str>, creating: &str, existing: &str) { + if agent_cmd.is_some() { + eprintln!( + "Note: --agent-cmd only applies when creating a new {}; existing {} keep their current command.", + creating, existing + ); + } +} + fn cmd_session_add_panes( context: &SessionCmdContext<'_>, name: &str, base: &str, panes_override: Option, + agent_cmd_override: Option<&str>, watch: bool, ) -> Result<()> { let tmux = panes_tmux(); let worktree_path = ensure_worktree_path(context, name, base)?; let panes = context.effective_panes(panes_override); + let session_config = context.effective_session_config(agent_cmd_override); let inside_session = tmux.is_inside_session(); + note_agent_cmd_override(agent_cmd_override); + if !tmux.session_exists()? { eprintln!("Creating tmux session: {}", SESSION_NAME); if watch { @@ -257,7 +293,7 @@ fn cmd_session_add_panes( } else { tmux.create_session(name, &worktree_path)?; } - tmux.setup_worktree_layout(name, &worktree_path, panes, &context.config.session)?; + tmux.setup_worktree_layout(name, &worktree_path, panes, &session_config)?; } else { if watch { ensure_status_window(&tmux, &context.repo.root)?; @@ -267,13 +303,14 @@ fn cmd_session_add_panes( if windows.iter().any(|window| window.name == name) { eprintln!("Window '{}' already exists in session.", name); + note_agent_cmd_ignored(agent_cmd_override, "agent pane", "windows"); 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)?; + tmux.setup_worktree_layout(name, &worktree_path, panes, &session_config)?; } } @@ -339,6 +376,7 @@ fn cmd_session_add_windows( name: &str, base: &str, panes_override: Option, + agent_cmd_override: Option<&str>, watch: bool, ) -> Result<()> { if watch { @@ -347,18 +385,22 @@ fn cmd_session_add_windows( 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 session_config = context.effective_session_config(agent_cmd_override); + let session_name = session_config.session_name_for(name); let tmux = TmuxManager::new(&session_name); + note_agent_cmd_override(agent_cmd_override); + if tmux.session_exists()? { eprintln!("Using existing session: {}", session_name); + note_agent_cmd_ignored(agent_cmd_override, "agent window", "sessions"); } 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)?; + tmux.setup_worktree_windows(&worktree_path, panes, &session_config)?; } persist_windows_session(name, &session_name, &worktree_path, panes)?; @@ -671,6 +713,7 @@ fn print_rm_hint(mode: SessionMode, name: &str, probe: &SessionRmProbe) { #[cfg(test)] mod tests { use super::*; + use wt::config::Config; fn probe() -> SessionRmProbe { SessionRmProbe { @@ -742,6 +785,68 @@ mod tests { assert_eq!(windows_rm_hint("demo", &probe()), None); } + #[test] + fn test_effective_session_config_uses_agent_cmd_override() { + let mut config = Config::default(); + config.session.agent_cmd = "claude --resume".to_string(); + config.session.editor_cmd = "hx".to_string(); + + let context = SessionCmdContext { + repo: &crate::RepoConfig { + root: std::path::PathBuf::from("/tmp/repo"), + worktree_dir: std::path::PathBuf::from("/tmp/repo/.worktrees"), + }, + config, + mode: SessionMode::Panes, + }; + + let effective = context.effective_session_config(Some("aider --fast")); + + assert_eq!(effective.agent_cmd, "aider --fast"); + assert_eq!(effective.editor_cmd, "hx"); + } + + #[test] + fn test_effective_session_config_override_is_one_off() { + let mut config = Config::default(); + config.session.agent_cmd = "claude --resume".to_string(); + config.session.editor_cmd = "hx".to_string(); + + let original_agent_cmd = config.session.agent_cmd.clone(); + let context = SessionCmdContext { + repo: &crate::RepoConfig { + root: std::path::PathBuf::from("/tmp/repo"), + worktree_dir: std::path::PathBuf::from("/tmp/repo/.worktrees"), + }, + config, + mode: SessionMode::Windows, + }; + + let effective = context.effective_session_config(Some("aider --fast")); + + assert_eq!(effective.agent_cmd, "aider --fast"); + assert_eq!(context.config.session.agent_cmd, original_agent_cmd); + } + + #[test] + fn test_effective_session_config_uses_loaded_value_when_no_override() { + let mut config = Config::default(); + config.session.agent_cmd = "opencode".to_string(); + + let context = SessionCmdContext { + repo: &crate::RepoConfig { + root: std::path::PathBuf::from("/tmp/repo"), + worktree_dir: std::path::PathBuf::from("/tmp/repo/.worktrees"), + }, + config, + mode: SessionMode::Windows, + }; + + let effective = context.effective_session_config(None); + + assert_eq!(effective.agent_cmd, "opencode"); + } + #[test] fn test_windows_layout_names_match_pane_count() { assert_eq!( diff --git a/tests/tmux_test.rs b/tests/tmux_test.rs index 5657eb7..27e511f 100644 --- a/tests/tmux_test.rs +++ b/tests/tmux_test.rs @@ -16,6 +16,18 @@ fn setup_test_repo() -> (TempDir, PathBuf) { .output() .expect("Failed to init git repo"); + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&repo_path) + .output() + .expect("Failed to set git user.email"); + + Command::new("git") + .args(["config", "user.name", "Test User"]) + .current_dir(&repo_path) + .output() + .expect("Failed to set git user.name"); + Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(&repo_path)