vtcode_core/terminal_setup/terminals/
iterm2.rs1use 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
20pub 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
94pub const PROFILE_GUID: &str = "1FC21C70-F2B1-4F0F-BB03-D1AE12EF900E";
97
98pub const PROFILE_ICON_FILENAME: &str = "vtcode-profile-120.png";
100
101const PROFILE_ICON_BYTES: &[u8] =
105 include_bytes!(concat!(env!("OUT_DIR"), "/embedded_assets/resources/icons/vtcode-profile-120.png"));
106
107pub struct ProfileIconInstallReport {
109 pub profile_path: PathBuf,
111 pub icon_path: PathBuf,
113}
114
115pub 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
135pub fn installed_icon_path(data_dir: &Path) -> PathBuf {
137 data_dir.join("icons").join(PROFILE_ICON_FILENAME)
138}
139
140pub 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
164pub 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
171pub 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
183pub 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
207pub 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
237pub 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
253pub 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 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}