diff --git a/src-tauri/src/commands/paste.rs b/src-tauri/src/commands/paste.rs index df316de..02d0b20 100644 --- a/src-tauri/src/commands/paste.rs +++ b/src-tauri/src/commands/paste.rs @@ -34,6 +34,12 @@ const CLIPBOARD_RESTORE_MS: u64 = 300; /// risks the keystroke still landing on the (now-invisible) overlay. const PREVIEW_HIDE_SETTLE_MS: u64 = 80; +/// Gap between the undo keystroke and the follow-up paste in the +/// replace flow (brief item #17). Most GUI apps process undo +/// instantly, but a short beat keeps the paste from racing the undo +/// on slower-to-redraw targets (Electron apps, LibreOffice). +const UNDO_PASTE_GAP_MS: u64 = 60; + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PasteOutcome { @@ -162,6 +168,55 @@ fn should_restore(current: Option<&str>, transcript: &str) -> bool { matches!(current, Some(text) if text == transcript) } +/// Replace-flow paste (brief item #17): send the platform's undo +/// keystroke to the previously-focused app, wait a beat so the undo +/// takes effect, then paste `text`. The caller (preview overlay's +/// "Replace with raw" button) is responsible for only invoking this +/// when a paste actually happened moments earlier — we don't probe to +/// verify there's something to undo. +/// +/// Returns the same PasteOutcome shape as `paste_text` so the frontend +/// can surface identical partial-success messaging. +#[tauri::command] +pub async fn paste_text_replacing( + app: tauri::AppHandle, + 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); + } + } + + hide_preview_overlay_for_paste(&app).await; + + if let Err(err) = trigger_undo_keystroke() { + outcome.message = Some(format!("undo: {err}")); + return Ok(outcome); + } + + tokio::time::sleep(Duration::from_millis(UNDO_PASTE_GAP_MS)).await; + + match trigger_paste_keystroke() { + Ok(backend) => { + outcome.backend = Some(backend); + outcome.pasted = true; + } + Err(err) => outcome.message = Some(err), + } + + Ok(outcome) +} + /// Hide the transcription-preview window if it's currently visible, then /// sleep a short beat so the compositor can recompute focus. No-ops when /// the window isn't registered yet (user never enabled the overlay) or @@ -375,6 +430,32 @@ fn trigger_paste_keystroke() -> Result { } } +/// Send Ctrl+Z / Cmd+Z to the focused window. Mirrors the shape of +/// `trigger_paste_keystroke`: reuse the same per-OS backend ordering, +/// just swap the keycode. Used by `paste_text_replacing` for the +/// raw-revert flow (brief item #17). +fn trigger_undo_keystroke() -> Result { + #[cfg(target_os = "linux")] + { + linux_undo( + std::env::var("XDG_SESSION_TYPE").ok().as_deref(), + std::env::var_os("WAYLAND_DISPLAY").is_some(), + ) + } + #[cfg(target_os = "macos")] + { + macos_undo() + } + #[cfg(target_os = "windows")] + { + windows_undo() + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + Err("undo-keystroke 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) { @@ -435,6 +516,44 @@ fn run_linux_tool(tool: &str) -> Result<(), String> { } } +#[cfg(target_os = "linux")] +fn linux_undo(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_undo_tool(tool) { + Ok(()) => return Ok(tool.to_string()), + Err(_) => continue, + } + } + Err("No undo backend available. Install wtype (Wayland) or xdotool (X11).".into()) +} + +#[cfg(target_os = "linux")] +fn run_linux_undo_tool(tool: &str) -> Result<(), String> { + let output = match tool { + // wtype -M ctrl z -m ctrl (press ctrl, tap z, release ctrl) + "wtype" => Command::new("wtype") + .args(["-M", "ctrl", "z", "-m", "ctrl"]) + .output(), + "xdotool" => Command::new("xdotool").args(["key", "ctrl+z"]).output(), + // 29 = LEFTCTRL, 44 = Z in linux input keycodes. + "ydotool" => Command::new("ydotool") + .args(["key", "29:1", "44:1", "44: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") @@ -464,6 +583,26 @@ fn macos_paste() -> Result { } } +#[cfg(target_os = "macos")] +fn macos_undo() -> Result { + let output = Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to keystroke \"z\" 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 @@ -489,6 +628,27 @@ fn windows_paste() -> Result { } } +#[cfg(target_os = "windows")] +fn windows_undo() -> Result { + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "(New-Object -ComObject WScript.Shell).SendKeys('^z')", + ]) + .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_linux { use super::pick_linux_backend_order; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d30e62..25b9cf1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -322,6 +322,7 @@ pub fn run() { commands::clipboard::copy_to_clipboard, // Paste (auto-insert at cursor) commands::paste::paste_text, + commands::paste::paste_text_replacing, commands::paste::detect_paste_backends, // Meeting auto-capture (process-list poll) commands::meeting::detect_meeting_processes, diff --git a/src/routes/preview/+page.svelte b/src/routes/preview/+page.svelte index 7b99720..a8996f4 100644 --- a/src/routes/preview/+page.svelte +++ b/src/routes/preview/+page.svelte @@ -4,7 +4,8 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; - import { Copy, Check, X } from "lucide-svelte"; + import { Copy, Check, X, RotateCcw } from "lucide-svelte"; + import { settings } from "$lib/stores/page.svelte.js"; // Phase state machine: // listening → live → cleanup → final → (auto-hide) @@ -64,6 +65,48 @@ getCurrentWindow().hide().catch(() => {}); } + let revertBusy = $state(false); + let reverted = $state(false); + + // Brief item #17: the raw Whisper transcript is the source of truth. + // In final phase the user sees both raw (in rawText) and formatted + // (in finalText). If the LLM cleanup changed their meaning, one + // click here replaces the pasted output with raw instead. + // + // Two paths depending on how the transcript was delivered to the + // target app: + // autoPaste on → the LLM output was pasted into whatever app had + // focus. paste_text_replacing sends undo, waits a + // beat, then pastes the raw text. + // autoPaste off → nothing was pasted; the LLM output is only on + // the clipboard. We just overwrite the clipboard + // with raw so the user's own paste yields raw. + async function revertToRaw() { + if (!rawText.trim() || revertBusy) return; + // Extend auto-hide so the user can actually see the confirmation + // without the window disappearing under them. + clearAutoHide(); + revertBusy = true; + try { + if (settings.autoPaste) { + const outcome = await invoke("paste_text_replacing", { text: rawText }); + if (!outcome?.pasted && outcome?.message) { + console.warn("[preview] paste_text_replacing partial failure:", outcome.message); + } + } else { + try { + await navigator.clipboard.writeText(rawText); + } catch { + await invoke("copy_to_clipboard", { text: rawText }).catch(() => {}); + } + } + reverted = true; + } finally { + revertBusy = false; + scheduleAutoHide(); + } + } + onMount(async () => { // preview-listening: recording just started, no text yet. unlisteners.push( @@ -106,6 +149,7 @@ await listen<{ text: string }>("preview-final", (event) => { finalText = (event.payload?.text ?? "").trim(); phase = "final"; + reverted = false; scrollToBottom(); scheduleAutoHide(); }), @@ -127,6 +171,12 @@ }); let activeText = $derived(phase === "final" ? finalText : rawText); + // Only offer "use raw" when there's a meaningful delta to revert to. + // If rawText and finalText are identical (Raw format mode, or LLM + // cleanup disabled), the button is pointless. + let canRevertToRaw = $derived( + phase === "final" && rawText.trim().length > 0 && rawText.trim() !== finalText.trim(), + ); let phaseLabel = $derived( phase === "listening" ? "Listening" : phase === "live" ? "Raw" @@ -168,6 +218,22 @@
+ {#if canRevertToRaw} + + {/if}