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>
This commit is contained in:
Sergey 2026-07-08 09:40:24 +03:00 committed by GitHub
parent 33f2f48ac9
commit 960acd8a7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 252 additions and 92 deletions

View file

@ -1,4 +1,4 @@
import { type AudioRecorder, createAudioRecorder, formatTime } from "./audio.js"; import { type AudioRecorder, createAudioRecorder } from "./audio.js";
import { import {
getConfig, getConfig,
isFirstRun, isFirstRun,
@ -6,7 +6,7 @@ import {
setConfig, setConfig,
validateApiKey, validateApiKey,
} from "./config.js"; } from "./config.js";
import { insertText, isOpencodePage, submitPrompt } from "./insert.js"; import { type InsertTarget, insertText, isOpencodePage, submitPrompt } from "./insert.js";
import { setupKeyboardShortcut } from "./keyboard.js"; import { setupKeyboardShortcut } from "./keyboard.js";
import { transcribe } from "./transcribe.js"; import { transcribe } from "./transcribe.js";
import type { DictationState } from "./types.js"; import type { DictationState } from "./types.js";
@ -17,10 +17,7 @@ let currentState: DictationState = "idle";
let timerInterval: ReturnType<typeof setInterval> | null = null; let timerInterval: ReturnType<typeof setInterval> | null = null;
let elapsedSeconds = 0; let elapsedSeconds = 0;
let ui: ReturnType<typeof setupUI> | null = null; let ui: ReturnType<typeof setupUI> | null = null;
let currentTarget: InsertTarget = "composer";
function getState(): DictationState {
return currentState;
}
function startTimer(): void { function startTimer(): void {
elapsedSeconds = 0; elapsedSeconds = 0;
@ -37,7 +34,8 @@ function stopTimer(): void {
} }
} }
async function toggleDictation(): Promise<void> { async function toggleDictation(target: InsertTarget): Promise<void> {
currentTarget = target;
if (currentState === "idle") { if (currentState === "idle") {
await startRecording(); await startRecording();
} else if (currentState === "recording") { } else if (currentState === "recording") {
@ -75,10 +73,10 @@ async function stopAndTranscribe(): Promise<void> {
const result = await transcribe(audioBlob, config); const result = await transcribe(audioBlob, config);
if (result.text) { if (result.text) {
const inserted = insertText(result.text); const inserted = insertText(result.text, currentTarget);
if (!inserted) { if (!inserted) {
ui?.toast("Could not find input field", true); ui?.toast("Could not find input field", true);
} else if (config.autoSubmit) { } else if (config.autoSubmit && currentTarget === "composer") {
submitPrompt(); submitPrompt();
} }
} }
@ -93,6 +91,26 @@ async function stopAndTranscribe(): Promise<void> {
} }
} }
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 { function promptForApiKey(): void {
const key = prompt("Enter your Groq API key (get one free at console.groq.com/keys):", ""); const key = prompt("Enter your Groq API key (get one free at console.groq.com/keys):", "");
if (key && validateApiKey(key)) { if (key && validateApiKey(key)) {
@ -155,10 +173,17 @@ function init(): void {
return; return;
} }
ui = setupUI(toggleDictation, getState); ui = setupUI({
onToggle: (target: InsertTarget) => {
void toggleDictation(target);
},
onCancel: () => {
void cancelRecording();
},
});
setupKeyboardShortcut(() => { setupKeyboardShortcut(() => {
void toggleDictation(); void toggleDictation("composer");
}); });
registerMenuCommands({ registerMenuCommands({

View file

@ -1,11 +1,21 @@
const PROMPT_INPUT_SELECTOR = '[data-component="prompt-input"]'; const PROMPT_INPUT_SELECTOR = '[data-component="prompt-input"]';
const SUBMIT_SELECTOR = '[data-action="prompt-submit"]'; const SUBMIT_SELECTOR = '[data-action="prompt-submit"]';
const QUESTION_INPUT_SELECTOR = '[data-slot="question-custom-input"]';
export type InsertTarget = "composer" | "question";
export function isOpencodePage(): boolean { export function isOpencodePage(): boolean {
return document.querySelector(PROMPT_INPUT_SELECTOR) !== null; return document.querySelector(PROMPT_INPUT_SELECTOR) !== null;
} }
export function insertText(text: string): boolean { export function insertText(text: string, target: InsertTarget = "composer"): boolean {
if (target === "question") {
return insertIntoTextarea(text);
}
return insertIntoContenteditable(text);
}
function insertIntoContenteditable(text: string): boolean {
const input = document.querySelector<HTMLElement>(PROMPT_INPUT_SELECTOR); const input = document.querySelector<HTMLElement>(PROMPT_INPUT_SELECTOR);
if (!input) { if (!input) {
return false; return false;
@ -26,6 +36,24 @@ export function insertText(text: string): boolean {
return true; return true;
} }
function insertIntoTextarea(text: string): boolean {
const textarea = document.querySelector<HTMLTextAreaElement>(QUESTION_INPUT_SELECTOR);
if (!textarea) {
return false;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newValue = textarea.value.substring(0, start) + text + textarea.value.substring(end);
textarea.value = newValue;
textarea.selectionStart = textarea.selectionEnd = start + text.length;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
textarea.focus();
return true;
}
export function submitPrompt(): boolean { export function submitPrompt(): boolean {
const submitButton = document.querySelector<HTMLElement>(SUBMIT_SELECTOR); const submitButton = document.querySelector<HTMLElement>(SUBMIT_SELECTOR);
if (!submitButton) { if (!submitButton) {

229
src/ui.ts
View file

@ -1,20 +1,24 @@
import type { InsertTarget } from "./insert.js";
import type { DictationState } from "./types.js"; import type { DictationState } from "./types.js";
const BUTTON_ID = "opencode-voice-dictation-btn"; const BUTTON_CLASS = "ocvd-btn";
const CONTAINER_ID = "opencode-voice-dictation-container"; const CONTAINER_CLASS = "ocvd-container";
const TIMER_ID = "opencode-voice-dictation-timer"; const TIMER_CLASS = "ocvd-timer";
const CANCEL_CLASS = "ocvd-cancel";
const COMPOSER_SELECTORS = [ const COMPOSER_SELECTORS = [
'[data-component="session-composer"]', '[data-component="session-composer"]',
'[data-component="session-new-composer"]', '[data-component="session-new-composer"]',
]; ];
const QUESTION_INPUT_SELECTOR = '[data-slot="question-custom-input"]';
const ICON_MIC = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>`; const ICON_MIC = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>`;
const ICON_STOP = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="3"/></svg>`; const ICON_STOP = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="3"/></svg>`;
const ICON_CANCEL = `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M18.3 5.71L12 12l6.3 6.29-1.42 1.42L10.58 13.4 4.29 19.71 2.87 18.3 9.16 12 2.87 5.71 4.29 4.29 10.58 10.58l6.29-6.29z"/></svg>`;
const ICON_SPINNER = `<svg class="ocvd-spin" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8z"/></svg>`; const ICON_SPINNER = `<svg class="ocvd-spin" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8z"/></svg>`;
function createButtonStyle(): string { function createButtonStyle(): string {
return ` return `
#${CONTAINER_ID} { .${CONTAINER_CLASS} {
position: absolute !important; position: absolute !important;
top: 8px; top: 8px;
right: 8px; right: 8px;
@ -24,10 +28,10 @@ function createButtonStyle(): string {
gap: 6px; gap: 6px;
pointer-events: none; pointer-events: none;
} }
#${CONTAINER_ID} > * { .${CONTAINER_CLASS} > * {
pointer-events: auto; pointer-events: auto;
} }
#${BUTTON_ID} { .${BUTTON_CLASS} {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -42,43 +46,65 @@ function createButtonStyle(): string {
padding: 0; padding: 0;
flex-shrink: 0; flex-shrink: 0;
} }
#${BUTTON_ID}:hover { .${BUTTON_CLASS}:hover {
background: var(--color-bg-hover, rgba(128, 128, 128, 0.25)); background: var(--color-bg-hover, rgba(128, 128, 128, 0.25));
color: var(--color-text-primary, #fff); color: var(--color-text-primary, #fff);
} }
#${BUTTON_ID}.recording { .${BUTTON_CLASS}.recording {
background: #e53935; background: #e53935;
color: #fff; color: #fff;
animation: ocvd-pulse 1.5s ease-in-out infinite; animation: ocvd-pulse 1.5s ease-in-out infinite;
} }
#${BUTTON_ID}.processing { .${BUTTON_CLASS}.processing {
background: var(--color-accent, #4a9eff); background: var(--color-accent, #4a9eff);
color: #fff; color: #fff;
pointer-events: none; pointer-events: none;
opacity: 0.8; opacity: 0.8;
} }
#${TIMER_ID} { .${CANCEL_CLASS} {
display: none;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(128, 128, 128, 0.2);
color: var(--color-text-secondary, #aaa);
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
flex-shrink: 0;
}
.${CANCEL_CLASS}:hover {
background: rgba(128, 128, 128, 0.4);
color: #fff;
}
.${CANCEL_CLASS}.visible {
display: flex;
}
.${TIMER_CLASS} {
display: none; display: none;
font-family: monospace; font-family: monospace;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #e53935; color: #fff;
background: rgba(229, 57, 53, 0.1); background: #e53935;
padding: 3px 10px; padding: 4px 10px;
border-radius: 12px; border-radius: 12px;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
} }
#${TIMER_ID}.visible { .${TIMER_CLASS}.visible {
display: inline-flex; display: inline-flex;
} }
#${TIMER_ID}::before { .${TIMER_CLASS}::before {
content: ""; content: "";
width: 7px; width: 7px;
height: 7px; height: 7px;
background: #e53935; background: #fff;
border-radius: 50%; border-radius: 50%;
animation: ocvd-blink 1s ease-in-out infinite; animation: ocvd-blink 1s ease-in-out infinite;
flex-shrink: 0; flex-shrink: 0;
@ -150,61 +176,75 @@ function showToast(message: string, isError = false): void {
}, 4000); }, 4000);
} }
function formatTimer(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
function createContainer(): HTMLDivElement { function createContainer(): HTMLDivElement {
const container = document.createElement("div"); const container = document.createElement("div");
container.id = CONTAINER_ID; container.className = CONTAINER_CLASS;
const cancel = document.createElement("button");
cancel.className = CANCEL_CLASS;
cancel.type = "button";
cancel.title = "Cancel recording";
cancel.innerHTML = ICON_CANCEL;
const timer = document.createElement("span"); const timer = document.createElement("span");
timer.id = TIMER_ID; timer.className = TIMER_CLASS;
timer.textContent = "00:00"; timer.textContent = "00:00";
const button = document.createElement("button"); const button = document.createElement("button");
button.id = BUTTON_ID; button.className = BUTTON_CLASS;
button.type = "button"; button.type = "button";
button.title = "Voice Dictation (Ctrl+Space)"; button.title = "Voice Dictation (Ctrl+Space)";
button.innerHTML = ICON_MIC; button.innerHTML = ICON_MIC;
container.appendChild(cancel);
container.appendChild(timer); container.appendChild(timer);
container.appendChild(button); container.appendChild(button);
return container; return container;
} }
function updateButtonState( function updateAllButtonStates(state: DictationState, elapsedSeconds = 0): void {
button: HTMLButtonElement, const containers = document.querySelectorAll<HTMLElement>(`.${CONTAINER_CLASS}`);
timer: HTMLElement,
state: DictationState,
elapsedSeconds = 0,
): void {
button.classList.remove("recording", "processing");
timer.classList.remove("visible");
switch (state) { for (const container of containers) {
case "idle": const button = container.querySelector<HTMLElement>(`.${BUTTON_CLASS}`);
button.innerHTML = ICON_MIC; const timer = container.querySelector<HTMLElement>(`.${TIMER_CLASS}`);
button.title = "Voice Dictation (Ctrl+Space)"; const cancel = container.querySelector<HTMLElement>(`.${CANCEL_CLASS}`);
break;
case "recording": if (!button || !timer || !cancel) continue;
button.classList.add("recording");
button.innerHTML = ICON_STOP; button.classList.remove("recording", "processing");
button.title = "Stop recording"; timer.classList.remove("visible");
timer.textContent = formatTimer(elapsedSeconds); cancel.classList.remove("visible");
timer.classList.add("visible");
break; switch (state) {
case "processing": case "idle":
button.classList.add("processing"); button.innerHTML = ICON_MIC;
button.innerHTML = ICON_SPINNER; button.title = "Voice Dictation (Ctrl+Space)";
button.title = "Transcribing..."; break;
break; case "recording":
button.classList.add("recording");
button.innerHTML = ICON_STOP;
button.title = "Stop recording";
timer.textContent = formatTimer(elapsedSeconds);
timer.classList.add("visible");
cancel.classList.add("visible");
break;
case "processing":
button.classList.add("processing");
button.innerHTML = ICON_SPINNER;
button.title = "Transcribing...";
break;
}
} }
} }
function formatTimer(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
function findComposer(): HTMLElement | null { function findComposer(): HTMLElement | null {
for (const selector of COMPOSER_SELECTORS) { for (const selector of COMPOSER_SELECTORS) {
const el = document.querySelector<HTMLElement>(selector); const el = document.querySelector<HTMLElement>(selector);
@ -215,48 +255,77 @@ function findComposer(): HTMLElement | null {
return null; return null;
} }
function isContainerInjected(): boolean { function ensureRelative(el: HTMLElement): void {
return document.getElementById(CONTAINER_ID) !== null; if (window.getComputedStyle(el).position === "static") {
el.style.position = "relative";
}
} }
function injectButton(onToggle: () => void): void { function injectIntoElement(
if (isContainerInjected()) { parent: HTMLElement,
return; onToggle: (target: InsertTarget) => void,
} onCancel: () => void,
target: InsertTarget,
const composer = findComposer(); ): HTMLElement | null {
if (!composer) { const existing = parent.querySelector(`.${CONTAINER_CLASS}`);
return; if (existing) {
return null;
} }
injectStyles(); injectStyles();
ensureRelative(parent);
const computedPosition = window.getComputedStyle(composer).position;
if (computedPosition === "static") {
composer.style.position = "relative";
}
const container = createContainer(); const container = createContainer();
const button = container.querySelector(`#${BUTTON_ID}`) as HTMLButtonElement;
const button = container.querySelector(`.${BUTTON_CLASS}`) as HTMLButtonElement;
button.addEventListener("click", (e) => { button.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onToggle(); onToggle(target);
}); });
composer.appendChild(container); const cancel = container.querySelector(`.${CANCEL_CLASS}`) as HTMLButtonElement;
cancel.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onCancel();
});
parent.appendChild(container);
return container;
} }
export function setupUI( function injectIntoComposer(onToggle: (target: InsertTarget) => void, onCancel: () => void): void {
onToggle: () => void, const composer = findComposer();
_getState: () => DictationState, if (composer) {
): { injectIntoElement(composer, onToggle, onCancel, "composer");
}
}
function injectIntoQuestionPrompts(
onToggle: (target: InsertTarget) => void,
onCancel: () => void,
): void {
const textareas = document.querySelectorAll<HTMLTextAreaElement>(QUESTION_INPUT_SELECTOR);
for (const textarea of textareas) {
const parent = textarea.parentElement;
if (parent) {
injectIntoElement(parent, onToggle, onCancel, "question");
}
}
}
export function setupUI(callbacks: {
onToggle: (target: InsertTarget) => void;
onCancel: () => void;
}): {
inject: () => void; inject: () => void;
updateState: (state: DictationState, elapsedSeconds?: number) => void; updateState: (state: DictationState, elapsedSeconds?: number) => void;
toast: (message: string, isError?: boolean) => void; toast: (message: string, isError?: boolean) => void;
} { } {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
injectButton(onToggle); injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
}); });
observer.observe(document.body, { observer.observe(document.body, {
@ -264,16 +333,16 @@ export function setupUI(
subtree: true, subtree: true,
}); });
injectButton(onToggle); injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
return { return {
inject: () => injectButton(onToggle), inject: () => {
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
},
updateState: (state: DictationState, elapsedSeconds = 0) => { updateState: (state: DictationState, elapsedSeconds = 0) => {
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null; updateAllButtonStates(state, elapsedSeconds);
const timer = document.getElementById(TIMER_ID);
if (button && timer) {
updateButtonState(button, timer, state, elapsedSeconds);
}
}, },
toast: showToast, toast: showToast,
}; };

View file

@ -73,3 +73,41 @@ describe("submitPrompt", () => {
expect(clicked).toBe(true); expect(clicked).toBe(true);
}); });
}); });
describe("insertText into question textarea", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("should return false when question textarea not found", () => {
expect(insertText("hello", "question")).toBe(false);
});
it("should insert text into textarea at cursor position", () => {
const textarea = document.createElement("textarea");
textarea.setAttribute("data-slot", "question-custom-input");
textarea.value = "hello world";
textarea.selectionStart = 6;
textarea.selectionEnd = 11;
document.body.appendChild(textarea);
const result = insertText("there", "question");
expect(result).toBe(true);
expect(textarea.value).toBe("hello there");
expect(textarea.selectionStart).toBe(11);
});
it("should dispatch input event", () => {
let eventCount = 0;
const textarea = document.createElement("textarea");
textarea.setAttribute("data-slot", "question-custom-input");
textarea.value = "";
textarea.addEventListener("input", () => {
eventCount++;
});
document.body.appendChild(textarea);
insertText("test", "question");
expect(eventCount).toBeGreaterThanOrEqual(1);
});
});