SLOPSHOPPER

copy-blocks

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

newrowscommandtoast
v0.1.0MITupdated 2026-10-14hearsawho25/roberts-mods/copy-blocks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · copy-blocks
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /copyblock ⎿ copy-blocks: No code blocks in this conversation yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

roberts-mods

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.

Install

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.

The mods

ModCommandWhat it does
roberts-toolbox/toolboxOne 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/betterTurns 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/challengeOne small Claude Code exercise a day, with a streak and a year of challenges that never repeat.
work-report/reportCounts 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, /keepwarmA 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/shipRepo 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-doctorChecks 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.

Credits

Several of these started as other people's ideas, and they deserve the credit:

  • Clean View, Prompt Upgrade, Daily Challenge and Work Report were built from prompts by Tristen O'Brien (AI Made Easy). The ideas are Tristen's. These are my builds of them.
  • Robert's Toolbox is based on a toolbox Tristen O'Brien showed in a video (tristenobrien.ai).
  • Relay's price table is adapted from cache-tax by Karan Bansal (MIT License).

The mods themselves were built with Claude Code.

License

MIT.

Source 2 files
hooks/register.tsx 57 lines
1import 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}
57
hooks/blocks.ts 86 lines
1/** 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