A copy button for each fenced code block in an assistant reply

Mods for the Claude Code terminal.
Adds a row of copy chips under each assistant reply that has fenced code blocks. Click a chip ( 1 bash) to copy the body of that block, without the fence lines. The chip shows ✓ 1 copied for 2 seconds.
At the prompt of a Claude Code terminal session:
/plugin install copy-blocks --marketplace wilsonfaustino/claudecode-mods
Answer y to add the marketplace, then pick a scope.
"tui": "fullscreen" in settings). The terminal reports mouse clicks on transcript rows only in this layout.copy-blocks/hooks/register.tsx.MIT
hooks/register.tsx 109 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4export type CodeBlock = { info: string; body: string }
5
6const OPENING_FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/
7// Nerd Font nf-md-content_copy.
8export const COPY_GLYPH = '\u{F018F}'
9// The engine draws a reply's first block as [bullet column of minWidth 2, text], so the text starts at column 2.
10export const BULLET_COLUMNS = 2
11const COPIED_MS = 2000
12// Catppuccin Mocha: surface0, surface1, mauve, green, subtext0.
13const IDLE_BACKGROUND = '#313244'
14const HOVER_BACKGROUND = '#45475a'
15const ACCENT_COLOR = '#cba6f7'
16const COPIED_COLOR = '#a6e3a1'
17const IDLE_COLOR = '#a6adc8'
18
19export function findCodeBlocks(text: string): CodeBlock[] {
20 const blocks: CodeBlock[] = []
21 const lines = text.split('\n')
22 let lineIndex = 0
23 while (lineIndex < lines.length) {
24 const opening = OPENING_FENCE.exec(lines[lineIndex] ?? '')
25 const fence = opening?.[1] ?? ''
26 const info = (opening?.[2] ?? '').trim()
27 // A backtick fence's info string may not hold a backtick (CommonMark).
28 if (!opening || (fence.startsWith('`') && info.includes('`'))) {
29 lineIndex++
30 continue
31 }
32 const closingFence = new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`)
33 const closingIndex = lines.findIndex((line, index) => index > lineIndex && closingFence.test(line))
34 // An unclosed fence is not a block.
35 if (closingIndex === -1) break
36 blocks.push({ info: info.split(/\s+/)[0] ?? '', body: lines.slice(lineIndex + 1, closingIndex).join('\n') })
37 lineIndex = closingIndex + 1
38 }
39 return blocks
40}
41
42export function buttonLabel(block: CodeBlock, blockNumber: number): string {
43 return `${COPY_GLYPH} ${blockNumber} ${block.info || 'text'}`
44}
45
46export function copiedLabel(blockNumber: number): string {
47 return `✓ ${blockNumber} copied`
48}
49
50// One member per message (keyed by requestId): the number of the block just copied, 0 for none.
51const copiedBlock = atom({ plugin: 'copy-blocks', key: 'copiedBlock' } as const, 0)
52
53export const register: Register = on => {
54 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
55 const blocks = findCodeBlocks(e.props.text)
56 if (e.props.isSummary || blocks.length === 0) return next(e)
57
58 const inner = await next(e)
59 const { Box, Button, Text } = $.ui.resolve(e)
60 const copiedInMessage = memberOf(copiedBlock, e)
61 const copiedBlockNumber = await read($, copiedInMessage)
62
63 return (
64 <Box flexDirection="column">
65 {inner}
66 <Box key="copy-row" flexDirection="row" flexWrap="wrap" gap={1} paddingLeft={e.props.isFirstOfReply ? BULLET_COLUMNS : 0}>
67 {blocks.map((block, index) => {
68 const blockNumber = index + 1
69 const isCopied = copiedBlockNumber === blockNumber
70 return (
71 <Box key={`chip-${blockNumber}`}>
72 <Button
73 key={`copy-${blockNumber}`}
74 plain
75 onPress={async press => {
76 const result = await $.ui.copy({ text: block.body, surface: press.surface })
77 if (!result.isCopied) {
78 $.ui.toast(`Copy failed: ${result.reason}`)
79 return
80 }
81 await update($, copiedInMessage, () => blockNumber)
82 $.clock.after(COPIED_MS, () => {
83 void update($, copiedInMessage, current => (current === blockNumber ? 0 : current))
84 })
85 }}
86 >
87 {isCopied ? (
88 <Text color={COPIED_COLOR} backgroundColor={IDLE_BACKGROUND}>
89 {` ${copiedLabel(blockNumber)} `}
90 </Text>
91 ) : (
92 <Text
93 color={IDLE_COLOR}
94 backgroundColor={IDLE_BACKGROUND}
95 hover={{ color: ACCENT_COLOR, backgroundColor: HOVER_BACKGROUND }}
96 >
97 {` ${buttonLabel(block, blockNumber)} `}
98 </Text>
99 )}
100 </Button>
101 </Box>
102 )
103 })}
104 </Box>
105 </Box>
106 )
107 })
108}
109types/index.d.ts 9 lines1// The number of the block just copied in a message, 0 for none.
2export type CopiedBlockNumber = number
3
4declare module 'claude-code' {
5 interface PluginState {
6 'copy-blocks': { copiedBlock: StateFamily<CopiedBlockNumber> }
7 }
8}
9