Open source voice dictation for your whole desktop. Local whisper.cpp transcription, global hotkeys, optional LLM enhancement via Claude Code, Gemini CLI, Codex, or any API. Wayland-first.
| 1 | import { useCallback, useEffect, useState } from "react"; |
| 2 | import { listen } from "@tauri-apps/api/event"; |
| 3 | import Overlay from "./Overlay"; |
| 4 | import GeneralPage from "./pages/General"; |
| 5 | import ModelsPage from "./pages/Models"; |
| 6 | import EnhancePage from "./pages/Enhance"; |
| 7 | import { getSettings, setSettings as persistSettings, toggleDictation } from "./api"; |
| 8 | import type { RecordingState, Settings, TranscriptPayload } from "./types"; |
| 9 | |
| 10 | type Page = "general" | "models" | "enhance"; |
| 11 | |
| 12 | const NAV: { id: Page; label: string }[] = [ |
| 13 | { id: "general", label: "general" }, |
| 14 | { id: "models", label: "models" }, |
| 15 | { id: "enhance", label: "enhance" }, |
| 16 | ]; |
| 17 | |
| 18 | export default function App() { |
| 19 | if (window.location.hash === "#overlay") { |
| 20 | return <Overlay />; |
| 21 | } |
| 22 | return <SettingsApp />; |
| 23 | } |
| 24 | |
| 25 | function SettingsApp() { |
| 26 | const [page, setPage] = useState<Page>("general"); |
| 27 | const [settings, setSettingsState] = useState<Settings | null>(null); |
| 28 | const [saveError, setSaveError] = useState<string | null>(null); |
| 29 | const [recState, setRecState] = useState<RecordingState>("idle"); |
| 30 | const [lastTranscript, setLastTranscript] = useState<TranscriptPayload | null>(null); |
| 31 | const [pipelineError, setPipelineError] = useState<string | null>(null); |
| 32 | |
| 33 | useEffect(() => { |
| 34 | getSettings().then(setSettingsState).catch(console.error); |
| 35 | const subs = [ |
| 36 | listen<{ state: RecordingState }>("recording-state", (e) => |
| 37 | setRecState(e.payload.state), |
| 38 | ), |
| 39 | listen<TranscriptPayload>("transcript", (e) => { |
| 40 | setLastTranscript(e.payload); |
| 41 | setPipelineError(null); |
| 42 | }), |
| 43 | listen<{ message: string }>("pipeline-error", (e) => |
| 44 | setPipelineError(e.payload.message), |
| 45 | ), |
| 46 | ]; |
| 47 | return () => { |
| 48 | subs.forEach((s) => s.then((fn) => fn())); |
| 49 | }; |
| 50 | }, []); |
| 51 | |
| 52 | const update = useCallback((next: Settings) => { |
| 53 | setSettingsState(next); |
| 54 | setSaveError(null); |
| 55 | persistSettings(next).catch((e) => setSaveError(String(e))); |
| 56 | }, []); |
| 57 | |
| 58 | return ( |
| 59 | <div className="shell"> |
| 60 | <aside className="sidebar"> |
| 61 | <div className="wordmark"> |
| 62 | <span className="wordmark-glyph" aria-hidden> |
| 63 | ((•)) |
| 64 | </span> |
| 65 | <h1>mutterbox</h1> |
| 66 | <p className="tagline">speak anywhere, type nowhere</p> |
| 67 | </div> |
| 68 | <nav> |
| 69 | {NAV.map((item) => ( |
| 70 | <button |
| 71 | key={item.id} |
| 72 | className={page === item.id ? "nav-item active" : "nav-item"} |
| 73 | onClick={() => setPage(item.id)} |
| 74 | > |
| 75 | <span className="nav-marker">{page === item.id ? "▸" : " "}</span> |
| 76 | {item.label} |
| 77 | </button> |
| 78 | ))} |
| 79 | </nav> |
| 80 | <div className="sidebar-foot"> |
| 81 | <button |
| 82 | className={`dictate-btn state-${recState}`} |
| 83 | onClick={() => toggleDictation()} |
| 84 | > |
| 85 | {recState === "recording" |
| 86 | ? "■ stop" |
| 87 | : recState === "processing" |
| 88 | ? "… working" |
| 89 | : "● dictate"} |
| 90 | </button> |
| 91 | <p className="hint"> |
| 92 | {settings ? `hotkey ${settings.hotkey}` : ""} |
| 93 | </p> |
| 94 | </div> |
| 95 | </aside> |
| 96 | |
| 97 | <main className="content"> |
| 98 | {saveError && <div className="banner error">save failed: {saveError}</div>} |
| 99 | {pipelineError && ( |
| 100 | <div className="banner error"> |
| 101 | {pipelineError} |
| 102 | <button className="banner-dismiss" onClick={() => setPipelineError(null)}> |
| 103 | ✕ |
| 104 | </button> |
| 105 | </div> |
| 106 | )} |
| 107 | {settings === null ? ( |
| 108 | <p className="hint">loading…</p> |
| 109 | ) : page === "general" ? ( |
| 110 | <GeneralPage |
| 111 | settings={settings} |
| 112 | onChange={update} |
| 113 | lastTranscript={lastTranscript} |
| 114 | /> |
| 115 | ) : page === "models" ? ( |
| 116 | <ModelsPage settings={settings} onChange={update} /> |
| 117 | ) : ( |
| 118 | <EnhancePage settings={settings} onChange={update} /> |
| 119 | )} |
| 120 | </main> |
| 121 | </div> |
| 122 | ); |
| 123 | } |