opencode-groq-dictate 0.0.0-stage → 1.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leonardo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,157 @@
1
- # Temporary Holding Version
1
+ # opencode-groq-dictate
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![opencode v2](https://img.shields.io/badge/opencode-v2-orange.svg)](#requirements)
4
+ [![npm](https://img.shields.io/npm/v/opencode-groq-dictate?label=npm)](https://www.npmjs.com/package/opencode-groq-dictate)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![release](https://img.shields.io/github/v/tag/LeonVeganMan/opencode-groq-dictate?label=release)](https://github.com/LeonVeganMan/opencode-groq-dictate/releases)
7
+ [![tests](https://img.shields.io/badge/tests-bun%20test-informational.svg)](#development)
8
+
9
+ Dictation inside the **opencode v2** TUI: press `alt+v`, speak, press `alt+v` again.
10
+ The take goes to Groq (`whisper-large-v3-turbo` by default) and the transcript is
11
+ pasted into the prompt, ready to edit. `esc` throws a take away without uploading it.
12
+
13
+ The sidebar carries the state, the model, the language, the spend and the request
14
+ count, so you can see what the plugin is doing and what it costs while you dictate.
15
+
16
+ ## Why a plugin and not a global shortcut
17
+
18
+ The usual setup is a shell script bound to a desktop shortcut: it records in one
19
+ process, then pastes with `xdotool ctrl+v` into whatever window has focus. That works
20
+ until the focus is somewhere else, and it leaves the transcript in no-man's land.
21
+
22
+ Here recording, upload and paste all happen inside the TUI. The text is put on the
23
+ clipboard and handed to `prompt.paste`, so it always lands in the composer, at the
24
+ cursor, as if it had been pasted by hand.
25
+
26
+ ## Requirements
27
+
28
+ - **opencode v2** — the generation whose TUI plugins are loaded from
29
+ `~/.config/opencode/plugins/` and built on `@opencode/plugin/tui` (`ui.slot`,
30
+ `ui.dialog`, `keymap.layer`). The v1 plugin API is a different generation and this
31
+ plugin does not target it.
32
+ - `parec` (`pulseaudio-utils`) to record, `xclip` to place the transcript on the
33
+ clipboard. Both are already installed on a normal PulseAudio/PipeWire desktop.
34
+ - A Groq API key, free tier is enough. It is asked once, on the first `alt+v`, and kept
35
+ in the opencode plugin storage — never in this repository.
36
+
37
+ `bun` is only needed for the tests.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ git clone https://github.com/LeonVeganMan/opencode-groq-dictate.git \
43
+ ~/.config/opencode/plugins/opencode-groq-dictate
44
+ ```
45
+
46
+ Local plugins are discovered from `~/.config/opencode/plugins/<name>/`: no entry in
47
+ `opencode.json` is needed. Restart the TUI afterwards — plugins are read at startup and
48
+ there is no hot reload.
49
+
50
+ The clone above stays the documented install — opencode loads plugins from that folder —
51
+ and the package is also on npm as
52
+ [`opencode-groq-dictate`](https://www.npmjs.com/package/opencode-groq-dictate) for
53
+ directories and registries that index plugins.
54
+
55
+ ## Usage
56
+
57
+ | Key | Action |
58
+ |-----|--------|
59
+ | `alt+v` | start recording, press again to stop and transcribe |
60
+ | `esc` | cancel the take: no upload, no request, no cost |
61
+ | `/dictate` | same as `alt+v`, from the command palette |
62
+
63
+ A take stops on its own after 60 seconds. Everything else is in the palette under
64
+ **Voice**: `Dictation model`, `Dictation language`, `Dictation quota`.
65
+
66
+ Super+V cannot be used: VTE (Tilix, xfce4-terminal) only enables xterm
67
+ `modifyOtherKeys`, which never reports the Super modifier, so the binding could not
68
+ fire inside the terminal. `alt+v` is reported by both the legacy and the
69
+ `modifyOtherKeys` encodings.
70
+
71
+ ## Screenshots
72
+
73
+ ![Sidebar panel](assets/sidebar.png)
74
+
75
+ The sidebar panel, without a border, same shape and colours as
76
+ [opencode-go-meter](https://github.com/LeonVeganMan/opencode-go-meter). Nothing in it is
77
+ decoration: every row is clickable and does something.
78
+
79
+ | Row | Shows | Click |
80
+ |-----|-------|-------|
81
+ | `Groq Dictate` (title) | the panel name, muted | start / stop a take |
82
+ | `state` | `idle`, `● rec 12s`, `transcribing…` | start / stop a take |
83
+ | `model` | `large-v3-turbo` or `large-v3` | model dialog |
84
+ | `lang` | `it`, `en`, `auto`, … | language dialog |
85
+ | `today` | `$0.0032 · 13` takes | quota dialog |
86
+ | `total` | `$0.0093` since the log began | quota dialog |
87
+ | `last` | `7.3s · $0.0001` of the last take | — |
88
+ | `rpm` | `1/20` requests in the last minute | quota dialog |
89
+
90
+ The state row is green when idle, red while recording, amber while the text is in
91
+ flight; `rpm` turns red at the limit, where a take would be refused.
92
+
93
+ ![Footer indicator](assets/footer.png)
94
+
95
+ While a take is running the prompt footer carries a red `● REC Ns`; it turns amber
96
+ while the text is in flight.
97
+
98
+ ![Quota dialog](assets/quota.png)
99
+
100
+ The quota dialog, also reachable by clicking `today`, `total` or `rpm`: model, language,
101
+ requests left today (from the Groq headers), local RPM, spend today with the number of
102
+ takes, spend in total.
103
+
104
+ ## Cost and quota
105
+
106
+ - Cost is estimated on the audio length at **$0.04/hour** and appended to
107
+ `~/.cache/groq-dictate-costs.log`, the same file and the same four columns
108
+ (`date time seconds cost`) the older shell script wrote, so a `/dictate-cost` command
109
+ keeps working unchanged.
110
+ - The **daily** request figure comes from the `x-ratelimit-*` headers of the real
111
+ transcription. No request is spent just to read the quota.
112
+ - The **per-minute** figure is tracked locally: timestamps of the last 60 seconds, cap
113
+ 20, the free tier limit. At the cap the recording does not start.
114
+ - Language defaults to Italian. Set another ISO code, or `auto` for detection, from the
115
+ language dialog. Model can be `whisper-large-v3-turbo` (default, faster) or
116
+ `whisper-large-v3` (same price, a little more accurate).
117
+
118
+ ## How it works
119
+
120
+ 1. `parec --rate=16000 --channels=1 --file-format=wav` records to `/tmp` at the rate
121
+ Whisper wants, so no `ffmpeg` step is needed.
122
+ 2. On stop the WAV goes to `https://api.groq.com/openai/v1/audio/transcriptions` with
123
+ `fetch` and `FormData` (no dependencies), and the file is deleted as soon as the
124
+ answer arrives.
125
+ 3. The text goes to the clipboard with `xclip`, then `prompt.paste` puts it in the
126
+ composer.
127
+ 4. The cost line is appended and the sidebar totals update.
128
+
129
+ The pure logic (`src/voice.ts`: audio length, cost, log format, rpm window, totals) is
130
+ separate from the TUI so it can be unit tested without a terminal.
131
+
132
+ ## Limitations
133
+
134
+ - TUI only: dictating into a browser or an editor is out of scope. A desktop shortcut
135
+ running a small script is still the right tool there.
136
+ - The transcript is pasted, not submitted: you always get a chance to fix it.
137
+ - No automatic stop at the end of a sentence yet. The microphone gain on a typical
138
+ laptop is too high for a threshold to separate voice from room; see [TODO.md](TODO.md)
139
+ for the measurements and the sketch.
140
+ - A silent or monitor source makes Whisper hallucinate ("Grazie a tutti!" is the
141
+ classic). The plugin checks `pactl get-default-source` and refuses to record when the
142
+ default is a `.monitor`; if the check passes and the text still looks invented, the
143
+ capture level is the first thing to look at.
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ bun test # unit tests on the pure logic
149
+ bun build tui.tsx --target=node --external '*' # type/JSX sanity check
150
+ ```
151
+
152
+ The plugin is one TUI entry point (`tui.tsx`) plus one pure module (`src/voice.ts`).
153
+ Editing it requires restarting the TUI: there is no hot reload.
154
+
155
+ ## License
156
+
157
+ MIT — see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "opencode-groq-dictate",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.2",
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
+ "private": false,
6
+ "type": "module",
7
+ "exports": {
8
+ "./tui": "./tui.tsx"
9
+ },
10
+ "scripts": {
11
+ "test": "bun test"
12
+ },
13
+ "files": [
14
+ "tui.tsx",
15
+ "src"
16
+ ],
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "tui",
21
+ "dictation",
22
+ "groq",
23
+ "whisper"
24
+ ],
25
+ "license": "MIT",
26
+ "author": "Leonardo Cosmai",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/LeonVeganMan/opencode-groq-dictate.git"
30
+ },
31
+ "homepage": "https://github.com/LeonVeganMan/opencode-groq-dictate#readme"
32
+ }
@@ -0,0 +1,70 @@
1
+ import { expect, test } from "bun:test"
2
+ import {
3
+ DEFAULT_MODEL,
4
+ audioSeconds,
5
+ cost,
6
+ logLine,
7
+ parseConfig,
8
+ recentRpm,
9
+ startTicker,
10
+ summarize,
11
+ } from "./voice"
12
+
13
+ test("audio length comes from the bytes past the header", () => {
14
+ expect(audioSeconds(44)).toBe(0)
15
+ expect(audioSeconds(44 + 16_000 * 2 * 3)).toBe(3)
16
+ })
17
+
18
+ test("cost is the hourly rate pro rata", () => {
19
+ expect(cost(3600)).toBeCloseTo(0.04, 6)
20
+ expect(cost(0)).toBe(0)
21
+ })
22
+
23
+ test("the rpm window drops stamps older than a minute", () => {
24
+ const now = 100_000
25
+ expect(recentRpm([now - 61_000, now - 1_000, now], now)).toEqual([now - 1_000, now])
26
+ })
27
+
28
+ test("a config with the wrong types falls back to the defaults", () => {
29
+ expect(parseConfig(null)).toEqual({ apiKey: "", language: "it", model: DEFAULT_MODEL })
30
+ expect(parseConfig({ apiKey: 42, language: "", model: "nope" })).toEqual({
31
+ apiKey: "",
32
+ language: "it",
33
+ model: DEFAULT_MODEL,
34
+ })
35
+ expect(parseConfig({ apiKey: "gsk_x", language: "auto", model: "whisper-large-v3" })).toEqual({
36
+ apiKey: "gsk_x",
37
+ language: "auto",
38
+ model: "whisper-large-v3",
39
+ })
40
+ })
41
+
42
+ test("the cost line keeps the shell script's four columns", () => {
43
+ 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")
44
+ })
45
+
46
+ test("the totals only count today's lines, and ignore junk", () => {
47
+ const now = new Date(2026, 9, 19, 12, 0, 0)
48
+ const log = [
49
+ "2026-10-18 16:30:57 453.7 0.005041",
50
+ "2026-10-19 08:05:03 12.3 0.000137",
51
+ "2026-10-19 09:00:00 30.0 0.000333",
52
+ "garbage line",
53
+ "",
54
+ ].join("\n")
55
+ const totals = summarize(log, now)
56
+ expect(totals.today).toBeCloseTo(0.00047, 9)
57
+ expect(totals.total).toBeCloseTo(0.005511, 9)
58
+ expect(totals.todayCount).toBe(2)
59
+ })
60
+
61
+ test("the ticker runs until its stop function is called", async () => {
62
+ let ticks = 0
63
+ const stop = startTicker(() => ticks++, 5)
64
+ await new Promise((done) => setTimeout(done, 22))
65
+ stop()
66
+ const seen = ticks
67
+ await new Promise((done) => setTimeout(done, 20))
68
+ expect(seen).toBeGreaterThan(0)
69
+ expect(ticks).toBe(seen)
70
+ })
package/src/voice.ts ADDED
@@ -0,0 +1,78 @@
1
+ // The parts of dictation that do not touch a terminal: config shape, WAV maths,
2
+ // the local RPM window, the cost-log line and the totals read back from it.
3
+ // Kept here so `bun test` can check them without the TUI.
4
+
5
+ export const MODELS = ["whisper-large-v3-turbo", "whisper-large-v3"] as const
6
+ export type Model = (typeof MODELS)[number]
7
+ export const DEFAULT_MODEL: Model = "whisper-large-v3-turbo"
8
+ // Both models bill the same per audio hour; turbo is only faster.
9
+ export const USD_PER_HOUR = 0.04
10
+ export const RPM_LIMIT = 20
11
+ // A forgotten recording would upload silence for minutes; cap it.
12
+ export const MAX_SECONDS = 60
13
+ export const SAMPLE_RATE = 16_000
14
+ export const BYTES_PER_SAMPLE = 2
15
+ // parec writes a canonical 44-byte header; anything past it is audio.
16
+ export const WAV_HEADER = 44
17
+
18
+ export type Config = { apiKey: string; language: string; model: Model }
19
+
20
+ export const DEFAULTS: Config = { apiKey: "", language: "it", model: DEFAULT_MODEL }
21
+
22
+ export function parseConfig(raw: unknown): Config {
23
+ const value = (raw ?? {}) as Record<string, unknown>
24
+ const model = MODELS.includes(value.model as Model) ? (value.model as Model) : DEFAULTS.model
25
+ return {
26
+ apiKey: typeof value.apiKey === "string" ? value.apiKey : DEFAULTS.apiKey,
27
+ language: typeof value.language === "string" && value.language ? value.language : DEFAULTS.language,
28
+ model,
29
+ }
30
+ }
31
+
32
+ export function audioSeconds(bytes: number): number {
33
+ return Math.max(0, bytes - WAV_HEADER) / (SAMPLE_RATE * BYTES_PER_SAMPLE)
34
+ }
35
+
36
+ export function cost(seconds: number): number {
37
+ return (seconds / 3600) * USD_PER_HOUR
38
+ }
39
+
40
+ export function recentRpm(stamps: number[], now: number): number[] {
41
+ return stamps.filter((at) => now - at < 60_000)
42
+ }
43
+
44
+ // Same columns the shell script wrote: `date time seconds cost`, which is what
45
+ // the /dictate-cost command's awk still reads.
46
+ export function logLine(at: Date, seconds: number, usd: number): string {
47
+ const pad = (n: number) => String(n).padStart(2, "0")
48
+ const date = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
49
+ const time = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
50
+ return `${date} ${time} ${seconds.toFixed(1)} ${usd.toFixed(6)}`
51
+ }
52
+
53
+ export type Totals = { today: number; total: number; todayCount: number }
54
+
55
+ // Reads back the same four columns, ignoring anything malformed so a hand-edited
56
+ // log line cannot break the sidebar.
57
+ export function summarize(text: string, now: Date): Totals {
58
+ const pad = (n: number) => String(n).padStart(2, "0")
59
+ const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
60
+ const totals: Totals = { today: 0, total: 0, todayCount: 0 }
61
+ for (const line of text.split("\n")) {
62
+ const [date, , , rawCost] = line.trim().split(/\s+/)
63
+ const usd = Number(rawCost)
64
+ if (!date || !Number.isFinite(usd)) continue
65
+ totals.total += usd
66
+ if (date === today) {
67
+ totals.today += usd
68
+ totals.todayCount += 1
69
+ }
70
+ }
71
+ return totals
72
+ }
73
+
74
+ // One ticker for the recording counter: the TUI only supplies the signal.
75
+ export function startTicker(onTick: () => void, ms = 1000): () => void {
76
+ const id = setInterval(onTick, ms)
77
+ return () => clearInterval(id)
78
+ }
package/tui.tsx ADDED
@@ -0,0 +1,433 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { execFile, spawn } from "node:child_process"
3
+ import { readFile, appendFile, rm, stat } from "node:fs/promises"
4
+ import { homedir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { Show, createMemo, createSignal } from "solid-js"
7
+ import { Plugin } from "@opencode/plugin/tui"
8
+ import {
9
+ DEFAULTS,
10
+ MAX_SECONDS,
11
+ MODELS,
12
+ RPM_LIMIT,
13
+ audioSeconds,
14
+ cost,
15
+ logLine,
16
+ parseConfig,
17
+ recentRpm,
18
+ startTicker,
19
+ summarize,
20
+ } from "./src/voice"
21
+
22
+ const WAV = "/tmp/opencode-groq-dictate.wav"
23
+ const COST_LOG = join(homedir(), ".cache/groq-dictate-costs.log")
24
+ const GROQ_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
25
+ // Same column width as the go-meter panel, so both plugins line up.
26
+ const LABEL_WIDTH = 8
27
+
28
+ // super+v is the key the shell script used; here the recording, the upload and the
29
+ // paste all stay inside the TUI, so nothing depends on which window has focus.
30
+ export default Plugin.define({
31
+ id: "opencode-groq-dictate",
32
+ setup(context) {
33
+ // The docs show the first element used as a value, other plugins read it as an
34
+ // accessor; accepting both keeps the plugin working whichever it is.
35
+ const read = <T,>(value: T | (() => T)): T =>
36
+ typeof value === "function" ? (value as () => T)() : value
37
+
38
+ const [config, updateConfig] = context.storage.store("opencode-groq-dictate", { initial: DEFAULTS })
39
+ const [rpm, updateRpm] = context.storage.memory("opencode-groq-dictate.rpm", { initial: [] as number[] })
40
+ const [quota, updateQuota] = context.storage.memory("opencode-groq-dictate.quota", {
41
+ initial: { remaining: 0, limit: 0 },
42
+ })
43
+ let recorder: ReturnType<typeof spawn> | null = null
44
+ // A signal, not a plain variable: the footer indicator has to re-render when
45
+ // it flips, and a call still reads the current value inside a command.
46
+ const [recording, setRecording] = createSignal(false)
47
+ const [elapsed, setElapsed] = createSignal(0)
48
+ // "busy" covers the gap between stop and paste, so the dot never blinks off.
49
+ const [busy, setBusy] = createSignal(false)
50
+ const [totals, setTotals] = createSignal({ today: 0, total: 0, todayCount: 0 })
51
+ const [last, setLast] = createSignal<{ seconds: number; usd: number } | null>(null)
52
+ let ticker: (() => void) | null = null
53
+ let autoStop: ReturnType<typeof setTimeout> | null = null
54
+
55
+ const settings = () => parseConfig(read(config))
56
+
57
+ // Seeded from the log the shell script already wrote, so the sidebar shows the
58
+ // real spend from the first run instead of starting at zero.
59
+ void readFile(COST_LOG, "utf8")
60
+ .then((text) => setTotals(summarize(text, new Date())))
61
+ .catch(() => {})
62
+
63
+ async function askKey() {
64
+ const apiKey = await context.ui.dialog.prompt({
65
+ title: "Groq API key",
66
+ placeholder: "gsk_...",
67
+ })
68
+ if (!apiKey) return ""
69
+ await updateConfig((draft) => {
70
+ draft.apiKey = apiKey.trim()
71
+ })
72
+ return apiKey.trim()
73
+ }
74
+
75
+ async function paste(text: string) {
76
+ // prompt.paste reads the system clipboard, so the text goes there first and
77
+ // the composer receives it exactly as if it had been pasted by hand.
78
+ const clip = spawn("xclip", ["-selection", "clipboard"], { stdio: ["pipe", "ignore", "ignore"] })
79
+ clip.stdin.end(text)
80
+ await new Promise((done) => clip.on("close", done))
81
+ context.keymap.dispatch("prompt.paste")
82
+ }
83
+
84
+ // One round trip to Groq. Returns the text, or why there is none, so the
85
+ // caller does not have to read the response twice.
86
+ async function request(key: string, model: string, language: string) {
87
+ const bytes = await readFile(WAV)
88
+ const form = new FormData()
89
+ form.append("file", new Blob([bytes], { type: "audio/wav" }), "voice.wav")
90
+ form.append("model", model)
91
+ if (language !== "auto") form.append("language", language)
92
+
93
+ const response = await fetch(GROQ_URL, {
94
+ method: "POST",
95
+ headers: { Authorization: `Bearer ${key}` },
96
+ body: form,
97
+ })
98
+
99
+ // The daily figure only ever arrives as a header, so it is read off the real
100
+ // request instead of spending a transcription on a probe.
101
+ const remaining = Number(response.headers.get("x-ratelimit-remaining-requests"))
102
+ const limit = Number(response.headers.get("x-ratelimit-limit-requests"))
103
+ if (limit > 0)
104
+ await updateQuota((draft) => {
105
+ draft.remaining = remaining
106
+ draft.limit = limit
107
+ })
108
+
109
+ if (response.status === 401) {
110
+ await updateConfig((draft) => {
111
+ draft.apiKey = ""
112
+ })
113
+ return { error: "Groq API key rejected" }
114
+ }
115
+ if (!response.ok) return { error: `Groq ${response.status}: ${await response.text()}` }
116
+
117
+ const text = ((await response.json()) as { text?: string }).text?.trim()
118
+ return text ? { text } : { warning: "Nothing transcribed" }
119
+ }
120
+
121
+ // Cost bookkeeping: the log line is best effort, the totals are in memory.
122
+ async function bookkeep(seconds: number) {
123
+ const usd = cost(seconds)
124
+ try {
125
+ await appendFile(COST_LOG, logLine(new Date(), seconds, usd) + "\n")
126
+ } catch (error) {
127
+ context.ui.toast.show({ message: `Cost log: ${String(error)}`, variant: "warning" })
128
+ }
129
+ setTotals((current) => ({
130
+ today: current.today + usd,
131
+ total: current.total + usd,
132
+ todayCount: current.todayCount + 1,
133
+ }))
134
+ setLast({ seconds, usd })
135
+ return usd
136
+ }
137
+
138
+ async function transcribe(seconds: number) {
139
+ const { apiKey, language, model } = settings()
140
+ const key = apiKey || (await askKey())
141
+ if (!key) return
142
+
143
+ const outcome = await request(key, model, language)
144
+ if (outcome.error) {
145
+ context.ui.toast.show({ message: outcome.error, variant: "error" })
146
+ return
147
+ }
148
+ if (!outcome.text) {
149
+ context.ui.toast.show({ message: outcome.warning ?? "Nothing transcribed", variant: "warning" })
150
+ return
151
+ }
152
+
153
+ await paste(outcome.text)
154
+ // The recording is only needed until Groq answers; leaving it in /tmp keeps
155
+ // a copy of everything dictated on disk.
156
+ await rm(WAV, { force: true }).catch(() => {})
157
+
158
+ const usd = await bookkeep(seconds)
159
+ context.ui.toast.show({ message: `Dictated ${seconds.toFixed(1)}s · $${usd.toFixed(4)}`, variant: "success" })
160
+ }
161
+
162
+ async function stop() {
163
+ if (!recorder) return
164
+ const child = recorder
165
+ recorder = null
166
+ setRecording(false)
167
+ setBusy(true)
168
+ ticker?.()
169
+ if (autoStop) clearTimeout(autoStop)
170
+ ticker = null
171
+ autoStop = null
172
+ child.kill("SIGINT")
173
+ await new Promise((done) => child.on("close", done))
174
+ const seconds = audioSeconds((await stat(WAV)).size)
175
+ if (seconds < 0.2) {
176
+ context.ui.toast.show({ message: "Recording too short", variant: "warning" })
177
+ setBusy(false)
178
+ return
179
+ }
180
+ context.ui.toast.show({ message: "Transcribing…", variant: "info" })
181
+ try {
182
+ await transcribe(seconds)
183
+ } catch (error) {
184
+ context.ui.toast.show({ message: String(error), variant: "error" })
185
+ } finally {
186
+ setBusy(false)
187
+ }
188
+ }
189
+
190
+ // A monitor source records the speakers, not the room: that silence comes back as
191
+ // an invented sentence ("Grazie a tutti!"), so the take is refused before it starts.
192
+ const hasMicrophone = () =>
193
+ new Promise<boolean>((done) => {
194
+ execFile("pactl", ["get-default-source"], (error, stdout) =>
195
+ done(!!error || !stdout.trim().endsWith(".monitor")),
196
+ )
197
+ })
198
+
199
+ async function start() {
200
+ const { apiKey } = settings()
201
+ if (!apiKey) {
202
+ context.ui.toast.show({ message: "Set the Groq API key first", variant: "warning" })
203
+ if (!(await askKey())) return
204
+ }
205
+ if (!(await hasMicrophone())) {
206
+ context.ui.toast.show({
207
+ message: "No microphone: the default source is a monitor",
208
+ variant: "error",
209
+ })
210
+ return
211
+ }
212
+ const now = Date.now()
213
+ const recent = recentRpm(read(rpm), now)
214
+ if (recent.length >= RPM_LIMIT) {
215
+ context.ui.toast.show({ message: `Groq free tier: ${RPM_LIMIT}/min reached, wait a moment`, variant: "error" })
216
+ return
217
+ }
218
+ await updateRpm((draft) => {
219
+ draft.splice(0, draft.length, ...recent, now)
220
+ })
221
+
222
+ recorder = spawn("parec", ["--rate=16000", "--channels=1", "--file-format=wav", WAV], {
223
+ stdio: "ignore",
224
+ })
225
+ recorder.on("error", (error) => {
226
+ recorder = null
227
+ setRecording(false)
228
+ setBusy(false)
229
+ ticker?.()
230
+ if (autoStop) clearTimeout(autoStop)
231
+ context.ui.toast.show({ message: `parec: ${error.message}`, variant: "error" })
232
+ })
233
+ setRecording(true)
234
+ setElapsed(0)
235
+ ticker = startTicker(() => setElapsed((value) => value + 1))
236
+ // Nobody wants a ten-minute upload because the stop key was forgotten.
237
+ autoStop = setTimeout(() => {
238
+ context.ui.toast.show({ message: `Stopped at ${MAX_SECONDS}s`, variant: "warning" })
239
+ void stop()
240
+ }, MAX_SECONDS * 1000)
241
+ context.ui.toast.show({ message: "Recording… alt+v stop · esc cancel", variant: "info", duration: 2000 })
242
+ }
243
+
244
+
245
+ // Esc aborts the take: nothing is uploaded, so no quota and no cost.
246
+ async function cancel() {
247
+ if (!recorder) return
248
+ const child = recorder
249
+ recorder = null
250
+ setRecording(false)
251
+ setBusy(false)
252
+ ticker?.()
253
+ ticker = null
254
+ if (autoStop) clearTimeout(autoStop)
255
+ autoStop = null
256
+ child.kill("SIGINT")
257
+ await new Promise((done) => child.on("close", done))
258
+ await rm(WAV, { force: true }).catch(() => {})
259
+ context.ui.toast.show({ message: "Recording cancelled", variant: "info" })
260
+ }
261
+
262
+ // One entry point for the keybinding and for the click on the panel.
263
+
264
+ const toggle = () => (recording() ? stop() : start())
265
+
266
+ async function pickModel() {
267
+ const model = await context.ui.dialog.select({
268
+ title: "Transcription model",
269
+ options: MODELS.map((id) => ({ value: id, title: id })),
270
+ })
271
+ if (!model) return
272
+ await updateConfig((draft) => {
273
+ draft.model = model
274
+ })
275
+ context.ui.toast.show({ message: `Model: ${model}`, variant: "success" })
276
+ }
277
+
278
+ async function showQuota() {
279
+ const { remaining, limit } = read(quota)
280
+ const used = recentRpm(read(rpm), Date.now()).length
281
+ const { today, total, todayCount } = read(totals)
282
+ await context.ui.dialog.alert({
283
+ title: "Groq dictation",
284
+ message: [
285
+ `Model: ${settings().model}`,
286
+ `Language: ${settings().language}`,
287
+ `Requests today: ${limit > 0 ? `${remaining} / ${limit} left` : "unknown (dictate once)"}`,
288
+ `Local RPM: ${used} / ${RPM_LIMIT}`,
289
+ `Spent today: $${today.toFixed(4)} (${todayCount} runs)`,
290
+ `Spent in total: $${total.toFixed(4)}`,
291
+ ].join("\n"),
292
+ })
293
+ }
294
+
295
+ async function pickLanguage() {
296
+ const language = await context.ui.dialog.prompt({
297
+ title: "Language code (blank or 'auto' for detection)",
298
+ placeholder: settings().language,
299
+ })
300
+ if (language === undefined) return
301
+ await updateConfig((draft) => {
302
+ draft.language = language.trim() || "auto"
303
+ })
304
+ context.ui.toast.show({ message: `Language: ${settings().language}`, variant: "success" })
305
+ }
306
+
307
+ context.keymap.layer(() => ({
308
+ mode: "global",
309
+ priority: 10,
310
+ commands: [
311
+ {
312
+ id: "opencode-groq-dictate.toggle",
313
+ title: "Dictate (Groq)",
314
+ group: "Voice",
315
+ // Not super+v: VTE only enables xterm modifyOtherKeys, which never
316
+ // reports the Super modifier, so that binding could not fire here.
317
+ bind: "alt+v",
318
+ palette: true,
319
+ slash: { name: "dictate" },
320
+ run: toggle,
321
+ },
322
+ {
323
+ id: "opencode-groq-dictate.cancel",
324
+ title: "Cancel dictation",
325
+ group: "Voice",
326
+ bind: "escape",
327
+ // Returning false hands Escape back to the TUI when nothing is recording.
328
+ run: () => {
329
+ if (!recorder) return false
330
+ void cancel()
331
+ return true
332
+ },
333
+ },
334
+ {
335
+ id: "opencode-groq-dictate.quota",
336
+ title: "Dictation quota",
337
+ group: "Voice",
338
+ palette: true,
339
+ run: showQuota,
340
+ },
341
+ {
342
+ id: "opencode-groq-dictate.language",
343
+ title: "Dictation language",
344
+ group: "Voice",
345
+ palette: true,
346
+ run: pickLanguage,
347
+ },
348
+ {
349
+ id: "opencode-groq-dictate.model",
350
+ title: "Dictation model",
351
+ group: "Voice",
352
+ palette: true,
353
+ run: pickModel,
354
+ },
355
+ ],
356
+ bindings: ["opencode-groq-dictate.toggle", "opencode-groq-dictate.cancel"],
357
+ }))
358
+
359
+ const red = () => context.theme?.hue?.red?.["400"] ?? "#ff5555"
360
+ const amber = () => context.theme?.hue?.orange ?? "#FF9654"
361
+ const green = () => context.theme?.hue?.green?.["400"] ?? "#7fbf7f"
362
+ const muted = () => context.theme?.text?.muted ?? context.theme?.text?.subdued ?? "#808080"
363
+ // The brand accent, the same one the go-meter panel uses for its figures.
364
+ const accent = () =>
365
+ context.theme?.hue?.accent?.["500"] ?? context.theme?.hue?.interactive?.["300"] ?? "#FF9654"
366
+
367
+ // The footer row already carries the model and the other status items, so the
368
+ // dot sits next to them and disappears when there is nothing to report.
369
+ context.ui.slot({
370
+ append: "prompt.footer.status",
371
+ render: () => (
372
+ <Show when={recording() || busy()}>
373
+ <text fg={recording() ? red() : amber()}>
374
+ {recording() ? `● REC ${elapsed()}s` : "● …"}
375
+ </text>
376
+ </Show>
377
+ ),
378
+ })
379
+
380
+ // Label column, the same width trick the go-meter rows use so the figures line
381
+ // up under each other instead of drifting.
382
+ const row = (label: string, value: string, fg: () => string | undefined, onClick?: () => void) => (
383
+ <box flexDirection="row" gap={0} onMouseUp={onClick}>
384
+ <text>{label.padEnd(LABEL_WIDTH)}</text>
385
+ <text fg={fg()}>{value}</text>
386
+ </box>
387
+ )
388
+
389
+ // Memoised so the six rows do not recompute the log totals on every repaint.
390
+ const spent = createMemo(() => read(totals))
391
+ const lastRun = createMemo(() => last())
392
+
393
+ // Same slot the go-meter plugin uses: the sidebar is where a figure can sit
394
+ // without crowding the prompt row.
395
+ context.ui.slot({
396
+ append: "sidebar.content",
397
+ render: () => {
398
+ const figures = spent()
399
+ const used = recentRpm(read(rpm), Date.now()).length
400
+ // Three colours, one meaning each: red while recording, amber while the
401
+ // text is in flight, green when the plugin is idle and ready.
402
+ const state = () => (recording() ? red() : busy() ? amber() : green())
403
+ return (
404
+ <box flexDirection="column">
405
+ <text fg={muted()} onMouseUp={toggle}>
406
+ Groq Dictate
407
+ </text>
408
+ {row(
409
+ "state",
410
+ recording() ? `● rec ${elapsed()}s` : busy() ? "transcribing…" : "idle",
411
+ state,
412
+ toggle,
413
+ )}
414
+ {row("model", settings().model.replace("whisper-", ""), () => undefined, pickModel)}
415
+ {row("lang", settings().language, () => undefined, pickLanguage)}
416
+ {row("today", `$${figures.today.toFixed(4)} · ${figures.todayCount}`, accent)}
417
+ {row("total", `$${figures.total.toFixed(4)}`, accent)}
418
+ <Show when={lastRun()}>
419
+ {(run) => row("last", `${run().seconds.toFixed(1)}s · $${run().usd.toFixed(4)}`, muted)}
420
+ </Show>
421
+ {row("rpm", `${used}/${RPM_LIMIT}`, used >= RPM_LIMIT ? red : muted, showQuota)}
422
+ </box>
423
+ )
424
+ },
425
+ })
426
+
427
+ return () => {
428
+ ticker?.()
429
+ if (autoStop) clearTimeout(autoStop)
430
+ if (recorder) recorder.kill("SIGINT")
431
+ }
432
+ },
433
+ })