diff --git a/.changeset/continue-worktree-sessions.md b/.changeset/continue-worktree-sessions.md new file mode 100644 index 00000000000..95a49a2b807 --- /dev/null +++ b/.changeset/continue-worktree-sessions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep continued Agent Manager sessions in the selected worktree instead of moving them to Local. 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/.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/.github/workflows/test.yml b/.github/workflows/test.yml index 559d65f0a9f..0af05b88e17 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,11 +41,21 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + # kilocode_change start - name: Setup Node - uses: actions/setup-node@v6 # kilocode_change + id: setup-node + continue-on-error: ${{ runner.os == 'Windows' }} + uses: actions/setup-node@v6 with: node-version: "24" + - name: Retry Setup Node on Windows + if: runner.os == 'Windows' && steps.setup-node.outcome == 'failure' + uses: actions/setup-node@v6 + with: + node-version: "24" + # kilocode_change end + - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 0d97b4bd0c3..a3aea424ebd 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -137,6 +137,7 @@ import type { KiloProviderOptions } from "./kilo-provider/options" import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" +type ContextMessage = { contextDirectory?: unknown } // Helper to map agent data to the subset of fields sent to the webview const mapAgent = (a: Agent) => ({ name: a.name, @@ -647,7 +648,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.readyResolvers.splice(0).forEach((r) => r()) break case "sendMessage": { - const files = parseMessageFiles(message.files) + const msg = message as typeof message & ContextMessage await this.handleSendMessage( message.text, typeof message.messageID === "string" ? message.messageID : undefined, @@ -657,13 +658,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.modelID, message.agent, message.variant, - files, + parseMessageFiles(message.files), typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined, + typeof msg.contextDirectory === "string" ? msg.contextDirectory : undefined, ) break } case "sendCommand": { - const files = parseMessageFiles(message.files) + const msg = message as typeof message & ContextMessage await this.handleSendCommand( message.command, message.arguments, @@ -674,8 +676,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.modelID, message.agent, message.variant, - files, + parseMessageFiles(message.files), typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined, + typeof msg.contextDirectory === "string" ? msg.contextDirectory : undefined, ) break } @@ -1081,13 +1084,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break } case "fetchMarketplaceData": { - const workspace = this.getProjectDirectory(this.currentSession?.id) - const mp = this.getMarketplace() - // Fetch skills from CLI backend (authoritative source) so the - // marketplace doesn't need to duplicate the CLI's skill scanning. - const skills = await this.fetchCliSkills() - const data = await mp.fetchData(workspace, skills) - this.postMessage({ type: "marketplaceData", ...data }) + await this.handleFetchMarketplaceData() break } case "filterMarketplaceItems": { @@ -1137,6 +1134,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.diffVirtualProvider.open(d) } + private async handleFetchMarketplaceData(): Promise { + const workspace = this.getProjectDirectory(this.currentSession?.id) + const mp = this.getMarketplace() + const skills = await this.fetchCliSkills() + const data = await mp.fetchData(workspace, skills) + this.postMessage({ type: "marketplaceData", ...data }) + } + /** * Initialize connection to the CLI backend server. * Subscribes to the shared KiloConnectionService. @@ -1539,6 +1544,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper client.session.list({ directory: dir, roots: true }, { throwOnError: true }).then(({ data }) => data) : null, sessionDirectories: this.sessionDirectories, + worktreeDirectories: this.opts.worktreeDirectories, workspaceDirectory: this.getWorkspaceDirectory(), postMessage: (msg: unknown) => this.postMessage(msg), } @@ -2442,7 +2448,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper details: getConfigErrorDetails(error), }) } - private async resolveSession(sessionID?: string, draftID?: string, context?: string) { + private async resolveSession(sessionID?: string, draftID?: string, context?: string, contextDirectory?: string) { if (!this.client) return undefined const dir = resolveNewSessionDirectory({ @@ -2450,6 +2456,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper currentSessionID: this.currentSession?.id, contextSessionID: this.contextSessionID, agentManagerContext: context, + contextDirectory, sessionDirectories: this.sessionDirectories, workspaceDirectory: this.getRootDirectory(), }) @@ -2562,6 +2569,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper variant?: string, files?: MessageFile[], context?: string, + contextDirectory?: string, ): Promise { if (!this.client) { this.postMessage({ @@ -2578,7 +2586,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper let resolved: { sid: string; dir: string } | undefined try { - resolved = await this.resolveSession(sessionID, draftID, context) + resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory) const parts: Array = [] if (files) { @@ -2639,6 +2647,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper variant?: string, files?: MessageFile[], context?: string, + contextDirectory?: string, ): Promise { if (!this.client) { this.postMessage({ @@ -2655,7 +2664,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper let resolved: { sid: string; dir: string } | undefined try { - resolved = await this.resolveSession(sessionID, draftID, context) + resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory) if (messageID) { this.connectionService.recordMessageSessionId(messageID, resolved!.sid) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index f88e32e770c..f4702df5fb4 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -181,6 +181,7 @@ export class AgentManagerProvider implements Disposable { this.attachPanel( this.host.openPanel({ onBeforeMessage: (msg) => this.onMessage(msg), + worktreeDirectories: () => this.getWorktreeDirectories(), }), ) } @@ -378,7 +379,7 @@ export class AgentManagerProvider implements Disposable { if (m.type === "agentManager.deleteWorktree") return this.onDeleteWorktree(m.worktreeId) if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m.worktreeId) if (m.type === "agentManager.promoteSession") return this.onPromoteSession(m.sessionId) - if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId) + if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId, m.sessionId) if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId, m.messageId) if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId) } @@ -418,6 +419,15 @@ export class AgentManagerProvider implements Disposable { return null } + if ((m.type === "sendMessage" || m.type === "sendCommand") && !m.sessionID) { + const ctx = typeof m.agentManagerContext === "string" ? m.agentManagerContext : undefined + const worktree = ctx && ctx !== "local" ? this.getStateManager()?.getWorktree(ctx) : undefined + if (worktree) { + if (m.draftID) this.activeSessionId = m.draftID + return { ...msg, contextDirectory: worktree.path } + } + } + if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) { this.activeSessionId = m.draftID return msg @@ -1049,7 +1059,7 @@ export class AgentManagerProvider implements Disposable { } /** Add a new session to an existing worktree. */ - private async onAddSessionToWorktree(worktreeId: string): Promise { + private async onAddSessionToWorktree(worktreeId: string, sessionId?: string): Promise { let client: KiloClient try { client = this.connectionService.getClient() @@ -1068,6 +1078,26 @@ export class AgentManagerProvider implements Disposable { return null } + if (sessionId) { + if (state.getSession(sessionId)) state.moveSession(sessionId, worktreeId) + else state.addSession(sessionId, worktreeId) + this.registerWorktreeSession(sessionId, worktree.path) + this.pushState() + this.postToWebview({ + type: "agentManager.sessionAdded", + sessionId, + worktreeId, + }) + this.host.capture("Agent Manager Session Started", { + source: PLATFORM, + sessionId, + worktreeId, + existing: true, + }) + this.log(`Added existing session ${sessionId} to worktree ${worktreeId}`) + return null + } + let session: Session try { const { data } = await client.session.create( @@ -1648,6 +1678,14 @@ export class AgentManagerProvider implements Disposable { return this.panel?.sessions.getSessionDirectories() ?? new Map() } + public getWorktreeDirectories(): string[] { + return ( + this.getStateManager() + ?.getWorktrees() + .map((wt) => wt.path) ?? [] + ) + } + /** * Continue a sidebar session in a new worktree. * Captures git state, creates worktree, applies state, forks session. diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts index 0c4dd458fcf..bf1f2dbd434 100644 --- a/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/packages/kilo-vscode/src/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -101,6 +101,7 @@ function createHarness() { prBridge: { handleMessage: ReturnType } activeSessionId: string | undefined stateReady: Promise | undefined + contextTarget: ReturnType createWorktreeOnDisk: ReturnType runSetupScriptForWorktree: ReturnType createSessionInWorktree: ReturnType @@ -121,6 +122,7 @@ function createHarness() { manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) } manager.activeSessionId = undefined manager.stateReady = Promise.resolve() + manager.contextTarget = vi.fn() manager.createWorktreeOnDisk = vi.fn() manager.runSetupScriptForWorktree = vi.fn().mockResolvedValue(undefined) manager.createSessionInWorktree = vi.fn() @@ -202,4 +204,28 @@ describe("AgentManagerProvider worktree creation", () => { expect(result).toEqual({ type: "requestFileSearch", query: "src", requestId: "r1", sessionID: "session-wt" }) }) + + it("resolves new sends to the selected worktree directory", async () => { + const manager = createHarness() + const state = { + getWorktree: vi.fn().mockReturnValue({ id: "wt-1", path: "/repo/.kilo/worktrees/wt-1" }), + } + manager.getStateManager.mockReturnValue(state) + manager.contextTarget.mockResolvedValue(undefined) + + const result = await manager.onMessage({ + type: "sendMessage", + text: "continue", + agentManagerContext: "wt-1", + draftID: "draft-1", + }) + + expect(result).toEqual({ + type: "sendMessage", + text: "continue", + agentManagerContext: "wt-1", + draftID: "draft-1", + contextDirectory: "/repo/.kilo/worktrees/wt-1", + }) + }) }) diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 92970ecac71..079b610f590 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -93,6 +93,7 @@ export interface Host { */ openPanel(opts: { onBeforeMessage: (msg: Record) => Promise | null> + worktreeDirectories?: () => string[] }): PanelContext /** Get the workspace/project root path. */ diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 4a74212b7a4..f6f312fe466 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -348,6 +348,7 @@ interface OpenLocallyIn { interface AddSessionToWorktreeIn { type: "agentManager.addSessionToWorktree" worktreeId: string + sessionId?: string } interface CloseSessionIn { @@ -606,6 +607,7 @@ interface SendMessageIn { variant?: string files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }> agentManagerContext?: string + contextDirectory?: string } interface SendCommandIn { @@ -621,6 +623,7 @@ interface SendCommandIn { variant?: string files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }> agentManagerContext?: string + contextDirectory?: string } interface RequestTerminalContextIn { diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index ae00ee79614..54e2b6ce5b3 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -36,6 +36,7 @@ export class VscodeHost implements Host { openPanel(opts: { onBeforeMessage: (msg: Record) => Promise | null> + worktreeDirectories?: () => string[] }): PanelContext { const panel = vscode.window.createWebviewPanel( "kilo-code.new.AgentManagerPanel", @@ -55,6 +56,7 @@ export class VscodeHost implements Host { panel: vscode.WebviewPanel, opts: { onBeforeMessage: (msg: Record) => Promise | null> + worktreeDirectories?: () => string[] }, ): PanelContext { return this.wirePanel(panel, opts) @@ -64,6 +66,7 @@ export class VscodeHost implements Host { panel: vscode.WebviewPanel, opts: { onBeforeMessage: (msg: Record) => Promise | null> + worktreeDirectories?: () => string[] }, ): PanelContext { panel.webview.options = { @@ -88,6 +91,7 @@ export class VscodeHost implements Host { const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, { platform: PLATFORM, slimEditMetadata: true, + worktreeDirectories: () => opts.worktreeDirectories?.() ?? [], }) if (this.diffVirtual) { provider.setDiffVirtualProvider(this.diffVirtual) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 8c2ae51cdad..1ee680bc9b4 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -171,6 +171,7 @@ export function activate(context: vscode.ExtensionContext) { deserializeWebviewPanel(panel: vscode.WebviewPanel) { const ctx = agentManagerHost.wrapExistingPanel(panel, { onBeforeMessage: (msg) => agentManagerProvider.handleMessage(msg), + worktreeDirectories: () => agentManagerProvider.getWorktreeDirectories(), }) agentManagerProvider.deserializePanel(ctx) return Promise.resolve() diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index caa1f565e18..115cb69d15d 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -222,6 +222,7 @@ export interface SessionRefreshContext { connectionState: "connecting" | "connected" | "disconnected" | "error" listSessions: ((dir: string) => Promise) | null sessionDirectories: Map + worktreeDirectories?: () => string[] workspaceDirectory: string postMessage(message: unknown): void } @@ -245,7 +246,7 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise() const extra = await Promise.all( [...worktreeDirs].map((dir) => @@ -339,6 +340,7 @@ export function resolveNewSessionDirectory(input: { currentSessionID?: string contextSessionID?: string agentManagerContext?: string + contextDirectory?: string sessionDirectories: Map workspaceDirectory: string }) { @@ -350,6 +352,8 @@ export function resolveNewSessionDirectory(input: { }) } + if (input.contextDirectory) return input.contextDirectory + return resolveContextDirectory({ currentSessionID: input.currentSessionID, contextSessionID: input.contextSessionID, diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index 0d2e863fbcb..00d6dcdbf41 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -3,4 +3,5 @@ export type KiloProviderOptions = { platform?: string slimEditMetadata?: boolean tabTitle?: (title: string) => void + worktreeDirectories?: () => string[] } 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"), diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-worktree-context.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-worktree-context.test.ts index 8e0a9afbacc..017b305cc94 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-worktree-context.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-worktree-context.test.ts @@ -93,6 +93,17 @@ describe("resolveNewSessionDirectory", () => { expect(dir).toBe("/repo/.kilo/worktrees/feature") }) + it("uses explicit Agent Manager worktree context after the active session was cleared", () => { + const dir = resolveNewSessionDirectory({ + agentManagerContext: "wt_feature", + contextDirectory: "/repo/.kilo/worktrees/feature", + sessionDirectories: new Map(), + workspaceDirectory: "/repo", + }) + + expect(dir).toBe("/repo/.kilo/worktrees/feature") + }) + it("creates local Agent Manager sessions in the workspace root after a worktree was selected", () => { const dir = resolveNewSessionDirectory({ contextSessionID: "ses_worktree", diff --git a/packages/kilo-vscode/tests/unit/review-comments.test.ts b/packages/kilo-vscode/tests/unit/review-comments.test.ts index 247da56c478..f1e75a3bc94 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,53 @@ 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/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/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 8cc186ff2ea..9dbfaa1b336 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -130,6 +130,7 @@ import { createMarkdownRender } from "./review-preferences" import { createSidebarCollapse } from "./sidebar-collapse" import { SidebarToggleButton } from "./SidebarToggleButton" import { setTabWidths } from "./tab-widths" +import { buildShortcutCategories } from "./shortcuts" import "./agent-manager.css" import "./agent-manager-review.css" const REVIEW_TAB_ID = "review" @@ -185,76 +186,6 @@ const defaultBindings: Record = { ), } -/** Shortcut category definition for the keyboard shortcuts dialog */ -interface ShortcutEntry { - label: string - binding: string -} - -interface ShortcutCategory { - title: string - shortcuts: ShortcutEntry[] -} - -/** Build the categorized list of keyboard shortcuts from the current bindings */ -function buildShortcutCategories( - bindings: Record, - t: (key: string, params?: Record) => string, -): ShortcutCategory[] { - return [ - { - title: t("agentManager.shortcuts.category.quickSwitch"), - shortcuts: [ - { - label: t("agentManager.shortcuts.jumpToItem"), - binding: (() => { - const first = bindings.jumpTo1 ?? "" - const prefix = first.replace(/\d+$/, "") - return prefix ? `${prefix}1-9` : "" - })(), - }, - ], - }, - { - title: t("agentManager.shortcuts.category.sidebar"), - shortcuts: [ - { label: t("agentManager.shortcuts.previousItem"), binding: bindings.previousSession ?? "" }, - { label: t("agentManager.shortcuts.nextItem"), binding: bindings.nextSession ?? "" }, - { label: t("agentManager.shortcuts.newWorktree"), binding: bindings.newWorktree ?? "" }, - { label: t("agentManager.shortcuts.advancedWorktree"), binding: bindings.advancedWorktree ?? "" }, - { label: t("agentManager.shortcuts.deleteWorktree"), binding: bindings.closeWorktree ?? "" }, - { label: t("agentManager.shortcuts.openWorktree"), binding: bindings.openWorktree ?? "" }, - ], - }, - { - title: t("agentManager.shortcuts.category.tabs"), - shortcuts: [ - { label: t("agentManager.shortcuts.previousTab"), binding: bindings.previousTab ?? "" }, - { label: t("agentManager.shortcuts.nextTab"), binding: bindings.nextTab ?? "" }, - { label: t("agentManager.shortcuts.newTab"), binding: bindings.newTab ?? "" }, - { label: t("agentManager.shortcuts.closeTab"), binding: bindings.closeTab ?? "" }, - ], - }, - { - title: t("agentManager.shortcuts.category.terminal"), - shortcuts: [ - { label: t("agentManager.shortcuts.toggleTerminal"), binding: bindings.showTerminal ?? "" }, - { label: t("agentManager.shortcuts.runScript"), binding: bindings.runScript ?? "" }, - { label: t("agentManager.shortcuts.toggleDiff"), binding: bindings.toggleDiff ?? "" }, - ], - }, - { - title: t("agentManager.shortcuts.category.global"), - shortcuts: [ - { label: t("agentManager.shortcuts.openAgentManager"), binding: bindings.agentManagerOpen ?? "" }, - { label: t("agentManager.shortcuts.cycleAgentMode"), binding: bindings.cycleAgentMode ?? "" }, - { label: t("agentManager.shortcuts.cyclePreviousAgentMode"), binding: bindings.cyclePreviousAgentMode ?? "" }, - { label: t("agentManager.shortcuts.showShortcuts"), binding: bindings.showShortcuts ?? "" }, - ].filter((s) => s.binding), - }, - ].filter((c) => c.shortcuts.length > 0) -} - import { parseBindingTokens } from "./keybind-tokens" const AgentManagerContent: Component = () => { @@ -1003,6 +934,27 @@ const AgentManagerContent: Component = () => { setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true) } + const addSessionToCurrentWorktree = (sid: string) => { + const sel = selection() + if (!sel || sel === LOCAL) return false + const current = managedSessions().find((entry) => entry.id === sid) + if (current?.worktreeId) return focusManagedSession(current.worktreeId, sid) + saveTabMemory() + setHistory(false) + setReviewActive(false) + appendToTabOrder(sel, sid) + evictLocal(sid) + vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel, sessionId: sid }) + return true + } + + const focusManagedSession = (worktreeId: string, sid: string) => { + selectWorktree(worktreeId) + setHistory(false) + session.selectSession(sid) + return true + } + const cycleAgent = (direction: 1 | -1) => { const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden) if (available.length <= 1) return @@ -1220,6 +1172,7 @@ const AgentManagerContent: Component = () => { saveTabMemory() appendToTabOrder(ev.worktreeId, ev.sessionId) setSelection(ev.worktreeId) + evictLocal(ev.sessionId) drafts.apply(ev.worktreeId, ev.sessionId) session.selectSession(ev.sessionId) } @@ -2988,6 +2941,7 @@ const AgentManagerContent: Component = () => { { + if (addSessionToCurrentWorktree(id)) return setHistory(false) if (localSessionIDs().includes(id)) { saveTabMemory() @@ -3021,6 +2975,7 @@ const AgentManagerContent: Component = () => {
{ + if (addSessionToCurrentWorktree(id)) return if (localSessionIDs().includes(id)) { session.selectSession(id) if (selection() === null) setSelection(LOCAL) 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..48c67f73d28 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx @@ -0,0 +1,138 @@ +import { createEffect, createRoot, 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 +} + +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 start = (model: string) => { + setOwner(key) + props.speech.start({ + model, + insert: (value) => insertReviewSpeechText(field, value), + }) + } + const speech: SpeechToText = { + state, + error: () => (mine() ? props.speech.error() : undefined), + active: () => mine() && props.speech.active(), + start: (opts) => start(opts.model), + 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 dispose = createRoot((root) => { + const view = renderSolid( + () => ( + + start(props.model())} + label={props.label} + /> + + ), + host, + ) + onCleanup(view) + return root + }) + + 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) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/shortcuts.ts b/packages/kilo-vscode/webview-ui/agent-manager/shortcuts.ts new file mode 100644 index 00000000000..8f21d698a11 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/shortcuts.ts @@ -0,0 +1,67 @@ +export interface ShortcutEntry { + label: string + binding: string +} + +export interface ShortcutCategory { + title: string + shortcuts: ShortcutEntry[] +} + +export function buildShortcutCategories( + bindings: Record, + t: (key: string, params?: Record) => string, +): ShortcutCategory[] { + return [ + { + title: t("agentManager.shortcuts.category.quickSwitch"), + shortcuts: [ + { + label: t("agentManager.shortcuts.jumpToItem"), + binding: (() => { + const first = bindings.jumpTo1 ?? "" + const prefix = first.replace(/\d+$/, "") + return prefix ? `${prefix}1-9` : "" + })(), + }, + ], + }, + { + title: t("agentManager.shortcuts.category.sidebar"), + shortcuts: [ + { label: t("agentManager.shortcuts.previousItem"), binding: bindings.previousSession ?? "" }, + { label: t("agentManager.shortcuts.nextItem"), binding: bindings.nextSession ?? "" }, + { label: t("agentManager.shortcuts.newWorktree"), binding: bindings.newWorktree ?? "" }, + { label: t("agentManager.shortcuts.advancedWorktree"), binding: bindings.advancedWorktree ?? "" }, + { label: t("agentManager.shortcuts.deleteWorktree"), binding: bindings.closeWorktree ?? "" }, + { label: t("agentManager.shortcuts.openWorktree"), binding: bindings.openWorktree ?? "" }, + ], + }, + { + title: t("agentManager.shortcuts.category.tabs"), + shortcuts: [ + { label: t("agentManager.shortcuts.previousTab"), binding: bindings.previousTab ?? "" }, + { label: t("agentManager.shortcuts.nextTab"), binding: bindings.nextTab ?? "" }, + { label: t("agentManager.shortcuts.newTab"), binding: bindings.newTab ?? "" }, + { label: t("agentManager.shortcuts.closeTab"), binding: bindings.closeTab ?? "" }, + ], + }, + { + title: t("agentManager.shortcuts.category.terminal"), + shortcuts: [ + { label: t("agentManager.shortcuts.toggleTerminal"), binding: bindings.showTerminal ?? "" }, + { label: t("agentManager.shortcuts.runScript"), binding: bindings.runScript ?? "" }, + { label: t("agentManager.shortcuts.toggleDiff"), binding: bindings.toggleDiff ?? "" }, + ], + }, + { + title: t("agentManager.shortcuts.category.global"), + shortcuts: [ + { label: t("agentManager.shortcuts.openAgentManager"), binding: bindings.agentManagerOpen ?? "" }, + { label: t("agentManager.shortcuts.cycleAgentMode"), binding: bindings.cycleAgentMode ?? "" }, + { label: t("agentManager.shortcuts.cyclePreviousAgentMode"), binding: bindings.cyclePreviousAgentMode ?? "" }, + { label: t("agentManager.shortcuts.showShortcuts"), binding: bindings.showShortcuts ?? "" }, + ].filter((s) => s.binding), + }, + ].filter((c) => c.shortcuts.length > 0) +} 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) => { +