11import { TextAttributes } from '@opentui/core'
2- import React from 'react'
2+ import React , { useMemo , useState } from 'react'
33
44import { useHoverToggle } from './agent-mode-toggle'
55import { Button } from './button'
66import { useTheme } from '../hooks/use-theme'
7+ import { useTimeout } from '../hooks/use-timeout'
78import { copyTextToClipboard } from '../utils/clipboard'
9+ import type { ContentBlock } from '../types/chat'
810
911interface CopyIconButtonProps {
10- textToCopy : string
12+ blocks ?: ContentBlock [ ]
13+ content ?: string
14+ }
15+
16+ const BULLET_CHAR = '•'
17+
18+ const extractTextFromBlocks = ( blocks ?: ContentBlock [ ] ) : string => {
19+ if ( ! blocks || blocks . length === 0 ) return ''
20+
21+ const textParts : string [ ] = [ ]
22+ const agentToolGroup : string [ ] = [ ]
23+
24+ for ( const block of blocks ) {
25+ if ( block . type === 'text' ) {
26+ // Flush any accumulated agent/tool names
27+ if ( agentToolGroup . length > 0 ) {
28+ // Remove trailing whitespace from last text block
29+ if ( textParts . length > 0 ) {
30+ const lastIndex = textParts . length - 1
31+ textParts [ lastIndex ] = textParts [ lastIndex ] . trimEnd ( )
32+ }
33+ // Add agent/tool names with bullets
34+ textParts . push ( agentToolGroup . join ( '\n' ) )
35+ // Add blank line after agent/tool group
36+ textParts . push ( '' )
37+ agentToolGroup . length = 0
38+ }
39+ textParts . push ( block . content )
40+ } else if ( block . type === 'agent' ) {
41+ // Only include agent name, not nested content
42+ agentToolGroup . push ( `${ BULLET_CHAR } ${ block . agentName } ` )
43+ } else if ( block . type === 'tool' ) {
44+ // Only include tool name, not nested content
45+ agentToolGroup . push ( `${ BULLET_CHAR } ${ block . toolName } ` )
46+ }
47+ // Skip other block types (html, agent-list, etc.)
48+ }
49+
50+ // Flush any remaining agent/tool names at the end
51+ if ( agentToolGroup . length > 0 ) {
52+ if ( textParts . length > 0 ) {
53+ const lastIndex = textParts . length - 1
54+ textParts [ lastIndex ] = textParts [ lastIndex ] . trimEnd ( )
55+ }
56+ textParts . push ( agentToolGroup . join ( '\n' ) )
57+ textParts . push ( '' )
58+ }
59+
60+ return textParts . join ( '\n' ) . trim ( )
1161}
1262
1363export const CopyIconButton : React . FC < CopyIconButtonProps > = ( {
14- textToCopy,
64+ blocks,
65+ content,
1566} ) => {
1667 const theme = useTheme ( )
1768 const hover = useHoverToggle ( )
69+ const { setTimeout } = useTimeout ( )
70+ const [ isCopied , setIsCopied ] = useState ( false )
71+
72+ // Compute text to copy from blocks or content
73+ const textToCopy = useMemo ( ( ) => {
74+ return blocks && blocks . length > 0
75+ ? extractTextFromBlocks ( blocks ) || content || ''
76+ : content || ''
77+ } , [ blocks , content ] )
1878
1979 const handleClick = async ( ) => {
2080 try {
2181 await copyTextToClipboard ( textToCopy , {
22- successMessage : 'Message copied to clipboard' ,
23- durationMs : 2000 ,
82+ suppressGlobalMessage : true ,
2483 } )
84+ setIsCopied ( true )
85+ setTimeout ( 'reset-copied' , ( ) => setIsCopied ( false ) , 2000 )
2586 } catch ( error ) {
2687 // Error is already logged and displayed by copyTextToClipboard
2788 }
2889 }
2990
3091 const handleMouseOver = ( ) => {
31- hover . clearCloseTimer ( )
32- hover . scheduleOpen ( )
92+ if ( ! isCopied ) {
93+ hover . clearCloseTimer ( )
94+ hover . scheduleOpen ( )
95+ }
3396 }
3497
3598 const handleMouseOut = ( ) => {
36- hover . scheduleClose ( )
99+ if ( ! isCopied ) {
100+ hover . scheduleClose ( )
101+ }
37102 }
38103
39104 const textCollapsed = '⎘'
40105 const textExpanded = '[⎘ copy]'
106+ const textCopied = '[✔ copied]'
41107
42108 return (
43109 < Button
@@ -54,10 +120,12 @@ export const CopyIconButton: React.FC<CopyIconButtonProps> = ({
54120 < text
55121 style = { {
56122 wrapMode : 'none' ,
57- fg : hover . isOpen ? theme . foreground : theme . muted ,
123+ fg : isCopied ? 'green' : hover . isOpen ? theme . foreground : theme . muted ,
58124 } }
59125 >
60- { hover . isOpen ? (
126+ { isCopied ? (
127+ textCopied
128+ ) : hover . isOpen ? (
61129 textExpanded
62130 ) : (
63131 < span attributes = { TextAttributes . DIM } > { textCollapsed } </ span >
0 commit comments