Reply to parts of an answer: comment on blocks inline, or quote a selection with > on an empty prompt line

Claude Code mod: reply to specific parts of an agent answer.
Two ways, both ending up in your prompt as > quote / comment pairs:
> quote 1
my comment 1
> quote 2
my comment 2
+ appears (over the ● bullet on a reply's first block). Click it; it turns into ▶ and the band above the prompt says which block is armed.└ comment, marker ✎) and the view stays put. Plain Enter saves too; outside tmux (see below) Claude Code then scrolls to the bottom.Text already in the prompt (a > quote, see below) is set aside while a block is armed and comes back below the comments once it is saved or cancelled.
Click ✎ to edit a comment, ▶ or Cancel to disarm, Remove to delete the armed block's comment, Clear in the band to drop them all.
Notes: the plugin draws assistant replies itself (to put markers in the gutter), so their layout may differ slightly from Claude Code's own. Comments are tied to a block's text, so identical blocks share one; while a block is armed a comment can't hold a line break (the newline saves).
Inside tmux this works out of the box. While a block is armed the plugin sets the pane option @qr_armed, and at session start it binds Enter in the running tmux server (your config files are untouched) to send Shift+Enter while that flag is set — a save without the scroll:
bind-key -T root Enter if-shell -F "#{@qr_armed}" "send-keys S-Enter" "send-keys Enter"
At session start (inside tmux only) the plugin also clears a @qr_armed left on its pane by a session that died with a block armed, then decides:
| Your tmux | What the plugin does |
|---|---|
extended-keys on (or always), Enter unbound | binds Enter as above |
| Enter already bound to this binding | leaves it as is |
| Enter bound by you to something else | leaves yours alone, says so once in the transcript |
extended-keys off | doesn't bind (tmux couldn't send a Shift+Enter Claude Code tells from Enter); says so once, suggesting set -g extended-keys on |
Without the binding, Shift+Enter still saves without the jump; plain Enter saves with it. What each row does to the binding was checked against a real tmux 3.6 server (the transcript notes only show in an interactive session). Turn the binding off with the plugin's tmuxEnter setting (/config); the binding lives in the server's memory, so it is gone after a tmux restart.
Not C-j: a bare line feed is inserted by Claude Code without reaching the plugin.
"tui": "fullscreen").> — the selection is inserted as a > quote, caret below it.\ + Enter. Repeat, then Enter to send.> on a non-empty line, with nothing selected, or a second time with the same selection inserts a plain >.
/plugin install quick-reply --marketplace ivanzoid/cc-quick-reply-mod
Answer y to add the marketplace, then pick the user scope.
hooks/register.ts 8 lines1import type { Register } from 'claude-code'
2
3import { registerInlineComments } from './inline'
4
5export const register: Register = (on, options) => {
6 registerInlineComments(on, options)
7}
8hooks/inline.tsx 317 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3
4import type { Armed, Comment } from '../types'
5import { blockId, composeReply, splitBlocks } from './blocks'
6import { quoteDecorations } from './quote'
7import { isQuoteTrigger, resetLastQuoted, withQuote } from './quote-trigger'
8import { ARMED_OPTION, ENTER_BINDING, tmuxPlan } from './tmux'
9
10const comments = atom({ plugin: 'quick-reply', key: 'comments' } as const, {})
11// The block armed for a comment: the next prompt typed is saved as its comment
12// instead of being sent. (A field inside a transcript row never gets the keys.)
13const editing = atom({ plugin: 'quick-reply', key: 'editing' } as const, null)
14// The prompt's text while a block is armed, drawn live under that block.
15const draft = atom({ plugin: 'quick-reply', key: 'draft' } as const, '')
16// The prompt's own text (a `>` quote, a reply begun) set aside while a block is
17// armed, put back below the pending comments once it is saved or cancelled.
18const held = atom({ plugin: 'quick-reply', key: 'held' } as const, '')
19
20/** Mirrors the prompt under the armed block as it is typed; no-op otherwise. */
21async function mirrorDraft($: EngineInterface, text: string) {
22 if ((await read($, editing)) === null) return
23 await update($, draft, () => text)
24}
25
26// Saves `text` as the armed block's comment (empty removes it) and disarms.
27async function saveArmed($: EngineInterface, armed: Armed, quote: string | undefined, text: string) {
28 await update($, comments, cur => {
29 const { [armed.id]: _, ...rest } = cur
30 const q = quote ?? cur[armed.id]?.quote
31
32 return text === '' || q === undefined ? rest : { ...rest, [armed.id]: { quote: q, text } }
33 })
34 await disarm($)
35}
36
37// Mirrors "a block is armed" into the tmux pane option @qr_armed, so the tmux
38// binding (setUpTmux) turns Enter into Shift+Enter then: a save without
39// Enter's submit, which scrolls the transcript to its end. Outside tmux, or
40// failing, nothing happens.
41async function setTmuxArmed($: EngineInterface, isArmed: boolean) {
42 const pane = await $.env.get('TMUX_PANE')
43 if (pane === undefined || pane === '') return
44 const argv = isArmed
45 ? ['tmux', 'set-option', '-p', '-t', pane, ARMED_OPTION, '1']
46 : ['tmux', 'set-option', '-p', '-u', '-t', pane, ARMED_OPTION]
47 await $.process.run(argv, { timeoutMs: 2000 }).catch(() => undefined)
48}
49
50// At session start inside tmux: clear a flag a crashed session left on this
51// pane, then (option tmuxEnter) bind Enter in the running server, never over
52// the person's own Enter binding nor without extended keys. The config files
53// are untouched; the binding lasts until the server ends.
54async function setUpTmux($: EngineInterface, isEnterWanted: boolean) {
55 const pane = await $.env.get('TMUX_PANE')
56 if (pane === undefined || pane === '') return
57 await setTmuxArmed($, false)
58 if (!isEnterWanted) return
59
60 const run = (argv: readonly string[]) =>
61 $.process.run(['tmux', ...argv], { timeoutMs: 2000 }).catch(() => undefined)
62 const keys = await run(['show-options', '-sv', 'extended-keys'])
63 const bound = await run(['list-keys', '-T', 'root', 'Enter'])
64 const plan = tmuxPlan(keys?.stdout ?? '', bound?.exitCode === 0 ? bound.stdout : '')
65
66 if (plan === 'bind') await run(ENTER_BINDING)
67 if (plan === 'no-extended-keys') {
68 $.ui.log('quick-reply: tmux extended-keys is off, so Enter saving a comment scrolls to the end; `set -g extended-keys on` fixes it')
69 }
70 if (plan === 'enter-taken') {
71 $.ui.log('quick-reply: tmux Enter is already bound, left as is; Shift+Enter saves a comment without scrolling')
72 }
73}
74
75const disarm = async ($: EngineInterface) => {
76 await update($, editing, () => null)
77 await update($, draft, () => '')
78 await setTmuxArmed($, false)
79}
80
81const firstLine = (text: string, max = 50) => {
82 const line = (text.split('\n')[0] ?? '').replace(/^[-*+#>\s]+|^\d+[.)]\s+/, '')
83
84 return line.length > max ? `${line.slice(0, max - 1)}…` : line
85}
86
87// Puts the pending comments in the prompt box, ready to send with Enter, and
88// below them the text set aside when the block was armed (or the caret's line).
89async function showPending($: EngineInterface) {
90 const text = pendingText(Object.values(await read($, comments))) + (await read($, held))
91 await update($, held, () => '')
92 await $.prompt.fill({ text, decorations: quoteDecorations(text) })
93}
94
95// The pending comments as the prompt box holds them, the caret's line below.
96const pendingText = (list: Comment[]) => (list.length === 0 ? '' : `${composeReply(list)}\n\n`)
97
98/** The box's text other than the pending comments showPending put at its head. */
99export function ownText(box: string, list: Comment[]): string {
100 const reply = list.length === 0 ? '' : composeReply(list)
101
102 return reply !== '' && box.startsWith(reply) ? box.slice(reply.length).replace(/^\n+/, '') : box
103}
104
105/**
106 * Inline comments: a marker beside each block of a reply arms it, the next
107 * prompt typed is saved as its comment, and the comments wait in the prompt.
108 * Also the `>` selection quote, which shares the prompt hooks.
109 */
110export function registerInlineComments(on: On, options: PluginOptions) {
111 on('session.start', async ($, e, next) => {
112 await setUpTmux($, options.tmuxEnter !== false)
113
114 return next(e)
115 })
116
117 // Quotes by block id, for blocks drawn this session: the armed id's quote
118 // is read from here when the comment is saved.
119 const quotes = new Map<string, string>()
120
121 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
122 if (e.props.isSummary) return next(e)
123 const blocks = splitBlocks(e.props.text)
124 if (blocks.length === 0) return next(e)
125
126 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
127 const all = await read($, comments)
128 const armed = await read($, editing)
129 const typed = await read($, draft)
130
131 return (
132 <Box flexDirection="column">
133 {blocks.map((block, i) => {
134 const id = blockId(block)
135 quotes.set(id, block)
136 const comment = all[id]
137 const isArmed = armed?.id === id
138 const isBullet = i === 0 && e.props.isFirstOfReply
139 const marker = isArmed ? '▶' : comment ? '✎' : null
140 const markerButton = (label: string) => (
141 <Button
142 key={`c:${id}`}
143 label={label}
144 plain
145 dimColor={label === '+'}
146 onPress={async () => {
147 if (isArmed) {
148 await disarm($)
149 await showPending($)
150 return
151 }
152 // Arming over another armed block drops that one's draft only.
153 if ((await read($, editing)) === null) {
154 const box = (await $.prompt.read()).text
155 const list = Object.values(await read($, comments))
156 await update($, held, () => ownText(box, list))
157 }
158 await update($, editing, () => ({ id, label: firstLine(block) }))
159 await setTmuxArmed($, true)
160 await update($, draft, () => comment?.text ?? '')
161 await $.prompt.fill({ text: comment?.text ?? '' })
162 }}
163 />
164 )
165
166 return (
167 <Box key={`b:${id}`} flexDirection="column" marginBottom={i < blocks.length - 1 ? 1 : 0}>
168 <Box flexDirection="row">
169 <Box key={`g:${id}`} width={2} flexShrink={0}>
170 {/* ✎ and ▶ show always. Otherwise a `+` is laid over the gutter
171 (absolute, so it covers a reply's ● bullet) and revealed only
172 while the pointer is over the gutter, its own hover scope. */}
173 {marker === null ? (
174 <>
175 <Text>{isBullet ? '●' : ' '}</Text>
176 <Box position="absolute" top={0} left={0} display="none" hover={{ display: 'flex' }}>
177 {markerButton('+')}
178 </Box>
179 </>
180 ) : (
181 markerButton(marker)
182 )}
183 </Box>
184 <Box flexGrow={1} flexShrink={1}>
185 <Markdown text={block} />
186 </Box>
187 </Box>
188 {isArmed ? (
189 <Box marginLeft={2}>
190 {typed.trim() === '' ? (
191 <Text dimColor italic>└ type your comment in the prompt; Shift+Enter or Ctrl+J saves</Text>
192 ) : (
193 <Text color="warning" italic>{`└ ${typed}▏`}</Text>
194 )}
195 </Box>
196 ) : (
197 comment && (
198 <Box marginLeft={2}>
199 <Text color="warning">{`└ ${comment.text}`}</Text>
200 </Box>
201 )
202 )}
203 </Box>
204 )
205 })}
206 </Box>
207 )
208 })
209
210 // The plugin's one prompt.edit hook: a `>` on an empty line quotes the
211 // mouse selection; while a block is armed the draft is mirrored under it and
212 // a newline (Shift+Enter, Ctrl+J) saves it: an edit, unlike
213 // Enter's submit, leaves the transcript where it is. Quote lines draw dim.
214 on('prompt.edit', async ($, e, next) => {
215 const armed = await read($, editing)
216 const isNewline =
217 e.inputText !== '' && e.inputText.trim() === '' && /[\r\n]/.test(e.inputText)
218 if (armed && isNewline) {
219 await saveArmed($, armed, quotes.get(armed.id), e.text.trim())
220 // A box answered here with the comments in it did not show them until
221 // Enter (seen live); consume the key with an empty box, then fill it.
222 $.clock.after(50, () => void showPending($))
223
224 return { text: '', cursor: 0 }
225 }
226
227 const edit = isQuoteTrigger(e) ? withQuote(e, (await $.ui.selection())?.text) : e
228 const r = await next(edit)
229 await mirrorDraft($, r.text)
230
231 return { ...r, decorations: [...(r.decorations ?? []), ...quoteDecorations(r.text)] }
232 })
233
234 on('prompt.submit', async ($, e, next) => {
235 resetLastQuoted()
236 if (e.origin?.kind !== 'composer' || e.text.trimStart().startsWith('/')) return next(e)
237
238 const armed = await read($, editing)
239 if (!armed) {
240 const list: Comment[] = Object.values(await read($, comments))
241 if (list.length === 0) return next(e)
242 await update($, comments, () => ({}))
243 // The box usually holds them already (showPending); else they go first.
244 const reply = composeReply(list)
245 const own = e.text.trim()
246 const text = own.startsWith(reply) ? own : own === '' ? reply : `${reply}\n\n${own}`
247
248 return next({ ...e, text })
249 }
250
251 await saveArmed($, armed, quotes.get(armed.id), e.text.trim())
252 // The engine empties the box once the drop is answered; refill after it.
253 // (Enter also scrolls the transcript to its end, which no hook can undo.)
254 $.clock.after(50, () => void showPending($))
255
256 return { drop: `comment saved on «${armed.label}» (not sent)` }
257 })
258
259 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
260 const all = await read($, comments)
261 const list: Comment[] = Object.values(all)
262 const armed = await read($, editing)
263 if (e.props.hasSurvey || (list.length === 0 && !armed)) return next(e)
264
265 const { Box, Button, Text } = $.ui.resolve(e)
266 // Drops the comments, keeping the box's own text (set aside, if armed).
267 const clear = async () => {
268 const own = armed ? await read($, held) : ownText((await $.prompt.read()).text, list)
269 await update($, comments, () => ({}))
270 await disarm($)
271 await update($, held, () => '')
272 await $.prompt.fill({ text: own, decorations: quoteDecorations(own) })
273 }
274
275 return (
276 <Box flexDirection="column">
277 {armed && (
278 <Box flexDirection="row" gap={1}>
279 <Text color="warning">{`✎ commenting on «${armed.label}» — type it, Shift+Enter or Ctrl+J saves`}</Text>
280 {all[armed.id] && (
281 <Button
282 key="remove"
283 label="Remove"
284 onPress={async () => {
285 await update($, comments, ({ [armed.id]: _, ...rest }) => rest)
286 await disarm($)
287 await showPending($)
288 }}
289 />
290 )}
291 <Button
292 key="cancel"
293 label="Cancel"
294 onPress={async () => {
295 await disarm($)
296 await showPending($)
297 }}
298 />
299 </Box>
300 )}
301 {list.length > 0 && (
302 <Box flexDirection="row" gap={1}>
303 <Text color="warning">{`${list.length} comment${list.length === 1 ? '' : 's'} · in the prompt, Enter sends`}</Text>
304 <Button
305 key="clear"
306 label="Clear"
307 onPress={async () => {
308 await clear()
309 }}
310 />
311 </Box>
312 )}
313 </Box>
314 )
315 })
316}
317hooks/blocks.ts 71 lines1import type { Comment } from '../types'
2
3const ITEM = /^(?:[-*+] |\d+[.)] |#{1,6} |> )/
4
5/**
6 * Splits a reply's markdown into the blocks a comment can attach to:
7 * paragraphs, top-level list items, headings, and fenced code blocks whole.
8 */
9export function splitBlocks(markdown: string): string[] {
10 const blocks: string[] = []
11 let current: string[] = []
12 let fence: string | undefined
13 const flush = () => {
14 if (current.length > 0) blocks.push(current.join('\n'))
15 current = []
16 }
17
18 for (const line of markdown.split('\n')) {
19 const marker = /^\s*(```+|~~~+)/.exec(line)?.[1]
20 if (fence !== undefined) {
21 current.push(line)
22 if (marker !== undefined && marker.startsWith(fence)) {
23 fence = undefined
24 flush()
25 }
26 continue
27 }
28 if (marker !== undefined) {
29 flush()
30 fence = marker
31 current.push(line)
32 continue
33 }
34 if (line.trim() === '') {
35 flush()
36 continue
37 }
38 if (ITEM.test(line)) flush()
39 current.push(line)
40 }
41 flush()
42
43 return blocks
44}
45
46/** The prompt the comments make: each quoted block, its comment under it. */
47export function composeReply(comments: readonly Comment[]): string {
48 return comments
49 .map(({ quote, text }) => {
50 const quoted = quote
51 .split('\n')
52 .map(line => (line.trim() === '' ? '>' : `> ${line.trimEnd()}`))
53 .join('\n')
54
55 return `${quoted}\n${text}`
56 })
57 .join('\n\n')
58}
59
60
61/**
62 * A block's id: a hash of its text. The engine may draw a reply again under
63 * another requestId, so ids made from it would orphan the comments made.
64 */
65export function blockId(block: string): string {
66 let h = 5381
67 for (let i = 0; i < block.length; i++) h = ((h * 33) ^ block.charCodeAt(i)) >>> 0
68
69 return `${h.toString(36)}${block.length.toString(36)}`
70}
71hooks/quote.ts 43 lines1import type { PromptDecoration } from 'claude-code'
2
3/** True when the caret sits at the start of an empty line of the draft. */
4export function atEmptyLine(text: string, cursor: number): boolean {
5 const before = cursor === 0 || text[cursor - 1] === '\n'
6 const after = cursor === text.length || text[cursor] === '\n'
7 return before && after
8}
9
10/**
11 * The selection as a markdown quote block, ending in a newline so the caret
12 * lands on the line below, where the reply goes. A blank line is put before
13 * it when the line above holds a previous reply, so the blocks stay apart.
14 */
15export function quoteBlock(selected: string, textBefore: string): string {
16 const lines = selected
17 .replace(/\r\n?/g, '\n')
18 .split('\n')
19 .map(line => line.trimEnd())
20 while (lines.length > 0 && lines[0] === '') lines.shift()
21 while (lines.length > 0 && lines.at(-1) === '') lines.pop()
22 if (lines.length === 0) return ''
23
24 const quoted = lines.map(line => (line === '' ? '>' : `> ${line}`)).join('\n')
25 const needsGap = textBefore.length > 0 && !textBefore.endsWith('\n\n')
26
27 return `${needsGap ? '\n' : ''}${quoted}\n`
28}
29
30/** Dims every quote line of the draft, so the replies stand out. */
31export function quoteDecorations(text: string): PromptDecoration[] {
32 const runs: PromptDecoration[] = []
33 let start = 0
34 for (const line of text.split('\n')) {
35 if (line.startsWith('>')) {
36 runs.push({ start, end: start + line.length, dimColor: true, italic: true })
37 }
38 start += line.length + 1
39 }
40
41 return runs
42}
43hooks/quote-trigger.ts 30 lines1import type { PromptEditInput } from 'claude-code'
2
3import { atEmptyLine, quoteBlock } from './quote'
4
5// The selection last turned into a quote: typing `>` again with the same
6// selection still held inserts a plain `>`.
7let lastQuoted: string | undefined
8
9/** Forgets the last quoted selection: the plugin's one prompt.submit hook calls it. */
10export function resetLastQuoted() {
11 lastQuoted = undefined
12}
13
14/** Whether the edit is a `>` typed on an empty prompt line. */
15export function isQuoteTrigger(e: PromptEditInput): boolean {
16 return e.inputText === '>' && e.start === e.end && atEmptyLine(e.text, e.start)
17}
18
19/**
20 * The trigger edit with the mouse selection put in as a quote block; the edit
21 * as it came when nothing is selected or that selection was just quoted.
22 */
23export function withQuote(e: PromptEditInput, selected: string | undefined): PromptEditInput {
24 const block = selected === undefined ? '' : quoteBlock(selected, e.text.slice(0, e.start))
25 if (block === '' || selected === lastQuoted) return e
26 lastQuoted = selected
27
28 return { ...e, inputText: block }
29}
30hooks/tmux.ts 25 lines1/** The flag the plugin sets on its tmux pane while a block is armed. */
2export const ARMED_OPTION = '@qr_armed'
3
4/** The root-table binding: Enter goes in as Shift+Enter while armed. */
5export const ENTER_BINDING = [
6 'bind-key', '-T', 'root', 'Enter',
7 'if-shell', '-F', `#{${ARMED_OPTION}}`, 'send-keys S-Enter', 'send-keys Enter',
8] as const
9
10export type TmuxPlan = 'bind' | 'already-bound' | 'no-extended-keys' | 'enter-taken'
11
12/**
13 * Whether to bind Enter, from `tmux show -sv extended-keys` and the root
14 * table's Enter binding as `tmux list-keys -T root Enter` prints it (empty
15 * when there is none). Without extended keys tmux can't send a Shift+Enter
16 * Claude Code tells from Enter; someone else's Enter binding is left alone.
17 */
18export function tmuxPlan(extendedKeys: string, enterBinding: string): TmuxPlan {
19 if (enterBinding.includes(ARMED_OPTION)) return 'already-bound'
20 if (enterBinding.trim() !== '') return 'enter-taken'
21 if (!['on', 'always'].includes(extendedKeys.trim())) return 'no-extended-keys'
22
23 return 'bind'
24}
25types/index.d.ts 18 lines1export type Comment = { quote: string; text: string }
2export type Armed = { id: string; label: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'quick-reply': {
7 /** Comments by `<message requestId>:<block index>`, in the order made. */
8 comments: Record<string, Comment>
9 /** The block the next typed prompt comments on, or null. */
10 editing: Armed | null
11 /** The prompt's text while a block is armed, mirrored under it. */
12 draft: string
13 /** The prompt's own text set aside while a block is armed. */
14 held: string
15 }
16 }
17}
18