SLOPSHOPPER

copy-code

Frames each code block in Claude's replies in a box that names its language and has a copy button

newrowstoastpromptprocess
A shopper browsing a rack in a slop shop
README

copy-code

A Claude Code mod that frames every code block in Claude's replies in a rounded box. The block's language is named on the left of the top border and a copy button sits on its right:

╭─ python ───────────────────────── ⧉ copy ─╮
│ def greet(name: str) -> str:              │
│     return f"hello {name}"                │
╰───────────────────────────────────────────╯

Hovering the button lights the whole block. Clicking it copies the code without the fences.

Terminal only

This mod is for Claude Code running in a terminal. In the Desktop app, VS Code or the web it does nothing, and code blocks keep Claude Code's own look.

Requirements

  • Claude Code 2.1.287 or newer.
  • A Nerd Font in your terminal, for the copy icon.
  • Mouse support for hover and click. The fullscreen layout ("tui": "fullscreen") has it.

Try it

claude --plugin-dir path/to/copy-code

Notes

  • In the terminal, the mod adds a short note to Claude's system prompt asking it to give every code block a language, so a sketch that isn't valid code on its own still gets a name and colours. Print mode and the Desktop app don't get the note.
  • Languages appear by their full name: py shows as python, md as markdown. A name it doesn't know appears as written.
  • Code is coloured as in Claude Code's own code blocks. Languages it has no grammar for, such as fish, zig, lean and typst, appear as plain text.
  • On Wayland, after Claude Code's own copy, the mod runs wl-copy --type 'text/plain;charset=utf-8', so the clipboard holds the code as text. Claude Code's copy lets wl-copy guess the type, so a one-letter block can land as binary data and HTML as rich text. Claude Code's copy still fills the primary selection, a tmux buffer and the terminal's clipboard. Over SSH, without a Wayland display, or when wl-copy is missing or fails, only Claude Code's copy runs.
  • In a narrow terminal a long language name is shortened, and left out when there is no room for it. Below 12 columns the mod steps aside and Claude Code draws the reply.
Source 4 files
hooks/register.tsx 167 lines
1import type { Register } from 'claude-code'
2
3import { fence } from './fence'
4import { languageName } from './languages'
5import { split } from './split'
6
7const MAX = 10000
8
9// nf-fa-copy, from a Nerd Font.
10const ICON = '\uF0C5'
11const LABEL = ` ${ICON} copy `
12// The cells right of the language name: the corner, the copy label and a gap.
13const RIGHT = 2 + LABEL.length + 1
14// With fewer cells than this a name says nothing, so it is left out.
15const MIN_NAME = 4
16// The reply's left gutter, as Claude Code draws it: the bullet on a reply's first block, else blank.
17const GUTTER = 2
18// Narrower than this a box can't hold its corners and copy, so Claude Code draws the reply.
19const MIN_COLUMNS = 12
20// How long a toast stays; Claude Code's default.
21const TOAST_MS = 4000
22// Copies stdin as plain text. Claude Code runs wl-copy without --type, so wl-copy guesses one: `x` lands as
23// application/octet-stream and HTML as text/html, which a paste as text can't read. Its output goes to /dev/null,
24// since the clipboard server it leaves running would hold the pipes open until the call timed out.
25const WL_COPY = ['sh', '-c', "exec wl-copy --type 'text/plain;charset=utf-8' >/dev/null 2>&1"]
26// How long wl-copy may take before Claude Code's copy is left to stand.
27const WL_COPY_MS = 2000
28// Claude Code's own wl-copy lands after its copy call returns: up to 28 ms after a typed copy run at once, in
29// six live runs. The typed copy waits this long so it lands last.
30const WL_COPY_AFTER_MS = 300
31// Asks Claude to tag every fence: an untagged block gets no name and no colours. Claude leaves a fence untagged
32// most often when its code isn't valid on its own. The text never varies, so the cached prompt before it holds.
33const FENCES =
34  'Code blocks in your replies are boxed by copy-code, which names and colours each by its fence language. ' +
35  'Give every fenced block a language tag (python, bash, json; text for plain output). For a sketch that is ' +
36  'not valid code on its own, tag the real language it is closest to: a made-up tag such as pseudo gets no colours.'
37
38export const register: Register = on => {
39  // Only where a terminal draws the replies: in print mode or the Desktop app the box isn't there.
40  on('prompt.compose', async (_$, e, next) => {
41    const composed = await next(e)
42    if (!e.surfaces.includes('terminal')) {
43      return composed
44    }
45    return { sections: [...composed.sections, { id: 'copy-code:fences', text: FENCES, scope: 'session' }] }
46  })
47
48  // The toast showing, if any. Raising another relays out the transcript, which can drop a press in flight.
49  let toast = { text: '', until: 0 }
50
51  on('ui.render', { component: 'AssistantMessage', surface: 'terminal' }, ($, e, next) => {
52    const segments = split(e.props.text).map(s => ({
53      ...s,
54      drawn: s.kind === 'code' ? fence(s.code, s.language) : s.text,
55    }))
56    const isDrawable = segments.some(s => s.kind === 'code') && segments.every(s => s.drawn.length <= MAX)
57
58    const columns = e.viewport?.columns
59    if (!isDrawable || (columns !== undefined && columns < MIN_COLUMNS)) {
60      return next(e)
61    }
62
63    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
64
65    // After Claude Code's copy, on Wayland and not over SSH (where wl-copy would reach the remote desktop),
66    // wl-copy puts the code back on the clipboard typed as text. When wl-copy fails or is missing, Claude Code's
67    // copy stands.
68    const retype = async (text: string) => {
69      if ((await $.env.get('WAYLAND_DISPLAY')) && !(await $.env.get('SSH_CONNECTION'))) {
70        await $.clock.sleep(WL_COPY_AFTER_MS)
71        await $.process.run(WL_COPY, { stdin: text, timeoutMs: WL_COPY_MS }).catch(() => undefined)
72      }
73    }
74
75    return (
76      <Box flexDirection="row">
77        <Box width={GUTTER} flexShrink={0}>
78          {e.props.isFirstOfReply && <Text color="text">●</Text>}
79        </Box>
80        <Box flexDirection="column" flexGrow={1}>
81          {segments.map((s, i) => {
82            // One blank row between segments, none after the last, as Claude Code spaces them.
83            const gap = i > 0 ? 1 : 0
84            if (s.kind === 'prose') {
85              return (
86                <Box key={`${e.requestId}:${i}`} marginTop={gap}>
87                  <Markdown text={s.text} />
88                </Box>
89              )
90            }
91
92            // Claude Code copies first: its own wl-copy, the primary selection, a tmux buffer and the terminal's
93            // OSC 52. Its result is what the toast says, so the toast shows before the retype.
94            const copy = async () => {
95              const result = await $.ui.copy({ text: s.code, surface: 'terminal' })
96              const text = result.isCopied ? 'Copied' : `Copy failed: ${result.reason}`
97              const now = await $.clock.now()
98              if (text !== toast.text || now >= toast.until) {
99                toast = { text, until: now + TOAST_MS }
100                $.ui.toast(text, { timeoutMs: TOAST_MS })
101              }
102              await retype(s.code)
103            }
104
105            // Not the wrapper's key: a keyed Box is a scope of its own, and hovering the code would join it.
106            const scope = `${e.requestId}:${i}:lit`
107            // The name's cells: the box less its left corner, the right side and a space either side.
108            const room = columns === undefined ? Infinity : columns - GUTTER - (s.indent ?? 0) - 2 - RIGHT - 2
109            const name = s.language !== undefined && room >= MIN_NAME ? languageName(s.language) : undefined
110
111            // The overlay redraws the whole box lit, so hovering the code lights nothing. The top row (name and copy)
112            // lies over the top border in the flow, so it scrolls away with the block: absolute, it was pinned to the
113            // viewport's top row once the block scrolled off. Column-reverse lays it out first but draws it last,
114            // over the overlay, so the name stays unlit: under the overlay it kept the lit background afterwards.
115            return (
116              <Box
117                key={`${e.requestId}:${i}`}
118                flexDirection="column-reverse"
119                marginTop={gap}
120                marginLeft={s.indent ?? 0}
121              >
122                <Box borderStyle="round" borderDimColor paddingX={1}>
123                  <Markdown text={s.drawn} />
124                </Box>
125                <Box
126                  position="absolute"
127                  top={0}
128                  left={0}
129                  right={0}
130                  bottom={0}
131                  display="none"
132                  backgroundColor="userMessageBackground"
133                  hover={{ scope, display: 'flex' }}
134                >
135                  <Box flexGrow={1} borderStyle="round" paddingX={1}>
136                    <Markdown text={s.drawn} />
137                  </Box>
138                </Box>
139                <Box flexDirection="row" marginBottom={-1} paddingX={2}>
140                  <Box flexGrow={1} width={0} marginRight={1}>
141                    {name !== undefined && (
142                      <Text dimColor wrap="truncate-end">
143                        {' '}
144                        {name}{' '}
145                      </Text>
146                    )}
147                  </Box>
148                  {s.code !== '' && (
149                    <Button
150                      key={`${e.requestId}:copy:${i}`}
151                      label={LABEL}
152                      plain
153                      dimColor
154                      hover={{ scope, dimColor: false, inverse: false, backgroundColor: 'userMessageBackground' }}
155                      onPress={copy}
156                    />
157                  )}
158                </Box>
159              </Box>
160            )
161          })}
162        </Box>
163      </Box>
164    )
165  })
166}
167
hooks/fence.ts 44 lines
1// The fence languages Claude Code highlights in a reply, as its resolver in the 2.1.289 binary lists them:
2// highlight.js's language ids and aliases (the tables at offsets ~202281323 and ~202284635) and its own cedar
3// grammar. A language missing here is fenced as plain text, so a newer Claude Code's extra language loses its
4// colours, never shows wrong ones or a second name.
5const HIGHLIGHTED = new Set(
6  `
7  abnf accesslog actionscript ada angelscript apache applescript arcade arduino armasm asciidoc aspectj autohotkey
8  autoit avrasm awk axapta bash basic bnf brainfuck c cal capnproto ceylon clean clojure clojure-repl cmake
9  coffeescript coq cos cpp crmsh crystal csharp csp css d dart delphi diff django dns dockerfile dos dsconfig dts
10  dust ebnf elixir elm erb erlang erlang-repl excel fix flix fortran fsharp gams gauss gcode gherkin glsl go golo
11  gradle graphql groovy haml handlebars haskell haxe hsp http hy inform7 ini irpf90 java javascript jboss-cli json
12  julia julia-repl kotlin lasso latex ldif leaf less lisp livecodeserver livescript llvm lsl lua makefile markdown
13  matlab mel mercury mipsasm mizar mojolicious monkey moonscript n1ql nestedtext nginx nim nix node-repl nsis
14  objectivec ocaml openscad oxygene parser3 perl pf pgsql php php-template plaintext pony powershell processing
15  profile prolog properties protobuf puppet purebasic python python-repl q qml r reasonml rib roboconf routeros
16  rsl ruby ruleslanguage rust sas scala scheme scilab scss shell smali smalltalk sml sql stan stata step21 stylus
17  subunit swift taggerscript tap tcl thrift tp twig typescript vala vbnet vbscript vbscript-html verilog vhdl vim
18  wasm wren x86asm xl xml xquery yaml zephir as asc apacheconf osascript ino arm adoc ahk x++ sh zsh bf h capnp
19  icl dcl clj edn cmake.in coffee cson iced cls cc c++ h++ hpp hh hxx cxx crm pcmk cr cs c# dpr dfm pas pascal
20  patch jinja bind zone docker bat cmd dst ex exs erl xlsx xls f90 f95 fs f# gms gss nc feature golang gql hbs
21  html.hbs html.handlebars htmlbars hs hx https hylang i7 toml jsp js jsx mjs cjs wildfly-cli jsonc jldoctest kt
22  kts ls lassoscript tex pluto mk mak make md mkdown mkd m moo mips moon nt nginxconf nixos mm objc obj-c obj-c++
23  objective-c++ ml scad pl pm pf.conf postgres postgresql text txt pwsh ps ps1 pde proto pp pb pbi py gyp ipython
24  pycon k kdb qt re graph instances mikrotik rb gemspec podspec thor irb rs scm sci console shellsession st
25  stanfuncs do ado p21 step stp styl tk craftcms ts tsx mts cts vb vbs v sv svh tao html xhtml rss atom xjb xsd
26  xsl plist wsf svg xpath xq xqm yml zep mysql oracle freepascal lazarus lpr lfm php3 php4 php5 php6 php7 php8
27  cedar cedarpolicy
28  `
29    .trim()
30    .split(/\s+/),
31)
32
33// The fence that draws `code` as Claude Code draws a reply's code block: the same renderer, so the same colours.
34// It is longer than any backtick run in the code, so the code can't close it. Claude Code highlights by the info
35// string's leading word (`u=i.match(/^[\w.+#-]+/)`, binary offset ~213293337), so only that word goes. A language
36// it can't highlight goes as plaintext, since its fence would print the name dim above the code, repeating the box's.
37export function fence(code: string, language?: string): string {
38  const ticks = '`'.repeat(Math.max(3, ...(code.match(/`+/g) ?? []).map(run => run.length + 1)))
39  const word = language?.match(/^[\w.+#-]+/)?.[0]
40  const info =
41    language === undefined ? '' : word !== undefined && HIGHLIGHTED.has(word.toLowerCase()) ? word : 'plaintext'
42  return `${ticks}${info}\n${code}\n${ticks}`
43}
44
hooks/languages.ts 24 lines
1// biome-ignore format: one row per language family reads better than one alias per line
2export const NAMES: Record<string, string> = {
3  py: 'python', python3: 'python', py3: 'python',
4  js: 'javascript', mjs: 'javascript', cjs: 'javascript',
5  ts: 'typescript', mts: 'typescript', cts: 'typescript',
6  md: 'markdown', mdx: 'markdown',
7  sh: 'shell', shellscript: 'shell', console: 'shell session', 'shell-session': 'shell session',
8  ps1: 'powershell', psm1: 'powershell',
9  yml: 'yaml',
10  rb: 'ruby', rs: 'rust', kt: 'kotlin', kts: 'kotlin', cs: 'c#', fs: 'f#',
11  cpp: 'c++', cc: 'c++', cxx: 'c++', hpp: 'c++', h: 'c',
12  objc: 'objective-c', hs: 'haskell', ex: 'elixir', exs: 'elixir', erl: 'erlang',
13  pl: 'perl', jl: 'julia', clj: 'clojure', ml: 'ocaml', tf: 'terraform', hcl: 'terraform',
14  txt: 'text', plaintext: 'text', patch: 'diff', dockerfile: 'docker', containerfile: 'docker',
15}
16
17// A Map, not NAMES itself: an object would also answer inherited keys such as `constructor`.
18const ALIASES = new Map(Object.entries(NAMES))
19
20// The full name of a fence's language, as written when it is not an alias.
21export function languageName(language: string): string {
22  return ALIASES.get(language.toLowerCase()) ?? language
23}
24
hooks/split.ts 54 lines
1export type Segment =
2  | { kind: 'prose'; text: string }
3  | { kind: 'code'; text: string; code: string; language?: string; indent?: number }
4
5// A backtick fence's info string may hold no backtick (CommonMark); a tilde fence's may.
6const OPEN = /^( {0,3})(`{3,}(?!.*`)|~{3,})\s*([^\s`]*)/
7
8// Splits markdown into prose and fenced code blocks, each code block keeping its fences.
9export function split(text: string): Segment[] {
10  const lines = text.split('\n')
11  const segments: Segment[] = []
12  let prose: string[] = []
13  let i = 0
14
15  const flush = () => {
16    if (prose.join('').trim() !== '') {
17      segments.push({ kind: 'prose', text: prose.join('\n') })
18    }
19    prose = []
20  }
21
22  while (i < lines.length) {
23    const line = lines[i] ?? ''
24    const open = OPEN.exec(line)
25    const fence = open?.[2]
26    if (!fence) {
27      prose.push(line)
28      i++
29      continue
30    }
31
32    const close = new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`)
33    let end = i + 1
34    while (end < lines.length && !close.test(lines[end] ?? '')) end++
35
36    flush()
37    // As CommonMark does: up to the fence's own indent comes off each code line.
38    const indent = open?.[1]?.length ?? 0
39    const body = lines.slice(i + 1, end).map(l => l.replace(new RegExp(`^ {0,${indent}}`), ''))
40    const segment: Segment = { kind: 'code', text: lines.slice(i, end + 1).join('\n'), code: body.join('\n') }
41    if (open?.[3]) {
42      segment.language = open[3]
43    }
44    if (indent > 0) {
45      segment.indent = indent
46    }
47    segments.push(segment)
48    i = end + 1
49  }
50
51  flush()
52  return segments
53}
54