feat(preview B.1 #17): raw-transcript revert button in preview overlay
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

Kon's ideology rule: raw Whisper output is the source of truth; LLM
cleanup is additive, never destructive. The preview overlay already
tracks both rawText and finalText across the listening → live →
cleanup → final phases — but until now the user had no one-click path
from final to raw if cleanup changed their meaning.

Frontend: a context-aware "Use raw" / "Copy raw" button appears in
the preview overlay's final phase, only when rawText and finalText
actually differ (Raw format mode or LLM-off leaves the button hidden).
Two behaviours depending on how the transcript reached the target:

  - settings.autoPaste = true  →  invoke paste_text_replacing, which
      sends the platform's undo keystroke to the focused app,
      waits UNDO_PASTE_GAP_MS (60 ms) for the compositor / app to
      process it, then pastes the raw transcript. The preview hides
      itself beforehand so the keystroke doesn't race focus
      (existing Wayland-hardening path).
  - settings.autoPaste = false →  nothing was pasted in the first
      place, so just overwrite the clipboard with raw. User's own
      paste yields raw.

Backend: new paste_text_replacing Tauri command plus a mirror of the
paste-backend matrix for undo (wtype -M ctrl z / xdotool key ctrl+z /
ydotool keycodes 29:1 44:1 44:0 29:0 / osascript cmd+z / SendKeys '^z').
Reuses the pick_linux_backend_order Wayland-vs-X11 preference.
Registered in the Tauri command handler.

Acceptance per the brief: "after paste, Ctrl+Z within 5 s replaces
LLM output with raw transcript" — satisfied via the 4 s auto-hide
window on the preview's final phase. The click extends auto-hide so
the user actually sees the confirmation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 16:29:43 +01:00
parent e10f435eb1
commit ae4c1e3c6d
3 changed files with 228 additions and 1 deletions

View File

@@ -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<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);
}
}
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<String, String> {
}
}
/// 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<String, String> {
#[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<String, String> {
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<String, String> {
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<String, String> {
}
}
#[cfg(target_os = "macos")]
fn macos_undo() -> Result<String, String> {
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<String, String> {
// SendKeys("^v") simulates Ctrl+V in the foreground window. Requires
@@ -489,6 +628,27 @@ fn windows_paste() -> Result<String, String> {
}
}
#[cfg(target_os = "windows")]
fn windows_undo() -> Result<String, String> {
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;

View File

@@ -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,

View File

@@ -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 @@
</div>
<div class="flex items-center gap-1">
{#if canRevertToRaw}
<button
class="px-2 py-1 rounded hover:bg-hover text-text-tertiary hover:text-text disabled:opacity-40 flex items-center gap-1 text-[11px]"
onclick={revertToRaw}
disabled={revertBusy}
title={settings.autoPaste ? "Replace pasted text with raw transcript" : "Put raw transcript on clipboard"}
>
{#if reverted}
<Check size={12} />
<span>Raw used</span>
{:else}
<RotateCcw size={12} />
<span>{settings.autoPaste ? "Use raw" : "Copy raw"}</span>
{/if}
</button>
{/if}
<button
class="p-1 rounded hover:bg-hover text-text-tertiary hover:text-text disabled:opacity-40"
onclick={copyActiveText}