opencode-voice-dictation/src/index.ts
Sergey 960acd8a7a
feat: cancel button, question prompt mic, solid timer (#10)
## Changes

### Cancel button (✕)
- New cancel button appears during recording next to stop button
- Tapping ✕ stops recording and **discards audio** — no text inserted
- Tapping ⏹ stops recording and **inserts text** (as before)

### Question prompt support
- Mic button injected into `[data-slot=question-custom-input]` (custom
answer textarea)
- Text insertion via `selectionStart`/`selectionEnd` (textarea ≠
contenteditable)
- MutationObserver detects when question prompt appears

### Solid timer
- Timer background: `#e53935` (opaque red) with white text
- Was `rgba(229, 57, 53, 0.1)` (semi-transparent — text showed through)
- Now fully visible over any text

### Technical
- Refactored UI to use CSS classes instead of single IDs (supports
multiple buttons)
- `insertText()` now takes `target: "composer" | "question"`
- 47 tests, 100% line coverage

Co-authored-by: opencode-agent <agent@slaid098.dev>
2026-07-08 09:40:24 +03:00

200 lines
5 KiB
TypeScript

import { type AudioRecorder, createAudioRecorder } from "./audio.js";
import {
getConfig,
isFirstRun,
registerMenuCommands,
setConfig,
validateApiKey,
} from "./config.js";
import { type InsertTarget, insertText, isOpencodePage, submitPrompt } from "./insert.js";
import { setupKeyboardShortcut } from "./keyboard.js";
import { transcribe } from "./transcribe.js";
import type { DictationState } from "./types.js";
import { setupUI } from "./ui.js";
let recorder: AudioRecorder | null = null;
let currentState: DictationState = "idle";
let timerInterval: ReturnType<typeof setInterval> | null = null;
let elapsedSeconds = 0;
let ui: ReturnType<typeof setupUI> | null = null;
let currentTarget: InsertTarget = "composer";
function startTimer(): void {
elapsedSeconds = 0;
timerInterval = setInterval(() => {
elapsedSeconds++;
ui?.updateState(currentState, elapsedSeconds);
}, 1000);
}
function stopTimer(): void {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
async function toggleDictation(target: InsertTarget): Promise<void> {
currentTarget = target;
if (currentState === "idle") {
await startRecording();
} else if (currentState === "recording") {
await stopAndTranscribe();
}
}
async function startRecording(): Promise<void> {
try {
recorder = createAudioRecorder();
await recorder.start();
currentState = "recording";
startTimer();
ui?.updateState(currentState, elapsedSeconds);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to access microphone";
ui?.toast(message, true);
recorder = null;
}
}
async function stopAndTranscribe(): Promise<void> {
if (!recorder) {
return;
}
stopTimer();
currentState = "processing";
ui?.updateState(currentState);
try {
const audioBlob = await recorder.stop();
const config = getConfig();
const result = await transcribe(audioBlob, config);
if (result.text) {
const inserted = insertText(result.text, currentTarget);
if (!inserted) {
ui?.toast("Could not find input field", true);
} else if (config.autoSubmit && currentTarget === "composer") {
submitPrompt();
}
}
} catch (err) {
const message = err instanceof Error ? err.message : "Transcription failed";
ui?.toast(message, true);
} finally {
currentState = "idle";
elapsedSeconds = 0;
recorder = null;
ui?.updateState(currentState);
}
}
async function cancelRecording(): Promise<void> {
if (currentState !== "recording" || !recorder) {
return;
}
stopTimer();
try {
await recorder.stop();
} catch {
// discard audio
}
currentState = "idle";
elapsedSeconds = 0;
recorder = null;
ui?.updateState(currentState);
ui?.toast("Recording cancelled");
}
function promptForApiKey(): void {
const key = prompt("Enter your Groq API key (get one free at console.groq.com/keys):", "");
if (key && validateApiKey(key)) {
setConfig({ groqApiKey: key.trim() });
ui?.toast("API key saved!");
} else if (key) {
ui?.toast("Invalid key format. Must start with 'gsk_'", true);
}
}
function promptForModel(): void {
const model = prompt(
"Whisper model (whisper-large-v3 or whisper-large-v3-turbo):",
getConfig().model,
);
if (model && (model === "whisper-large-v3" || model === "whisper-large-v3-turbo")) {
setConfig({ model });
ui?.toast(`Model set to ${model}`);
} else if (model) {
ui?.toast("Invalid model name", true);
}
}
function promptForLanguage(): void {
const lang = prompt(
"Language code (empty for auto-detect, e.g. 'ru', 'en'):",
getConfig().language,
);
if (lang !== null) {
setConfig({ language: lang.trim() });
ui?.toast(lang.trim() ? `Language set to ${lang}` : "Auto-detect enabled");
}
}
function promptForWhisperPrompt(): void {
const text = prompt("Whisper prompt (context for transcription):", getConfig().whisperPrompt);
if (text !== null) {
setConfig({ whisperPrompt: text });
ui?.toast("Whisper prompt updated");
}
}
function toggleAutoSubmit(): void {
const config = getConfig();
setConfig({ autoSubmit: !config.autoSubmit });
ui?.toast(`Auto-submit ${!config.autoSubmit ? "enabled" : "disabled"}`);
}
function checkFirstRun(): void {
if (isFirstRun()) {
setTimeout(() => {
ui?.toast("First run: Set your Groq API key via the Violentmonkey/Tampermonkey menu");
}, 2000);
}
}
function init(): void {
if (!isOpencodePage()) {
setTimeout(init, 1500);
return;
}
ui = setupUI({
onToggle: (target: InsertTarget) => {
void toggleDictation(target);
},
onCancel: () => {
void cancelRecording();
},
});
setupKeyboardShortcut(() => {
void toggleDictation("composer");
});
registerMenuCommands({
onSetKey: promptForApiKey,
onToggleAutoSubmit: toggleAutoSubmit,
onSetModel: promptForModel,
onSetLanguage: promptForLanguage,
onSetPrompt: promptForWhisperPrompt,
});
checkFirstRun();
}
init();