opencode-groq-dictate 1.1.6 → 1.1.8
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 +29 -3
- package/package.json +1 -1
- package/src/voice.test.ts +27 -2
- package/src/voice.ts +25 -2
- package/tui.tsx +126 -14
package/README.md
CHANGED
|
@@ -67,16 +67,42 @@ clone out of `~/.config/opencode/plugins/` before installing from npm.
|
|
|
67
67
|
| Key | Action |
|
|
68
68
|
|-----|--------|
|
|
69
69
|
| `alt+v` | start recording, press again to stop and transcribe |
|
|
70
|
+
| `ctrl+alt+v` | same, but the prompt is submitted as soon as the text lands |
|
|
71
|
+
| click on `🎤 Groq Dictate` or on the `state` row | same as `alt+v` |
|
|
72
|
+
| `ctrl+click` on the same two | same as `ctrl+alt+v` |
|
|
70
73
|
| `esc` | cancel the take: no upload, no request, no cost |
|
|
71
74
|
| `/dictate` | same as `alt+v`, from the command palette |
|
|
75
|
+
| `⚙` next to the title | open the settings panel |
|
|
72
76
|
|
|
73
77
|
A take stops on its own after 60 seconds. Everything else is in the palette under
|
|
74
|
-
**Voice**: `
|
|
78
|
+
**Voice**: `Dictate (Groq) and submit`, `Dictation model`, `Dictation language`,
|
|
79
|
+
`Dictation quota`, `Dictation settings`.
|
|
80
|
+
|
|
81
|
+
The `⚙` panel (or `Dictation settings` in the palette) holds the four things worth
|
|
82
|
+
changing: the two shortcuts, the model and the language. Click a row, write the new
|
|
83
|
+
value, and it applies at once — the keymap layer reads the stored config, so a new
|
|
84
|
+
combination works without restarting OpenCode. A blank shortcut disables the key but
|
|
85
|
+
leaves the command in the palette. `model` and `language` open the same pickers as the
|
|
86
|
+
palette commands.
|
|
87
|
+
|
|
88
|
+
The shortcuts are ordinary commands, so you can also rebind or disable them in
|
|
89
|
+
`cli.json` without touching the plugin:
|
|
90
|
+
|
|
91
|
+
```jsonc
|
|
92
|
+
{
|
|
93
|
+
"keybinds": {
|
|
94
|
+
"opencode-groq-dictate.toggle": "alt+d",
|
|
95
|
+
"opencode-groq-dictate.toggleSubmit": false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The click handlers stay available even when both bindings are off.
|
|
75
101
|
|
|
76
102
|
Super+V cannot be used: VTE (Tilix, xfce4-terminal) only enables xterm
|
|
77
103
|
`modifyOtherKeys`, which never reports the Super modifier, so the binding could not
|
|
78
|
-
fire inside the terminal. `alt+v`
|
|
79
|
-
`modifyOtherKeys` encodings.
|
|
104
|
+
fire inside the terminal. `alt+v` and `ctrl+alt+v` are reported by both the legacy
|
|
105
|
+
and the `modifyOtherKeys` encodings.
|
|
80
106
|
|
|
81
107
|
## Screenshots
|
|
82
108
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-groq-dictate",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "Groq dictation in the OpenCode v2 TUI: alt+v records, transcribes with whisper-large-v3-turbo, and pastes the text into the prompt.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
package/src/voice.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
|
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_MODEL,
|
|
4
4
|
audioSeconds,
|
|
5
|
+
bindOf,
|
|
5
6
|
cost,
|
|
6
7
|
logLine,
|
|
7
8
|
parseConfig,
|
|
@@ -32,19 +33,43 @@ test("the rpm window drops stamps older than a minute", () => {
|
|
|
32
33
|
})
|
|
33
34
|
|
|
34
35
|
test("a config with the wrong types falls back to the defaults", () => {
|
|
35
|
-
expect(parseConfig(null)).toEqual({
|
|
36
|
+
expect(parseConfig(null)).toEqual({
|
|
37
|
+
apiKey: "",
|
|
38
|
+
language: "it",
|
|
39
|
+
model: DEFAULT_MODEL,
|
|
40
|
+
bindStart: "alt+v",
|
|
41
|
+
bindSubmit: "ctrl+alt+v",
|
|
42
|
+
})
|
|
36
43
|
expect(parseConfig({ apiKey: 42, language: "", model: "nope" })).toEqual({
|
|
37
44
|
apiKey: "",
|
|
38
45
|
language: "it",
|
|
39
46
|
model: DEFAULT_MODEL,
|
|
47
|
+
bindStart: "alt+v",
|
|
48
|
+
bindSubmit: "ctrl+alt+v",
|
|
40
49
|
})
|
|
41
|
-
expect(
|
|
50
|
+
expect(
|
|
51
|
+
parseConfig({
|
|
52
|
+
apiKey: "gsk_x",
|
|
53
|
+
language: "auto",
|
|
54
|
+
model: "whisper-large-v3",
|
|
55
|
+
bindStart: "alt+d",
|
|
56
|
+
bindSubmit: "",
|
|
57
|
+
}),
|
|
58
|
+
).toEqual({
|
|
42
59
|
apiKey: "gsk_x",
|
|
43
60
|
language: "auto",
|
|
44
61
|
model: "whisper-large-v3",
|
|
62
|
+
bindStart: "alt+d",
|
|
63
|
+
// An empty bind is meaningful: the command stays in the palette, the key goes away.
|
|
64
|
+
bindSubmit: "",
|
|
45
65
|
})
|
|
46
66
|
})
|
|
47
67
|
|
|
68
|
+
test("an empty bind becomes false for the keymap", () => {
|
|
69
|
+
expect(bindOf("alt+v")).toBe("alt+v")
|
|
70
|
+
expect(bindOf(" ")).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
48
73
|
test("the cost line keeps the shell script's four columns", () => {
|
|
49
74
|
expect(logLine(new Date(2026, 9, 19, 8, 5, 3), 12.34, 0.000137)).toBe("2026-10-19 08:05:03 12.3 0.000137")
|
|
50
75
|
expect(logLine(new Date(2026, 9, 19, 8, 5, 3), 12.34, 0.000137, "whisper-large-v3")).toBe(
|
package/src/voice.ts
CHANGED
|
@@ -20,20 +20,43 @@ export const BYTES_PER_SAMPLE = 2
|
|
|
20
20
|
// parec writes a canonical 44-byte header; anything past it is audio.
|
|
21
21
|
export const WAV_HEADER = 44
|
|
22
22
|
|
|
23
|
-
export type Config = {
|
|
23
|
+
export type Config = {
|
|
24
|
+
apiKey: string
|
|
25
|
+
language: string
|
|
26
|
+
model: Model
|
|
27
|
+
// Empty means "no binding": the command stays in the palette, the key does nothing.
|
|
28
|
+
bindStart: string
|
|
29
|
+
bindSubmit: string
|
|
30
|
+
}
|
|
24
31
|
|
|
25
|
-
export const DEFAULTS: Config = {
|
|
32
|
+
export const DEFAULTS: Config = {
|
|
33
|
+
apiKey: "",
|
|
34
|
+
language: "it",
|
|
35
|
+
model: DEFAULT_MODEL,
|
|
36
|
+
bindStart: "alt+v",
|
|
37
|
+
bindSubmit: "ctrl+alt+v",
|
|
38
|
+
}
|
|
26
39
|
|
|
27
40
|
export function parseConfig(raw: unknown): Config {
|
|
28
41
|
const value = (raw ?? {}) as Record<string, unknown>
|
|
29
42
|
const model = MODELS.includes(value.model as Model) ? (value.model as Model) : DEFAULTS.model
|
|
43
|
+
// Binds are free text: an empty string is meaningful (disabled), a missing key is not.
|
|
44
|
+
const bind = (key: "bindStart" | "bindSubmit") =>
|
|
45
|
+
typeof value[key] === "string" ? (value[key] as string) : DEFAULTS[key]
|
|
30
46
|
return {
|
|
31
47
|
apiKey: typeof value.apiKey === "string" ? value.apiKey : DEFAULTS.apiKey,
|
|
32
48
|
language: typeof value.language === "string" && value.language ? value.language : DEFAULTS.language,
|
|
33
49
|
model,
|
|
50
|
+
bindStart: bind("bindStart"),
|
|
51
|
+
bindSubmit: bind("bindSubmit"),
|
|
34
52
|
}
|
|
35
53
|
}
|
|
36
54
|
|
|
55
|
+
// The keymap wants `false` to disable a binding, not an empty string.
|
|
56
|
+
export function bindOf(value: string): string | false {
|
|
57
|
+
return value.trim() || false
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
export function audioSeconds(bytes: number): number {
|
|
38
61
|
return Math.max(0, bytes - WAV_HEADER) / (SAMPLE_RATE * BYTES_PER_SAMPLE)
|
|
39
62
|
}
|
package/tui.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
MODELS,
|
|
12
12
|
RPM_LIMIT,
|
|
13
13
|
audioSeconds,
|
|
14
|
+
bindOf,
|
|
14
15
|
cost,
|
|
15
16
|
logLine,
|
|
16
17
|
parseConfig,
|
|
@@ -136,7 +137,11 @@ export default Plugin.define({
|
|
|
136
137
|
return usd
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
|
|
140
|
+
// A recording started with ctrl+alt+v (or ctrl+click) also submits the prompt
|
|
141
|
+
// once the text lands, so the same gesture can send without reaching for Enter.
|
|
142
|
+
let submitOnStop = false
|
|
143
|
+
|
|
144
|
+
async function transcribe(seconds: number, submit: boolean) {
|
|
140
145
|
const { apiKey, language, model } = settings()
|
|
141
146
|
const key = apiKey || (await askKey())
|
|
142
147
|
if (!key) return
|
|
@@ -152,6 +157,12 @@ export default Plugin.define({
|
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
await paste(outcome.text)
|
|
160
|
+
if (submit) {
|
|
161
|
+
// The TUI applies the paste on the next frame; submitting in the same
|
|
162
|
+
// tick would send the prompt without the dictated text.
|
|
163
|
+
await new Promise((done) => setTimeout(done, 80))
|
|
164
|
+
context.keymap.dispatch("prompt.submit")
|
|
165
|
+
}
|
|
155
166
|
// The recording is only needed until Groq answers; leaving it in /tmp keeps
|
|
156
167
|
// a copy of everything dictated on disk.
|
|
157
168
|
await rm(WAV, { force: true }).catch(() => {})
|
|
@@ -162,6 +173,8 @@ export default Plugin.define({
|
|
|
162
173
|
|
|
163
174
|
async function stop() {
|
|
164
175
|
if (!recorder) return
|
|
176
|
+
const submit = submitOnStop
|
|
177
|
+
submitOnStop = false
|
|
165
178
|
const child = recorder
|
|
166
179
|
recorder = null
|
|
167
180
|
setRecording(false)
|
|
@@ -180,7 +193,7 @@ export default Plugin.define({
|
|
|
180
193
|
}
|
|
181
194
|
context.ui.toast.show({ message: "Transcribing…", variant: "info" })
|
|
182
195
|
try {
|
|
183
|
-
await transcribe(seconds)
|
|
196
|
+
await transcribe(seconds, submit)
|
|
184
197
|
} catch (error) {
|
|
185
198
|
context.ui.toast.show({ message: String(error), variant: "error" })
|
|
186
199
|
} finally {
|
|
@@ -197,7 +210,7 @@ export default Plugin.define({
|
|
|
197
210
|
)
|
|
198
211
|
})
|
|
199
212
|
|
|
200
|
-
async function start() {
|
|
213
|
+
async function start(submit = false) {
|
|
201
214
|
const { apiKey } = settings()
|
|
202
215
|
if (!apiKey) {
|
|
203
216
|
context.ui.toast.show({ message: "Set the Groq API key first", variant: "warning" })
|
|
@@ -232,6 +245,9 @@ export default Plugin.define({
|
|
|
232
245
|
context.ui.toast.show({ message: `parec: ${error.message}`, variant: "error" })
|
|
233
246
|
})
|
|
234
247
|
setRecording(true)
|
|
248
|
+
// Only a take that actually started arms the submit, so a failed start does
|
|
249
|
+
// not make the next alt+v send the prompt.
|
|
250
|
+
submitOnStop = submit
|
|
235
251
|
setElapsed(0)
|
|
236
252
|
ticker = startTicker(() => setElapsed((value) => value + 1))
|
|
237
253
|
// Nobody wants a ten-minute upload because the stop key was forgotten.
|
|
@@ -239,13 +255,20 @@ export default Plugin.define({
|
|
|
239
255
|
context.ui.toast.show({ message: `Stopped at ${MAX_SECONDS}s`, variant: "warning" })
|
|
240
256
|
void stop()
|
|
241
257
|
}, MAX_SECONDS * 1000)
|
|
242
|
-
context.ui.toast.show({
|
|
258
|
+
context.ui.toast.show({
|
|
259
|
+
message: submit
|
|
260
|
+
? `Recording… ${settings().bindSubmit || "the palette"} stops and sends · esc cancels`
|
|
261
|
+
: `Recording… ${settings().bindStart || "the palette"} stops · esc cancels`,
|
|
262
|
+
variant: "info",
|
|
263
|
+
duration: 2000,
|
|
264
|
+
})
|
|
243
265
|
}
|
|
244
266
|
|
|
245
267
|
|
|
246
268
|
// Esc aborts the take: nothing is uploaded, so no quota and no cost.
|
|
247
269
|
async function cancel() {
|
|
248
270
|
if (!recorder) return
|
|
271
|
+
submitOnStop = false
|
|
249
272
|
const child = recorder
|
|
250
273
|
recorder = null
|
|
251
274
|
setRecording(false)
|
|
@@ -261,8 +284,11 @@ export default Plugin.define({
|
|
|
261
284
|
}
|
|
262
285
|
|
|
263
286
|
// One entry point for the keybinding and for the click on the panel.
|
|
264
|
-
|
|
265
|
-
const toggle = (
|
|
287
|
+
// submit=true means "record, transcribe, then press Enter".
|
|
288
|
+
const toggle = (submit = false) => {
|
|
289
|
+
if (recording()) return stop()
|
|
290
|
+
return start(submit)
|
|
291
|
+
}
|
|
266
292
|
|
|
267
293
|
async function pickModel() {
|
|
268
294
|
const model = await context.ui.dialog.select({
|
|
@@ -305,6 +331,57 @@ export default Plugin.define({
|
|
|
305
331
|
context.ui.toast.show({ message: `Language: ${settings().language}`, variant: "success" })
|
|
306
332
|
}
|
|
307
333
|
|
|
334
|
+
// One panel for the four things a user changes: the two shortcuts, the model
|
|
335
|
+
// and the language. Each row reopens the panel after writing, because the
|
|
336
|
+
// dialog body renders once and its labels would otherwise stay stale.
|
|
337
|
+
const settingsRow = (label: string, value: string, onClick: () => void) => (
|
|
338
|
+
<box flexDirection="row" onMouseUp={onClick}>
|
|
339
|
+
<text>{(label + " ").padEnd(18)}</text>
|
|
340
|
+
<text fg={accent()}>{value}</text>
|
|
341
|
+
</box>
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
function openSettings() {
|
|
345
|
+
const current = settings()
|
|
346
|
+
context.ui.dialog.clear()
|
|
347
|
+
context.ui.dialog.show(() => (
|
|
348
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
349
|
+
<text fg={accent()}>{"Groq dictation"}</text>
|
|
350
|
+
<text fg={muted()}>{"click a row: changes apply at once"}</text>
|
|
351
|
+
{settingsRow("start / stop", current.bindStart || "disabled", () =>
|
|
352
|
+
editBind("bindStart", "Start and stop"),
|
|
353
|
+
)}
|
|
354
|
+
{settingsRow("start and send", current.bindSubmit || "disabled", () =>
|
|
355
|
+
editBind("bindSubmit", "Start, stop and send"),
|
|
356
|
+
)}
|
|
357
|
+
{settingsRow("model", current.model.replace("whisper-", ""), () => change(pickModel))}
|
|
358
|
+
{settingsRow("language", current.language, () => change(pickLanguage))}
|
|
359
|
+
</box>
|
|
360
|
+
))
|
|
361
|
+
context.ui.dialog.set({ size: "large", centered: true })
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The panel closes before a prompt can open, then comes back with the new value.
|
|
365
|
+
async function change(action: () => Promise<void>) {
|
|
366
|
+
context.ui.dialog.clear()
|
|
367
|
+
await action()
|
|
368
|
+
openSettings()
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function editBind(key: "bindStart" | "bindSubmit", title: string) {
|
|
372
|
+
context.ui.dialog.clear()
|
|
373
|
+
const value = await context.ui.dialog.prompt({
|
|
374
|
+
title: `${title} (blank disables the key)`,
|
|
375
|
+
placeholder: settings()[key] || "e.g. alt+v",
|
|
376
|
+
value: settings()[key],
|
|
377
|
+
})
|
|
378
|
+
if (value !== undefined)
|
|
379
|
+
await updateConfig((draft) => {
|
|
380
|
+
draft[key] = value.trim()
|
|
381
|
+
})
|
|
382
|
+
openSettings()
|
|
383
|
+
}
|
|
384
|
+
|
|
308
385
|
context.keymap.layer(() => ({
|
|
309
386
|
mode: "global",
|
|
310
387
|
priority: 10,
|
|
@@ -315,10 +392,21 @@ export default Plugin.define({
|
|
|
315
392
|
group: "Voice",
|
|
316
393
|
// Not super+v: VTE only enables xterm modifyOtherKeys, which never
|
|
317
394
|
// reports the Super modifier, so that binding could not fire here.
|
|
318
|
-
|
|
395
|
+
// Read from the stored config, so changing it in the panel rebinds now.
|
|
396
|
+
bind: bindOf(settings().bindStart),
|
|
319
397
|
palette: true,
|
|
320
398
|
slash: { name: "dictate" },
|
|
321
|
-
run: toggle,
|
|
399
|
+
run: () => toggle(),
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
id: "opencode-groq-dictate.toggleSubmit",
|
|
403
|
+
title: "Dictate (Groq) and submit",
|
|
404
|
+
group: "Voice",
|
|
405
|
+
// ctrl+alt is what modifyOtherKeys reports, the same path the TUI's own
|
|
406
|
+
// ctrl+alt defaults take. Rebind or disable it in cli.json like any other.
|
|
407
|
+
bind: bindOf(settings().bindSubmit),
|
|
408
|
+
palette: true,
|
|
409
|
+
run: () => toggle(true),
|
|
322
410
|
},
|
|
323
411
|
{
|
|
324
412
|
id: "opencode-groq-dictate.cancel",
|
|
@@ -353,8 +441,19 @@ export default Plugin.define({
|
|
|
353
441
|
palette: true,
|
|
354
442
|
run: pickModel,
|
|
355
443
|
},
|
|
444
|
+
{
|
|
445
|
+
id: "opencode-groq-dictate.settings",
|
|
446
|
+
title: "Dictation settings",
|
|
447
|
+
group: "Voice",
|
|
448
|
+
palette: true,
|
|
449
|
+
run: openSettings,
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
bindings: [
|
|
453
|
+
"opencode-groq-dictate.toggle",
|
|
454
|
+
"opencode-groq-dictate.toggleSubmit",
|
|
455
|
+
"opencode-groq-dictate.cancel",
|
|
356
456
|
],
|
|
357
|
-
bindings: ["opencode-groq-dictate.toggle", "opencode-groq-dictate.cancel"],
|
|
358
457
|
}))
|
|
359
458
|
|
|
360
459
|
const red = () => context.theme?.hue?.red?.["400"] ?? "#ff5555"
|
|
@@ -380,7 +479,15 @@ export default Plugin.define({
|
|
|
380
479
|
|
|
381
480
|
// Label column, the same width trick the go-meter rows use so the figures line
|
|
382
481
|
// up under each other instead of drifting.
|
|
383
|
-
|
|
482
|
+
// ctrl+click mirrors ctrl+alt+v: the OpenTUI MouseEvent carries the modifiers.
|
|
483
|
+
type Click = { modifiers?: { ctrl?: boolean } } | undefined
|
|
484
|
+
const wantsSubmit = (event: Click) => !!event?.modifiers?.ctrl
|
|
485
|
+
const row = (
|
|
486
|
+
label: string,
|
|
487
|
+
value: string,
|
|
488
|
+
fg: () => string | undefined,
|
|
489
|
+
onClick?: (event: Click) => void,
|
|
490
|
+
) => (
|
|
384
491
|
<box flexDirection="row" gap={0} onMouseUp={onClick}>
|
|
385
492
|
<text>{label.padEnd(LABEL_WIDTH)}</text>
|
|
386
493
|
<text fg={fg()}>{value}</text>
|
|
@@ -403,14 +510,19 @@ export default Plugin.define({
|
|
|
403
510
|
const state = () => (recording() ? red() : busy() ? amber() : green())
|
|
404
511
|
return (
|
|
405
512
|
<box flexDirection="column">
|
|
406
|
-
<
|
|
407
|
-
|
|
408
|
-
|
|
513
|
+
<box flexDirection="row" gap={1}>
|
|
514
|
+
<text fg={muted()} onMouseUp={(event) => toggle(wantsSubmit(event))}>
|
|
515
|
+
{"🎤 Groq Dictate"}
|
|
516
|
+
</text>
|
|
517
|
+
<text fg={muted()} onMouseUp={() => openSettings()}>
|
|
518
|
+
{"⚙"}
|
|
519
|
+
</text>
|
|
520
|
+
</box>
|
|
409
521
|
{row(
|
|
410
522
|
"state",
|
|
411
523
|
recording() ? `● rec ${elapsed()}s` : busy() ? "transcribing…" : "idle",
|
|
412
524
|
state,
|
|
413
|
-
toggle,
|
|
525
|
+
(event) => toggle(wantsSubmit(event)),
|
|
414
526
|
)}
|
|
415
527
|
{row("model", settings().model.replace("whisper-", ""), () => undefined, pickModel)}
|
|
416
528
|
{row("lang", settings().language, () => undefined, pickLanguage)}
|