@vaibhavgupta0786/opencode-voice 0.0.0-stage → 0.3.1

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/tui.tsx ADDED
@@ -0,0 +1,733 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ /** @jsxImportSource @opentui/solid */
3
+ import { createSignal } from "solid-js"
4
+ import { spawn, type ChildProcess } from "node:child_process"
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import { join } from "node:path"
8
+ import { fileURLToPath } from "node:url"
9
+ import {
10
+ RATE,
11
+ TICK_MS,
12
+ hadSpeech,
13
+ healthEndpoints,
14
+ initialVadState,
15
+ isLocalEndpoint,
16
+ mergeOptions,
17
+ peakOf,
18
+ sttEndpoint,
19
+ vadStep,
20
+ wavFromPcm,
21
+ type Capture,
22
+ type VoiceOptions,
23
+ } from "./logic.ts"
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // opencode-voice: local-only push-to-talk for OpenCode v2.
27
+ // F9 — dictate a take (press to start, press to stop); takes append to
28
+ // that session's draft
29
+ // F10 — open the session's draft editor (any time, empty or not);
30
+ // Ctrl+Enter sends, Esc keeps edits
31
+ // F12 — wipe the session's draft (confirmation dialog)
32
+ // Each session keeps its own draft, so switching chats never mixes them.
33
+ // On MacBooks press F-keys with Fn: plain F10 is the hardware mic-mute key,
34
+ // plain F11 is Show Desktop (macOS reserves it), so review/wipe use F10/F12.
35
+ // No cloud, no temp audio files, no auto-send, no permission answering.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ type Phase = "idle" | "recording" | "transcribing"
39
+
40
+ /** STT server health. Only tracked for local endpoints — cloud STT has no
41
+ * health route, so a dot there would always lie (see isLocalEndpoint). */
42
+ type ServerHealth = "unknown" | "up" | "down"
43
+
44
+ const HEALTH_POLL_MS = 30_000
45
+ const HEALTH_TIMEOUT_MS = 2_000
46
+ const HEALTH_DEBOUNCE_MS = 5_000
47
+
48
+ const DEBUG_LOG = "/tmp/opencode/voice-plugin.log"
49
+
50
+ function dbg(enabled: boolean, message: string): void {
51
+ if (!enabled) return
52
+ try {
53
+ mkdirSync("/tmp/opencode", { recursive: true })
54
+ appendFileSync(DEBUG_LOG, `${new Date().toISOString()} ${message}\n`)
55
+ } catch {
56
+ // logging must never break dictation
57
+ }
58
+ }
59
+
60
+ function here(): string {
61
+ try {
62
+ return join(fileURLToPath(new URL(".", import.meta.url)))
63
+ } catch {
64
+ return join(process.env.HOME ?? "~", ".config", "opencode", "plugins", "voice")
65
+ }
66
+ }
67
+
68
+ /** Bundled mic recorder (audited source: miniaudio, streams raw PCM to stdout). */
69
+ function recorderPath(): string | null {
70
+ const os = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : null
71
+ if (!os) return null
72
+ const arch = process.arch === "arm64" ? "arm64" : "x64"
73
+ const path = join(here(), "bin", `${os}-${arch}`, "opencode-voice-recorder")
74
+ return existsSync(path) ? path : null
75
+ }
76
+
77
+ /** Path of the voice-check skill if installed in the user's global skills
78
+ * dir (skills are only discovered from there, not from plugin packages). */
79
+ function installedSkillPath(): string {
80
+ const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
81
+ return join(configHome, "opencode", "skills", "voice-check", "SKILL.md")
82
+ }
83
+
84
+ /** Install the bundled voice-check skill into the user's global skills dir
85
+ * (once — an existing file is never overwritten, so user edits survive).
86
+ * Only ever called from the explicit voice-setup command: a plugin package
87
+ * silently writing to global config on load would be exactly the kind of
88
+ * behavior a careful developer audits for. */
89
+ function installSkill(log: (m: string) => void): "installed" | "exists" | "missing-source" | "error" {
90
+ try {
91
+ const target = installedSkillPath()
92
+ if (existsSync(target)) return "exists"
93
+ const source = join(here(), "skills", "voice-check", "SKILL.md")
94
+ if (!existsSync(source)) return "missing-source"
95
+ mkdirSync(join(target, ".."), { recursive: true })
96
+ writeFileSync(target, readFileSync(source))
97
+ log("installed voice-check skill globally")
98
+ return "installed"
99
+ } catch {
100
+ // a skill install must never break dictation
101
+ return "error"
102
+ }
103
+ }
104
+
105
+ /** Record one utterance. Audio stays in memory.
106
+ * Returns the capture promise plus a stop handle for toggle mode.
107
+ * `shouldAbort` is polled every 500 ms: the moment it reports true the
108
+ * recorder is killed and the take is discarded ("take-aborted"). */
109
+ function capture(
110
+ opts: ReturnType<typeof mergeOptions>,
111
+ log: (m: string) => void,
112
+ shouldAbort?: () => boolean,
113
+ ): { done: Promise<Capture>; stop: () => void } {
114
+ const cmd = recorderPath()
115
+ if (!cmd)
116
+ return {
117
+ done: Promise.reject(new Error("no bundled recorder for this platform")),
118
+ stop: () => {},
119
+ }
120
+ const maxSec = Math.ceil(opts.maxMs / 1000).toFixed(0)
121
+
122
+ let stopFn: () => void = () => {}
123
+ const done = new Promise<Capture>((resolve, reject) => {
124
+ const child: ChildProcess = spawn(cmd, ["--seconds", maxSec, "--gain", "1.00"], {
125
+ stdio: ["ignore", "pipe", "pipe"],
126
+ })
127
+ const chunks: Uint8Array[] = []
128
+ let state = initialVadState()
129
+ let finished = false
130
+ let errorText = ""
131
+ let leftover: Uint8Array = new Uint8Array(0)
132
+
133
+ const finish = (reason: string) => {
134
+ if (finished) return
135
+ finished = true
136
+ stopFn = () => {}
137
+ clearInterval(timer)
138
+ try {
139
+ child.kill("SIGKILL")
140
+ } catch {
141
+ // already exited
142
+ }
143
+ const raw = Buffer.concat(chunks)
144
+ if (leftover.length > 0) {
145
+ // account for a trailing partial window
146
+ const peak = peakOf(leftover)
147
+ if (peak > opts.vadThreshold) state = { ...state, voicedMs: state.voicedMs + (leftover.length / (RATE * 2)) * 1000 }
148
+ }
149
+ if (raw.length === 0 && errorText.trim()) {
150
+ reject(new Error(`recorder produced no audio: ${errorText.trim().slice(0, 200)}`))
151
+ return
152
+ }
153
+ log(
154
+ `capture ${reason} voiced=${Math.round(state.voicedMs)}ms loudest=${state.loudest.toFixed(3)} bytes=${raw.length}`,
155
+ )
156
+ resolve({ wav: wavFromPcm(raw), voicedMs: state.voicedMs, loudest: state.loudest, hadSpeech: hadSpeech(state, opts) })
157
+ }
158
+
159
+ child.stdout?.on("data", (data: Buffer) => {
160
+ if (finished) return
161
+ chunks.push(data)
162
+ let buf: Uint8Array = leftover.length > 0 ? Buffer.concat([leftover, data]) : data
163
+ const window = Math.floor((RATE * 2 * TICK_MS) / 1000)
164
+ while (buf.length >= window && !finished) {
165
+ state = vadStep(state, peakOf(buf.subarray(0, window)), opts)
166
+ buf = buf.subarray(window)
167
+ if (state.done) finish(state.doneReason ?? "vad")
168
+ }
169
+ leftover = buf
170
+ })
171
+ child.stderr?.on("data", (data: Buffer) => {
172
+ if (errorText.length < 2048) errorText += data.toString()
173
+ })
174
+ child.on("error", (error) => {
175
+ if (!finished) {
176
+ finished = true
177
+ clearInterval(timer)
178
+ reject(error)
179
+ }
180
+ })
181
+ child.on("close", () => finish("recorder-exit"))
182
+ // Failsafe: never hold the mic forever. Also aborts the take the
183
+ // moment the user switches away from the session it belongs to
184
+ // (checked every 500 ms).
185
+ const timer = setInterval(() => {
186
+ if (finished) return
187
+ if (shouldAbort?.()) {
188
+ finished = true
189
+ stopFn = () => {}
190
+ clearInterval(timer)
191
+ try {
192
+ child.kill("SIGKILL")
193
+ } catch {
194
+ // already exited
195
+ }
196
+ log("capture aborted — session switched mid-recording")
197
+ reject(new Error("take-aborted"))
198
+ return
199
+ }
200
+ if (state.elapsed >= opts.maxMs + 5000) finish("watchdog")
201
+ }, 500)
202
+ stopFn = () => finish("manual-stop")
203
+ })
204
+ return { done, stop: () => stopFn() }
205
+ }
206
+
207
+ async function transcribe(wav: Uint8Array, opts: ReturnType<typeof mergeOptions>): Promise<string> {
208
+ const form = new FormData()
209
+ form.append("model", opts.model)
210
+ // Copy into a fresh ArrayBuffer-backed view: BlobPart rejects SharedArrayBuffer views.
211
+ form.append("file", new Blob([Buffer.from(wav)], { type: "audio/wav" }), "utterance.wav")
212
+ const response = await fetch(sttEndpoint(opts.stt), { method: "POST", body: form })
213
+ if (!response.ok) throw new Error(`STT HTTP ${response.status} ${response.statusText}`)
214
+ const body = (await response.json()) as { text?: string }
215
+ return (body.text ?? "").trim()
216
+ }
217
+
218
+ /** Display width of text — cursorOffset is measured in width units (host does
219
+ * the same via Bun.stringWidth); plain UTF-16 length is the ASCII fallback. */
220
+ function displayWidth(text: string): number {
221
+ const bun = (globalThis as { Bun?: { stringWidth?: (s: string) => number } }).Bun
222
+ return bun?.stringWidth ? bun.stringWidth(text) : text.length
223
+ }
224
+
225
+ // One session's draft.
226
+ interface DraftState {
227
+ text: string
228
+ takes: number
229
+ }
230
+
231
+ const EMPTY_DRAFT: DraftState = { text: "", takes: 0 }
232
+
233
+ export default Plugin.define({
234
+ id: "voice",
235
+ setup(ctx) {
236
+ // TODO: wire debug to plugin options once discovery supports them.
237
+ const opts = { ...mergeOptions(ctx.options as VoiceOptions | undefined), debug: true }
238
+ const log = (m: string) => dbg(opts.debug, m)
239
+
240
+ // --- plugin-scope state -------------------------------------------------
241
+ // Per-session drafts, kept at setup scope: switching sessions remounts
242
+ // the prompt footer (render-scoped state would reset), and each session
243
+ // must keep its own draft anyway. Keyed by sessionID.
244
+ const [phase, setPhase] = createSignal<Phase>("idle")
245
+ const [drafts, setDrafts] = createSignal<Record<string, DraftState>>({})
246
+ const draftOf = (sessionID: string): DraftState => drafts()[sessionID] ?? EMPTY_DRAFT
247
+ const putDraft = (sessionID: string, patch: Partial<DraftState>): void => {
248
+ setDrafts({ ...drafts(), [sessionID]: { ...draftOf(sessionID), ...patch } })
249
+ }
250
+ // --- server health ------------------------------------------------------
251
+ // Heartbeat for the local STT server behind the footer mic dot. Read-only
252
+ // GETs, fire-and-forget, never blocking dictation. Runs on load and every
253
+ // 30 s; dictate attempts and STT failures force an out-of-band refresh.
254
+ const localServer = isLocalEndpoint(opts.stt)
255
+ const [server, setServer] = createSignal<ServerHealth>("unknown")
256
+ let lastHealthCheck = 0
257
+ const checkHealth = async (force = false): Promise<void> => {
258
+ if (!localServer) return
259
+ if (!force && Date.now() - lastHealthCheck < HEALTH_DEBOUNCE_MS) return
260
+ lastHealthCheck = Date.now()
261
+ for (const url of healthEndpoints(opts.stt)) {
262
+ try {
263
+ const response = await fetch(url, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
264
+ if (response.ok) {
265
+ if (server() !== "up") log("stt server up")
266
+ setServer("up")
267
+ return
268
+ }
269
+ } catch {
270
+ // try the next candidate
271
+ }
272
+ }
273
+ if (server() !== "down") log("stt server down")
274
+ setServer("down")
275
+ }
276
+
277
+ let heartbeat: ReturnType<typeof setInterval> | undefined
278
+ if (localServer) {
279
+ void checkHealth(true) // no "unknown" dot lingering after load
280
+ heartbeat = setInterval(() => void checkHealth(), HEALTH_POLL_MS)
281
+ }
282
+ // Toggle state: f9 while recording stops the take instead of starting one.
283
+ let stopActive: (() => void) | null = null
284
+
285
+ interface ActiveEditor {
286
+ getContent: () => string
287
+ /** Session whose draft this editor edits. */
288
+ sessionID: string
289
+ }
290
+ let active: ActiveEditor | null = null
291
+
292
+ const persistActive = (): void => {
293
+ if (!active) return
294
+ const sessionID = active.sessionID
295
+ const content = active.getContent()
296
+ if (content.trim()) {
297
+ putDraft(sessionID, { text: content })
298
+ log(`persisted editor edits session=${sessionID} chars=${content.length}`)
299
+ } else {
300
+ putDraft(sessionID, { text: "", takes: 0 })
301
+ }
302
+ }
303
+
304
+ const dictate = async () => {
305
+ // Second f9 while recording: stop the take, transcription follows.
306
+ if (stopActive) {
307
+ ctx.ui.toast.show({ message: "Stopped — transcribing…", variant: "info", duration: 2000 })
308
+ log("manual stop")
309
+ const stop = stopActive
310
+ stopActive = null
311
+ stop()
312
+ return
313
+ }
314
+ // Never overlap a second recording onto an in-flight transcription.
315
+ if (phase() === "transcribing") {
316
+ ctx.ui.toast.show({ message: "Still transcribing — one moment.", variant: "warning", duration: 2000 })
317
+ return
318
+ }
319
+ const route = ctx.ui.router.current()
320
+ let sessionID: string
321
+ if (route.type === "session") {
322
+ sessionID = route.sessionID
323
+ } else if (route.type === "home") {
324
+ // A brand-new blank session renders the home/launch route.
325
+ // Dictating there starts the session, exactly like typing a
326
+ // prompt on the launch view would.
327
+ try {
328
+ const created = await ctx.client.session.create({})
329
+ sessionID = created.id
330
+ ctx.ui.router.navigate({ type: "session", sessionID })
331
+ log(`created session ${sessionID} for dictation`)
332
+ } catch (error) {
333
+ const message = error instanceof Error ? error.message : String(error)
334
+ log(`session create failed: ${message}`)
335
+ ctx.ui.toast.show({
336
+ title: "Voice",
337
+ message: `Could not start a session: ${message}`,
338
+ variant: "error",
339
+ })
340
+ return
341
+ }
342
+ } else {
343
+ ctx.ui.toast.show({ message: "Open a session first, then dictate.", variant: "warning" })
344
+ return
345
+ }
346
+ // New take from inside the editor: persist edits, close it, record.
347
+ if (active) {
348
+ persistActive()
349
+ active = null
350
+ ctx.ui.dialog.clear()
351
+ }
352
+ // Freshest possible dot before recording (debounced: free when recent).
353
+ await checkHealth()
354
+ setPhase("recording")
355
+ ctx.ui.toast.show({
356
+ message: opts.toggle ? "Recording… f9 to stop." : "Listening… speak now.",
357
+ variant: "info",
358
+ duration: 2000,
359
+ })
360
+ log("dictate started")
361
+ // Discard the take if the user switches away from its session: checked
362
+ // while recording (500 ms ticks), after stop (before STT), and after
363
+ // STT. Only the take is discarded — every draft stays untouched.
364
+ const switchedAway = (): boolean => {
365
+ const r = ctx.ui.router.current()
366
+ return r.type !== "session" || r.sessionID !== sessionID
367
+ }
368
+ const cap = capture(opts, log, switchedAway)
369
+ stopActive = cap.stop
370
+ let audio: Capture
371
+ try {
372
+ audio = await cap.done
373
+ } catch (error) {
374
+ stopActive = null
375
+ setPhase("idle")
376
+ const message = error instanceof Error ? error.message : String(error)
377
+ if (message === "take-aborted") {
378
+ ctx.ui.toast.show({
379
+ message: "Take discarded — you switched chats mid-recording.",
380
+ variant: "warning",
381
+ duration: 3000,
382
+ })
383
+ return
384
+ }
385
+ log(`capture failed: ${message}`)
386
+ ctx.ui.toast.show({ title: "Voice", message: `Mic failed: ${message}`, variant: "error" })
387
+ return
388
+ }
389
+ stopActive = null
390
+ if (!audio.hadSpeech) {
391
+ setPhase("idle")
392
+ ctx.ui.toast.show({ message: "Heard nothing — try again.", variant: "warning" })
393
+ return
394
+ }
395
+ // Checkpoint: switched between stop and transcription — skip the STT
396
+ // upload entirely.
397
+ if (switchedAway()) {
398
+ setPhase("idle")
399
+ log("discarded take — session switched before transcription")
400
+ ctx.ui.toast.show({ message: "Take discarded — you switched chats.", variant: "warning", duration: 3000 })
401
+ return
402
+ }
403
+ setPhase("transcribing")
404
+ const sttStarted = Date.now()
405
+ let text: string
406
+ try {
407
+ text = await transcribe(audio.wav, opts)
408
+ } catch (error) {
409
+ setPhase("idle")
410
+ const message = error instanceof Error ? error.message : String(error)
411
+ log(`transcribe failed: ${message}`)
412
+ // A green dot must never survive a failed take: force the re-check
413
+ // so the footer tells the truth within a second.
414
+ await checkHealth(true)
415
+ ctx.ui.toast.show({
416
+ title: "Voice",
417
+ message: existsSync(installedSkillPath())
418
+ ? `STT failed (${message}) — run @voice-check to restart the server.`
419
+ : `STT failed (${message}) — run /voice-setup once, then @voice-check.`,
420
+ variant: "error",
421
+ })
422
+ return
423
+ }
424
+ if (!text) {
425
+ setPhase("idle")
426
+ ctx.ui.toast.show({ message: "Empty transcription — try again.", variant: "warning" })
427
+ return
428
+ }
429
+ log(`transcript="${text}"`)
430
+ log(`timing audio=${Math.round(audio.voicedMs)}ms stt=${Date.now() - sttStarted}ms bytes=${audio.wav.length}`)
431
+ // Checkpoint: switched during transcription — the transcript is
432
+ // discarded, never appended to any draft.
433
+ if (switchedAway()) {
434
+ setPhase("idle")
435
+ log("discarded transcript — session switched during transcription")
436
+ ctx.ui.toast.show({ message: "Take discarded — you switched chats.", variant: "warning", duration: 3000 })
437
+ return
438
+ }
439
+ setPhase("idle")
440
+ if (!opts.accumulate) {
441
+ openEditor(sessionID, text, false)
442
+ return
443
+ }
444
+ const current = draftOf(sessionID)
445
+ const merged = current.text ? `${current.text}\n\n${text}` : text
446
+ putDraft(sessionID, { text: merged, takes: current.takes + 1 })
447
+ log(`draft session=${sessionID} take=${current.takes + 1} chars=${merged.length}`)
448
+ openEditor(sessionID, merged, true)
449
+ }
450
+
451
+ const openSendDialog = () => {
452
+ const route = ctx.ui.router.current()
453
+ if (route.type !== "session") {
454
+ ctx.ui.toast.show({ message: "Open a session first.", variant: "warning" })
455
+ return
456
+ }
457
+ // Fold in edits from an editor the host may have closed itself.
458
+ persistActive()
459
+ const sessionID = route.sessionID
460
+ // Open the editor even when this session's draft is empty: it doubles
461
+ // as a place to compose by hand and then send.
462
+ openEditor(sessionID, draftOf(sessionID).text, true)
463
+ }
464
+
465
+ // F12 wipes the draft of the session being viewed.
466
+ const clearDraft = async () => {
467
+ const hadEditor = active !== null
468
+ const editorSession = active?.sessionID
469
+ if (active) {
470
+ persistActive()
471
+ active = null
472
+ ctx.ui.dialog.clear()
473
+ }
474
+ const route = ctx.ui.router.current()
475
+ if (route.type !== "session") {
476
+ ctx.ui.toast.show({ message: "Open a session first.", variant: "warning" })
477
+ return
478
+ }
479
+ const sessionID = route.sessionID
480
+ const current = draftOf(sessionID)
481
+ if (!current.text.trim()) {
482
+ ctx.ui.toast.show({ message: "Draft is already empty.", variant: "info" })
483
+ return
484
+ }
485
+ const confirmed = await ctx.ui.dialog.confirm({
486
+ title: "Wipe voice draft?",
487
+ message: `Discard this session's draft (${current.text.length} chars)? This cannot be undone.`,
488
+ label: { confirm: "Wipe draft", cancel: "Keep it" },
489
+ })
490
+ if (confirmed === true) {
491
+ putDraft(sessionID, { text: "", takes: 0 })
492
+ ctx.ui.dialog.clear()
493
+ ctx.ui.toast.show({ message: "Voice draft wiped.", variant: "info" })
494
+ return
495
+ }
496
+ // Kept: if we closed this session's editor for the confirm, reopen it
497
+ // exactly as it was (edits were persisted before the confirm appeared).
498
+ if (hadEditor && editorSession === sessionID) {
499
+ openEditor(sessionID, draftOf(sessionID).text, true)
500
+ }
501
+ }
502
+
503
+ // --- editor --------------------------------------------------------------
504
+ // One dialog, idempotent. Opening always clears any previous dialog and
505
+ // shows the text with the cursor at the end; the textarea is height-
506
+ // capped like the host's own prompt, so long drafts scroll internally
507
+ // and the cursor stays visible.
508
+ const openEditor = (sessionID: string, initial: string, persistDraft: boolean) => {
509
+ let edited = initial
510
+ let sent = false
511
+ let cursorPlaced = false
512
+ let area: { plainText?: string; cursorOffset?: number } | undefined
513
+
514
+ // Live-tracked content: prefer the renderable itself, fall back to
515
+ // the onContentChange mirror (survives renderable destruction).
516
+ const getContent = (): string => {
517
+ try {
518
+ const t = area?.plainText
519
+ if (typeof t === "string") return t
520
+ } catch {
521
+ // destroyed renderable
522
+ }
523
+ return edited
524
+ }
525
+
526
+ const self: ActiveEditor = { getContent, sessionID }
527
+ const header = persistDraft
528
+ ? "🎤 Voice draft — edit · Ctrl+Enter send · Esc keep edits"
529
+ : "🎤 Dictation — edit · Ctrl+Enter send · Esc cancel"
530
+
531
+ const finish = async (value: string) => {
532
+ if (sent || active !== self) return
533
+ const text = value.trim()
534
+ if (!text) {
535
+ cancel()
536
+ return
537
+ }
538
+ // Refuse to deliver to a session other than the one visible now.
539
+ const route = ctx.ui.router.current()
540
+ if (route.type !== "session" || route.sessionID !== sessionID) {
541
+ ctx.ui.toast.show({
542
+ title: "Voice",
543
+ message: "Session changed — not sent. Draft kept.",
544
+ variant: "warning",
545
+ })
546
+ return
547
+ }
548
+ sent = true
549
+ if (persistDraft) putDraft(sessionID, { text }) // a failed send must not lose the latest text
550
+ try {
551
+ await ctx.client.session.prompt({ sessionID, text })
552
+ } catch (error) {
553
+ sent = false
554
+ const message = error instanceof Error ? error.message : String(error)
555
+ log(`send failed: ${message}`)
556
+ ctx.ui.toast.show({
557
+ title: "Voice",
558
+ message: `Send failed: ${message} — draft kept, Ctrl+Enter to retry.`,
559
+ variant: "error",
560
+ })
561
+ return // editor stays open; retry in place
562
+ }
563
+ active = null
564
+ ctx.ui.dialog.clear()
565
+ if (persistDraft) putDraft(sessionID, { text: "", takes: 0 })
566
+ }
567
+
568
+ const cancel = () => {
569
+ if (active !== self) return
570
+ if (persistDraft) persistActive()
571
+ active = null
572
+ ctx.ui.dialog.clear()
573
+ }
574
+
575
+ active = self
576
+ ctx.ui.dialog.clear() // never stack editors
577
+ ctx.ui.dialog.set({ size: "large", centered: true })
578
+ // The host's own prompt caps its textarea the same way: bounded
579
+ // height makes the editor scroll internally and keep the cursor
580
+ // visible.
581
+ const termRows = (process.stdout as { rows?: number }).rows ?? 40
582
+ const maxHeight = Math.max(6, Math.floor(termRows * 0.5))
583
+ ctx.ui.dialog.show(
584
+ () => (
585
+ <box flexDirection="column" gap={1} padding={1}>
586
+ <text>{header}</text>
587
+ <textarea
588
+ width="100%"
589
+ minHeight={1}
590
+ maxHeight={maxHeight}
591
+ initialValue={initial}
592
+ focused
593
+ keyBindings={[{ name: "return", ctrl: true, action: "submit" }]}
594
+ ref={(el: unknown) => {
595
+ area = el as { plainText?: string; cursorOffset?: number }
596
+ // Cursor at the end: new takes land there and edits
597
+ // usually continue at the tail of the draft. Deferred —
598
+ // the host's own prompt mutates the textarea post-mount
599
+ // the same way (the initialValue prop sync would otherwise
600
+ // run after an immediate set and reset the cursor).
601
+ if (!cursorPlaced) {
602
+ cursorPlaced = true
603
+ setTimeout(() => {
604
+ const el2 = area
605
+ if (!el2) return
606
+ try {
607
+ el2.cursorOffset = displayWidth(initial)
608
+ } catch {
609
+ // optional nicety, never fatal
610
+ }
611
+ }, 0)
612
+ }
613
+ }}
614
+ onContentChange={(value: unknown) => {
615
+ if (typeof value === "string") edited = value
616
+ }}
617
+ onSubmit={() => finish(getContent())}
618
+ onKeyDown={(event: unknown) => {
619
+ const name = (event as { name?: string } | null)?.name
620
+ if (name === "escape") cancel()
621
+ }}
622
+ />
623
+ </box>
624
+ ),
625
+ () => {
626
+ // The host closed the dialog itself (Esc handled at the dialog
627
+ // level): fold the edits into the draft so nothing is lost.
628
+ if (active !== self) return
629
+ if (persistDraft) persistActive()
630
+ active = null
631
+ },
632
+ )
633
+ }
634
+
635
+ // One footer slot owns the visible parts: the live mic indicator plus
636
+ // the global keymap (layers need a component owner, the slot provides
637
+ // it). All state lives at setup scope above, so slot remounts (session
638
+ // switches) never lose drafts; the take count shown is the visible
639
+ // session's own.
640
+ // The slot's own cleanup plus the heartbeat: composed so a plugin
641
+ // reload removes the footer/keymap AND stops the timer (no leaks).
642
+ const releaseSlot = ctx.ui.slot({
643
+ append: "prompt.footer.status",
644
+ render: (input) => {
645
+ ctx.keymap.layer(() => ({
646
+ mode: "global",
647
+ commands: [
648
+ {
649
+ id: "voice.dictate",
650
+ title: "Dictate prompt (voice)",
651
+ description: "Record one utterance, transcribe locally, add to the draft.",
652
+ group: "Voice",
653
+ bind: "f9",
654
+ palette: true,
655
+ slash: { name: "dictate" },
656
+ run: () => dictate(),
657
+ },
658
+ {
659
+ id: "voice.send",
660
+ title: "Review voice draft",
661
+ description: "Open the session's draft editor (empty or not). Ctrl+Enter sends, Esc keeps.",
662
+ group: "Voice",
663
+ bind: "f10",
664
+ palette: true,
665
+ slash: { name: "voice-send" },
666
+ run: () => openSendDialog(),
667
+ },
668
+ {
669
+ id: "voice.clear",
670
+ title: "Wipe voice draft",
671
+ description: "Discard this session's draft (asks for confirmation).",
672
+ group: "Voice",
673
+ bind: "f12",
674
+ palette: true,
675
+ slash: { name: "voice-clear" },
676
+ run: () => clearDraft(),
677
+ },
678
+ {
679
+ id: "voice.setup",
680
+ title: "Install voice-check recovery skill",
681
+ description: "One-time opt-in: install the voice-check STT recovery skill globally.",
682
+ group: "Voice",
683
+ palette: true,
684
+ slash: { name: "voice-setup" },
685
+ run: () => {
686
+ const result = installSkill(log)
687
+ if (result === "installed") {
688
+ ctx.ui.toast.show({
689
+ title: "Voice",
690
+ message: "voice-check skill installed — type @voice-check or say 'voice is dead' any time.",
691
+ variant: "success",
692
+ duration: 5000,
693
+ })
694
+ } else if (result === "exists") {
695
+ ctx.ui.toast.show({ message: "voice-check skill is already installed.", variant: "info" })
696
+ } else {
697
+ ctx.ui.toast.show({
698
+ title: "Voice",
699
+ message: "Could not install the skill — see docs/STT.md for manual recovery.",
700
+ variant: "error",
701
+ })
702
+ }
703
+ },
704
+ },
705
+ ],
706
+ bindings: ["voice.dictate", "voice.send", "voice.clear", "voice.setup"],
707
+ }))
708
+
709
+ const label = () => {
710
+ const p = phase()
711
+ if (p === "recording") return "🔴 REC f9"
712
+ if (p === "transcribing") return "🟡 …"
713
+ const sid = input.sessionID
714
+ const n = sid ? (drafts()[sid]?.takes ?? 0) : 0
715
+ const mic = n > 0 ? `🎤·${n} f9` : "🎤 f9"
716
+ // Health dot only in idle (phases own the footer during a take)
717
+ // and only for local servers; unknown renders dotless.
718
+ if (!localServer) return mic
719
+ const s = server()
720
+ if (s === "up") return `🟢${mic}`
721
+ if (s === "down") return `🔴${mic}`
722
+ return mic
723
+ }
724
+ return <text>{label()}</text>
725
+ },
726
+ })
727
+
728
+ return () => {
729
+ if (heartbeat !== undefined) clearInterval(heartbeat)
730
+ releaseSlot()
731
+ }
732
+ },
733
+ })