SLOPSHOPPER

suggestion-spotlight

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

newbandrowscommandpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · suggestion-spotlight
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /suggestion-spotlight ⎿ suggestion-spotlight: Suggestion spotlight is off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Suggestion Spotlight

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.

The claude style: the passage outlined in clay with the pixel crab, and quoted above the prompt

Two styles

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.

The highlighter style: the passage marked in yellow in the reply and above the prompt

Switch at any time with /suggestion-spotlight style highlighter or /suggestion-spotlight style claude.

How it behaves

  • Take a suggestion (Tab), then type anything, even a space. The spotlight appears.
  • Press Enter and the box above the prompt goes, since you've made your choice. The mark in the reply stays while Claude answers, then clears.
  • Type something else instead and the spotlight hides; clear the box and it comes back. Your own typing never triggers it.
  • Generic suggestions such as "okay, let me know when it's done" point at nothing specific, so nothing is marked.
  • Press ✕ on the box to dismiss it.

Install

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.

Commands

CommandWhat it does
/suggestion-spotlightTurn it on or off. The choice is remembered across sessions.
/suggestion-spotlight on / offThe same, explicitly.
/suggestion-spotlight style claude / highlighterSwitch the look.
/suggestion-spotlight demoSpotlight 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.

Settings

In /config:

SettingOptionsDefault
Spotlight styleclaude, highlighterclaude
Where to spotlightboth, above-prompt, in-replyboth
Lookup modelhaiku, sonnethaiku

How it works, and what it costs

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.

  • Cost: one small Haiku call each time you take a suggestion, on your own Claude plan. At most about 16,000 characters of a reply are sent, taken from the end, since that's where options usually are. If the call fails, it falls back to matching keywords.
  • Privacy: the reply text goes only to the model your Claude Code session already uses. Nothing else leaves your machine.
  • The desktop app shows suggestions without telling mods. So the mod notices instead when the prompt box suddenly holds a phrase you never typed, which is what Tab does. That's why you type one key after Tab. Text dictated into the box can occasionally be read the same way.

Requirements

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.

Licence

MIT. See LICENSE.

Made by Cael Jones. If you use or build on this, a credit or a link back is appreciated.

Source 4 files
hooks/register.tsx 276 lines
1import { 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}
276
hooks/look.tsx 135 lines
1import 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
135
hooks/match.ts 204 lines
1// 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}
204
types/index.d.ts 29 lines
1/** 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