fix(voice): run reactive opentui and harden shutdown
This commit is contained in:
parent
5f0487fa87
commit
e5ac4ca560
5 changed files with 87 additions and 51 deletions
8
bun.lock
8
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=="],
|
||||
|
|
|
|||
4
packages/voice/bunfig.toml
Normal file
4
packages/voice/bunfig.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue