@superdots/airtype 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.tsx ADDED
@@ -0,0 +1,652 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { render, Box, Text, useInput } from "ink";
3
+ import SelectInput from "ink-select-input";
4
+ import Spinner from "ink-spinner";
5
+ import { loadConfig, saveConfig, loadEnvKey, isReady, airpath, addWords, isOverLimit, type AirtypeConfig } from "./config.js"; // airpath imported
6
+ import { GlobalKeyboardListener } from "node-global-key-listener";
7
+ import { buildCombo, isModifier, isDuplicate } from "./keys.js";
8
+ import { startRecording } from "./audio.js";
9
+ import { transcribe } from "./stt.js";
10
+ import { polish } from "./llm.js";
11
+ import { pasteText } from "./paste.js";
12
+ import { execSync } from "child_process";
13
+ import { writeFileSync, mkdirSync } from "fs";
14
+ import { reportError, reportStartup, reportLogs } from "./report.js";
15
+
16
+ // ─── Logo (bigger, clearer) ─────────────────────────
17
+ const LOGO = [
18
+ "",
19
+ " █████╗ ██╗██████╗ ████████╗██╗ ██╗██████╗ ███████╗",
20
+ " ██╔══██╗██║██╔══██╗╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝",
21
+ " ███████║██║██████╔╝ ██║ ╚████╔╝ ██████╔╝█████╗ ",
22
+ " ██╔══██║██║██╔══██╗ ██║ ╚██╔╝ ██╔═══╝ ██╔══╝ ",
23
+ " ██║ ██║██║██║ ██║ ██║ ██║ ██║ ███████╗",
24
+ " ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝",
25
+ "",
26
+ " hands-free transcription v0.2",
27
+ "",
28
+ ];
29
+
30
+ const LogoBox = () => (
31
+ <Box flexDirection="column">
32
+ {LOGO.map((l, i) => <Text key={i} color="cyan">{l}</Text>)}
33
+ </Box>
34
+ );
35
+
36
+ const LogoSmall = () => (
37
+ <Box flexDirection="column" paddingLeft={2}>
38
+ <Text color="cyan"> ╭─ AIRTYPE ─────────────────────────╮</Text>
39
+ <Text color="cyan"> │ hands-free transcription v0.2 │</Text>
40
+ <Text color="cyan"> ╰────────────────────────────────────╯</Text>
41
+ </Box>
42
+ );
43
+
44
+ // ─── Speech Bar ──────────────────────────────────────
45
+ const SpeechBar = ({ level }: { level: number }) => {
46
+ const width = 20;
47
+ const filled = Math.round(level * width);
48
+ return <Text color="cyan">[{"█".repeat(filled)}{"░".repeat(width - filled)}]</Text>;
49
+ };
50
+
51
+ // ─── Status Line (shared by onboarding + daemon) ─────
52
+ type RecPhase = "wait" | "rec" | "proc" | "done";
53
+
54
+ const StatusLine = ({ phase, shortcut, result, volume }: {
55
+ phase: RecPhase;
56
+ shortcut: string;
57
+ result: { raw: string; pol: string } | null;
58
+ volume: number;
59
+ }) => (
60
+ <Box flexDirection="column" marginTop={1} paddingLeft={2}>
61
+ {phase === "wait" && <Text dimColor>{shortcut} 으로 시작</Text>}
62
+ {phase === "rec" && (
63
+ <Box>
64
+ <Text color="red" bold><Spinner type="dots" /> 녹음 중 </Text>
65
+ <SpeechBar level={volume} />
66
+ </Box>
67
+ )}
68
+ {phase === "proc" && <Text color="yellow"><Spinner type="dots" /> 처리 중...</Text>}
69
+ {phase === "done" && result && (
70
+ <>
71
+ <Text dimColor>You said: {result.raw.trim()}</Text>
72
+ <Text bold color="green">Result: {result.pol}</Text>
73
+ <Box marginTop={1}><Text dimColor>Enter — 다음</Text></Box>
74
+ </>
75
+ )}
76
+ </Box>
77
+ );
78
+
79
+ // ─── Key Capture Help (shows after 5s if no input) ───
80
+ const KeyCaptureHint = () => {
81
+ const [elapsed, setElapsed] = useState(0);
82
+
83
+ useEffect(() => {
84
+ const timer = setInterval(() => setElapsed(e => e + 1), 1000);
85
+ return () => clearInterval(timer);
86
+ }, []);
87
+
88
+ if (elapsed < 5) return null;
89
+
90
+ return (
91
+ <Box flexDirection="column" marginTop={1}>
92
+ <Text color="yellow" bold>키 입력이 감지되지 않나요?</Text>
93
+ <Text dimColor>시스템 설정 → 개인정보 보호 및 보안 → 입력 모니터링</Text>
94
+ <Text dimColor>에서 이 터미널 앱을 허용해주세요.</Text>
95
+ {elapsed >= 10 && (
96
+ <Box marginTop={1}>
97
+ <Text dimColor>그래도 안 되면 Ctrl+C 후 터미널을 재시작하고 다시 실행하세요.</Text>
98
+ </Box>
99
+ )}
100
+ </Box>
101
+ );
102
+ };
103
+
104
+ // ─── Onboarding ──────────────────────────────────────
105
+ const TOTAL_STEPS = 7;
106
+
107
+ const GUIDES = [
108
+ { intro: "말을 더듬어도, Airtype이 깔끔하게 정리해줘요.", sentence: "Um so I think... no wait... we need to fix the login bug" },
109
+ { intro: "나열하면 자동으로 번호를 매겨줘요.", sentence: "First update docs second fix the bug third deploy" },
110
+ { intro: "이메일도 말로 쓸 수 있어요.", sentence: "Dear Michael new line follow up period Regards Chris" },
111
+ ];
112
+
113
+ type StepId = 1 | 2 | 3 | 4 | 5 | 6 | 7; // 7 = congrats
114
+
115
+ const Onboarding = ({ config, onDone }: { config: AirtypeConfig; onDone: (c: AirtypeConfig) => void }) => {
116
+ const [stepId, setStepId] = useState<StepId>(1);
117
+
118
+ const [cfg, setCfg] = useState({ ...config });
119
+ const [capturedCombo, setCapturedCombo] = useState("");
120
+ const [phase, setPhase] = useState<RecPhase>("wait");
121
+ const [result, setResult] = useState<{ raw: string; pol: string } | null>(null);
122
+ const [volume, setVolume] = useState(0);
123
+
124
+ const stepRef = useRef(stepId);
125
+ stepRef.current = stepId;
126
+ const phaseRef = useRef(phase);
127
+ phaseRef.current = phase;
128
+ const recRef = useRef<ReturnType<typeof startRecording> | null>(null);
129
+ const cfgRef = useRef(cfg);
130
+ cfgRef.current = cfg;
131
+
132
+ // ── Global key listener ──
133
+ useEffect(() => {
134
+ const listener = new GlobalKeyboardListener();
135
+
136
+ listener.addListener((e, isDown) => {
137
+ if (e.state !== "DOWN") return;
138
+ const name = e.name || "";
139
+ if (isModifier(name)) return;
140
+ const combo = buildCombo(name, isDown);
141
+
142
+ const s = stepRef.current;
143
+ const p = phaseRef.current;
144
+
145
+ // Step 1: shortcut capture
146
+ if (s === 1 && combo.includes("+")) {
147
+ setCapturedCombo(combo);
148
+ }
149
+
150
+ // Steps 3-6: recording toggle
151
+ if (s >= 3 && s <= 6 && combo === cfgRef.current.shortcutDisplay && !isDuplicate(combo)) {
152
+ if (p === "wait") {
153
+ playSound("Glass");
154
+ phaseRef.current = "rec";
155
+ setPhase("rec");
156
+ setVolume(0);
157
+ const rec = startRecording(cfgRef.current.micDevice);
158
+ rec.onVolume((v) => setVolume(v));
159
+ recRef.current = rec;
160
+ } else if (p === "rec" && recRef.current) {
161
+ playSound("Pop");
162
+ phaseRef.current = "proc";
163
+ setPhase("proc");
164
+ const r = recRef.current;
165
+ recRef.current = null;
166
+ r.stop().then(async (wav) => {
167
+ try {
168
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
169
+ const dir = airpath("recordings");
170
+ mkdirSync(dir, { recursive: true });
171
+ writeFileSync(`${dir}/onboard-${ts}.wav`, wav);
172
+
173
+ const stt = await transcribe(wav, "auto");
174
+ const llm = await polish(stt.text);
175
+ setResult({ raw: stt.text, pol: llm.text });
176
+
177
+ writeFileSync(`${dir}/onboard-${ts}.json`, JSON.stringify({
178
+ ts, step: stepRef.current, rawText: stt.text, polishedText: llm.text,
179
+ sttMs: stt.durationMs, llmMs: llm.durationMs,
180
+ }, null, 2));
181
+ } catch (err: any) {
182
+ setResult({ raw: "(error)", pol: err.message });
183
+ }
184
+ phaseRef.current = "done";
185
+ setPhase("done");
186
+ });
187
+ }
188
+ }
189
+ });
190
+
191
+ return () => listener.kill();
192
+ }, []);
193
+
194
+ // ── Enter key ──
195
+ useInput((_, key) => {
196
+ if (!key.return) return;
197
+ const s = stepRef.current;
198
+ const p = phaseRef.current;
199
+
200
+ if (s === 1 && capturedCombo) {
201
+ const updated = { ...cfgRef.current, shortcutDisplay: capturedCombo, shortcutKeys: capturedCombo.split("+") };
202
+ cfgRef.current = updated;
203
+ setCfg(updated);
204
+ stepRef.current = 2;
205
+ setStepId(2);
206
+ }
207
+
208
+ if (p === "done") {
209
+ setResult(null);
210
+ phaseRef.current = "wait";
211
+ setPhase("wait");
212
+ setVolume(0);
213
+
214
+ // Mark test passed after free test (step 3)
215
+ if (s === 3) {
216
+ cfgRef.current = { ...cfgRef.current, testPassed: true };
217
+ setCfg(cfgRef.current);
218
+ }
219
+
220
+ if (s < 6) {
221
+ stepRef.current = (s + 1) as StepId;
222
+ setStepId((s + 1) as StepId);
223
+ } else {
224
+ // Save and show congrats
225
+ const final = { ...cfgRef.current, onboardingDone: true, testPassed: true };
226
+ saveConfig(final);
227
+ stepRef.current = 7;
228
+ setStepId(7);
229
+ }
230
+ }
231
+
232
+ // Congrats → daemon
233
+ if (s === 7) {
234
+ onDone(cfgRef.current);
235
+ }
236
+ });
237
+
238
+ return (
239
+ <Box flexDirection="column">
240
+ {stepId !== 7 && (
241
+ <>
242
+ <LogoBox />
243
+ <Box paddingLeft={2}><Text dimColor>[{Math.min(stepId, TOTAL_STEPS)}/{TOTAL_STEPS}]</Text></Box>
244
+ </>
245
+ )}
246
+
247
+ <Box flexDirection="column" paddingLeft={2} marginTop={1}>
248
+ {/* Step 1: Shortcut */}
249
+ {stepId === 1 && !capturedCombo && (
250
+ <>
251
+ <Text bold>단축키를 정해주세요.</Text>
252
+ <Text dimColor>원하는 키 조합을 눌러보세요.</Text>
253
+ <Text dimColor>(예: Cmd+Esc, Ctrl+Shift+R, Alt+Space)</Text>
254
+ <Box marginTop={1}><Text dimColor>기다리는 중...</Text></Box>
255
+ <KeyCaptureHint />
256
+ </>
257
+ )}
258
+ {stepId === 1 && capturedCombo && (
259
+ <>
260
+ <Box marginBottom={1}><Text bold color="cyan">{capturedCombo}</Text></Box>
261
+ <Text>이 키로 할까요?</Text>
262
+ <Text dimColor>Enter — 확정 | 다른 키 — 변경</Text>
263
+ </>
264
+ )}
265
+
266
+ {/* Step 2: Mic */}
267
+ {stepId === 2 && (
268
+ <>
269
+ <Text bold>어떤 마이크를 사용할까요?</Text>
270
+ <Box marginTop={1}>
271
+ <SelectInput
272
+ items={getMics().map(m => ({ label: m, value: m }))}
273
+ onSelect={item => {
274
+ setCfg(prev => ({ ...prev, micDevice: item.value }));
275
+ cfgRef.current = { ...cfgRef.current, micDevice: item.value };
276
+ stepRef.current = 3;
277
+ setStepId(3);
278
+ }}
279
+ />
280
+ </Box>
281
+ </>
282
+ )}
283
+
284
+ {/* Step 3: Free test */}
285
+ {stepId === 3 && (
286
+ <>
287
+ <Text bold>테스트해볼게요.</Text>
288
+ <Text dimColor>아무 말이나 해보세요.</Text>
289
+ <StatusLine phase={phase} shortcut={cfg.shortcutDisplay} result={result} volume={volume} />
290
+ </>
291
+ )}
292
+
293
+ {/* Steps 4-6: Guided tests */}
294
+ {stepId >= 4 && stepId <= 6 && (
295
+ <>
296
+ <Text>{GUIDES[stepId - 4]!.intro}</Text>
297
+ <Box marginTop={1}>
298
+ <Text bold color="white">"{GUIDES[stepId - 4]!.sentence}"</Text>
299
+ </Box>
300
+ <StatusLine phase={phase} shortcut={cfg.shortcutDisplay} result={result} volume={volume} />
301
+ </>
302
+ )}
303
+
304
+ {/* Step 7: Congrats */}
305
+ {stepId === 7 && (
306
+ <>
307
+ <LogoBox />
308
+ <Box flexDirection="column" paddingLeft={2}>
309
+ <Text bold color="green">✓ 설정 완료!</Text>
310
+ <Text> </Text>
311
+ <Text>이제 아무 앱에서 <Text bold color="cyan">{cfg.shortcutDisplay}</Text> 을 누르면</Text>
312
+ <Text>녹음이 시작됩니다. 소리로 알려드릴게요.</Text>
313
+ <Text> </Text>
314
+ <Text dimColor>단축키: {cfg.shortcutDisplay}</Text>
315
+ <Text dimColor>마이크: {cfg.micDevice}</Text>
316
+ <Text> </Text>
317
+ <Text dimColor>설정을 다시 하려면: bun run src/app.tsx --setup</Text>
318
+ <Text> </Text>
319
+ <Text dimColor>Enter — 시작</Text>
320
+ </Box>
321
+ </>
322
+ )}
323
+ </Box>
324
+ </Box>
325
+ );
326
+ };
327
+
328
+ // ─── Daemon ──────────────────────────────────────────
329
+ type DState = "ready" | "rec" | "proc";
330
+
331
+ const Daemon = ({ config, autoEnter, onToggleAutoEnter, onOpenSettings }: { config: AirtypeConfig; autoEnter: boolean; onToggleAutoEnter: () => void; onOpenSettings: () => void }) => {
332
+ const [state, setState] = useState<DState>("ready");
333
+ const [last, setLast] = useState<{ raw: string; pol: string; stt: number; llm: number; paste: number; total: number } | null>(null);
334
+ const [error, setError] = useState<string | null>(null);
335
+ const [vol, setVol] = useState(0);
336
+ const sRef = useRef<DState>("ready");
337
+ const rRef = useRef<ReturnType<typeof startRecording> | null>(null);
338
+ const autoEnterRef = useRef(autoEnter);
339
+ autoEnterRef.current = autoEnter;
340
+
341
+ useInput((input) => {
342
+ if (input === "e" || input === "E") onToggleAutoEnter();
343
+ if (input === "s" || input === "S") onOpenSettings();
344
+ });
345
+
346
+ useEffect(() => {
347
+ const listener = new GlobalKeyboardListener();
348
+ listener.addListener((e, isDown) => {
349
+ if (e.state !== "DOWN") return;
350
+ const name = e.name || "";
351
+ if (isModifier(name)) return;
352
+ const combo = buildCombo(name, isDown);
353
+ if (combo !== config.shortcutDisplay || isDuplicate(combo)) return;
354
+
355
+ if (sRef.current === "rec" && rRef.current) {
356
+ playSound("Pop");
357
+ sRef.current = "proc"; setState("proc");
358
+ const rec = rRef.current; rRef.current = null;
359
+ rec.stop().then(async (wav) => {
360
+ try {
361
+ const t0 = Date.now();
362
+ const stt = await transcribe(wav, "auto");
363
+ if (!stt.text.trim()) { sRef.current = "ready"; setState("ready"); return; }
364
+ const llm = await polish(stt.text);
365
+ const paste = await pasteText(llm.text, autoEnterRef.current);
366
+ const total = Date.now() - t0;
367
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
368
+ const dir = airpath("recordings");
369
+ mkdirSync(dir, { recursive: true });
370
+ writeFileSync(`${dir}/${ts}.wav`, wav);
371
+ writeFileSync(`${dir}/${ts}.json`, JSON.stringify({ ts, rawText: stt.text, polishedText: llm.text, sttMs: stt.durationMs, llmMs: llm.durationMs, pasteMs: paste, totalMs: total }, null, 2));
372
+ setLast({ raw: stt.text, pol: llm.text, stt: stt.durationMs, llm: llm.durationMs, paste, total });
373
+ addWords(config, llm.text);
374
+ setError(null);
375
+ } catch (e: any) { setError(e.message); reportError("daemon-pipeline", e.message); }
376
+ sRef.current = "ready"; setState("ready");
377
+ });
378
+ } else if (sRef.current === "ready") {
379
+ playSound("Glass");
380
+ const rec = startRecording(config.micDevice);
381
+ rec.onVolume((v) => setVol(v));
382
+ rRef.current = rec;
383
+ sRef.current = "rec"; setState("rec");
384
+ setVol(0); setLast(null); setError(null);
385
+ }
386
+ });
387
+ return () => listener.kill();
388
+ }, []);
389
+
390
+ return (
391
+ <Box flexDirection="column" paddingLeft={2}>
392
+ {state === "ready" && <Text color="green" bold>● Ready</Text>}
393
+ {state === "rec" && (
394
+ <Box>
395
+ <Text color="red" bold><Spinner type="dots" /> Recording </Text>
396
+ <SpeechBar level={vol} />
397
+ </Box>
398
+ )}
399
+ {state === "proc" && <Text color="yellow"><Spinner type="dots" /> Processing...</Text>}
400
+ {last && (
401
+ <Box flexDirection="column" marginTop={1}>
402
+ <Text dimColor>STT {last.stt}ms {trunc(last.raw, 60)}</Text>
403
+ <Text dimColor>Total {last.total}ms (paste {last.paste}ms)</Text>
404
+ <Box marginTop={1} flexDirection="column">
405
+ <Text dimColor>── Result ──────────────────────────────</Text>
406
+ <Text bold wrap="wrap">{last.pol}</Text>
407
+ <Text dimColor>────────────────────────────────────────</Text>
408
+ </Box>
409
+ </Box>
410
+ )}
411
+ {error && <Text color="red">ERROR: {error}</Text>}
412
+ {isOverLimit(config) && state === "ready" && (
413
+ <Box marginTop={1} flexDirection="column">
414
+ <Text color="yellow">무료 {(config.wordCount || 0).toLocaleString()}단어 사용 (10,000단어 무료)</Text>
415
+ <Text dimColor>Airtype이 도움이 되셨나요? 유료 플랜을 준비 중이에요.</Text>
416
+ <Text dimColor>지금은 계속 무료로 사용 가능합니다.</Text>
417
+ </Box>
418
+ )}
419
+ </Box>
420
+ );
421
+ };
422
+
423
+ // ─── Settings ────────────────────────────────────────
424
+ type SettingItem = "shortcut" | "mic" | "auto-enter" | "back";
425
+ type SettingSub = null | "shortcut-capture" | "mic-select";
426
+
427
+ const Settings = ({ config, onSave }: { config: AirtypeConfig; onSave: (c: AirtypeConfig) => void }) => {
428
+ const [sub, setSub] = useState<SettingSub>(null);
429
+ const [cfg, setCfg] = useState({ ...config });
430
+ const [capturedCombo, setCapturedCombo] = useState("");
431
+ const cfgRef = useRef(cfg);
432
+ cfgRef.current = cfg;
433
+
434
+ // Shortcut capture listener
435
+ useEffect(() => {
436
+ if (sub !== "shortcut-capture") return;
437
+ const listener = new GlobalKeyboardListener();
438
+ listener.addListener((e, isDown) => {
439
+ if (e.state !== "DOWN") return;
440
+ const name = e.name || "";
441
+ if (isModifier(name)) return;
442
+ const combo = buildCombo(name, isDown);
443
+ if (!combo.includes("+")) return;
444
+ setCapturedCombo(combo);
445
+ });
446
+ return () => listener.kill();
447
+ }, [sub]);
448
+
449
+ useInput((input, key) => {
450
+ if (sub === "shortcut-capture" && key.return && capturedCombo) {
451
+ const updated = { ...cfgRef.current, shortcutDisplay: capturedCombo, shortcutKeys: capturedCombo.split("+") };
452
+ saveConfig(updated);
453
+ setCfg(updated);
454
+ setSub(null);
455
+ setCapturedCombo("");
456
+ }
457
+ if (sub === "shortcut-capture" && key.escape) {
458
+ setSub(null);
459
+ setCapturedCombo("");
460
+ }
461
+ });
462
+
463
+ // Main menu
464
+ if (!sub) {
465
+ const items = [
466
+ { label: `Shortcut ${cfg.shortcutDisplay}`, value: "shortcut" as SettingItem },
467
+ { label: `Microphone ${cfg.micDevice}`, value: "mic" as SettingItem },
468
+ { label: `Auto-Enter ${cfg.autoEnter ? "ON" : "OFF"}`, value: "auto-enter" as SettingItem },
469
+ { label: "← Back", value: "back" as SettingItem },
470
+ ];
471
+
472
+ return (
473
+ <Box flexDirection="column" paddingLeft={2}>
474
+ <Text bold color="cyan">Settings</Text>
475
+ <Box marginTop={1}>
476
+ <SelectInput
477
+ items={items}
478
+ onSelect={(item) => {
479
+ if (item.value === "back") { onSave(cfg); return; }
480
+ if (item.value === "shortcut") { setSub("shortcut-capture"); return; }
481
+ if (item.value === "mic") { setSub("mic-select"); return; }
482
+ if (item.value === "auto-enter") {
483
+ const updated = { ...cfgRef.current, autoEnter: !cfgRef.current.autoEnter };
484
+ saveConfig(updated);
485
+ setCfg(updated);
486
+ }
487
+ }}
488
+ />
489
+ </Box>
490
+ </Box>
491
+ );
492
+ }
493
+
494
+ // Sub: shortcut capture
495
+ if (sub === "shortcut-capture") {
496
+ return (
497
+ <Box flexDirection="column" paddingLeft={2}>
498
+ <Text bold color="cyan">Shortcut</Text>
499
+ <Box marginTop={1}><Text dimColor>원하는 키 조합을 눌러보세요.</Text></Box>
500
+ {capturedCombo ? (
501
+ <>
502
+ <Box marginTop={1}><Text bold color="cyan">{capturedCombo}</Text></Box>
503
+ <Text dimColor>Enter — 확정 | 다른 키 — 변경 | Esc — 취소</Text>
504
+ </>
505
+ ) : (
506
+ <Box marginTop={1}><Text dimColor>기다리는 중...</Text></Box>
507
+ )}
508
+ </Box>
509
+ );
510
+ }
511
+
512
+ // Sub: mic select
513
+ if (sub === "mic-select") {
514
+ return (
515
+ <Box flexDirection="column" paddingLeft={2}>
516
+ <Text bold color="cyan">Microphone</Text>
517
+ <Box marginTop={1}>
518
+ <SelectInput
519
+ items={getMics().map(m => ({ label: m, value: m }))}
520
+ onSelect={(item) => {
521
+ const updated = { ...cfgRef.current, micDevice: item.value };
522
+ saveConfig(updated);
523
+ setCfg(updated);
524
+ setSub(null);
525
+ }}
526
+ />
527
+ </Box>
528
+ </Box>
529
+ );
530
+ }
531
+
532
+ return null;
533
+ };
534
+
535
+ // ─── App ─────────────────────────────────────────────
536
+ const StatusBox = ({ config, autoEnter }: { config: AirtypeConfig; autoEnter: boolean }) => (
537
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
538
+ <Text><Text dimColor>Shortcut </Text> <Text bold>{config.shortcutDisplay}</Text></Text>
539
+ <Text><Text dimColor>Mic </Text> {config.micDevice}</Text>
540
+ <Text><Text dimColor>Auto-Enter</Text> {autoEnter ? <Text color="green">ON</Text> : <Text dimColor>OFF</Text>}</Text>
541
+ <Box marginTop={1}><Text dimColor>S settings | E auto-enter | Ctrl+C quit</Text></Box>
542
+ </Box>
543
+ );
544
+
545
+ const App = () => {
546
+ const [config, setConfig] = useState<AirtypeConfig | null>(null);
547
+ const [mode, setMode] = useState<"load" | "onboard" | "daemon" | "settings">("load");
548
+ const [autoEnter, setAutoEnter] = useState(true);
549
+
550
+ useEffect(() => {
551
+ const c = loadConfig();
552
+ setConfig(c);
553
+ setAutoEnter(c.autoEnter ?? true);
554
+ setMode(!isReady(c) || process.argv.includes("--setup") ? "onboard" : "daemon");
555
+ }, []);
556
+
557
+ const toggleAutoEnter = () => {
558
+ setAutoEnter(prev => {
559
+ const next = !prev;
560
+ if (config) { saveConfig({ ...config, autoEnter: next }); }
561
+ return next;
562
+ });
563
+ };
564
+
565
+ if (!config) return <Text>Loading...</Text>;
566
+ if (mode === "onboard") return <Onboarding config={config} onDone={c => { setConfig(c); setAutoEnter(c.autoEnter ?? true); setMode("daemon"); }} />;
567
+
568
+ if (mode === "settings") {
569
+ return (
570
+ <Box flexDirection="column">
571
+ <LogoSmall />
572
+ <Settings config={config} onSave={(c) => { setConfig(c); setAutoEnter(c.autoEnter ?? true); setMode("daemon"); }} />
573
+ </Box>
574
+ );
575
+ }
576
+
577
+ return (
578
+ <Box flexDirection="column">
579
+ <LogoSmall />
580
+ <StatusBox config={config} autoEnter={autoEnter} />
581
+ <Daemon config={config} autoEnter={autoEnter} onToggleAutoEnter={toggleAutoEnter} onOpenSettings={() => setMode("settings")} />
582
+ </Box>
583
+ );
584
+ };
585
+
586
+ // ─── Helpers ─────────────────────────────────────────
587
+ function getMics(): string[] {
588
+ try {
589
+ const out = execSync("system_profiler SPAudioDataType 2>/dev/null", { encoding: "utf-8" });
590
+ const names: string[] = [];
591
+ for (const line of out.split("\n")) {
592
+ const t = line.trim();
593
+ if (t.endsWith(":") && !["Audio", "Devices", "Input", "Output"].some(x => t.startsWith(x))) {
594
+ const n = t.replace(/:$/, "").trim();
595
+ if (n && !names.includes(n)) names.push(n);
596
+ }
597
+ }
598
+ return names.length ? names : ["default"];
599
+ } catch { return ["default"]; }
600
+ }
601
+
602
+ function playSound(name: string) {
603
+ try { execSync(`afplay /System/Library/Sounds/${name}.aiff &`, { stdio: "ignore" }); } catch {}
604
+ }
605
+
606
+ function trunc(s: string, max: number) {
607
+ s = s.trim();
608
+ return s.length <= max ? s : s.slice(0, max - 3) + "...";
609
+ }
610
+
611
+ // ─── Single Instance Lock ────────────────────────────
612
+ const LOCK_FILE = "/tmp/airtype.lock";
613
+
614
+ function ensureSingleInstance() {
615
+ const { existsSync, readFileSync, writeFileSync, unlinkSync } = require("fs");
616
+
617
+ if (existsSync(LOCK_FILE)) {
618
+ const oldPid = parseInt(readFileSync(LOCK_FILE, "utf-8").trim(), 10);
619
+ if (oldPid) {
620
+ try {
621
+ process.kill(oldPid, 0); // check if alive
622
+ // Still running — kill it
623
+ process.kill(oldPid, "SIGTERM");
624
+ console.log(` Stopped previous airtype (pid ${oldPid})`);
625
+ // Wait a moment for cleanup
626
+ Bun.sleepSync(500);
627
+ } catch {
628
+ // Not running, stale lock
629
+ }
630
+ }
631
+ try { unlinkSync(LOCK_FILE); } catch {}
632
+ }
633
+
634
+ writeFileSync(LOCK_FILE, String(process.pid));
635
+
636
+ // Clean up lock on exit
637
+ const cleanup = () => { try { unlinkSync(LOCK_FILE); } catch {} };
638
+ process.on("exit", cleanup);
639
+ process.on("SIGINT", () => { cleanup(); process.exit(0); });
640
+ process.on("SIGTERM", () => { cleanup(); process.exit(0); });
641
+ }
642
+
643
+ // ─── Entry ───────────────────────────────────────────
644
+ if (process.argv.includes("--report")) {
645
+ reportLogs().then(() => process.exit(0));
646
+ } else {
647
+ ensureSingleInstance();
648
+ // Report startup
649
+ const cfg = loadConfig();
650
+ if (isReady(cfg)) reportStartup(cfg);
651
+ render(<App />);
652
+ }