opencode-groq-dictate 1.1.7 → 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 +10 -2
- package/package.json +1 -1
- package/src/voice.test.ts +27 -2
- package/src/voice.ts +25 -2
- package/tui.tsx +72 -7
package/README.md
CHANGED
|
@@ -72,12 +72,20 @@ clone out of `~/.config/opencode/plugins/` before installing from npm.
|
|
|
72
72
|
| `ctrl+click` on the same two | same as `ctrl+alt+v` |
|
|
73
73
|
| `esc` | cancel the take: no upload, no request, no cost |
|
|
74
74
|
| `/dictate` | same as `alt+v`, from the command palette |
|
|
75
|
+
| `⚙` next to the title | open the settings panel |
|
|
75
76
|
|
|
76
77
|
A take stops on its own after 60 seconds. Everything else is in the palette under
|
|
77
78
|
**Voice**: `Dictate (Groq) and submit`, `Dictation model`, `Dictation language`,
|
|
78
|
-
`Dictation quota`.
|
|
79
|
+
`Dictation quota`, `Dictation settings`.
|
|
79
80
|
|
|
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
|
|
81
89
|
`cli.json` without touching the plugin:
|
|
82
90
|
|
|
83
91
|
```jsonc
|
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,
|
|
@@ -256,8 +257,8 @@ export default Plugin.define({
|
|
|
256
257
|
}, MAX_SECONDS * 1000)
|
|
257
258
|
context.ui.toast.show({
|
|
258
259
|
message: submit
|
|
259
|
-
?
|
|
260
|
-
:
|
|
260
|
+
? `Recording… ${settings().bindSubmit || "the palette"} stops and sends · esc cancels`
|
|
261
|
+
: `Recording… ${settings().bindStart || "the palette"} stops · esc cancels`,
|
|
261
262
|
variant: "info",
|
|
262
263
|
duration: 2000,
|
|
263
264
|
})
|
|
@@ -330,6 +331,57 @@ export default Plugin.define({
|
|
|
330
331
|
context.ui.toast.show({ message: `Language: ${settings().language}`, variant: "success" })
|
|
331
332
|
}
|
|
332
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
|
+
|
|
333
385
|
context.keymap.layer(() => ({
|
|
334
386
|
mode: "global",
|
|
335
387
|
priority: 10,
|
|
@@ -340,7 +392,8 @@ export default Plugin.define({
|
|
|
340
392
|
group: "Voice",
|
|
341
393
|
// Not super+v: VTE only enables xterm modifyOtherKeys, which never
|
|
342
394
|
// reports the Super modifier, so that binding could not fire here.
|
|
343
|
-
|
|
395
|
+
// Read from the stored config, so changing it in the panel rebinds now.
|
|
396
|
+
bind: bindOf(settings().bindStart),
|
|
344
397
|
palette: true,
|
|
345
398
|
slash: { name: "dictate" },
|
|
346
399
|
run: () => toggle(),
|
|
@@ -351,7 +404,7 @@ export default Plugin.define({
|
|
|
351
404
|
group: "Voice",
|
|
352
405
|
// ctrl+alt is what modifyOtherKeys reports, the same path the TUI's own
|
|
353
406
|
// ctrl+alt defaults take. Rebind or disable it in cli.json like any other.
|
|
354
|
-
bind:
|
|
407
|
+
bind: bindOf(settings().bindSubmit),
|
|
355
408
|
palette: true,
|
|
356
409
|
run: () => toggle(true),
|
|
357
410
|
},
|
|
@@ -388,6 +441,13 @@ export default Plugin.define({
|
|
|
388
441
|
palette: true,
|
|
389
442
|
run: pickModel,
|
|
390
443
|
},
|
|
444
|
+
{
|
|
445
|
+
id: "opencode-groq-dictate.settings",
|
|
446
|
+
title: "Dictation settings",
|
|
447
|
+
group: "Voice",
|
|
448
|
+
palette: true,
|
|
449
|
+
run: openSettings,
|
|
450
|
+
},
|
|
391
451
|
],
|
|
392
452
|
bindings: [
|
|
393
453
|
"opencode-groq-dictate.toggle",
|
|
@@ -450,9 +510,14 @@ export default Plugin.define({
|
|
|
450
510
|
const state = () => (recording() ? red() : busy() ? amber() : green())
|
|
451
511
|
return (
|
|
452
512
|
<box flexDirection="column">
|
|
453
|
-
<
|
|
454
|
-
{
|
|
455
|
-
|
|
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>
|
|
456
521
|
{row(
|
|
457
522
|
"state",
|
|
458
523
|
recording() ? `● rec ${elapsed()}s` : busy() ? "transcribing…" : "idle",
|