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

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.
| You do | The mod does |
|---|---|
| Highlight text with the mouse | Shows a row above the prompt: ↳ Reply to "…" 1: Reply |
Press 1 in an empty prompt | Puts the text in the prompt as a > quote, with a blank line under it for your words |
Click Reply | The 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 again | Adds the next quote under the first one, so one prompt can answer several parts |
| Send the prompt | The row goes away |
The mod never sends a prompt. It puts text in the prompt box, and you send it.
In a Claude Code terminal session:
/plugin install reply --marketplace krishna-goutham-tls/cc-mods
/tui fullscreen turns it on.MIT
hooks/register.tsx 106 lines1// 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}
106hooks/quote.ts 59 lines1// 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}
59types/index.d.ts 13 lines1/** 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