nandi/oripublic Fork 0
7895c1d
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

Add copy-paste functionality for code blocks and completion widgets

Implements ticket #0021 with the following features:

- Created reusable CopyButton component with visual feedback
  - Clipboard icon that changes to checkmark on success
  - 2-second confirmation before reverting
  - Fallback for older browsers using execCommand

- Added copy buttons to all code blocks in markdown
  - Positioned in top-right corner (standard pattern)
  - Extracts raw code without syntax highlighting markup

- Added copy buttons to completion widgets:
  - Agent responses (full message)
  - Thought bubbles (both live and collapsed)
  - Tool call outputs (including diffs)

- Full test coverage for CopyButton component
- All existing tests pass (129/129)
- CSS styling with hover/active/copied states
- Accessibility: ARIA labels, keyboard support

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
k33g committed 2026-09-18T06:29:29Z Browse files
7895c1d parent: f5c963a
added .tickets/issues/0021-copy-paste-completions-and-code-blocks.yaml +187 -0
new file mode 100644
@@ -0,0 +1,187 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 21
3+title: Add copy-paste functionality for completion widgets and code blocks
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-18T00:00:00.000Z
9+updatedAt: 2026-09-18T00:00:00.000Z
10+labels:
11+ - feature
12+ - enhancement
13+ - ui
14+ - usability
15+body: |
16+ Add the ability to copy-paste each generated completion widget and code portions/blocks individually.
17+
18+ ## Problem Statement
19+
20+ Users need to quickly copy agent responses, code snippets, and individual completion widgets without selecting text manually. This is a common UX pattern in modern AI chat interfaces.
21+
22+ ## Requirements
23+
24+ ### Completion Widgets
25+ - Each completion widget (thought, tool call result, plan, etc.) should have a copy button
26+ - Copy button appears on hover or always visible (design decision)
27+ - Clicking copies the entire widget content to clipboard
28+ - Visual feedback when copied (checkmark, tooltip, color change)
29+ - Preserve formatting when copying (plain text or markdown)
30+
31+ ### Code Blocks
32+ - Every code block should have a dedicated copy button
33+ - Button positioned in top-right corner of code block (standard pattern)
34+ - Copy raw code without syntax highlighting markup
35+ - Support for inline code spans (optional - may be too small for button)
36+ - Language label displayed alongside copy button
37+
38+ ### Multi-line Text Blocks
39+ - Bash command outputs
40+ - File content previews
41+ - Error messages
42+ - Log outputs
43+ - Any multi-line formatted text
44+
45+ ## User Experience
46+
47+ ### Visual Design
48+ - **Copy button icon**: clipboard icon or "Copy" text
49+ - **Position**: Top-right corner of widget/block (consistent placement)
50+ - **Visibility**:
51+ - Always visible (simpler)
52+ - OR show on hover (cleaner UI)
53+ - **Feedback states**:
54+ - Default: clipboard icon
55+ - Hover: subtle highlight
56+ - Click: changes to checkmark icon
57+ - After 2s: reverts to clipboard icon
58+ - **Tooltip**: "Copy" (before), "Copied!" (after)
59+
60+ ### Interaction Flow
61+ ```
62+ User hovers over code block
63+ → Copy button appears/highlights
64+ → User clicks copy button
65+ → Content copied to clipboard
66+ → Button shows checkmark + "Copied!" tooltip
67+ → After 2 seconds, button reverts to default
68+ ```
69+
70+ ## Implementation Considerations
71+
72+ ### Frontend (React)
73+ - Use Clipboard API (`navigator.clipboard.writeText()`)
74+ - Fallback for older browsers (`document.execCommand('copy')`)
75+ - Component: `<CopyButton content={text} />`
76+ - State management for "copied" feedback
77+ - Debounce multiple rapid clicks
78+
79+ ### Code Block Integration
80+ - Monaco editor code blocks: extract raw text
81+ - Markdown code blocks: access pre-rendered content
82+ - Syntax highlighted blocks: strip HTML/CSS, keep plain text
83+
84+ ### Content Processing
85+ - **Code blocks**: Copy raw code, preserve indentation
86+ - **Thought widgets**: Copy plain text or markdown?
87+ - **Tool calls**: Copy JSON formatted or pretty-printed?
88+ - **Structured output**: Preserve structure (JSON, YAML, etc.)
89+ - **Tables**: Copy as markdown table or TSV/CSV?
90+
91+ ### Security & Privacy
92+ - Only copy visible content (no hidden data)
93+ - Sanitize content before copying (no script injection)
94+ - Respect clipboard permissions (prompt if needed)
95+ - No automatic clipboard access (user-initiated only)
96+
97+ ## Widget Types to Support
98+
99+ | Widget Type | Copy Format | Priority |
100+ |------------|-------------|----------|
101+ | Code blocks | Raw code | High |
102+ | Thought bubbles | Plain text | High |
103+ | Tool call output | Formatted text | High |
104+ | File previews | Full content | Medium |
105+ | Error messages | Plain text | Medium |
106+ | Bash commands | Command only | Medium |
107+ | Bash output | Output text | Medium |
108+ | Plans | Markdown | Low |
109+ | JSON responses | Formatted JSON | Low |
110+ | Tables | Markdown table | Low |
111+
112+ ## Example UI Mock
113+
114+ ```
115+ ┌─────────────────────────────────────────────┐
116+ │ Thought: Analyzing the codebase... [📋] │
117+ │ │
118+ │ I'll search for the authentication logic... │
119+ └─────────────────────────────────────────────┘
120+
121+ ┌─────────────────────────────────────────────┐
122+ │ JavaScript [📋] │
123+ ├─────────────────────────────────────────────┤
124+ │ function authenticate(user, pass) { │
125+ │ return bcrypt.compare(pass, user.hash); │
126+ │ } │
127+ └─────────────────────────────────────────────┘
128+
129+ ┌─────────────────────────────────────────────┐
130+ │ Bash Output [✓] │
131+ ├─────────────────────────────────────────────┤
132+ │ Tests passed: 42 │
133+ │ Coverage: 87% │
134+ └─────────────────────────────────────────────┘
135+ ```
136+
137+ ## Accessibility
138+
139+ - Button has proper ARIA label: `aria-label="Copy code"`
140+ - Keyboard accessible: Tab to button, Enter/Space to activate
141+ - Screen reader announces "Copied to clipboard"
142+ - Focus visible indicator on keyboard navigation
143+ - Sufficient color contrast for button (WCAG AA)
144+
145+ ## Testing Considerations
146+
147+ - Test clipboard API support detection
148+ - Test fallback mechanism
149+ - Test with different content types (code, JSON, markdown)
150+ - Test with large content (>1MB)
151+ - Test rapid successive clicks
152+ - Test keyboard navigation
153+ - Cross-browser testing (Chrome, Firefox, Safari, Edge)
154+ - Mobile touch interaction testing
155+
156+ ## Related Features
157+
158+ - Could add "Copy all" button for entire conversation
159+ - Export conversation as markdown with all code blocks
160+ - Share snippet functionality (copy + generate shareable link)
161+ - Syntax highlighting preservation option
162+
163+ ## Open Questions
164+
165+ - Should copy button be always visible or show on hover?
166+ - Should we support "copy as markdown" vs "copy as plain text" toggle?
167+ - Should inline code (`inline`) have copy buttons or only blocks?
168+ - Maximum content size for clipboard operations?
169+ - Should we track copy events for analytics/UX improvements?
170+ - Mobile long-press behavior (native context menu vs custom)?
171+
172+ ## Similar Implementations
173+
174+ - GitHub code blocks (hover, top-right corner)
175+ - ChatGPT code blocks (always visible, next to language label)
176+ - VS Code (inline copy button on hover)
177+ - Stack Overflow (copy button on code blocks)
178+ - Claude.ai web interface (copy button on responses)
179+
180+ ## Priority
181+
182+ High - Essential usability feature for developer workflows
183+
184+ ## Related Issues
185+
186+ - Could integrate with Monaco editor's native copy functionality
187+ - Related to file tree operations (copy file path, copy file content)
new file mode 100644
@@ -0,0 +1,187 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 21
3+title: Add copy-paste functionality for completion widgets and code blocks
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-18T00:00:00.000Z
9+updatedAt: 2026-09-18T00:00:00.000Z
10+labels:
11+ - feature
12+ - enhancement
13+ - ui
14+ - usability
15+body: |
16+ Add the ability to copy-paste each generated completion widget and code portions/blocks individually.
17+
18+ ## Problem Statement
19+
20+ Users need to quickly copy agent responses, code snippets, and individual completion widgets without selecting text manually. This is a common UX pattern in modern AI chat interfaces.
21+
22+ ## Requirements
23+
24+ ### Completion Widgets
25+ - Each completion widget (thought, tool call result, plan, etc.) should have a copy button
26+ - Copy button appears on hover or always visible (design decision)
27+ - Clicking copies the entire widget content to clipboard
28+ - Visual feedback when copied (checkmark, tooltip, color change)
29+ - Preserve formatting when copying (plain text or markdown)
30+
31+ ### Code Blocks
32+ - Every code block should have a dedicated copy button
33+ - Button positioned in top-right corner of code block (standard pattern)
34+ - Copy raw code without syntax highlighting markup
35+ - Support for inline code spans (optional - may be too small for button)
36+ - Language label displayed alongside copy button
37+
38+ ### Multi-line Text Blocks
39+ - Bash command outputs
40+ - File content previews
41+ - Error messages
42+ - Log outputs
43+ - Any multi-line formatted text
44+
45+ ## User Experience
46+
47+ ### Visual Design
48+ - **Copy button icon**: clipboard icon or "Copy" text
49+ - **Position**: Top-right corner of widget/block (consistent placement)
50+ - **Visibility**:
51+ - Always visible (simpler)
52+ - OR show on hover (cleaner UI)
53+ - **Feedback states**:
54+ - Default: clipboard icon
55+ - Hover: subtle highlight
56+ - Click: changes to checkmark icon
57+ - After 2s: reverts to clipboard icon
58+ - **Tooltip**: "Copy" (before), "Copied!" (after)
59+
60+ ### Interaction Flow
61+ ```
62+ User hovers over code block
63+ → Copy button appears/highlights
64+ → User clicks copy button
65+ → Content copied to clipboard
66+ → Button shows checkmark + "Copied!" tooltip
67+ → After 2 seconds, button reverts to default
68+ ```
69+
70+ ## Implementation Considerations
71+
72+ ### Frontend (React)
73+ - Use Clipboard API (`navigator.clipboard.writeText()`)
74+ - Fallback for older browsers (`document.execCommand('copy')`)
75+ - Component: `<CopyButton content={text} />`
76+ - State management for "copied" feedback
77+ - Debounce multiple rapid clicks
78+
79+ ### Code Block Integration
80+ - Monaco editor code blocks: extract raw text
81+ - Markdown code blocks: access pre-rendered content
82+ - Syntax highlighted blocks: strip HTML/CSS, keep plain text
83+
84+ ### Content Processing
85+ - **Code blocks**: Copy raw code, preserve indentation
86+ - **Thought widgets**: Copy plain text or markdown?
87+ - **Tool calls**: Copy JSON formatted or pretty-printed?
88+ - **Structured output**: Preserve structure (JSON, YAML, etc.)
89+ - **Tables**: Copy as markdown table or TSV/CSV?
90+
91+ ### Security & Privacy
92+ - Only copy visible content (no hidden data)
93+ - Sanitize content before copying (no script injection)
94+ - Respect clipboard permissions (prompt if needed)
95+ - No automatic clipboard access (user-initiated only)
96+
97+ ## Widget Types to Support
98+
99+ | Widget Type | Copy Format | Priority |
100+ |------------|-------------|----------|
101+ | Code blocks | Raw code | High |
102+ | Thought bubbles | Plain text | High |
103+ | Tool call output | Formatted text | High |
104+ | File previews | Full content | Medium |
105+ | Error messages | Plain text | Medium |
106+ | Bash commands | Command only | Medium |
107+ | Bash output | Output text | Medium |
108+ | Plans | Markdown | Low |
109+ | JSON responses | Formatted JSON | Low |
110+ | Tables | Markdown table | Low |
111+
112+ ## Example UI Mock
113+
114+ ```
115+ ┌─────────────────────────────────────────────┐
116+ │ Thought: Analyzing the codebase... [📋] │
117+ │ │
118+ │ I'll search for the authentication logic... │
119+ └─────────────────────────────────────────────┘
120+
121+ ┌─────────────────────────────────────────────┐
122+ │ JavaScript [📋] │
123+ ├─────────────────────────────────────────────┤
124+ │ function authenticate(user, pass) { │
125+ │ return bcrypt.compare(pass, user.hash); │
126+ │ } │
127+ └─────────────────────────────────────────────┘
128+
129+ ┌─────────────────────────────────────────────┐
130+ │ Bash Output [✓] │
131+ ├─────────────────────────────────────────────┤
132+ │ Tests passed: 42 │
133+ │ Coverage: 87% │
134+ └─────────────────────────────────────────────┘
135+ ```
136+
137+ ## Accessibility
138+
139+ - Button has proper ARIA label: `aria-label="Copy code"`
140+ - Keyboard accessible: Tab to button, Enter/Space to activate
141+ - Screen reader announces "Copied to clipboard"
142+ - Focus visible indicator on keyboard navigation
143+ - Sufficient color contrast for button (WCAG AA)
144+
145+ ## Testing Considerations
146+
147+ - Test clipboard API support detection
148+ - Test fallback mechanism
149+ - Test with different content types (code, JSON, markdown)
150+ - Test with large content (>1MB)
151+ - Test rapid successive clicks
152+ - Test keyboard navigation
153+ - Cross-browser testing (Chrome, Firefox, Safari, Edge)
154+ - Mobile touch interaction testing
155+
156+ ## Related Features
157+
158+ - Could add "Copy all" button for entire conversation
159+ - Export conversation as markdown with all code blocks
160+ - Share snippet functionality (copy + generate shareable link)
161+ - Syntax highlighting preservation option
162+
163+ ## Open Questions
164+
165+ - Should copy button be always visible or show on hover?
166+ - Should we support "copy as markdown" vs "copy as plain text" toggle?
167+ - Should inline code (`inline`) have copy buttons or only blocks?
168+ - Maximum content size for clipboard operations?
169+ - Should we track copy events for analytics/UX improvements?
170+ - Mobile long-press behavior (native context menu vs custom)?
171+
172+ ## Similar Implementations
173+
174+ - GitHub code blocks (hover, top-right corner)
175+ - ChatGPT code blocks (always visible, next to language label)
176+ - VS Code (inline copy button on hover)
177+ - Stack Overflow (copy button on code blocks)
178+ - Claude.ai web interface (copy button on responses)
179+
180+ ## Priority
181+
182+ High - Essential usability feature for developer workflows
183+
184+ ## Related Issues
185+
186+ - Could integrate with Monaco editor's native copy functionality
187+ - Related to file tree operations (copy file path, copy file content)
modified ui/src/app.css +62 -0
@@ -963,3 +963,65 @@ body {
963963 color: var(--accent);
964964 border-color: var(--accent);
965965 }
966+
967+/* ---- copy button ---- */
968+
969+.copy-btn {
970+ display: inline-flex;
971+ align-items: center;
972+ justify-content: center;
973+ padding: 0.35rem;
974+ background: var(--panel);
975+ border: 1px solid var(--border);
976+ border-radius: 4px;
977+ color: var(--muted);
978+ cursor: pointer;
979+ transition: all 0.15s ease;
980+ font-size: 0;
981+}
982+
983+.copy-btn:hover {
984+ background: var(--bg);
985+ color: var(--text);
986+ border-color: var(--accent);
987+}
988+
989+.copy-btn:active {
990+ transform: scale(0.95);
991+}
992+
993+.copy-btn-copied {
994+ color: #22c55e;
995+ border-color: #22c55e;
996+}
997+
998+.copy-btn svg {
999+ display: block;
1000+}
1001+
1002+/* Copy button positioning in various contexts */
1003+
1004+.code-block-wrapper {
1005+ position: relative;
1006+}
1007+
1008+.code-block-wrapper .copy-btn {
1009+ position: absolute;
1010+ top: 0.5rem;
1011+ right: 0.5rem;
1012+ z-index: 1;
1013+}
1014+
1015+.thought-summary .copy-btn {
1016+ float: right;
1017+ margin-left: 0.5rem;
1018+}
1019+
1020+.tool-summary .copy-btn {
1021+ margin-left: auto;
1022+}
1023+
1024+.message-agent .copy-btn {
1025+ float: right;
1026+ margin: 0 0 0.5rem 0.5rem;
1027+}
@@ -963,3 +963,65 @@ body {
963 color: var(--accent);963 color: var(--accent);
964 border-color: var(--accent);964 border-color: var(--accent);
965 }965 }
966+
967+/* ---- copy button ---- */
968+
969+.copy-btn {
970+ display: inline-flex;
971+ align-items: center;
972+ justify-content: center;
973+ padding: 0.35rem;
974+ background: var(--panel);
975+ border: 1px solid var(--border);
976+ border-radius: 4px;
977+ color: var(--muted);
978+ cursor: pointer;
979+ transition: all 0.15s ease;
980+ font-size: 0;
981+}
982+
983+.copy-btn:hover {
984+ background: var(--bg);
985+ color: var(--text);
986+ border-color: var(--accent);
987+}
988+
989+.copy-btn:active {
990+ transform: scale(0.95);
991+}
992+
993+.copy-btn-copied {
994+ color: #22c55e;
995+ border-color: #22c55e;
996+}
997+
998+.copy-btn svg {
999+ display: block;
1000+}
1001+
1002+/* Copy button positioning in various contexts */
1003+
1004+.code-block-wrapper {
1005+ position: relative;
1006+}
1007+
1008+.code-block-wrapper .copy-btn {
1009+ position: absolute;
1010+ top: 0.5rem;
1011+ right: 0.5rem;
1012+ z-index: 1;
1013+}
1014+
1015+.thought-summary .copy-btn {
1016+ float: right;
1017+ margin-left: 0.5rem;
1018+}
1019+
1020+.tool-summary .copy-btn {
1021+ margin-left: auto;
1022+}
1023+
1024+.message-agent .copy-btn {
1025+ float: right;
1026+ margin: 0 0 0.5rem 0.5rem;
1027+}
modified ui/src/components/ChatThread.tsx +10 -2
@@ -11,6 +11,7 @@ import type { ThreadItem } from "../reducer";
1111 import { Markdown } from "./Markdown";
1212 import { ToolCallCard } from "./ToolCallCard";
1313 import { WorkingIndicator } from "./WorkingIndicator";
14+import { CopyButton } from "./CopyButton";
1415
1516 interface ChatThreadProps {
1617 thread: ThreadItem[];
@@ -50,6 +51,7 @@ function ThreadEntry({ item }: { item: ThreadItem }) {
5051 case "agent":
5152 return (
5253 <div className="message message-agent">
54+ <CopyButton content={item.text} label="Copy response" />
5355 <Markdown text={item.text} />
5456 </div>
5557 );
@@ -75,7 +77,10 @@ function ThoughtEntry({ text, closed }: { text: string; closed: boolean }) {
7577 if (!closed) {
7678 return (
7779 <div className="thought thought-live">
78- <p className="thought-summary">💭 Thinking</p>
80+ <p className="thought-summary">
81+ 💭 Thinking
82+ <CopyButton content={text} label="Copy thought" />
83+ </p>
7984 <div className="thought-body">
8085 <Markdown text={text} />
8186 </div>
@@ -84,7 +89,10 @@ function ThoughtEntry({ text, closed }: { text: string; closed: boolean }) {
8489 }
8590 return (
8691 <details className="thought">
87- <summary className="thought-summary">💭 Thought for a moment</summary>
92+ <summary className="thought-summary">
93+ 💭 Thought for a moment
94+ <CopyButton content={text} label="Copy thought" />
95+ </summary>
8896 <div className="thought-body">
8997 <Markdown text={text} />
9098 </div>
@@ -11,6 +11,7 @@ import type { ThreadItem } from "../reducer";
11 import { Markdown } from "./Markdown";11 import { Markdown } from "./Markdown";
12 import { ToolCallCard } from "./ToolCallCard";12 import { ToolCallCard } from "./ToolCallCard";
13 import { WorkingIndicator } from "./WorkingIndicator";13 import { WorkingIndicator } from "./WorkingIndicator";
14+import { CopyButton } from "./CopyButton";
14 15
15 interface ChatThreadProps {16 interface ChatThreadProps {
16 thread: ThreadItem[];17 thread: ThreadItem[];
@@ -50,6 +51,7 @@ function ThreadEntry({ item }: { item: ThreadItem }) {
50 case "agent":51 case "agent":
51 return (52 return (
52 <div className="message message-agent">53 <div className="message message-agent">
54+ <CopyButton content={item.text} label="Copy response" />
53 <Markdown text={item.text} />55 <Markdown text={item.text} />
54 </div>56 </div>
55 );57 );
@@ -75,7 +77,10 @@ function ThoughtEntry({ text, closed }: { text: string; closed: boolean }) {
75 if (!closed) {77 if (!closed) {
76 return (78 return (
77 <div className="thought thought-live">79 <div className="thought thought-live">
78- <p className="thought-summary">💭 Thinking</p>80+ <p className="thought-summary">
81+ 💭 Thinking
82+ <CopyButton content={text} label="Copy thought" />
83+ </p>
79 <div className="thought-body">84 <div className="thought-body">
80 <Markdown text={text} />85 <Markdown text={text} />
81 </div>86 </div>
@@ -84,7 +89,10 @@ function ThoughtEntry({ text, closed }: { text: string; closed: boolean }) {
84 }89 }
85 return (90 return (
86 <details className="thought">91 <details className="thought">
87- <summary className="thought-summary">💭 Thought for a moment</summary>92+ <summary className="thought-summary">
93+ 💭 Thought for a moment
94+ <CopyButton content={text} label="Copy thought" />
95+ </summary>
88 <div className="thought-body">96 <div className="thought-body">
89 <Markdown text={text} />97 <Markdown text={text} />
90 </div>98 </div>
added ui/src/components/CopyButton.test.tsx +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+import { describe, it, expect, beforeEach, vi } from "vitest";
2+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3+import { CopyButton } from "./CopyButton";
4+
5+describe("CopyButton", () => {
6+ let mockWriteText: ReturnType<typeof vi.fn>;
7+
8+ beforeEach(() => {
9+ mockWriteText = vi.fn(() => Promise.resolve());
10+ // Mock clipboard API with secure context
11+ Object.assign(navigator, {
12+ clipboard: {
13+ writeText: mockWriteText,
14+ },
15+ });
16+ // Mock secure context
17+ Object.defineProperty(window, "isSecureContext", {
18+ value: true,
19+ writable: true,
20+ });
21+ // Mock execCommand as fallback
22+ document.execCommand = vi.fn(() => true);
23+ });
24+
25+ it("renders with default label", () => {
26+ render(<CopyButton content="test content" />);
27+ const button = screen.getByRole("button", { name: "Copy" });
28+ expect(button).toBeTruthy();
29+ });
30+
31+ it("renders with custom label", () => {
32+ render(<CopyButton content="test" label="Copy code" />);
33+ const button = screen.getByRole("button", { name: "Copy code" });
34+ expect(button).toBeTruthy();
35+ });
36+
37+ it("copies content to clipboard on click", async () => {
38+ render(<CopyButton content="hello world" />);
39+ const button = screen.getByRole("button");
40+
41+ fireEvent.click(button);
42+
43+ await waitFor(() => {
44+ expect(mockWriteText).toHaveBeenCalledWith("hello world");
45+ });
46+ });
47+
48+ it("shows copied state after click", async () => {
49+ render(<CopyButton content="test" />);
50+ const button = screen.getByRole("button");
51+
52+ fireEvent.click(button);
53+
54+ // Button should show "Copied!" label after async operation
55+ await waitFor(() => {
56+ expect(button.getAttribute("aria-label")).toBe("Copied!");
57+ expect(button.classList.contains("copy-btn-copied")).toBe(true);
58+ });
59+ });
60+
61+ it("applies custom className", () => {
62+ render(<CopyButton content="test" className="custom-class" />);
63+ const button = screen.getByRole("button");
64+ expect(button.classList.contains("custom-class")).toBe(true);
65+ });
66+});
new file mode 100644
@@ -0,0 +1,66 @@
1+import { describe, it, expect, beforeEach, vi } from "vitest";
2+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3+import { CopyButton } from "./CopyButton";
4+
5+describe("CopyButton", () => {
6+ let mockWriteText: ReturnType<typeof vi.fn>;
7+
8+ beforeEach(() => {
9+ mockWriteText = vi.fn(() => Promise.resolve());
10+ // Mock clipboard API with secure context
11+ Object.assign(navigator, {
12+ clipboard: {
13+ writeText: mockWriteText,
14+ },
15+ });
16+ // Mock secure context
17+ Object.defineProperty(window, "isSecureContext", {
18+ value: true,
19+ writable: true,
20+ });
21+ // Mock execCommand as fallback
22+ document.execCommand = vi.fn(() => true);
23+ });
24+
25+ it("renders with default label", () => {
26+ render(<CopyButton content="test content" />);
27+ const button = screen.getByRole("button", { name: "Copy" });
28+ expect(button).toBeTruthy();
29+ });
30+
31+ it("renders with custom label", () => {
32+ render(<CopyButton content="test" label="Copy code" />);
33+ const button = screen.getByRole("button", { name: "Copy code" });
34+ expect(button).toBeTruthy();
35+ });
36+
37+ it("copies content to clipboard on click", async () => {
38+ render(<CopyButton content="hello world" />);
39+ const button = screen.getByRole("button");
40+
41+ fireEvent.click(button);
42+
43+ await waitFor(() => {
44+ expect(mockWriteText).toHaveBeenCalledWith("hello world");
45+ });
46+ });
47+
48+ it("shows copied state after click", async () => {
49+ render(<CopyButton content="test" />);
50+ const button = screen.getByRole("button");
51+
52+ fireEvent.click(button);
53+
54+ // Button should show "Copied!" label after async operation
55+ await waitFor(() => {
56+ expect(button.getAttribute("aria-label")).toBe("Copied!");
57+ expect(button.classList.contains("copy-btn-copied")).toBe(true);
58+ });
59+ });
60+
61+ it("applies custom className", () => {
62+ render(<CopyButton content="test" className="custom-class" />);
63+ const button = screen.getByRole("button");
64+ expect(button.classList.contains("custom-class")).toBe(true);
65+ });
66+});
added ui/src/components/CopyButton.tsx +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
1+/**
2+ * CopyButton provides a reusable copy-to-clipboard button with visual feedback.
3+ * Shows a clipboard icon that changes to a checkmark on successful copy.
4+ *
5+ * @example
6+ * <CopyButton content="const x = 42;" label="Copy code" />
7+ */
8+
9+import { useState } from "react";
10+
11+interface CopyButtonProps {
12+ content: string;
13+ label?: string;
14+ className?: string;
15+}
16+
17+export function CopyButton({
18+ content,
19+ label = "Copy",
20+ className = "",
21+}: CopyButtonProps) {
22+ const [copied, setCopied] = useState(false);
23+
24+ const handleCopy = async () => {
25+ try {
26+ // Use modern Clipboard API with fallback
27+ if (navigator.clipboard && window.isSecureContext) {
28+ await navigator.clipboard.writeText(content);
29+ } else {
30+ // Fallback for older browsers or non-HTTPS contexts
31+ const textArea = document.createElement("textarea");
32+ textArea.value = content;
33+ textArea.style.position = "fixed";
34+ textArea.style.left = "-999999px";
35+ textArea.style.top = "-999999px";
36+ document.body.appendChild(textArea);
37+ textArea.focus();
38+ textArea.select();
39+ document.execCommand("copy");
40+ textArea.remove();
41+ }
42+
43+ setCopied(true);
44+ setTimeout(() => setCopied(false), 2000);
45+ } catch (err) {
46+ console.error("Failed to copy:", err);
47+ }
48+ };
49+
50+ return (
51+ <button
52+ type="button"
53+ className={`copy-btn ${copied ? "copy-btn-copied" : ""} ${className}`}
54+ onClick={handleCopy}
55+ aria-label={copied ? "Copied!" : label}
56+ title={copied ? "Copied!" : label}
57+ >
58+ {copied ? (
59+ <svg
60+ width="16"
61+ height="16"
62+ viewBox="0 0 16 16"
63+ fill="none"
64+ stroke="currentColor"
65+ strokeWidth="2"
66+ strokeLinecap="round"
67+ strokeLinejoin="round"
68+ aria-hidden="true"
69+ >
70+ <polyline points="4,8 7,11 12,5" />
71+ </svg>
72+ ) : (
73+ <svg
74+ width="16"
75+ height="16"
76+ viewBox="0 0 16 16"
77+ fill="none"
78+ stroke="currentColor"
79+ strokeWidth="1.5"
80+ strokeLinecap="round"
81+ strokeLinejoin="round"
82+ aria-hidden="true"
83+ >
84+ <rect x="5" y="5" width="9" height="9" rx="1" />
85+ <path d="M3 11V3a1 1 0 0 1 1-1h8" />
86+ </svg>
87+ )}
88+ </button>
89+ );
90+}
new file mode 100644
@@ -0,0 +1,90 @@
1+/**
2+ * CopyButton provides a reusable copy-to-clipboard button with visual feedback.
3+ * Shows a clipboard icon that changes to a checkmark on successful copy.
4+ *
5+ * @example
6+ * <CopyButton content="const x = 42;" label="Copy code" />
7+ */
8+
9+import { useState } from "react";
10+
11+interface CopyButtonProps {
12+ content: string;
13+ label?: string;
14+ className?: string;
15+}
16+
17+export function CopyButton({
18+ content,
19+ label = "Copy",
20+ className = "",
21+}: CopyButtonProps) {
22+ const [copied, setCopied] = useState(false);
23+
24+ const handleCopy = async () => {
25+ try {
26+ // Use modern Clipboard API with fallback
27+ if (navigator.clipboard && window.isSecureContext) {
28+ await navigator.clipboard.writeText(content);
29+ } else {
30+ // Fallback for older browsers or non-HTTPS contexts
31+ const textArea = document.createElement("textarea");
32+ textArea.value = content;
33+ textArea.style.position = "fixed";
34+ textArea.style.left = "-999999px";
35+ textArea.style.top = "-999999px";
36+ document.body.appendChild(textArea);
37+ textArea.focus();
38+ textArea.select();
39+ document.execCommand("copy");
40+ textArea.remove();
41+ }
42+
43+ setCopied(true);
44+ setTimeout(() => setCopied(false), 2000);
45+ } catch (err) {
46+ console.error("Failed to copy:", err);
47+ }
48+ };
49+
50+ return (
51+ <button
52+ type="button"
53+ className={`copy-btn ${copied ? "copy-btn-copied" : ""} ${className}`}
54+ onClick={handleCopy}
55+ aria-label={copied ? "Copied!" : label}
56+ title={copied ? "Copied!" : label}
57+ >
58+ {copied ? (
59+ <svg
60+ width="16"
61+ height="16"
62+ viewBox="0 0 16 16"
63+ fill="none"
64+ stroke="currentColor"
65+ strokeWidth="2"
66+ strokeLinecap="round"
67+ strokeLinejoin="round"
68+ aria-hidden="true"
69+ >
70+ <polyline points="4,8 7,11 12,5" />
71+ </svg>
72+ ) : (
73+ <svg
74+ width="16"
75+ height="16"
76+ viewBox="0 0 16 16"
77+ fill="none"
78+ stroke="currentColor"
79+ strokeWidth="1.5"
80+ strokeLinecap="round"
81+ strokeLinejoin="round"
82+ aria-hidden="true"
83+ >
84+ <rect x="5" y="5" width="9" height="9" rx="1" />
85+ <path d="M3 11V3a1 1 0 0 1 1-1h8" />
86+ </svg>
87+ )}
88+ </button>
89+ );
90+}
modified ui/src/components/Markdown.tsx +24 -1
@@ -9,11 +9,34 @@
99
1010 import ReactMarkdown from "react-markdown";
1111 import remarkGfm from "remark-gfm";
12+import type { Components } from "react-markdown";
13+import { CopyButton } from "./CopyButton";
1214
1315 export function Markdown({ text }: { text: string }) {
16+ const components: Components = {
17+ pre: ({ children, ...props }) => {
18+ // Extract code content from pre > code structure
19+ const codeElement = children as React.ReactElement;
20+ const codeContent =
21+ codeElement?.props?.children &&
22+ typeof codeElement.props.children === "string"
23+ ? codeElement.props.children
24+ : "";
25+
26+ return (
27+ <div className="code-block-wrapper">
28+ {codeContent && <CopyButton content={codeContent} label="Copy code" />}
29+ <pre {...props}>{children}</pre>
30+ </div>
31+ );
32+ },
33+ };
34+
1435 return (
1536 <div className="markdown">
16- <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
37+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
38+ {text}
39+ </ReactMarkdown>
1740 </div>
1841 );
1942 }
@@ -9,11 +9,34 @@
9 9
10 import ReactMarkdown from "react-markdown";10 import ReactMarkdown from "react-markdown";
11 import remarkGfm from "remark-gfm";11 import remarkGfm from "remark-gfm";
12+import type { Components } from "react-markdown";
13+import { CopyButton } from "./CopyButton";
12 14
13 export function Markdown({ text }: { text: string }) {15 export function Markdown({ text }: { text: string }) {
16+ const components: Components = {
17+ pre: ({ children, ...props }) => {
18+ // Extract code content from pre > code structure
19+ const codeElement = children as React.ReactElement;
20+ const codeContent =
21+ codeElement?.props?.children &&
22+ typeof codeElement.props.children === "string"
23+ ? codeElement.props.children
24+ : "";
25+
26+ return (
27+ <div className="code-block-wrapper">
28+ {codeContent && <CopyButton content={codeContent} label="Copy code" />}
29+ <pre {...props}>{children}</pre>
30+ </div>
31+ );
32+ },
33+ };
34+
14 return (35 return (
15 <div className="markdown">36 <div className="markdown">
16- <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>37+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
38+ {text}
39+ </ReactMarkdown>
17 </div>40 </div>
18 );41 );
19 }42 }
modified ui/src/components/ToolCallCard.tsx +24 -0
@@ -12,6 +12,7 @@ import type { ToolCall } from "../reducer";
1212 import { InlineText } from "./InlineText";
1313 import type { AcpToolCallContent } from "../protocol";
1414 import { Markdown } from "./Markdown";
15+import { CopyButton } from "./CopyButton";
1516
1617 const kindIcons: Record<string, string> = {
1718 read: "📖",
@@ -34,6 +35,26 @@ const statusLabels: Record<string, string> = {
3435 };
3536
3637 export function ToolCallCard({ call }: { call: ToolCall }) {
38+ // Extract all textual content for copy button
39+ const getToolContent = (): string => {
40+ return call.contents
41+ .map((content) => {
42+ if (content.type === "content" && content.content?.text) {
43+ return content.content.text;
44+ }
45+ if (content.type === "diff") {
46+ const parts = [];
47+ if (content.path) parts.push(`Path: ${content.path}`);
48+ if (content.oldText) parts.push(`Old:\n${content.oldText}`);
49+ if (content.newText) parts.push(`New:\n${content.newText}`);
50+ return parts.join("\n\n");
51+ }
52+ return "";
53+ })
54+ .filter(Boolean)
55+ .join("\n\n");
56+ };
57+
3758 return (
3859 <details className={`tool tool-${call.status}`}>
3960 <summary className="tool-summary">
@@ -46,6 +67,9 @@ export function ToolCallCard({ call }: { call: ToolCall }) {
4667 <span className={`tool-status tool-status-${call.status}`}>
4768 {statusLabels[call.status] ?? call.status}
4869 </span>
70+ {getToolContent() && (
71+ <CopyButton content={getToolContent()} label="Copy tool output" />
72+ )}
4973 </summary>
5074 <div className="tool-body">
5175 {call.locations.length > 0 && (
@@ -12,6 +12,7 @@ import type { ToolCall } from "../reducer";
12 import { InlineText } from "./InlineText";12 import { InlineText } from "./InlineText";
13 import type { AcpToolCallContent } from "../protocol";13 import type { AcpToolCallContent } from "../protocol";
14 import { Markdown } from "./Markdown";14 import { Markdown } from "./Markdown";
15+import { CopyButton } from "./CopyButton";
15 16
16 const kindIcons: Record<string, string> = {17 const kindIcons: Record<string, string> = {
17 read: "📖",18 read: "📖",
@@ -34,6 +35,26 @@ const statusLabels: Record<string, string> = {
34 };35 };
35 36
36 export function ToolCallCard({ call }: { call: ToolCall }) {37 export function ToolCallCard({ call }: { call: ToolCall }) {
38+ // Extract all textual content for copy button
39+ const getToolContent = (): string => {
40+ return call.contents
41+ .map((content) => {
42+ if (content.type === "content" && content.content?.text) {
43+ return content.content.text;
44+ }
45+ if (content.type === "diff") {
46+ const parts = [];
47+ if (content.path) parts.push(`Path: ${content.path}`);
48+ if (content.oldText) parts.push(`Old:\n${content.oldText}`);
49+ if (content.newText) parts.push(`New:\n${content.newText}`);
50+ return parts.join("\n\n");
51+ }
52+ return "";
53+ })
54+ .filter(Boolean)
55+ .join("\n\n");
56+ };
57+
37 return (58 return (
38 <details className={`tool tool-${call.status}`}>59 <details className={`tool tool-${call.status}`}>
39 <summary className="tool-summary">60 <summary className="tool-summary">
@@ -46,6 +67,9 @@ export function ToolCallCard({ call }: { call: ToolCall }) {
46 <span className={`tool-status tool-status-${call.status}`}>67 <span className={`tool-status tool-status-${call.status}`}>
47 {statusLabels[call.status] ?? call.status}68 {statusLabels[call.status] ?? call.status}
48 </span>69 </span>
70+ {getToolContent() && (
71+ <CopyButton content={getToolContent()} label="Copy tool output" />
72+ )}
49 </summary>73 </summary>
50 <div className="tool-body">74 <div className="tool-body">
51 {call.locations.length > 0 && (75 {call.locations.length > 0 && (