Skip to main content

git_worktree_manager/operations/
ai_tools.rs

1/// AI tool integration operations.
2///
3/// Handles launching AI coding assistants in various terminal environments.
4use std::path::Path;
5
6use console::style;
7
8use crate::config::{
9    self, get_ai_tool_command, get_ai_tool_resume_command, is_claude_tool, parse_term_option,
10};
11use crate::constants::{
12    format_config_key, LaunchMethod, CONFIG_KEY_BASE_BRANCH, MAX_SESSION_NAME_LENGTH,
13};
14use crate::error::Result;
15use crate::git;
16use crate::hooks;
17use crate::messages;
18use crate::session;
19
20use super::helpers::{build_hook_context, resolve_worktree_target};
21use super::launchers;
22
23/// Launch AI coding assistant in the specified directory.
24pub fn launch_ai_tool(
25    path: &Path,
26    term: Option<&str>,
27    resume: bool,
28    prompt: Option<&str>,
29    initial_prompt: Option<&str>,
30) -> Result<()> {
31    let (method, session_name) = parse_term_option(term)?;
32
33    // Determine command
34    let ai_cmd_parts = if let Some(p) = prompt {
35        config::get_ai_tool_merge_command(p)?
36    } else if let Some(ip) = initial_prompt {
37        config::get_ai_tool_delegate_command(ip)?
38    } else if resume {
39        get_ai_tool_resume_command()?
40    } else {
41        // Smart --continue for Claude
42        if is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(path) {
43            eprintln!("Found existing Claude session, using --continue");
44            get_ai_tool_resume_command()?
45        } else {
46            get_ai_tool_command()?
47        }
48    };
49
50    if ai_cmd_parts.is_empty() {
51        return Ok(());
52    }
53
54    let ai_tool_name = &ai_cmd_parts[0];
55
56    if !git::has_command(ai_tool_name) {
57        println!(
58            "{} {} not detected. Install it or update config with 'cw config set ai-tool <tool>'.\n",
59            style("!").yellow(),
60            ai_tool_name,
61        );
62        return Ok(());
63    }
64
65    // Build shell command string
66    let cmd = shell_quote_join(&ai_cmd_parts);
67
68    // Dispatch to launcher. Foreground blocks on the AI process, so an RAII
69    // lockfile spans the full session. Other launchers detach to a terminal
70    // emulator / multiplexer and return immediately, so a lock acquired here
71    // would be released before the AI session really starts — for those we
72    // rely on process-cwd scanning in `busy::detect_busy` instead.
73    match method {
74        LaunchMethod::Foreground => {
75            println!(
76                "{}\n",
77                style(messages::starting_ai_tool_foreground(ai_tool_name)).cyan()
78            );
79            // `_session_lock` binding is intentional: RAII guard lives for
80            // the foreground AI process lifetime; dropped on return.
81            let _session_lock = match crate::operations::lockfile::acquire(path, ai_tool_name) {
82                Ok(lock) => Some(lock),
83                Err(err @ crate::operations::lockfile::AcquireError::ForeignLock(_)) => {
84                    return Err(crate::error::CwError::Other(format!(
85                        "{}; exit that session first",
86                        err
87                    )));
88                }
89                Err(e) => {
90                    eprintln!(
91                        "{} could not write session lock: {}",
92                        style("warning:").yellow(),
93                        e
94                    );
95                    None
96                }
97            };
98            launchers::foreground::run(path, &cmd);
99        }
100        LaunchMethod::Detach => {
101            launchers::detached::run(path, &cmd);
102            println!(
103                "{} {} detached (survives terminal close)\n",
104                style("*").green().bold(),
105                ai_tool_name
106            );
107        }
108        // iTerm
109        LaunchMethod::ItermWindow => launchers::iterm::launch_window(path, &cmd, ai_tool_name)?,
110        LaunchMethod::ItermTab => launchers::iterm::launch_tab(path, &cmd, ai_tool_name)?,
111        LaunchMethod::ItermPaneH => launchers::iterm::launch_pane(path, &cmd, ai_tool_name, true)?,
112        LaunchMethod::ItermPaneV => launchers::iterm::launch_pane(path, &cmd, ai_tool_name, false)?,
113        // tmux
114        LaunchMethod::Tmux => {
115            let sn = session_name.unwrap_or_else(|| generate_session_name(path));
116            launchers::tmux::launch_session(path, &cmd, ai_tool_name, &sn)?;
117        }
118        LaunchMethod::TmuxWindow => launchers::tmux::launch_window(path, &cmd, ai_tool_name)?,
119        LaunchMethod::TmuxPaneH => launchers::tmux::launch_pane(path, &cmd, ai_tool_name, true)?,
120        LaunchMethod::TmuxPaneV => launchers::tmux::launch_pane(path, &cmd, ai_tool_name, false)?,
121        // Zellij
122        LaunchMethod::Zellij => {
123            let sn = session_name.unwrap_or_else(|| generate_session_name(path));
124            launchers::zellij::launch_session(path, &cmd, ai_tool_name, &sn)?;
125        }
126        LaunchMethod::ZellijTab => launchers::zellij::launch_tab(path, &cmd, ai_tool_name)?,
127        LaunchMethod::ZellijPaneH => {
128            launchers::zellij::launch_pane(path, &cmd, ai_tool_name, true)?
129        }
130        LaunchMethod::ZellijPaneV => {
131            launchers::zellij::launch_pane(path, &cmd, ai_tool_name, false)?
132        }
133        // WezTerm
134        LaunchMethod::WeztermWindow => launchers::wezterm::launch_window(path, &cmd, ai_tool_name)?,
135        LaunchMethod::WeztermTab => launchers::wezterm::launch_tab(path, &cmd, ai_tool_name)?,
136        LaunchMethod::WeztermTabBg => launchers::wezterm::launch_tab_bg(path, &cmd, ai_tool_name)?,
137        LaunchMethod::WeztermPaneH => {
138            launchers::wezterm::launch_pane(path, &cmd, ai_tool_name, true)?
139        }
140        LaunchMethod::WeztermPaneV => {
141            launchers::wezterm::launch_pane(path, &cmd, ai_tool_name, false)?
142        }
143    }
144
145    Ok(())
146}
147
148/// Resume AI work in a worktree with context restoration.
149pub fn resume_worktree(
150    worktree: Option<&str>,
151    term: Option<&str>,
152    lookup_mode: Option<&str>,
153) -> Result<()> {
154    let resolved = resolve_worktree_target(worktree, lookup_mode)?;
155    let worktree_path = resolved.path;
156    let branch_name = resolved.branch;
157    let worktree_repo = resolved.repo;
158
159    // Pre-resume hooks
160    let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch_name);
161    let base_branch = git::get_config(&base_key, Some(&worktree_repo)).unwrap_or_default();
162
163    let mut hook_ctx = build_hook_context(
164        &branch_name,
165        &base_branch,
166        &worktree_path,
167        &worktree_repo,
168        "resume.pre",
169        "resume",
170    );
171    hooks::run_hooks(
172        "resume.pre",
173        &hook_ctx,
174        Some(&worktree_path),
175        Some(&worktree_repo),
176    )?;
177
178    // Change directory if specified
179    if worktree.is_some() {
180        let _ = std::env::set_current_dir(&worktree_path);
181        println!(
182            "{}\n",
183            style(messages::switched_to_worktree(&worktree_path)).dim()
184        );
185    }
186
187    // Check for existing session
188    let has_session =
189        is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(&worktree_path);
190
191    if has_session {
192        println!(
193            "{} Found session for branch: {}",
194            style("*").green(),
195            style(&branch_name).bold()
196        );
197
198        if let Some(metadata) = session::load_session_metadata(&branch_name) {
199            println!("  AI tool: {}", style(&metadata.ai_tool).dim());
200            println!("  Last updated: {}", style(&metadata.updated_at).dim());
201        }
202
203        if let Some(context) = session::load_context(&branch_name) {
204            println!("\n{}", style("Previous context:").cyan());
205            println!("{}", style(&context).dim());
206        }
207        println!();
208    } else {
209        println!(
210            "{} No previous session found for branch: {}",
211            style("i").yellow(),
212            style(&branch_name).bold()
213        );
214        println!("{}\n", style("Starting fresh session...").dim());
215    }
216
217    // Save metadata and launch
218    let ai_cmd = if has_session {
219        get_ai_tool_resume_command()?
220    } else {
221        get_ai_tool_command()?
222    };
223
224    if !ai_cmd.is_empty() {
225        let ai_tool_name = &ai_cmd[0];
226        let _ = session::save_session_metadata(
227            &branch_name,
228            ai_tool_name,
229            &worktree_path.to_string_lossy(),
230        );
231
232        if has_session {
233            println!(
234                "{} {}\n",
235                style(messages::resuming_ai_tool_in(ai_tool_name)).cyan(),
236                worktree_path.display()
237            );
238        } else {
239            println!(
240                "{} {}\n",
241                style(messages::starting_ai_tool_in(ai_tool_name)).cyan(),
242                worktree_path.display()
243            );
244        }
245
246        launch_ai_tool(&worktree_path, term, has_session, None, None)?;
247    }
248
249    // Post-resume hooks
250    hook_ctx.insert("event".into(), "resume.post".into());
251    let _ = hooks::run_hooks(
252        "resume.post",
253        &hook_ctx,
254        Some(&worktree_path),
255        Some(&worktree_repo),
256    );
257
258    Ok(())
259}
260
261/// Generate a session name from path with length limit.
262fn generate_session_name(path: &Path) -> String {
263    let config = config::load_config().unwrap_or_default();
264    let prefix = &config.launch.tmux_session_prefix;
265    let dir_name = path
266        .file_name()
267        .map(|n| n.to_string_lossy().to_string())
268        .unwrap_or_else(|| "worktree".to_string());
269
270    let name = format!("{}-{}", prefix, dir_name);
271    if name.len() > MAX_SESSION_NAME_LENGTH {
272        name[..MAX_SESSION_NAME_LENGTH].to_string()
273    } else {
274        name
275    }
276}
277
278/// Shell-quote and join command parts.
279fn shell_quote_join(parts: &[String]) -> String {
280    parts
281        .iter()
282        .map(|p| {
283            if p.contains(char::is_whitespace) || p.contains('\'') || p.contains('"') {
284                format!("'{}'", p.replace('\'', "'\\''"))
285            } else {
286                p.clone()
287            }
288        })
289        .collect::<Vec<_>>()
290        .join(" ")
291}