Take Claude's suggested next prompt and see what it refers to: the passage is marked in the reply and quoted above the prompt.

See what Claude's suggestion is pointing at before you send it.
After a reply, Claude Code often suggests your next message in the prompt box: "do the Friday one", "go with option 2", "add the toggle to Settings too". Press Tab and it's yours. But which Friday one? If the reply was long, you scroll back up and reread it to find out what you're agreeing to.
Suggestion Spotlight finds it for you. Take a suggestion, and the passage it refers to is marked where it sits in the reply and quoted in a box just above the prompt, right where you're about to press Enter.

claude (the default): a clay-orange box headed by Clawd, the pixel crab.
highlighter: no box. The passage is marked like a highlighter pen, in yellow with dark text, so it reads the same in light and dark mode.

Switch at any time with /suggestion-spotlight style highlighter or /suggestion-spotlight style claude.
In a Claude Code terminal session:
/plugin install suggestion-spotlight --marketplace C-M-Jones/suggestion-spotlight
Answer y to add the marketplace and pick the user scope. It's active straight away.
Desktop app, or loading it by hand: clone the repo and add the folder to the env block of ~/.claude/settings.json. Separate several mod folders with :, and append to the variable rather than replacing it if it's already set.
git clone https://github.com/C-M-Jones/suggestion-spotlight.git ~/suggestion-spotlight
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/suggestion-spotlight/suggestion-spotlight"
}
}
Then restart Claude Code. git pull in the clone updates it.
| Command | What it does |
|---|---|
/suggestion-spotlight | Turn it on or off. The choice is remembered across sessions. |
/suggestion-spotlight on / off | The same, explicitly. |
/suggestion-spotlight style claude / highlighter | Switch the look. |
/suggestion-spotlight demo | Spotlight the last list item of the reply above, to see the current style. |
/suggestion-spotlight demo <text> | Run the real lookup as if <text> were the suggestion you took. Handy when Claude hasn't offered one. |
In /config:
| Setting | Options | Default |
|---|---|---|
| Spotlight style | claude, highlighter | claude |
| Where to spotlight | both, above-prompt, in-reply | both |
| Lookup model | haiku, sonnet | haiku |
When you take a suggestion, the mod splits the reply into numbered blocks (sentences, list items, whole tables and code blocks) and asks a small model which one block the suggestion points at. It gets back a number, not text. A heading that ends in a colon brings the list beneath it along, so "do step 1" marks the whole step.
Claude Code with mods (function hooks), an early-access API. Built and tested on Claude Code 2.1.293 in the desktop app. The terminal uses Claude Code's own suggestion event instead and hasn't been tested as thoroughly. The API may change between releases.
MIT. See LICENSE.
Made by Cael Jones. If you use or build on this, a credit or a link back is appreciated.
hooks/register.tsx 276 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Anchor } from '../types'
5import { abovePrompt, inReply, isStyle, STYLES } from './look'
6import type { Style } from './look'
7import {
8 byWords,
9 isPhrase,
10 lastReply,
11 numbered,
12 pickFrom,
13 promptFor,
14 sectionOf,
15 segmentsOf,
16 squash,
17 SYSTEM,
18 unitsOf,
19} from './match'
20import type { Row, Unit } from './match'
21
22// When you take Claude's suggested next prompt ("do the Friday one now"), find
23// the part of the last reply it refers to and spotlight it: outlined in the
24// reply, and quoted in a box above the prompt, in the style the person picked.
25
26const offered = atom({ plugin: 'suggestion-spotlight', key: 'offered' } as const, '')
27const anchor = atom({ plugin: 'suggestion-spotlight', key: 'anchor' } as const, null)
28const enabled = atom({ plugin: 'suggestion-spotlight', key: 'enabled' } as const, true)
29const style = atom({ plugin: 'suggestion-spotlight', key: 'style' } as const, 'claude' as Style)
30
31const COMMAND = 'suggestion-spotlight'
32
33type Show = 'both' | 'above-prompt' | 'in-reply'
34
35// The prompt box as the person's own edits left it. A (re)load starts it empty,
36// as the box nearly always is between turns.
37let lastBox = ''
38
39const toRows = (messages: unknown): Row[] =>
40 Array.isArray(messages)
41 ? messages.map(m => ({
42 role: m.role === 'assistant' ? ('assistant' as const) : ('user' as const),
43 text: typeof m.text === 'string' ? m.text : '',
44 isToolResult: Array.isArray(m.toolResults) && m.toolResults.length > 0,
45 }))
46 : []
47
48const freshAnchor = (suggestion: string, isSent: boolean): Anchor => ({
49 suggestion,
50 status: 'finding',
51 units: [],
52 quotes: [],
53 isSent,
54 isHidden: false,
55})
56
57async function locate($: EngineInterface, suggestion: string, model: string, isSent: boolean): Promise<void> {
58 const current = await read($, anchor)
59 if (current && current.suggestion === suggestion) return
60 await update($, anchor, () => freshAnchor(suggestion, isSent))
61
62 const reply = lastReply(toRows(await $.session.messages()), suggestion)
63 const lines = reply.split('\n')
64 const units = unitsOf(lines)
65 let index = -1
66 if (units.length) {
67 const blocks = numbered(units)
68 const answer = await $.model.complete({
69 model,
70 maxTokens: 10,
71 timeoutMs: 15_000,
72 system: SYSTEM,
73 prompt: promptFor(blocks.text, suggestion),
74 })
75 index = answer.isAnswered ? pickFrom(answer.text, blocks.indexOf) : byWords(suggestion, units)
76 }
77 const section: Unit[] = index >= 0 ? sectionOf(units, index, lines) : []
78
79 await update($, anchor, a =>
80 a && a.suggestion === suggestion
81 ? {
82 ...a,
83 status: section.length ? ('found' as const) : ('none' as const),
84 units: section.map(u => squash(u.text)),
85 // The box above the prompt quotes the heading alone; the reply outlines the whole section.
86 quotes: section.slice(0, 1).map(u => u.text),
87 }
88 : a,
89 )
90}
91
92// A suggestion the person took: remember it and look it up once, in the
93// background so the keystroke or the send is never held up.
94async function take($: EngineInterface, text: string, model: string, isSent = false): Promise<void> {
95 if (!(await read($, enabled))) return
96 if (squash(await read($, offered)) === squash(text)) return
97 await update($, offered, () => text)
98 await update($, anchor, () => null)
99 void locate($, text, model, isSent)
100}
101
102// ✕ forgets the suggestion as well as the spotlight, so taking it again looks it up again.
103async function forget($: EngineInterface): Promise<void> {
104 await update($, anchor, () => null)
105 await update($, offered, () => '')
106}
107
108// `/suggestion-spotlight demo`: spotlight the last list item (or paragraph) of
109// the last reply, so the look can be tried without waiting for a suggestion.
110async function demo($: EngineInterface): Promise<string> {
111 const reply = lastReply(toRows(await $.session.messages()), '')
112 const lines = reply.split('\n')
113 const units = unitsOf(lines)
114 const items = units.map((u, i) => ({ u, i })).filter(({ u }) => /^\s*([-*+]|\d+[.)])\s/.test(u.text))
115 const pick = items[items.length - 1]?.i ?? units.length - 1
116 if (pick < 0) return 'Nothing to spotlight yet: there is no reply above.'
117 const section = sectionOf(units, pick, lines)
118 await update($, offered, () => '')
119 await update($, anchor, () => ({
120 ...freshAnchor('demo', false),
121 status: 'found' as const,
122 units: section.map(u => squash(u.text)),
123 quotes: section.slice(0, 1).map(u => u.text),
124 }))
125 return 'Spotlighting the last item of the reply above. Press ✕ on the box, or type, to clear it.'
126}
127
128export const register: Register = (on, options) => {
129 const model = typeof options.model === 'string' && options.model ? options.model : 'haiku'
130 const show: Show = options.show === 'above-prompt' || options.show === 'in-reply' ? options.show : 'both'
131 const configured: Style = isStyle(options.style) ? options.style : 'claude'
132
133 on('session.start', async ($, e, next) => {
134 const started = await next(e)
135 await $.command.register({
136 name: COMMAND,
137 description: `Turn the suggestion spotlight on or off (on, off), pick its look (style ${STYLES.join('|')}), or show a demo on the last reply (demo, or demo <a suggestion> to run the real lookup)`,
138 })
139 const stored = await $.store.get('enabled')
140 await update($, enabled, () => stored !== false)
141 // A look picked with the command outlasts the /config default until the default changes.
142 const picked = await $.store.get('style')
143 const pickedOver = await $.store.get('styleOver')
144 await update($, style, () => (isStyle(picked) && pickedOver === configured ? picked : configured))
145 return started
146 })
147
148 on('command.run', { command: COMMAND }, async ($, e) => {
149 const [arg = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
150 if (arg === 'demo') {
151 // `demo <text>`: the real lookup, as if <text> were the suggestion taken with Tab.
152 const asIf = e.args.trim().replace(/^demo\s*/i, '').trim()
153 if (!asIf) return { text: await demo($) }
154 await update($, offered, () => asIf)
155 await update($, anchor, () => null)
156 await locate($, asIf, model, false)
157 const found = await read($, anchor)
158 return {
159 text:
160 found?.status === 'found'
161 ? `Spotlighting what “${asIf}” refers to. Press ✕ on the box, or type, to clear it.`
162 : `“${asIf}” does not point at anything specific in the reply above.`,
163 }
164 }
165 if (arg === 'style') {
166 if (!isStyle(value)) return { text: `Styles: ${STYLES.join(', ')}. Now: ${await read($, style)}.` }
167 await update($, style, () => value)
168 await $.store.set('style', value)
169 await $.store.set('styleOver', configured)
170 return { text: `Spotlight style: ${value}. Try /${COMMAND} demo to see it.` }
171 }
172 const next = arg === 'on' ? true : arg === 'off' ? false : !(await read($, enabled))
173 await update($, enabled, () => next)
174 await $.store.set('enabled', next)
175 if (!next) await update($, anchor, () => null)
176 return { text: next ? 'Suggestion spotlight is on.' : 'Suggestion spotlight is off.' }
177 })
178
179 // A suggestion appears (the terminal says so; the desktop app does not): look
180 // it up at once, so the spotlight shows beside it.
181 on('prompt.suggest', async ($, e, next) => {
182 const result = await next(e)
183 if (result.isShown && (await read($, enabled))) {
184 await update($, offered, () => e.text)
185 await update($, anchor, a => (a && a.suggestion === e.text ? a : null))
186 void locate($, e.text, model, false)
187 }
188 return result
189 })
190
191 // The desktop app neither raises `prompt.suggest` nor counts taking the
192 // suggestion (Tab) as an edit: the phrase just appears in the box. So a box
193 // that holds a phrase the person never typed was filled by Tab, and the first
194 // keystroke after it shows that. Typing along with it keeps the spotlight;
195 // typing something else hides it, and clearing the box brings it back.
196 on('prompt.edit', async ($, e, next) => {
197 const box = await next(e)
198 const before = lastBox
199 lastBox = box.text
200 if (squash(e.text) !== squash(before) && isPhrase(e.text)) await take($, e.text.trim(), model)
201 const suggestion = await read($, offered)
202 if (!suggestion) return box
203 const typed = squash(box.text)
204 const isHidden = !!typed && !squash(suggestion).startsWith(typed) && !typed.startsWith(squash(suggestion))
205 await update($, anchor, a => (a && a.suggestion === suggestion && a.isHidden !== isHidden ? { ...a, isHidden } : a))
206 return box
207 })
208
209 // Sent as offered, or with more added: the spotlight stays while the reply is
210 // written. Taken with Tab and sent at once: no keystroke showed it, so look now.
211 on('prompt.submit', async ($, e, next) => {
212 const before = lastBox
213 lastBox = ''
214 if (e.origin.kind === 'composer' && squash(e.text) !== squash(before) && isPhrase(e.text)) {
215 await take($, e.text.trim(), model, true)
216 return next(e)
217 }
218 const suggestion = await read($, offered)
219 if (suggestion && squash(e.text).startsWith(squash(suggestion))) {
220 await update($, anchor, a => (a && a.suggestion === suggestion ? { ...a, isSent: true, isHidden: false } : a))
221 } else if (e.origin.kind !== 'task-notification') {
222 await update($, anchor, () => null)
223 }
224 await update($, offered, () => '')
225 return next(e)
226 })
227
228 // Once the reply that answers it is done, a sent suggestion's spotlight has served.
229 on('turn.complete', async ($, e, next) => {
230 if (e.agentId === undefined) await update($, anchor, a => (a && a.isSent ? null : a))
231 return next(e)
232 })
233
234 // The box above the prompt quotes what the suggestion refers to, and keeps
235 // whatever other mods draw there beneath it.
236 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
237 const below = await next(e)
238 if (show === 'in-reply') return below
239 const a = await read($, anchor)
240 // Once sent, the choice is made: the box gives the space back, while the
241 // outline in the reply stays until the answer is done.
242 if (!a || a.status !== 'found' || a.isHidden || a.isSent || e.props.hasSurvey || !a.quotes.length) return below
243 const ui = $.ui.resolve(e)
244 const { Box, Button } = ui
245 const dismiss = (
246 <Button key="suggestion-spotlight-dismiss" label="✕" plain role="dismiss" onPress={() => forget($)} />
247 )
248 return (
249 <Box flexDirection="column">
250 {abovePrompt(ui, await read($, style), a.suggestion, a.quotes.join('\n'), dismiss)}
251 {below}
252 </Box>
253 )
254 })
255
256 // The passage itself, outlined where it sits in the reply.
257 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
258 if (show === 'above-prompt') return next(e)
259 const a = await read($, anchor)
260 if (!a || a.status !== 'found' || a.isHidden || e.props.isSummary) return next(e)
261 const segments = segmentsOf(e.props.text, new Set(a.units))
262 if (!segments) return next(e)
263
264 const ui = $.ui.resolve(e)
265 const { Box, Markdown } = ui
266 const look = await read($, style)
267 return (
268 <Box flexDirection="column">
269 {segments.map((s, i) =>
270 s.isTarget ? inReply(ui, look, a.suggestion, s.text, `t${i}`) : <Markdown key={`p${i}`} text={s.text} />,
271 )}
272 </Box>
273 )
274 })
275}
276hooks/look.tsx 135 lines1import type { EngineInterface, RenderElement } from 'claude-code'
2
3import { clip, inkLines } from './match'
4
5// The two looks a spotlight can take. `claude` draws a clay box headed by the
6// pixel crab; `highlighter` leaves the passage in place and marks it in yellow.
7
8export const STYLES = ['claude', 'highlighter'] as const
9export type Style = (typeof STYLES)[number]
10
11export const isStyle = (v: unknown): v is Style => typeof v === 'string' && (STYLES as readonly string[]).includes(v)
12
13type Ui = ReturnType<EngineInterface['ui']['resolve']>
14
15const CLAY = '#D97757'
16const INK = '#1F1E1D'
17// Dark text on a highlighter yellow reads the same in light and dark themes.
18const YELLOW = '#FFE066'
19const ON_YELLOW = '#1f1f1f'
20const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
21
22const xml = (s: string): string =>
23 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
24
25// Clawd, the pixel crab, cropped to its body: 24×16 grid cells.
26const CRAB_RECTS: [number, number, number, number, string][] = [
27 [4, 0, 16, 12, CLAY],
28 [0, 4, 4, 4, CLAY],
29 [20, 4, 4, 4, CLAY],
30 [6, 2, 2, 2, INK],
31 [16, 2, 2, 2, INK],
32 [4, 12, 2, 4, CLAY],
33 [8, 12, 2, 4, CLAY],
34 [14, 12, 2, 4, CLAY],
35 [18, 12, 2, 4, CLAY],
36]
37
38/** The crab and a line of clay text, as one drawing: the desktop wraps sibling elements, so they share an SVG. */
39const crabLabel = (text: string, width: number): string => {
40 const crab = CRAB_RECTS.map(([x, y, w, h, c]) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"/>`).join('')
41 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="18" viewBox="0 0 ${width} 18">
42<g transform="translate(0,3) scale(0.75)" shape-rendering="crispEdges">${crab}</g>
43<text x="25" y="13.5" font-family="${FONT}" font-size="13" font-weight="500" fill="${CLAY}">${xml(text)}</text></svg>`
44}
45
46/** The heading line of a spotlight: what the suggestion was, in the style's own voice. */
47const label = (ui: Ui, style: Style, text: string): RenderElement => {
48 const { Text } = ui
49 if (style === 'claude') {
50 if ('Svg' in ui) {
51 const { Svg } = ui
52 // About 7.4px a character of 13px system text, after the crab's 25px.
53 const width = Math.ceil(30 + text.length * 7.4)
54 return <Svg source={crabLabel(text, width)} alt={text} width={width} height={18} />
55 }
56 return (
57 <Text color={CLAY} wrap="truncate-end">
58 ▣ {text}
59 </Text>
60 )
61 }
62 return (
63 <Text dimColor wrap="truncate-end">
64 🖍 {text}
65 </Text>
66 )
67}
68
69/** The passage itself, marked in the style; without the heading line. */
70const passage = (ui: Ui, style: Style, md: string, key: string): RenderElement => {
71 const { Box, Text, Markdown } = ui
72 if (style === 'claude') return <Markdown key={key} text={md} />
73 return (
74 <Box key={key} flexDirection="column">
75 {inkLines(md).map((line, i) => (
76 <Text key={`${key}-${i}`} wrap="wrap">
77 {line.marker}
78 <Text backgroundColor={YELLOW} color={ON_YELLOW}>
79 {line.runs.map((r, j) => (
80 <Text key={`${key}-${i}-${j}`} bold={r.bold} backgroundColor={YELLOW} color={ON_YELLOW}>
81 {r.text}
82 </Text>
83 ))}
84 </Text>
85 </Text>
86 ))}
87 </Box>
88 )
89}
90
91/**
92 * The passage where it sits in the reply. The claude style boxes it under a
93 * heading naming the suggestion; the highlighter marks the text and says nothing.
94 */
95export const inReply = (ui: Ui, style: Style, suggestion: string, md: string, key: string): RenderElement => {
96 const { Box } = ui
97 if (style === 'highlighter') return passage(ui, style, md, key)
98 const text = suggestion === 'demo' ? 'demo' : `“${clip(suggestion, 60)}” refers to this`
99 return (
100 <Box key={key} flexDirection="column" borderStyle="round" borderColor={CLAY} paddingLeft={1}>
101 {label(ui, style, text)}
102 {passage(ui, style, md, `${key}-body`)}
103 </Box>
104 )
105}
106
107/** The box above the prompt: the heading, a dismiss, and the passage's first line. */
108export const abovePrompt = (
109 ui: Ui,
110 style: Style,
111 suggestion: string,
112 quote: string,
113 dismiss: RenderElement,
114): RenderElement => {
115 const { Box } = ui
116 const text = suggestion === 'demo' ? 'Demo: what a suggestion would point at' : `“${clip(suggestion, 60)}” refers to:`
117 const head = (
118 <Box flexDirection="row" justifyContent="space-between">
119 {label(ui, style, text)}
120 {dismiss}
121 </Box>
122 )
123 return style === 'highlighter' ? (
124 <Box flexDirection="column" paddingLeft={1}>
125 {head}
126 {passage(ui, style, quote, 'quote')}
127 </Box>
128 ) : (
129 <Box flexDirection="column" borderStyle="round" borderColor={CLAY} paddingLeft={1}>
130 {head}
131 {passage(ui, style, quote, 'quote')}
132 </Box>
133 )
134}
135hooks/match.ts 204 lines1// Pure text work: splitting a reply into blocks, asking which block a follow-up
2// points at, and splitting a drawn block into plain and outlined runs. No engine
3// calls here, so the tests exercise it directly.
4
5export const squash = (s: string): string => s.replace(/\s+/g, ' ').trim().toLowerCase()
6
7export const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, max - 1) + '…' : s)
8
9export const isPhrase = (s: string): boolean => s.trim().split(/\s+/).length >= 2
10
11/**
12 * A unit is what an outline may hold: one line of prose or one list item, or a
13 * whole code block or table, which would break apart if split.
14 */
15export type Unit = { start: number; end: number; text: string }
16
17export const unitsOf = (lines: string[]): Unit[] => {
18 const units: Unit[] = []
19 let i = 0
20 while (i < lines.length) {
21 const line = lines[i] ?? ''
22 if (!line.trim()) {
23 i++
24 continue
25 }
26 let end = i + 1
27 if (/^\s*(```|~~~)/.test(line)) {
28 const fence = line.trim().slice(0, 3)
29 while (end < lines.length && !(lines[end] ?? '').trim().startsWith(fence)) end++
30 end = Math.min(lines.length, end + 1)
31 } else if (/^\s*\|/.test(line)) {
32 while (end < lines.length && /^\s*\|/.test(lines[end] ?? '')) end++
33 }
34 units.push({ start: i, end, text: lines.slice(i, end).join('\n') })
35 i = end
36 }
37 return units
38}
39
40const isListLine = (line: string): boolean => /^\s*([-*+]|\d+[.)])\s/.test(line) || /^\s{2,}\S/.test(line)
41
42// "1. **Polish the mod:**" introduces the list beneath it: the section is the
43// heading and that list, so an outline holds what the heading stands for.
44const endsWithColon = (text: string): boolean => /:\s*(\*\*|__)?\s*$/.test(text.trim())
45
46/** The picked unit, and the list under it when the unit is a heading ending in a colon. */
47export const sectionOf = (units: Unit[], index: number, lines: string[]): Unit[] => {
48 const head = units[index]
49 if (!head || !endsWithColon(head.text)) return head ? [head] : []
50 const section = [head]
51 for (const u of units.slice(index + 1)) {
52 const between = lines.slice(section[section.length - 1]!.end, u.start)
53 if (between.some(l => l.trim())) break
54 if (!isListLine(u.text.split('\n')[0] ?? '')) break
55 // A sibling of the heading (the next "2. …" at the same indent) ends the section.
56 if (/^\d+[.)]\s/.test(u.text) && /^\d+[.)]\s/.test(head.text)) break
57 section.push(u)
58 }
59 return section
60}
61
62const STOP = new Set(
63 'the this that with from have will what your yours just then them they into also more some okay sure please lets let'.split(' '),
64)
65
66/** Without the model: the index of the unit sharing the most words with the follow-up, or -1. */
67export const byWords = (followUp: string, units: Unit[]): number => {
68 const words = squash(followUp)
69 .split(/[^\p{L}\p{N}]+/u)
70 .filter(w => w.length > 3 && !STOP.has(w))
71 if (!words.length) return -1
72 const scores = units.map(u => words.filter(w => squash(u.text).includes(w)).length)
73 const best = Math.max(0, ...scores)
74 return best >= 2 || (best === 1 && words.length === 1) ? scores.indexOf(best) : -1
75}
76
77/** At most this much of a reply goes to the model; past it, its closing part, where options usually sit. */
78export const MAX_PROMPT_CHARS = 16_000
79const UNIT_CHARS = 400
80
81/** The numbered blocks the model reads, and the unit index each number stands for. */
82export const numbered = (units: Unit[]): { text: string; indexOf: Map<number, number> } => {
83 const rows: string[] = []
84 const indexOf = new Map<number, number>()
85 let size = 0
86 for (let i = units.length - 1; i >= 0; i--) {
87 const row = clip((units[i]?.text ?? '').replace(/\n/g, ' '), UNIT_CHARS)
88 if (size + row.length > MAX_PROMPT_CHARS && rows.length) break
89 rows.unshift(row)
90 size += row.length + 8
91 }
92 const first = units.length - rows.length
93 rows.forEach((_, k) => indexOf.set(k + 1, first + k))
94 return { text: rows.map((r, k) => `[${k + 1}] ${r}`).join('\n'), indexOf }
95}
96
97export const SYSTEM =
98 'You link a short follow-up message to the one part of the previous reply it refers to. ' +
99 'Answer with a single block number, or NONE.'
100
101export const promptFor = (blocks: string, followUp: string): string =>
102 `Previous reply, split into numbered blocks:\n${blocks}\n\n` +
103 `Follow-up the user is about to send: "${followUp}"\n\n` +
104 'Which one block does the follow-up agree to, act on or point at? Pick the most specific block: ' +
105 'the option, item or sentence itself, never a closing question that merely offers it. ' +
106 'Answer NONE if it refers to nothing specific, such as a general "go ahead" or "let me know".'
107
108/** The unit index the model's answer names, or -1. */
109export const pickFrom = (answer: string, indexOf: Map<number, number>): number => {
110 if (/none/i.test(answer)) return -1
111 const n = Number(/\d+/.exec(answer)?.[0])
112 return indexOf.get(n) ?? -1
113}
114
115/** One row of the conversation, as far as finding the last reply needs. */
116export type Row = { role: 'user' | 'assistant'; text: string; isToolResult: boolean }
117
118/**
119 * The reply a follow-up answers: the assistant text after the last prompt the
120 * person sent, not counting the follow-up itself once it has been sent.
121 */
122export const lastReply = (rows: Row[], followUp: string): string => {
123 const parts: string[] = []
124 for (let i = rows.length - 1; i >= 0; i--) {
125 const r = rows[i]
126 if (!r) continue
127 if (r.role === 'assistant') {
128 if (r.text.trim()) parts.unshift(r.text)
129 continue
130 }
131 // Tool results come back as user rows inside the same reply: not a prompt.
132 if (r.isToolResult) continue
133 const text = r.text.trim()
134 if (!text || (squash(text) === squash(followUp) && !parts.length)) continue
135 if (parts.length) break
136 }
137 return parts.join('\n\n')
138}
139
140// --- The highlighter, which paints text and so needs the passage as plain runs.
141
142const MARKER = /^(\s*(?:[-*+]|\d+[.)]|#{1,6})\s+)?(.*)$/
143
144/** A run of one line as a highlighter draws it: plain text, bold or not. */
145export type Run = { text: string; bold: boolean }
146
147/** One line of the highlighted passage: its list marker (left unmarked) and its runs. */
148export type InkLine = { marker: string; runs: Run[] }
149
150/**
151 * The passage as plain lines for the highlighter, which paints text and so
152 * cannot draw markdown: bold stays bold, code and links keep their words.
153 */
154export const inkLines = (md: string): InkLine[] =>
155 md
156 .split('\n')
157 .filter(l => l.trim() && !/^\s*(```|~~~)/.test(l) && !/^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(l))
158 .map(line => {
159 const [, marker = '', rest = ''] = MARKER.exec(line) ?? []
160 const plain = rest.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/`([^`]*)`/g, '$1')
161 const runs: Run[] = plain
162 .split(/(\*\*|__)/)
163 .reduce<{ runs: Run[]; bold: boolean }>(
164 (acc, part) =>
165 part === '**' || part === '__'
166 ? { ...acc, bold: !acc.bold }
167 : part
168 ? { ...acc, runs: [...acc.runs, { text: part, bold: acc.bold }] }
169 : acc,
170 { runs: [], bold: false },
171 ).runs
172 return { marker, runs }
173 })
174
175export type Segment = { text: string; isTarget: boolean }
176
177/** Splits a drawn block of the reply into runs of plain and outlined lines, or null when it holds no target. */
178export const segmentsOf = (text: string, targets: Set<string>): Segment[] | null => {
179 const lines = text.split('\n')
180 const hit = new Array<boolean>(lines.length).fill(false)
181 let any = false
182 for (const u of unitsOf(lines)) {
183 if (!targets.has(squash(u.text))) continue
184 any = true
185 for (let i = u.start; i < u.end; i++) hit[i] = true
186 }
187 if (!any) return null
188 // A blank line between two outlined units keeps them in one outline.
189 const filled = lines.map((line, i) => {
190 if (hit[i] || line.trim()) return hit[i] ?? false
191 const before = hit.slice(0, i).findLast((_, k) => (lines[k] ?? '').trim() !== '')
192 const after = hit.slice(i + 1).find((_, k) => (lines[i + 1 + k] ?? '').trim() !== '')
193 return before === true && after === true
194 })
195 const segments: Segment[] = []
196 lines.forEach((line, i) => {
197 const isTarget = filled[i] ?? false
198 const last = segments[segments.length - 1]
199 if (last && last.isTarget === isTarget) last.text += '\n' + line
200 else segments.push({ text: line, isTarget })
201 })
202 return segments.map(s => ({ ...s, text: s.text.replace(/^\n+|\n+$/g, '') })).filter(s => s.text.trim())
203}
204types/index.d.ts 29 lines1/** What a taken suggestion was matched to in the reply before it. */
2export type Anchor = {
3 /** The suggestion as taken, or 'demo' for `/suggestion-spotlight demo`. */
4 suggestion: string
5 status: 'finding' | 'found' | 'none'
6 /** The matched units of the reply (lines, or whole tables and code blocks), whitespace-squashed. */
7 units: string[]
8 /** The first matched unit as written, for the box above the prompt. */
9 quotes: string[]
10 /** True once the suggestion was sent; the spotlight then stays until the reply is done. */
11 isSent: boolean
12 /** True while the box holds something other than the suggestion. */
13 isHidden: boolean
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'suggestion-spotlight': {
19 /** The suggestion the prompt box holds or offers now, '' when none. */
20 offered: string
21 anchor: Anchor | null
22 /** Off after `/suggestion-spotlight off`, remembered across sessions. */
23 enabled: boolean
24 /** The look: the /config default, or what `/suggestion-spotlight style` picked since. */
25 style: 'claude' | 'highlighter'
26 }
27 }
28}
29