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

View file

@ -1,11 +1,21 @@
const PROMPT_INPUT_SELECTOR = '[data-component="prompt-input"]';
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 {
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);
if (!input) {
return false;
@ -26,6 +36,24 @@ export function insertText(text: string): boolean {
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 {
const submitButton = document.querySelector<HTMLElement>(SUBMIT_SELECTOR);
if (!submitButton) {

229
src/ui.ts
View file

@ -1,20 +1,24 @@
import type { InsertTarget } from "./insert.js";
import type { DictationState } from "./types.js";
const BUTTON_ID = "opencode-voice-dictation-btn";
const CONTAINER_ID = "opencode-voice-dictation-container";
const TIMER_ID = "opencode-voice-dictation-timer";
const BUTTON_CLASS = "ocvd-btn";
const CONTAINER_CLASS = "ocvd-container";
const TIMER_CLASS = "ocvd-timer";
const CANCEL_CLASS = "ocvd-cancel";
const COMPOSER_SELECTORS = [
'[data-component="session-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_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>`;
function createButtonStyle(): string {
return `
#${CONTAINER_ID} {
.${CONTAINER_CLASS} {
position: absolute !important;
top: 8px;
right: 8px;
@ -24,10 +28,10 @@ function createButtonStyle(): string {
gap: 6px;
pointer-events: none;
}
#${CONTAINER_ID} > * {
.${CONTAINER_CLASS} > * {
pointer-events: auto;
}
#${BUTTON_ID} {
.${BUTTON_CLASS} {
display: flex;
align-items: center;
justify-content: center;
@ -42,43 +46,65 @@ function createButtonStyle(): string {
padding: 0;
flex-shrink: 0;
}
#${BUTTON_ID}:hover {
.${BUTTON_CLASS}:hover {
background: var(--color-bg-hover, rgba(128, 128, 128, 0.25));
color: var(--color-text-primary, #fff);
}
#${BUTTON_ID}.recording {
.${BUTTON_CLASS}.recording {
background: #e53935;
color: #fff;
animation: ocvd-pulse 1.5s ease-in-out infinite;
}
#${BUTTON_ID}.processing {
.${BUTTON_CLASS}.processing {
background: var(--color-accent, #4a9eff);
color: #fff;
pointer-events: none;
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;
font-family: monospace;
font-size: 13px;
font-weight: 600;
color: #e53935;
background: rgba(229, 57, 53, 0.1);
padding: 3px 10px;
color: #fff;
background: #e53935;
padding: 4px 10px;
border-radius: 12px;
align-items: center;
gap: 5px;
line-height: 1;
white-space: nowrap;
}
#${TIMER_ID}.visible {
.${TIMER_CLASS}.visible {
display: inline-flex;
}
#${TIMER_ID}::before {
.${TIMER_CLASS}::before {
content: "";
width: 7px;
height: 7px;
background: #e53935;
background: #fff;
border-radius: 50%;
animation: ocvd-blink 1s ease-in-out infinite;
flex-shrink: 0;
@ -150,61 +176,75 @@ function showToast(message: string, isError = false): void {
}, 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 {
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");
timer.id = TIMER_ID;
timer.className = TIMER_CLASS;
timer.textContent = "00:00";
const button = document.createElement("button");
button.id = BUTTON_ID;
button.className = BUTTON_CLASS;
button.type = "button";
button.title = "Voice Dictation (Ctrl+Space)";
button.innerHTML = ICON_MIC;
container.appendChild(cancel);
container.appendChild(timer);
container.appendChild(button);
return container;
}
function updateButtonState(
button: HTMLButtonElement,
timer: HTMLElement,
state: DictationState,
elapsedSeconds = 0,
): void {
button.classList.remove("recording", "processing");
timer.classList.remove("visible");
function updateAllButtonStates(state: DictationState, elapsedSeconds = 0): void {
const containers = document.querySelectorAll<HTMLElement>(`.${CONTAINER_CLASS}`);
switch (state) {
case "idle":
button.innerHTML = ICON_MIC;
button.title = "Voice Dictation (Ctrl+Space)";
break;
case "recording":
button.classList.add("recording");
button.innerHTML = ICON_STOP;
button.title = "Stop recording";
timer.textContent = formatTimer(elapsedSeconds);
timer.classList.add("visible");
break;
case "processing":
button.classList.add("processing");
button.innerHTML = ICON_SPINNER;
button.title = "Transcribing...";
break;
for (const container of containers) {
const button = container.querySelector<HTMLElement>(`.${BUTTON_CLASS}`);
const timer = container.querySelector<HTMLElement>(`.${TIMER_CLASS}`);
const cancel = container.querySelector<HTMLElement>(`.${CANCEL_CLASS}`);
if (!button || !timer || !cancel) continue;
button.classList.remove("recording", "processing");
timer.classList.remove("visible");
cancel.classList.remove("visible");
switch (state) {
case "idle":
button.innerHTML = ICON_MIC;
button.title = "Voice Dictation (Ctrl+Space)";
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 {
for (const selector of COMPOSER_SELECTORS) {
const el = document.querySelector<HTMLElement>(selector);
@ -215,48 +255,77 @@ function findComposer(): HTMLElement | null {
return null;
}
function isContainerInjected(): boolean {
return document.getElementById(CONTAINER_ID) !== null;
function ensureRelative(el: HTMLElement): void {
if (window.getComputedStyle(el).position === "static") {
el.style.position = "relative";
}
}
function injectButton(onToggle: () => void): void {
if (isContainerInjected()) {
return;
}
const composer = findComposer();
if (!composer) {
return;
function injectIntoElement(
parent: HTMLElement,
onToggle: (target: InsertTarget) => void,
onCancel: () => void,
target: InsertTarget,
): HTMLElement | null {
const existing = parent.querySelector(`.${CONTAINER_CLASS}`);
if (existing) {
return null;
}
injectStyles();
const computedPosition = window.getComputedStyle(composer).position;
if (computedPosition === "static") {
composer.style.position = "relative";
}
ensureRelative(parent);
const container = createContainer();
const button = container.querySelector(`#${BUTTON_ID}`) as HTMLButtonElement;
const button = container.querySelector(`.${BUTTON_CLASS}`) as HTMLButtonElement;
button.addEventListener("click", (e) => {
e.preventDefault();
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(
onToggle: () => void,
_getState: () => DictationState,
): {
function injectIntoComposer(onToggle: (target: InsertTarget) => void, onCancel: () => void): void {
const composer = findComposer();
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;
updateState: (state: DictationState, elapsedSeconds?: number) => void;
toast: (message: string, isError?: boolean) => void;
} {
const observer = new MutationObserver(() => {
injectButton(onToggle);
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
});
observer.observe(document.body, {
@ -264,16 +333,16 @@ export function setupUI(
subtree: true,
});
injectButton(onToggle);
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
return {
inject: () => injectButton(onToggle),
inject: () => {
injectIntoComposer(callbacks.onToggle, callbacks.onCancel);
injectIntoQuestionPrompts(callbacks.onToggle, callbacks.onCancel);
},
updateState: (state: DictationState, elapsedSeconds = 0) => {
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null;
const timer = document.getElementById(TIMER_ID);
if (button && timer) {
updateButtonState(button, timer, state, elapsedSeconds);
}
updateAllButtonStates(state, elapsedSeconds);
},
toast: showToast,
};

View file

@ -73,3 +73,41 @@ describe("submitPrompt", () => {
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);
});
});