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>
281 lines
9.5 KiB
Svelte
281 lines
9.5 KiB
Svelte
<script lang="ts">
|
|
// @ts-nocheck
|
|
import { onDestroy, onMount, tick } from "svelte";
|
|
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, RotateCcw } from "lucide-svelte";
|
|
import { settings } from "$lib/stores/page.svelte.js";
|
|
|
|
// Phase state machine:
|
|
// listening → live → cleanup → final → (auto-hide)
|
|
type Phase = "listening" | "live" | "cleanup" | "final";
|
|
let phase = $state<Phase>("listening");
|
|
let rawText = $state("");
|
|
let finalText = $state("");
|
|
let copied = $state(false);
|
|
|
|
let scrollEl: HTMLDivElement | null = null;
|
|
const unlisteners: Array<() => void> = [];
|
|
let autoHideTimer: number | null = null;
|
|
let copyResetTimer: number | null = null;
|
|
|
|
const AUTO_HIDE_MS = 4000;
|
|
const COPY_RESET_MS = 1400;
|
|
|
|
function clearAutoHide() {
|
|
if (autoHideTimer !== null) {
|
|
window.clearTimeout(autoHideTimer);
|
|
autoHideTimer = null;
|
|
}
|
|
}
|
|
|
|
function scheduleAutoHide() {
|
|
clearAutoHide();
|
|
autoHideTimer = window.setTimeout(() => {
|
|
getCurrentWindow().hide().catch(() => {});
|
|
}, AUTO_HIDE_MS);
|
|
}
|
|
|
|
async function scrollToBottom() {
|
|
await tick();
|
|
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
|
}
|
|
|
|
async function copyActiveText() {
|
|
const text = phase === "final" ? finalText : rawText;
|
|
if (!text.trim()) return;
|
|
let ok = false;
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
ok = true;
|
|
} catch {
|
|
ok = await invoke("copy_to_clipboard", { text }).then(() => true).catch(() => false);
|
|
}
|
|
if (!ok) return;
|
|
copied = true;
|
|
if (copyResetTimer !== null) window.clearTimeout(copyResetTimer);
|
|
copyResetTimer = window.setTimeout(() => { copied = false; }, COPY_RESET_MS);
|
|
// Re-arm auto-hide so the user's copy action doesn't get cut short.
|
|
if (phase === "final") scheduleAutoHide();
|
|
}
|
|
|
|
function dismiss() {
|
|
clearAutoHide();
|
|
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(
|
|
await listen("preview-listening", () => {
|
|
clearAutoHide();
|
|
phase = "listening";
|
|
rawText = "";
|
|
finalText = "";
|
|
}),
|
|
);
|
|
|
|
// preview-append: main forwards each live chunk's raw_text. We also
|
|
// accept the full payload from single-shot transcriptions as append.
|
|
unlisteners.push(
|
|
await listen<{ text: string; replace?: boolean }>("preview-append", (event) => {
|
|
const chunk = (event.payload?.text ?? "").trim();
|
|
if (!chunk) return;
|
|
if (event.payload?.replace) {
|
|
rawText = chunk;
|
|
} else if (rawText.length === 0) {
|
|
rawText = chunk;
|
|
} else {
|
|
rawText = `${rawText} ${chunk}`;
|
|
}
|
|
phase = "live";
|
|
scrollToBottom();
|
|
}),
|
|
);
|
|
|
|
// preview-cleanup: main window is about to run the LLM cleanup pass.
|
|
unlisteners.push(
|
|
await listen("preview-cleanup", () => {
|
|
phase = "cleanup";
|
|
}),
|
|
);
|
|
|
|
// preview-final: cleanup done (or skipped). Show formatted text and
|
|
// start the 4s auto-hide countdown.
|
|
unlisteners.push(
|
|
await listen<{ text: string }>("preview-final", (event) => {
|
|
finalText = (event.payload?.text ?? "").trim();
|
|
phase = "final";
|
|
reverted = false;
|
|
scrollToBottom();
|
|
scheduleAutoHide();
|
|
}),
|
|
);
|
|
|
|
// preview-hide: main asks us to dismiss immediately (recording
|
|
// cancelled, or the user re-focused the main window mid-stream).
|
|
unlisteners.push(
|
|
await listen("preview-hide", () => {
|
|
dismiss();
|
|
}),
|
|
);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
clearAutoHide();
|
|
if (copyResetTimer !== null) window.clearTimeout(copyResetTimer);
|
|
for (const off of unlisteners) off();
|
|
});
|
|
|
|
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"
|
|
: phase === "cleanup" ? "Cleaning up"
|
|
: "Final",
|
|
);
|
|
let borderColorClass = $derived(
|
|
phase === "final" ? "border-success"
|
|
: phase === "cleanup" ? "border-accent"
|
|
: "border-border",
|
|
);
|
|
</script>
|
|
|
|
<div
|
|
class="h-full w-full flex flex-col bg-bg text-text p-3 gap-2 border-l-2 {borderColorClass}"
|
|
style="transition: border-color var(--duration-ui) ease-out"
|
|
data-tauri-drag-region
|
|
>
|
|
<header class="flex items-center justify-between gap-2 text-[11px] text-text-secondary" data-tauri-drag-region>
|
|
<div class="flex items-center gap-2" data-tauri-drag-region>
|
|
{#if phase === "listening"}
|
|
<span class="inline-block w-[8px] h-[8px] rounded-full bg-text-tertiary animate-pulse"></span>
|
|
{:else if phase === "live"}
|
|
<span class="inline-flex items-end gap-[2px] h-[10px]">
|
|
<span class="w-[3px] bg-accent rounded-sm animate-bars-1"></span>
|
|
<span class="w-[3px] bg-accent rounded-sm animate-bars-2"></span>
|
|
<span class="w-[3px] bg-accent rounded-sm animate-bars-3"></span>
|
|
</span>
|
|
{:else if phase === "cleanup"}
|
|
<span class="inline-flex items-end gap-[2px] h-[10px]">
|
|
<span class="w-[3px] bg-accent/80 rounded-sm animate-bars-1"></span>
|
|
<span class="w-[3px] bg-accent/80 rounded-sm animate-bars-2"></span>
|
|
<span class="w-[3px] bg-accent/80 rounded-sm animate-bars-3"></span>
|
|
</span>
|
|
{:else}
|
|
<Check size={12} class="text-success" />
|
|
{/if}
|
|
<span class="uppercase tracking-wider font-medium">{phaseLabel}</span>
|
|
</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}
|
|
disabled={!activeText.trim()}
|
|
title="Copy"
|
|
>
|
|
{#if copied}
|
|
<Check size={14} />
|
|
{:else}
|
|
<Copy size={14} />
|
|
{/if}
|
|
</button>
|
|
<button
|
|
class="p-1 rounded hover:bg-hover text-text-tertiary hover:text-text"
|
|
onclick={dismiss}
|
|
title="Dismiss"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div
|
|
bind:this={scrollEl}
|
|
class="flex-1 min-h-0 overflow-y-auto text-[14px] leading-relaxed whitespace-pre-wrap break-words"
|
|
>
|
|
{#if activeText.trim()}
|
|
{activeText}
|
|
{:else}
|
|
<span class="text-text-tertiary italic">
|
|
{phase === "listening" ? "Waiting for speech…" : ""}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
@keyframes bars-1 { 0%,100% { height: 40%; } 50% { height: 100%; } }
|
|
@keyframes bars-2 { 0%,100% { height: 70%; } 50% { height: 30%; } }
|
|
@keyframes bars-3 { 0%,100% { height: 50%; } 50% { height: 90%; } }
|
|
:global(.animate-bars-1) { animation: bars-1 0.9s ease-in-out infinite; }
|
|
:global(.animate-bars-2) { animation: bars-2 0.9s ease-in-out infinite 0.15s; }
|
|
:global(.animate-bars-3) { animation: bars-3 0.9s ease-in-out infinite 0.3s; }
|
|
</style>
|