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

Keep message and response copy buttons working after switching focus away from VS Code.
1 change: 1 addition & 0 deletions packages/kilo-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
Expand Down
17 changes: 17 additions & 0 deletions packages/kilo-ui/src/context/clipboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createContext, type ParentComponent, useContext } from "solid-js"

type ClipboardContextValue = {
write: (text: string) => void | Promise<void>
}

const ClipboardContext = createContext<ClipboardContextValue>({
write: (text) => navigator.clipboard.writeText(text),
})

export const ClipboardProvider: ParentComponent<ClipboardContextValue> = (props) => (
<ClipboardContext.Provider value={{ write: props.write }}>{props.children}</ClipboardContext.Provider>
)

export function useClipboard() {
return useContext(ClipboardContext)
}
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
) {
Expand Down
24 changes: 23 additions & 1 deletion packages/kilo-vscode/src/kilo-provider/early-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,32 @@ type Ctx = {
dir: string
post: (msg: unknown) => void
exportTranscript: (sessionID: string) => Promise<void>
copy: (text: string) => PromiseLike<void>
openSessions: (ids: string[]) => void
}

export async function routeEarlyMessage(message: { type: string }, ctx: Ctx): Promise<boolean> {
export async function routeEarlyMessage(
message: { type: string; id?: unknown; text?: unknown },
ctx: Ctx,
): Promise<boolean> {
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") {
Expand Down
44 changes: 44 additions & 0 deletions packages/kilo-vscode/tests/unit/early-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "bun:test"
import { routeEarlyMessage } from "../../src/kilo-provider/early-message"

type Ctx = Parameters<typeof routeEarlyMessage>[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" }])
})
})
34 changes: 33 additions & 1 deletion packages/kilo-vscode/webview-ui/src/context/vscode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +43,7 @@ const VSCodeContext = createContext<VSCodeContextValue>()
export const VSCodeProvider: ParentComponent = (props) => {
const api = getVSCodeAPI()
const handlers = new Set<(message: ExtensionMessage) => void>()
const copies = new Map<string, { resolve: () => 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
Expand All @@ -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))
}

Expand All @@ -63,6 +76,7 @@ export const VSCodeProvider: ParentComponent = (props) => {
onCleanup(() => {
window.removeEventListener("message", messageListener)
handlers.clear()
copies.clear()
})

const value: VSCodeContextValue = {
Expand All @@ -82,7 +96,25 @@ export const VSCodeProvider: ParentComponent = (props) => {
},
}

return <VSCodeContext.Provider value={value}>{props.children}</VSCodeContext.Provider>
return (
<VSCodeContext.Provider value={value}>
<ClipboardProvider
write={(text) =>
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}
</ClipboardProvider>
</VSCodeContext.Provider>
)
}

export function useVSCode(): VSCodeContextValue {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1241,6 +1248,7 @@ export type ExtensionMessage =
| TelemetryStateMessage
| RemoteStatusMessage
| ValidateFilesResultMessage
| ClipboardWriteResultMessage
| MemoryLoadedMessage
| MemoryEventMessage
| MemoryOperationResultMessage
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading