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