diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index cd47e917085b..90d7b1ff2c4e 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -43,6 +43,8 @@ import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
+import { useTuiConfig } from "@tui/context/tui-config"
+import { Voice } from "@tui/voice"
export type PromptProps = {
sessionID?: string
@@ -118,6 +120,49 @@ export function Prompt(props: PromptProps) {
const stash = usePromptStash()
const command = useCommandDialog()
const renderer = useRenderer()
+ const tuiConfig = useTuiConfig()
+ const voice = Voice.createVoiceController(() => tuiConfig.voice)
+
+ createEffect(() => {
+ const err = voice.error()
+ if (!err) return
+ toast.show({ variant: "error", message: err, duration: 5000 })
+ })
+
+ createEffect(() => {
+ const n = voice.notice()
+ if (!n) return
+ toast.show({ variant: "info", message: n, duration: 3000 })
+ })
+
+ onCleanup(() => {
+ void voice.cancel()
+ })
+
+ async function toggleVoice() {
+ if (!voice.isEnabled) return
+ if (voice.state() === "idle") {
+ await voice.start()
+ if (voice.state() === "recording") {
+ toast.show({ variant: "info", message: "Recording… press again to transcribe", duration: 2000 })
+ }
+ return
+ }
+ if (voice.state() === "recording") {
+ toast.show({ variant: "info", message: "Transcribing…", duration: 2000 })
+ const text = await voice.stop()
+ if (text) {
+ if (!input || input.isDestroyed) return
+ input.insertText(text)
+ setTimeout(() => {
+ if (!input || input.isDestroyed) return
+ input.getLayoutNode().markDirty()
+ input.gotoBufferEnd()
+ renderer.requestRender()
+ }, 0)
+ }
+ }
+ }
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
const kv = useKV()
@@ -309,6 +354,19 @@ export function Prompt(props: PromptProps) {
}
},
},
+ {
+ title: "Voice input",
+ value: "prompt.voice",
+ keybind: "input_voice",
+ category: "Prompt",
+ hidden: !voice.isEnabled,
+ slash: {
+ name: "voice",
+ aliases: ["mic", "dictate"],
+ },
+ description: voice.state() === "recording" ? "Stop & transcribe" : "Start recording",
+ onSelect: () => toggleVoice(),
+ },
{
title: "Interrupt session",
value: "session.interrupt",
@@ -1106,6 +1164,11 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPartIndex", new Map())
return
}
+ if (keybind.match("input_voice", e) && voice.isEnabled) {
+ e.preventDefault()
+ void toggleVoice()
+ return
+ }
if (keybind.match("app_exit", e)) {
if (store.prompt.input === "") {
await exit()
@@ -1294,6 +1357,23 @@ export function Prompt(props: PromptProps) {
{props.right}
+
+
+
+ Transcribing… {voice.elapsed()}s
+
+ }
+ >
+ ●
+
+ Rec {Math.floor(voice.elapsed() / 60)}:{String(voice.elapsed() % 60).padStart(2, "0")}
+
+
+
+
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
index ed79e8e52418..08dfb7a816ed 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
@@ -24,6 +24,26 @@ export const TuiOptions = z.object({
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
+ voice: z
+ .object({
+ enabled: z.boolean().optional().describe("Enable voice input via local Whisper CLI"),
+ whisper_command: z
+ .array(z.string())
+ .optional()
+ .describe(
+ "Whisper CLI args array. {audio} = recorded wav path, {language} = language code, {output_dir} = txt output dir. Defaults to ['whisper','{audio}','--language','{language}','--output_format','txt','--output_dir','{output_dir}'].",
+ ),
+ record_command: z
+ .array(z.string())
+ .optional()
+ .describe(
+ "Recorder args array. {output} = wav path, {max_seconds} = max duration. Platform defaults: macOS avfoundation, Linux pulse, Windows dshow (set this on Windows to match your mic device name).",
+ ),
+ language: z.string().optional().describe("Language code passed to Whisper (e.g. 'en', 'zh'). Defaults to 'auto'."),
+ max_seconds: z.number().min(1).optional().describe("Max recording duration in seconds before auto-stop. Defaults to 60."),
+ })
+ .optional()
+ .describe("Voice input configuration for the prompt"),
})
export const TuiInfo = z
diff --git a/packages/opencode/src/cli/cmd/tui/voice/index.tsx b/packages/opencode/src/cli/cmd/tui/voice/index.tsx
new file mode 100644
index 000000000000..e13b5da56ebc
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/voice/index.tsx
@@ -0,0 +1,348 @@
+export * as Voice from "./index"
+
+import os from "os"
+import path from "path"
+import { rm } from "fs/promises"
+import { existsSync } from "fs"
+import { createSignal } from "solid-js"
+
+export type VoiceState = "idle" | "recording" | "transcribing"
+
+export type VoiceConfig = {
+ enabled?: boolean
+ whisper_command?: string[]
+ record_command?: string[]
+ language?: string
+ max_seconds?: number
+}
+
+export type VoiceController = ReturnType
+
+const DEFAULT_MAX_SECONDS = 60
+const DEFAULT_LANGUAGE = "auto"
+
+// Platform-aware default recorder. Uses ffmpeg with a duration cap (`-t`)
+// so the file is finalized even if the process is killed ungracefully. On
+// Windows the dshow input device name varies per machine, so users should
+// override `record_command` in tui.json to match their microphone.
+function defaultRecordCommand(): string[] {
+ const cap = "{max_seconds}"
+ switch (process.platform) {
+ case "darwin":
+ return [
+ "ffmpeg",
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ "avfoundation",
+ "-i",
+ ":default",
+ "-t",
+ cap,
+ "-ar",
+ "16000",
+ "-ac",
+ "1",
+ "{output}",
+ ]
+ case "linux":
+ return [
+ "ffmpeg",
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ "pulse",
+ "-i",
+ "default",
+ "-t",
+ cap,
+ "-ar",
+ "16000",
+ "-ac",
+ "1",
+ "{output}",
+ ]
+ case "win32":
+ return [
+ "ffmpeg",
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ "dshow",
+ "-i",
+ "audio=Microphone",
+ "-t",
+ cap,
+ "-ar",
+ "16000",
+ "-ac",
+ "1",
+ "{output}",
+ ]
+ default:
+ return [
+ "ffmpeg",
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-t",
+ cap,
+ "-ar",
+ "16000",
+ "-ac",
+ "1",
+ "{output}",
+ ]
+ }
+}
+
+function defaultWhisperCommand(): string[] {
+ return ["whisper", "{audio}", "--language", "{language}", "--output_format", "txt", "--output_dir", "{output_dir}"]
+}
+
+function fillTemplate(args: string[], vars: Record): string[] {
+ return args.map((arg) => {
+ let out = arg
+ for (const [key, value] of Object.entries(vars)) {
+ out = out.split(`{${key}}`).join(String(value))
+ }
+ return out
+ })
+}
+
+function isMissingTemplate(args: string[]): boolean {
+ return args.some((arg) => /\{[^}]+\}/.test(arg))
+}
+
+export function createVoiceController(config: () => VoiceConfig | undefined) {
+ const [state, setState] = createSignal("idle")
+ const [elapsed, setElapsed] = createSignal(0)
+ const [error, setError] = createSignal(undefined)
+ const [notice, setNotice] = createSignal(undefined)
+
+ let recorder: ReturnType | undefined
+ let whisperProc: ReturnType | undefined
+ let elapsedTimer: NodeJS.Timeout | undefined
+ let maxTimer: NodeJS.Timeout | undefined
+ let startedAt = 0
+ let audioPath: string | undefined
+ const maxSeconds = () => config()?.max_seconds ?? DEFAULT_MAX_SECONDS
+
+ function clearMaxTimer() {
+ if (maxTimer) {
+ clearTimeout(maxTimer)
+ maxTimer = undefined
+ }
+ }
+
+ function clearElapsedTimer() {
+ if (elapsedTimer) {
+ clearInterval(elapsedTimer)
+ elapsedTimer = undefined
+ }
+ }
+
+ function clearTimers() {
+ clearMaxTimer()
+ clearElapsedTimer()
+ }
+
+ async function start(): Promise {
+ if (state() !== "idle") return
+ setError(undefined)
+ setNotice(undefined)
+ const cfg = config() ?? {}
+ const recordCmd = cfg.record_command ?? defaultRecordCommand()
+
+ audioPath = path.join(os.tmpdir(), `opencode-voice-${Date.now()}.wav`)
+ const filled = fillTemplate(recordCmd, { output: audioPath, max_seconds: maxSeconds() })
+
+ try {
+ recorder = Bun.spawn({
+ cmd: filled,
+ stdout: "ignore",
+ stderr: "pipe",
+ stdin: "ignore",
+ })
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e)
+ setError(`Failed to start recorder (${filled[0] ?? "?"}): ${msg}. Is ffmpeg installed?`)
+ audioPath = undefined
+ return
+ }
+
+ startedAt = Date.now()
+ setState("recording")
+ setElapsed(0)
+ elapsedTimer = setInterval(() => {
+ setElapsed(Math.floor((Date.now() - startedAt) / 1000))
+ }, 250).unref()
+ maxTimer = setTimeout(() => {
+ setNotice("Max duration reached, transcribing…")
+ void stop().catch((e) => setError(e instanceof Error ? e.message : String(e)))
+ }, maxSeconds() * 1000).unref()
+
+ // If the recorder exits immediately (e.g. missing device), surface the error.
+ const rec = recorder
+ void (async () => {
+ const [code, stderrText] = await Promise.all([
+ rec.exited,
+ new Response(rec.stderr).text().catch(() => ""),
+ ])
+ if (state() !== "recording") return
+ if (code !== 0 && code !== null) {
+ clearTimers()
+ setState("idle")
+ const hint =
+ process.platform === "win32"
+ ? " Run `ffmpeg -list_devices true -f dshow -i dummy` to find your mic name, then set record_command in tui.json."
+ : ""
+ setError(`Recorder exited with code ${code}.${stderrText.trim() ? ` ${stderrText.trim()}` : ""}${hint}`)
+ void cleanup()
+ }
+ })()
+ }
+
+ async function stop(): Promise {
+ if (state() !== "recording" || !audioPath) return undefined
+ clearMaxTimer()
+
+ // Ask ffmpeg to finalize gracefully. SIGINT lets it flush the WAV header.
+ if (recorder) {
+ try {
+ process.kill(recorder.pid, "SIGINT")
+ } catch {
+ try {
+ recorder.kill()
+ } catch {}
+ }
+ await recorder.exited.catch(() => undefined)
+ }
+ recorder = undefined
+
+ // Keep the elapsed timer running, now counting transcription time.
+ startedAt = Date.now()
+ setElapsed(0)
+ setState("transcribing")
+
+ const text = await transcribe()
+ clearElapsedTimer()
+ setState("idle")
+ void cleanup()
+ return text
+ }
+
+ async function transcribe(): Promise {
+ if (!audioPath || !existsSync(audioPath)) {
+ setError("No audio captured. Check your microphone / record_command config.")
+ return undefined
+ }
+ const cfg = config() ?? {}
+ const whisperCmd = cfg.whisper_command ?? defaultWhisperCommand()
+ const language = cfg.language ?? DEFAULT_LANGUAGE
+ const outputDir = path.dirname(audioPath)
+ const filled = fillTemplate(whisperCmd, {
+ audio: audioPath,
+ language,
+ output_dir: outputDir,
+ })
+
+ if (isMissingTemplate(filled)) {
+ setError(`whisper_command has unfilled placeholders: ${filled.join(" ")}`)
+ return undefined
+ }
+
+ let stdout: string | undefined
+ let stderr: string | undefined
+ try {
+ whisperProc = Bun.spawn({
+ cmd: filled,
+ stdout: "pipe",
+ stderr: "pipe",
+ stdin: "ignore",
+ })
+ const [out, err, code] = await Promise.all([
+ new Response(whisperProc.stdout).text().catch(() => ""),
+ new Response(whisperProc.stderr).text().catch(() => ""),
+ whisperProc.exited,
+ ])
+ stdout = out
+ stderr = err
+ if (code !== 0) {
+ setError(`Whisper exited with code ${code}. ${stderr?.trim() ?? ""}`)
+ return undefined
+ }
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e)
+ setError(
+ `Failed to run whisper (${filled[0] ?? "?"}): ${msg}. Is it installed? You can also set whisper_command in tui.json to use whisper-cpp or faster-whisper.`,
+ )
+ return undefined
+ } finally {
+ whisperProc = undefined
+ }
+
+ // whisper writes .txt next to the audio when --output_dir is the audio's dir.
+ const txtPath = audioPath.replace(/\.wav$/i, ".txt")
+ if (existsSync(txtPath)) {
+ const text = (await Bun.file(txtPath).text()).trim()
+ if (text) return text
+ }
+ // Some whisper builds print the transcript to stdout instead.
+ const fromStdout = stdout?.trim()
+ if (fromStdout) return fromStdout
+ // Empty transcript — likely silence. Not an error, just nothing to insert.
+ return undefined
+ }
+
+ async function cleanup() {
+ if (audioPath) {
+ const base = audioPath.replace(/\.wav$/i, "")
+ await Promise.all([
+ rm(audioPath, { force: true }),
+ rm(`${base}.txt`, { force: true }),
+ ]).catch(() => {})
+ audioPath = undefined
+ }
+ }
+
+ async function cancel(): Promise {
+ clearTimers()
+ if (recorder) {
+ try {
+ recorder.kill()
+ } catch {}
+ await recorder.exited.catch(() => undefined)
+ recorder = undefined
+ }
+ if (whisperProc) {
+ try {
+ whisperProc.kill()
+ } catch {}
+ await whisperProc.exited.catch(() => undefined)
+ whisperProc = undefined
+ }
+ setState("idle")
+ await cleanup()
+ }
+
+ return {
+ state,
+ elapsed,
+ error,
+ notice,
+ start,
+ stop,
+ cancel,
+ get isEnabled() {
+ return config()?.enabled !== false
+ },
+ }
+}
diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts
index a84fc0b37d58..fc3bf33f5bfd 100644
--- a/packages/opencode/src/config/keybinds.ts
+++ b/packages/opencode/src/config/keybinds.ts
@@ -66,6 +66,7 @@ const KeybindsSchema = Schema.Struct({
variant_list: keybind("none", "List model variants"),
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind("ctrl+v", "Paste from clipboard"),
+ input_voice: keybind("v", "Toggle voice input (record / stop & transcribe)"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),