From ded8811ca9b0049076bea68acea1e0456c150c60 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Apr 2026 11:41:35 +0000 Subject: [PATCH] feat(B.1 #10): detect focused terminal and switch to clipboard-only paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends commands/paste.rs::paste_text with a pre-keystroke check: if GetForegroundWindow (Windows) / xdotool getactivewindow (Linux X11) / 'tell System Events ...' (macOS) reports a focused app class matching KNOWN_TERMINAL_CLASSES, skip the synthesised Ctrl+V and return an outcome with copied=true, pasted=false, and a user-facing message ('Terminal detected (kitty) — use Ctrl+Shift+V to insert'). Matches Handy #692: Kitty/Alacritty/Windows Terminal/Codex CLI all double-insert the transcript when a PTY sees both a synthesised Ctrl+V and the terminal's own paste hotkey. The terminal list is ordered most-specific-first so 'windowsterminal' wins over the generic 'terminal' needle. Adds classify_terminal() as a pure helper + seven unit tests. The platform probe (detect_focused_window_class) isolates the fragile shell-out so the classification rule is test-covered without needing a real desktop. Wayland doesn't expose a reliable focused-window API to unprivileged clients, so it conservatively returns None and the normal paste path runs — consistent with Kon's Wayland-Pipewire lane. Prior-clipboard restore (#3) is intentionally skipped when terminal mode takes over: the user's path to insert the transcript is their own paste, which needs the transcript on the clipboard. Co-authored-by: jars --- src-tauri/src/commands/paste.rs | 212 ++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/src-tauri/src/commands/paste.rs b/src-tauri/src/commands/paste.rs index 4b6917d..df316de 100644 --- a/src-tauri/src/commands/paste.rs +++ b/src-tauri/src/commands/paste.rs @@ -87,6 +87,24 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result Vec { available } +/// Well-known terminal-emulator window classes / process names. Each +/// entry is matched case-insensitively against whatever the +/// focused-window probe returns on each platform. The list is +/// deliberately conservative — users can always copy + paste manually, +/// so false negatives are cheap; false positives silently break +/// auto-paste on a legitimate GUI, which is expensive. +/// Longest / most specific matches come first so that `classify_terminal` +/// returns the specific name when two needles would both hit +/// (e.g. "windowsterminal" contains "terminal"). Substring match is +/// otherwise blunt enough that a `foot`/`pwsh` match on a non-terminal +/// window would be vanishingly unlikely. +const KNOWN_TERMINAL_CLASSES: &[&str] = &[ + // Windows — specific names first so they win over plain "terminal". + "windowsterminal", + "powershell", + "conhost", + "console", + "pwsh", + "cmd", + // macOS + "iterm2", + "iterm", + "terminal", + // Linux + "gnome-terminal-server", + "alacritty", + "konsole", + "wezterm", + "kitty", + "tilix", + "xterm", + "urxvt", + "st-256color", + "hyper", + "foot", +]; + +/// Return `Some(class)` when the focused window matches a known +/// terminal emulator, `None` otherwise. Pure by design: the actual +/// platform probe is isolated in `detect_focused_window_class` so the +/// classification rule is unit-testable. +fn detect_focused_terminal() -> Option { + let raw = detect_focused_window_class()?; + classify_terminal(&raw) +} + +/// Case-insensitive substring match of the focused window class / +/// process name against `KNOWN_TERMINAL_CLASSES`. Returns the matched +/// needle (for user-facing copy: "Terminal detected (kitty)") when +/// positive. +fn classify_terminal(raw: &str) -> Option { + let haystack = raw.to_ascii_lowercase(); + for needle in KNOWN_TERMINAL_CLASSES { + if haystack.contains(needle) { + return Some((*needle).to_string()); + } + } + None +} + +/// Probe the focused window's class name / process name. Returns +/// `None` when the probe tool is missing or fails — we fall back to +/// the default auto-paste path rather than guessing wrong and +/// breaking a GUI user's workflow. +fn detect_focused_window_class() -> Option { + #[cfg(target_os = "linux")] + { + return detect_focused_window_class_linux(); + } + #[cfg(target_os = "macos")] + { + return detect_focused_window_class_macos(); + } + #[cfg(target_os = "windows")] + { + return detect_focused_window_class_windows(); + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + return None; + } +} + +#[cfg(target_os = "linux")] +fn detect_focused_window_class_linux() -> Option { + // X11: xdotool getactivewindow getwindowclassname + if let Ok(output) = Command::new("xdotool") + .args(["getactivewindow", "getwindowclassname"]) + .output() + { + if output.status.success() { + let class = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !class.is_empty() { + return Some(class); + } + } + } + // Wayland: no reliable focused-window probe from an unprivileged + // client (by design). Return None and let the normal paste path + // run; users on Kitty-under-Wayland already lean on Ctrl+Shift+V + // themselves. + None +} + +#[cfg(target_os = "macos")] +fn detect_focused_window_class_macos() -> Option { + // `osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'` + let output = Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to get name of first application process whose frontmost is true", + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if name.is_empty() { None } else { Some(name) } +} + +#[cfg(target_os = "windows")] +fn detect_focused_window_class_windows() -> Option { + // PowerShell one-liner: (Get-Process -Id (Get-Process | Where-Object ... + // Keeping it simple here — GetForegroundWindow + GetWindowThreadProcessId + // via PowerShell works without a `windows` crate dep. + let script = r#" + $w = Add-Type -MemberDefinition ' + [DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(System.IntPtr hWnd, out int pid); + ' -Name W -PassThru + $pid = 0 + [void]$w::GetWindowThreadProcessId($w::GetForegroundWindow(), [ref]$pid) + (Get-Process -Id $pid).ProcessName + "#; + let output = Command::new("powershell") + .args(["-NoProfile", "-Command", script]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if name.is_empty() { None } else { Some(name) } +} + fn trigger_paste_keystroke() -> Result { #[cfg(target_os = "linux")] { @@ -389,3 +553,51 @@ mod tests_clipboard_restore { assert!(!should_restore(Some(""), "dictated text")); } } + +#[cfg(test)] +mod tests_terminal_classification { + use super::classify_terminal; + + #[test] + fn kitty_class_is_a_terminal() { + assert_eq!(classify_terminal("kitty"), Some("kitty".into())); + } + + #[test] + fn alacritty_matches_case_insensitively() { + assert_eq!(classify_terminal("Alacritty"), Some("alacritty".into())); + } + + #[test] + fn windows_terminal_matches_collapsed_name() { + // Windows reports the process name sans space: "WindowsTerminal" + assert_eq!( + classify_terminal("WindowsTerminal"), + Some("windowsterminal".into()), + ); + } + + #[test] + fn iterm_matches_iterm2_as_well() { + assert_eq!(classify_terminal("iTerm2"), Some("iterm2".into())); + } + + #[test] + fn firefox_is_not_a_terminal() { + assert_eq!(classify_terminal("firefox"), None); + } + + #[test] + fn empty_class_is_not_a_terminal() { + assert_eq!(classify_terminal(""), None); + } + + #[test] + fn substring_match_survives_xdotool_decorations() { + // Some X11 window managers report "Alacritty.Alacritty" + assert_eq!( + classify_terminal("Alacritty.Alacritty"), + Some("alacritty".into()), + ); + } +}