opencode-voice-dictation 0.8.1 → 0.9.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/README.md CHANGED
@@ -1,12 +1,13 @@
1
- # Dictado por voz para OpenCode Desktop
1
+ # Dictado por voz para OpenCode Desktop y TUI
2
2
 
3
3
  Plugin de [OpenCode](https://opencode.ai) V2 que añade voz a la app de
4
- escritorio:
4
+ escritorio y a la TUI:
5
5
 
6
6
  - **Dictado**: pulsas la píldora y se abre el dictado de Windows 11 (Win+H),
7
7
  que escribe en el prompt con su motor neuronal. También puedes usar el motor
8
8
  offline `System.Speech` desde Ajustes.
9
9
  - **Lectura**: pulsa «Leer» y reproduce en voz alta la última respuesta.
10
+ - **TUI**: usa `/dictado` y `/leer` (o `<leader>d` y `<leader>v`) en la terminal.
10
11
 
11
12
  - **Dictado preciso**: por defecto delega en Windows Voice Typing (Win+H).
12
13
  - **Modo offline**: `System.Speech` sigue disponible como alternativa sin
@@ -28,8 +29,8 @@ escritorio:
28
29
  opencode plugin add opencode-voice-dictation
29
30
  ```
30
31
 
31
- Reinicia OpenCode Desktop. El plugin queda registrado en el `opencode.json`
32
- global del entorno donde ejecutes el comando.
32
+ Reinicia OpenCode Desktop o la TUI. El plugin queda registrado en el
33
+ `opencode.json` global del entorno donde ejecutes el comando.
33
34
 
34
35
  Actualizar:
35
36
 
@@ -44,6 +45,20 @@ está commiteado):
44
45
  opencode plugin add github:evious/opencode-voice-dictation
45
46
  ```
46
47
 
48
+ ## TUI (terminal)
49
+
50
+ El paquete también exporta una parte TUI. En Windows puedes dictar y escuchar
51
+ la última respuesta sin salir de la terminal:
52
+
53
+ | Comando | Atajo | Acción |
54
+ | --- | --- | --- |
55
+ | `/dictado` | `<leader>d` (`Ctrl+X`, luego `d`) | Abre el dictado de Windows (Win+H) en el prompt. |
56
+ | `/leer` | `<leader>v` (`Ctrl+X`, luego `v`) | Lee la última respuesta. Si ya está leyendo, la detiene. |
57
+
58
+ La lectura usa las voces neuronales de Edge y, si falla la red o la
59
+ reproducción, cae en la voz del sistema. El dictado de la TUI requiere Windows
60
+ por ahora; Linux se añadirá más adelante.
61
+
47
62
  ## Compatibilidad
48
63
 
49
64
  Probado con OpenCode Desktop **2.2.6** en Windows 11 y Ubuntu 24.04. La API de
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-voice-dictation",
3
- "version": "0.8.1",
4
- "description": "Offline voice dictation and read-aloud for OpenCode Desktop on Windows and Linux",
3
+ "version": "0.9.0",
4
+ "description": "Offline voice dictation and read-aloud for OpenCode Desktop and TUI on Windows and Linux",
5
5
  "license": "MIT",
6
6
  "author": "Fran Carlos <francarlos803@gmail.com>",
7
7
  "homepage": "https://github.com/evious/opencode-voice-dictation#readme",
@@ -15,6 +15,8 @@
15
15
  "keywords": [
16
16
  "opencode",
17
17
  "opencode-plugin",
18
+ "tui",
19
+ "terminal",
18
20
  "voice",
19
21
  "dictation",
20
22
  "speech-to-text",
@@ -28,7 +30,8 @@
28
30
  "exports": {
29
31
  ".": "./src/index.ts",
30
32
  "./gui-experimental": "./dist/gui-experimental.cjs",
31
- "./rpc": "./src/rpc.ts"
33
+ "./rpc": "./src/rpc.ts",
34
+ "./tui": "./src/tui.ts"
32
35
  },
33
36
  "files": [
34
37
  "src",
@@ -38,8 +41,8 @@
38
41
  "docs"
39
42
  ],
40
43
  "dependencies": {
41
- "msedge-tts": "^2.1.1",
42
- "@opencode/plugin": "2.2.6"
44
+ "@opencode/plugin": "2.2.6",
45
+ "msedge-tts": "^2.1.1"
43
46
  },
44
47
  "publishConfig": {
45
48
  "access": "public"
@@ -47,11 +50,14 @@
47
50
  "scripts": {
48
51
  "build": "vite build",
49
52
  "dev": "vite build --watch",
50
- "test": "node test/smoke.cjs",
53
+ "test": "node test/speech-text.cjs && node test/tui.cjs && node test/smoke.cjs",
51
54
  "typecheck": "tsc --noEmit",
52
55
  "prepublishOnly": "npm run typecheck && npm run build && npm test"
53
56
  },
54
57
  "devDependencies": {
58
+ "@opencode/theme": "^2.2.6",
59
+ "@opentui/core": "^0.8.1",
60
+ "@opentui/solid": "^0.8.1",
55
61
  "@types/node": "^24.0.0",
56
62
  "effect": "4.1.0",
57
63
  "solid-js": "^1.9.17",
package/src/gui/main.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { Plugin } from "@opencode/plugin/gui"
2
2
  import { Dictation, Speech, VoiceTyping } from "./contract"
3
3
  import { diagnose, startEngine, type EngineHandle } from "./engines/system-speech"
4
- import { listSapiVoices, speakSapi, type SpeakHandle } from "./engines/sapi-speech"
5
- import { sendVoiceTypingShortcut } from "./engines/win-h"
4
+ import { listSapiVoices, speakSapi, type SpeakHandle } from "../sapi-speech"
5
+ import { sendVoiceTypingShortcut } from "../win-h"
6
6
 
7
7
  type Status = "idle" | "starting" | "listening" | "error"
8
8
 
package/src/gui/speak.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createSignal } from "solid-js"
2
2
  import type { IpcClient, SetupContext } from "@opencode/plugin/gui"
3
3
  import { VoiceRpc } from "../rpc"
4
+ import { cleanForSpeech, chunkForSpeech } from "../speech-text"
4
5
  import { Speech } from "./contract"
5
6
  import type { definition } from "./definition"
6
7
 
@@ -30,81 +31,7 @@ export interface Speaker {
30
31
  preview(): Promise<boolean>
31
32
  }
32
33
 
33
- const MAX_CHUNK = 250
34
-
35
- /** Turns Markdown into something worth hearing. */
36
- export function cleanForSpeech(text: string, skipCode: boolean): string {
37
- let value = text.replace(/\r\n/g, "\n")
38
-
39
- if (skipCode) {
40
- value = value.replace(/```[\s\S]*?```/g, " ")
41
- } else {
42
- value = value.replace(/```[^\n]*\n([\s\S]*?)```/g, "$1")
43
- }
44
-
45
- value = value.replace(/`([^`]+)`/g, "$1")
46
- value = value.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
47
- value = value.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
48
- value = value.replace(/^\s*#{1,6}\s+/gm, "")
49
- value = value.replace(/^\s*>\s?/gm, "")
50
- value = value.replace(/^\s*[-*+]\s+/gm, "")
51
- value = value.replace(/^\s*\d+\.\s+/gm, "")
52
- value = value.replace(/(\*\*|__)(.*?)\1/g, "$2")
53
- value = value.replace(/(\*|_)(.*?)\1/g, "$2")
54
- value = value.replace(/^\s*\|.*\|\s*$/gm, " ")
55
- value = value.replace(/\n{2,}/g, ". ")
56
- value = value.replace(/\n/g, " ")
57
- value = value.replace(/\s+/g, " ")
58
- value = value.replace(/\s+([.,;:!?])/g, "$1")
59
- value = value.replace(/([.,;:!?]){2,}/g, "$1")
60
-
61
- return value.trim()
62
- }
63
-
64
- /** Splits at sentence boundaries, then at word boundaries for long sentences. */
65
- export function chunkForSpeech(text: string, max = MAX_CHUNK): string[] {
66
- const trimmed = text.trim()
67
- if (!trimmed) return []
68
-
69
- const sentences = trimmed.split(/(?<=[.!?…])\s+/)
70
- const chunks: string[] = []
71
- let current = ""
72
-
73
- const pushWords = (sentence: string) => {
74
- let part = ""
75
- for (const word of sentence.split(/\s+/)) {
76
- if (!word) continue
77
- if (part && part.length + 1 + word.length > max) {
78
- chunks.push(part)
79
- part = word
80
- } else {
81
- part = part ? `${part} ${word}` : word
82
- }
83
- }
84
- return part
85
- }
86
-
87
- for (const sentence of sentences) {
88
- if (sentence.length > max) {
89
- if (current) {
90
- chunks.push(current)
91
- current = ""
92
- }
93
- current = pushWords(sentence)
94
- continue
95
- }
96
-
97
- if (current && current.length + 1 + sentence.length > max) {
98
- chunks.push(current)
99
- current = sentence
100
- } else {
101
- current = current ? `${current} ${sentence}` : sentence
102
- }
103
- }
104
-
105
- if (current) chunks.push(current)
106
- return chunks
107
- }
34
+ export { cleanForSpeech, chunkForSpeech } from "../speech-text"
108
35
 
109
36
  export function createSpeaker(
110
37
  ctx: SetupContext<typeof definition>,
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto"
3
3
  import { mkdirSync, rmSync, writeFileSync } from "node:fs"
4
4
  import { tmpdir } from "node:os"
5
5
  import { join } from "node:path"
6
- import { SPEAK_SCRIPT } from "./speak-script"
6
+ import { SPEAK_SCRIPT } from "./speak-script.js"
7
7
 
8
8
  const POWERSHELL = "powershell.exe"
9
9
 
@@ -0,0 +1,75 @@
1
+ const MAX_CHUNK = 250
2
+
3
+ /** Turns Markdown into something worth hearing. */
4
+ export function cleanForSpeech(text: string, skipCode: boolean): string {
5
+ let value = text.replace(/\r\n/g, "\n")
6
+
7
+ if (skipCode) {
8
+ value = value.replace(/```[\s\S]*?```/g, " ")
9
+ } else {
10
+ value = value.replace(/```[^\n]*\n([\s\S]*?)```/g, "$1")
11
+ }
12
+
13
+ value = value.replace(/`([^`]+)`/g, "$1")
14
+ value = value.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
15
+ value = value.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
16
+ value = value.replace(/^\s*#{1,6}\s+/gm, "")
17
+ value = value.replace(/^\s*>\s?/gm, "")
18
+ value = value.replace(/^\s*[-*+]\s+/gm, "")
19
+ value = value.replace(/^\s*\d+\.\s+/gm, "")
20
+ value = value.replace(/(\*\*|__)(.*?)\1/g, "$2")
21
+ value = value.replace(/(\*|_)(.*?)\1/g, "$2")
22
+ value = value.replace(/^\s*\|.*\|\s*$/gm, " ")
23
+ value = value.replace(/\n{2,}/g, ". ")
24
+ value = value.replace(/\n/g, " ")
25
+ value = value.replace(/\s+/g, " ")
26
+ value = value.replace(/\s+([.,;:!?])/g, "$1")
27
+ value = value.replace(/([.,;:!?]){2,}/g, "$1")
28
+
29
+ return value.trim()
30
+ }
31
+
32
+ /** Splits at sentence boundaries, then at word boundaries for long sentences. */
33
+ export function chunkForSpeech(text: string, max = MAX_CHUNK): string[] {
34
+ const trimmed = text.trim()
35
+ if (!trimmed) return []
36
+
37
+ const sentences = trimmed.split(/(?<=[.!?…])\s+/)
38
+ const chunks: string[] = []
39
+ let current = ""
40
+
41
+ const pushWords = (sentence: string) => {
42
+ let part = ""
43
+ for (const word of sentence.split(/\s+/)) {
44
+ if (!word) continue
45
+ if (part && part.length + 1 + word.length > max) {
46
+ chunks.push(part)
47
+ part = word
48
+ } else {
49
+ part = part ? `${part} ${word}` : word
50
+ }
51
+ }
52
+ return part
53
+ }
54
+
55
+ for (const sentence of sentences) {
56
+ if (sentence.length > max) {
57
+ if (current) {
58
+ chunks.push(current)
59
+ current = ""
60
+ }
61
+ current = pushWords(sentence)
62
+ continue
63
+ }
64
+
65
+ if (current && current.length + 1 + sentence.length > max) {
66
+ chunks.push(current)
67
+ current = sentence
68
+ } else {
69
+ current = current ? `${current} ${sentence}` : sentence
70
+ }
71
+ }
72
+
73
+ if (current) chunks.push(current)
74
+ return chunks
75
+ }
@@ -0,0 +1,128 @@
1
+ import { spawn, type ChildProcess } from "node:child_process"
2
+ import { randomUUID } from "node:crypto"
3
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+
7
+ const POWERSHELL = "powershell.exe"
8
+
9
+ const PLAYER_SCRIPT = `param(
10
+ [Parameter(Mandatory = $true)][string]$Path
11
+ )
12
+
13
+ $ErrorActionPreference = "Stop"
14
+ $player = $null
15
+
16
+ try {
17
+ [Windows.Media.Playback.MediaPlayer, Windows.Media.Playback, ContentType = WindowsRuntime] | Out-Null
18
+ [Windows.Media.Core.MediaSource, Windows.Media.Core, ContentType = WindowsRuntime] | Out-Null
19
+
20
+ $player = [Windows.Media.Playback.MediaPlayer]::new()
21
+ $player.Source = [Windows.Media.Core.MediaSource]::CreateFromUri([Uri]$Path)
22
+ $player.Play()
23
+
24
+ $deadline = [DateTime]::UtcNow.AddSeconds(5)
25
+ $duration = 0
26
+ do {
27
+ Start-Sleep -Milliseconds 50
28
+ $duration = $player.PlaybackSession.NaturalDuration.TotalMilliseconds
29
+ } while ($duration -le 0 -and [DateTime]::UtcNow -lt $deadline)
30
+
31
+ if ($duration -le 0) {
32
+ $player.Dispose()
33
+ exit 5
34
+ }
35
+
36
+ Start-Sleep -Milliseconds ([int]$duration + 350)
37
+ $player.Pause()
38
+ $player.Dispose()
39
+ exit 0
40
+ } catch {
41
+ if ($null -ne $player) {
42
+ try { $player.Dispose() } catch { }
43
+ }
44
+ Write-Error $_
45
+ exit 4
46
+ }
47
+ `
48
+
49
+ export interface Playback {
50
+ stop(): void
51
+ done: Promise<boolean>
52
+ }
53
+
54
+ function workDir(): string {
55
+ const dir = join(tmpdir(), "opencode-voice-dictation")
56
+ mkdirSync(dir, { recursive: true })
57
+ return dir
58
+ }
59
+
60
+ function killTree(child: ChildProcess) {
61
+ const pid = child.pid
62
+ if (pid === undefined) {
63
+ child.kill()
64
+ return
65
+ }
66
+ const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
67
+ windowsHide: true,
68
+ stdio: "ignore",
69
+ })
70
+ killer.on("error", () => child.kill())
71
+ }
72
+
73
+ export function playMp3Base64(base64: string): Playback {
74
+ if (process.platform !== "win32") {
75
+ return { stop() {}, done: Promise.resolve(false) }
76
+ }
77
+
78
+ const id = randomUUID()
79
+ const file = join(workDir(), `speech-${id}.mp3`)
80
+ const script = join(workDir(), `speech-${id}.ps1`)
81
+ writeFileSync(file, Buffer.from(base64, "base64"))
82
+ writeFileSync(script, PLAYER_SCRIPT, "utf8")
83
+
84
+ let child: ChildProcess | undefined
85
+ let settled = false
86
+ let resolveDone!: (ok: boolean) => void
87
+ const done = new Promise<boolean>((resolve) => {
88
+ resolveDone = resolve
89
+ })
90
+
91
+ const clean = () => {
92
+ for (const path of [file, script]) {
93
+ try {
94
+ rmSync(path, { force: true })
95
+ } catch {
96
+ // Best effort: the temp folder is cleaned by the OS anyway.
97
+ }
98
+ }
99
+ }
100
+
101
+ const finish = (ok: boolean) => {
102
+ if (settled) return
103
+ settled = true
104
+ clean()
105
+ resolveDone(ok)
106
+ }
107
+
108
+ child = spawn(
109
+ POWERSHELL,
110
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Path", file],
111
+ {
112
+ windowsHide: true,
113
+ stdio: "ignore",
114
+ },
115
+ )
116
+
117
+ child.on("error", () => finish(false))
118
+ child.on("close", (code) => finish(code === 0))
119
+
120
+ return {
121
+ stop() {
122
+ if (settled) return
123
+ if (child) killTree(child)
124
+ finish(false)
125
+ },
126
+ done,
127
+ }
128
+ }
package/src/tui.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import { VoiceRpc } from "./rpc.js"
3
+ import { chunkForSpeech, cleanForSpeech } from "./speech-text.js"
4
+ import { speakSapi, type SpeakHandle } from "./sapi-speech.js"
5
+ import { playMp3Base64, type Playback } from "./tui/player.js"
6
+ import { sendVoiceTypingShortcut } from "./win-h.js"
7
+
8
+ const DEFAULT_VOICE = "es-ES-XimenaNeural"
9
+ const DEFAULT_RATE = 1
10
+ const MAX_EDGE_CHUNK = 4000
11
+
12
+ function errorMessage(error: unknown): string {
13
+ if (error instanceof Error && error.message) return error.message
14
+ if (typeof error === "string" && error) return error
15
+ return "error desconocido"
16
+ }
17
+
18
+ export default Plugin.define({
19
+ id: "voice-dictation.tui",
20
+ setup(context) {
21
+ const voice = typeof context.options.voice === "string" ? context.options.voice : DEFAULT_VOICE
22
+ const rate = typeof context.options.rate === "number" ? context.options.rate : DEFAULT_RATE
23
+ const skipCode = typeof context.options.skipCode === "boolean" ? context.options.skipCode : true
24
+
25
+ let generation = 0
26
+ let busy = false
27
+ let active: { stop(): void } | undefined
28
+
29
+ const stop = () => {
30
+ generation++
31
+ active?.stop()
32
+ active = undefined
33
+ busy = false
34
+ }
35
+
36
+ const dictate = async () => {
37
+ if (process.platform !== "win32") {
38
+ context.ui.toast.show({
39
+ title: "Dictado por voz",
40
+ message: "El dictado de la TUI está disponible solo en Windows por ahora.",
41
+ variant: "warning",
42
+ })
43
+ return
44
+ }
45
+
46
+ try {
47
+ await sendVoiceTypingShortcut()
48
+ context.ui.toast.show({
49
+ title: "Dictado por voz",
50
+ message: "Dictado de Windows abierto. Habla y el texto aparecerá en el prompt.",
51
+ variant: "info",
52
+ })
53
+ } catch (error) {
54
+ context.ui.toast.show({
55
+ title: "Dictado por voz",
56
+ message: `No se pudo abrir el dictado: ${errorMessage(error)}`,
57
+ variant: "error",
58
+ })
59
+ }
60
+ }
61
+
62
+ const speakSystem = (text: string, current: number) => {
63
+ return new Promise<void>((resolve) => {
64
+ let settled = false
65
+ let wrapper: { stop(): void } | undefined
66
+
67
+ const finish = () => {
68
+ if (settled) return
69
+ settled = true
70
+ if (active === wrapper) active = undefined
71
+ resolve()
72
+ }
73
+
74
+ const handle: SpeakHandle = speakSapi({ text, rate, voice: "" }, (ok) => {
75
+ if (!ok && current === generation) {
76
+ context.ui.toast.show({
77
+ title: "Dictado por voz",
78
+ message: "Tampoco se pudo reproducir con la voz del sistema.",
79
+ variant: "error",
80
+ })
81
+ }
82
+ finish()
83
+ })
84
+
85
+ wrapper = {
86
+ stop() {
87
+ handle.stop()
88
+ finish()
89
+ },
90
+ }
91
+ active = wrapper
92
+
93
+ if (current !== generation) {
94
+ handle.stop()
95
+ finish()
96
+ }
97
+ })
98
+ }
99
+
100
+ const read = async () => {
101
+ if (busy || active) {
102
+ stop()
103
+ context.ui.toast.show({
104
+ title: "Dictado por voz",
105
+ message: "Lectura detenida.",
106
+ variant: "info",
107
+ })
108
+ return
109
+ }
110
+
111
+ if (process.platform !== "win32") {
112
+ context.ui.toast.show({
113
+ title: "Dictado por voz",
114
+ message: "La lectura de la TUI está disponible solo en Windows por ahora.",
115
+ variant: "warning",
116
+ })
117
+ return
118
+ }
119
+
120
+ const route = context.ui.router.current()
121
+ if (route.type !== "session") {
122
+ context.ui.toast.show({
123
+ title: "Dictado por voz",
124
+ message: "Abre una sesión para leer su última respuesta.",
125
+ variant: "warning",
126
+ })
127
+ return
128
+ }
129
+
130
+ const current = ++generation
131
+ busy = true
132
+
133
+ try {
134
+ const response = await context.client.message.list({
135
+ sessionID: route.sessionID,
136
+ type: "assistant",
137
+ order: "desc",
138
+ limit: 10,
139
+ })
140
+ if (current !== generation) return
141
+
142
+ const message = response.data.find(
143
+ (entry) =>
144
+ entry.type === "assistant" &&
145
+ entry.content.some((part) => part.type === "text" && part.text.trim().length > 0),
146
+ )
147
+ if (!message || message.type !== "assistant") {
148
+ context.ui.toast.show({
149
+ title: "Dictado por voz",
150
+ message: "No hay una respuesta para leer.",
151
+ variant: "info",
152
+ })
153
+ return
154
+ }
155
+
156
+ const text = message.content
157
+ .filter((part) => part.type === "text")
158
+ .map((part) => part.text)
159
+ .join("\n")
160
+ const cleaned = cleanForSpeech(text, skipCode)
161
+ if (!cleaned) {
162
+ context.ui.toast.show({
163
+ title: "Dictado por voz",
164
+ message: "La última respuesta no tiene texto para leer.",
165
+ variant: "info",
166
+ })
167
+ return
168
+ }
169
+
170
+ let edgeFailed = false
171
+ for (const chunk of chunkForSpeech(cleaned, MAX_EDGE_CHUNK)) {
172
+ if (current !== generation) return
173
+
174
+ let audio: string
175
+ try {
176
+ const result = await context.client.rpc(VoiceRpc).edgeSpeak({ text: chunk, voice, rate })
177
+ if (typeof result !== "string" || result.length === 0) {
178
+ edgeFailed = true
179
+ break
180
+ }
181
+ audio = result
182
+ } catch {
183
+ edgeFailed = true
184
+ break
185
+ }
186
+ if (current !== generation) return
187
+
188
+ const playback: Playback = playMp3Base64(audio)
189
+ active = playback
190
+ const ok = await playback.done
191
+ if (active === playback) active = undefined
192
+ if (current !== generation) return
193
+
194
+ if (!ok) {
195
+ edgeFailed = true
196
+ break
197
+ }
198
+ }
199
+
200
+ if (!edgeFailed || current !== generation) return
201
+
202
+ context.ui.toast.show({
203
+ title: "Dictado por voz",
204
+ message: "No se pudo usar Edge. Usando la voz del sistema.",
205
+ variant: "warning",
206
+ })
207
+ await speakSystem(cleaned, current)
208
+ } catch (error) {
209
+ if (current === generation) {
210
+ context.ui.toast.show({
211
+ title: "Dictado por voz",
212
+ message: `No se pudo leer la respuesta: ${errorMessage(error)}`,
213
+ variant: "error",
214
+ })
215
+ }
216
+ } finally {
217
+ if (current === generation) busy = false
218
+ }
219
+ }
220
+
221
+ context.keymap.layer(() => ({
222
+ mode: "global",
223
+ priority: 10,
224
+ commands: [
225
+ {
226
+ id: "voice-dictation.tui.dictate",
227
+ title: "Dictar por voz",
228
+ description: "Abre el dictado de Windows (Win+H) en el prompt.",
229
+ group: "Voz",
230
+ bind: "<leader>d",
231
+ palette: true,
232
+ slash: { name: "dictado", aliases: ["dictate"] },
233
+ suggested: true,
234
+ run: dictate,
235
+ },
236
+ {
237
+ id: "voice-dictation.tui.read",
238
+ title: "Leer última respuesta",
239
+ description: "Lee en voz alta la última respuesta con voces Edge.",
240
+ group: "Voz",
241
+ bind: "<leader>v",
242
+ palette: true,
243
+ slash: { name: "leer", aliases: ["read"] },
244
+ suggested: true,
245
+ run: read,
246
+ },
247
+ ],
248
+ bindings: ["voice-dictation.tui.dictate", "voice-dictation.tui.read"],
249
+ }))
250
+
251
+ return () => stop()
252
+ },
253
+ })
File without changes