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