diff --git a/bun.lock b/bun.lock index e7c5e9a54b..8cfc906a62 100644 --- a/bun.lock +++ b/bun.lock @@ -993,7 +993,7 @@ "@opencode-ai/client": "workspace:*", "@opentui/core": "catalog:", "@opentui/solid": "catalog:", - "solid-js": "catalog:", + "solid-js": "1.9.12", }, "devDependencies": { "@tsconfig/bun": "catalog:", @@ -6311,6 +6311,8 @@ "@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="], + "@opencode-ai/voice/solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], + "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@opencode-ai/www/@astrojs/cloudflare": ["@astrojs/cloudflare@14.1.4", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/underscore-redirects": "1.0.3", "@cloudflare/vite-plugin": "^1.39.0", "piccolore": "^0.1.3", "vite": "^8.0.13" }, "peerDependencies": { "astro": "^7.0.0", "wrangler": "^4.83.0" } }, "sha512-Zyo1E/5/dmegmKODbwUzOd67euNd6oKcbllhAwe3uFFprA7mIFNkpF6yBBa78vIkpMNuU13MkBJHCLd+yJSuEA=="], @@ -7347,6 +7349,10 @@ "@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], + "@opencode-ai/voice/solid-js/seroval": ["seroval@1.5.5", "", {}, "sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw=="], + + "@opencode-ai/voice/solid-js/seroval-plugins": ["seroval-plugins@1.5.5", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-+BDhqYM6CEn3x09v44dpa9p6974FuUB2dxk+Ctn04k0cO1Zt6QODTXfmEZK0eBaTe/fJBvP4NMGuNJ+R8T+QMg=="], + "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], diff --git a/packages/voice/bunfig.toml b/packages/voice/bunfig.toml new file mode 100644 index 0000000000..b16283cb5b --- /dev/null +++ b/packages/voice/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["@opentui/solid/preload"] + +[test] +preload = ["@opentui/solid/preload"] diff --git a/packages/voice/package.json b/packages/voice/package.json index 25d04af59c..4750f30294 100644 --- a/packages/voice/package.json +++ b/packages/voice/package.json @@ -6,7 +6,7 @@ "description": "Prototype voice control for OpenCode via the OpenAI Realtime API", "type": "module", "scripts": { - "spike": "bun src/spike.ts", + "spike": "bun run --no-orphans --conditions=browser src/spike.ts", "typecheck": "tsgo --noEmit" }, "devDependencies": { @@ -19,6 +19,6 @@ "@opencode-ai/client": "workspace:*", "@opentui/core": "catalog:", "@opentui/solid": "catalog:", - "solid-js": "catalog:" + "solid-js": "1.9.12" } } diff --git a/packages/voice/src/spike.ts b/packages/voice/src/spike.ts index abaa93ab4f..c483ed0274 100644 --- a/packages/voice/src/spike.ts +++ b/packages/voice/src/spike.ts @@ -95,7 +95,7 @@ const truncate = (text: string, max = 2000) => (text.length > max ? text.slice(0 // --------------------------------------------------------------------------- const { createConsoleUI, createVoiceTUI } = await import("./ui") -const tuiActive = !args.text && process.stdout.isTTY +const tuiActive = !args.text const ui = tuiActive ? await createVoiceTUI({ onInterrupt: () => interrupt(), @@ -327,10 +327,12 @@ const fullDuplex = aecBinary !== undefined || args.duplex // session is untouched). const voices = ["marin", "cedar", "coral", "sage", "ash", "ballad", "alloy", "verse"] let currentVoice = args.voice ?? "marin" -let ws!: WebSocket +let ws: WebSocket | undefined let reconnecting = false -const send = (event: Record) => ws.send(JSON.stringify(event)) +const send = (event: Record) => { + if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(event)) +} function setVoice(voice: string) { currentVoice = voice @@ -338,7 +340,7 @@ function setVoice(voice: string) { ui.meta(`[voice] switching to ${voice}…`) reconnecting = true flushPlayback() - ws.close(1000) + ws?.close(1000) connectRealtime() } @@ -378,7 +380,7 @@ async function startMicrophone() { ui.setStatus({ audio: args.speakers ? "duplex+aec" : "duplex" }) ui.meta("mic live — talk any time, even over the assistant") for await (const chunk of audio.stdout as ReadableStream) { - if (ws.readyState === WebSocket.OPEN) + if (ws?.readyState === WebSocket.OPEN) send({ type: "input_audio_buffer.append", audio: Buffer.from(chunk).toString("base64") }) } return @@ -388,7 +390,7 @@ async function startMicrophone() { ui.meta("mic live — start talking") if (!args.duplex) ui.meta("mic mutes while the assistant speaks; press any key to interrupt") for await (const chunk of recorder.stdout as ReadableStream) { - if (ws.readyState !== WebSocket.OPEN) continue + if (ws?.readyState !== WebSocket.OPEN) continue // Half-duplex: drop mic audio while the assistant is audible (plus a // short tail) so speaker echo can't barge-in against itself. if (!args.duplex && Date.now() < playbackEndsAt + 300) continue @@ -570,12 +572,16 @@ function onClose(event: CloseEvent) { shutdown() } +let shuttingDown = false + function shutdown() { - ui.close() + if (shuttingDown) return + shuttingDown = true recorder?.kill() player?.kill() audio?.kill() - if (ws.readyState === WebSocket.OPEN) ws.close() + if (ws?.readyState === WebSocket.OPEN) ws.close() + ui.close() process.exit(0) } diff --git a/packages/voice/src/ui.tsx b/packages/voice/src/ui.tsx index a6eafafb23..b782665bd4 100644 --- a/packages/voice/src/ui.tsx +++ b/packages/voice/src/ui.tsx @@ -4,9 +4,8 @@ // moment the audio buffer commits (before the assistant starts replying) and // its transcript is filled in when Whisper finishes. import { createCliRenderer } from "@opentui/core" -import { render, useKeyboard } from "@opentui/solid" -import { For } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { createSignal, For } from "solid-js" export type VoiceStatus = { server?: string @@ -151,39 +150,53 @@ export async function createVoiceTUI(options: { onExit(): void onCycleVoice(): void }): Promise { - const [state, setState] = createStore({ + const [state, setState] = createSignal({ messages: [] as Message[], status: {} as VoiceStatus, }) const renderer = await createCliRenderer({ useMouse: true, - exitOnCtrlC: false, + // Handle Ctrl-C in OpenTUI's native input parser, before Solid handlers + // and rendering work. onDestroy performs process/audio cleanup below. + exitOnCtrlC: true, + exitSignals: [], autoFocus: false, openConsoleOnError: false, + screenMode: "alternate-screen", + externalOutputMode: "passthrough", + consoleMode: "disabled", + onDestroy: options.onExit, }) function App() { + const dimensions = useTerminalDimensions() useKeyboard((evt) => { - if (evt.ctrl && evt.name === "c") return options.onExit() + if (evt.ctrl && evt.name === "c") return if (evt.name === "v") return options.onCycleVoice() options.onInterrupt() }) const statusLine = () => [ - state.status.audio ?? "connecting…", - state.status.voice, - state.status.session ?? "no session", - state.status.server, + state().status.audio ?? "connecting…", + state().status.voice, + state().status.session ?? "no session", + state().status.server, "v: voice · any key interrupts · ctrl+c quits", ] .filter(Boolean) .join(" ") return ( - + - {(message) => } + {(message) => } @@ -195,50 +208,57 @@ export async function createVoiceTUI(options: { void render(() => , renderer) - const push = (message: Message) => setState("messages", state.messages.length, message) + const redraw = () => renderer.requestRender() + const push = (message: Message) => { + setState((current) => ({ ...current, messages: [...current.messages, message] })) + redraw() + } return { meta: (text) => push({ kind: "meta", text }), userCommitted: (itemID) => push({ kind: "user", itemID }), userTranscript: (itemID, text) => { - const index = state.messages.findIndex((m) => m.kind === "user" && m.itemID === itemID) + const index = state().messages.findIndex((m) => m.kind === "user" && m.itemID === itemID) if (index === -1) return push({ kind: "user", itemID, text }) - setState( - "messages", - index, - produce((m) => { - if (m.kind === "user") m.text = text - }), - ) + setState((current) => ({ + ...current, + messages: current.messages.map((message, i) => + i === index && message.kind === "user" ? { ...message, text } : message, + ), + })) + redraw() }, assistantDelta: (text) => { - const index = state.messages.length - 1 - const last = state.messages[index] + const index = state().messages.length - 1 + const last = state().messages[index] if (last?.kind === "assistant" && last.streaming) { - setState( - "messages", - index, - produce((m) => { - if (m.kind === "assistant") m.text += text - }), - ) + setState((current) => ({ + ...current, + messages: current.messages.map((message, i) => + i === index && message.kind === "assistant" ? { ...message, text: message.text + text } : message, + ), + })) + redraw() return } push({ kind: "assistant", text, streaming: true }) }, assistantDone: () => { - const index = state.messages.length - 1 - if (state.messages[index]?.kind !== "assistant") return - setState( - "messages", - index, - produce((m) => { - if (m.kind === "assistant") m.streaming = false - }), - ) + const index = state().messages.length - 1 + if (state().messages[index]?.kind !== "assistant") return + setState((current) => ({ + ...current, + messages: current.messages.map((message, i) => + i === index && message.kind === "assistant" ? { ...message, streaming: false } : message, + ), + })) + redraw() }, tool: (name, output) => push({ kind: "tool", name, json: compactJson(output) }), - setStatus: (patch) => setState("status", patch), + setStatus: (patch) => { + setState((current) => ({ ...current, status: { ...current.status, ...patch } })) + redraw() + }, close: () => { if (!renderer.isDestroyed) renderer.destroy() },