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 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
- Both bindings are ordinary commands, so you can rebind or disable them in
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.7",
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({ apiKey: "", language: "it", model: DEFAULT_MODEL })
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(parseConfig({ apiKey: "gsk_x", language: "auto", model: "whisper-large-v3" })).toEqual({
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 = { apiKey: string; language: string; model: Model }
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 = { apiKey: "", language: "it", model: DEFAULT_MODEL }
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
- ? "Recording… ctrl+alt+v stops and sends · esc cancels"
260
- : "Recording… alt+v stop · esc cancel",
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
- bind: "alt+v",
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: "ctrl+alt+v",
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
- <text fg={muted()} onMouseUp={(event) => toggle(wantsSubmit(event))}>
454
- {"🎤 Groq Dictate"}
455
- </text>
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",