irongit

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.

mutterbox/src/App.tsx
123 lines3.9 KBJavaScript
1import { useCallback, useEffect, useState } from "react";
2import { listen } from "@tauri-apps/api/event";
3import Overlay from "./Overlay";
4import GeneralPage from "./pages/General";
5import ModelsPage from "./pages/Models";
6import EnhancePage from "./pages/Enhance";
7import { getSettings, setSettings as persistSettings, toggleDictation } from "./api";
8import type { RecordingState, Settings, TranscriptPayload } from "./types";
9
10type Page = "general" | "models" | "enhance";
11
12const NAV: { id: Page; label: string }[] = [
13 { id: "general", label: "general" },
14 { id: "models", label: "models" },
15 { id: "enhance", label: "enhance" },
16];
17
18export default function App() {
19 if (window.location.hash === "#overlay") {
20 return <Overlay />;
21 }
22 return <SettingsApp />;
23}
24
25function 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}