A one-click Copy button under every code block in Claude's replies, plus /copyblock, so you get clean text without the box.

A small collection of Claude Code mods (hooks-module plugins), shared as a plugin marketplace. Use any of them, change them, pass them on: they're MIT-licensed.
claude plugin marketplace add hearsawho25/roberts-mods
claude plugin install <mod>@roberts-mods
Install as many as you like, then run /reload-plugins or start a new session.
| Mod | Command | What it does | |
|---|---|---|---|
roberts-toolbox | /toolbox | One button at the end of the prompt hint line that opens a menu of your mods and the commands that change Claude Code. | |
clean-view | `/simple on\ | off` | Hides tool calls, diffs and command output, and shows one plain-English checklist above the prompt instead. |
prompt-upgrade | /better | Turns a rough prompt into a better one in a pane: up to three quick questions, a rewrite, and a side-by-side review before anything is sent. | |
daily-challenge | /challenge | One small Claude Code exercise a day, with a streak and a year of challenges that never repeat. | |
work-report | /report | Counts the tasks Claude finished for you and estimates the hours and dollars saved, by day, week, month and year. | |
copy-blocks | /copyblock [n] | A Copy button under every code block in Claude's replies, so you get clean text without the frame. | |
relay | /relay, /handoff, /keepwarm | A context and prompt-cache meter above the prompt, one-click handoffs to a fresh session, and cache warming with a cold-cache guard. See relay/README.md. | |
git-ship | /ship | Repo status above the prompt, and a pane whose buttons fill ready-to-run git and gh commands (commit messages and PRs drafted for you). It never runs a write: you press Enter. See git-ship/README.md. | |
mod-doctor | /mod-doctor | Checks that every enabled mod actually loaded. Stays quiet until one didn't, then shows a red row and the reason from claude plugin validate. See mod-doctor/README.md. |
Several of these started as other people's ideas, and they deserve the credit:
The mods themselves were built with Claude Code.
MIT.
hooks/register.tsx 57 lines1import type { EngineInterface, Register, RenderSurface } from 'claude-code'
2
3import { describeBlock, parseIndex, pickBlock, splitAfterBlocks } from './blocks'
4
5const COMMAND = 'copyblock'
6
7/** Puts a block's text on the clipboard and says how it went in a toast. */
8async function copyText($: EngineInterface, text: string, what: string, surface?: RenderSurface): Promise<boolean> {
9 const copied = await $.ui.copy(surface === undefined ? { text } : { text, surface }).catch(() => ({ isCopied: false as const }))
10 $.ui.toast(copied.isCopied ? `Copied ${what}` : 'Could not reach the clipboard')
11 return copied.isCopied
12}
13
14export const register: Register = on => {
15 // Registered first so Robert's Toolbox "Open" chip runs it.
16 on('session.start', async ($, e, next) => {
17 await $.command.register({
18 name: COMMAND,
19 description: 'Copy a code block from my replies, clean (no box): /copyblock = last, /copyblock 2 = the one before',
20 })
21 return next(e)
22 })
23
24 on('command.run', { command: COMMAND }, async ($, e) => {
25 const n = parseIndex(e.args)
26 if (n === undefined) return { text: 'Usage: /copyblock (the last block) or /copyblock 2 (the one before it), and so on.' }
27 const messages = await $.session.messages()
28 const replies = messages.filter(m => m.role === 'assistant').map(m => m.text)
29 const block = pickBlock(replies, n)
30 if (block === undefined) return { text: n === 1 ? 'No code blocks in this conversation yet.' : `There aren't ${n} code blocks in this conversation yet.` }
31 const ok = await copyText($, block.text, describeBlock(block))
32 return { text: ok ? `Copied block ${n === 1 ? '(the last one)' : `#${n} from the end`}: ${describeBlock(block)}.` : 'Could not reach the clipboard.' }
33 })
34
35 // Draws the reply as Claude Code does, a piece at a time, with a Copy button under each finished block.
36 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
37 if (e.props.isSummary === true) return next(e)
38 const pieces = splitAfterBlocks(e.props.text)
39 if (!pieces.some(piece => piece.block?.isClosed === true)) return next(e)
40
41 const { Box, Button } = $.ui.resolve(e)
42 const rows = []
43 for (const [i, piece] of pieces.entries()) {
44 rows.push(await next({ ...e, props: { ...e.props, text: piece.text, isFirstOfReply: e.props.isFirstOfReply && i === 0 } }))
45 const block = piece.block
46 if (block?.isClosed === true) {
47 rows.push(
48 <Box key={`copy-row-${i}`} marginLeft={2}>
49 <Button key={`copy-${i}`} label="⧉ Copy" onPress={press => void copyText($, block.text, describeBlock(block), press.surface)} />
50 </Box>,
51 )
52 }
53 }
54 return <Box key="copy-blocks" flexDirection="column">{rows}</Box>
55 })
56}
57hooks/blocks.ts 86 lines1/** One fenced block of a reply: the text a copy puts on the clipboard, and where the block ends. */
2export type Block = {
3 /** The lines between the fences, verbatim: no fence lines, no added indent. */
4 text: string
5 /** The fence's info string (`bash`, `json`), '' when it has none. */
6 language: string
7 /** The index into the source just past the closing fence's line (or the end, while one streams unclosed). */
8 end: number
9 /** False while the closing fence has not arrived yet. */
10 isClosed: boolean
11}
12
13const OPENER = /^( {0,3})(`{3,}|~{3,})[ \t]*([^\n`]*)$/
14
15/** Finds every fenced block in markdown, CommonMark's way: a run of 3+ backticks or tildes opens, a run as long or longer of the same closes. */
16export function findBlocks(markdown: string): Block[] {
17 const blocks: Block[] = []
18 const lines = markdown.split('\n')
19 let offset = 0
20 let open: { fence: string; indent: number; language: string; body: string[] } | null = null
21
22 for (const raw of lines) {
23 const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
24 const lineEnd = offset + raw.length + 1
25 if (open === null) {
26 const m = OPENER.exec(line)
27 if (m !== null) {
28 open = { fence: m[2] ?? '```', indent: (m[1] ?? '').length, language: (m[3] ?? '').trim().split(/\s+/)[0] ?? '', body: [] }
29 }
30 } else {
31 const trimmed = line.replace(/^ {0,3}/, '')
32 const fenceChar = open.fence[0] ?? '`'
33 const closer = new RegExp(`^\\${fenceChar}{${open.fence.length},}[ \\t]*$`)
34 if (closer.test(trimmed)) {
35 blocks.push({ text: open.body.join('\n'), language: open.language, end: Math.min(lineEnd, markdown.length), isClosed: true })
36 open = null
37 } else {
38 open.body.push(stripIndent(line, open.indent))
39 }
40 }
41 offset = lineEnd
42 }
43 if (open !== null) {
44 blocks.push({ text: open.body.join('\n'), language: open.language, end: markdown.length, isClosed: false })
45 }
46 return blocks
47}
48
49/** A fence indented by N spaces strips up to N from each content line, as CommonMark does. */
50function stripIndent(line: string, indent: number): string {
51 let i = 0
52 while (i < indent && line[i] === ' ') i += 1
53 return line.slice(i)
54}
55
56/** Cuts markdown into pieces that each end with one fenced block, and a last piece of whatever prose follows. */
57export function splitAfterBlocks(markdown: string): { text: string; block?: Block }[] {
58 const out: { text: string; block?: Block }[] = []
59 let start = 0
60 for (const block of findBlocks(markdown)) {
61 out.push({ text: markdown.slice(start, block.end).replace(/\n+$/, ''), block })
62 start = block.end
63 }
64 const rest = markdown.slice(start).replace(/^\n+/, '')
65 if (rest.trim() !== '') out.push({ text: rest })
66 return out
67}
68
69/** The block `n` back from the last (1 = the last) across replies, oldest first. */
70export function pickBlock(replies: readonly string[], n: number): Block | undefined {
71 const all = replies.flatMap(text => findBlocks(text))
72 return all[all.length - n]
73}
74
75/** `/copyblock`'s argument: blank is 1, else a whole number 1 or more; anything else is undefined. */
76export function parseIndex(args: string): number | undefined {
77 const t = args.trim()
78 if (t === '') return 1
79 return /^\d+$/.test(t) && Number(t) >= 1 ? Number(t) : undefined
80}
81
82export function describeBlock(block: Block): string {
83 const lines = block.text === '' ? 0 : block.text.split('\n').length
84 return `${lines} line${lines === 1 ? '' : 's'}${block.language === '' ? '' : ` of ${block.language}`}`
85}
86