SLOPSHOPPER

copy-code-kb

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

newbandcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · copy-code-kb
› 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 › /cc ⎿ copy-code-kb: No code block in the last reply. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

copy-code-kb

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).
  • After a reply with code, a band above the prompt lists its blocks as 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.
  • More than nine blocks: the band shows nine and +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.

Install

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).

Develop

claude plugin validate .
claude plugin test .

Alternatives

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).

Source 3 files
hooks/register.tsx 123 lines
1// 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}
123
hooks/split.ts 77 lines
1// 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}
77
types/index.d.ts 9 lines
1// 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