diff --git a/README.md b/README.md
index f448224..d3bcf73 100644
--- a/README.md
+++ b/README.md
@@ -1,268 +1,110 @@
# OpenCode Voice Dictation / Голосовая диктовка для OpenCode
[](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml)
+[](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/release.yml)
[](https://opensource.org/licenses/MIT)
-Voice dictation for [OpenCode](https://opencode.ai) web interface using OpenAI Whisper via Groq API. Works on both desktop and mobile browsers through a Tampermonkey/Violentmonkey userscript.
-
-Голосовая диктовка для веб-интерфейса [OpenCode](https://opencode.ai) с использованием OpenAI Whisper через Groq API. Работает как на десктопных, так и на мобильных браузерах через юзерскрипт Tampermonkey/Violentmonkey.
-
----
-
-## English
-
-### Features
-
-- **Whisper large-v3** transcription via Groq API (same quality as Cursor)
-- **Tap-to-toggle** recording (tap to start, tap to stop)
-- **Noise suppression** + echo cancellation (great for outdoor use)
-- **Recording timer** (MM:SS display)
-- **Ctrl+Space** keyboard shortcut on desktop
-- **Custom Whisper prompt** for software development context
-- **Auto-detect language** (Russian + English technical terms)
-- **Auto-submit** option (configurable)
-- **No backend needed** — API calls go directly from browser via `GM_xmlhttpRequest`
-
-### Requirements
-
-| Component | Requirement |
-|-----------|-------------|
-| OpenCode | Web interface running (`opencode web`) |
-| Groq API key | Free at [console.groq.com/keys](https://console.groq.com/keys) |
-| PC Browser | Vivaldi / Chrome / Firefox + Violentmonkey or Tampermonkey |
-| Mobile Browser | Firefox for Android + Tampermonkey |
-
-### Compatibility
-
-| Platform | Browser | Userscript Manager | Status |
-|----------|---------|-------------------|--------|
-| Desktop (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Supported |
-| Desktop | Chrome | Tampermonkey | ✅ Supported |
-| Desktop | Firefox | Tampermonkey | ✅ Supported |
-| Android | Firefox | Tampermonkey | ✅ Supported |
-| Android | Vivaldi | — | ❌ No extension support |
-| Android | Chrome | — | ❌ No extension support |
-
-### Install
-
-> **One-click install**: Open this link in your browser with Violentmonkey/Tampermonkey installed:
->
-> **[Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)**
->
-> Your userscript manager will intercept the download and show an installation dialog.
-
-#### Desktop (Vivaldi + Violentmonkey)
-
-1. Install [Violentmonkey](https://violentmonkey.github.io/) extension in Vivaldi
-2. Click the [Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) link
-3. Confirm installation in the Violentmonkey dialog
-
-#### Android (Firefox + Tampermonkey)
-
-1. Install [Firefox for Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) from Play Store
-2. Install [Tampermonkey](https://addons.mozilla.org/en-US/android/addon/tampermonkey/) from Firefox Add-ons
-3. In Firefox, open the [Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) link
-4. Confirm installation in the Tampermonkey dialog
-
-#### Manual install (from GitHub Releases)
-
-1. Go to [Releases](https://github.com/slaid098/opencode-voice-dictation/releases)
-2. Download `opencode-voice-dictation.user.js` from the latest release
-3. Open Violentmonkey/Tampermonkey dashboard → **+** → paste the file contents → Save
-
-### Setup
-
-1. Get a free Groq API key at [console.groq.com/keys](https://console.groq.com/keys)
-2. Open any OpenCode web page
-3. Open Violentmonkey/Tampermonkey menu → **"Set Groq API Key"**
-4. Paste your key (`gsk_...`)
-
-### Auto-Update
-
-The script updates automatically. No action needed.
-
-- `@updateURL` checks for new versions periodically (every 24h in Tampermonkey, every 7 days in Violentmonkey)
-- Only a tiny `.meta.js` (~1KB) is downloaded to check the version
-- If a new version is found, the full `.user.js` (~18KB) is downloaded and installed
-- Updates are published automatically by CI when code is pushed to `main`
-
-### Usage
-
-1. Open OpenCode web in your browser
-2. A **microphone button** appears next to the chat input
-3. **Tap** the button to start recording (red pulse + timer)
-4. **Tap** again to stop — audio is transcribed and inserted into the input
-5. (Desktop) Press **Ctrl+Space** as an alternative to clicking
-
-### Configuration
-
-All settings available via the Violentmonkey/Tampermonkey menu:
-
-| Menu Item | Description |
-|-----------|-------------|
-| Set Groq API Key | Enter your `gsk_...` key |
-| Toggle Auto-Submit | Auto-send message after transcription |
-| Set Whisper Model | `whisper-large-v3` (default) or `whisper-large-v3-turbo` (faster) |
-| Set Language | Language code (e.g., `ru`, `en`) or empty for auto-detect |
-| Set Whisper Prompt | Context prompt for transcription accuracy |
-
---
## Русский
-### Возможности
+Голосовая диктовка для [OpenCode](https://opencode.ai) web через Whisper (Groq API). Качество как в Cursor. Работает на ПК и телефоне.
-- **Whisper large-v3** транскрипция через Groq API (качество как в Cursor)
-- **Tap-to-toggle** запись (тапнул — начал, тапнул — стоп)
-- **Шумоподавление** + эхоподавление (для записи на улице)
-- **Таймер записи** (формат MM:SS)
-- **Ctrl+Space** горячая клавиша на ПК
-- **Кастомный Whisper prompt** для контекста разработки
-- **Авто-определение языка** (русский + английские технические термины)
-- **Авто-отправка** опционально (настраивается)
-- **Без бэкенда** — запросы идут напрямую из браузера через `GM_xmlhttpRequest`
+### Установка
-### Требования
+> **[Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** — открой ссылку в браузере с Violentmonkey/Tampermonkey и подтверди установку.
-| Компонент | Требование |
-|-----------|------------|
-| OpenCode | Запущенный веб-интерфейс (`opencode web`) |
-| Groq API ключ | Бесплатно на [console.groq.com/keys](https://console.groq.com/keys) |
-| Браузер ПК | Vivaldi / Chrome / Firefox + Violentmonkey или Tampermonkey |
-| Браузер телефон | Firefox для Android + Tampermonkey |
+Или скачай с [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) и импортируй вручную.
### Совместимость
| Платформа | Браузер | Менеджер скриптов | Статус |
|-----------|---------|-------------------|--------|
-| Десктоп (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Поддерживается |
-| Десктоп | Chrome | Tampermonkey | ✅ Поддерживается |
-| Десктоп | Firefox | Tampermonkey | ✅ Поддерживается |
-| Android | Firefox | Tampermonkey | ✅ Поддерживается |
-| Android | Vivaldi | — | ❌ Нет поддержки расширений |
-| Android | Chrome | — | ❌ Нет поддержки расширений |
-
-### Установка
-
-> **Установка в один клик**: Откройте эту ссылку в браузере с установленным Violentmonkey/Tampermonkey:
->
-> **[Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)**
->
-> Менеджер скриптов перехватит загрузку и покажет диалог установки.
-
-#### ПК (Vivaldi + Violentmonkey)
-
-1. Установите расширение [Violentmonkey](https://violentmonkey.github.io/) в Vivaldi
-2. Нажмите на ссылку [Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)
-3. Подтвердите установку в диалоге Violentmonkey
-
-#### Android (Firefox + Tampermonkey)
-
-1. Установите [Firefox для Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) из Play Store
-2. Установите [Tampermonkey](https://addons.mozilla.org/ru/android/addon/tampermonkey/) из дополнений Firefox
-3. В Firefox откройте ссылку [Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)
-4. Подтвердите установку в диалоге Tampermonkey
-
-#### Ручная установка (из GitHub Releases)
-
-1. Перейдите на страницу [Releases](https://github.com/slaid098/opencode-voice-dictation/releases)
-2. Скачайте `opencode-voice-dictation.user.js` из последнего релиза
-3. Откройте панель Violentmonkey/Tampermonkey → **+** → вставьте содержимое файла → Сохранить
+| ПК | Vivaldi | Violentmonkey | ✅ |
+| ПК | Chrome | Tampermonkey | ✅ |
+| ПК | Firefox | Tampermonkey | ✅ |
+| Android | Firefox | Tampermonkey | ✅ |
+| Android | Vivaldi | — | ❌ Нет расширений |
+| Android | Chrome | — | ❌ Нет расширений |
### Настройка
-1. Получите бесплатный ключ Groq на [console.groq.com/keys](https://console.groq.com/keys)
-2. Откройте любую страницу OpenCode web
-3. Откройте меню Violentmonkey/Tampermonkey → **"Set Groq API Key"**
-4. Вставьте ваш ключ (`gsk_...`)
-
-### Автообновление
-
-Скрипт обновляется автоматически. Ничего делать не нужно.
-
-- `@updateURL` периодически проверяет новые версии (каждые 24ч в Tampermonkey, каждые 7 дней в Violentmonkey)
-- Скачивается только маленький `.meta.js` (~1KB) для проверки версии
-- Если найдена новая версия — скачивается полный `.user.js` (~18KB) и устанавливается
-- Обновления публикуются автоматически CI при пуше в `main`
+1. Получи бесплатный ключ на [console.groq.com/keys](https://console.groq.com/keys)
+2. Открой OpenCode web
+3. Меню Violentmonkey/Tampermonkey → **Set Groq API Key** → вставь `gsk_...`
### Использование
-1. Откройте OpenCode web в браузере
-2. Рядом с полем ввода появится **кнопка микрофона**
-3. **Тапните** кнопку для начала записи (красная пульсация + таймер)
-4. **Тапните** снова для остановки — аудио транскрибируется и вставляется в поле ввода
-5. (На ПК) Нажмите **Ctrl+Space** как альтернативу клику
+1. Нажми кнопку микрофона в правом верхнем углу поля ввода
+2. Говори (виден таймер записи)
+3. Нажми снова — текст появится в поле ввода
+4. На ПК: **Ctrl+Space** — горячая клавиша
+
+### Автообновление
+
+Скрипт обновляется сам. При пуше в `main` CI собирает новую версию и публикует в ветку `dist`. Пользователи получают обновление автоматически.
### Настройки
-Все параметры доступны через меню Violentmonkey/Tampermonkey:
-
| Пункт меню | Описание |
|------------|----------|
-| Set Groq API Key | Ввести ключ `gsk_...` |
-| Toggle Auto-Submit | Автоматическая отправка после транскрипции |
-| Set Whisper Model | `whisper-large-v3` (по умолчанию) или `whisper-large-v3-turbo` (быстрее) |
-| Set Language | Код языка (например, `ru`, `en`) или пусто для авто-определения |
-| Set Whisper Prompt | Контекстный промпт для точности транскрипции |
+| Set Groq API Key | Ключ `gsk_...` |
+| Toggle Auto-Submit | Отправлять после транскрипции |
+| Set Whisper Model | `whisper-large-v3` или `whisper-large-v3-turbo` |
+| Set Language | `ru`, `en` или пусто (авто) |
+| Set Whisper Prompt | Контекст для точности |
---
-## Development / Разработка
+## English
-### Prerequisites
+Voice dictation for [OpenCode](https://opencode.ai) web via Whisper (Groq API). Cursor-quality transcription. Works on desktop and mobile.
-- Node.js 22+
-- npm
+### Install
+
+> **[Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** — open this link in a browser with Violentmonkey/Tampermonkey and confirm installation.
+
+Or download from [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) and import manually.
+
+### Compatibility
+
+| Platform | Browser | Userscript Manager | Status |
+|----------|---------|-------------------|--------|
+| Desktop | Vivaldi | Violentmonkey | ✅ |
+| Desktop | Chrome | Tampermonkey | ✅ |
+| Desktop | Firefox | Tampermonkey | ✅ |
+| Android | Firefox | Tampermonkey | ✅ |
+| Android | Vivaldi | — | ❌ No extensions |
+| Android | Chrome | — | ❌ No extensions |
### Setup
-```bash
-git clone https://github.com/slaid098/opencode-voice-dictation.git
-cd opencode-voice-dictation
-npm install
-```
+1. Get a free API key at [console.groq.com/keys](https://console.groq.com/keys)
+2. Open OpenCode web
+3. Violentmonkey/Tampermonkey menu → **Set Groq API Key** → paste `gsk_...`
-### Commands
+### Usage
-| Command | Description |
-|---------|-------------|
-| `npm run dev` | Development mode with auto-reload |
-| `npm run build` | Build to `dist/opencode-voice-dictation.user.js` |
-| `npm run lint` | Lint with Biome |
-| `npm run format` | Format with Biome |
-| `npm run typecheck` | TypeScript type checking |
-| `npm run test` | Run tests with coverage (60% threshold) |
-| `npm run knip` | Find unused code |
+1. Click the mic button in the top-right corner of the input area
+2. Speak (recording timer is displayed)
+3. Click again — transcribed text appears in the input
+4. Desktop: **Ctrl+Space** shortcut
-### Project Structure
+### Auto-Update
-```
-src/
-├── index.ts # Entry point — orchestrates all modules
-├── config.ts # GM storage, defaults, validation, menu commands
-├── audio.ts # MediaRecorder, getUserMedia, audio constraints
-├── transcribe.ts # Groq API call, FormData builder, error parsing
-├── insert.ts # Contenteditable text insertion + InputEvent dispatch
-├── ui.ts # Button injection, MutationObserver, toast notifications
-├── keyboard.ts # Ctrl+Space keyboard shortcut handler
-└── types.ts # Shared TypeScript types
+The script updates automatically. When code is pushed to `main`, CI builds and publishes to the `dist` branch. Users receive updates automatically.
-tests/
-├── transcribe.test.ts # FormData format, error parsing (5 tests)
-├── insert.test.ts # Contenteditable insertion, event dispatch (5 tests)
-├── config.test.ts # Defaults, key validation, state (9 tests)
-├── audio.test.ts # Time formatting (5 tests)
-└── __mocks__/$/ # Mock for GM_api functions
-```
+### Settings
-### CI/CD
+| Menu Item | Description |
+|-----------|-------------|
+| Set Groq API Key | API key `gsk_...` |
+| Toggle Auto-Submit | Auto-send after transcription |
+| Set Whisper Model | `whisper-large-v3` or `whisper-large-v3-turbo` |
+| Set Language | `ru`, `en`, or empty (auto-detect) |
+| Set Whisper Prompt | Context for accuracy |
-- **Lint**: Biome (recommended rules, `noExplicitAny: error`)
-- **Typecheck**: `tsc --noEmit`
-- **Knip**: Dead code detection
-- **Test**: Vitest + happy-dom, 60% coverage threshold
-- **Build**: Vite → single `.user.js` file
-- **Dependabot**: Weekly npm + GitHub Actions updates
+---
## License
diff --git a/src/ui.ts b/src/ui.ts
index 9ac34bd..2e05c65 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -1,56 +1,103 @@
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 COMPOSER_SELECTORS = [
'[data-component="session-composer"]',
'[data-component="session-new-composer"]',
];
+const ICON_MIC = ``;
+const ICON_STOP = ``;
+const ICON_SPINNER = ``;
+
function createButtonStyle(): string {
return `
+ #${CONTAINER_ID} {
+ position: absolute !important;
+ top: 8px;
+ right: 8px;
+ z-index: 999998;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ pointer-events: none;
+ }
+ #${CONTAINER_ID} > * {
+ pointer-events: auto;
+ }
#${BUTTON_ID} {
- display: inline-flex;
+ display: flex;
align-items: center;
justify-content: center;
- width: 36px;
- height: 36px;
- border-radius: 8px;
- border: 1px solid var(--color-border, #333);
- background: var(--color-bg-secondary, #1a1a1a);
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: none;
+ background: var(--color-bg-tertiary, rgba(128, 128, 128, 0.15));
color: var(--color-text-secondary, #888);
cursor: pointer;
transition: all 0.2s ease;
- flex-shrink: 0;
- font-size: 18px;
padding: 0;
- gap: 4px;
+ flex-shrink: 0;
}
#${BUTTON_ID}:hover {
- background: var(--color-bg-tertiary, #2a2a2a);
+ background: var(--color-bg-hover, rgba(128, 128, 128, 0.25));
color: var(--color-text-primary, #fff);
}
#${BUTTON_ID}.recording {
background: #e53935;
color: #fff;
- border-color: #e53935;
- animation: pulse 1.5s ease-in-out infinite;
+ animation: ocvd-pulse 1.5s ease-in-out infinite;
}
#${BUTTON_ID}.processing {
background: var(--color-accent, #4a9eff);
color: #fff;
- border-color: var(--color-accent, #4a9eff);
pointer-events: none;
- opacity: 0.7;
+ opacity: 0.8;
}
- #${BUTTON_ID} .timer {
- font-size: 11px;
- font-weight: 600;
+ #${TIMER_ID} {
+ display: none;
font-family: monospace;
- margin-right: 2px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #e53935;
+ background: rgba(229, 57, 53, 0.1);
+ padding: 3px 10px;
+ border-radius: 12px;
+ align-items: center;
+ gap: 5px;
+ line-height: 1;
+ white-space: nowrap;
}
- @keyframes pulse {
+ #${TIMER_ID}.visible {
+ display: inline-flex;
+ }
+ #${TIMER_ID}::before {
+ content: "";
+ width: 7px;
+ height: 7px;
+ background: #e53935;
+ border-radius: 50%;
+ animation: ocvd-blink 1s ease-in-out infinite;
+ flex-shrink: 0;
+ }
+ @keyframes ocvd-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.4); }
- 50% { box-shadow: 0 0 0 8px rgba(229, 57, 53, 0); }
+ 50% { box-shadow: 0 0 0 6px rgba(229, 57, 53, 0); }
+ }
+ @keyframes ocvd-blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+ }
+ @keyframes ocvd-spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+ }
+ .ocvd-spin {
+ animation: ocvd-spin 0.8s linear infinite;
+ transform-origin: center;
}
#opencode-voice-toast {
position: fixed;
@@ -103,35 +150,50 @@ function showToast(message: string, isError = false): void {
}, 4000);
}
-function createButton(): HTMLButtonElement {
+function createContainer(): HTMLDivElement {
+ const container = document.createElement("div");
+ container.id = CONTAINER_ID;
+
+ const timer = document.createElement("span");
+ timer.id = TIMER_ID;
+ timer.textContent = "00:00";
+
const button = document.createElement("button");
button.id = BUTTON_ID;
button.type = "button";
button.title = "Voice Dictation (Ctrl+Space)";
- button.innerHTML = "🎤";
- return button;
+ button.innerHTML = ICON_MIC;
+
+ 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");
switch (state) {
case "idle":
- button.innerHTML = "🎤";
+ button.innerHTML = ICON_MIC;
button.title = "Voice Dictation (Ctrl+Space)";
break;
case "recording":
button.classList.add("recording");
- button.innerHTML = `${formatTimer(elapsedSeconds)}⏹`;
+ 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 = "⌛";
+ button.innerHTML = ICON_SPINNER;
button.title = "Transcribing...";
break;
}
@@ -153,12 +215,12 @@ function findComposer(): HTMLElement | null {
return null;
}
-function isButtonInjected(): boolean {
- return document.getElementById(BUTTON_ID) !== null;
+function isContainerInjected(): boolean {
+ return document.getElementById(CONTAINER_ID) !== null;
}
function injectButton(onToggle: () => void): void {
- if (isButtonInjected()) {
+ if (isContainerInjected()) {
return;
}
@@ -169,19 +231,25 @@ function injectButton(onToggle: () => void): void {
injectStyles();
- const button = createButton();
+ const computedPosition = window.getComputedStyle(composer).position;
+ if (computedPosition === "static") {
+ composer.style.position = "relative";
+ }
+
+ const container = createContainer();
+ const button = container.querySelector(`#${BUTTON_ID}`) as HTMLButtonElement;
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onToggle();
});
- composer.appendChild(button);
+ composer.appendChild(container);
}
export function setupUI(
onToggle: () => void,
- getState: () => DictationState,
+ _getState: () => DictationState,
): {
inject: () => void;
updateState: (state: DictationState, elapsedSeconds?: number) => void;
@@ -202,8 +270,9 @@ export function setupUI(
inject: () => injectButton(onToggle),
updateState: (state: DictationState, elapsedSeconds = 0) => {
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null;
- if (button) {
- updateButtonState(button, state, elapsedSeconds);
+ const timer = document.getElementById(TIMER_ID);
+ if (button && timer) {
+ updateButtonState(button, timer, state, elapsedSeconds);
}
},
toast: showToast,