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

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.
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.
"tui": "fullscreen") has it.claude --plugin-dir path/to/copy-code
py shows as python, md as markdown. A name it doesn't know appears as written.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.hooks/register.tsx 167 lines1import 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}
167hooks/fence.ts 44 lines1// 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}
44hooks/languages.ts 24 lines1// 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}
24hooks/split.ts 54 lines1export 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