feat(preview B.1 #17): raw-transcript revert button in preview overlay
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:
@@ -34,6 +34,12 @@ const CLIPBOARD_RESTORE_MS: u64 = 300;
|
|||||||
/// risks the keystroke still landing on the (now-invisible) overlay.
|
/// risks the keystroke still landing on the (now-invisible) overlay.
|
||||||
const PREVIEW_HIDE_SETTLE_MS: u64 = 80;
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PasteOutcome {
|
pub struct PasteOutcome {
|
||||||
@@ -162,6 +168,55 @@ fn should_restore(current: Option<&str>, transcript: &str) -> bool {
|
|||||||
matches!(current, Some(text) if text == transcript)
|
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
|
/// Hide the transcription-preview window if it's currently visible, then
|
||||||
/// sleep a short beat so the compositor can recompute focus. No-ops when
|
/// 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
|
/// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn linux_paste(xdg_session_type: Option<&str>, wayland_display_set: bool) -> Result<String, String> {
|
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) {
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn which_on_path(tool: &str) -> bool {
|
fn which_on_path(tool: &str) -> bool {
|
||||||
Command::new("sh")
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
fn windows_paste() -> Result<String, String> {
|
fn windows_paste() -> Result<String, String> {
|
||||||
// SendKeys("^v") simulates Ctrl+V in the foreground window. Requires
|
// 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"))]
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
mod tests_linux {
|
mod tests_linux {
|
||||||
use super::pick_linux_backend_order;
|
use super::pick_linux_backend_order;
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ pub fn run() {
|
|||||||
commands::clipboard::copy_to_clipboard,
|
commands::clipboard::copy_to_clipboard,
|
||||||
// Paste (auto-insert at cursor)
|
// Paste (auto-insert at cursor)
|
||||||
commands::paste::paste_text,
|
commands::paste::paste_text,
|
||||||
|
commands::paste::paste_text_replacing,
|
||||||
commands::paste::detect_paste_backends,
|
commands::paste::detect_paste_backends,
|
||||||
// Meeting auto-capture (process-list poll)
|
// Meeting auto-capture (process-list poll)
|
||||||
commands::meeting::detect_meeting_processes,
|
commands::meeting::detect_meeting_processes,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
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:
|
// Phase state machine:
|
||||||
// listening → live → cleanup → final → (auto-hide)
|
// listening → live → cleanup → final → (auto-hide)
|
||||||
@@ -64,6 +65,48 @@
|
|||||||
getCurrentWindow().hide().catch(() => {});
|
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 () => {
|
onMount(async () => {
|
||||||
// preview-listening: recording just started, no text yet.
|
// preview-listening: recording just started, no text yet.
|
||||||
unlisteners.push(
|
unlisteners.push(
|
||||||
@@ -106,6 +149,7 @@
|
|||||||
await listen<{ text: string }>("preview-final", (event) => {
|
await listen<{ text: string }>("preview-final", (event) => {
|
||||||
finalText = (event.payload?.text ?? "").trim();
|
finalText = (event.payload?.text ?? "").trim();
|
||||||
phase = "final";
|
phase = "final";
|
||||||
|
reverted = false;
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
scheduleAutoHide();
|
scheduleAutoHide();
|
||||||
}),
|
}),
|
||||||
@@ -127,6 +171,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let activeText = $derived(phase === "final" ? finalText : rawText);
|
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(
|
let phaseLabel = $derived(
|
||||||
phase === "listening" ? "Listening"
|
phase === "listening" ? "Listening"
|
||||||
: phase === "live" ? "Raw"
|
: phase === "live" ? "Raw"
|
||||||
@@ -168,6 +218,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<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
|
<button
|
||||||
class="p-1 rounded hover:bg-hover text-text-tertiary hover:text-text disabled:opacity-40"
|
class="p-1 rounded hover:bg-hover text-text-tertiary hover:text-text disabled:opacity-40"
|
||||||
onclick={copyActiveText}
|
onclick={copyActiveText}
|
||||||
|
|||||||
Reference in New Issue
Block a user