fix(voice): run reactive opentui and harden shutdown

This commit is contained in:
Kit Langton 2026-07-27 14:53:27 -04:00
commit e5ac4ca560
5 changed files with 87 additions and 51 deletions

View file

@ -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=="],

View file

@ -0,0 +1,4 @@
preload = ["@opentui/solid/preload"]
[test]
preload = ["@opentui/solid/preload"]

View file

@ -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"
}
}

View file

@ -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<string, unknown>) => ws.send(JSON.stringify(event))
const send = (event: Record<string, unknown>) => {
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<Uint8Array>) {
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<Uint8Array>) {
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)
}

View file

@ -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<VoiceUI> {
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 (
<box flexDirection="column" width="100%" height="100%" paddingLeft={1} paddingRight={1}>
<box
flexDirection="column"
width={dimensions().width}
height={dimensions().height}
paddingLeft={1}
paddingRight={1}
>
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom" scrollbarOptions={{ visible: false }}>
<box flexDirection="column" flexShrink={0}>
<For each={state.messages}>{(message) => <MessageRow message={message} />}</For>
<For each={state().messages}>{(message) => <MessageRow message={message} />}</For>
</box>
</scrollbox>
<box height={1} marginTop={1}>
@ -195,50 +208,57 @@ export async function createVoiceTUI(options: {
void render(() => <App />, 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()
},