SLOPSHOPPER

reply

Highlight part of Claude's answer and reply to it. The text goes into your prompt as a quote.

newbandtoasttimer
v0.1.0MITupdated 2026-10-15krishna-goutham-tls/cc-mods/reply
A shopper browsing a rack in a slop shop
README

reply

A Claude Code mod for the terminal. Highlight part of Claude's answer, press 1, and the text goes into your prompt as a quote. You type your reply under it and send.

<img src="docs/reply-light-1.png" alt="A sentence of Claude's answer is highlighted. A row above the prompt reads: Reply to, the sentence, then 1: Reply." width="100%">

<img src="docs/reply-light-2.png" alt="The prompt holds the sentence as a quote, with a question typed under it." width="100%">

The pictures are from a test terminal, on a made-up project called demo-app.

How it works

You doThe mod does
Highlight text with the mouseShows a row above the prompt: ↳ Reply to "…" 1: Reply
Press 1 in an empty promptPuts the text in the prompt as a > quote, with a blank line under it for your words
Click ReplyThe same. Use the click when the prompt already has a draft: the draft stays, and the quote goes under it
Highlight more text and press againAdds the next quote under the first one, so one prompt can answer several parts
Send the promptThe row goes away

The mod never sends a prompt. It puts text in the prompt box, and you send it.

Install

In a Claude Code terminal session:

/plugin install reply --marketplace krishna-goutham-tls/cc-mods

Before you install

  • Terminal only, in the fullscreen layout. Claude Code tells a mod what you highlighted only there. /tui fullscreen turns it on.
  • Claude Code raises no event when you highlight text, so the mod checks for a highlight 4 times a second. It reads only the highlight.
  • A highlight made only to copy text also shows the row. The row stays until you send your next prompt.
  • Built and tested on Claude Code 2.1.300. Mods are early access, so a later release can change the API and break the mod.

License

MIT

Source 3 files
hooks/register.tsx 106 lines
1// reply: highlight part of Claude's answer, press 1, and the text goes into the
2// prompt as a quote with room under it for your words. Terminal only.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { Picked } from '../types'
7import { fillFor, isWorthQuoting, previewOf } from './quote'
8
9/** How often the mod looks for a new highlight. The engine raises no event for one. */
10const POLL_MS = 250
11const LABEL = 'Reply'
12const HOTKEY = '1'
13
14const picked = atom({ plugin: 'reply', key: 'picked' } as const, null as Picked)
15const used = atom({ plugin: 'reply', key: 'used' } as const, '')
16
17/** Reads the highlight and keeps it in state, so the band shows or hides. */
18const look = async ($: EngineInterface): Promise<void> => {
19  const selected = await $.ui.selection().catch(() => undefined)
20  const text = selected !== undefined && isWorthQuoting(selected.text) ? selected.text : ''
21  const now = await read($, picked)
22
23  if ((now?.text ?? '') === text) {
24    return
25  }
26
27  await update($, picked, (): Picked => (text === '' ? null : { text }))
28
29  if (text === '') {
30    await update($, used, () => '')
31  }
32}
33
34/** Puts the highlight in the prompt as a quote. */
35const reply = async ($: EngineInterface): Promise<void> => {
36  const selected = await $.ui.selection().catch(() => undefined)
37  const text = selected !== undefined && isWorthQuoting(selected.text) ? selected.text : ((await read($, picked))?.text ?? '')
38
39  if (text === '') {
40    return
41  }
42
43  const box = await $.prompt.read().catch(() => ({ text: '', cursor: 0 }))
44  const filled = await $.prompt.fill(fillFor(box.text, text))
45
46  if (filled.isFilled) {
47    await update($, used, () => text)
48  } else {
49    $.ui.toast('The prompt cannot take the quote now. Close the dialog and press Reply again.')
50  }
51}
52
53export const register: Register = on => {
54  let isLooking = false
55
56  on('session.start', async ($, e, next) => {
57    if (e.surface !== 'terminal') {
58      return next(e)
59    }
60
61    $.clock.every(POLL_MS, () => {
62      if (isLooking) {
63        return
64      }
65
66      isLooking = true
67      void look($)
68        .catch(() => undefined)
69        .finally(() => {
70          isLooking = false
71        })
72    })
73
74    return next(e)
75  })
76
77  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78    if (e.surface !== 'terminal' || e.props.hasSurvey) {
79      return next(e)
80    }
81
82    const now = await read($, picked)
83
84    if (now === null || now.text === (await read($, used))) {
85      return next(e)
86    }
87
88    const { Box, Text, Button } = $.ui.resolve(e)
89    const { words, lines } = previewOf(now.text)
90    const count = lines > 1 ? ` · ${lines} lines` : ''
91    // "↳ Reply to " before the words, the count and "1: Reply" after, two cells of gap.
92    const room = Math.max(8, e.props.bodyColumns - 11 - count.length - 2 - (HOTKEY.length + 2 + LABEL.length) - 2)
93    const shown = words.length > room ? `${words.slice(0, room - 1).replace(/\s+$/, '')}…` : words
94
95    return (
96      <Box flexDirection="row" width={e.props.bodyColumns}>
97        <Text color="claude">↳ </Text>
98        <Text color="inactive">Reply to </Text>
99        <Text color="text">“{shown}”</Text>
100        <Text color="inactive">{count}  </Text>
101        <Button key="reply" label={LABEL} hotkey={HOTKEY} plain onPress={() => void reply($).catch(() => undefined)} />
102      </Box>
103    )
104  })
105}
106
hooks/quote.ts 59 lines
1// The text work of the mod: no `$` here.
2
3/** The most of a selection that goes into the prompt. */
4export const MAX_QUOTE = 4000
5
6/** A selection as the lines it covers, with blank lines at both ends and trailing spaces gone. */
7const linesOf = (text: string): string[] => {
8  const lines = text.replace(/\r\n?/g, '\n').split('\n').map(line => line.replace(/\s+$/, ''))
9
10  while (lines.length > 0 && lines[0] === '') {
11    lines.shift()
12  }
13
14  while (lines.length > 0 && lines[lines.length - 1] === '') {
15    lines.pop()
16  }
17
18  return lines
19}
20
21/** True when a selection holds something worth a reply. */
22export const isWorthQuoting = (text: string): boolean => linesOf(text).join('').trim() !== ''
23
24/** The selection as a markdown quote: `> ` before each line, `>` alone on a blank one. */
25export const quoteOf = (text: string): string => {
26  const lines = linesOf(text)
27  const all = lines.join('\n')
28  const kept = all.length > MAX_QUOTE ? `${all.slice(0, MAX_QUOTE).replace(/\s+$/, '')} …` : all
29
30  return kept
31    .split('\n')
32    .map(line => (line === '' ? '>' : `> ${line}`))
33    .join('\n')
34}
35
36/**
37 * What goes in the prompt box. An empty box takes the quote and a blank line.
38 * A box with a draft keeps it and gets the quote after a blank line, so
39 * several replies stack in one prompt.
40 */
41export const fillFor = (draft: string, text: string): { text: string; mode: 'replace' | 'append' } => {
42  const quote = quoteOf(text)
43
44  if (draft.trim() === '') {
45    return { text: `${quote}\n\n`, mode: 'replace' }
46  }
47
48  const gap = draft.endsWith('\n\n') ? '' : draft.endsWith('\n') ? '\n' : '\n\n'
49
50  return { text: `${gap}${quote}\n\n`, mode: 'append' }
51}
52
53/** One line for the band: the selection's words on one row, and how many lines it has. */
54export const previewOf = (text: string): { words: string; lines: number } => {
55  const lines = linesOf(text).filter(line => line.trim() !== '')
56
57  return { words: lines.map(line => line.trim()).join(' '), lines: lines.length }
58}
59
types/index.d.ts 13 lines
1/** The text the person last highlighted with the mouse, as a copy would take it. */
2export type Picked = { text: string } | null
3
4declare module 'claude-code' {
5  interface PluginState {
6    reply: {
7      picked: Picked
8      /** The last text put in the prompt, so the band hides once it is used. */
9      used: string
10    }
11  }
12}
13