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

@ -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()
},