diff --git a/.changeset/reliable-vscode-message-copy.md b/.changeset/reliable-vscode-message-copy.md new file mode 100644 index 00000000000..566918cf939 --- /dev/null +++ b/.changeset/reliable-vscode-message-copy.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep message and response copy buttons working after switching focus away from VS Code. diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 29444b544a4..bb66539bf66 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -66,6 +66,7 @@ "./text-strikethrough": "./src/components/text-strikethrough.tsx", "./context": "./src/context/index.ts", "./context/code": "./src/context/code.tsx", + "./context/clipboard": "./src/context/clipboard.tsx", "./context/data": "./src/context/data.tsx", "./context/dialog": "./src/context/dialog.tsx", "./context/diff": "./src/context/diff.tsx", diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index ecbc45b2fc9..664eb58a890 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -30,6 +30,7 @@ import { import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" +import { useClipboard } from "../context/clipboard" import { type UiI18n, useI18n } from "../context/i18n" import { GenericTool, BasicTool } from "./basic-tool" import { Accordion } from "./accordion" @@ -749,6 +750,7 @@ export function UserMessageDisplay(props: { const data = useData() const dialog = useDialog() const i18n = useI18n() + const clipboard = useClipboard() const [copied, setCopied] = createSignal(false) const textPart = createMemo( @@ -811,7 +813,7 @@ export function UserMessageDisplay(props: { const handleCopy = async () => { const content = props.copyText ?? text() if (!content) return - await navigator.clipboard.writeText(content) + await clipboard.write(content) setCopied(true) setTimeout(() => setCopied(false), 2000) } @@ -1309,6 +1311,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() const i18n = useI18n() + const clipboard = useClipboard() const part = () => props.part as TextPart const displayText = () => (part().text ?? "").trim() @@ -1347,7 +1350,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const handleCopy = async () => { const content = displayText() if (!content) return - await navigator.clipboard.writeText(content) + await clipboard.write(content) setCopied(true) setTimeout(() => setCopied(false), 2000) } diff --git a/packages/kilo-ui/src/context/clipboard.tsx b/packages/kilo-ui/src/context/clipboard.tsx new file mode 100644 index 00000000000..3cc19dfb861 --- /dev/null +++ b/packages/kilo-ui/src/context/clipboard.tsx @@ -0,0 +1,17 @@ +import { createContext, type ParentComponent, useContext } from "solid-js" + +type ClipboardContextValue = { + write: (text: string) => void | Promise +} + +const ClipboardContext = createContext({ + write: (text) => navigator.clipboard.writeText(text), +}) + +export const ClipboardProvider: ParentComponent = (props) => ( + {props.children} +) + +export function useClipboard() { + return useContext(ClipboardContext) +} diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 2f9d8784a66..43504a267d9 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -932,6 +932,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper dir: this.getWorkspaceDirectory(this.currentSession?.id), post: (msg) => this.postMessage(msg), exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID), + copy: (text) => vscode.env.clipboard.writeText(text), openSessions: (ids) => this.trackOpenSessions(ids), }) ) { diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index 67cb6634b2f..1273d65584e 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -12,10 +12,32 @@ type Ctx = { dir: string post: (msg: unknown) => void exportTranscript: (sessionID: string) => Promise + copy: (text: string) => PromiseLike openSessions: (ids: string[]) => void } -export async function routeEarlyMessage(message: { type: string }, ctx: Ctx): Promise { +export async function routeEarlyMessage( + message: { type: string; id?: unknown; text?: unknown }, + ctx: Ctx, +): Promise { + if (message.type === "copyToClipboard") { + if (typeof message.id !== "string") return true + if (typeof message.text !== "string") { + ctx.post({ type: "clipboardWriteResult", id: message.id, ok: false, error: "Invalid clipboard text" }) + return true + } + await ctx.copy(message.text).then( + () => ctx.post({ type: "clipboardWriteResult", id: message.id, ok: true }), + (err) => + ctx.post({ + type: "clipboardWriteResult", + id: message.id, + ok: false, + error: err instanceof Error ? err.message : String(err), + }), + ) + return true + } await routeSuggestionWebviewMessage(ctx.question, message) if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true if (message.type === "exportSessionTranscript") { diff --git a/packages/kilo-vscode/tests/unit/early-message.test.ts b/packages/kilo-vscode/tests/unit/early-message.test.ts new file mode 100644 index 00000000000..6075faff48a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/early-message.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "bun:test" +import { routeEarlyMessage } from "../../src/kilo-provider/early-message" + +type Ctx = Parameters[1] + +function context(copied: string[], posted: unknown[], fail = false) { + return { + copy: async (text: string) => { + if (fail) throw new Error("clipboard unavailable") + copied.push(text) + }, + post: (message: unknown) => posted.push(message), + } as Ctx +} + +describe("routeEarlyMessage clipboard handling", () => { + it("routes clipboard text to the host", async () => { + const copied: string[] = [] + const posted: unknown[] = [] + + const handled = await routeEarlyMessage( + { type: "copyToClipboard", id: "copy-1", text: "message text" }, + context(copied, posted), + ) + + expect(handled).toBe(true) + expect(copied).toEqual(["message text"]) + expect(posted).toEqual([{ type: "clipboardWriteResult", id: "copy-1", ok: true }]) + }) + + it("reports host clipboard failures", async () => { + const copied: string[] = [] + const posted: unknown[] = [] + + const handled = await routeEarlyMessage( + { type: "copyToClipboard", id: "copy-2", text: "message text" }, + context(copied, posted, true), + ) + + expect(handled).toBe(true) + expect(copied).toEqual([]) + expect(posted).toEqual([{ type: "clipboardWriteResult", id: "copy-2", ok: false, error: "clipboard unavailable" }]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/context/vscode.tsx b/packages/kilo-vscode/webview-ui/src/context/vscode.tsx index e446cb07601..b314d6f0179 100644 --- a/packages/kilo-vscode/webview-ui/src/context/vscode.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/vscode.tsx @@ -5,6 +5,7 @@ import { createContext, useContext, onCleanup, ParentComponent, createSignal } from "solid-js" import type { VSCodeAPI, WebviewMessage, ExtensionMessage } from "../types/messages" +import { ClipboardProvider } from "@kilocode/kilo-ui/context/clipboard" // Get the VS Code API (only available in webview context) let vscodeApi: VSCodeAPI | undefined @@ -42,6 +43,7 @@ const VSCodeContext = createContext() export const VSCodeProvider: ParentComponent = (props) => { const api = getVSCodeAPI() const handlers = new Set<(message: ExtensionMessage) => void>() + const copies = new Map void; reject: (err: Error) => void }>() // Model-selector expand/collapse preference. Stored in extension globalState // so it is shared across webviews (sidebar + agent-manager panel); a local @@ -51,6 +53,17 @@ export const VSCodeProvider: ParentComponent = (props) => { // Listen for messages from the extension const messageListener = (event: MessageEvent) => { const message = event.data as ExtensionMessage + if (message.type === "clipboardWriteResult") { + const copy = copies.get(message.id) + if (!copy) return + copies.delete(message.id) + if (message.ok) { + copy.resolve() + return + } + copy.reject(new Error(message.error ?? "Failed to write to clipboard")) + return + } handlers.forEach((handler) => handler(message)) } @@ -63,6 +76,7 @@ export const VSCodeProvider: ParentComponent = (props) => { onCleanup(() => { window.removeEventListener("message", messageListener) handlers.clear() + copies.clear() }) const value: VSCodeContextValue = { @@ -82,7 +96,25 @@ export const VSCodeProvider: ParentComponent = (props) => { }, } - return {props.children} + return ( + + + new Promise((resolve, reject) => { + const id = crypto.randomUUID() + copies.set(id, { resolve, reject }) + api.postMessage({ type: "copyToClipboard", id, text }) + setTimeout(() => { + if (!copies.delete(id)) return + reject(new Error("Clipboard write timed out")) + }, 5000) + }) + } + > + {props.children} + + + ) } export function useVSCode(): VSCodeContextValue { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index c3132d9d77c..3a2ac96e57c 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -1079,6 +1079,13 @@ export interface ValidateFilesResultMessage { existing: string[] } +export interface ClipboardWriteResultMessage { + type: "clipboardWriteResult" + id: string + ok: boolean + error?: string +} + export type ExtensionMessage = | ReadyMessage | FontSizeChangedMessage @@ -1241,6 +1248,7 @@ export type ExtensionMessage = | TelemetryStateMessage | RemoteStatusMessage | ValidateFilesResultMessage + | ClipboardWriteResultMessage | MemoryLoadedMessage | MemoryEventMessage | MemoryOperationResultMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index fd6db5b42ed..c5bc53059ca 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -678,7 +678,8 @@ export interface OpenWorktreeRequest { // Copy text to the system clipboard via the extension host export interface CopyToClipboardRequest { - type: "agentManager.copyToClipboard" + type: "copyToClipboard" + id: string text: string }