SLOPSHOPPER

copy-blocks

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

newrowstoasttimer
A shopper browsing a rack in a slop shop
README

claudecode-mods

Mods for the Claude Code terminal.

copy-blocks

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.

Install

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.

Requirements

  • Fullscreen layout ("tui": "fullscreen" in settings). The terminal reports mouse clicks on transcript rows only in this layout.
  • A Nerd Font in your terminal for the copy glyph. Without one, the glyph shows as a missing-character box.
  • The colors are hardcoded to the Catppuccin Mocha palette and suit dark themes. To change them, edit the constants at the top of copy-blocks/hooks/register.tsx.

License

MIT

Source 2 files
hooks/register.tsx 109 lines
1import { 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}
109
types/index.d.ts 9 lines
1// 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