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 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**: `Dictation model`, `Dictation language`, `Dictation quota`.
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` is reported by both the legacy and the
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.6",
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,
@@ -136,7 +137,11 @@ export default Plugin.define({
136
137
  return usd
137
138
  }
138
139
 
139
- async function transcribe(seconds: number) {
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({ message: "Recording… alt+v stop · esc cancel", variant: "info", duration: 2000 })
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 = () => (recording() ? stop() : start())
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
- bind: "alt+v",
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
- const row = (label: string, value: string, fg: () => string | undefined, onClick?: () => void) => (
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
- <text fg={muted()} onMouseUp={toggle}>
407
- Groq Dictate
408
- </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>
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)}