Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/warm-speech-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Wait for microphone capture to start before showing voice input as recording.
6 changes: 6 additions & 0 deletions packages/kilo-vscode/src/services/input-tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { KiloConnectionService } from "./cli-backend/connection-service"
import { routeAutocompleteMessage } from "./autocomplete/settings"
import { handleSpeechToTextCancel, handleSpeechToTextStart, handleSpeechToTextStop } from "../speech-to-text/handler"
import { prewarmSpeechCapture } from "../speech-to-text/capture"

type Msg = {
type: string
Expand All @@ -18,6 +19,11 @@ type Ctx = {
export async function routeInputToolMessage(message: Msg, ctx: Ctx): Promise<boolean> {
if (await routeAutocompleteMessage(message, ctx.post)) return true

if (message.type === "speechToTextPrewarm") {
void prewarmSpeechCapture().catch((err: unknown) => console.warn("[Kilo New] Speech capture prewarm failed:", err))
return true
}

if (message.type === "speechToTextStart") {
if (!message.requestId) return true
handleSpeechToTextStart(
Expand Down
24 changes: 23 additions & 1 deletion packages/kilo-vscode/src/speech-to-text/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,18 @@ type Args = {

let active: Recording | undefined
let starting: string | undefined
let ffmpeg: Promise<string> | undefined

export async function prewarmSpeechCapture(): Promise<void> {
await resolveFFmpeg()
}

export async function startSpeechCapture(input: Input): Promise<boolean> {
if (active || starting) throw new Error("Speech recording is already in progress")

starting = input.requestId
try {
const bin = await findFFmpeg()
const bin = await resolveFFmpeg()
const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`)
const state = await startWithArgs(bin, file, input, await inputArgSets(bin))
return !state.stopped
Expand Down Expand Up @@ -229,6 +234,23 @@ function requireActive(requestId: string): Recording {
return active
}

async function resolveFFmpeg(): Promise<string> {
const cached = ffmpeg
if (cached) {
const bin = await cached
if (!path.isAbsolute(bin) || existsSync(bin)) return bin
Comment thread
marius-kilocode marked this conversation as resolved.
}

const task = findFFmpeg()
ffmpeg = task
try {
return await task
} catch (err) {
if (ffmpeg === task) ffmpeg = undefined
throw err
}
}

async function findFFmpeg(): Promise<string> {
const paths = [
process.env.KILO_FFMPEG_PATH,
Expand Down
109 changes: 109 additions & 0 deletions packages/kilo-vscode/tests/unit/speech-to-text-prewarm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from "bun:test"
import path from "node:path"

// Run from webview-ui so the child transpiles JSX with solid-js (its tsconfig sets
// jsxImportSource: solid-js). The package root tsconfig has no jsx setting, which makes
// bun fall back to react/jsx-dev-runtime and fail whenever react isn't hoisted nearby.
const WEBVIEW = path.resolve(import.meta.dir, "../../webview-ui")

// solid-js effects only run under the browser export condition, so the component has to be
// exercised in a child process resolved with `--conditions=browser`. The child prints an
// explicit PASS/FAIL sentinel: a FAIL means the prewarm logic is wrong (fail immediately),
// while any other non-zero exit is a transient spawn failure under load and is retried so
// the suite stays deterministic.
const PASS = "PREWARM_PASS"
const FAIL = "PREWARM_FAIL:"

const SCRIPT = `
import { Window } from "happy-dom"

const window = new Window()
globalThis.window = window
globalThis.document = window.document
globalThis.Node = window.Node

const sent = []
globalThis.acquireVsCodeApi = () => ({
postMessage: (message) => sent.push(message),
getState: () => undefined,
setState: () => {},
})

const { createComponent, createSignal } = await import("solid-js")
const { render } = await import("solid-js/web")
const { ConfigContext } = await import("./src/context/config.tsx")
const { ProviderContext } = await import("./src/context/provider.tsx")
const { SpeechToTextPrewarm } = await import(
"./src/components/speech-to-text/SpeechToTextPrewarm.tsx"
)

const fail = (reason) => {
console.log("${FAIL}" + reason)
process.exit(2)
}

const [config, setConfig] = createSignal({ disabled_providers: ["kilo"] })
const [auth, setAuth] = createSignal({})
const root = document.createElement("div")
const dispose = render(
() =>
createComponent(ProviderContext.Provider, {
value: { authStates: auth },
get children() {
return createComponent(ConfigContext.Provider, {
value: { config },
get children() {
return createComponent(SpeechToTextPrewarm, {})
},
})
},
}),
root,
)

if (sent.length !== 0) fail("prewarmed without Kilo access")
setAuth({ kilo: "api" })
if (sent.length !== 0) fail("prewarmed while Kilo was disabled")
setConfig({})
if (sent.length !== 1 || sent[0]?.type !== "speechToTextPrewarm") {
fail("did not prewarm after Kilo access became available")
}
setAuth({ kilo: "oauth" })
if (sent.length !== 1) fail("prewarmed more than once")
dispose()
console.log("${PASS}")
`

describe("speech-to-text prewarm", () => {
it("starts only after Kilo speech access becomes available", () => {
const attempts = 3
const failures: string[] = []

for (let attempt = 1; attempt <= attempts; attempt++) {
Comment thread
marius-kilocode marked this conversation as resolved.
const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", SCRIPT], {
cwd: WEBVIEW,
stdout: "pipe",
stderr: "pipe",
})
const output = result.stdout.toString() + result.stderr.toString()

if (output.includes(PASS)) return

const logic = output.indexOf(FAIL)
// A FAIL sentinel is a real assertion failure in the prewarm logic — surface it now.
if (logic !== -1) {
expect.unreachable(
output
.slice(logic + FAIL.length)
.split("\n")[0]
?.trim(),
)
}

// Otherwise the child died before it could run (starved/transient spawn) — retry.
failures.push(`attempt ${attempt} exit ${result.exitCode}: ${output.trim() || "<no output>"}`)
}

expect.unreachable(`prewarm child never reported success:\n${failures.join("\n")}`)
})
})
56 changes: 56 additions & 0 deletions packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,59 @@ function setup() {
}

describe("useSpeechToText", () => {
it("waits for microphone readiness before reporting recording", () => {
const ctx = setup()

ctx.speech.start({ model: "scribe", insert: () => {} })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")

expect(ctx.speech.state()).toBe("starting")
expect(ctx.speech.active()).toBe(true)

ctx.fire({ type: "speechToTextStarted", requestId: "another-request" })
expect(ctx.speech.state()).toBe("starting")

ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
expect(ctx.speech.state()).toBe("recording")
ctx.dispose()
})

it("does not stop or start another recording while the microphone is starting", () => {
const ctx = setup()

ctx.speech.start({ model: "scribe", insert: () => {} })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")

ctx.speech.stop()
ctx.speech.start({ model: "other", insert: () => {} })
expect(ctx.speech.state()).toBe("starting")
expect(ctx.sent).toEqual([start])

ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
ctx.speech.stop()
expect(ctx.speech.state()).toBe("transcribing")
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })
ctx.dispose()
})

it("cancels a pending microphone startup and ignores its late acknowledgement", () => {
const ctx = setup()

ctx.speech.start({ model: "scribe", insert: () => {} })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")

ctx.speech.cancel()
expect(ctx.sent[1]).toEqual({ type: "speechToTextCancel", requestId: start.requestId })
expect(ctx.speech.state()).toBe("idle")

ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
expect(ctx.speech.state()).toBe("idle")
ctx.dispose()
})

it("offers sign-in when stored credentials stop authenticating", () => {
const ctx = setup()

Expand Down Expand Up @@ -70,6 +123,7 @@ describe("useSpeechToText", () => {
ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })

ctx.speech.stop({ done: () => done++ })
ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Recorded prompt" })
Expand All @@ -87,6 +141,7 @@ describe("useSpeechToText", () => {
ctx.speech.start({ model: "scribe", insert: () => {} })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })

ctx.speech.stop({ done: () => done++ })
ctx.speech.cancel()
Expand All @@ -105,6 +160,7 @@ describe("useSpeechToText", () => {
ctx.speech.start({ model: "scribe", insert: (value) => text.push(value) })
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })

ctx.speech.stop({ done: () => done++, ready: () => false })
ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Keep as draft" })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { SessionProvider, useSession } from "../src/context/session"
import { AgentRequirementsProvider } from "../src/context/agent-requirements"
import { WorktreeModeProvider } from "../src/context/worktree-mode"
import { ChatView } from "../src/components/chat"
import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm"
import HistoryView from "../src/components/history/HistoryView"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { DataBridge, MermaidDownloadBridge } from "../src/App"
Expand Down Expand Up @@ -3132,6 +3133,7 @@ export const AgentManagerApp: Component = () => {
<FileComponentProvider component={File}>
<ProviderProvider>
<ConfigProvider>
<SpeechToTextPrewarm />
<DisplayProvider>
<IndexingProvider>
<KiloEmbeddingModelsProvider>
Expand Down
2 changes: 2 additions & 0 deletions packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { DiffSourceCapabilities, DiffSourceDescriptor } from "../../src/dif
import type { DiffViewerNotice } from "../src/types/messages/extension-messages"
import { DiffPickerHeader } from "./DiffPickerHeader"
import { BaseBranchPicker } from "./BaseBranchPicker"
import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm"

const NOTICE_KEYS: Record<DiffViewerNotice, string> = {
"snapshots-disabled": "diffViewer.notice.snapshotsDisabled",
Expand Down Expand Up @@ -305,6 +306,7 @@ export const DiffViewerApp: Component = () => {
<ServerProvider>
<ProviderProvider>
<ConfigProvider>
<SpeechToTextPrewarm />
<DiffViewerShell />
</ConfigProvider>
</ProviderProvider>
Expand Down
2 changes: 2 additions & 0 deletions packages/kilo-vscode/webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ChatView } from "./components/chat"
import { SidebarEmptyState } from "./components/chat/SidebarEmptyState"
import { registerExpandedTaskTool } from "./components/chat/TaskToolExpanded"
import { registerVscodeToolOverrides } from "./components/chat/VscodeToolOverrides"
import { SpeechToTextPrewarm } from "./components/speech-to-text/SpeechToTextPrewarm"

// Override the upstream "task" tool renderer with the fully-expanded version
// that shows child session parts inline in the VS Code sidebar.
Expand Down Expand Up @@ -409,6 +410,7 @@ const App: Component = () => {
<FileComponentProvider component={File}>
<ProviderProvider>
<ConfigProvider>
<SpeechToTextPrewarm />
<DisplayProvider>
<WorkStyleProvider>
<IndexingProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Button } from "@kilocode/kilo-ui/button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import type { Component } from "solid-js"
import { onCleanup, type Component } from "solid-js"
import type { SpeechToText } from "./useSpeechToText"

type Props = {
Expand All @@ -12,15 +12,19 @@ type Props = {
}

export const SpeechToTextButton: Component<Props> = (props) => {
const disabled = () => !!props.disabled
const unavailable = () => !!props.disabled && props.speech.state() === "idle"
const locked = () => unavailable() || props.speech.state() === "starting"
const busy = () => props.speech.state() === "starting" || props.speech.state() === "transcribing"
const label = () => {
if (props.speech.state() === "starting") return props.label("speechToText.tooltip.starting")
if (props.speech.state() === "recording") return props.label("speechToText.tooltip.stop")
if (props.speech.state() === "transcribing") return props.label("speechToText.tooltip.transcribing")
if (props.speech.state() === "error") return props.speech.error() || props.label("speechToText.tooltip.error")
return props.label("speechToText.tooltip.start")
}

const click = () => {
if (props.speech.state() === "starting") return
if (props.speech.state() === "recording") {
props.speech.stop()
return
Expand All @@ -33,22 +37,28 @@ export const SpeechToTextButton: Component<Props> = (props) => {
props.speech.clear()
return
}
if (disabled()) return
if (unavailable()) return
props.start()
}

onCleanup(() => {
if (props.speech.active()) props.speech.cancel()
Comment thread
marius-kilocode marked this conversation as resolved.
})

return (
<Tooltip value={label()} placement="top">
<Button
variant="ghost"
size="small"
onClick={click}
disabled={disabled()}
disabled={locked()}
aria-label={label()}
aria-disabled={locked()}
aria-busy={busy()}
aria-pressed={props.speech.state() === "recording"}
class={`prompt-speech-button prompt-speech-button--${props.speech.state()}`}
Comment thread
marius-kilocode marked this conversation as resolved.
>
{props.speech.state() === "transcribing" ? (
{busy() ? (
<Spinner style={{ width: "16px", height: "16px" }} />
) : (
<svg class="prompt-speech-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createEffect, type Component } from "solid-js"
import { useConfig } from "../../context/config"
import { useProvider } from "../../context/provider"
import { getVSCodeAPI } from "../../context/vscode"
import { canUseSpeechToText } from "./availability"

export const SpeechToTextPrewarm: Component = () => {
const vscode = getVSCodeAPI()
const provider = useProvider()
const { config } = useConfig()
let prepared = false

createEffect(() => {
if (prepared || !canUseSpeechToText(config(), provider.authStates())) return
prepared = true
vscode.postMessage({ type: "speechToTextPrewarm" })
})

return null
}
Loading
Loading