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