From 4c0c876ade1a47a11ada47dcc5d662c92c5ddc8c Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 21 Apr 2026 07:45:16 +0100 Subject: [PATCH] feat(paste): auto-insert transcript at cursor via wtype/xdotool/ydotool/osascript/SendKeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in "auto-paste into focused window" toggle. When enabled, the dictation pipeline sets the clipboard and then sends a Ctrl+V / Cmd+V keystroke to whatever window currently has focus — the common case after a global-hotkey dictation, since Kon's own window never stole focus. Backend (src-tauri/src/commands/paste.rs) probes for a platform paste tool and falls back cleanly: - Linux Wayland: wtype > ydotool > xdotool - Linux X11: xdotool > ydotool > wtype - macOS: osascript System Events keystroke - Windows: PowerShell WScript.Shell SendKeys detect_paste_backends is a pure probe used by Settings to describe the available backend next to the toggle (or nudge the user to install one). paste_text always copies first, so auto-paste failure degrades to the existing clipboard-only behaviour and surfaces a warn toast. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/paste.rs | 264 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 3 + src/lib/pages/DictationPage.svelte | 17 +- src/lib/pages/SettingsPage.svelte | 18 ++ src/lib/stores/page.svelte.ts | 1 + src/lib/types/app.ts | 1 + 7 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/commands/paste.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 99fdcdd..206048b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod hotkey; pub mod live; pub mod llm; pub mod models; +pub mod paste; pub mod profiles; pub mod tasks; pub mod transcription; diff --git a/src-tauri/src/commands/paste.rs b/src-tauri/src/commands/paste.rs new file mode 100644 index 0000000..8ba9bf0 --- /dev/null +++ b/src-tauri/src/commands/paste.rs @@ -0,0 +1,264 @@ +//! Auto-insert-at-cursor. +//! +//! `copy_to_clipboard` puts the transcript on the user's clipboard. That is +//! fine when the user wants to choose where it lands. It is friction when +//! they were about to paste into the already-focused window — which is +//! almost always, for a dictation tool. +//! +//! This module adds the follow-on step: send the platform's Ctrl+V / Cmd+V +//! keystroke to the focused window so the transcript lands where the cursor +//! already is. Each platform uses a different primitive (wtype / xdotool / +//! ydotool / osascript / SendKeys), so we probe and fall back. +//! +//! NOTE: focus must already be on the target window when the keystroke +//! fires. The global hotkey flow preserves this naturally; clicking Kon's +//! window does not. The frontend surfaces this caveat next to the toggle. + +use std::process::Command; + +use arboard::Clipboard; +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasteOutcome { + /// The backend that executed the paste, if any (`"wtype"`, `"xdotool"`, + /// `"ydotool"`, `"osascript"`, `"sendkeys"`). + pub backend: Option, + pub pasted: bool, + pub copied: bool, + /// Diagnostic message when either step failed. Present even on success + /// if the paste fell back to copy-only so the frontend can toast it. + pub message: Option, +} + +/// Copy `text` to the clipboard, then trigger a paste keystroke in the +/// focused window. Returns a structured result so the frontend can surface +/// partial success (clipboard set, paste failed). +#[tauri::command] +pub async fn paste_text(text: String) -> Result { + let mut outcome = PasteOutcome { + backend: None, + pasted: false, + copied: false, + message: None, + }; + + match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) { + Ok(()) => outcome.copied = true, + Err(err) => { + outcome.message = Some(format!("clipboard: {err}")); + return Ok(outcome); + } + } + + match trigger_paste_keystroke() { + Ok(backend) => { + outcome.backend = Some(backend); + outcome.pasted = true; + } + Err(err) => outcome.message = Some(err), + } + + Ok(outcome) +} + +/// Report which paste backends the OS has available right now. Pure probe — +/// does not paste. Used by Settings to tell the user "install wtype" when +/// nothing is available on their session. +#[tauri::command] +pub fn detect_paste_backends() -> Vec { + let mut available = Vec::new(); + + #[cfg(target_os = "linux")] + { + for tool in ["wtype", "xdotool", "ydotool"] { + if which_on_path(tool) { + available.push(tool.to_string()); + } + } + } + #[cfg(target_os = "macos")] + { + available.push("osascript".to_string()); + } + #[cfg(target_os = "windows")] + { + available.push("sendkeys".to_string()); + } + + available +} + +fn trigger_paste_keystroke() -> Result { + #[cfg(target_os = "linux")] + { + linux_paste( + std::env::var("XDG_SESSION_TYPE").ok().as_deref(), + std::env::var_os("WAYLAND_DISPLAY").is_some(), + ) + } + #[cfg(target_os = "macos")] + { + macos_paste() + } + #[cfg(target_os = "windows")] + { + windows_paste() + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + Err("auto-paste not implemented on this platform".into()) + } +} + +#[cfg(target_os = "linux")] +fn linux_paste(xdg_session_type: Option<&str>, wayland_display_set: bool) -> Result { + for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) { + match run_linux_tool(tool) { + Ok(()) => return Ok(tool.to_string()), + Err(_) => continue, + } + } + Err( + "No paste backend available. Install wtype (Wayland) or xdotool (X11) to enable \ + auto-insert-at-cursor." + .into(), + ) +} + +#[cfg(target_os = "linux")] +fn pick_linux_backend_order( + xdg_session_type: Option<&str>, + wayland_display_set: bool, +) -> &'static [&'static str] { + let is_wayland = xdg_session_type + .map(|value| value.eq_ignore_ascii_case("wayland")) + .unwrap_or(false) + || wayland_display_set; + if is_wayland { + &["wtype", "ydotool", "xdotool"] + } else { + &["xdotool", "ydotool", "wtype"] + } +} + +#[cfg(target_os = "linux")] +fn run_linux_tool(tool: &str) -> Result<(), String> { + let output = match tool { + // wtype -M ctrl v -m ctrl (press ctrl, tap v, release ctrl) + "wtype" => Command::new("wtype") + .args(["-M", "ctrl", "v", "-m", "ctrl"]) + .output(), + "xdotool" => Command::new("xdotool").args(["key", "ctrl+v"]).output(), + // ydotool linux input keycodes: 29=LEFTCTRL, 47=V. Format is + // `code:state` pairs. Requires ydotoold running with access to + // /dev/uinput. + "ydotool" => Command::new("ydotool") + .args(["key", "29:1", "47:1", "47:0", "29:0"]) + .output(), + other => return Err(format!("unknown backend: {other}")), + } + .map_err(|e| format!("{tool} unavailable: {e}"))?; + + if output.status.success() { + Ok(()) + } else { + Err(format!( + "{tool} exit {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +#[cfg(target_os = "linux")] +fn which_on_path(tool: &str) -> bool { + Command::new("sh") + .args(["-c", &format!("command -v {tool}")]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[cfg(target_os = "macos")] +fn macos_paste() -> Result { + let output = Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to keystroke \"v\" using command down", + ]) + .output() + .map_err(|e| format!("osascript: {e}"))?; + if output.status.success() { + Ok("osascript".into()) + } else { + Err(format!( + "osascript exit {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +#[cfg(target_os = "windows")] +fn windows_paste() -> Result { + // SendKeys("^v") simulates Ctrl+V in the foreground window. Requires + // no extra permissions on Windows. A native SendInput call would skip + // the PowerShell spawn but pulls in the windows crate; cost not worth + // the complexity until this hot path actually shows up. + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "(New-Object -ComObject WScript.Shell).SendKeys('^v')", + ]) + .output() + .map_err(|e| format!("powershell: {e}"))?; + if output.status.success() { + Ok("sendkeys".into()) + } else { + Err(format!( + "powershell exit {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::pick_linux_backend_order; + + #[test] + fn wayland_session_prefers_wtype_then_ydotool() { + assert_eq!( + pick_linux_backend_order(Some("wayland"), true), + &["wtype", "ydotool", "xdotool"] + ); + } + + #[test] + fn x11_session_prefers_xdotool() { + assert_eq!( + pick_linux_backend_order(Some("x11"), false), + &["xdotool", "ydotool", "wtype"] + ); + } + + #[test] + fn wayland_display_env_var_alone_is_enough() { + assert_eq!( + pick_linux_backend_order(None, true), + &["wtype", "ydotool", "xdotool"] + ); + } + + #[test] + fn uppercase_wayland_token_still_detects_wayland() { + assert_eq!( + pick_linux_backend_order(Some("WAYLAND"), false), + &["wtype", "ydotool", "xdotool"] + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9bc8ab3..9021a5c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -307,6 +307,9 @@ pub fn run() { commands::windows::open_viewer_window, // Clipboard commands::clipboard::copy_to_clipboard, + // Paste (auto-insert at cursor) + commands::paste::paste_text, + commands::paste::detect_paste_backends, // Hardware commands::hardware::probe_system, commands::hardware::rank_models, diff --git a/src/lib/pages/DictationPage.svelte b/src/lib/pages/DictationPage.svelte index b3ca6fd..cb2674e 100644 --- a/src/lib/pages/DictationPage.svelte +++ b/src/lib/pages/DictationPage.svelte @@ -479,7 +479,22 @@ replaceSegmentsWithCleanedText(cleanedTranscript); } - if (settings.autoCopy) { + if (settings.autoPaste) { + try { + const outcome = await invoke("paste_text", { text: transcript }); + if (!outcome?.pasted && outcome?.message) { + toasts.warn( + "Auto-paste unavailable", + `${outcome.message}${outcome.copied ? ". Transcript is on your clipboard." : ""}`, + ); + } + } catch (err) { + await navigator.clipboard + .writeText(transcript) + .catch(() => invoke("copy_to_clipboard", { text: transcript }).catch(() => {})); + toasts.warn("Auto-paste failed", String(err)); + } + } else if (settings.autoCopy) { navigator.clipboard.writeText(transcript).catch(() => { invoke("copy_to_clipboard", { text: transcript }).catch(() => {}); }); diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index b2c1bb9..320e60d 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -22,6 +22,13 @@ let engineOk = $state(false); let downloadedModels = $state([]); let runtimeCapabilities = $state(null); + let pasteBackends = $state([]); + let pasteBackendsDescription = $derived.by(() => { + if (pasteBackends.length === 0) { + return "Install wtype (Wayland) or xdotool (X11) to enable auto-paste. Focus must already be on the target window."; + } + return `Uses ${pasteBackends.join(", ")}. Focus must already be on the target window.`; + }); let downloadingModel = $state(""); let downloadProgress = $state(0); let unlisten = null; @@ -541,6 +548,12 @@ downloadedModels = await invoke("list_models"); } catch {} + try { + pasteBackends = (await invoke("detect_paste_backends")) || []; + } catch { + pasteBackends = []; + } + // Parakeet status try { parakeetOk = await invoke("check_parakeet_engine"); @@ -1498,6 +1511,11 @@
+