Select text with the mouse, click Reply, and your next prompt is sent as a reply to that quoted part.

A Claude Code mod that works like the desktop app's "Reply": select text in the terminal, click Reply ↰, and your next prompt is sent as a reply to that quoted part.

Known limit: mouse selection is only visible to mods in fullscreen mode.
At the prompt of a Claude Code session:
/plugin install reply-quote --marketplace DiegoHermosilloP/reply-quote
Answer y to add the marketplace, then choose a scope. From a shell:
claude plugin install reply-quote --marketplace DiegoHermosilloP/reply-quote
Under /config: preview length, maximum quotes, maximum characters sent per quote (0 sends every quote whole), and the colours of quotes, selection and quoted text.
usage-dots: plan usage and context-window gauges above the prompt, plus a live prompt token estimate.
MIT
hooks/register.tsx 162 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const quotes = atom({ plugin: 'reply-quote', key: 'quotes' } as const, [])
5const selected = atom({ plugin: 'reply-quote', key: 'selected' } as const, null)
6// Indices of the quotes drawn in full rather than cut to the preview length.
7const open = atom({ plugin: 'reply-quote', key: 'open' } as const, [])
8
9const FULL = 3000
10const POLL_MS = 400
11
12const cut = (text: string, max: number, isFlat: boolean) => {
13 const shown = isFlat ? text.replace(/\s+/g, ' ').trim() : text.trim()
14
15 return shown.length > max ? `${shown.slice(0, max)}…` : shown
16}
17
18const asBlock = (text: string) =>
19 text
20 .split('\n')
21 .map(line => `> ${line}`)
22 .join('\n')
23
24// What is sent for a quote: whole, or, past `limit` characters, its start and
25// end. The text is already in Claude's context, so the ends locate it.
26const sent = (text: string, limit: number) => {
27 const whole = text.trim()
28
29 if (limit === 0 || whole.length <= limit) {
30 return whole
31 }
32
33 const head = whole.slice(0, Math.ceil(limit * 0.65)).trimEnd()
34 const tail = whole.slice(-Math.floor(limit * 0.35)).trimStart()
35
36 return `${head}\n[… middle omitted, it is earlier in this conversation …]\n${tail}`
37}
38
39// A rough token count, four characters to a token.
40const tokens = (text: string) => Math.ceil(text.length / 4)
41
42export const register: Register = (on, options) => {
43 const preview = Number(options.previewChars)
44 const maxQuotes = Number(options.maxQuotes)
45 const quoteColor = String(options.quoteColor)
46 const selectionColor = String(options.selectionColor)
47 const textColor = String(options.quoteTextColor)
48 const sentChars = Number(options.sentChars)
49
50 // The selection last quoted, so a highlight that outlives the press does
51 // not offer itself again.
52 let taken: string | null = null
53
54 on('session.start', async ($, e, next) => {
55 $.clock.every(POLL_MS, async () => {
56 const text = (await $.ui.selection())?.text.trim() || null
57
58 if (text === null) {
59 taken = null
60 }
61
62 const offered = text !== null && text !== taken ? text : null
63
64 if (offered !== (await read($, selected))) {
65 await update($, selected, () => offered)
66 }
67 })
68
69 return next(e)
70 })
71
72 on('prompt.submit', async ($, e, next) => {
73 const list = await read($, quotes)
74
75 if (e.command || e.isMeta || list.length === 0) {
76 return next(e)
77 }
78
79 await update($, quotes, () => [])
80 await update($, open, () => [])
81 const heading = list.length === 1 ? 'Replying to:' : 'Replying to these:'
82 const body = list.map(quote => asBlock(sent(quote, sentChars))).join('\n\n')
83
84 return next({ ...e, text: `${heading}\n\n${body}\n\n${e.text}` })
85 })
86
87 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
88 const list = await read($, quotes)
89 const offered = await read($, selected)
90 const opened = await read($, open)
91
92 if (e.props.hasSurvey || (list.length === 0 && offered === null)) {
93 return next(e)
94 }
95
96 const { Box, Button, Text } = $.ui.resolve(e)
97
98 const toggle = (i: number) =>
99 update($, open, l => (l.includes(i) ? l.filter(n => n !== i) : [...l, i]))
100
101 return (
102 <Box flexDirection="column">
103 {list.map((quote, i) => {
104 const isOpen = opened.includes(i)
105 const isLong = quote.length > preview
106
107 return (
108 <Box key={`q${i}`} flexDirection="column" borderStyle="round" borderColor={quoteColor} paddingX={1}>
109 <Text color={quoteColor} weight="bold">
110 {`↰ Replying to (${i + 1}/${list.length}) · ~${tokens(sent(quote, sentChars))} tokens sent`}
111 </Text>
112 <Text color={textColor}>{isOpen ? cut(quote, FULL, false) : cut(quote, preview, true)}</Text>
113 <Box>
114 {isLong && (
115 <Button
116 key={`t${i}`}
117 label={isOpen ? '▲ Show less ' : '▼ Show more '}
118 onPress={() => toggle(i)}
119 />
120 )}
121 <Button
122 key={`x${i}`}
123 label="✕ Remove"
124 onPress={async () => {
125 await update($, quotes, l => l.filter((_, n) => n !== i))
126 await update($, open, () => [])
127 }}
128 />
129 </Box>
130 </Box>
131 )
132 })}
133 {list.length > 0 && (
134 <Button
135 key="clear"
136 label="✕ Remove quotes"
137 onPress={async () => {
138 await update($, quotes, () => [])
139 await update($, open, () => [])
140 }}
141 />
142 )}
143 {offered !== null && (
144 <Box flexDirection="column" borderStyle="round" borderColor={selectionColor} paddingX={1}>
145 <Text color={selectionColor}>{`“${cut(offered, 160, true)}”`}</Text>
146 <Button
147 key="reply"
148 variant="primary"
149 label="Reply ↰"
150 onPress={async () => {
151 taken = offered
152 await update($, quotes, l => [...l, offered].slice(-maxQuotes))
153 await update($, selected, () => null)
154 }}
155 />
156 </Box>
157 )}
158 </Box>
159 )
160 })
161}
162types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'reply-quote': { quotes: string[]; selected: string | null; open: number[] }
4 }
5}
6