Copy Claude's responses as clean Markdown, in full or block by block (/og-copy), or a part Claude picks (og_copy tool)

Copy what Claude wrote as clean Markdown, without the terminal's line wraps and indentation: ready to paste into a Jira ticket, an email or a document.
/og-copy: pick the blocksOpens a pane on Claude's last response, split into numbered blocks: paragraphs, headings, each list item (with its nested items), tables and code blocks, each whole.
| Key | Does |
|---|---|
| Tab, Enter | Move, check or uncheck a block |
c | Copy the checked blocks, or the whole response when none is checked |
a | Check or uncheck all |
p / n | Previous / next response |
| Esc | Close |
A response is everything Claude wrote in one turn, tool calls left out. Where no pane can be drawn, /og-copy copies the whole last response.
og_copyFor a part that is not a whole block, or a rewrite for where it goes, ask in words: "copy only the error table", "copy this as a short Jira comment". Claude calls the og_copy tool with the text, and a toast says what was copied.
pbcopy on macOS, Set-Clipboard on Windows, wl-copy or xclip on Linux.
/plugin install og-copy --marketplace oscar-guggiana-neto-neocol/claude-modshooks/register.tsx 163 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { View } from '../types'
5import { joinBlocks, previewOf, splitBlocks } from './blocks'
6
7const PLUGIN = 'og-copy'
8const COMMAND = 'og-copy'
9const TOOL = 'og_copy'
10const PANE = 'og-copy'
11
12const view = atom({ plugin: 'og-copy', key: 'view' } as const, null)
13
14/**
15 * Claude's responses, newest first: the text of each turn's assistant messages,
16 * joined. A prompt the person typed starts a turn; tool results do not.
17 */
18async function responsesOf($: EngineInterface): Promise<string[]> {
19 const messages = await $.session.messages()
20 const turns: string[][] = []
21 for (const message of messages) {
22 if (message.role === 'user' && message.text.trim() !== '') turns.push([])
23 if (message.role === 'assistant' && message.text.trim() !== '') {
24 if (turns.length === 0) turns.push([])
25 turns[turns.length - 1]?.push(message.text.trim())
26 }
27 }
28
29 return turns.map(texts => texts.join('\n\n')).filter(text => text !== '').reverse()
30}
31
32/** Puts text on the system clipboard: pbcopy, Set-Clipboard on Windows, wl-copy or xclip on Linux. */
33async function toClipboard($: EngineInterface, text: string) {
34 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
35 const commands = isWindows
36 ? [['powershell', '-NoProfile', '-Command', '[Console]::In.ReadToEnd() | Set-Clipboard']]
37 : [['pbcopy'], ['wl-copy'], ['xclip', '-selection', 'clipboard']]
38 for (const argv of commands) {
39 const ran = await $.process.run(argv, { stdin: text }).catch(() => undefined)
40 if (ran?.exitCode === 0) return
41 }
42 throw new Error('no clipboard command worked (pbcopy, wl-copy, xclip, Set-Clipboard)')
43}
44
45const sizeOf = (text: string) => `${text.length} characters`
46
47/** Copies the checked blocks of the shown response (all of them when none is checked), then closes the pane. */
48async function copyChecked($: EngineInterface) {
49 const shown = await read($, view)
50 const response = shown?.responses[shown.index]
51 if (shown === null || response === undefined) return
52 const blocks = splitBlocks(response)
53 const picked = shown.checked.length === 0 ? blocks : blocks.filter((_, i) => shown.checked.includes(i))
54 const text = joinBlocks(picked)
55 try {
56 await toClipboard($, text)
57 $.ui.toast(`Copied ${picked.length} of ${blocks.length} blocks (${sizeOf(text)}).`)
58 await $.ui.close({ id: PANE })
59 } catch (error) {
60 $.ui.toast(`og-copy: ${String(error instanceof Error ? error.message : error)}`)
61 }
62}
63
64const toggle = (list: number[], i: number) => (list.includes(i) ? list.filter(one => one !== i) : [...list, i].sort((a, b) => a - b))
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 const started = await next(e)
69 await $.command.register({
70 name: COMMAND,
71 description: "Copy Claude's last response, or some of its blocks, as Markdown",
72 })
73 await $.tool.register({
74 name: TOOL,
75 description:
76 "Copies text to the user's clipboard. Use it when the user asks you to copy something: a part of your response (only the table, only the summary) or a version of it rewritten for where they will paste it (a Jira comment, an email). Pass the exact Markdown to copy.",
77 inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
78 })
79
80 return started
81 })
82
83 on('command.run', { command: COMMAND }, async $ => {
84 const responses = await responsesOf($)
85 if (responses.length === 0) return { text: 'No response to copy yet.' }
86 await update($, view, () => ({ responses, index: 0, checked: [] }))
87 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Copy', focus: true, closeOnEscape: true })
88 if (isPlaced) return {}
89 // No pane to choose in: the whole last response.
90 const text = responses[0] as string
91 await toClipboard($, text)
92
93 return { text: `Copied the last response (${sizeOf(text)}).` }
94 }).catch(($, e) => ({ text: `og-copy: ${String(e)}` }))
95
96 on('tool.call', { tool: `mcp__${PLUGIN}__${TOOL}` }, async ($, e) => {
97 const text = String((e as { text?: unknown }).text ?? '')
98 if (text.trim() === '') return { deny: 'Nothing to copy: the text is empty.' }
99 try {
100 await toClipboard($, text)
101 } catch (error) {
102 return { deny: `Not copied: ${error instanceof Error ? error.message : String(error)}` }
103 }
104 $.ui.toast(`Copied ${sizeOf(text)}.`)
105
106 return { result: `Copied to the clipboard (${sizeOf(text)}).` } as never
107 })
108
109 on('ui.close', { id: PANE }, async ($, e, next) => {
110 await update($, view, () => null)
111 return next(e)
112 })
113
114 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
115 const { Box, Text, Button } = $.ui.resolve(e)
116 const shown = await read($, view)
117 const response = shown?.responses[shown.index]
118 if (shown === null || response === undefined) return <Text dimColor>Nothing to copy.</Text>
119 const blocks = splitBlocks(response)
120 const width = Math.max(20, (e.props.bodyColumns ?? 80) - 16)
121 const setView = (fn: (one: View) => View) => update($, view, one => (one === null ? one : fn(one)))
122 const move = (by: number) => setView(one => ({ ...one, index: one.index + by, checked: [] }))
123 const allChecked = shown.checked.length === blocks.length
124
125 return (
126 <Box flexDirection="column">
127 <Text dimColor>
128 Response {shown.index + 1} of {shown.responses.length} (1 is the latest) · Tab moves · Enter checks · c copies the checked
129 blocks, or all when none is
130 </Text>
131 <Box flexDirection="column" marginTop={1}>
132 {blocks.map((block, i) => {
133 const { line, more } = previewOf(block, width)
134 return (
135 <Button key={`block:${i}`} plain onPress={() => setView(one => ({ ...one, checked: toggle(one.checked, i) }))}>
136 {shown.checked.includes(i) ? '[x]' : '[ ]'} <Text dimColor>{String(i + 1).padStart(2)}</Text> {line}
137 {more ? <Text dimColor> ({more})</Text> : ''}
138 </Button>
139 )
140 })}
141 </Box>
142 <Box marginTop={1} gap={1}>
143 <Button
144 key="copy"
145 label={shown.checked.length === 0 ? 'Copy all' : `Copy ${shown.checked.length}`}
146 hotkey="c"
147 variant="primary"
148 onPress={() => copyChecked($)}
149 />
150 <Button
151 key="all"
152 label={allChecked ? 'Uncheck all' : 'Check all'}
153 hotkey="a"
154 onPress={() => setView(one => ({ ...one, checked: allChecked ? [] : blocks.map((_, i) => i) }))}
155 />
156 {shown.index + 1 < shown.responses.length && <Button key="prev" label="Previous" hotkey="p" onPress={() => move(1)} />}
157 {shown.index > 0 && <Button key="next" label="Next" hotkey="n" onPress={() => move(-1)} />}
158 </Box>
159 </Box>
160 )
161 })
162}
163hooks/blocks.ts 81 lines1// Pure logic: a response's Markdown split into blocks that can be copied on
2// their own, and joined back. No `$` here, so tests drive it alone.
3
4import type { Block, BlockKind } from '../types'
5
6const FENCE = /^\s*(`{3,}|~{3,})/
7const TABLE = /^\s*\|/
8const HEADING = /^#{1,6}\s/
9// A list item at the top level: up to three spaces before its marker.
10const ITEM = /^ {0,3}([-*+]|\d+[.)])\s+/
11
12const isBlank = (line: string) => line.trim() === ''
13const startsBlock = (line: string) => FENCE.test(line) || TABLE.test(line) || HEADING.test(line) || ITEM.test(line)
14
15export const splitBlocks = (text: string): Block[] => {
16 const lines = text.replace(/\r\n/g, '\n').split('\n')
17 const blocks: Block[] = []
18 const push = (kind: BlockKind, from: number, to: number) => blocks.push({ kind, text: lines.slice(from, to).join('\n').trimEnd() })
19 let i = 0
20
21 while (i < lines.length) {
22 const line = lines[i] as string
23 const start = i
24 if (isBlank(line)) {
25 i++
26 } else if (FENCE.test(line)) {
27 const fence = (FENCE.exec(line)?.[1] ?? '```').slice(0, 3)
28 i++
29 while (i < lines.length && !(lines[i] as string).trim().startsWith(fence)) i++
30 i = Math.min(i + 1, lines.length)
31 push('code', start, i)
32 } else if (TABLE.test(line)) {
33 while (i < lines.length && TABLE.test(lines[i] as string)) i++
34 push('table', start, i)
35 } else if (HEADING.test(line)) {
36 i++
37 push('heading', start, i)
38 } else if (ITEM.test(line)) {
39 i++
40 while (i < lines.length) {
41 const next = lines[i] as string
42 if (isBlank(next)) {
43 // A blank line inside an item only when what follows is indented under it.
44 const after = lines.slice(i + 1).find(one => !isBlank(one))
45 if (after === undefined || !/^\s/.test(after)) break
46 i++
47 } else if (/^\s/.test(next) || !startsBlock(next)) {
48 i++
49 } else {
50 break
51 }
52 }
53 push('item', start, i)
54 } else {
55 i++
56 while (i < lines.length && !isBlank(lines[i] as string) && !startsBlock(lines[i] as string)) i++
57 push('paragraph', start, i)
58 }
59 }
60
61 return blocks
62}
63
64/** The blocks' Markdown, a blank line between them; list items that follow each other stay one list. */
65export const joinBlocks = (blocks: readonly Block[]) =>
66 blocks.reduce((text, block, i) => {
67 if (i === 0) return block.text
68 const sameList = block.kind === 'item' && blocks[i - 1]?.kind === 'item'
69 return `${text}${sameList ? '\n' : '\n\n'}${block.text}`
70 }, '')
71
72/** A block as one row: its first line (a code block's fence and first line), cut to `width`, and its size. */
73export const previewOf = (block: Block, width: number) => {
74 const lines = block.text.split('\n')
75 const first = block.kind === 'code' ? lines.slice(0, 2).join(' ') : (lines[0] ?? '')
76 const chars = [...first.trim()]
77 const line = chars.length <= width ? chars.join('') : `${chars.slice(0, width - 1).join('')}…`
78
79 return { line, more: lines.length > 1 ? `${lines.length} lines` : undefined }
80}
81types/index.d.ts 27 lines1export type BlockKind = 'code' | 'table' | 'heading' | 'item' | 'paragraph'
2
3/** One part of a response that can be copied on its own. */
4export type Block = {
5 kind: BlockKind
6 /** Its Markdown, as Claude wrote it. */
7 text: string
8}
9
10/** What the pane shows: one response and the blocks checked in it. */
11export type View = {
12 /** Claude's responses, newest first, each the text of one turn. */
13 responses: string[]
14 /** Which response is shown: 0 is the newest. */
15 index: number
16 /** The checked blocks' positions in the shown response. */
17 checked: number[]
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'og-copy': {
23 view: View | null
24 }
25 }
26}
27