SLOPSHOPPER

reply-comment

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

panerowscommandtoast
v0.2.0MITupdated 2026-10-12korenl137/reply-comment
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reply-comment
│ ┃ quote ✕ › fix the failing auth test and add an audit log call │ ┃ Note: comment on the picked paragraphs, Ente │ ┃ ☐ I updated src/auth.ts to reject expired ⏺ Read(src/auth.ts) │ ┃ claims and added an audit call. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ │ ❝ Done. I made refresh reject expired claims and added an audit cal │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /quote │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · quote
Note: comment on the picked paragraphs, Enter adds them to t ☐ I updated src/auth.ts to reject expired claims and added a audit call.
Claude's reply
❝ Done. I made refresh reject expired claims and added an audit call.
README

reply-comment

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.

  • ❝ per paragraph. In a fullscreen terminal, each paragraph of a reply gets a dim ❝ at its left. Press it and that paragraph is appended to your prompt as a > 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.

Install

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.

Requirements

  • Claude Code 2.1.287 or later (mods were introduced there).
  • The ❝ buttons need the fullscreen terminal; it is the only place a plugin receives clicks. /quote is registered in every session.

Good to know

  • Other mods that draw replies. This mod draws the 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.
  • Known quirk. In one live session the first paragraph's ❝ did not respond to three presses and worked after a later paragraph had been quoted. The cause is not found.
  • Not yet observed natively. The tests use stubs. The paragraph-by-paragraph layout and the cursor position after a ❝ press have been seen in a native fullscreen session only in part.

What it can touch

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.

Develop

claude plugin validate .
claude plugin test .

See CONTRIBUTING.md and CHANGELOG.md.

AI disclosure

This mod was written with AI assistance.

License

MIT

Source 1 files
hooks/register.tsx 146 lines
1/**
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