Copy a code block of the last reply from the keyboard: /cc [n], or a digit on the band above the prompt.

Copy a fenced code block of Claude's last reply from the keyboard, on the main-screen TUI of Claude Code. No mouse, no fullscreen layout.
/cc copies the last block of the last reply; /cc 2 the second. The answer names what was copied: Copied block 2/3 (bash, 5 lines).1: bash 5L 2: python 12L …. With the prompt empty, typing the digit copies that block; the digit flashes in the prompt and is taken back, which is the engine handing it to the band. The band hides while a turn runs, under a survey, and once you send the next prompt.+N more: /cc n.The block body is copied without its fences. Claude Code's own copy runs first (clipboard tool, OSC 52, tmux buffer); on Wayland, and not over SSH, the mod then re-copies through wl-copy --type text/plain so a one-letter block is not typed as binary data.
Nothing is sent to the model and nothing is added to the system prompt. State lives in the session only.
At the prompt of a terminal session:
/plugin install copy-code-kb --marketplace gwarf/claude-plugins
Answer y to add the marketplace, then pick a scope. From a shell:
claude plugin install copy-code-kb --marketplace gwarf/claude-plugins
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with gwarf/claude-plugins, then Add plugin. The mod draws only in a terminal; in the desktop app it does nothing.
Requires Claude Code 2.1.287 or newer (function hooks API).
claude plugin validate .
claude plugin test .
On the fullscreen TUI, copy-code by AH-Merii draws a copy button on every block, with hover highlighting, and is the better fit there. This mod exists for the main screen, where that button cannot be pressed. The idea, the fence splitting and the wl-copy re-copy come from it (MIT, see THIRD_PARTY.md).
hooks/register.tsx 123 lines1// copy-code-kb: copy a fenced block of the last reply from the keyboard, on the main screen.
2// Fence splitting and the wl-copy re-copy adapted from AH-Merii/claude-plugins copy-code (MIT, see THIRD_PARTY.md).
3
4import type { EngineInterface, Register, RenderSurface } from 'claude-code'
5import { atom, read, update } from 'claude-code'
6
7import type { Block } from '../types'
8import { languageName, blocks as splitBlocks } from './split'
9
10const COMMAND = 'cc'
11// Digit hotkeys reach 1-9; later blocks stay reachable through /cc n.
12const HOTKEYS = 9
13// A bare index, or nothing: Number() and parseInt would accept "", "2abc", "1e1".
14const INDEX = /^\s*([1-9]\d*)?\s*$/
15// Copies stdin as plain text. Claude Code's own wl-copy lets it guess the type, so a one-letter block lands as
16// octet-stream. Output to /dev/null: the clipboard daemon wl-copy leaves running would hold the pipes open.
17const WL_COPY = ['sh', '-c', "exec wl-copy --type 'text/plain;charset=utf-8' >/dev/null 2>&1"]
18const WL_COPY_MS = 2000
19// Claude Code's own wl-copy lands shortly after its copy call returns; the typed one waits so it lands last.
20const WL_COPY_AFTER_MS = 300
21
22const blocks = atom({ plugin: 'copy-code-kb', key: 'blocks' } as const, [])
23
24const describe = (block: Block, index: number, count: number) =>
25 `block ${index}/${count} (${languageName(block.language)}, ${block.lines} ${block.lines === 1 ? 'line' : 'lines'})`
26
27// Claude Code's copy first (its result is what the answer says), then on Wayland and not over SSH the plain-text
28// re-copy; when wl-copy is missing or fails, Claude Code's copy stands.
29async function copy($: EngineInterface, block: Block, surface?: RenderSurface) {
30 const result = await $.ui.copy(surface === undefined ? { text: block.code } : { text: block.code, surface })
31 if (result.isCopied && (await $.env.get('WAYLAND_DISPLAY')) && !(await $.env.get('SSH_CONNECTION'))) {
32 await $.clock.sleep(WL_COPY_AFTER_MS)
33 await $.process.run(WL_COPY, { stdin: block.code, timeoutMs: WL_COPY_MS }).catch(() => undefined)
34 }
35 return result
36}
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 await $.command.register({
41 name: COMMAND,
42 description: 'Copy a code block of the last reply: /cc copies the last one, /cc 2 the second.',
43 })
44 return next(e)
45 })
46
47 // A new prompt means the reply on screen is no longer the last one, even when its turn gets interrupted.
48 on('prompt.submit', async ($, e, next) => {
49 await update($, blocks, () => [])
50 return next(e)
51 // A failure here must never drop the prompt: next(e) replays a call already made, else passes it on.
52 }).catch((_$, e, next) => next(e))
53
54 on('turn.complete', async ($, e, next) => {
55 // A subagent's turn ends on the same hook; only the main loop's reply is "the last reply".
56 if (e.agentId === undefined) {
57 const found = splitBlocks(e.answer)
58 await update($, blocks, () => found)
59 }
60 return next(e)
61 })
62
63 on('command.run', { command: COMMAND }, async ($, e) => {
64 const list = await read($, blocks)
65 if (list.length === 0) {
66 return { text: 'No code block in the last reply.' }
67 }
68 const match = INDEX.exec(e.args)
69 if (match === null) {
70 return { text: `Usage: /cc [n], n from 1 to ${list.length}.` }
71 }
72 const index = match[1] === undefined ? list.length : Number(match[1])
73 const block = list[index - 1]
74 if (block === undefined) {
75 return { text: `No block ${index}: the last reply has ${list.length}.` }
76 }
77 const result = await copy($, block)
78 return {
79 text: result.isCopied ? `Copied ${describe(block, index, list.length)}.` : `Copy failed: ${result.reason}.`,
80 }
81 }).catch(() => ({ text: 'Copy failed: copy-code-kb hit an error.' }))
82
83 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
84 const list = await read($, blocks)
85 if (list.length === 0 || e.props.hasSurvey || e.props.isWorking) {
86 return next(e)
87 }
88
89 const { Box, Button, Text } = $.ui.resolve(e)
90 const shown = list.slice(0, HOTKEYS)
91 const rest = list.length - shown.length
92
93 return (
94 <Box flexDirection="row" flexWrap="wrap">
95 <Box marginRight={1}>
96 <Text dimColor>copy</Text>
97 </Box>
98 {shown.map((block, i) => (
99 <Box key={`copy:${i + 1}`} marginRight={2}>
100 <Button
101 key={`copy:${i + 1}`}
102 plain
103 hotkey={String(i + 1)}
104 label={`${languageName(block.language)} ${block.lines}L`}
105 onPress={async press => {
106 try {
107 const result = await copy($, block, press.surface)
108 $.ui.toast(
109 result.isCopied ? `Copied ${describe(block, i + 1, list.length)}` : `Copy failed: ${result.reason}`,
110 )
111 } catch {
112 $.ui.toast('Copy failed: copy-code-kb hit an error')
113 }
114 }}
115 />
116 </Box>
117 ))}
118 {rest > 0 && <Text dimColor>+{rest} more: /cc n</Text>}
119 </Box>
120 )
121 })
122}
123hooks/split.ts 77 lines1// Fence splitting adapted from AH-Merii/claude-plugins copy-code (MIT), trimmed to the code segments.
2import type { Block } from '../types'
3
4// A backtick fence's info string may hold no backtick (CommonMark); a tilde fence's may.
5const OPEN = /^( {0,3})(`{3,}(?!.*`)|~{3,})\s*([^\s`]*)/
6
7// The fenced code blocks of a markdown reply, in order, bodies without their fences.
8export function blocks(text: string): Block[] {
9 const lines = text.split(/\r?\n/)
10 const found: Block[] = []
11 let i = 0
12
13 while (i < lines.length) {
14 const open = OPEN.exec(lines[i] ?? '')
15 const fence = open?.[2]
16 if (!fence) {
17 i++
18 continue
19 }
20
21 const close = new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`)
22 let end = i + 1
23 while (end < lines.length && !close.test(lines[end] ?? '')) end++
24
25 // As CommonMark does: up to the fence's own indent comes off each code line.
26 const indent = open?.[1]?.length ?? 0
27 const body = lines.slice(i + 1, end).map(l => l.replace(new RegExp(`^ {0,${indent}}`), ''))
28 const block: Block = { code: body.join('\n'), lines: body.length }
29 if (open?.[3]) {
30 block.language = open[3]
31 }
32 found.push(block)
33 i = end + 1
34 }
35
36 return found
37}
38
39const NAMES: Record<string, string> = {
40 py: 'python',
41 python3: 'python',
42 js: 'javascript',
43 mjs: 'javascript',
44 cjs: 'javascript',
45 ts: 'typescript',
46 mts: 'typescript',
47 md: 'markdown',
48 sh: 'shell',
49 shellscript: 'shell',
50 console: 'shell session',
51 yml: 'yaml',
52 rb: 'ruby',
53 rs: 'rust',
54 kt: 'kotlin',
55 cs: 'c#',
56 cpp: 'c++',
57 cc: 'c++',
58 hs: 'haskell',
59 ex: 'elixir',
60 pl: 'perl',
61 tf: 'terraform',
62 hcl: 'terraform',
63 txt: 'text',
64 plaintext: 'text',
65 patch: 'diff',
66 dockerfile: 'docker',
67}
68
69// A Map, not NAMES itself: an object would also answer inherited keys such as `constructor`.
70const ALIASES = new Map(Object.entries(NAMES))
71
72// The full name of a fence's language; `text` for an untagged block.
73export function languageName(language: string | undefined): string {
74 if (language === undefined) return 'text'
75 return ALIASES.get(language.toLowerCase()) ?? language
76}
77types/index.d.ts 9 lines1// One fenced code block of the last reply, as the band and /cc see it.
2export type Block = { code: string; language?: string; lines: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'copy-code-kb': { blocks: Block[] }
7 }
8}
9