From 14a25b037bbbd7596aea88729989511ffe4dfce6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 15 May 2026 09:26:03 +0200 Subject: [PATCH 1/7] feat(agent-manager): add voice input to inline review comments --- .changeset/inline-review-speech.md | 5 + .../tests/unit/review-comments.test.ts | 54 ++++++++ .../webview-ui/agent-manager/DiffPanel.tsx | 32 +++++ .../agent-manager/FullScreenDiffView.tsx | 32 +++++ .../agent-manager/agent-manager.css | 5 + .../review-annotation-speech.tsx | 129 ++++++++++++++++++ .../agent-manager/review-annotations.ts | 27 ++++ 7 files changed, 284 insertions(+) create mode 100644 .changeset/inline-review-speech.md create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx diff --git a/.changeset/inline-review-speech.md b/.changeset/inline-review-speech.md new file mode 100644 index 00000000000..30cf6eeb87b --- /dev/null +++ b/.changeset/inline-review-speech.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support speech-to-text voice input in Agent Manager inline review comments. diff --git a/packages/kilo-vscode/tests/unit/review-comments.test.ts b/packages/kilo-vscode/tests/unit/review-comments.test.ts index 247da56c478..ca9b9353cfe 100644 --- a/packages/kilo-vscode/tests/unit/review-comments.test.ts +++ b/packages/kilo-vscode/tests/unit/review-comments.test.ts @@ -8,6 +8,11 @@ import { type ReviewComment, } from "../../webview-ui/agent-manager/review-comments" import { markdownCommentBlocks } from "../../webview-ui/agent-manager/markdown-comment-ranges" +import { + reviewAnnotationSpeechKey, + reviewDraftSpeechKey, + reviewEditSpeechKey, +} from "../../webview-ui/agent-manager/review-annotations" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" function diff(file: string, before: string, after: string): WorktreeFileDiff { @@ -236,6 +241,55 @@ describe("markdownCommentBlocks", () => { }) }) +// ── review annotation speech keys ─────────────────────────────────────────── + +describe("review annotation speech keys", () => { + it("keeps draft keys scoped to the selected review location", () => { + expect(reviewDraftSpeechKey({ file: "src/a.ts", side: "additions", line: 4 })).toBe( + "draft:src/a.ts:additions:4:4", + ) + expect(reviewDraftSpeechKey({ file: "src/a.ts", side: "deletions", line: 4, endLine: 8 })).toBe( + "draft:src/a.ts:deletions:4:8", + ) + }) + + it("keeps edit keys scoped to the review comment id", () => { + expect(reviewEditSpeechKey("c-123")).toBe("edit:c-123") + }) + + it("only exposes annotation speech keys for active draft and edit composers", () => { + const current = comment({ file: "src/a.ts", line: 7 }) + expect( + reviewAnnotationSpeechKey({ + type: "draft", + comment: null, + file: "src/a.ts", + side: "additions", + line: 7, + }), + ).toBe("draft:src/a.ts:additions:7:7") + expect( + reviewAnnotationSpeechKey({ + type: "comment", + comment: current, + file: current.file, + side: current.side, + line: current.line, + editing: true, + }), + ).toBe(`edit:${current.id}`) + expect( + reviewAnnotationSpeechKey({ + type: "comment", + comment: current, + file: current.file, + side: current.side, + line: current.line, + }), + ).toBeUndefined() + }) +}) + // ── getDirectory / getFilename ────────────────────────────────────────────── describe("getDirectory", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 72de493aabd..f7b01b37690 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -14,13 +14,22 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie import type { WorktreeFileDiff } from "../src/types/messages" import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" import { useLanguage } from "../src/context/language" +import { useVSCode } from "../src/context/vscode" +import { useServer } from "../src/context/server" +import { useProvider } from "../src/context/provider" +import { useConfig } from "../src/context/config" +import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" +import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" import { buildFileAnnotations, buildReviewAnnotation, + reviewDraftSpeechKey, + reviewEditSpeechKey, type AnnotationLabels, type AnnotationMeta, } from "./review-annotations" +import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" import { LONG_DIFF_MARKER_FILE_COUNT, allOpenFiles, @@ -59,6 +68,13 @@ interface DiffPanelProps { export const DiffPanel: Component = (props) => { const { t } = useLanguage() + const vscode = useVSCode() + const server = useServer() + const provider = useProvider() + const { config, settings } = useConfig() + const speech = useSpeechToText(vscode, server, { t }) + const canUseSpeech = () => canUseSpeechToText(settings(), config(), provider.connected(), server.profileData()) + const speechModel = () => selectedSpeechToTextModel(settings()) const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) const sendAllKeybind = () => isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other") @@ -78,6 +94,21 @@ export const DiffPanel: Component = (props) => { null, ) const [editing, setEditing] = createSignal(null) + const speechKeys = createMemo(() => { + const keys = new Set() + const current = draft() + const edit = editing() + if (current) keys.add(reviewDraftSpeechKey(current)) + if (edit) keys.add(reviewEditSpeechKey(edit)) + return keys + }) + const reviewSpeech = createReviewAnnotationSpeechRenderer({ + speech, + enabled: canUseSpeech, + model: speechModel, + label: t, + keys: speechKeys, + }) let nextId = 0 // Tracks the session key for which initial open state has already run. When the // key changes (different worktree) we expand reviewable files. Within the same key, @@ -307,6 +338,7 @@ export const DiffPanel: Component = (props) => { cancelDraft, labels: labels(), activeTerminalId: props.activeTerminalId, + speech: reviewSpeech, }) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index 6f8b8828f77..e9ad869f4a8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -20,15 +20,24 @@ import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pie import type { WorktreeFileDiff } from "../src/types/messages" import { KILO_FILE_PATH_MIME } from "../src/utils/path-mentions" import { useLanguage } from "../src/context/language" +import { useVSCode } from "../src/context/vscode" +import { useServer } from "../src/context/server" +import { useProvider } from "../src/context/provider" +import { useConfig } from "../src/context/config" +import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability" +import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText" import { FileTree } from "./FileTree" import { treeOrder } from "./file-tree-utils" import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments" import { buildFileAnnotations, buildReviewAnnotation, + reviewDraftSpeechKey, + reviewEditSpeechKey, type AnnotationLabels, type AnnotationMeta, } from "./review-annotations" +import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" import { LONG_DIFF_MARKER_FILE_COUNT, allOpenFiles, @@ -69,6 +78,13 @@ interface FullScreenDiffViewProps { export const FullScreenDiffView: Component = (props) => { const { t } = useLanguage() + const vscode = useVSCode() + const server = useServer() + const provider = useProvider() + const { config, settings } = useConfig() + const speech = useSpeechToText(vscode, server, { t }) + const canUseSpeech = () => canUseSpeechToText(settings(), config(), provider.connected(), server.profileData()) + const speechModel = () => selectedSpeechToTextModel(settings()) const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) const sendAllKeybind = () => isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other") @@ -88,6 +104,21 @@ export const FullScreenDiffView: Component = (props) => null, ) const [editing, setEditing] = createSignal(null) + const speechKeys = createMemo(() => { + const keys = new Set() + const current = draft() + const edit = editing() + if (current) keys.add(reviewDraftSpeechKey(current)) + if (edit) keys.add(reviewEditSpeechKey(edit)) + return keys + }) + const reviewSpeech = createReviewAnnotationSpeechRenderer({ + speech, + enabled: canUseSpeech, + model: speechModel, + label: t, + keys: speechKeys, + }) const [activeFile, setActiveFile] = createSignal(null) const [treeWidth, setTreeWidth] = createSignal(240) let nextId = 0 @@ -322,6 +353,7 @@ export const FullScreenDiffView: Component = (props) => cancelDraft, labels: labels(), activeTerminalId: props.activeTerminalId, + speech: reviewSpeech, }) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 305359b3dc7..ad549141990 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -2146,6 +2146,11 @@ body.am-wt-dragging-active * { gap: 6px; } +.am-annotation-speech { + display: flex; + align-items: center; +} + .am-annotation-btn { padding: 2px 10px; font-size: inherit; diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx b/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx new file mode 100644 index 00000000000..ae8314e1b52 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx @@ -0,0 +1,129 @@ +import { createEffect, createSignal, onCleanup, Show, type Accessor } from "solid-js" +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 { reviewAnnotationSpeechKey, type AnnotationMeta } from "./review-annotations" + +type Props = { + speech: SpeechToText + enabled: Accessor + model: Accessor + label: (key: string) => string + keys: Accessor> +} + +type Node = { + host: HTMLDivElement + dispose: VoidFunction + setTextarea: (textarea: HTMLTextAreaElement) => void +} + +export function insertReviewSpeechText(textarea: HTMLTextAreaElement, value: string): void { + const text = textarea.value + const start = textarea.selectionStart ?? text.length + const end = textarea.selectionEnd ?? start + const result = insertSpacedText(text, value, start, end) + + textarea.value = result.text + textarea.setSelectionRange(result.pos, result.pos) + textarea.focus() +} + +export function createReviewAnnotationSpeechRenderer(props: Props) { + const nodes = new Map() + const [owner, setOwner] = createSignal() + + const mount = (key: string, textarea: HTMLTextAreaElement): Node | undefined => { + if (typeof document === "undefined") return undefined + + const host = document.createElement("div") + host.className = "am-annotation-speech" + let field = textarea + const mine = () => owner() === key + const state = (): SpeechState => (mine() ? props.speech.state() : "idle") + const speech: SpeechToText = { + state, + error: () => (mine() ? props.speech.error() : undefined), + active: () => mine() && props.speech.active(), + start: props.speech.start, + stop: () => { + if (!mine()) return + props.speech.stop() + }, + cancel: () => { + if (!mine()) return + props.speech.cancel() + setOwner(undefined) + }, + clear: () => { + if (!mine()) return + props.speech.clear() + setOwner(undefined) + }, + } + const blocked = () => props.speech.active() && !mine() + const start = () => { + setOwner(key) + props.speech.start({ + model: props.model(), + insert: (value) => insertReviewSpeechText(field, value), + }) + } + + const dispose = renderSolid( + () => ( + + + + ), + host, + ) + + const node = { + host, + dispose, + setTextarea: (next: HTMLTextAreaElement) => { + field = next + }, + } + nodes.set(key, node) + return node + } + + const render = (meta: AnnotationMeta, textarea: HTMLTextAreaElement): HTMLElement | undefined => { + const key = reviewAnnotationSpeechKey(meta) + if (!key) return undefined + const node = nodes.get(key) ?? mount(key, textarea) + if (!node) return undefined + node.setTextarea(textarea) + return node.host + } + + createEffect(() => { + const keys = props.keys() + const current = owner() + if (current && !keys.has(current)) { + if (props.speech.active()) props.speech.cancel() + if (props.speech.state() === "error") props.speech.clear() + setOwner(undefined) + } + + for (const [key, node] of nodes) { + if (keys.has(key)) continue + node.dispose() + nodes.delete(key) + } + }) + + onCleanup(() => { + if (owner() && props.speech.active()) props.speech.cancel() + for (const [, node] of nodes) node.dispose() + nodes.clear() + }) + + return { + active: props.speech.active, + render, + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts index 3c46aac071c..380a501998e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts @@ -26,6 +26,22 @@ export interface AnnotationMeta { editing?: boolean } +type SpeechDraft = Pick + +export function reviewDraftSpeechKey(draft: SpeechDraft): string { + return `draft:${draft.file}:${draft.side}:${draft.line}:${draft.endLine ?? draft.line}` +} + +export function reviewEditSpeechKey(id: string): string { + return `edit:${id}` +} + +export function reviewAnnotationSpeechKey(meta: AnnotationMeta): string | undefined { + if (meta.type === "draft") return reviewDraftSpeechKey(meta) + if (!meta.editing || !meta.comment) return undefined + return reviewEditSpeechKey(meta.comment.id) +} + interface AnnotationHandlers { diffs: WorktreeFileDiff[] editing: string | null @@ -36,6 +52,10 @@ interface AnnotationHandlers { cancelDraft: () => void labels: AnnotationLabels activeTerminalId?: string + speech?: { + active: () => boolean + render: (meta: AnnotationMeta, textarea: HTMLTextAreaElement) => HTMLElement | undefined + } } function focusWhenConnected(el: HTMLTextAreaElement): void { @@ -150,6 +170,8 @@ export function buildReviewAnnotation( submitButton.className = "am-annotation-btn am-annotation-btn-submit" submitButton.textContent = handlers.labels.comment + const speech = handlers.speech?.render(meta, textarea) + if (speech) actions.appendChild(speech) actions.appendChild(cancelButton) actions.appendChild(submitButton) wrapper.appendChild(header) @@ -159,6 +181,7 @@ export function buildReviewAnnotation( focusWhenConnected(textarea) const submit = () => { + if (handlers.speech?.active()) return const text = textarea.value.trim() if (!text) return const diff = handlers.diffs.find((item) => item.file === meta.file) @@ -216,6 +239,8 @@ export function buildReviewAnnotation( saveButton.className = "am-annotation-btn am-annotation-btn-submit" saveButton.textContent = handlers.labels.save + const speech = handlers.speech?.render(meta, textarea) + if (speech) actions.appendChild(speech) actions.appendChild(cancelButton) actions.appendChild(saveButton) wrapper.appendChild(header) @@ -231,6 +256,7 @@ export function buildReviewAnnotation( saveButton.addEventListener("click", (event) => { event.stopPropagation() + if (handlers.speech?.active()) return const text = textarea.value.trim() if (!text) return handlers.updateComment(comment.id, text) @@ -244,6 +270,7 @@ export function buildReviewAnnotation( } 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) From 62bd0289104501afccf9a961cd6907bd9cfff742 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 15 May 2026 09:34:27 +0200 Subject: [PATCH 2/7] test(agent-manager): include speech annotation source --- packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index c1ce05633dd..fc4de9e19f8 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -30,6 +30,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/DiffEndMarker.tsx"), path.join(ROOT, "webview-ui/agent-manager/FileTree.tsx"), path.join(ROOT, "webview-ui/agent-manager/review-annotations.ts"), + path.join(ROOT, "webview-ui/agent-manager/review-annotation-speech.tsx"), path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), From 4b816f621baa163c8aaf3ec84ce15219d77bae19 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 15 May 2026 09:45:44 +0200 Subject: [PATCH 3/7] fix(vscode): send recorded prompts from send button --- .changeset/swift-voices-send.md | 5 ++ .../tests/unit/use-speech-to-text.test.ts | 84 +++++++++++++++++++ .../src/components/chat/PromptInput.tsx | 64 ++++++++++---- .../speech-to-text/useSpeechToText.ts | 17 +++- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 1 + 23 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 .changeset/swift-voices-send.md create mode 100644 packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts diff --git a/.changeset/swift-voices-send.md b/.changeset/swift-voices-send.md new file mode 100644 index 00000000000..6ee05869f9b --- /dev/null +++ b/.changeset/swift-voices-send.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Send recorded voice prompts directly when the prompt send button is pressed. diff --git a/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts b/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts new file mode 100644 index 00000000000..66c0b74a2f8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/use-speech-to-text.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { useSpeechToText } from "../../webview-ui/src/components/speech-to-text/useSpeechToText" +import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages" + +function setup() { + const sent: WebviewMessage[] = [] + let handler: ((message: ExtensionMessage) => void) | undefined + + const root = createRoot((dispose) => ({ + dispose, + speech: useSpeechToText( + { + postMessage: (message) => sent.push(message), + onMessage: (next) => { + handler = next + return () => { + handler = undefined + } + }, + }, + { profileData: () => ({}), goToLogin: () => {} }, + { t: (key) => key }, + ), + })) + + const fire = (message: ExtensionMessage) => handler?.(message) + return { ...root, fire, sent } +} + +describe("useSpeechToText", () => { + it("runs the stop completion after inserting a transcript", () => { + const ctx = setup() + const text: string[] = [] + let done = 0 + + 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.speech.stop({ done: () => done++ }) + ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Recorded prompt" }) + + expect(text).toEqual(["Recorded prompt"]) + expect(done).toBe(1) + expect(ctx.speech.state()).toBe("idle") + ctx.dispose() + }) + + it("drops the stop completion when transcription is cancelled", () => { + const ctx = setup() + let done = 0 + + 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({ done: () => done++ }) + ctx.speech.cancel() + ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Ignored prompt" }) + + expect(done).toBe(0) + expect(ctx.speech.state()).toBe("idle") + ctx.dispose() + }) + + it("drops the stop completion when the send context changes", () => { + const ctx = setup() + const text: string[] = [] + let done = 0 + + 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.speech.stop({ done: () => done++, ready: () => false }) + ctx.fire({ type: "speechToTextResult", requestId: start.requestId, text: "Keep as draft" }) + + expect(text).toEqual(["Keep as draft"]) + expect(done).toBe(0) + expect(ctx.speech.state()).toBe("idle") + ctx.dispose() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 27d984c56a0..1047bd13d1c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -335,8 +335,17 @@ export const PromptInput: Component = (props) => { const speechModel = () => selectedSpeechToTextModel(settings()) const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 const canSend = () => - hasInput() && !isDisabled() && !speech.active() && !terminal.pending() && !git.pending() && !props.blocked?.() - const showStop = () => isBusy() && !hasInput() + !isDisabled() && + !terminal.pending() && + !git.pending() && + !props.blocked?.() && + (speech.state() === "recording" || (hasInput() && !speech.active())) + const sendLabel = () => { + if (props.blocked?.()) return language.t("prompt.action.send.blocked") + if (speech.state() === "recording") return language.t("prompt.action.send.recording") + return language.t("prompt.action.send") + } + const showStop = () => isBusy() && !hasInput() && speech.state() !== "recording" const isAtEnd = () => textareaRef ? atEnd(textareaRef.selectionStart, textareaRef.selectionEnd, textareaRef.value.length) : false const highlightMentions = () => { @@ -675,6 +684,33 @@ export const PromptInput: Component = (props) => { speech.start({ model: speechModel(), insert: insertSpeechText }) } + const transcribeAndSend = () => { + const key = draftKey() + const id = sid() + const context = ctx() + const value = text() + const comments = reviewComments() + const images = imageAttach.images() + speech.stop({ + done: () => void handleSend(), + ready: () => + draftKey() === key && + sid() === id && + ctx() === context && + text() === value && + reviewComments() === comments && + imageAttach.images() === images, + }) + } + + const handleSendClick = () => { + if (speech.state() !== "recording" || !canSend()) { + void handleSend() + return + } + transcribeAndSend() + } + const handleSend = async () => { const draft = text().trim() @@ -721,6 +757,8 @@ export const PromptInput: Component = (props) => { const pendingId = props.pendingSessionID ?? session.draftSessionID() const id = sid() const sel = session.selected(id) + const context = ctx() + const key = draftKey() const terminalFile = await terminal.resolveAttachment(message, id).catch((err: Error) => { showToast({ variant: "error", title: "Terminal context unavailable", description: err.message }) @@ -742,16 +780,20 @@ export const PromptInput: Component = (props) => { ] const attachments = allFiles.length > 0 ? allFiles : undefined - const key = draftKey() // Server-side slash command (cmdMatch/matched already computed above) if (matched) { const rest = draft.slice(cmdMatch![0].length).trim() const args = review && rest ? `${review}\n\n${rest}` : rest || review - session.sendCommand(matched.name, args, sel?.providerID, sel?.modelID, attachments, pendingId, ctx()) + session.sendCommand(matched.name, args, sel?.providerID, sel?.modelID, attachments, pendingId, context) } else { - session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, ctx()) + session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context) } + drafts.delete(key) + reviewDrafts.delete(key) + imageDrafts.delete(key) + if (draftKey() !== key) return + history.append(draft) history.reset() setText("") @@ -759,9 +801,6 @@ export const PromptInput: Component = (props) => { imageAttach.clear() mention.closeMention() slash.close() - drafts.delete(key) - reviewDrafts.delete(key) - imageDrafts.delete(key) if (textareaRef) textareaRef.style.height = "auto" } @@ -1085,16 +1124,13 @@ export const PromptInput: Component = (props) => { +