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/quiet-speech-shortcut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Bind voice input to Cmd/Ctrl+K in Kilo prompt and review comment fields, with hold-to-talk and release-to-send support.
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ When you are signed in to the enabled Kilo provider, a microphone button appears
3. Click again to stop recording
4. Your speech is transcribed into text

You can also use **Cmd/Ctrl+K** while a Kilo prompt or review comment field is focused. Tap it to start or stop recording, or hold it while speaking and release to transcribe and submit the focused field. Press it during transcription to cancel.

The feature includes real-time audio level visualization and voice activity detection to automatically detect when you're speaking.

---
Expand Down
156 changes: 156 additions & 0 deletions packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it, mock } from "bun:test"
import { createRoot } from "solid-js"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
import {
createSpeechShortcut,
isSpeechShortcut,
SPEECH_HOLD_MS,
speechShortcutLabel,
speechShortcutValue,
toggleSpeech,
} from "../../webview-ui/src/components/speech-to-text/shortcut"

type Toast = {
actions?: Array<{ onClick: string | (() => void) }>
Expand Down Expand Up @@ -171,3 +179,151 @@ describe("useSpeechToText", () => {
ctx.dispose()
})
})

describe("speech shortcut", () => {
const key = (timeStamp: number, repeat = false) => ({
key: "k",
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false,
repeat,
timeStamp,
})

it("accepts only the platform modifier with K", () => {
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, true)).toBe(
true,
)
expect(isSpeechShortcut({ key: "k", metaKey: false, ctrlKey: true, altKey: false, shiftKey: false }, true)).toBe(
false,
)
expect(isSpeechShortcut({ key: "k", metaKey: false, ctrlKey: true, altKey: false, shiftKey: false }, false)).toBe(
true,
)
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: false, shiftKey: false }, false)).toBe(
false,
)
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: true, shiftKey: false }, true)).toBe(
false,
)
expect(isSpeechShortcut({ key: "k", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true }, true)).toBe(
false,
)
})

it("exposes platform-specific labels for the focused input", () => {
expect(speechShortcutLabel(true)).toBe("⌘K")
expect(speechShortcutValue(true)).toBe("Meta+K")
expect(speechShortcutLabel(false)).toBe("Ctrl+K")
expect(speechShortcutValue(false)).toBe("Control+K")
})

it("does not handle a shortcut when speech is unavailable", () => {
const ctx = setup()
let started = 0
expect(toggleSpeech(ctx.speech, true, () => started++)).toBe(false)
expect(started).toBe(0)
ctx.dispose()
})

it("ignores key repeat and keeps a quick press recording", () => {
const ctx = setup()
const shortcut = createSpeechShortcut({
speech: ctx.speech,
disabled: () => false,
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
finish: () => ctx.speech.stop(),
mac: true,
})

expect(shortcut.down(key(0))).toBe(true)
expect(shortcut.down(key(50, true))).toBe(true)
expect(shortcut.down(key(100, true))).toBe(true)
expect(shortcut.up(key(SPEECH_HOLD_MS - 1))).toBe(true)
expect(ctx.sent).toHaveLength(1)
expect(ctx.speech.state()).toBe("starting")

const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
expect(ctx.speech.state()).toBe("recording")
ctx.dispose()
})

it("stops recording on a second quick press", () => {
const ctx = setup()
const shortcut = createSpeechShortcut({
speech: ctx.speech,
disabled: () => false,
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
finish: () => ctx.speech.stop(),
mac: true,
})

shortcut.down(key(0))
shortcut.up(key(100))
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })

shortcut.down(key(200))
shortcut.down(key(250, true))
shortcut.up(key(300))
expect(ctx.speech.state()).toBe("transcribing")
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })
ctx.dispose()
})

it("queues transcription and submit when a held press is released during startup", () => {
const ctx = setup()
let submitted = 0
const shortcut = createSpeechShortcut({
speech: ctx.speech,
disabled: () => false,
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
finish: (submit) => ctx.speech.stop(submit ? { done: () => submitted++ } : undefined),
mac: true,
})

shortcut.down(key(0))
shortcut.up(key(SPEECH_HOLD_MS))
expect(ctx.speech.state()).toBe("starting")
expect(ctx.sent).toHaveLength(1)

const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })
expect(ctx.speech.state()).toBe("transcribing")
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })

ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Held prompt" })
expect(submitted).toBe(1)
ctx.dispose()
})

it("submits when macOS suppresses K key-up and only reports Command release", () => {
const ctx = setup()
let submitted = 0
const shortcut = createSpeechShortcut({
speech: ctx.speech,
disabled: () => false,
start: () => ctx.speech.start({ model: "scribe", insert: () => {} }),
finish: (submit) => ctx.speech.stop(submit ? { done: () => submitted++ } : undefined),
mac: true,
})

shortcut.down(key(0))
const start = ctx.sent[0]
if (start?.type !== "speechToTextStart") throw new Error("speech start message missing")
ctx.fire({ type: "speechToTextStarted", requestId: start.requestId })

expect(shortcut.up({ key: "Meta", timeStamp: SPEECH_HOLD_MS })).toBe(true)
expect(ctx.speech.state()).toBe("transcribing")
expect(ctx.sent[1]).toEqual({ type: "speechToTextStop", requestId: start.requestId })

ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Held prompt" })
expect(submitted).toBe(1)
ctx.dispose()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import { useLanguage } from "../src/context/language"
import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments"
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut"
import { convertToMentionPath } from "../src/utils/path-mentions"
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
Expand Down Expand Up @@ -345,6 +346,12 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
}

const onKey = (e: KeyboardEvent) => {
if (shortcut.down(e)) {
e.preventDefault()
e.stopPropagation()
return
}

// Shift+Tab cycles reasoning effort variants (setting: chat.shiftTabCyclesVariant).
// When disabled or no variants exist, fall through to default focus navigation.
if (e.key === "Tab" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
Expand Down Expand Up @@ -394,6 +401,19 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
speech.start({ model: speechModel(), insert: insertSpeechText })
}

const shortcut = createSpeechShortcut({
speech,
disabled: () => !canUseSpeech() || starting(),
start: startSpeech,
finish: (submit) => speech.stop(submit ? { done: handleSubmit } : undefined),
})
const speechUp = (e: KeyboardEvent) => {
if (!shortcut.up(e)) return
e.preventDefault()
e.stopPropagation()
}
onCleanup(shortcut.reset)

const canEnhance = () => !starting() && !enhancing() && !speech.active() && server.isConnected()

const handleEnhance = () => {
Expand Down Expand Up @@ -571,6 +591,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
adjustHeight()
}}
onKeyDown={onKey}
onKeyUp={speechUp}
onPaste={(e) => imageAttach.handlePaste(e)}
rows={3}
dir="auto"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render as renderSolid } from "solid-js/web"
import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton"
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
import type { SpeechState, SpeechToText } from "../src/components/speech-to-text/useSpeechToText"
import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut"
import { reviewAnnotationSpeechKey, type AnnotationMeta } from "./review-annotations"

type Props = {
Expand All @@ -13,10 +14,14 @@ type Props = {
keys: Accessor<Set<string>>
}

type Event = Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "repeat" | "timeStamp">

type Node = {
host: HTMLDivElement
dispose: VoidFunction
setTextarea: (textarea: HTMLTextAreaElement) => void
down: (event: Event, submit: () => void) => boolean
up: (event: Pick<KeyboardEvent, "key" | "timeStamp">) => boolean
}

function insertReviewSpeechText(textarea: HTMLTextAreaElement, value: string): void {
Expand Down Expand Up @@ -55,9 +60,9 @@ export function createReviewAnnotationSpeechRenderer(props: Props) {
error: () => (mine() ? props.speech.error() : undefined),
active: () => mine() && props.speech.active(),
start: (opts) => start(opts.model),
stop: () => {
stop: (opts) => {
if (!mine()) return
props.speech.stop()
props.speech.stop(opts)
},
cancel: () => {
if (!mine()) return
Expand All @@ -71,6 +76,13 @@ export function createReviewAnnotationSpeechRenderer(props: Props) {
},
}
const blocked = () => props.speech.active() && !mine()
let submit = () => {}
const shortcut = createSpeechShortcut({
speech,
disabled: () => !props.enabled() || blocked(),
start: () => start(props.model()),
finish: (send) => speech.stop(send ? { done: submit } : undefined),
})

const dispose = createRoot((root) => {
const view = renderSolid(
Expand All @@ -92,10 +104,18 @@ export function createReviewAnnotationSpeechRenderer(props: Props) {

const node = {
host,
dispose,
dispose: () => {
shortcut.reset()
dispose()
},
setTextarea: (next: HTMLTextAreaElement) => {
field = next
},
down: (event: Event, next: () => void) => {
submit = next
return shortcut.down(event)
},
up: shortcut.up,
Comment thread
marius-kilocode marked this conversation as resolved.
}
nodes.set(key, node)
return node
Expand Down Expand Up @@ -134,6 +154,16 @@ export function createReviewAnnotationSpeechRenderer(props: Props) {

return {
active: props.speech.active,
down: (meta: AnnotationMeta, event: Event, submit: () => void) => {
const key = reviewAnnotationSpeechKey(meta)
if (!key) return false
return nodes.get(key)?.down(event, submit) ?? false
},
up: (meta: AnnotationMeta, event: Pick<KeyboardEvent, "key" | "timeStamp">) => {
const key = reviewAnnotationSpeechKey(meta)
if (!key) return false
return nodes.get(key)?.up(event) ?? false
},
render,
}
}
35 changes: 29 additions & 6 deletions packages/kilo-vscode/webview-ui/diff-viewer/review-annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ interface AnnotationHandlers {
speech?: {
active: () => boolean
render: (meta: AnnotationMeta, textarea: HTMLTextAreaElement) => HTMLElement | undefined
down: (meta: AnnotationMeta, event: KeyboardEvent, submit: () => void) => boolean
up: (meta: AnnotationMeta, event: KeyboardEvent) => boolean
}
}

Expand Down Expand Up @@ -309,6 +311,11 @@ export function buildReviewAnnotation(
})

textarea.addEventListener("keydown", (event) => {
if (handlers.speech?.down(meta, event, send)) {
event.preventDefault()
event.stopPropagation()
return
}
if (event.key === "Escape") {
event.preventDefault()
handlers.cancelDraft()
Expand All @@ -319,6 +326,11 @@ export function buildReviewAnnotation(
submit()
}
})
textarea.addEventListener("keyup", (event) => {
if (!handlers.speech?.up(meta, event)) return
event.preventDefault()
event.stopPropagation()
})
textarea.addEventListener("input", update)

return wrapper
Expand Down Expand Up @@ -363,28 +375,39 @@ export function buildReviewAnnotation(
handlers.setEditing(null)
})

saveButton.addEventListener("click", (event) => {
event.stopPropagation()
const save = () => {
if (handlers.speech?.active()) return
const text = textarea.value.trim()
if (!text) return
handlers.updateComment(comment.id, text)
}

saveButton.addEventListener("click", (event) => {
event.stopPropagation()
save()
})

textarea.addEventListener("keydown", (event) => {
if (handlers.speech?.down(meta, event, save)) {
event.preventDefault()
event.stopPropagation()
return
}
if (event.key === "Escape") {
event.preventDefault()
handlers.setEditing(null)
return
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (handlers.speech?.active()) return
const text = textarea.value.trim()
if (!text) return
handlers.updateComment(comment.id, text)
save()
}
})
textarea.addEventListener("keyup", (event) => {
if (!handlers.speech?.up(meta, event)) return
event.preventDefault()
event.stopPropagation()
})

return wrapper
}
Expand Down
Loading
Loading