Skip to main content

git_worktree_manager/operations/
ai_tools.rs

1/// AI tool integration operations.
2///
3/// Mirrors src/git_worktree_manager/operations/ai_tools.py (752 lines).
4/// Handles launching AI coding assistants in various terminal environments.
5use std::collections::HashMap;
6use std::path::Path;
7
8use console::style;
9
10use crate::config::{
11    self, get_ai_tool_command, get_ai_tool_resume_command, is_claude_tool, parse_term_option,
12};
13use crate::constants::{
14    format_config_key, LaunchMethod, CONFIG_KEY_BASE_BRANCH, MAX_SESSION_NAME_LENGTH,
15};
16use crate::error::Result;
17use crate::git;
18use crate::hooks;
19use crate::session;
20
21use super::helpers::resolve_worktree_target;
22use super::launchers;
23
24/// Launch AI coding assistant in the specified directory.
25pub fn launch_ai_tool(
26    path: &Path,
27    term: Option<&str>,
28    resume: bool,
29    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 resume {
37        get_ai_tool_resume_command()?
38    } else {
39        // Smart --continue for Claude
40        if is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(path) {
41            eprintln!("Found existing Claude session, using --continue");
42            get_ai_tool_resume_command()?
43        } else {
44            get_ai_tool_command()?
45        }
46    };
47
48    if ai_cmd_parts.is_empty() {
49        return Ok(());
50    }
51
52    let ai_tool_name = &ai_cmd_parts[0];
53
54    if !git::has_command(ai_tool_name) {
55        println!(
56            "{} {} not detected. Install it or update config with 'cw config set ai-tool <tool>'.\n",
57            style("!").yellow(),
58            ai_tool_name,
59        );
60        return Ok(());
61    }
62
63    // Build shell command string
64    let cmd = shell_quote_join(&ai_cmd_parts);
65
66    // Dispatch to launcher
67    match method {
68        LaunchMethod::Foreground => {
69            println!(
70                "{}\n",
71                style(format!("Starting {} (Ctrl+C to exit)...", ai_tool_name)).cyan()
72            );
73            launchers::foreground::run(path, &cmd);
74        }
75        LaunchMethod::Detach => {
76            launchers::detached::run(path, &cmd);
77            println!(
78                "{} {} detached (survives terminal close)\n",
79                style("*").green().bold(),
80                ai_tool_name
81            );
82        }
83        // iTerm
84        LaunchMethod::ItermWindow => launchers::iterm::launch_window(path, &cmd, ai_tool_name)?,
85        LaunchMethod::ItermTab => launchers::iterm::launch_tab(path, &cmd, ai_tool_name)?,
86        LaunchMethod::ItermPaneH => launchers::iterm::launch_pane(path, &cmd, ai_tool_name, true)?,
87        LaunchMethod::ItermPaneV => launchers::iterm::launch_pane(path, &cmd, ai_tool_name, false)?,
88        // tmux
89        LaunchMethod::Tmux => {
90            let sn = session_name.unwrap_or_else(|| generate_session_name(path));
91            launchers::tmux::launch_session(path, &cmd, ai_tool_name, &sn)?;
92        }
93        LaunchMethod::TmuxWindow => launchers::tmux::launch_window(path, &cmd, ai_tool_name)?,
94        LaunchMethod::TmuxPaneH => launchers::tmux::launch_pane(path, &cmd, ai_tool_name, true)?,
95        LaunchMethod::TmuxPaneV => launchers::tmux::launch_pane(path, &cmd, ai_tool_name, false)?,
96        // Zellij
97        LaunchMethod::Zellij => {
98            let sn = session_name.unwrap_or_else(|| generate_session_name(path));
99            launchers::zellij::launch_session(path, &cmd, ai_tool_name, &sn)?;
100        }
101        LaunchMethod::ZellijTab => launchers::zellij::launch_tab(path, &cmd, ai_tool_name)?,
102        LaunchMethod::ZellijPaneH => {
103            launchers::zellij::launch_pane(path, &cmd, ai_tool_name, true)?
104        }
105        LaunchMethod::ZellijPaneV => {
106            launchers::zellij::launch_pane(path, &cmd, ai_tool_name, false)?
107        }
108        // WezTerm
109        LaunchMethod::WeztermWindow => launchers::wezterm::launch_window(path, &cmd, ai_tool_name)?,
110        LaunchMethod::WeztermTab => launchers::wezterm::launch_tab(path, &cmd, ai_tool_name)?,
111        LaunchMethod::WeztermPaneH => {
112            launchers::wezterm::launch_pane(path, &cmd, ai_tool_name, true)?
113        }
114        LaunchMethod::WeztermPaneV => {
115            launchers::wezterm::launch_pane(path, &cmd, ai_tool_name, false)?
116        }
117    }
118
119    Ok(())
120}
121
122/// Resume AI work in a worktree with context restoration.
123pub fn resume_worktree(worktree: Option<&str>, term: Option<&str>) -> Result<()> {
124    let (worktree_path, branch_name, worktree_repo) = resolve_worktree_target(worktree, None)?;
125
126    // Pre-resume hooks
127    let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch_name);
128    let base_branch = git::get_config(&base_key, Some(&worktree_repo)).unwrap_or_default();
129
130    let mut hook_ctx = HashMap::new();
131    hook_ctx.insert("branch".into(), branch_name.clone());
132    hook_ctx.insert("base_branch".into(), base_branch);
133    hook_ctx.insert(
134        "worktree_path".into(),
135        worktree_path.to_string_lossy().to_string(),
136    );
137    hook_ctx.insert(
138        "repo_path".into(),
139        worktree_repo.to_string_lossy().to_string(),
140    );
141    hook_ctx.insert("event".into(), "resume.pre".into());
142    hook_ctx.insert("operation".into(), "resume".into());
143    hooks::run_hooks(
144        "resume.pre",
145        &hook_ctx,
146        Some(&worktree_path),
147        Some(&worktree_repo),
148    )?;
149
150    // Change directory if specified
151    if worktree.is_some() {
152        let _ = std::env::set_current_dir(&worktree_path);
153        println!(
154            "{}\n",
155            style(format!("Switched to worktree: {}", worktree_path.display())).dim()
156        );
157    }
158
159    // Check for existing session
160    let has_session =
161        is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(&worktree_path);
162
163    if has_session {
164        println!(
165            "{} Found session for branch: {}",
166            style("*").green(),
167            style(&branch_name).bold()
168        );
169
170        if let Some(metadata) = session::load_session_metadata(&branch_name) {
171            println!("  AI tool: {}", style(&metadata.ai_tool).dim());
172            println!("  Last updated: {}", style(&metadata.updated_at).dim());
173        }
174
175        if let Some(context) = session::load_context(&branch_name) {
176            println!("\n{}", style("Previous context:").cyan());
177            println!("{}", style(&context).dim());
178        }
179        println!();
180    } else {
181        println!(
182            "{} No previous session found for branch: {}",
183            style("i").yellow(),
184            style(&branch_name).bold()
185        );
186        println!("{}\n", style("Starting fresh session...").dim());
187    }
188
189    // Save metadata and launch
190    let ai_cmd = if has_session {
191        get_ai_tool_resume_command()?
192    } else {
193        get_ai_tool_command()?
194    };
195
196    if !ai_cmd.is_empty() {
197        let ai_tool_name = &ai_cmd[0];
198        let _ = session::save_session_metadata(
199            &branch_name,
200            ai_tool_name,
201            &worktree_path.to_string_lossy(),
202        );
203
204        if has_session {
205            println!(
206                "{} {}\n",
207                style(format!("Resuming {} in:", ai_tool_name)).cyan(),
208                worktree_path.display()
209            );
210        } else {
211            println!(
212                "{} {}\n",
213                style(format!("Starting {} in:", ai_tool_name)).cyan(),
214                worktree_path.display()
215            );
216        }
217
218        launch_ai_tool(&worktree_path, term, has_session, None)?;
219    }
220
221    // Post-resume hooks
222    hook_ctx.insert("event".into(), "resume.post".into());
223    let _ = hooks::run_hooks(
224        "resume.post",
225        &hook_ctx,
226        Some(&worktree_path),
227        Some(&worktree_repo),
228    );
229
230    Ok(())
231}
232
233/// Generate a session name from path with length limit.
234fn generate_session_name(path: &Path) -> String {
235    let config = config::load_config().unwrap_or_default();
236    let prefix = &config.launch.tmux_session_prefix;
237    let dir_name = path
238        .file_name()
239        .map(|n| n.to_string_lossy().to_string())
240        .unwrap_or_else(|| "worktree".to_string());
241
242    let name = format!("{}-{}", prefix, dir_name);
243    if name.len() > MAX_SESSION_NAME_LENGTH {
244        name[..MAX_SESSION_NAME_LENGTH].to_string()
245    } else {
246        name
247    }
248}
249
250/// Shell-quote and join command parts.
251fn shell_quote_join(parts: &[String]) -> String {
252    parts
253        .iter()
254        .map(|p| {
255            if p.contains(char::is_whitespace) || p.contains('\'') || p.contains('"') {
256                format!("'{}'", p.replace('\'', "'\\''"))
257            } else {
258                p.clone()
259            }
260        })
261        .collect::<Vec<_>>()
262        .join(" ")
263}