SLOPSHOPPER

reply-quote

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

newbandprompttimer
v0.3.0MITupdated 2026-10-20DiegoHermosilloP/reply-quote
A shopper browsing a rack in a slop shop
README

reply-quote

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.

A quoted reply above the prompt

  • Select text with the mouse, in a response or in your own prompt. A box above the prompt offers Reply ↰.
  • Quotes stack: select and reply to several parts, then write once to reply to all of them.
  • Each quote shows in the band above the prompt, with ▼ Show more for long ones and ✕ Remove to drop it.
  • Long quotes are sent to Claude as their start and end only (Claude already has the text in context), which saves tokens. Each quote shows a rough token estimate.

Known limit: mouse selection is only visible to mods in fullscreen mode.

Install

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

Settings

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.

More mods

usage-dots: plan usage and context-window gauges above the prompt, plus a live prompt token estimate.

License

MIT

Source 2 files
hooks/register.tsx 162 lines
1import { 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}
162
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'reply-quote': { quotes: string[]; selected: string | null; open: number[] }
4  }
5}
6