Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 103 additions & 28 deletions bun.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@
],
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:"
"@types/node": "catalog:",
"sharp": "0.34.5"
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@huggingface/transformers": "3.8.1",
"@zip.js/zip.js": "2.7.62",
"sharp": "0.34.5",
"wavefile": "11.0.0",
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogVoice } from "@tui/component/dialog-voice"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
Expand Down Expand Up @@ -423,6 +424,17 @@ function App() {
dialog.replace(() => <DialogMcp />)
},
},
{
title: "Voice settings",
value: "voice.settings",
category: "Agent",
slash: {
name: "voice",
},
onSelect: () => {
dialog.replace(() => <DialogVoice />)
},
},
{
title: "Agent cycle",
value: "agent.cycle",
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,27 @@ export function DialogStatus() {
</For>
</box>
</Show>
<Show when={sync.data.voice?.status !== "disabled"}>
<box>
<text fg={theme.text}>Voice</text>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: sync.data.voice?.status === "ready" ? theme.success : theme.warning,
}}
>
</text>
<text fg={theme.text} wrapMode="word">
<b>{sync.data.voice?.status === "ready" ? sync.data.voice.model : "..."}</b>{" "}
<span style={{ fg: theme.textMuted }}>
{sync.data.voice?.status === "ready" ? "Ready" : "Loading model..."}
</span>
</text>
</box>
</box>
</Show>
</box>
)
}
125 changes: 125 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-voice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useTheme } from "../context/theme"
import { Keybind } from "@/util/keybind"
import { TextAttributes } from "@opentui/core"

function Status(props: { status: string; loading: boolean }) {
const { theme } = useTheme()
if (props.loading) {
return <span style={{ fg: theme.textMuted }}>⋯ Loading</span>
}
if (props.status === "ready") {
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}>✓ Ready</span>
}
if (props.status === "downloading") {
return <span style={{ fg: theme.textMuted }}>⬇ Downloading</span>
}
if (props.status === "loading") {
return <span style={{ fg: theme.textMuted }}>⋯ Loading</span>
}
if (props.status === "disabled") {
return <span style={{ fg: theme.textMuted }}>○ Disabled</span>
}
if (props.status === "idle") {
return <span style={{ fg: theme.textMuted }}>○ Idle</span>
}
return <span style={{ fg: theme.error }}>✗ Error</span>
}

export function DialogVoice() {
const local = useLocal()
const sync = useSync()
const [, setRef] = createSignal<DialogSelectRef<unknown>>()
const [loading, setLoading] = createSignal<string | null>(null)

const voiceData = () => sync.data.voice
const voiceStatus = () => (voiceData() as any)?.status ?? "disabled"
const voiceModel = () => (voiceData() as any)?.model

const options = createMemo(() => {
const loadingModel = loading()
const currentStatus = voiceStatus()

const result: DialogSelectOption<string>[] = []

// Toggle voice on/off
result.push({
value: "toggle",
title: currentStatus === "disabled" ? "Enable Voice" : "Disable Voice",
description: "Toggle voice transcription",
footer: <Status status={currentStatus} loading={loadingModel === "toggle"} />,
category: "Control",
})

// Model selection
const models = [
{ name: "tiny", size: "75 MB", description: "Fast, lower accuracy" },
{ name: "base", size: "142 MB", description: "Balanced speed and accuracy" },
{ name: "small", size: "466 MB", description: "Better accuracy, slower" },
]

for (const model of models) {
const isCurrent = voiceModel() === model.name
result.push({
value: `model:${model.name}`,
title: `${model.name} (${model.size})`,
description: model.description,
footer: loadingModel === model.name ? <span>⋯ Loading</span> : isCurrent ? <span>✓ Active</span> : undefined,
category: "Models",
})
}

return result
})

const keybinds = createMemo(() => [
{
keybind: Keybind.parse("space")[0],
title: "select",
onTrigger: async (option: DialogSelectOption<string>) => {
if (loading() !== null) return

const value = option.value

if (value === "toggle") {
setLoading("toggle")
try {
await local.voice.toggle()
} catch (error) {
console.error("Failed to toggle voice:", error)
} finally {
setLoading(null)
}
return
}

if (value.startsWith("model:")) {
const modelName = value.replace("model:", "") as "tiny" | "base" | "small"
setLoading(modelName)
try {
await local.voice.switchModel(modelName)
} catch (error) {
console.error("Failed to switch voice model:", error)
} finally {
setLoading(null)
}
}
},
},
])

return (
<DialogSelect
ref={setRef}
title="Voice Settings"
options={options()}
keybind={keybinds()}
onSelect={(option) => {
// Don't close on select, only on escape
}}
/>
)
}
95 changes: 95 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { VoiceRecorder, type VoiceRecorderStatus } from "@tui/util/voice-recorder"
import { DialogSkill } from "../dialog-skill"

export type PromptProps = {
Expand Down Expand Up @@ -135,6 +136,18 @@ export function Prompt(props: PromptProps) {
interrupt: 0,
})

// Voice recording state
const [voiceStatus, setVoiceStatus] = createSignal<VoiceRecorderStatus>("idle")
let voiceRecorder: VoiceRecorder | null = null

onMount(() => {
voiceRecorder = new VoiceRecorder(sdk.client)
})

onCleanup(() => {
voiceRecorder?.cancel()
})

createEffect(
on(
() => props.sessionID,
Expand Down Expand Up @@ -834,6 +847,71 @@ export function Prompt(props: PromptProps) {
e.preventDefault()
return
}

// Handle voice input - toggle recording
if (keybind.match("voice_input", e)) {
// Prevent default to avoid inserting the keybind character
e.preventDefault()

// Only allow voice input if service is available
if (sync.data.voice?.status !== "ready") {
return
}

if (voiceStatus() === "idle" && voiceRecorder) {
// Start recording
try {
await voiceRecorder.startRecording()
setVoiceStatus(voiceRecorder.status)
} catch (err) {
toast.show({
variant: "error",
message: `Failed to start recording: ${err instanceof Error ? err.message : String(err)}`,
duration: 3000,
})
}
return
}

if (voiceStatus() === "recording" && voiceRecorder) {
// Stop recording and transcribe
setVoiceStatus("transcribing")

try {
const text = await voiceRecorder.stopRecordingAndTranscribe()
setVoiceStatus(voiceRecorder.status)

if (!text) {
toast.show({
variant: "warning",
message: "No speech detected",
duration: 3000,
})
return
}

// Insert transcribed text at cursor position
input.insertText(text)
setTimeout(() => {
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
} catch (err) {
setVoiceStatus("error")
toast.show({
variant: "error",
message: `Transcription failed: ${err instanceof Error ? err.message : String(err)}`,
duration: 5000,
})
// Reset status after error
setTimeout(() => setVoiceStatus("idle"), 100)
}
return
}

return
}

// Handle clipboard paste (Ctrl+V) - check for images first on Windows
// This is needed because Windows terminal doesn't properly send image data
// through bracketed paste, so we need to intercept the keypress and
Expand Down Expand Up @@ -1126,6 +1204,23 @@ export function Prompt(props: PromptProps) {
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<Show when={sync.data.voice?.status === "ready" && voiceStatus() === "recording"}>
<text fg={theme.primary}>
<span style={{ fg: theme.primary, bold: true }}>
Recording... ({keybind.print("voice_input")} to stop)
</span>
</text>
</Show>
<Show when={sync.data.voice?.status === "ready" && voiceStatus() === "transcribing"}>
<text fg={theme.warning}>
<span style={{ fg: theme.warning, bold: true }}>Transcribing...</span>
</text>
</Show>
<Show when={sync.data.voice?.status === "ready" && voiceStatus() === "idle"}>
<text fg={theme.text}>
{keybind.print("voice_input")} <span style={{ fg: theme.textMuted }}>voice</span>
</text>
</Show>
<Show when={local.model.variant.list().length > 0}>
<text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}

const voice = {
async toggle() {
const status = sync.data.voice
if (status?.status === "ready") {
await sdk.client.voice.disable()
} else {
await sdk.client.voice.enable()
}
},
async switchModel(model: "tiny" | "base" | "small") {
await sdk.client.voice.switchModel({ model })
},
}

// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
Expand All @@ -403,6 +417,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model,
agent,
mcp,
voice,
}
return result
},
Expand Down
Loading
Loading