diff --git a/src/index.ts b/src/index.ts index 52072d3..d8db03d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 | null = null; let elapsedSeconds = 0; let ui: ReturnType | 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 { +async function toggleDictation(target: InsertTarget): Promise { + currentTarget = target; if (currentState === "idle") { await startRecording(); } else if (currentState === "recording") { @@ -75,10 +73,10 @@ async function stopAndTranscribe(): Promise { 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 { } } +async function cancelRecording(): Promise { + 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({ diff --git a/src/insert.ts b/src/insert.ts index 1eb3bd9..e96bb29 100644 --- a/src/insert.ts +++ b/src/insert.ts @@ -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(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(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(SUBMIT_SELECTOR); if (!submitButton) { diff --git a/src/ui.ts b/src/ui.ts index 2e05c65..492e064 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -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 = ``; const ICON_STOP = ``; +const ICON_CANCEL = ``; const ICON_SPINNER = ``; 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(`.${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(`.${BUTTON_CLASS}`); + const timer = container.querySelector(`.${TIMER_CLASS}`); + const cancel = container.querySelector(`.${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(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(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, }; diff --git a/tests/insert.test.ts b/tests/insert.test.ts index c40130f..48d0afd 100644 --- a/tests/insert.test.ts +++ b/tests/insert.test.ts @@ -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); + }); +});