feat(B.1 #10): detect focused terminal and switch to clipboard-only paste
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

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 <jakejars@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-21 11:41:35 +00:00
committed by Jake
parent df58d98adc
commit ded8811ca9

View File

@@ -87,6 +87,24 @@ pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<PasteOutc
}
}
// Brief item #10: if the focused window is a known terminal
// emulator, skip the synthesised Ctrl+V — it duplicates the
// keystroke through the PTY, which Kitty / Alacritty / Windows
// Terminal all demonstrably mishandle (Handy #692). Clipboard-only
// paste with a "Terminal detected" message lets the user finish
// with a manual right-click paste or Ctrl+Shift+V.
if let Some(term) = detect_focused_terminal() {
outcome.message = Some(format!(
"Terminal detected ({term}) — transcript is on your clipboard. \
Use Ctrl+Shift+V (or right-click → paste) to insert."
));
// Prior clipboard snapshot is intentionally NOT restored here:
// the user's path to recover the transcript is their own paste,
// which needs the transcript on the clipboard. The restore
// task below only runs when `outcome.pasted` is true.
return Ok(outcome);
}
hide_preview_overlay_for_paste(&app).await;
match trigger_paste_keystroke() {
@@ -189,6 +207,152 @@ pub fn detect_paste_backends() -> Vec<String> {
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<String> {
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<String> {
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<String> {
#[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<String> {
// 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<String> {
// `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<String> {
// 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<String, String> {
#[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()),
);
}
}