Quote parts of Claude's reply into your prompt with a comment: /quote opens a pane with the last reply split into paragraphs.

Quote parts of Claude's reply into your prompt, with a comment. Nothing is sent: the quote lands in the prompt draft and you press Enter yourself.
> quote, followed by a comment: line for your answer./quote. Opens a pane with the last reply split into paragraphs. Tick the ones you want, type a note, press Enter, and they go into the prompt draft as one quote followed by the note.Fenced code blocks stay in one piece, and a list with blank lines between its items stays one block.
claude plugin marketplace add korenl137/reply-comment
claude plugin install reply-comment@reply-comment
Then restart Claude Code, or run /reload-plugins in an open session.
/quote is registered in every session.AssistantMessage itself. It calls the hooks below it first, so a reply another mod drew (for example display math) is kept, and it gets one ❝ over the whole reply instead of one per paragraph. Mods that draw replies in some other way may still conflict.The hook uses only Claude Code's own $ calls: command.register, session.messages, prompt.read, prompt.fill, ui.*. It has no filesystem, process, network or storage access, and it never submits anything.
claude plugin validate .
claude plugin test .
See CONTRIBUTING.md and CHANGELOG.md.
This mod was written with AI assistance.
hooks/register.tsx 146 lines1/**
2 * reply-comment: pick paragraphs of a reply, add a note, and have them appended to the
3 * prompt draft as a quote. Nothing is submitted; you review the draft and press Enter.
4 *
5 * A dim ❝ at the head of each paragraph of a reply (fullscreen terminal) quotes that paragraph into the prompt.
6 * /quote opens a pane of the last reply's paragraphs; picked paragraphs plus a note are appended
7 * to the prompt draft as a quote. Nothing is submitted.
8 *
9 * Calls on `$`: command.register, session.messages, prompt.read, prompt.fill, ui.open,
10 * ui.close, ui.invalidate, ui.resolve, ui.toast. No fs, process, http or store.
11 */
12import type { Register } from 'claude-code'
13
14const PANE = 'reply-comment'
15// Blank-line separated blocks; a fenced code block stays in one piece.
16export function paragraphs(text: string): string[] {
17 const out: string[] = []
18 let cur: string[] = []
19 let fenced = false
20 const flush = () => {
21 if (cur.length) out.push(cur.join('\n'))
22 cur = []
23 }
24 for (const line of text.split('\n')) {
25 if (/^\s*(```|~~~)/.test(line)) fenced = !fenced
26 if (!fenced && line.trim() === '' && !/^\s*(```|~~~)/.test(line)) flush()
27 else cur.push(line)
28 }
29 flush()
30 // a list with blank lines between its items stays one block, or each item would restart at "1."
31 const item = /^\s*([-*+]|\d+[.)])\s/
32 return out.reduce<string[]>((acc, block) => {
33 const prev = acc[acc.length - 1]
34 if (prev !== undefined && item.test(block) && item.test(prev.split('\n').at(-1) ?? '') || (prev !== undefined && item.test(prev) && /^\s{2,}\S/.test(block))) {
35 acc[acc.length - 1] = `${prev}\n\n${block}`
36 } else acc.push(block)
37 return acc
38 }, [])
39}
40
41export function quoted(parts: string[], note: string): string {
42 const q = parts.map(p => p.split('\n').map(l => (l ? `> ${l}` : '>')).join('\n')).join('\n>\n')
43 return note.trim() ? `${q}\n\n${note.trim()}` : q
44}
45
46let paras: string[] = []
47let picked = new Set<number>()
48
49async function openFor($: any, text: string) {
50 paras = paragraphs(text)
51 picked = new Set()
52 await $.ui.open({ id: PANE, title: 'quote', focus: true, closeOnEscape: true })
53 $.ui.invalidate('ui.render')
54}
55
56// trailer: text after the quote, e.g. "\ncomment: " so the cursor lands on a labelled answer line
57async function fillPrompt($: any, parts: string[], note: string, trailer = ''): Promise<boolean> {
58 const box = await $.prompt.read()
59 const r = await $.prompt.fill({ text: (box.text.trim() ? '\n\n' : '') + quoted(parts, note) + trailer, mode: 'append' })
60 // the quote showing in the prompt is the confirmation; only a failure needs a word
61 if (!r.isFilled) $.ui.toast("Couldn't fill the prompt (is a dialog open?)")
62 return r.isFilled
63}
64
65export const register: Register = on => {
66 on('session.start', async ($, e, next) => {
67 await $.command.register({ name: 'quote', description: 'Pick paragraphs of the last reply and quote them into your prompt', immediate: true }).catch(() => undefined)
68 return next(e)
69 })
70
71 on('command.run', { command: 'quote' }, async $ => {
72 const last = [...(await $.session.messages())].reverse().find(m => m.role === 'assistant' && m.text.trim())
73 if (!last) $.ui.toast('No reply to quote yet')
74 else await openFor($, last.text)
75 return {}
76 })
77
78 // The reply drawn as the engine draws it, paragraph by paragraph, each with a dim ❝ at its left
79 // head that appends that paragraph to the prompt as a quote. Fullscreen terminal only (the only
80 // place a plugin gets clicks). A reply another hook drew (tex-display's math) keeps that drawing:
81 // its paragraphs cannot be lined up with ours, so one ❝ goes over the whole reply instead.
82 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
83 if (e.surface !== 'terminal' || !e.viewport?.isFullscreen) return next(e)
84 const drawn = await next(e)
85 const blocks = paragraphs(e.props.text)
86 if (!blocks.length) return drawn
87 // the engine's own drawing (any ref: next(e) numbers each answer) is what the paragraphs replace
88 if (drawn.type !== 'engine') {
89 const { Box, Button } = $.ui.resolve(e)
90 return (
91 <Box flexDirection="row">
92 <Box flexShrink={0} minWidth={2} marginTop={1}>
93 <Button key="quote-reply" label="❝" plain dimColor onPress={() => fillPrompt($, [e.props.text], '', '\ncomment: ')} />
94 </Box>
95 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
96 {drawn}
97 </Box>
98 </Box>
99 )
100 }
101 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
102 return (
103 <Box flexDirection="row" marginTop={1}>
104 <Box minWidth={2}>
105 <Text>{e.props.isFirstOfReply ? '⏺' : ' '}</Text>
106 </Box>
107 <Box flexDirection="column" flexGrow={1}>
108 {blocks.map((block, i) => (
109 <Box key={`b${i}`} flexDirection="row" columnGap={1} alignItems="flex-start" marginTop={i === 0 ? 0 : 1}>
110 {/* a fixed cell on the paragraph's first line, so every paragraph's text starts in the same column */}
111 <Box flexShrink={0}>
112 <Button key={`q${i}`} label="❝" plain dimColor onPress={() => fillPrompt($, [block], '', '\ncomment: ')} />
113 </Box>
114 <Box flexGrow={1} flexShrink={1}>
115 <Markdown key={`m${i}`} text={block} />
116 </Box>
117 </Box>
118 ))}
119 </Box>
120 </Box>
121 )
122 })
123
124 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
125 if (e.requestId !== PANE) return next(e)
126 const { Box, Text, Button, Input, Markdown } = $.ui.resolve(e)
127 const redraw = () => $.ui.invalidate('ui.render')
128 const send = async (note: string) => {
129 const parts = [...picked].sort((a, b) => a - b).map(i => paras[i]!)
130 if (!parts.length) return void $.ui.toast('Pick at least one paragraph (❝)')
131 if (await fillPrompt($, parts, note)) await $.ui.close({ id: PANE })
132 }
133 return (
134 <Box flexDirection="column">
135 <Input key="note" label="Note" placeholder="comment on the picked paragraphs, Enter adds them to the prompt" value="" submitLabel="add" autoFocus onSubmit={send} />
136 {paras.map((p, i) => (
137 <Box key={`p${i}`} flexDirection="row" columnGap={1}>
138 <Button key={`pick-${i}`} label={picked.has(i) ? '☑' : '☐'} plain onPress={() => { picked.has(i) ? picked.delete(i) : picked.add(i); redraw() }} />
139 <Markdown key={`m${i}`} text={p} />
140 </Box>
141 ))}
142 </Box>
143 )
144 })
145}
146