feat(paste): auto-insert transcript at cursor via wtype/xdotool/ydotool/osascript/SendKeys

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 07:45:16 +01:00
parent 36efcf2320
commit 4c0c876ade
7 changed files with 304 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ pub mod hotkey;
pub mod live; pub mod live;
pub mod llm; pub mod llm;
pub mod models; pub mod models;
pub mod paste;
pub mod profiles; pub mod profiles;
pub mod tasks; pub mod tasks;
pub mod transcription; pub mod transcription;

View File

@@ -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<String>,
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<String>,
}
/// 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<PasteOutcome, String> {
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<String> {
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<String, String> {
#[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<String, String> {
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<String, String> {
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<String, String> {
// 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"]
);
}
}

View File

@@ -307,6 +307,9 @@ pub fn run() {
commands::windows::open_viewer_window, commands::windows::open_viewer_window,
// Clipboard // Clipboard
commands::clipboard::copy_to_clipboard, commands::clipboard::copy_to_clipboard,
// Paste (auto-insert at cursor)
commands::paste::paste_text,
commands::paste::detect_paste_backends,
// Hardware // Hardware
commands::hardware::probe_system, commands::hardware::probe_system,
commands::hardware::rank_models, commands::hardware::rank_models,

View File

@@ -479,7 +479,22 @@
replaceSegmentsWithCleanedText(cleanedTranscript); 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(() => { navigator.clipboard.writeText(transcript).catch(() => {
invoke("copy_to_clipboard", { text: transcript }).catch(() => {}); invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
}); });

View File

@@ -22,6 +22,13 @@
let engineOk = $state(false); let engineOk = $state(false);
let downloadedModels = $state([]); let downloadedModels = $state([]);
let runtimeCapabilities = $state(null); 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 downloadingModel = $state("");
let downloadProgress = $state(0); let downloadProgress = $state(0);
let unlisten = null; let unlisten = null;
@@ -541,6 +548,12 @@
downloadedModels = await invoke("list_models"); downloadedModels = await invoke("list_models");
} catch {} } catch {}
try {
pasteBackends = (await invoke("detect_paste_backends")) || [];
} catch {
pasteBackends = [];
}
// Parakeet status // Parakeet status
try { try {
parakeetOk = await invoke("check_parakeet_engine"); parakeetOk = await invoke("check_parakeet_engine");
@@ -1498,6 +1511,11 @@
<div class="px-5 pb-5 animate-fade-in"> <div class="px-5 pb-5 animate-fade-in">
<div class="space-y-0.5"> <div class="space-y-0.5">
<Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" /> <Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" />
<Toggle
bind:checked={settings.autoPaste}
label="Auto-paste into focused window"
description={pasteBackendsDescription}
/>
<Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" /> <Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" />
<Toggle <Toggle
bind:checked={settings.saveAudio} bind:checked={settings.saveAudio}

View File

@@ -46,6 +46,7 @@ const defaults: SettingsState = {
antiHallucination: true, antiHallucination: true,
britishEnglish: true, britishEnglish: true,
autoCopy: true, autoCopy: true,
autoPaste: false,
includeTimestamps: true, includeTimestamps: true,
theme: "Dark", theme: "Dark",
fontSize: 14, fontSize: 14,

View File

@@ -36,6 +36,7 @@ export interface SettingsState {
antiHallucination: boolean; antiHallucination: boolean;
britishEnglish: boolean; britishEnglish: boolean;
autoCopy: boolean; autoCopy: boolean;
autoPaste: boolean;
includeTimestamps: boolean; includeTimestamps: boolean;
theme: "Dark" | "Light" | "System"; theme: "Dark" | "Light" | "System";
fontSize: number; fontSize: number;