summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-22 06:29:33 -0600
committerAdam <[email protected]>2026-01-22 06:29:38 -0600
commitfb007d6bab1258ac21f6d9b7efc4b7149499cf8f (patch)
treedb07d04398b5f072ac211de0cb328135e2ba01be
parent4ca088ed121c293819f5da13c0d2ff2a616ad597 (diff)
downloadopencode-fb007d6bab1258ac21f6d9b7efc4b7149499cf8f.tar.gz
opencode-fb007d6bab1258ac21f6d9b7efc4b7149499cf8f.zip
feat(app): copy buttons for assistant messages and code blocks
-rw-r--r--.opencode/bun.lock18
-rw-r--r--.opencode/package.json5
-rw-r--r--packages/ui/src/components/markdown.css29
-rw-r--r--packages/ui/src/components/markdown.tsx128
-rw-r--r--packages/ui/src/components/message-part.css20
-rw-r--r--packages/ui/src/components/message-part.tsx28
-rw-r--r--packages/ui/src/components/session-turn.css18
-rw-r--r--packages/ui/src/components/session-turn.tsx45
8 files changed, 282 insertions, 9 deletions
diff --git a/.opencode/bun.lock b/.opencode/bun.lock
new file mode 100644
index 000000000..e78ccc941
--- /dev/null
+++ b/.opencode/bun.lock
@@ -0,0 +1,18 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "dependencies": {
+ "@opencode-ai/plugin": "0.0.0-dev-202601211610",
+ },
+ },
+ },
+ "packages": {
+ "@opencode-ai/plugin": ["@opencode-ai/[email protected]", "", { "dependencies": { "@opencode-ai/sdk": "0.0.0-dev-202601211610", "zod": "4.1.8" } }, "sha512-7yBM53Xr7B7fsJlR0kItHi7Rubqyasruj+A167aaXImO3lNczIH9IMizAU+f1O73u0fJYqvs+BGaU/eXOHdaRA=="],
+
+ "@opencode-ai/sdk": ["@opencode-ai/[email protected]", "", {}, "sha512-p6hg+eZqz+kVIZqOQYhQwnRfW9s0Fojqb9f+i//cZ8a0Vj5RBwcySkQDA8CwSK1gVWuNwHfy8RLrjGxdxAaS5g=="],
+
+ "zod": ["[email protected]", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
+ }
+}
diff --git a/.opencode/package.json b/.opencode/package.json
new file mode 100644
index 000000000..deaf49716
--- /dev/null
+++ b/.opencode/package.json
@@ -0,0 +1,5 @@
+{
+ "dependencies": {
+ "@opencode-ai/plugin": "0.0.0-dev-202601211610"
+ }
+} \ No newline at end of file
diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css
index 1cbcf6f97..a30510a8d 100644
--- a/packages/ui/src/components/markdown.css
+++ b/packages/ui/src/components/markdown.css
@@ -111,6 +111,35 @@
border: 0.5px solid var(--border-weak-base);
}
+ [data-component="markdown-code"] {
+ position: relative;
+ }
+
+ [data-slot="markdown-copy-button"] {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ z-index: 1;
+ }
+
+ [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
+ opacity: 1;
+ }
+
+ [data-slot="markdown-copy-button"] [data-slot="check-icon"] {
+ display: none;
+ }
+
+ [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] {
+ display: none;
+ }
+
+ [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] {
+ display: inline-flex;
+ }
+
pre {
margin-top: 2rem;
margin-bottom: 2rem;
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index 3aefe04da..f7a1ec16f 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -1,7 +1,8 @@
import { useMarked } from "../context/marked"
+import { useI18n } from "../context/i18n"
import DOMPurify from "dompurify"
import { checksum } from "@opencode-ai/util/encode"
-import { ComponentProps, createResource, splitProps } from "solid-js"
+import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
type Entry = {
@@ -32,11 +33,120 @@ const config = {
FORBID_CONTENTS: ["style", "script"],
}
+const iconPaths = {
+ copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
+ check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
+}
+
function sanitize(html: string) {
if (!DOMPurify.isSupported) return ""
return DOMPurify.sanitize(html, config)
}
+type CopyLabels = {
+ copy: string
+ copied: string
+}
+
+function createIcon(path: string, slot: string) {
+ const icon = document.createElement("div")
+ icon.setAttribute("data-component", "icon")
+ icon.setAttribute("data-size", "small")
+ icon.setAttribute("data-slot", slot)
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
+ svg.setAttribute("data-slot", "icon-svg")
+ svg.setAttribute("fill", "none")
+ svg.setAttribute("viewBox", "0 0 20 20")
+ svg.setAttribute("aria-hidden", "true")
+ svg.innerHTML = path
+ icon.appendChild(svg)
+ return icon
+}
+
+function createCopyButton(labels: CopyLabels) {
+ const button = document.createElement("button")
+ button.type = "button"
+ button.setAttribute("data-component", "icon-button")
+ button.setAttribute("data-variant", "secondary")
+ button.setAttribute("data-size", "normal")
+ button.setAttribute("data-slot", "markdown-copy-button")
+ button.setAttribute("aria-label", labels.copy)
+ button.setAttribute("title", labels.copy)
+ button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
+ button.appendChild(createIcon(iconPaths.check, "check-icon"))
+ return button
+}
+
+function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
+ if (copied) {
+ button.setAttribute("data-copied", "true")
+ button.setAttribute("aria-label", labels.copied)
+ button.setAttribute("title", labels.copied)
+ return
+ }
+ button.removeAttribute("data-copied")
+ button.setAttribute("aria-label", labels.copy)
+ button.setAttribute("title", labels.copy)
+}
+
+function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
+ const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
+
+ const updateLabel = (button: HTMLButtonElement) => {
+ const copied = button.getAttribute("data-copied") === "true"
+ setCopyState(button, labels, copied)
+ }
+
+ const ensureWrapper = (block: HTMLPreElement) => {
+ const parent = block.parentElement
+ if (!parent) return
+ const wrapped = parent.getAttribute("data-component") === "markdown-code"
+ if (wrapped) return
+ const wrapper = document.createElement("div")
+ wrapper.setAttribute("data-component", "markdown-code")
+ parent.replaceChild(wrapper, block)
+ wrapper.appendChild(block)
+ wrapper.appendChild(createCopyButton(labels))
+ }
+
+ const handleClick = async (event: MouseEvent) => {
+ const target = event.target
+ if (!(target instanceof Element)) return
+ const button = target.closest('[data-slot="markdown-copy-button"]')
+ if (!(button instanceof HTMLButtonElement)) return
+ const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
+ const content = code?.textContent ?? ""
+ if (!content) return
+ const clipboard = navigator?.clipboard
+ if (!clipboard) return
+ await clipboard.writeText(content)
+ setCopyState(button, labels, true)
+ const existing = timeouts.get(button)
+ if (existing) clearTimeout(existing)
+ const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
+ timeouts.set(button, timeout)
+ }
+
+ const blocks = Array.from(root.querySelectorAll("pre"))
+ for (const block of blocks) {
+ ensureWrapper(block)
+ }
+
+ const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
+ for (const button of buttons) {
+ if (button instanceof HTMLButtonElement) updateLabel(button)
+ }
+
+ root.addEventListener("click", handleClick)
+
+ return () => {
+ root.removeEventListener("click", handleClick)
+ for (const timeout of timeouts.values()) {
+ clearTimeout(timeout)
+ }
+ }
+}
+
function touch(key: string, value: Entry) {
cache.delete(key)
cache.set(key, value)
@@ -58,6 +168,8 @@ export function Markdown(
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
const marked = useMarked()
+ const i18n = useI18n()
+ const [root, setRoot] = createSignal<HTMLDivElement>()
const [html] = createResource(
() => local.text,
async (markdown) => {
@@ -81,6 +193,19 @@ export function Markdown(
},
{ initialValue: "" },
)
+
+ createEffect(() => {
+ const container = root()
+ const content = html()
+ if (!container) return
+ if (!content) return
+ if (isServer) return
+ const cleanup = setupCodeCopy(container, {
+ copy: i18n.t("ui.message.copy"),
+ copied: i18n.t("ui.message.copied"),
+ })
+ onCleanup(cleanup)
+ })
return (
<div
data-component="markdown"
@@ -89,6 +214,7 @@ export function Markdown(
[local.class ?? ""]: !!local.class,
}}
innerHTML={html.latest}
+ ref={setRoot}
{...others}
/>
)
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 07f9aa312..d47a3a79b 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -106,8 +106,26 @@
[data-component="text-part"] {
width: 100%;
- [data-component="markdown"] {
+ [data-slot="text-part-body"] {
+ position: relative;
margin-top: 32px;
+ }
+
+ [data-slot="text-part-copy-wrapper"] {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ z-index: 1;
+ }
+
+ [data-slot="text-part-body"]:hover [data-slot="text-part-copy-wrapper"] {
+ opacity: 1;
+ }
+
+ [data-component="markdown"] {
+ margin-top: 0;
font-size: var(--font-size-base);
}
}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index d639c5224..76e88d353 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -673,14 +673,40 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
+ const i18n = useI18n()
const part = props.part as TextPart
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
const throttledText = createThrottledValue(displayText)
+ const [copied, setCopied] = createSignal(false)
+
+ const handleCopy = async () => {
+ const content = displayText()
+ if (!content) return
+ await navigator.clipboard.writeText(content)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
return (
<Show when={throttledText()}>
<div data-component="text-part">
- <Markdown text={throttledText()} cacheKey={part.id} />
+ <div data-slot="text-part-body">
+ <Markdown text={throttledText()} cacheKey={part.id} />
+ <div data-slot="text-part-copy-wrapper">
+ <Tooltip
+ value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+ placement="top"
+ gutter={8}
+ >
+ <IconButton
+ icon={copied() ? "check" : "copy"}
+ variant="secondary"
+ onClick={handleCopy}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+ />
+ </Tooltip>
+ </div>
+ </div>
</div>
</Show>
)
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 034d30247..8ff6be594 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -209,6 +209,24 @@
gap: 4px;
align-self: stretch;
+ [data-slot="session-turn-response"] {
+ position: relative;
+ width: 100%;
+ }
+
+ [data-slot="session-turn-response-copy-wrapper"] {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ z-index: 1;
+ }
+
+ [data-slot="session-turn-response"]:hover [data-slot="session-turn-response-copy-wrapper"] {
+ opacity: 1;
+ }
+
p {
font-size: var(--font-size-base);
line-height: var(--line-height-x-large);
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 1cd210499..a8aa8324b 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -22,10 +22,12 @@ import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
+import { IconButton } from "./icon-button"
import { Card } from "./card"
import { Dynamic } from "solid-js/web"
import { Button } from "./button"
import { Spinner } from "./spinner"
+import { Tooltip } from "./tooltip"
import { createStore } from "solid-js/store"
import { DateTime, DurationUnit, Interval } from "luxon"
import { createAutoScroll } from "../hooks"
@@ -356,6 +358,16 @@ export function SessionTurn(
const hasDiffs = createMemo(() => messageDiffs().length > 0)
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
+ const [copied, setCopied] = createSignal(false)
+
+ const handleCopy = async () => {
+ const content = response() ?? ""
+ if (!content) return
+ await navigator.clipboard.writeText(content)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
@@ -597,12 +609,33 @@ export function SessionTurn(
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
- <Markdown
- data-slot="session-turn-markdown"
- data-diffs={hasDiffs()}
- text={response() ?? ""}
- cacheKey={responsePartId()}
- />
+ <div data-slot="session-turn-response">
+ <Markdown
+ data-slot="session-turn-markdown"
+ data-diffs={hasDiffs()}
+ text={response() ?? ""}
+ cacheKey={responsePartId()}
+ />
+ <Show when={response()}>
+ <div data-slot="session-turn-response-copy-wrapper">
+ <Tooltip
+ value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+ placement="top"
+ gutter={8}
+ >
+ <IconButton
+ icon={copied() ? "check" : "copy"}
+ variant="secondary"
+ onClick={(event) => {
+ event.stopPropagation()
+ handleCopy()
+ }}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+ />
+ </Tooltip>
+ </div>
+ </Show>
+ </div>
</div>
<Accordion
data-slot="session-turn-accordion"