opencode-voice-plugin 0.1.0 → 0.1.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/README.md CHANGED
@@ -1,61 +1,63 @@
1
1
  # opencode-voice-plugin
2
2
 
3
- Ditado por voz para o **OpenCode V2**: um ícone de microfone (`○`) no rodapé do prompt. Clique nele
4
- (ou use `ctrl+y`), fale, e o texto transcrito vira uma mensagem da sessão.
3
+ **English** · [Português (BR)](README.pt-BR.md)
5
4
 
6
- A transcrição usa o **OpenRouter** (`POST /api/v1/audio/transcriptions`) e a chave é reaproveitada do
7
- próprio OpenCode quando você já tem o OpenRouter conectado — sem variável de ambiente e sem duplicar
8
- segredo. Funciona em **Windows, Linux e macOS** a partir do mesmo código, e é instalado **na máquina**
9
- (não por projeto).
5
+ Voice dictation for **OpenCode V2**: a microphone icon (`○`) in the prompt footer. Click it (or press
6
+ `ctrl+y`), speak, and the transcribed text becomes a message in the session.
10
7
 
11
- [Repositório](https://github.com/kabrau/opencode-voice-plugin) ·
8
+ Transcription uses **OpenRouter** (`POST /api/v1/audio/transcriptions`), and the API key is reused
9
+ from OpenCode itself when you already have OpenRouter connected — no environment variable, no
10
+ duplicating secrets. It works on **Windows, Linux and macOS** from the same code, and installs
11
+ **machine-wide** (not per project).
12
+
13
+ [Repository](https://github.com/kabrau/opencode-voice-plugin) ·
12
14
  [Issues](https://github.com/kabrau/opencode-voice-plugin/issues) ·
13
15
  [Design](DESIGN.md) · MIT
14
16
 
15
17
  ---
16
18
 
17
- ## Como funciona
19
+ ## How it works
18
20
 
19
- 1. `🎤` no rodapé do prompt (aparece dentro de uma sessão).
20
- 2. Clique, ou `ctrl+y`, ou digite `/voice` → começa a gravar (`●` + segundos).
21
- 3. Clique de novo (ou espere `maxSeconds`) → para, transcreve (`⏳`).
22
- 4. Abre o dialog **Ditado** com o texto — edite se quiser e confirme → a mensagem é enviada na sessão.
21
+ 1. `○` in the prompt footer (visible inside a session).
22
+ 2. Click it, press `ctrl+y`, or type `/voice` → recording starts (`●` + seconds).
23
+ 3. Click again (or wait for `maxSeconds`) → it stops and transcribes (`…`).
24
+ 4. The **Dictation** dialog opens with the text — edit if you want and confirm → the message is sent.
23
25
 
24
- Na **tela inicial** também funciona: como ali ainda não existe sessão, o `ctrl+y` **cria a sessão na
25
- hora** (antes de gravar) e o texto é enviado nela — o mesmo que digitar e dar Enter. Desligue com
26
- `"allowFromHome": false` se preferir que o microfone só aja dentro de uma sessão.
26
+ It also works on the **home screen**: since there is no session yet, `ctrl+y` **creates one right
27
+ away** (before recording) and sends the text there — the same as typing and pressing Enter. Turn it
28
+ off with `"allowFromHome": false` if you prefer the microphone to work only inside a session.
27
29
 
28
30
  ---
29
31
 
30
- ## Requisitos
32
+ ## Requirements
31
33
 
32
34
  - OpenCode **V2** (`opencode --version`).
33
- - **ffmpeg** no sistema:
35
+ - **ffmpeg** on the system:
34
36
  - Windows: `choco install ffmpeg`
35
37
  - Debian/Ubuntu: `sudo apt install ffmpeg pulseaudio-utils`
36
38
  - macOS: `brew install ffmpeg`
37
- - Uma chave de API do **OpenRouter** (veja [Custo](#custo) e [Chave da API](#chave-da-api)).
39
+ - An **OpenRouter** API key (see [Cost](#cost) and [API key](#api-key)).
38
40
 
39
41
  ---
40
42
 
41
- ## Instalação (via npm — recomendado)
43
+ ## Install (via npm — recommended)
42
44
 
43
45
  ```bash
44
46
  opencode plugin add opencode-voice-plugin
45
47
  ```
46
48
 
47
- Pronto — sem clonar nem copiar arquivo. Atualizar e remover:
49
+ That's it — no cloning, no copying files. To update or remove:
48
50
 
49
51
  ```bash
50
52
  opencode plugin update opencode-voice-plugin
51
53
  opencode plugin remove opencode-voice-plugin
52
54
  ```
53
55
 
54
- > Para **configurar options** (ícones, atalho, dispositivo, etc.), veja
55
- > [Onde declarar as options](#onde-declarar-as-options) — é um detalhe que o `plugin add`
56
- > sozinho não cobre.
56
+ > To **configure options** (icons, keybind, device, etc.), see
57
+ > [Where to declare options](#where-to-declare-options) — that is a detail `plugin add` alone
58
+ > does not cover.
57
59
 
58
- ### Sem npm (direto do GitHub)
60
+ ### Without npm (straight from GitHub)
59
61
 
60
62
  ```bash
61
63
  opencode plugin add github:kabrau/opencode-voice-plugin
@@ -63,50 +65,50 @@ opencode plugin add github:kabrau/opencode-voice-plugin
63
65
 
64
66
  ---
65
67
 
66
- ## Instalação manual (na máquina)
68
+ ## Manual install (machine-wide)
67
69
 
68
- Se preferir cópia local, o OpenCode descobre plugins no diretório global de configuração:
70
+ If you prefer a local copy, OpenCode discovers plugins in the global config directory:
69
71
 
70
72
  ```
71
- <config-global>/plugins/<nome>/index.ts # entrypoint de servidor
72
- <config-global>/plugins/<nome>/tui.tsx # entrypoint da TUI (o plugin)
73
+ <global-config>/plugins/<name>/index.ts # server entrypoint
74
+ <global-config>/plugins/<name>/tui.tsx # TUI entrypoint (the plugin itself)
73
75
  ```
74
76
 
75
- No Windows o config global fica em `C:\Users\<você>\.config\opencode`. No Linux/macOS, em
76
- `~/.config/opencode` (ou `$XDG_CONFIG_HOME/opencode`).
77
+ On Windows the global config lives in `C:\Users\<you>\.config\opencode`. On Linux/macOS it is
78
+ `~/.config/opencode` (or `$XDG_CONFIG_HOME/opencode`).
77
79
 
78
80
  ### Windows (PowerShell)
79
81
 
80
82
  ```powershell
81
83
  git clone https://github.com/kabrau/opencode-voice-plugin.git
82
- $destino = "$env:USERPROFILE\.config\opencode\plugins\voice"
83
- New-Item -ItemType Directory -Force -Path $destino | Out-Null
84
- Copy-Item .\opencode-voice-plugin\src\* $destino
84
+ $dest = "$env:USERPROFILE\.config\opencode\plugins\voice"
85
+ New-Item -ItemType Directory -Force -Path $dest | Out-Null
86
+ Copy-Item .\opencode-voice-plugin\src\* $dest
85
87
  ```
86
88
 
87
89
  ### Linux / macOS
88
90
 
89
91
  ```bash
90
92
  git clone https://github.com/kabrau/opencode-voice-plugin.git
91
- destino="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/voice"
92
- mkdir -p "$destino"
93
- cp opencode-voice-plugin/src/* "$destino/"
93
+ dest="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/voice"
94
+ mkdir -p "$dest"
95
+ cp opencode-voice-plugin/src/* "$dest/"
94
96
  ```
95
97
 
96
- Depois, **reinicie o `opencode`**.
98
+ Then **restart `opencode`**.
97
99
 
98
- ### Instalação alternativa (apontando para o clone)
100
+ ### Alternative install (pointing at the clone)
99
101
 
100
- Aponte o `cli.json` global para o repositório clonado. Isso mantém o plugin atualizado via `git pull`
101
- e é o jeito de passar options, já que a pasta de descoberta não tem onde declará-las:
102
+ Point the global `cli.json` at the cloned repository. This keeps the plugin updated via `git pull`
103
+ and is the way to pass options, since the discovery folder has nowhere to declare them:
102
104
 
103
105
  ```json
104
106
  {
105
107
  "plugins": [
106
108
  {
107
- "package": "file:///caminho/para/opencode-voice-plugin",
109
+ "package": "file:///path/to/opencode-voice-plugin",
108
110
  "options": {
109
- "language": "pt",
111
+ "language": "en",
110
112
  "keybind": "ctrl+y"
111
113
  }
112
114
  }
@@ -114,58 +116,58 @@ e é o jeito de passar options, já que a pasta de descoberta não tem onde decl
114
116
  }
115
117
  ```
116
118
 
117
- > O caminho pode ser um caminho absoluto ou uma URL `file://`. **Adicione** ao array `plugins`
118
- > existente em `~/.config/opencode/cli.json`, sem apagar o resto do arquivo.
119
+ > The path can be an absolute path or a `file://` URL. **Add** it to the existing `plugins` array in
120
+ > `~/.config/opencode/cli.json`, without deleting the rest of the file.
119
121
 
120
122
  ---
121
123
 
122
- ## Chave da API
124
+ ## API key
123
125
 
124
- O plugin resolve a chave nesta ordem:
126
+ The plugin resolves the key in this order:
125
127
 
126
- 1. **Variável de ambiente** — `OPENROUTER_API_KEY` (ou o nome em `apiKeyEnv`), se existir.
127
- 2. **Store do próprio OpenCode** — se você já conectou o OpenRouter, o plugin usa essa chave
128
- automaticamente. **Nada a configurar.**
128
+ 1. **Environment variable** — `OPENROUTER_API_KEY` (or whatever `apiKeyEnv` says), if present.
129
+ 2. **OpenCode's own credential store** — if you already connected OpenRouter, the plugin uses that
130
+ key automatically. **Nothing to configure.**
129
131
 
130
- Ou seja: se o OpenRouter já aparece conectado, você **não precisa** criar variável nenhuma.
131
- Confira com:
132
+ In other words: if OpenRouter already shows up as connected, you **do not** need to create any
133
+ variable. Check with:
132
134
 
133
135
  ```bash
134
- opencode auth list # deve listar "OpenRouter ... stored"
136
+ opencode auth list # should list "OpenRouter ... stored"
135
137
  ```
136
138
 
137
- Se preferir a env var (ou não tiver o OpenRouter conectado):
139
+ If you prefer the env var (or do not have OpenRouter connected):
138
140
 
139
141
  ```powershell
140
- setx OPENROUTER_API_KEY "sk-or-..." # Windows; reabra o terminal depois
142
+ setx OPENROUTER_API_KEY "sk-or-..." # Windows; reopen the terminal afterwards
141
143
  ```
142
144
 
143
145
  ```bash
144
146
  export OPENROUTER_API_KEY="sk-or-..." # Linux/macOS (~/.bashrc / ~/.zshrc)
145
147
  ```
146
148
 
147
- Gere uma chave em <https://openrouter.ai/settings/keys>. Nos dois casos a chave **nunca** é
148
- logada, copiada nem gravada pelo plugin — e a leitura do store é somente-leitura, do arquivo
149
- que o próprio OpenCode mantém (`<data>/opencode/auth.json`).
149
+ Create a key at <https://openrouter.ai/settings/keys>. Either way the key is **never** logged,
150
+ copied or stored by the plugin — and reading the store is read-only, from the file OpenCode itself
151
+ maintains (`<data>/opencode/auth.json`).
150
152
 
151
- Para uma postura "só env var", use `"apiKeyFromAuth": false` nas options; para ler de outra
152
- integração (ex.: a do Console), ajuste `authIntegration`.
153
+ For an env-var-only policy, use `"apiKeyFromAuth": false`; to read from another integration (for
154
+ example the Console one), change `authIntegration`.
153
155
 
154
156
  ---
155
157
 
156
- ## Configuração
158
+ ## Configuration
157
159
 
158
- ### Onde declarar as options
160
+ ### Where to declare options
159
161
 
160
- As options são lidas do **`cli.json`** (config global), **não** do `opencode.jsonc`. Isso vale
161
- mesmo que você tenha instalado por cópia em `plugins/voice/`: adicione a entrada abaixo ao array
162
- `plugins` do `~/.config/opencode/cli.json`, preservando o resto do arquivo.
162
+ Options are read from **`cli.json`** (global config), **not** from `opencode.jsonc`. This holds even
163
+ if you installed by copying into `plugins/voice/`: add the entry below to the `plugins` array in
164
+ `~/.config/opencode/cli.json`, keeping the rest of the file intact.
163
165
 
164
166
  ```json
165
167
  {
166
168
  "plugins": [
167
169
  {
168
- "package": "C:\\Users\\<você>\\.config\\opencode\\plugins\\voice",
170
+ "package": "C:\\Users\\<you>\\.config\\opencode\\plugins\\voice",
169
171
  "options": {
170
172
  "autoSubmit": true
171
173
  }
@@ -174,214 +176,224 @@ mesmo que você tenha instalado por cópia em `plugins/voice/`: adicione a entra
174
176
  }
175
177
  ```
176
178
 
177
- > Verificado nas duas direções: com essa entrada, `context.options` recebe o objeto; declarando as
178
- > mesmas options no array `plugins` do **`opencode.jsonc`**, o componente de TUI recebe `{}`.
179
- > Não há duplicação do plugin por estar também descoberto na pasta global.
179
+ > Verified in both directions: with this entry `context.options` receives the object; declaring the
180
+ > same options in the `plugins` array of **`opencode.jsonc`** makes the TUI component receive `{}`.
181
+ > There is no duplication from the plugin also being discovered in the global folder.
180
182
 
181
- Todas as options são opcionais.
183
+ Every option is optional.
182
184
 
183
- | Option | Default | Descrição |
185
+ | Option | Default | Description |
184
186
  |---|---|---|
185
- | `apiKeyEnv` | `"OPENROUTER_API_KEY"` | Nome da variável de ambiente com a chave. Tem prioridade sobre o store. |
186
- | `apiKeyFromAuth` | `true` | Se a env var não existir, usa a chave já guardada pelo OpenCode. |
187
- | `authIntegration` | `"openrouter"` | Integração do OpenCode de onde ler a chave. |
188
- | `model` | `"openai/whisper-large-v3"` | Modelo de STT do OpenRouter. |
189
- | `language` | `"pt"` | ISO-639-1. Use `"auto"` para detecção automática. |
190
- | `baseUrl` | `"https://openrouter.ai/api/v1"` | Base do provedor compatível com `/audio/transcriptions`. |
191
- | `device` | `null` | Microfone. `null` = autodetectar. Formatos por SO abaixo. |
192
- | `ffmpegPath` | `null` | Caminho do ffmpeg. `null` = procurar no PATH e em locais conhecidos. |
193
- | `keybind` | `"ctrl+y"` | Atalho do ditado. Use `"<leader>v"` se preferir (`<leader>` = `ctrl+x`). |
194
- | `autoSubmit` | `false` | `true` envia direto, sem o dialog de revisão. |
195
- | `allowFromHome` | `true` | Permite ditar na tela inicial, criando a sessão na hora. |
196
- | `maxSeconds` | `55` | Corte automático da gravação. Teto de segurança em `120`. |
197
- | `keepAudio` | `false` | `true` mantém o WAV temporário (debug). |
198
- | `icons` | `{ idle: "○", recording: "●", transcribing: "…" }` | Glifos do ícone. Sobrescreva se sua fonte suportar emoji. |
199
-
200
- > **Por que os glifos padrão são `○ ● …` e não 🎤:** emojis fora do BMP (como `🎤`) não
201
- > existem na fonte de muitos consoles e aparecem como `?`; além disso a largura deles é
202
- > ambígua, o que **desalinha a área clicável** (o clique roteia por hit-test de coordenadas).
203
- > Os glifos padrão são do BMP, largura 1 e existem em Consolas/Cascadia/DejaVu. Se o seu
204
- > terminal renderiza emoji corretamente, use por exemplo:
187
+ | `apiKeyEnv` | `"OPENROUTER_API_KEY"` | Name of the environment variable holding the key. Takes priority over the store. |
188
+ | `apiKeyFromAuth` | `true` | When the env var is absent, use the key already stored by OpenCode. |
189
+ | `authIntegration` | `"openrouter"` | OpenCode integration to read the key from. |
190
+ | `model` | `"openai/whisper-large-v3"` | OpenRouter STT model. |
191
+ | `language` | `"auto"` | ISO-639-1 code, or `"auto"` to auto-detect. |
192
+ | `baseUrl` | `"https://openrouter.ai/api/v1"` | Base URL of a provider compatible with `/audio/transcriptions`. |
193
+ | `device` | `null` | Microphone. `null` = auto-detect. Per-OS formats below. |
194
+ | `ffmpegPath` | `null` | Path to ffmpeg. `null` = look in PATH and known locations. |
195
+ | `keybind` | `"ctrl+y"` | Dictation shortcut. Use `"<leader>v"` if you prefer (`<leader>` = `ctrl+x`). |
196
+ | `autoSubmit` | `false` | `true` sends right away, skipping the review dialog. |
197
+ | `allowFromHome` | `true` | Allow dictating on the home screen, creating the session on the spot. |
198
+ | `maxSeconds` | `55` | Automatic recording cutoff. Safety ceiling is `120`. |
199
+ | `keepAudio` | `false` | `true` keeps the temporary WAV (debugging). |
200
+ | `icons` | `{ idle: "○", recording: "●", transcribing: "…" }` | Icon glyphs. Override if your font supports emoji. |
201
+
202
+ > **Why the default glyphs are `○ ● …` and not 🎤:** emoji outside the BMP (such as `🎤`) are missing
203
+ > from many console fonts and render as `?`; worse, their width is ambiguous, which **misaligns the
204
+ > clickable area** (clicks are routed by coordinate hit-testing). The default glyphs are BMP,
205
+ > single-width, and present in Consolas/Cascadia/DejaVu. If your terminal renders emoji correctly,
206
+ > use for example:
205
207
  >
206
208
  > ```json
207
209
  > { "options": { "icons": { "idle": "🎤", "recording": "🔴", "transcribing": "⏳" } } }
208
210
  > ```
209
211
 
210
- Formatos de `device`:
212
+ `device` formats:
211
213
 
212
- | SO | Formato | Exemplo |
214
+ | OS | Format | Example |
213
215
  |---|---|---|
214
- | Windows | nome exato do DirectShow | `"Microphone (C922 Pro Stream Webcam)"` |
215
- | Linux | nome do source Pulse/PipeWire | `"default"`, `"@DEFAULT_SOURCE@"`, `"alsa_input.pci-0000_00_1f.3.analog-stereo"` |
216
+ | Windows | exact DirectShow name | `"Microphone (C922 Pro Stream Webcam)"` |
217
+ | Linux | Pulse/PipeWire source name | `"default"`, `"@DEFAULT_SOURCE@"`, `"alsa_input.pci-0000_00_1f.3.analog-stereo"` |
216
218
  | Linux (ALSA) | `hw:CARD,DEV` | `"hw:1,0"` |
217
- | macOS | índice numérico | `"0"` |
219
+ | macOS | numeric index | `"0"` |
218
220
 
219
221
  ---
220
222
 
221
- ## Uso
223
+ ## Usage
222
224
 
223
- | Ação | Como |
225
+ | Action | How |
224
226
  |---|---|
225
- | Iniciar / parar | clique no ícone, ou `ctrl+y`, ou `/voice` |
226
- | Escolher microfone | command palette → **"Voz: escolher microfone"** (a escolha fica salva) |
227
- | Enviar sem revisar | `"autoSubmit": true` nas options |
227
+ | Start / stop | click the icon, or `ctrl+y`, or `/voice` |
228
+ | Choose microphone | command palette → **"Voice: choose microphone"** (the choice is remembered) |
229
+ | Send without reviewing | `"autoSubmit": true` in the options |
228
230
 
229
- Estados do ícone: `○` ocioso · `● Ns` gravando · `…` transcrevendo. Erros sempre viram um toast,
230
- nunca travam a TUI.
231
+ Icon states: `○` idle · `● Ns` recording · `…` transcribing. Errors always become a toast and never
232
+ freeze the TUI.
231
233
 
232
234
  ---
233
235
 
234
- ## Custo
236
+ ## Cost
235
237
 
236
- O `openai/whisper-large-v3` é cobrado **por token** (o áudio é tokenizado), e o preço varia por
237
- provedor. Valores consultados na API do OpenRouter em out/2026 (por 1M de tokens de prompt):
238
+ `openai/whisper-large-v3` is billed **per token** (the audio is tokenized), and the price varies by
239
+ provider. Values read from the OpenRouter API in Oct/2026 (per 1M prompt tokens):
238
240
 
239
- | Provedor | Preço (prompt) |
241
+ | Provider | Price (prompt) |
240
242
  |---|---|
241
- | DeepInfra | US$ 7,50 / 1M tokens |
242
- | Together | US$ 25,00 / 1M tokens |
243
- | Groq | US$ 30,83 / 1M tokens |
243
+ | DeepInfra | US$ 7.50 / 1M tokens |
244
+ | Together | US$ 25.00 / 1M tokens |
245
+ | Groq | US$ 30.83 / 1M tokens |
244
246
 
245
- O OpenRouter roteia automaticamente para um provedor disponível — o custo real de cada requisição
246
- vem no campo `usage.cost` da resposta. Como ordem de grandeza, o exemplo da própria documentação do
247
- OpenRouter mostra uma transcrição de 9,2 s custando **US$ 0,000508**.
247
+ OpenRouter routes to an available provider automatically — the real cost of each request comes back
248
+ in the `usage.cost` field of the response. As a rough order of magnitude, OpenRouter's own
249
+ documentation shows a 9.2 s transcription costing **US$ 0.000508**.
248
250
 
249
- **Confira os valores atuais** na página do modelo: <https://openrouter.ai/openai/whisper-large-v3>
250
- (e o painel de gastos em <https://openrouter.ai/activity>). Não confie nos números acima a longo prazo.
251
+ **Check the current numbers** on the model page: <https://openrouter.ai/openai/whisper-large-v3>
252
+ (and the spend dashboard at <https://openrouter.ai/activity>). Do not trust the figures above long
253
+ term.
251
254
 
252
- Para trocar de modelo, basta mudar `model` nas options (veja os modelos com
253
- `output_modalities=transcription` em <https://openrouter.ai/models?output_modalities=transcription>).
255
+ To switch models, just change `model` in the options (browse models with
256
+ `output_modalities=transcription` at <https://openrouter.ai/models?output_modalities=transcription>).
254
257
 
255
258
  ---
256
259
 
257
260
  ## Troubleshooting
258
261
 
259
- **O plugin não aparece / não carregou**
260
- Verifique o log (existe mesmo no Windows):
262
+ **The plugin does not show up / did not load**
263
+
264
+ Check the log (it exists on Windows too):
261
265
 
262
266
  ```
263
- C:\Users\<você>\.local\share\opencode\log\opencode.log # Windows
264
- ~/.local/share/opencode/log/opencode.log # Linux/macOS
267
+ C:\Users\<you>\.local\share\opencode\log\opencode.log # Windows
268
+ ~/.local/share/opencode/log/opencode.log # Linux/macOS
265
269
  ```
266
270
 
267
- Procure por `role=cli`. Confirme que os arquivos estão em `<config-global>/plugins/voice/` e
268
- reinicie o `opencode`.
271
+ Look for `role=cli`. Confirm the files are in `<global-config>/plugins/voice/` and restart
272
+ `opencode`.
273
+
274
+ **"ffmpeg not found" toast**
275
+
276
+ Install ffmpeg (commands above) or point `ffmpegPath` at it explicitly. On Windows the plugin
277
+ automatically descends from the Chocolatey *shim*
278
+ (`C:\ProgramData\chocolatey\bin\ffmpeg.exe`) to the real binary at
279
+ `C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin\ffmpeg.exe`, because the shim may not forward
280
+ the stop command to the recording process.
269
281
 
270
- **"ffmpeg não encontrado" (toast)**
271
- Instale o ffmpeg (comandos acima) ou aponte `ffmpegPath` explicitamente. No Windows, o plugin já
272
- desce automaticamente do *shim* do Chocolatey
273
- (`C:\ProgramData\chocolatey\bin\ffmpeg.exe`) para o binário real em
274
- `C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin\ffmpeg.exe`, porque o shim pode não repassar
275
- o comando de parada ao processo de gravação.
282
+ **"Missing API key" toast**
276
283
 
277
- **"Chave da API ausente" (toast)**
278
- A variável não está visível para o processo. No Windows, `setx` só vale para **novos** terminais —
279
- feche e reabra. Confira com `echo $env:OPENROUTER_API_KEY` (PowerShell) ou `echo $OPENROUTER_API_KEY`.
284
+ The variable is not visible to the process. On Windows `setx` only applies to **new** terminals —
285
+ close and reopen. Check with `echo $env:OPENROUTER_API_KEY` (PowerShell) or `echo $OPENROUTER_API_KEY`.
280
286
 
281
- **Grava silêncio / nada é transcrito**
282
- Provavelmente o dispositivo padrão é um cabo virtual. Rode **"Voz: escolher microfone"** e selecione
283
- o microfone de verdade. A heurística já evita nomes como `CABLE Output`, `Stereo Mix`, `loopback`,
284
- `Virtual` e `.monitor`, mas nem todo dispositivo virtual se denuncia pelo nome.
287
+ **Records silence / nothing is transcribed**
285
288
 
286
- **Linux: sem microfone / erro do PulseAudio ou ALSA**
287
- Instale `pulseaudio-utils` (fornece o `pactl`, usado para listar os microfones). O plugin usa
288
- `-f pulse` quando encontra o `pactl`, senão cai para `-f alsa` com `default`. Para listar os sources
289
- manualmente:
289
+ The default device is probably a virtual cable. Run **"Voice: choose microphone"** and pick the real
290
+ microphone. The heuristic already avoids names like `CABLE Output`, `Stereo Mix`, `loopback`,
291
+ `Virtual` and `.monitor`, but not every virtual device gives itself away by name.
292
+
293
+ **Linux: no microphone / PulseAudio or ALSA error**
294
+
295
+ Install `pulseaudio-utils` (it provides `pactl`, used to list microphones). The plugin uses
296
+ `-f pulse` when it finds `pactl`, otherwise it falls back to `-f alsa` with `default`. To list
297
+ sources manually:
290
298
 
291
299
  ```bash
292
- pactl list short sources # ignore linhas terminando em .monitor
293
- arecord -l # alternativa via ALSA
300
+ pactl list short sources # ignore lines ending in .monitor
301
+ arecord -l # ALSA alternative
294
302
  ```
295
303
 
296
304
  **WSL2**
297
- O áudio chega pelo WSLg como um source do Pulse (normalmente `RDPSource`). Se `pactl info` falhar,
298
- rode `wsl --shutdown` no Windows, reabra e verifique a permissão de microfone em
299
- *Configurações → Privacidade → Microfone*. Em último caso, use `device` explícito.
300
-
301
- **O ícone aparece como `?`** (ou outro caractere estranho)
302
- Sua fonte de console não tem o glifo configurado. O padrão (`○ ● …`) funciona na maioria.
303
- Se você trocou por emoji via `icons`, volte para o padrão ou use uma fonte com emoji.
304
-
305
- **Não consigo clicar no ícone**
306
- 1. Confirme que o terminal envia eventos de mouse: no **mintty/Git Bash**, veja
307
- *Options → Mouse* (deve estar habilitado). O OpenTUI liga o mouse tracking ao iniciar.
308
- 2. Prefira os glifos padrão (`○ ● …`). Emoji têm largura ambígua e deslocam a área clicável.
309
- 3. Clique **em cima** do glifo (há um espaço de folga de cada lado).
310
- 4. Enquanto isso, o atalho faz exatamente o mesmo: `ctrl+y` (ou `<leader>v`: `ctrl+x`, depois `v`), ou
311
- `ctrl+p` → **"Voz: ditar"**, ou `/voice`.
312
-
313
- **A transcrição falha ou demora**
314
- O provedor estoura em cerca de **60 s por requisição**. Por isso `maxSeconds` é 55 por padrão. Se você
315
- aumentar, o dialog avisa que gravações acima de 1 minuto podem falhar.
316
-
317
- **O começo da fala se perde (Windows)**
318
- O DirectShow leva cerca de 1 s para abrir o dispositivo. Espere o `●` aparecer e só então comece a
319
- falar.
305
+
306
+ Audio arrives through WSLg as a Pulse source (usually `RDPSource`). If `pactl info` fails, run
307
+ `wsl --shutdown` on Windows, reopen, and check the microphone permission under
308
+ *Settings → Privacy → Microphone*. As a last resort, set `device` explicitly.
309
+
310
+ **The icon shows as `?`** (or another odd character)
311
+
312
+ Your console font lacks the configured glyph. The default (`○ ● …`) works almost everywhere. If you
313
+ switched to emoji through `icons`, go back to the default or use a font with emoji support.
314
+
315
+ **I cannot click the icon**
316
+
317
+ 1. Make sure the terminal sends mouse events: on **mintty/Git Bash**, check *Options → Mouse*
318
+ (it should be enabled). OpenTUI turns on mouse tracking at startup.
319
+ 2. Prefer the default glyphs (`○ ● …`). Emoji have ambiguous width and shift the clickable area.
320
+ 3. Click **right on** the glyph (there is one space of padding on each side).
321
+ 4. Meanwhile the shortcut does exactly the same: `ctrl+y` (or `<leader>v`: `ctrl+x`, then `v`), or
322
+ `ctrl+p` → **"Voice: dictate"**, or `/voice`.
323
+
324
+ **Transcription fails or is slow**
325
+
326
+ The provider times out at around **60 s per request**. That is why `maxSeconds` defaults to 55. If
327
+ you raise it, the dialog warns that recordings over 1 minute may fail.
328
+
329
+ **The beginning of speech is lost (Windows)**
330
+
331
+ DirectShow takes about 1 s to open the device. Wait for `●` to appear, then start speaking.
320
332
 
321
333
  ---
322
334
 
323
- ## Desenvolvimento
335
+ ## Development
324
336
 
325
337
  ```bash
326
338
  npm install
327
339
  npm run typecheck
328
340
  ```
329
341
 
330
- Sem dependências de runtime — apenas módulos nativos do Node (`child_process`, `fs`, `os`, `path`,
331
- `buffer`), que funcionam sob o Bun embutido no OpenCode.
342
+ No runtime dependencies — only Node built-ins (`child_process`, `fs`, `os`, `path`, `buffer`), which
343
+ work under the Bun runtime embedded in OpenCode.
332
344
 
333
- Toda a lógica por plataforma está isolada em `src/capture.ts`. Os demais arquivos não contêm
345
+ All platform-specific logic is isolated in `src/capture.ts`. The other files contain no
334
346
  `if (platform)`.
335
347
 
336
- > Observação: `@opencode/plugin` declara `engines: node >= 26.4.0`. No Node 24 o `npm install` emite
337
- > um aviso `EBADENGINE`, inofensivo para o typecheck.
348
+ > Note: `@opencode/plugin` declares `engines: node >= 26.4.0`. On Node 24 `npm install` prints an
349
+ > `EBADENGINE` warning, harmless for typechecking.
338
350
 
339
- ### Estrutura
351
+ ### Layout
340
352
 
341
353
  ```
342
- src/index.ts entrypoint de servidor (vazio, exigido pelo layout de descoberta)
343
- src/tui.tsx plugin: slot do ícone, atalhos, máquina de estados, envio
344
- src/capture.ts ÚNICO módulo com lógica por plataforma
345
- src/ffmpeg.ts resolução do binário, execução, parada limpa
354
+ src/index.ts server entrypoint (empty; required by the discovery layout)
355
+ src/tui.tsx the plugin: icon slot, shortcuts, state machine, delivery
356
+ src/capture.ts the ONLY module with platform-specific logic
357
+ src/ffmpeg.ts binary resolution, process execution, clean stop
346
358
  src/transcribe.ts OpenRouter /audio/transcriptions
347
359
  src/settings.ts options + env + defaults
348
360
  ```
349
361
 
350
- As decisões de design e a pesquisa de API (o que foi verificado e o que **não** é possível)
351
- estão em [DESIGN.md](DESIGN.md).
362
+ Design decisions and the API research (what was verified and what is **not** possible) are in
363
+ [DESIGN.md](DESIGN.md) (currently in Portuguese).
352
364
 
353
365
  ---
354
366
 
355
- ## Limitações conhecidas
356
-
357
- - **Ditado avulso:** uma gravação = uma mensagem. Não há ditado contínuo/streaming nem leitura das
358
- respostas em voz alta (TTS).
359
- - **Sem escrita direta no input do prompt:** a V2 não expõe — a nenhum plugin — um meio de inserir
360
- texto no campo do prompt. Isso foi verificado em três frentes: (1) a API `UI` não tem nada de
361
- prompt/composer (nem na 2.0.29 nem na 2.0.32); (2) o OpenAPI do servidor não tem rota `/tui/*`;
362
- (3) o evento interno `tui.prompt.append` é consumido pela TUI (`insertText` no composer), mas
363
- **nada o publica** — não há endpoint HTTP nem superfície RPC. A V1 tinha
364
- `client.tui.appendPrompt`, removido na V2. Por isso o texto passa pelo dialog de revisão
365
- (já preenchido e editável), e o pedido de reabrir isso está em
367
+ ## Known limitations
368
+
369
+ - **One-shot dictation:** one recording = one message. There is no continuous/streaming dictation and
370
+ no text-to-speech.
371
+ - **No direct writing into the prompt input:** V2 exposes no way — to any plugin — to insert text into
372
+ the composer. This was verified three ways: (1) the `UI` API has nothing for prompt/composer
373
+ (checked in 2.0.29 and 2.0.32); (2) the server OpenAPI has no `/tui/*` route; (3) the internal
374
+ `tui.prompt.append` event is consumed by the TUI (`insertText` on the composer) but **nothing
375
+ publishes it** — there is no HTTP endpoint and no RPC surface. V1 had `client.tui.appendPrompt`,
376
+ removed in V2. That is why the text goes through the review dialog (pre-filled and editable), and
377
+ the request to bring it back is at
366
378
  [anomalyco/opencode#51209](https://github.com/anomalyco/opencode/issues/51209)
367
- (veja também [#54681](https://github.com/anomalyco/opencode/issues/54681)). Quando a API existir,
368
- trocar o dialog por "inserir no prompt" é uma mudança pequena e localizada.
369
- - **Transcrição na nuvem:** o áudio é enviado ao OpenRouter e ao provedor escolhido. Não use com
370
- informação sensível se isso for um problema.
371
- - **Linux e macOS** foram validados pelos comandos e pela lógica, mas o teste ponta a ponta foi feito
372
- apenas no Windows. Teste real em Linux: pendente.
379
+ (see also [#54681](https://github.com/anomalyco/opencode/issues/54681)). Once the API exists,
380
+ swapping the dialog for "insert into the prompt" is a small, local change.
381
+ - **Cloud transcription:** the audio is sent to OpenRouter and to the selected provider. Do not use it
382
+ with sensitive information if that is a concern.
383
+ - **Linux and macOS** were validated through the commands and the logic, but the end-to-end test was
384
+ done on Windows only. A real Linux test is still pending.
373
385
 
374
386
  ---
375
387
 
376
- ## Segurança
388
+ ## Security
377
389
 
378
- - A chave vem da variável de ambiente ou, se ela não existir, do store do próprio OpenCode
379
- (leitura somente-leitura). Nunca é logada, copiada nem persistida pelo plugin.
380
- - O WAV é gravado em `os.tmpdir()` e apagado depois de transcrever (salvo `keepAudio: true`).
381
- - Uma gravação em andamento é encerrada quando o plugin é descarregado.
390
+ - The key comes from the environment variable or, if absent, from OpenCode's own store (read-only).
391
+ It is never logged, copied or persisted by the plugin.
392
+ - The WAV is written to `os.tmpdir()` and deleted after transcription (unless `keepAudio: true`).
393
+ - A recording in progress is stopped when the plugin is unloaded.
382
394
 
383
395
  ---
384
396
 
385
- ## Licença
397
+ ## License
386
398
 
387
- MIT — veja [LICENSE](LICENSE).
399
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,389 @@
1
+ # opencode-voice-plugin
2
+
3
+ [English](README.md) · **Português (BR)**
4
+
5
+ Ditado por voz para o **OpenCode V2**: um ícone de microfone (`○`) no rodapé do prompt. Clique nele
6
+ (ou use `ctrl+y`), fale, e o texto transcrito vira uma mensagem da sessão.
7
+
8
+ A transcrição usa o **OpenRouter** (`POST /api/v1/audio/transcriptions`) e a chave é reaproveitada do
9
+ próprio OpenCode quando você já tem o OpenRouter conectado — sem variável de ambiente e sem duplicar
10
+ segredo. Funciona em **Windows, Linux e macOS** a partir do mesmo código, e é instalado **na máquina**
11
+ (não por projeto).
12
+
13
+ [Repositório](https://github.com/kabrau/opencode-voice-plugin) ·
14
+ [Issues](https://github.com/kabrau/opencode-voice-plugin/issues) ·
15
+ [Design](DESIGN.md) · MIT
16
+
17
+ ---
18
+
19
+ ## Como funciona
20
+
21
+ 1. `○` no rodapé do prompt (aparece dentro de uma sessão).
22
+ 2. Clique, ou `ctrl+y`, ou digite `/voice` → começa a gravar (`●` + segundos).
23
+ 3. Clique de novo (ou espere `maxSeconds`) → para, transcreve (`…`).
24
+ 4. Abre o dialog **Ditado** com o texto — edite se quiser e confirme → a mensagem é enviada na sessão.
25
+
26
+ Na **tela inicial** também funciona: como ali ainda não existe sessão, o `ctrl+y` **cria a sessão na
27
+ hora** (antes de gravar) e o texto é enviado nela — o mesmo que digitar e dar Enter. Desligue com
28
+ `"allowFromHome": false` se preferir que o microfone só aja dentro de uma sessão.
29
+
30
+ ---
31
+
32
+ ## Requisitos
33
+
34
+ - OpenCode **V2** (`opencode --version`).
35
+ - **ffmpeg** no sistema:
36
+ - Windows: `choco install ffmpeg`
37
+ - Debian/Ubuntu: `sudo apt install ffmpeg pulseaudio-utils`
38
+ - macOS: `brew install ffmpeg`
39
+ - Uma chave de API do **OpenRouter** (veja [Custo](#custo) e [Chave da API](#chave-da-api)).
40
+
41
+ ---
42
+
43
+ ## Instalação (via npm — recomendado)
44
+
45
+ ```bash
46
+ opencode plugin add opencode-voice-plugin
47
+ ```
48
+
49
+ Pronto — sem clonar nem copiar arquivo. Atualizar e remover:
50
+
51
+ ```bash
52
+ opencode plugin update opencode-voice-plugin
53
+ opencode plugin remove opencode-voice-plugin
54
+ ```
55
+
56
+ > Para **configurar options** (ícones, atalho, dispositivo, etc.), veja
57
+ > [Onde declarar as options](#onde-declarar-as-options) — é um detalhe que o `plugin add`
58
+ > sozinho não cobre.
59
+
60
+ ### Sem npm (direto do GitHub)
61
+
62
+ ```bash
63
+ opencode plugin add github:kabrau/opencode-voice-plugin
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Instalação manual (na máquina)
69
+
70
+ Se preferir cópia local, o OpenCode descobre plugins no diretório global de configuração:
71
+
72
+ ```
73
+ <config-global>/plugins/<nome>/index.ts # entrypoint de servidor
74
+ <config-global>/plugins/<nome>/tui.tsx # entrypoint da TUI (o plugin)
75
+ ```
76
+
77
+ No Windows o config global fica em `C:\Users\<você>\.config\opencode`. No Linux/macOS, em
78
+ `~/.config/opencode` (ou `$XDG_CONFIG_HOME/opencode`).
79
+
80
+ ### Windows (PowerShell)
81
+
82
+ ```powershell
83
+ git clone https://github.com/kabrau/opencode-voice-plugin.git
84
+ $destino = "$env:USERPROFILE\.config\opencode\plugins\voice"
85
+ New-Item -ItemType Directory -Force -Path $destino | Out-Null
86
+ Copy-Item .\opencode-voice-plugin\src\* $destino
87
+ ```
88
+
89
+ ### Linux / macOS
90
+
91
+ ```bash
92
+ git clone https://github.com/kabrau/opencode-voice-plugin.git
93
+ destino="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/voice"
94
+ mkdir -p "$destino"
95
+ cp opencode-voice-plugin/src/* "$destino/"
96
+ ```
97
+
98
+ Depois, **reinicie o `opencode`**.
99
+
100
+ ### Instalação alternativa (apontando para o clone)
101
+
102
+ Aponte o `cli.json` global para o repositório clonado. Isso mantém o plugin atualizado via `git pull`
103
+ e é o jeito de passar options, já que a pasta de descoberta não tem onde declará-las:
104
+
105
+ ```json
106
+ {
107
+ "plugins": [
108
+ {
109
+ "package": "file:///caminho/para/opencode-voice-plugin",
110
+ "options": {
111
+ "language": "pt",
112
+ "keybind": "ctrl+y"
113
+ }
114
+ }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ > O caminho pode ser um caminho absoluto ou uma URL `file://`. **Adicione** ao array `plugins`
120
+ > existente em `~/.config/opencode/cli.json`, sem apagar o resto do arquivo.
121
+
122
+ ---
123
+
124
+ ## Chave da API
125
+
126
+ O plugin resolve a chave nesta ordem:
127
+
128
+ 1. **Variável de ambiente** — `OPENROUTER_API_KEY` (ou o nome em `apiKeyEnv`), se existir.
129
+ 2. **Store do próprio OpenCode** — se você já conectou o OpenRouter, o plugin usa essa chave
130
+ automaticamente. **Nada a configurar.**
131
+
132
+ Ou seja: se o OpenRouter já aparece conectado, você **não precisa** criar variável nenhuma.
133
+ Confira com:
134
+
135
+ ```bash
136
+ opencode auth list # deve listar "OpenRouter ... stored"
137
+ ```
138
+
139
+ Se preferir a env var (ou não tiver o OpenRouter conectado):
140
+
141
+ ```powershell
142
+ setx OPENROUTER_API_KEY "sk-or-..." # Windows; reabra o terminal depois
143
+ ```
144
+
145
+ ```bash
146
+ export OPENROUTER_API_KEY="sk-or-..." # Linux/macOS (~/.bashrc / ~/.zshrc)
147
+ ```
148
+
149
+ Gere uma chave em <https://openrouter.ai/settings/keys>. Nos dois casos a chave **nunca** é
150
+ logada, copiada nem gravada pelo plugin — e a leitura do store é somente-leitura, do arquivo
151
+ que o próprio OpenCode mantém (`<data>/opencode/auth.json`).
152
+
153
+ Para uma postura "só env var", use `"apiKeyFromAuth": false` nas options; para ler de outra
154
+ integração (ex.: a do Console), ajuste `authIntegration`.
155
+
156
+ ---
157
+
158
+ ## Configuração
159
+
160
+ ### Onde declarar as options
161
+
162
+ As options são lidas do **`cli.json`** (config global), **não** do `opencode.jsonc`. Isso vale
163
+ mesmo que você tenha instalado por cópia em `plugins/voice/`: adicione a entrada abaixo ao array
164
+ `plugins` do `~/.config/opencode/cli.json`, preservando o resto do arquivo.
165
+
166
+ ```json
167
+ {
168
+ "plugins": [
169
+ {
170
+ "package": "C:\\Users\\<você>\\.config\\opencode\\plugins\\voice",
171
+ "options": {
172
+ "autoSubmit": true
173
+ }
174
+ }
175
+ ]
176
+ }
177
+ ```
178
+
179
+ > Verificado nas duas direções: com essa entrada, `context.options` recebe o objeto; declarando as
180
+ > mesmas options no array `plugins` do **`opencode.jsonc`**, o componente de TUI recebe `{}`.
181
+ > Não há duplicação do plugin por estar também descoberto na pasta global.
182
+
183
+ Todas as options são opcionais.
184
+
185
+ | Option | Default | Descrição |
186
+ |---|---|---|
187
+ | `apiKeyEnv` | `"OPENROUTER_API_KEY"` | Nome da variável de ambiente com a chave. Tem prioridade sobre o store. |
188
+ | `apiKeyFromAuth` | `true` | Se a env var não existir, usa a chave já guardada pelo OpenCode. |
189
+ | `authIntegration` | `"openrouter"` | Integração do OpenCode de onde ler a chave. |
190
+ | `model` | `"openai/whisper-large-v3"` | Modelo de STT do OpenRouter. |
191
+ | `language` | `"auto"` | Código ISO-639-1, ou `"auto"` para detecção automática. |
192
+ | `baseUrl` | `"https://openrouter.ai/api/v1"` | Base do provedor compatível com `/audio/transcriptions`. |
193
+ | `device` | `null` | Microfone. `null` = autodetectar. Formatos por SO abaixo. |
194
+ | `ffmpegPath` | `null` | Caminho do ffmpeg. `null` = procurar no PATH e em locais conhecidos. |
195
+ | `keybind` | `"ctrl+y"` | Atalho do ditado. Use `"<leader>v"` se preferir (`<leader>` = `ctrl+x`). |
196
+ | `autoSubmit` | `false` | `true` envia direto, sem o dialog de revisão. |
197
+ | `allowFromHome` | `true` | Permite ditar na tela inicial, criando a sessão na hora. |
198
+ | `maxSeconds` | `55` | Corte automático da gravação. Teto de segurança em `120`. |
199
+ | `keepAudio` | `false` | `true` mantém o WAV temporário (debug). |
200
+ | `icons` | `{ idle: "○", recording: "●", transcribing: "…" }` | Glifos do ícone. Sobrescreva se sua fonte suportar emoji. |
201
+
202
+ > **Por que os glifos padrão são `○ ● …` e não 🎤:** emojis fora do BMP (como `🎤`) não
203
+ > existem na fonte de muitos consoles e aparecem como `?`; além disso a largura deles é
204
+ > ambígua, o que **desalinha a área clicável** (o clique roteia por hit-test de coordenadas).
205
+ > Os glifos padrão são do BMP, largura 1 e existem em Consolas/Cascadia/DejaVu. Se o seu
206
+ > terminal renderiza emoji corretamente, use por exemplo:
207
+ >
208
+ > ```json
209
+ > { "options": { "icons": { "idle": "🎤", "recording": "🔴", "transcribing": "⏳" } } }
210
+ > ```
211
+
212
+ Formatos de `device`:
213
+
214
+ | SO | Formato | Exemplo |
215
+ |---|---|---|
216
+ | Windows | nome exato do DirectShow | `"Microphone (C922 Pro Stream Webcam)"` |
217
+ | Linux | nome do source Pulse/PipeWire | `"default"`, `"@DEFAULT_SOURCE@"`, `"alsa_input.pci-0000_00_1f.3.analog-stereo"` |
218
+ | Linux (ALSA) | `hw:CARD,DEV` | `"hw:1,0"` |
219
+ | macOS | índice numérico | `"0"` |
220
+
221
+ ---
222
+
223
+ ## Uso
224
+
225
+ | Ação | Como |
226
+ |---|---|
227
+ | Iniciar / parar | clique no ícone, ou `ctrl+y`, ou `/voice` |
228
+ | Escolher microfone | command palette → **"Voice: choose microphone"** (a escolha fica salva) |
229
+ | Enviar sem revisar | `"autoSubmit": true` nas options |
230
+
231
+ Estados do ícone: `○` ocioso · `● Ns` gravando · `…` transcrevendo. Erros sempre viram um toast,
232
+ nunca travam a TUI.
233
+
234
+ ---
235
+
236
+ ## Custo
237
+
238
+ O `openai/whisper-large-v3` é cobrado **por token** (o áudio é tokenizado), e o preço varia por
239
+ provedor. Valores consultados na API do OpenRouter em out/2026 (por 1M de tokens de prompt):
240
+
241
+ | Provedor | Preço (prompt) |
242
+ |---|---|
243
+ | DeepInfra | US$ 7,50 / 1M tokens |
244
+ | Together | US$ 25,00 / 1M tokens |
245
+ | Groq | US$ 30,83 / 1M tokens |
246
+
247
+ O OpenRouter roteia automaticamente para um provedor disponível — o custo real de cada requisição
248
+ vem no campo `usage.cost` da resposta. Como ordem de grandeza, o exemplo da própria documentação do
249
+ OpenRouter mostra uma transcrição de 9,2 s custando **US$ 0,000508**.
250
+
251
+ **Confira os valores atuais** na página do modelo: <https://openrouter.ai/openai/whisper-large-v3>
252
+ (e o painel de gastos em <https://openrouter.ai/activity>). Não confie nos números acima a longo prazo.
253
+
254
+ Para trocar de modelo, basta mudar `model` nas options (veja os modelos com
255
+ `output_modalities=transcription` em <https://openrouter.ai/models?output_modalities=transcription>).
256
+
257
+ ---
258
+
259
+ ## Troubleshooting
260
+
261
+ **O plugin não aparece / não carregou**
262
+ Verifique o log (existe mesmo no Windows):
263
+
264
+ ```
265
+ C:\Users\<você>\.local\share\opencode\log\opencode.log # Windows
266
+ ~/.local/share/opencode/log/opencode.log # Linux/macOS
267
+ ```
268
+
269
+ Procure por `role=cli`. Confirme que os arquivos estão em `<config-global>/plugins/voice/` e
270
+ reinicie o `opencode`.
271
+
272
+ **"ffmpeg não encontrado" (toast)**
273
+ Instale o ffmpeg (comandos acima) ou aponte `ffmpegPath` explicitamente. No Windows, o plugin já
274
+ desce automaticamente do *shim* do Chocolatey
275
+ (`C:\ProgramData\chocolatey\bin\ffmpeg.exe`) para o binário real em
276
+ `C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin\ffmpeg.exe`, porque o shim pode não repassar
277
+ o comando de parada ao processo de gravação.
278
+
279
+ **"Chave da API ausente" (toast)**
280
+ A variável não está visível para o processo. No Windows, `setx` só vale para **novos** terminais —
281
+ feche e reabra. Confira com `echo $env:OPENROUTER_API_KEY` (PowerShell) ou `echo $OPENROUTER_API_KEY`.
282
+
283
+ **Grava silêncio / nada é transcrito**
284
+ Provavelmente o dispositivo padrão é um cabo virtual. Rode **"Voice: choose microphone"** e selecione
285
+ o microfone de verdade. A heurística já evita nomes como `CABLE Output`, `Stereo Mix`, `loopback`,
286
+ `Virtual` e `.monitor`, mas nem todo dispositivo virtual se denuncia pelo nome.
287
+
288
+ **Linux: sem microfone / erro do PulseAudio ou ALSA**
289
+ Instale `pulseaudio-utils` (fornece o `pactl`, usado para listar os microfones). O plugin usa
290
+ `-f pulse` quando encontra o `pactl`, senão cai para `-f alsa` com `default`. Para listar os sources
291
+ manualmente:
292
+
293
+ ```bash
294
+ pactl list short sources # ignore linhas terminando em .monitor
295
+ arecord -l # alternativa via ALSA
296
+ ```
297
+
298
+ **WSL2**
299
+ O áudio chega pelo WSLg como um source do Pulse (normalmente `RDPSource`). Se `pactl info` falhar,
300
+ rode `wsl --shutdown` no Windows, reabra e verifique a permissão de microfone em
301
+ *Configurações → Privacidade → Microfone*. Em último caso, use `device` explícito.
302
+
303
+ **O ícone aparece como `?`** (ou outro caractere estranho)
304
+ Sua fonte de console não tem o glifo configurado. O padrão (`○ ● …`) funciona na maioria.
305
+ Se você trocou por emoji via `icons`, volte para o padrão ou use uma fonte com emoji.
306
+
307
+ **Não consigo clicar no ícone**
308
+ 1. Confirme que o terminal envia eventos de mouse: no **mintty/Git Bash**, veja
309
+ *Options → Mouse* (deve estar habilitado). O OpenTUI liga o mouse tracking ao iniciar.
310
+ 2. Prefira os glifos padrão (`○ ● …`). Emoji têm largura ambígua e deslocam a área clicável.
311
+ 3. Clique **em cima** do glifo (há um espaço de folga de cada lado).
312
+ 4. Enquanto isso, o atalho faz exatamente o mesmo: `ctrl+y` (ou `<leader>v`: `ctrl+x`, depois `v`), ou
313
+ `ctrl+p` → **"Voice: dictate"**, ou `/voice`.
314
+
315
+ **A transcrição falha ou demora**
316
+ O provedor estoura em cerca de **60 s por requisição**. Por isso `maxSeconds` é 55 por padrão. Se você
317
+ aumentar, o dialog avisa que gravações acima de 1 minuto podem falhar.
318
+
319
+ **O começo da fala se perde (Windows)**
320
+ O DirectShow leva cerca de 1 s para abrir o dispositivo. Espere o `●` aparecer e só então comece a
321
+ falar.
322
+
323
+ ---
324
+
325
+ ## Desenvolvimento
326
+
327
+ ```bash
328
+ npm install
329
+ npm run typecheck
330
+ ```
331
+
332
+ Sem dependências de runtime — apenas módulos nativos do Node (`child_process`, `fs`, `os`, `path`,
333
+ `buffer`), que funcionam sob o Bun embutido no OpenCode.
334
+
335
+ Toda a lógica por plataforma está isolada em `src/capture.ts`. Os demais arquivos não contêm
336
+ `if (platform)`.
337
+
338
+ > Observação: `@opencode/plugin` declara `engines: node >= 26.4.0`. No Node 24 o `npm install` emite
339
+ > um aviso `EBADENGINE`, inofensivo para o typecheck.
340
+
341
+ ### Estrutura
342
+
343
+ ```
344
+ src/index.ts entrypoint de servidor (vazio, exigido pelo layout de descoberta)
345
+ src/tui.tsx plugin: slot do ícone, atalhos, máquina de estados, envio
346
+ src/capture.ts ÚNICO módulo com lógica por plataforma
347
+ src/ffmpeg.ts resolução do binário, execução, parada limpa
348
+ src/transcribe.ts OpenRouter /audio/transcriptions
349
+ src/settings.ts options + env + defaults
350
+ ```
351
+
352
+ As decisões de design e a pesquisa de API (o que foi verificado e o que **não** é possível)
353
+ estão em [DESIGN.md](DESIGN.md).
354
+
355
+ ---
356
+
357
+ ## Limitações conhecidas
358
+
359
+ - **Ditado avulso:** uma gravação = uma mensagem. Não há ditado contínuo/streaming nem leitura das
360
+ respostas em voz alta (TTS).
361
+ - **Sem escrita direta no input do prompt:** a V2 não expõe — a nenhum plugin — um meio de inserir
362
+ texto no campo do prompt. Isso foi verificado em três frentes: (1) a API `UI` não tem nada de
363
+ prompt/composer (nem na 2.0.29 nem na 2.0.32); (2) o OpenAPI do servidor não tem rota `/tui/*`;
364
+ (3) o evento interno `tui.prompt.append` é consumido pela TUI (`insertText` no composer), mas
365
+ **nada o publica** — não há endpoint HTTP nem superfície RPC. A V1 tinha
366
+ `client.tui.appendPrompt`, removido na V2. Por isso o texto passa pelo dialog de revisão
367
+ (já preenchido e editável), e o pedido de reabrir isso está em
368
+ [anomalyco/opencode#51209](https://github.com/anomalyco/opencode/issues/51209)
369
+ (veja também [#54681](https://github.com/anomalyco/opencode/issues/54681)). Quando a API existir,
370
+ trocar o dialog por "inserir no prompt" é uma mudança pequena e localizada.
371
+ - **Transcrição na nuvem:** o áudio é enviado ao OpenRouter e ao provedor escolhido. Não use com
372
+ informação sensível se isso for um problema.
373
+ - **Linux e macOS** foram validados pelos comandos e pela lógica, mas o teste ponta a ponta foi feito
374
+ apenas no Windows. Teste real em Linux: pendente.
375
+
376
+ ---
377
+
378
+ ## Segurança
379
+
380
+ - A chave vem da variável de ambiente ou, se ela não existir, do store do próprio OpenCode
381
+ (leitura somente-leitura). Nunca é logada, copiada nem persistida pelo plugin.
382
+ - O WAV é gravado em `os.tmpdir()` e apagado depois de transcrever (salvo `keepAudio: true`).
383
+ - Uma gravação em andamento é encerrada quando o plugin é descarregado.
384
+
385
+ ---
386
+
387
+ ## Licença
388
+
389
+ MIT — veja [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-voice-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Voice dictation for OpenCode V2: click a mic in the prompt footer, speak, and send the transcribed text.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "src",
19
19
  "README.md",
20
+ "README.pt-BR.md",
20
21
  "LICENSE"
21
22
  ],
22
23
  "repository": {
package/src/capture.ts CHANGED
@@ -79,12 +79,12 @@ const CONTROL: ProcessControl = captureControl()
79
79
 
80
80
  export function installHint(): string {
81
81
  if (PLATFORM === "win32") {
82
- return "Instale o ffmpeg com `choco install ffmpeg` (ou baixe em ffmpeg.org e coloque no PATH)."
82
+ return "Install ffmpeg with `choco install ffmpeg` (or download it from ffmpeg.org and add it to PATH)."
83
83
  }
84
84
  if (PLATFORM === "darwin") {
85
- return "Instale o ffmpeg com `brew install ffmpeg`."
85
+ return "Install ffmpeg with `brew install ffmpeg`."
86
86
  }
87
- return "Instale o ffmpeg com `sudo apt install ffmpeg pulseaudio-utils` (Debian/Ubuntu) ou o equivalente da sua distro."
87
+ return "Install ffmpeg with `sudo apt install ffmpeg pulseaudio-utils` (Debian/Ubuntu) or the equivalent for your distro."
88
88
  }
89
89
 
90
90
  export interface CaptureBackend {
@@ -173,7 +173,7 @@ export function pickDefaultDevice(devices: readonly string[]): string | undefine
173
173
 
174
174
  function windowsInputArgs(device?: string): string[] {
175
175
  if (!device) {
176
- throw new Error("No Windows é preciso escolher um microfone DirectShow antes de gravar.")
176
+ throw new Error("On Windows you must pick a DirectShow microphone before recording.")
177
177
  }
178
178
  return ["-f", "dshow", "-i", `audio=${device}`]
179
179
  }
package/src/ffmpeg.ts CHANGED
@@ -89,7 +89,7 @@ export function resolveFfmpeg(explicit: string | null | undefined, control: Proc
89
89
  return Promise.resolve(resolved)
90
90
  }
91
91
 
92
- return Promise.reject(new Error("ffmpeg não encontrado"))
92
+ return Promise.reject(new Error("ffmpeg not found"))
93
93
  }
94
94
 
95
95
  export interface RecordingResult {
package/src/settings.ts CHANGED
@@ -66,7 +66,7 @@ export const DEFAULTS: VoiceSettings = {
66
66
  apiKeyFromAuth: true,
67
67
  authIntegration: "openrouter",
68
68
  model: "openai/whisper-large-v3",
69
- language: "pt",
69
+ language: "auto",
70
70
  baseUrl: "https://openrouter.ai/api/v1",
71
71
  device: null,
72
72
  ffmpegPath: null,
package/src/transcribe.ts CHANGED
@@ -75,14 +75,14 @@ export async function transcribe(input: TranscribeInput): Promise<TranscribeOutp
75
75
 
76
76
  const raw = await response.text()
77
77
  if (!response.ok) {
78
- throw new TranscribeError(`O provedor respondeu ${response.status}.`, response.status, truncate(raw))
78
+ throw new TranscribeError(`The provider returned ${response.status}.`, response.status, truncate(raw))
79
79
  }
80
80
 
81
81
  let parsed: { text?: unknown; usage?: { cost?: unknown; seconds?: unknown } }
82
82
  try {
83
83
  parsed = JSON.parse(raw) as typeof parsed
84
84
  } catch {
85
- throw new TranscribeError("Resposta inválida do provedor.", response.status, truncate(raw))
85
+ throw new TranscribeError("Invalid response from the provider.", response.status, truncate(raw))
86
86
  }
87
87
 
88
88
  const text = typeof parsed.text === "string" ? parsed.text.trim() : ""
@@ -92,7 +92,7 @@ export async function transcribe(input: TranscribeInput): Promise<TranscribeOutp
92
92
  } catch (error) {
93
93
  if (error instanceof TranscribeError) throw error
94
94
  if (error instanceof Error && error.name === "AbortError") {
95
- throw new TranscribeError("Tempo esgotado ao falar com o provedor.")
95
+ throw new TranscribeError("Timed out talking to the provider.")
96
96
  }
97
97
  throw new TranscribeError(toMessage(error))
98
98
  } finally {
package/src/tui.tsx CHANGED
@@ -45,7 +45,7 @@ export default Plugin.define({
45
45
  let inFlightStop: Promise<void> | undefined
46
46
  let activeSessionID: string | undefined
47
47
 
48
- const notify = (message: string, variant: Variant = "info", title = "Voz"): void => {
48
+ const notify = (message: string, variant: Variant = "info", title = "Voice"): void => {
49
49
  context.ui.toast.show({ title, message, variant })
50
50
  }
51
51
 
@@ -83,7 +83,7 @@ export default Plugin.define({
83
83
  return ffmpegPath
84
84
  } catch {
85
85
  setReady(false)
86
- notify(`ffmpeg não encontrado. ${installHint()}`, "error")
86
+ notify(`ffmpeg not found. ${installHint()}`, "error")
87
87
  return undefined
88
88
  }
89
89
  }
@@ -113,7 +113,7 @@ export default Plugin.define({
113
113
  const info = await context.client.session.create({ location: context.location ?? null })
114
114
  sessionID = info.id
115
115
  } catch (error) {
116
- notify(failure(error), "error", "Não consegui criar a sessão")
116
+ notify(failure(error), "error", "Could not create the session")
117
117
  return undefined
118
118
  }
119
119
 
@@ -128,39 +128,39 @@ export default Plugin.define({
128
128
  async function deliver(text: string): Promise<void> {
129
129
  const sessionID = currentSessionID() ?? activeSessionID
130
130
  if (!sessionID) {
131
- notify("Abra uma sessão para ditar.", "warning")
131
+ notify("Open a session to dictate.", "warning")
132
132
  return
133
133
  }
134
134
  if (settings.autoSubmit) {
135
135
  await context.client.session.prompt({ sessionID, text })
136
- notify("Ditado enviado.", "success")
136
+ notify("Dictation sent.", "success")
137
137
  return
138
138
  }
139
139
  const description =
140
140
  settings.maxSeconds > PROVIDER_SAFE_SECONDS
141
- ? "Gravações acima de 1 minuto podem falhar no provedor. Revise e edite antes de enviar."
142
- : "Revise e edite antes de enviar."
143
- const edited = await context.ui.dialog.prompt({ title: "Ditado", description, value: text })
141
+ ? "Recordings over 1 minute may fail at the provider. Review and edit before sending."
142
+ : "Review and edit before sending."
143
+ const edited = await context.ui.dialog.prompt({ title: "Dictation", description, value: text })
144
144
  const finalText = edited?.trim()
145
145
  if (!finalText) return
146
146
  await context.client.session.prompt({ sessionID, text: finalText })
147
- notify("Ditado enviado.", "success")
147
+ notify("Dictation sent.", "success")
148
148
  }
149
149
 
150
150
  async function start(): Promise<void> {
151
151
  const { key } = resolveApiKey(settings)
152
152
  if (!key) {
153
153
  notify(
154
- `Chave do OpenRouter ausente. Conecte com "opencode auth login" ou defina a variável ${settings.apiKeyEnv}.`,
154
+ `OpenRouter API key missing. Connect with "opencode auth login" or set the ${settings.apiKeyEnv} environment variable.`,
155
155
  "error",
156
- "Chave da API ausente",
156
+ "Missing API key",
157
157
  )
158
158
  return
159
159
  }
160
160
 
161
161
  const sessionID = await ensureSession()
162
162
  if (!sessionID) {
163
- notify("Abra uma sessão para ditar.", "warning")
163
+ notify("Open a session to dictate.", "warning")
164
164
  return
165
165
  }
166
166
  activeSessionID = sessionID
@@ -186,7 +186,7 @@ export default Plugin.define({
186
186
  file,
187
187
  ]
188
188
  } catch (error) {
189
- notify(failure(error), "error", "Microfone")
189
+ notify(failure(error), "error", "Microphone")
190
190
  return
191
191
  }
192
192
 
@@ -214,13 +214,13 @@ export default Plugin.define({
214
214
  const result = await current.stop()
215
215
  if (result.code !== 0 && result.code !== null) {
216
216
  const detail = result.stderr.trim()
217
- notify(`ffmpeg terminou com código ${result.code}.${detail ? ` ${detail}` : ""}`, "error", "Falha na gravação")
217
+ notify(`ffmpeg exited with code ${result.code}.${detail ? ` ${detail}` : ""}`, "error", "Recording failed")
218
218
  return
219
219
  }
220
220
 
221
221
  const { key } = resolveApiKey(settings)
222
222
  if (!key) {
223
- notify(`Defina a variável de ambiente ${settings.apiKeyEnv} ou conecte o OpenRouter.`, "error", "Chave da API ausente")
223
+ notify(`Set the ${settings.apiKeyEnv} environment variable or connect OpenRouter.`, "error", "Missing API key")
224
224
  return
225
225
  }
226
226
 
@@ -235,7 +235,7 @@ export default Plugin.define({
235
235
  setState("idle")
236
236
 
237
237
  if (!text) {
238
- notify("Não entendi nada no áudio. Tente falar mais perto do microfone.", "warning")
238
+ notify("Nothing was recognized in the audio. Try speaking closer to the microphone.", "warning")
239
239
  return
240
240
  }
241
241
 
@@ -285,18 +285,18 @@ export default Plugin.define({
285
285
  try {
286
286
  devices = await backend.listDevices(ffmpeg)
287
287
  } catch (error) {
288
- notify(failure(error), "error", "Microfone")
288
+ notify(failure(error), "error", "Microphone")
289
289
  return
290
290
  }
291
291
 
292
292
  if (devices.length === 0) {
293
- notify("Nenhum microfone encontrado.", "warning")
293
+ notify("No microphone found.", "warning")
294
294
  return
295
295
  }
296
296
 
297
297
  const current = settings.device ?? (deviceStore.name || undefined)
298
298
  const choice = await context.ui.dialog.select({
299
- title: "Microfone",
299
+ title: "Microphone",
300
300
  current,
301
301
  options: devices.map((device) => ({ title: device, value: device })),
302
302
  })
@@ -305,7 +305,7 @@ export default Plugin.define({
305
305
  await setDeviceStore((draft) => {
306
306
  draft.name = choice
307
307
  })
308
- notify(`Microfone: ${choice}`, "success")
308
+ notify(`Microphone: ${choice}`, "success")
309
309
  }
310
310
 
311
311
  const iconColor = (): RGBA => {
@@ -349,9 +349,9 @@ export default Plugin.define({
349
349
  commands: [
350
350
  {
351
351
  id: "voice.mic.toggle",
352
- title: "Voz: ditar",
353
- description: "Grava do microfone e transcreve para enviar na sessão",
354
- group: "Voz",
352
+ title: "Voice: dictate",
353
+ description: "Record from the microphone and transcribe it into the session",
354
+ group: "Voice",
355
355
  bind: settings.keybind,
356
356
  palette: true,
357
357
  slash: { name: "voice" },
@@ -361,9 +361,9 @@ export default Plugin.define({
361
361
  },
362
362
  {
363
363
  id: "voice.mic.device",
364
- title: "Voz: escolher microfone",
365
- description: "Lista os microfones e guarda o escolhido",
366
- group: "Voz",
364
+ title: "Voice: choose microphone",
365
+ description: "List microphones and remember the chosen one",
366
+ group: "Voice",
367
367
  palette: true,
368
368
  run: () => {
369
369
  void chooseDevice()