Skip to main content

vtcode_core/terminal_setup/terminals/
iterm2.rs

1//! iTerm2 configuration instruction generator.
2//!
3//! Most iTerm2 settings live in plist files, which are complex to modify
4//! programmatically, so this module generates manual setup instructions for
5//! them. The tab icon is the exception: iTerm2 loads Dynamic Profiles from
6//! plain JSON files with no restart, so the icon profile below is fully
7//! installable from code.
8
9use std::path::{Path, PathBuf};
10
11use crate::terminal_setup::detector::TerminalType;
12use crate::terminal_setup::features::multiline;
13use anyhow::{Context, Result};
14use vtcode_commons::VtCodePaths;
15use vtcode_commons::terminal_detection::{
16    ITERM2_DYNAMIC_PROFILE_FILENAME, ITERM2_ICON_MODE_CUSTOM, ITERM2_PROFILE_NAME, installed_iterm2_profile_path,
17    iterm2_dynamic_profiles_dir,
18};
19
20/// Generate iTerm2 setup instructions (manual configuration required)
21pub fn generate_config(features: &[crate::terminal_setup::detector::TerminalFeature]) -> Result<String> {
22    let mut instructions = vec![
23        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string(),
24        "  iTerm2 Manual Configuration Instructions".to_string(),
25        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string(),
26        String::new(),
27        "iTerm2 requires manual configuration via the GUI.".to_string(),
28        "Follow these steps to configure each feature:".to_string(),
29        String::new(),
30    ];
31
32    for (i, feature) in features.iter().enumerate() {
33        match feature {
34            crate::terminal_setup::detector::TerminalFeature::Multiline => {
35                instructions.push(format!("{}. MULTILINE INPUT (Shift+Enter)", i + 1));
36                instructions.push(String::new());
37                let multiline_instructions = multiline::generate_config(TerminalType::ITerm2)?;
38                instructions.push(multiline_instructions);
39                instructions.push(String::new());
40            }
41            crate::terminal_setup::detector::TerminalFeature::CopyPaste => {
42                instructions.push(format!("{}. COPY/PASTE INTEGRATION", i + 1));
43                instructions.push(String::new());
44                instructions.push("1. Open iTerm2 Preferences (Cmd+,)".to_string());
45                instructions.push("2. Go to General → Selection".to_string());
46                instructions.push("3. Enable 'Copy to pasteboard on selection'".to_string());
47                instructions.push("4. Go to Pointer tab".to_string());
48                instructions.push("5. Set middle-click action to 'Paste from Clipboard'".to_string());
49                instructions.push(String::new());
50            }
51            crate::terminal_setup::detector::TerminalFeature::ShellIntegration => {
52                instructions.push(format!("{}. SHELL INTEGRATION", i + 1));
53                instructions.push(String::new());
54                instructions.push("1. Open iTerm2 Preferences (Cmd+,)".to_string());
55                instructions.push("2. Go to Profiles → General".to_string());
56                instructions.push("3. Under 'Command', select your shell".to_string());
57                instructions.push("4. iTerm2's shell integration will auto-install on first launch".to_string());
58                instructions.push("5. Or manually install: curl -L https://iterm2.com/shell_integration/install_shell_integration.sh | bash".to_string());
59                instructions.push(String::new());
60            }
61            crate::terminal_setup::detector::TerminalFeature::ThemeSync => {
62                instructions.push(format!("{}. THEME SYNCHRONIZATION", i + 1));
63                instructions.push(String::new());
64                instructions.push("1. Open iTerm2 Preferences (Cmd+,)".to_string());
65                instructions.push("2. Go to Profiles → Colors".to_string());
66                instructions.push("3. Choose a color preset or customize manually".to_string());
67                instructions.push("4. VT Code theme colors can be manually configured here".to_string());
68                instructions.push(String::new());
69            }
70            crate::terminal_setup::detector::TerminalFeature::Notifications => {
71                instructions.push(format!("{}. SYSTEM NOTIFICATIONS", i + 1));
72                instructions.push(String::new());
73                instructions.push("1. Open iTerm2 Preferences (Cmd+,)".to_string());
74                instructions.push("2. Navigate to Profiles → Terminal".to_string());
75                instructions.push(
76                    "3. Enable 'Silence bell' and Filter Alerts → 'Send escape sequence-generated alerts'".to_string(),
77                );
78                instructions.push("4. Set your preferred notification delay".to_string());
79                instructions.push(
80                    "5. For shell integration notifications, consider using tools like terminal-notifier".to_string(),
81                );
82                instructions.push(String::new());
83            }
84        }
85    }
86
87    instructions.push("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string());
88    instructions.push("After configuration, restart iTerm2 for changes to take effect.".to_string());
89    instructions.push("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string());
90
91    Ok(instructions.join("\n"))
92}
93
94/// Stable identity of the shipped iTerm2 profile so reinstalls overwrite
95/// instead of duplicating it.
96pub const PROFILE_GUID: &str = "1FC21C70-F2B1-4F0F-BB03-D1AE12EF900E";
97
98/// Filename of the installed profile artwork under the data root.
99pub const PROFILE_ICON_FILENAME: &str = "vtcode-profile-120.png";
100
101/// Embedded 120px profile artwork so installed binaries work without a
102/// repo checkout. Resolved through the `build.rs` embedded-assets pipeline so
103/// the published crate compiles without reaching outside its package root.
104const PROFILE_ICON_BYTES: &[u8] =
105    include_bytes!(concat!(env!("OUT_DIR"), "/embedded_assets/resources/icons/vtcode-profile-120.png"));
106
107/// Outcome of [`install_profile_icon`].
108pub struct ProfileIconInstallReport {
109    /// Dynamic-profile JSON that iTerm2 loads live.
110    pub profile_path: PathBuf,
111    /// Installed artwork referenced by the profile.
112    pub icon_path: PathBuf,
113}
114
115/// Render the dynamic-profile JSON pointing at an absolute icon path.
116///
117/// Unspecified attributes inherit live from the default profile, so
118/// switching to this profile changes only the tab icon. There is no
119/// `Automatic Profile Switching` rule: VT Code switches to this profile
120/// explicitly at TUI startup and reverts to the session's original profile
121/// on exit, so automatic reversion (which requires iTerm2 Shell Integration)
122/// is neither needed nor reliable.
123pub fn dynamic_profile_json(icon_path: &Path) -> Result<String> {
124    let document = serde_json::json!({
125        "Profiles": [{
126            "Name": ITERM2_PROFILE_NAME,
127            "Guid": PROFILE_GUID,
128            "Icon": ITERM2_ICON_MODE_CUSTOM,
129            "Custom Icon Path": icon_path.to_string_lossy(),
130        }],
131    });
132    serde_json::to_string_pretty(&document).context("failed to serialize iTerm2 dynamic profile")
133}
134
135/// Absolute destination of the installed icon under a data root.
136pub fn installed_icon_path(data_dir: &Path) -> PathBuf {
137    data_dir.join("icons").join(PROFILE_ICON_FILENAME)
138}
139
140/// Install the VT Code iTerm2 profile icon (macOS only, idempotent).
141///
142/// Writes only VT Code-owned files: the artwork under the data root and
143/// `vtcode.json` under iTerm2's DynamicProfiles directory. Existing
144/// profiles are never modified; deleting `vtcode.json` uninstalls.
145pub fn install_profile_icon(home: &Path, data_dir: &Path) -> Result<ProfileIconInstallReport> {
146    if !cfg!(target_os = "macos") {
147        anyhow::bail!("iTerm2 profile icons are only available on macOS");
148    }
149    let icon_path = installed_icon_path(data_dir);
150    if let Some(parent) = icon_path.parent() {
151        std::fs::create_dir_all(parent)
152            .with_context(|| format!("failed to create icon directory {}", parent.display()))?;
153    }
154    std::fs::write(&icon_path, PROFILE_ICON_BYTES)
155        .with_context(|| format!("failed to write icon {}", icon_path.display()))?;
156    let profiles_dir = iterm2_dynamic_profiles_dir(home);
157    std::fs::create_dir_all(&profiles_dir).with_context(|| format!("failed to create {}", profiles_dir.display()))?;
158    let profile_path = profiles_dir.join(ITERM2_DYNAMIC_PROFILE_FILENAME);
159    let json = dynamic_profile_json(&icon_path)?;
160    std::fs::write(&profile_path, json).with_context(|| format!("failed to write {}", profile_path.display()))?;
161    Ok(ProfileIconInstallReport { profile_path, icon_path })
162}
163
164/// Resolve default install locations: home directory and canonical data root.
165pub fn default_install_paths() -> Result<(PathBuf, PathBuf)> {
166    let home = dirs::home_dir().context("failed to determine home directory")?;
167    let data_dir = VtCodePaths::resolve()?.data_dir().to_path_buf();
168    Ok((home, data_dir))
169}
170
171/// Guidance lines pointing at the automatic installer and the manual fallback.
172pub fn profile_icon_instructions() -> Vec<String> {
173    vec![
174        "TAB ICON (profile image):".to_string(),
175        "1. Run `/terminal-setup install-iterm2-icon` to install the VT Code tab icon automatically.".to_string(),
176        "2. If a tab is stuck showing the VT Code icon after exit, run `/terminal-setup reset-iterm2-icon`."
177            .to_string(),
178        "3. Or set it manually: Settings → Profiles → General → Icon → Custom, then pick a PNG from resources/icons/."
179            .to_string(),
180    ]
181}
182
183/// Run the non-interactive profile-icon install with progress output.
184///
185/// Fails closed outside iTerm2 or off macOS; never touches existing profiles.
186pub fn run_profile_icon_install(renderer: &mut crate::utils::ansi::AnsiRenderer) -> Result<()> {
187    use crate::terminal_setup::detector::TerminalType;
188    use crate::utils::ansi::MessageStyle;
189
190    let terminal = TerminalType::detect()?;
191    if !matches!(terminal, TerminalType::ITerm2) {
192        renderer.line(MessageStyle::Error, &format!("This installer needs iTerm2 (detected {}).", terminal.name()))?;
193        return Ok(());
194    }
195    let (home, data_dir) = default_install_paths()?;
196    renderer.line(MessageStyle::Info, "Installing VT Code iTerm2 tab icon...")?;
197    let report = install_profile_icon(&home, &data_dir)?;
198    renderer.line(MessageStyle::Status, &format!("✓ Profile written: {}", report.profile_path.display()))?;
199    renderer.line(MessageStyle::Status, &format!("✓ Icon installed: {}", report.icon_path.display()))?;
200    renderer.line(
201        MessageStyle::Info,
202        "Open a new iTerm2 tab so it picks up the profile; the logo replaces the generic tab glyph while VT Code runs.",
203    )?;
204    Ok(())
205}
206
207/// Reset the current session's iTerm2 profile to its original name.
208///
209/// Repair path for tabs left stuck on the `VT Code` icon profile by an older
210/// build that switched profile without reverting. Emits `OSC 1337;SetProfile=`
211/// with the session's original profile (from `ITERM_PROFILE`, falling back to
212/// iTerm2's `Default`). Fails closed outside iTerm2 or off macOS.
213pub fn run_profile_icon_reset(renderer: &mut crate::utils::ansi::AnsiRenderer) -> Result<()> {
214    use crate::terminal_setup::detector::TerminalType;
215    use crate::utils::ansi::MessageStyle;
216    use std::io::Write as _;
217    use vtcode_commons::ansi_codes::set_iterm2_profile;
218    use vtcode_commons::terminal_detection::{ITERM2_DEFAULT_PROFILE_NAME, original_iterm2_profile_name};
219
220    let terminal = TerminalType::detect()?;
221    if !matches!(terminal, TerminalType::ITerm2) {
222        renderer.line(MessageStyle::Error, &format!("This reset needs iTerm2 (detected {}).", terminal.name()))?;
223        return Ok(());
224    }
225    let target = original_iterm2_profile_name().unwrap_or_else(|| ITERM2_DEFAULT_PROFILE_NAME.to_string());
226    let mut stdout = std::io::stdout();
227    stdout
228        .write_all(set_iterm2_profile(&target).as_bytes())
229        .with_context(|| "failed to write iTerm2 profile reset sequence")?;
230    stdout
231        .flush()
232        .with_context(|| "failed to flush iTerm2 profile reset sequence")?;
233    renderer.line(MessageStyle::Status, &format!("✓ Session profile reset to \"{target}\"."))?;
234    Ok(())
235}
236
237/// Ensure the iTerm2 profile icon is installed, returning the install
238/// report when this call wrote files.
239///
240/// Best-effort first-run path: skips silently off macOS, outside iTerm2,
241/// or under tmux, and rewrites the profile when the installed JSON or
242/// artwork drifts from the shipped copy so icon/profile updates propagate.
243pub fn ensure_profile_icon() -> Result<Option<ProfileIconInstallReport>> {
244    if !cfg!(target_os = "macos") {
245        return Ok(None);
246    }
247    let (home, data_dir) = default_install_paths()?;
248    let iterm_session = std::env::var("ITERM_SESSION_ID").is_ok();
249    let tmux_session = std::env::var("TMUX").is_ok();
250    ensure_profile_icon_at(&home, &data_dir, iterm_session, tmux_session)
251}
252
253/// Testable core of [`ensure_profile_icon`] with explicit paths and
254/// environment flags (no process-environment reads, no global roots).
255pub fn ensure_profile_icon_at(
256    home: &Path,
257    data_dir: &Path,
258    iterm_session: bool,
259    tmux_session: bool,
260) -> Result<Option<ProfileIconInstallReport>> {
261    if !cfg!(target_os = "macos") || !iterm_session || tmux_session {
262        return Ok(None);
263    }
264    let profile_path = installed_iterm2_profile_path(home);
265    let icon_path = installed_icon_path(data_dir);
266    let icon_fresh = std::fs::read(&icon_path)
267        .map(|bytes| bytes == PROFILE_ICON_BYTES)
268        .unwrap_or(false);
269    let profile_current = std::fs::read_to_string(&profile_path)
270        .ok()
271        .zip(dynamic_profile_json(&icon_path).ok())
272        .is_some_and(|(installed, expected)| installed == expected);
273    if profile_current && icon_fresh {
274        return Ok(None);
275    }
276    install_profile_icon(home, data_dir).map(Some)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::terminal_setup::detector::TerminalFeature;
283
284    #[test]
285    fn test_generate_instructions() {
286        let features = vec![TerminalFeature::Multiline, TerminalFeature::CopyPaste];
287        let instructions = generate_config(&features).unwrap();
288        assert!(instructions.contains("iTerm2"));
289        assert!(instructions.contains("Preferences"));
290        assert!(instructions.contains("MULTILINE"));
291        assert!(instructions.contains("COPY/PASTE"));
292    }
293
294    #[test]
295    fn dynamic_profile_json_points_at_given_icon() {
296        let first = dynamic_profile_json(Path::new("/data/vtcode/icons/vtcode-profile-120.png")).unwrap();
297        let second = dynamic_profile_json(Path::new("/other/icons/vtcode-profile-120.png")).unwrap();
298        assert_ne!(first, second);
299
300        let value: serde_json::Value = serde_json::from_str(&first).unwrap();
301        let profile = &value["Profiles"][0];
302        assert_eq!(profile["Name"], ITERM2_PROFILE_NAME);
303        assert_eq!(profile["Guid"], PROFILE_GUID);
304        assert_eq!(profile["Icon"], ITERM2_ICON_MODE_CUSTOM);
305        assert_eq!(profile["Custom Icon Path"], "/data/vtcode/icons/vtcode-profile-120.png");
306        assert!(
307            profile.get("Automatic Profile Switching").is_none(),
308            "reversion is explicit now; the dynamic profile must not carry an APS rule"
309        );
310    }
311
312    #[cfg(target_os = "macos")]
313    #[test]
314    fn install_profile_icon_writes_artwork_and_profile() {
315        let home = tempfile::tempdir().unwrap();
316        let data = tempfile::tempdir().unwrap();
317
318        let first = install_profile_icon(home.path(), data.path()).unwrap();
319        assert!(first.profile_path.exists());
320        assert!(first.icon_path.exists());
321        assert_eq!(std::fs::read(&first.icon_path).unwrap(), PROFILE_ICON_BYTES);
322
323        let stored: serde_json::Value =
324            serde_json::from_str(&std::fs::read_to_string(&first.profile_path).unwrap()).unwrap();
325        assert_eq!(
326            stored["Profiles"][0]["Custom Icon Path"].as_str().unwrap().to_string(),
327            first.icon_path.to_string_lossy().into_owned()
328        );
329
330        let second = install_profile_icon(home.path(), data.path()).unwrap();
331        assert_eq!(second.profile_path, first.profile_path);
332        assert_eq!(second.icon_path, first.icon_path);
333    }
334
335    #[test]
336    fn profile_icon_instructions_point_at_installer() {
337        let lines = profile_icon_instructions();
338        assert!(lines.iter().any(|line| line.contains("install-iterm2-icon")));
339    }
340
341    #[test]
342    fn ensure_skips_without_iterm_session_and_writes_nothing() {
343        let home = tempfile::tempdir().unwrap();
344        let data = tempfile::tempdir().unwrap();
345
346        let report = ensure_profile_icon_at(home.path(), data.path(), false, false).unwrap();
347        assert!(report.is_none());
348        assert!(!iterm2_dynamic_profiles_dir(home.path()).exists());
349    }
350
351    #[test]
352    fn ensure_skips_under_tmux_and_writes_nothing() {
353        let home = tempfile::tempdir().unwrap();
354        let data = tempfile::tempdir().unwrap();
355
356        let report = ensure_profile_icon_at(home.path(), data.path(), true, true).unwrap();
357        assert!(report.is_none());
358        assert!(!iterm2_dynamic_profiles_dir(home.path()).exists());
359    }
360
361    #[cfg(target_os = "macos")]
362    #[test]
363    fn ensure_installs_once_then_refreshes_stale_artwork() {
364        let home = tempfile::tempdir().unwrap();
365        let data = tempfile::tempdir().unwrap();
366
367        let first = ensure_profile_icon_at(home.path(), data.path(), true, false).unwrap();
368        assert!(first.is_some());
369
370        let second = ensure_profile_icon_at(home.path(), data.path(), true, false).unwrap();
371        assert!(second.is_none());
372
373        let icon_path = installed_icon_path(data.path());
374        std::fs::write(&icon_path, b"stale-bytes").unwrap();
375        let third = ensure_profile_icon_at(home.path(), data.path(), true, false).unwrap();
376        let report = third.expect("stale artwork must trigger a refresh");
377        assert_eq!(std::fs::read(&report.icon_path).unwrap(), PROFILE_ICON_BYTES);
378    }
379
380    #[cfg(target_os = "macos")]
381    #[test]
382    fn ensure_refreshes_when_installed_profile_content_drifts() {
383        let home = tempfile::tempdir().unwrap();
384        let data = tempfile::tempdir().unwrap();
385
386        let first = ensure_profile_icon_at(home.path(), data.path(), true, false).unwrap();
387        let profile_path = first.expect("first run installs").profile_path;
388
389        // Simulate an older install that still carries the removed APS rule.
390        std::fs::write(&profile_path, r#"{"Profiles":[{"Name":"VT Code"}]}"#).unwrap();
391        let refreshed = ensure_profile_icon_at(home.path(), data.path(), true, false).unwrap();
392        let report = refreshed.expect("drifted profile content must trigger a rewrite");
393        assert_eq!(report.profile_path, profile_path);
394
395        let stored: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&profile_path).unwrap()).unwrap();
396        assert!(stored["Profiles"][0].get("Automatic Profile Switching").is_none());
397    }
398}