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.
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user