feat(B.1 #10): detect focused terminal and switch to clipboard-only paste
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:
@@ -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;
|
hide_preview_overlay_for_paste(&app).await;
|
||||||
|
|
||||||
match trigger_paste_keystroke() {
|
match trigger_paste_keystroke() {
|
||||||
@@ -189,6 +207,152 @@ pub fn detect_paste_backends() -> Vec<String> {
|
|||||||
available
|
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> {
|
fn trigger_paste_keystroke() -> Result<String, String> {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
@@ -389,3 +553,51 @@ mod tests_clipboard_restore {
|
|||||||
assert!(!should_restore(Some(""), "dictated text"));
|
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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user