Skip to main content

claudectl_core/terminals/
mod.rs

1#[cfg(target_os = "macos")]
2mod apple;
3#[cfg(target_os = "macos")]
4mod ghostty;
5mod gnome_terminal;
6#[cfg(target_os = "macos")]
7mod iterm2;
8mod kitty;
9mod tmux;
10#[cfg(target_os = "macos")]
11mod warp;
12mod wezterm;
13mod windows_terminal;
14
15use crate::session::ClaudeSession;
16use std::path::PathBuf;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19enum TerminalAction {
20    Launch,
21    Switch,
22    Input,
23    Approve,
24}
25
26impl TerminalAction {
27    fn label(&self) -> &'static str {
28        match self {
29            TerminalAction::Launch => "Launch new session",
30            TerminalAction::Switch => "Switch to session terminal",
31            TerminalAction::Input => "Send input to session",
32            TerminalAction::Approve => "Approve prompt",
33        }
34    }
35
36    fn summary_name(&self) -> &'static str {
37        match self {
38            TerminalAction::Launch => "launch",
39            TerminalAction::Switch => "switch",
40            TerminalAction::Input => "input",
41            TerminalAction::Approve => "approve",
42        }
43    }
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum DoctorStatus {
48    Ready,
49    Blocked,
50    Unsupported,
51}
52
53impl DoctorStatus {
54    fn label(&self) -> &'static str {
55        match self {
56            DoctorStatus::Ready => "ok",
57            DoctorStatus::Blocked => "blocked",
58            DoctorStatus::Unsupported => "n/a",
59        }
60    }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct DoctorCheck {
65    pub name: &'static str,
66    pub status: DoctorStatus,
67    pub detail: String,
68    pub fix: Option<String>,
69}
70
71impl DoctorCheck {
72    fn ready(name: &'static str, detail: impl Into<String>) -> Self {
73        Self {
74            name,
75            status: DoctorStatus::Ready,
76            detail: detail.into(),
77            fix: None,
78        }
79    }
80
81    fn blocked(
82        name: &'static str,
83        detail: impl Into<String>,
84        fix: impl Into<Option<String>>,
85    ) -> Self {
86        Self {
87            name,
88            status: DoctorStatus::Blocked,
89            detail: detail.into(),
90            fix: fix.into(),
91        }
92    }
93
94    fn unsupported(name: &'static str, detail: impl Into<String>) -> Self {
95        Self {
96            name,
97            status: DoctorStatus::Unsupported,
98            detail: detail.into(),
99            fix: None,
100        }
101    }
102}
103
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub struct DoctorReport {
106    pub terminal: String,
107    pub platform: String,
108    pub actions: Vec<DoctorCheck>,
109    pub prerequisites: Vec<DoctorCheck>,
110    pub notes: Vec<String>,
111}
112
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum Terminal {
115    Gnome,
116    Ghostty,
117    Warp,
118    ITerm2,
119    Kitty,
120    WezTerm,
121    WindowsTerm,
122    Apple,
123    Tmux,
124    Unknown(String),
125}
126
127fn terminal_name(t: &Terminal) -> &str {
128    match t {
129        Terminal::Gnome => "GNOME Terminal",
130        Terminal::Ghostty => "Ghostty",
131        Terminal::Warp => "Warp",
132        Terminal::ITerm2 => "iTerm2",
133        Terminal::Kitty => "Kitty",
134        Terminal::WezTerm => "WezTerm",
135        Terminal::WindowsTerm => "Windows Terminal",
136        Terminal::Apple => "Apple Terminal",
137        Terminal::Tmux => "tmux",
138        Terminal::Unknown(name) => name,
139    }
140}
141
142fn platform_label(os: &str, is_wsl: bool) -> String {
143    if is_wsl && os == "linux" {
144        "linux (WSL)".to_string()
145    } else {
146        os.to_string()
147    }
148}
149
150fn platform_name() -> String {
151    platform_label(std::env::consts::OS, is_wsl())
152}
153
154fn environment_notes(is_wsl: bool, has_windows_terminal_bridge: bool) -> Vec<String> {
155    if !is_wsl {
156        return Vec::new();
157    }
158
159    let mut notes = vec![
160        "WSL detected. Linux session discovery should work normally inside the distro."
161            .to_string(),
162        "For reliable switch, input, and approval automation in WSL today, prefer tmux or Kitty inside WSL."
163            .to_string(),
164    ];
165
166    if has_windows_terminal_bridge {
167        notes.push(
168            "Windows Terminal launch is available from WSL through `cmd.exe /c wt.exe`, but tab switching and input automation still rely on tmux or Kitty."
169                .to_string(),
170        );
171    } else {
172        notes.push(
173            "Windows Terminal launch is not available from this WSL shell, so claudectl currently relies on Linux-native terminals inside WSL."
174                .to_string(),
175        );
176    }
177
178    notes
179}
180
181fn windows_terminal_bridge_ready() -> bool {
182    command_ready("cmd.exe") && command_ready("wt.exe")
183}
184
185fn wsl_interop_check(is_wsl: bool) -> Option<DoctorCheck> {
186    if !is_wsl {
187        return None;
188    }
189
190    if windows_terminal_bridge_ready() {
191        Some(DoctorCheck::ready(
192            "Windows Terminal interop",
193            "`cmd.exe /c wt.exe` is reachable from WSL.",
194        ))
195    } else if !command_ready("cmd.exe") {
196        Some(DoctorCheck::blocked(
197            "Windows Terminal interop",
198            "`cmd.exe` is not on PATH from this WSL environment.",
199            Some(
200                "Enable WSL Windows interop or reopen this distro from a normal WSL shell."
201                    .to_string(),
202            ),
203        ))
204    } else {
205        Some(DoctorCheck::blocked(
206            "Windows Terminal interop",
207            "`wt.exe` is not on PATH from this WSL environment.",
208            Some(
209                "Install Windows Terminal or enable WSL interop, then reopen the shell."
210                    .to_string(),
211            ),
212        ))
213    }
214}
215
216fn is_wsl() -> bool {
217    #[cfg(target_os = "linux")]
218    {
219        if std::env::var_os("WSL_DISTRO_NAME").is_some()
220            || std::env::var_os("WSL_INTEROP").is_some()
221        {
222            return true;
223        }
224
225        for path in ["/proc/sys/kernel/osrelease", "/proc/version"] {
226            let Ok(contents) = std::fs::read_to_string(path) else {
227                continue;
228            };
229
230            if contents.to_ascii_lowercase().contains("microsoft") {
231                return true;
232            }
233        }
234    }
235
236    false
237}
238
239fn supported_actions(terminal: &Terminal) -> Vec<TerminalAction> {
240    match terminal {
241        Terminal::Gnome | Terminal::WindowsTerm => vec![TerminalAction::Launch],
242        Terminal::Kitty | Terminal::Tmux => vec![
243            TerminalAction::Launch,
244            TerminalAction::Switch,
245            TerminalAction::Input,
246            TerminalAction::Approve,
247        ],
248        Terminal::WezTerm => vec![TerminalAction::Launch, TerminalAction::Switch],
249        #[cfg(target_os = "macos")]
250        Terminal::Ghostty | Terminal::Warp | Terminal::ITerm2 | Terminal::Apple => vec![
251            TerminalAction::Switch,
252            TerminalAction::Input,
253            TerminalAction::Approve,
254        ],
255        Terminal::Unknown(_) => Vec::new(),
256        #[cfg(not(target_os = "macos"))]
257        _ => Vec::new(),
258    }
259}
260
261pub(crate) fn build_claude_args(prompt: Option<&str>, resume: Option<&str>) -> Vec<String> {
262    let mut args = Vec::new();
263    if let Some(resume_id) = resume {
264        args.push("--resume".to_string());
265        args.push(resume_id.to_string());
266    }
267    if let Some(prompt_text) = prompt {
268        args.push("-p".to_string());
269        args.push(prompt_text.to_string());
270    }
271    args
272}
273
274pub(crate) fn shell_escape(arg: &str) -> String {
275    format!("'{}'", arg.replace('\'', "'\"'\"'"))
276}
277
278pub fn detect_terminal() -> Terminal {
279    if std::env::var("TMUX").is_ok() {
280        return Terminal::Tmux;
281    }
282
283    if std::env::var("GNOME_TERMINAL_SERVICE").is_ok()
284        || std::env::var("GNOME_TERMINAL_SCREEN").is_ok()
285        || ancestor_process_contains("gnome-terminal")
286    {
287        return Terminal::Gnome;
288    }
289
290    if is_wsl() && std::env::var_os("WT_SESSION").is_some() {
291        return Terminal::WindowsTerm;
292    }
293
294    // Terminal-specific env vars that don't rely on TERM_PROGRAM.
295    // Some terminals (notably kitty on Linux) don't set TERM_PROGRAM at all.
296    if let Some(term) = detect_by_native_env() {
297        return term;
298    }
299
300    match std::env::var("TERM_PROGRAM").as_deref() {
301        Ok("ghostty") => Terminal::Ghostty,
302        Ok("WarpTerminal") => Terminal::Warp,
303        Ok("iTerm.app") => Terminal::ITerm2,
304        Ok("kitty") => Terminal::Kitty,
305        Ok("WezTerm") => Terminal::WezTerm,
306        Ok("Apple_Terminal") => Terminal::Apple,
307        Ok(other) => Terminal::Unknown(other.to_string()),
308        Err(_) => Terminal::Unknown("unknown".to_string()),
309    }
310}
311
312/// Detect terminal from native env vars that each terminal sets unconditionally,
313/// without relying on TERM_PROGRAM (which some terminals don't set on Linux).
314fn detect_by_native_env() -> Option<Terminal> {
315    // Kitty: KITTY_WINDOW_ID is set unconditionally per-window.
316    // TERM=xterm-kitty is also reliable but can be inherited by child shells.
317    if std::env::var_os("KITTY_WINDOW_ID").is_some() {
318        return Some(Terminal::Kitty);
319    }
320
321    // WezTerm: WEZTERM_EXECUTABLE is set on all platforms.
322    if std::env::var_os("WEZTERM_EXECUTABLE").is_some() {
323        return Some(Terminal::WezTerm);
324    }
325
326    // Ghostty: GHOSTTY_RESOURCES_DIR is set on all platforms.
327    if std::env::var_os("GHOSTTY_RESOURCES_DIR").is_some() {
328        return Some(Terminal::Ghostty);
329    }
330
331    // TERM=xterm-kitty as last resort (weaker signal — can be inherited through ssh/tmux)
332    if std::env::var("TERM").as_deref() == Ok("xterm-kitty") {
333        return Some(Terminal::Kitty);
334    }
335
336    None
337}
338
339fn ancestor_process_contains(needle: &str) -> bool {
340    let mut pid = unsafe { libc::getppid() } as u32;
341    let needle = needle.to_ascii_lowercase();
342
343    for _ in 0..8 {
344        if pid == 0 {
345            break;
346        }
347
348        let output = match std::process::Command::new("ps")
349            .args(["-o", "ppid=,comm=", "-p", &pid.to_string()])
350            .output()
351        {
352            Ok(output) => output,
353            Err(_) => return false,
354        };
355
356        let stdout = String::from_utf8_lossy(&output.stdout);
357        let line = stdout.trim();
358        if line.is_empty() {
359            break;
360        }
361
362        let mut parts = line.split_whitespace();
363        let parent = parts
364            .next()
365            .and_then(|value| value.parse::<u32>().ok())
366            .unwrap_or(0);
367        let command = parts.collect::<Vec<_>>().join(" ").to_ascii_lowercase();
368        if command.contains(&needle) {
369            return true;
370        }
371        pid = parent;
372    }
373
374    false
375}
376
377pub fn can_launch_session() -> bool {
378    supported_actions(&detect_terminal()).contains(&TerminalAction::Launch)
379}
380
381pub fn help_capability_summary() -> String {
382    help_capability_summary_for(&detect_terminal())
383}
384
385fn help_capability_summary_for(terminal: &Terminal) -> String {
386    let actions = supported_actions(terminal);
387    if actions.is_empty() {
388        format!(
389            "Current terminal: {} (monitor-only)",
390            terminal_name(terminal)
391        )
392    } else {
393        let summary = actions
394            .iter()
395            .map(TerminalAction::summary_name)
396            .collect::<Vec<_>>()
397            .join(", ");
398        format!("Current terminal: {} ({summary})", terminal_name(terminal))
399    }
400}
401
402fn find_command_path(name: &str) -> Option<PathBuf> {
403    if name.contains(std::path::MAIN_SEPARATOR) {
404        let path = PathBuf::from(name);
405        return path.is_file().then_some(path);
406    }
407
408    let path_var = std::env::var_os("PATH")?;
409    std::env::split_paths(&path_var)
410        .map(|dir| dir.join(name))
411        .find(|candidate| candidate.is_file())
412}
413
414fn binary_check(name: &'static str) -> DoctorCheck {
415    match find_command_path(name) {
416        Some(path) => DoctorCheck::ready(name, format!("Found at {}", path.display())),
417        None => DoctorCheck::blocked(
418            name,
419            format!("`{name}` is not on PATH."),
420            Some(format!("Install `{name}` or add it to PATH.")),
421        ),
422    }
423}
424
425fn command_ready(name: &'static str) -> bool {
426    find_command_path(name).is_some()
427}
428
429fn output_message(output: &std::process::Output) -> String {
430    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
431    if !stderr.is_empty() {
432        return stderr;
433    }
434    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
435    if !stdout.is_empty() {
436        return stdout;
437    }
438    format!("Command exited with status {}", output.status)
439}
440
441fn probe_kitty_remote_control() -> Result<(), String> {
442    let output = std::process::Command::new("kitty")
443        .args(["@", "ls"])
444        .output()
445        .map_err(|e| format!("kitty @ ls failed: {e}"))?;
446
447    if output.status.success() {
448        Ok(())
449    } else {
450        Err(output_message(&output))
451    }
452}
453
454fn probe_tmux_connectivity() -> Result<(), String> {
455    let output = std::process::Command::new("tmux")
456        .args(["list-panes", "-a", "-F", "#{pane_tty}"])
457        .output()
458        .map_err(|e| format!("tmux list-panes failed: {e}"))?;
459
460    if output.status.success() {
461        Ok(())
462    } else {
463        Err(output_message(&output))
464    }
465}
466
467fn probe_wezterm_cli() -> Result<(), String> {
468    let output = std::process::Command::new("wezterm")
469        .args(["cli", "list", "--format", "json"])
470        .output()
471        .map_err(|e| format!("wezterm cli list failed: {e}"))?;
472
473    if output.status.success() {
474        Ok(())
475    } else {
476        Err(output_message(&output))
477    }
478}
479
480#[cfg(target_os = "macos")]
481fn probe_system_events_access() -> Result<(), String> {
482    let script = r#"tell application "System Events" to return UI elements enabled"#;
483    let output = std::process::Command::new("osascript")
484        .args(["-e", script])
485        .output()
486        .map_err(|e| format!("osascript probe failed: {e}"))?;
487
488    if output.status.success() {
489        Ok(())
490    } else {
491        Err(output_message(&output))
492    }
493}
494
495fn action_check(
496    action: TerminalAction,
497    status: DoctorStatus,
498    detail: impl Into<String>,
499    fix: impl Into<Option<String>>,
500) -> DoctorCheck {
501    match status {
502        DoctorStatus::Ready => DoctorCheck::ready(action.label(), detail),
503        DoctorStatus::Blocked => DoctorCheck::blocked(action.label(), detail, fix),
504        DoctorStatus::Unsupported => DoctorCheck::unsupported(action.label(), detail.into()),
505    }
506}
507
508pub fn doctor_report() -> DoctorReport {
509    doctor_report_for(detect_terminal())
510}
511
512fn doctor_report_for(terminal: Terminal) -> DoctorReport {
513    let terminal_label = terminal_name(&terminal).to_string();
514    let is_wsl = is_wsl();
515    let mut prerequisites = vec![binary_check("claude")];
516    if let Some(wsl_check) = wsl_interop_check(is_wsl) {
517        prerequisites.push(wsl_check);
518    }
519    let mut actions = Vec::new();
520    let mut notes = vec![
521        "Run `claudectl --doctor` inside the same terminal family that launches Claude."
522            .to_string(),
523        "`n` and `--new` use the same launch capability shown here.".to_string(),
524    ];
525    notes.extend(environment_notes(is_wsl, windows_terminal_bridge_ready()));
526
527    match terminal {
528        Terminal::Gnome => {
529            let gnome_check = binary_check("gnome-terminal");
530            let gnome_ready = gnome_check.status == DoctorStatus::Ready;
531            prerequisites.push(gnome_check);
532
533            let launch_status = if gnome_ready {
534                DoctorStatus::Ready
535            } else {
536                DoctorStatus::Blocked
537            };
538            let launch_detail = if gnome_ready {
539                "GNOME Terminal can launch visible Claude sessions with `gnome-terminal --window`."
540            } else {
541                "GNOME Terminal CLI is unavailable, so visible launch cannot run."
542            };
543            let launch_fix =
544                Some("Install GNOME Terminal and ensure `gnome-terminal` is on PATH.".to_string());
545            actions.push(action_check(
546                TerminalAction::Launch,
547                launch_status,
548                launch_detail,
549                launch_fix.clone(),
550            ));
551
552            for action in [
553                TerminalAction::Switch,
554                TerminalAction::Input,
555                TerminalAction::Approve,
556            ] {
557                actions.push(action_check(
558                    action,
559                    DoctorStatus::Unsupported,
560                    "GNOME Terminal launch is supported, but reliable remote focus/input automation is not currently available.",
561                    Some(
562                        "Use tmux or Kitty when you need remote switching, input, or approval from claudectl."
563                            .to_string(),
564                    ),
565                ));
566            }
567
568            notes.push(
569                "GNOME Terminal launch works on Linux and was smoke-tested under Docker/X11. Remote focus/input automation is intentionally disabled until window targeting is reliable."
570                    .to_string(),
571            );
572        }
573        Terminal::WindowsTerm => {
574            let cmd_check = binary_check("cmd.exe");
575            let cmd_ready = cmd_check.status == DoctorStatus::Ready;
576            prerequisites.push(cmd_check);
577
578            let wt_check = binary_check("wt.exe");
579            let wt_ready = wt_check.status == DoctorStatus::Ready;
580            prerequisites.push(wt_check);
581
582            let launch_status = if cmd_ready && wt_ready {
583                DoctorStatus::Ready
584            } else {
585                DoctorStatus::Blocked
586            };
587            let launch_detail = if launch_status == DoctorStatus::Ready {
588                "Windows Terminal can open a new WSL tab in the current window and run `claude` there."
589            } else {
590                "Windows Terminal launch needs both `cmd.exe` and `wt.exe` reachable from this WSL shell."
591            };
592            let launch_fix = Some(
593                "Enable WSL Windows interop, ensure Windows Terminal is installed, then rerun `claudectl --doctor`."
594                    .to_string(),
595            );
596            actions.push(action_check(
597                TerminalAction::Launch,
598                launch_status,
599                launch_detail,
600                launch_fix.clone(),
601            ));
602
603            for action in [
604                TerminalAction::Switch,
605                TerminalAction::Input,
606                TerminalAction::Approve,
607            ] {
608                actions.push(action_check(
609                    action,
610                    DoctorStatus::Unsupported,
611                    "Windows Terminal launch works from WSL, but remote tab switching and input automation are not implemented there yet.",
612                    Some(
613                        "Use tmux or Kitty inside WSL when you need switch/input/approve automation."
614                            .to_string(),
615                    ),
616                ));
617            }
618
619            notes.push(
620                "Windows Terminal support is WSL-only and currently covers visible launch into a new tab, not remote control of existing tabs."
621                    .to_string(),
622            );
623        }
624        Terminal::Kitty => {
625            let kitty_check = binary_check("kitty");
626            let kitty_ready = kitty_check.status == DoctorStatus::Ready;
627            prerequisites.push(kitty_check);
628
629            let remote_check = if kitty_ready {
630                match probe_kitty_remote_control() {
631                    Ok(()) => DoctorCheck::ready(
632                        "kitty remote control",
633                        "`kitty @` is reachable from this shell.",
634                    ),
635                    Err(err) => DoctorCheck::blocked(
636                        "kitty remote control",
637                        format!("`kitty @` is unavailable: {err}"),
638                        Some(
639                            "Set `allow_remote_control yes` or `allow_remote_control socket-only` in kitty.conf, then restart Kitty."
640                                .to_string(),
641                        ),
642                    ),
643                }
644            } else {
645                DoctorCheck::blocked(
646                    "kitty remote control",
647                    "Kitty CLI is unavailable, so `kitty @` cannot be used.",
648                    Some("Install Kitty and ensure `kitty` is on PATH.".to_string()),
649                )
650            };
651            let remote_ready = remote_check.status == DoctorStatus::Ready;
652            prerequisites.push(remote_check);
653
654            let action_status = if kitty_ready && remote_ready {
655                DoctorStatus::Ready
656            } else {
657                DoctorStatus::Blocked
658            };
659            let detail = if action_status == DoctorStatus::Ready {
660                "Kitty can focus tabs and send text through `kitty @`."
661            } else {
662                "Kitty support is configured, but remote control is not currently available."
663            };
664            let fix = Some(
665                "Enable Kitty remote control in kitty.conf and rerun `claudectl --doctor`."
666                    .to_string(),
667            );
668
669            for action in supported_actions(&Terminal::Kitty) {
670                actions.push(action_check(action, action_status, detail, fix.clone()));
671            }
672        }
673        Terminal::Tmux => {
674            let tmux_check = binary_check("tmux");
675            let tmux_ready = tmux_check.status == DoctorStatus::Ready;
676            prerequisites.push(tmux_check);
677
678            let session_check = if tmux_ready {
679                match probe_tmux_connectivity() {
680                    Ok(()) => DoctorCheck::ready(
681                        "tmux session access",
682                        "`tmux list-panes` can see the active server.",
683                    ),
684                    Err(err) => DoctorCheck::blocked(
685                        "tmux session access",
686                        format!("tmux is installed, but pane discovery failed: {err}"),
687                        Some("Run claudectl from inside the tmux session that owns the Claude panes.".to_string()),
688                    ),
689                }
690            } else {
691                DoctorCheck::blocked(
692                    "tmux session access",
693                    "tmux is unavailable, so pane discovery cannot run.",
694                    Some("Install tmux and rerun `claudectl --doctor`.".to_string()),
695                )
696            };
697            let session_ready = session_check.status == DoctorStatus::Ready;
698            prerequisites.push(session_check);
699
700            let action_status = if tmux_ready && session_ready {
701                DoctorStatus::Ready
702            } else {
703                DoctorStatus::Blocked
704            };
705            let detail = if action_status == DoctorStatus::Ready {
706                "tmux can open windows, locate panes by TTY, and send keys."
707            } else {
708                "tmux support needs a reachable tmux server from this shell."
709            };
710            let fix = Some(
711                "Run claudectl inside tmux or connect it to the same tmux server.".to_string(),
712            );
713
714            for action in supported_actions(&Terminal::Tmux) {
715                actions.push(action_check(action, action_status, detail, fix.clone()));
716            }
717        }
718        Terminal::WezTerm => {
719            let wezterm_check = binary_check("wezterm");
720            let wezterm_ready = wezterm_check.status == DoctorStatus::Ready;
721            prerequisites.push(wezterm_check);
722
723            let cli_check = if wezterm_ready {
724                match probe_wezterm_cli() {
725                    Ok(()) => DoctorCheck::ready(
726                        "wezterm cli",
727                        "`wezterm cli` can query panes from this shell.",
728                    ),
729                    Err(err) => DoctorCheck::blocked(
730                        "wezterm cli",
731                        format!("WezTerm CLI is installed, but pane discovery failed: {err}"),
732                        Some(
733                            "Run claudectl inside WezTerm with a reachable mux server.".to_string(),
734                        ),
735                    ),
736                }
737            } else {
738                DoctorCheck::blocked(
739                    "wezterm cli",
740                    "WezTerm CLI is unavailable, so pane discovery cannot run.",
741                    Some("Install WezTerm and ensure `wezterm` is on PATH.".to_string()),
742                )
743            };
744            let cli_ready = cli_check.status == DoctorStatus::Ready;
745            prerequisites.push(cli_check);
746
747            let action_status = if wezterm_ready && cli_ready {
748                DoctorStatus::Ready
749            } else {
750                DoctorStatus::Blocked
751            };
752            let detail = if action_status == DoctorStatus::Ready {
753                "WezTerm supports visible launch and pane activation through `wezterm cli`."
754            } else {
755                "WezTerm support needs a reachable mux server from this shell."
756            };
757            let fix = Some(
758                "Start claudectl from the same WezTerm environment that owns the Claude panes."
759                    .to_string(),
760            );
761
762            for action in [TerminalAction::Launch, TerminalAction::Switch] {
763                actions.push(action_check(action, action_status, detail, fix.clone()));
764            }
765            for action in [TerminalAction::Input, TerminalAction::Approve] {
766                actions.push(action_check(
767                    action,
768                    DoctorStatus::Unsupported,
769                    "WezTerm integration currently supports launch and pane focus only.",
770                    None::<String>,
771                ));
772            }
773            notes.push("WezTerm input injection is not implemented yet.".to_string());
774        }
775        #[cfg(target_os = "macos")]
776        Terminal::Ghostty => {
777            let apple_script_check = binary_check("osascript");
778            let apple_script_ready = apple_script_check.status == DoctorStatus::Ready;
779            prerequisites.push(apple_script_check);
780
781            let detail = if apple_script_ready {
782                "Ghostty exposes switch/input/approve through its AppleScript API."
783            } else {
784                "Ghostty support requires `osascript`."
785            };
786            let status = if apple_script_ready {
787                DoctorStatus::Ready
788            } else {
789                DoctorStatus::Blocked
790            };
791            let fix = Some(
792                "Ensure macOS automation tools are available and Ghostty is running normally."
793                    .to_string(),
794            );
795
796            for action in supported_actions(&Terminal::Ghostty) {
797                actions.push(action_check(action, status, detail, fix.clone()));
798            }
799            actions.push(action_check(
800                TerminalAction::Launch,
801                DoctorStatus::Unsupported,
802                "Visible launch is only implemented for tmux, Kitty, and WezTerm.",
803                None::<String>,
804            ));
805            notes.push("Ghostty does not need Kitty-style remote control setup, but macOS may still prompt for automation access.".to_string());
806        }
807        #[cfg(target_os = "macos")]
808        Terminal::Warp | Terminal::ITerm2 | Terminal::Apple => {
809            let apple_script_check = binary_check("osascript");
810            let apple_script_ready = apple_script_check.status == DoctorStatus::Ready;
811            prerequisites.push(apple_script_check);
812
813            let system_events_check = if apple_script_ready {
814                match probe_system_events_access() {
815                    Ok(()) => DoctorCheck::ready(
816                        "System Events access",
817                        "AppleScript can talk to System Events from this shell.",
818                    ),
819                    Err(err) => DoctorCheck::blocked(
820                        "System Events access",
821                        format!("macOS UI scripting is not currently available: {err}"),
822                        Some(
823                            "Grant Automation/Accessibility access in System Settings > Privacy & Security, then rerun `claudectl --doctor`."
824                                .to_string(),
825                        ),
826                    ),
827                }
828            } else {
829                DoctorCheck::blocked(
830                    "System Events access",
831                    "`osascript` is unavailable, so macOS UI scripting cannot run.",
832                    Some(
833                        "Ensure `/usr/bin/osascript` is available and rerun the doctor."
834                            .to_string(),
835                    ),
836                )
837            };
838            let system_events_ready = system_events_check.status == DoctorStatus::Ready;
839            prerequisites.push(system_events_check);
840
841            actions.push(action_check(
842                TerminalAction::Launch,
843                DoctorStatus::Unsupported,
844                "Visible launch is only implemented for tmux, Kitty, and WezTerm.",
845                None::<String>,
846            ));
847
848            let status = if apple_script_ready && system_events_ready {
849                DoctorStatus::Ready
850            } else {
851                DoctorStatus::Blocked
852            };
853            let detail = format!(
854                "{} uses AppleScript and System Events for focus and input control.",
855                terminal_name(&terminal)
856            );
857            let fix = Some(
858                "Grant Automation/Accessibility permissions to the terminal and rerun `claudectl --doctor`."
859                    .to_string(),
860            );
861            for action in [
862                TerminalAction::Switch,
863                TerminalAction::Input,
864                TerminalAction::Approve,
865            ] {
866                actions.push(action_check(action, status, &detail, fix.clone()));
867            }
868        }
869        Terminal::Unknown(name) => {
870            for action in [
871                TerminalAction::Launch,
872                TerminalAction::Switch,
873                TerminalAction::Input,
874                TerminalAction::Approve,
875            ] {
876                actions.push(action_check(
877                    action,
878                    DoctorStatus::Unsupported,
879                    format!(
880                        "No integration is configured for `{name}`. Supported terminals: GNOME Terminal, Windows Terminal on WSL, tmux, Kitty, WezTerm, Ghostty, Warp, iTerm2, Terminal.app."
881                    ),
882                    None::<String>,
883                ));
884            }
885            notes.push(
886                "Monitoring still works in unsupported terminals, but control actions stay manual."
887                    .to_string(),
888            );
889        }
890        #[cfg(not(target_os = "macos"))]
891        Terminal::Ghostty | Terminal::Warp | Terminal::ITerm2 | Terminal::Apple => {
892            for action in [
893                TerminalAction::Launch,
894                TerminalAction::Switch,
895                TerminalAction::Input,
896                TerminalAction::Approve,
897            ] {
898                actions.push(action_check(
899                    action,
900                    DoctorStatus::Unsupported,
901                    format!(
902                        "{} control hooks are currently only implemented on macOS.",
903                        terminal_name(&terminal)
904                    ),
905                    None::<String>,
906                ));
907            }
908            notes.push(
909                "Monitoring still works in unsupported terminals, but control actions stay manual."
910                    .to_string(),
911            );
912        }
913    }
914
915    if !command_ready("claude") {
916        notes.push("Launching a new session will fail until `claude` is on PATH.".to_string());
917    }
918
919    DoctorReport {
920        terminal: terminal_label,
921        platform: platform_name(),
922        actions,
923        prerequisites,
924        notes,
925    }
926}
927
928pub fn format_doctor_report(report: &DoctorReport) -> String {
929    let mut lines = vec![
930        "claudectl doctor".to_string(),
931        String::new(),
932        format!("Platform: {}", report.platform),
933        format!("Detected terminal: {}", report.terminal),
934        String::new(),
935        "Prerequisites".to_string(),
936    ];
937
938    for check in &report.prerequisites {
939        lines.push(format!(
940            "  [{}] {}: {}",
941            check.status.label(),
942            check.name,
943            check.detail
944        ));
945        if let Some(fix) = &check.fix {
946            lines.push(format!("      fix: {fix}"));
947        }
948    }
949
950    lines.push(String::new());
951    lines.push("Capabilities".to_string());
952    for action in &report.actions {
953        lines.push(format!(
954            "  [{}] {}: {}",
955            action.status.label(),
956            action.name,
957            action.detail
958        ));
959        if let Some(fix) = &action.fix {
960            lines.push(format!("      fix: {fix}"));
961        }
962    }
963
964    if !report.notes.is_empty() {
965        lines.push(String::new());
966        lines.push("Notes".to_string());
967        for note in &report.notes {
968            lines.push(format!("  - {note}"));
969        }
970    }
971
972    lines.join("\n")
973}
974
975pub fn launch_session(
976    cwd: &str,
977    prompt: Option<&str>,
978    resume: Option<&str>,
979) -> Result<String, String> {
980    let terminal = detect_terminal();
981    match terminal {
982        Terminal::Gnome => gnome_terminal::launch(cwd, prompt, resume),
983        Terminal::Kitty => kitty::launch(cwd, prompt, resume),
984        Terminal::Tmux => tmux::launch(cwd, prompt, resume),
985        Terminal::WezTerm => wezterm::launch(cwd, prompt, resume),
986        Terminal::WindowsTerm => windows_terminal::launch(cwd, prompt, resume),
987        other => Err(format!(
988            "Visible session launch is not supported in {}. Start `claude` manually, use tmux/Kitty/WezTerm/GNOME Terminal/Windows Terminal on WSL, or run `claudectl --doctor` for setup guidance.",
989            terminal_name(&other)
990        )),
991    }
992}
993
994pub fn switch_to_terminal(session: &ClaudeSession) -> Result<(), String> {
995    let terminal = detect_terminal();
996
997    // Only require a TTY for terminals that match sessions by TTY name.
998    // Kitty, Ghostty, and Warp use their own IPC (PID/cwd matching) and don't need it.
999    let needs_tty = matches!(
1000        terminal,
1001        Terminal::Tmux | Terminal::WezTerm | Terminal::Apple | Terminal::ITerm2
1002    );
1003    if needs_tty && session.tty.is_empty() {
1004        return Err("No TTY associated with this session".into());
1005    }
1006    crate::logger::log(
1007        "DEBUG",
1008        &format!(
1009            "terminal switch: {} (tty={}) via {:?}",
1010            session.display_name(),
1011            session.tty,
1012            terminal_name(&terminal)
1013        ),
1014    );
1015
1016    match terminal {
1017        Terminal::Gnome => gnome_terminal::switch(session),
1018        Terminal::Kitty => kitty::switch(session),
1019        Terminal::WezTerm => wezterm::switch(session),
1020        Terminal::Tmux => tmux::switch(session),
1021        Terminal::WindowsTerm => Err(
1022            "Windows Terminal currently supports WSL launch only. Use tmux or Kitty inside WSL for session switching."
1023                .into(),
1024        ),
1025        #[cfg(target_os = "macos")]
1026        Terminal::Ghostty => ghostty::switch(session),
1027        #[cfg(target_os = "macos")]
1028        Terminal::Warp => warp::switch(session),
1029        #[cfg(target_os = "macos")]
1030        Terminal::ITerm2 => iterm2::switch(session),
1031        #[cfg(target_os = "macos")]
1032        Terminal::Apple => apple::switch(session),
1033        Terminal::Unknown(name) => Err(format!(
1034            "Unsupported terminal: {name}. Supported: GNOME Terminal, Windows Terminal on WSL (launch only), Ghostty, Warp, iTerm2, Kitty, WezTerm, Terminal.app, tmux. Run `claudectl --doctor` for details."
1035        )),
1036        #[cfg(not(target_os = "macos"))]
1037        _ => Err("Terminal switching not supported on this platform. Run `claudectl --doctor` for details.".into()),
1038    }
1039}
1040
1041pub fn send_input(session: &ClaudeSession, text: &str) -> Result<(), String> {
1042    match detect_terminal() {
1043        Terminal::Gnome => gnome_terminal::send_input(session, text),
1044        #[cfg(target_os = "macos")]
1045        Terminal::Ghostty => ghostty::send_input(session, text),
1046        Terminal::Kitty => kitty::send_input(session, text),
1047        Terminal::Tmux => tmux::send_input(session, text),
1048        Terminal::WindowsTerm => Err(
1049            "Windows Terminal currently supports WSL launch only. Use tmux or Kitty inside WSL for session input automation."
1050                .into(),
1051        ),
1052        #[cfg(target_os = "macos")]
1053        Terminal::Warp => warp::send_input(session, text),
1054        #[cfg(target_os = "macos")]
1055        _ => {
1056            // iTerm2, Apple Terminal, etc: switch + System Events keystroke
1057            switch_to_terminal(session)?;
1058            std::thread::sleep(std::time::Duration::from_millis(300));
1059            let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
1060            run_osascript(&format!(
1061                r#"tell application "System Events" to keystroke "{escaped}""#,
1062            ))
1063        }
1064        #[cfg(not(target_os = "macos"))]
1065        _ => Err("Input injection not supported for this terminal. Run `claudectl --doctor` for details.".into()),
1066    }
1067}
1068
1069pub fn approve_session(session: &ClaudeSession) -> Result<(), String> {
1070    match detect_terminal() {
1071        Terminal::Gnome => gnome_terminal::approve(session),
1072        #[cfg(target_os = "macos")]
1073        Terminal::Ghostty => ghostty::approve(session),
1074        Terminal::Kitty => kitty::approve(session),
1075        Terminal::Tmux => tmux::send_input(session, "\r"),
1076        Terminal::WindowsTerm => Err(
1077            "Windows Terminal currently supports WSL launch only. Use tmux or Kitty inside WSL for approval automation."
1078                .into(),
1079        ),
1080        #[cfg(target_os = "macos")]
1081        Terminal::Warp => warp::approve(session),
1082        #[cfg(target_os = "macos")]
1083        _ => {
1084            // iTerm2, Apple Terminal, etc: switch + press Enter
1085            switch_to_terminal(session)?;
1086            std::thread::sleep(std::time::Duration::from_millis(300));
1087            run_osascript(r#"tell application "System Events" to key code 36"#)
1088        }
1089        #[cfg(not(target_os = "macos"))]
1090        _ => Err("Input injection not supported for this terminal. Run `claudectl --doctor` for details.".into()),
1091    }
1092}
1093
1094#[cfg(target_os = "macos")]
1095pub fn run_osascript(script: &str) -> Result<(), String> {
1096    let output = std::process::Command::new("osascript")
1097        .args(["-e", script])
1098        .output()
1099        .map_err(|e| format!("Failed to run osascript: {e}"))?;
1100
1101    if output.status.success() {
1102        Ok(())
1103    } else {
1104        let stderr = String::from_utf8_lossy(&output.stderr);
1105        Err(format!("AppleScript error: {}", stderr.trim()))
1106    }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112
1113    #[test]
1114    fn help_summary_lists_kitty_actions() {
1115        let summary = help_capability_summary_for(&Terminal::Kitty);
1116        assert_eq!(
1117            summary,
1118            "Current terminal: Kitty (launch, switch, input, approve)"
1119        );
1120    }
1121
1122    #[test]
1123    fn help_summary_marks_unknown_terminal_monitor_only() {
1124        let summary = help_capability_summary_for(&Terminal::Unknown("foot".into()));
1125        assert_eq!(summary, "Current terminal: foot (monitor-only)");
1126    }
1127
1128    #[test]
1129    fn help_summary_mentions_gnome_terminal() {
1130        let summary = help_capability_summary_for(&Terminal::Gnome);
1131        assert!(summary.starts_with("Current terminal: GNOME Terminal"));
1132    }
1133
1134    #[test]
1135    fn help_summary_lists_windows_terminal_launch() {
1136        let summary = help_capability_summary_for(&Terminal::WindowsTerm);
1137        assert_eq!(summary, "Current terminal: Windows Terminal (launch)");
1138    }
1139
1140    #[test]
1141    fn doctor_report_for_unknown_terminal_marks_actions_unsupported() {
1142        let report = doctor_report_for(Terminal::Unknown("foot".into()));
1143        assert_eq!(report.actions.len(), 4);
1144        assert!(
1145            report
1146                .actions
1147                .iter()
1148                .all(|action| action.status == DoctorStatus::Unsupported)
1149        );
1150    }
1151
1152    #[test]
1153    fn platform_label_marks_wsl_explicitly() {
1154        assert_eq!(platform_label("linux", true), "linux (WSL)");
1155        assert_eq!(platform_label("macos", false), "macos");
1156    }
1157
1158    #[test]
1159    fn environment_notes_describe_wsl_interop_state() {
1160        let notes = environment_notes(true, true);
1161        assert!(notes.iter().any(|note| note.contains("WSL detected")));
1162        assert!(notes.iter().any(|note| note.contains("cmd.exe /c wt.exe")));
1163    }
1164
1165    #[test]
1166    fn wsl_interop_check_reports_when_available() {
1167        let check = wsl_interop_check(true).unwrap();
1168        assert_eq!(check.name, "Windows Terminal interop");
1169    }
1170
1171    // Native env var detection tests.
1172    // These mutate env vars and must be serialized.
1173    use std::sync::Mutex;
1174    static ENV_LOCK: Mutex<()> = Mutex::new(());
1175
1176    /// Helper: clear all terminal-related env vars, run f(), then restore.
1177    fn with_clean_env<F: FnOnce() -> R, R>(f: F) -> R {
1178        let _guard = ENV_LOCK.lock().unwrap();
1179
1180        let keys = [
1181            "KITTY_WINDOW_ID",
1182            "KITTY_PID",
1183            "WEZTERM_EXECUTABLE",
1184            "GHOSTTY_RESOURCES_DIR",
1185            "TERM",
1186            "TERM_PROGRAM",
1187            "TMUX",
1188            "GNOME_TERMINAL_SERVICE",
1189            "GNOME_TERMINAL_SCREEN",
1190            "WT_SESSION",
1191        ];
1192        let saved: Vec<(&str, Option<String>)> =
1193            keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
1194
1195        for key in &keys {
1196            unsafe { std::env::remove_var(key) };
1197        }
1198
1199        let result = f();
1200
1201        for (key, val) in saved {
1202            match val {
1203                Some(v) => unsafe { std::env::set_var(key, v) },
1204                None => unsafe { std::env::remove_var(key) },
1205            }
1206        }
1207
1208        result
1209    }
1210
1211    #[test]
1212    fn detect_kitty_via_kitty_window_id() {
1213        with_clean_env(|| {
1214            unsafe { std::env::set_var("KITTY_WINDOW_ID", "49") };
1215            assert_eq!(detect_by_native_env(), Some(Terminal::Kitty));
1216        });
1217    }
1218
1219    #[test]
1220    fn detect_kitty_via_term_xterm_kitty() {
1221        with_clean_env(|| {
1222            unsafe { std::env::set_var("TERM", "xterm-kitty") };
1223            assert_eq!(detect_by_native_env(), Some(Terminal::Kitty));
1224        });
1225    }
1226
1227    #[test]
1228    fn detect_wezterm_via_wezterm_executable() {
1229        with_clean_env(|| {
1230            unsafe { std::env::set_var("WEZTERM_EXECUTABLE", "/usr/bin/wezterm") };
1231            assert_eq!(detect_by_native_env(), Some(Terminal::WezTerm));
1232        });
1233    }
1234
1235    #[test]
1236    fn detect_ghostty_via_ghostty_resources_dir() {
1237        with_clean_env(|| {
1238            unsafe { std::env::set_var("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty") };
1239            assert_eq!(detect_by_native_env(), Some(Terminal::Ghostty));
1240        });
1241    }
1242
1243    #[test]
1244    fn detect_native_env_returns_none_when_clean() {
1245        with_clean_env(|| {
1246            assert_eq!(detect_by_native_env(), None);
1247        });
1248    }
1249
1250    #[test]
1251    fn kitty_window_id_takes_priority_over_term_xterm_kitty() {
1252        // Both set — KITTY_WINDOW_ID should match first (stronger signal)
1253        with_clean_env(|| {
1254            unsafe {
1255                std::env::set_var("KITTY_WINDOW_ID", "1");
1256                std::env::set_var("TERM", "xterm-kitty");
1257            }
1258            assert_eq!(detect_by_native_env(), Some(Terminal::Kitty));
1259        });
1260    }
1261}