SLOPSHOPPER

redline

Freeze the screen, drag a box, draw and comment on it; the marked-up image rides along with your next message.

panebandcommandtoastpromptprocess
★ 1v0.1.0no licenseupdated 2026-10-18betemonkey/redline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redline
│ ┃ redline ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing waiting. Use /redline or │ ┃ Ctrl+Alt+Shift+R. ⏺ 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 │ │ › /redline │ ⎿ redline: Redline: drag a box around what you want to mark (Esc c │ │ [ Redline ▾ ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ Redline ▾ ]
Pane · redline
Nothing waiting. Use /redline or Ctrl+Alt+Shift+R.
README

Redline

Mark up what Claude shows you instead of describing it. A Claude Code mod for Windows.

No more "the second button on the left, no, the other one". Point at it, scribble on it, pin a comment on it, and send it to Claude.

Capture screen

Freeze the screen, drag a box, then draw, highlight and pin comments right on it. Send puts the marked-up image in front of Claude, along with the text under your marks (read with Windows OCR).

Redline drawing on a frozen screen: a boxed price, a scribbled-out feature, a highlighted sentence and four comment pins

Review conversation

Long reply? Open the whole conversation in its own window, kept live as Claude answers. Select text to comment on it, highlight it or cross it out, or switch to Draw and circle things. One comment can cover several marks. Claude gets the exact quotes and where they are, so it never has to guess what you meant.

Redline Text: a reply with highlights, a crossed-out line and a circled line, and their comments in the margin

Also included: dictation in comments (🎤), seven colour palettes, and the global hotkey Ctrl+Alt+Shift+R.

Install

Requirements: Windows 10/11, Node.js and Microsoft Edge.

In Claude Code:

/plugin marketplace add betemonkey/redline
/plugin install redline@monkey-mods

Then start a new session (or run /reload-plugins).

Use

WhereHow
Claude app (Code tab)the Redline logo + ▾ above the chat box, after the first message
Claude Code CLIthe [ ▾ ] row above the prompt
Anywhere/redline (capture), /redline text (review conversation), /redline discard
Outside ClaudeCtrl+Alt+Shift+R captures; the result waits as a card for your next message

Claude apps that host Claude Code in their own chat window may not draw the row; the slash commands and the hotkey still work there.

Standalone review page

app/text.html also works on its own, with no Claude Code and no install. Open it in a browser, paste a reply, mark it up, then press Copy and paste the result into any chat. This suits locked-down machines.

Data

Captures, the send queue and preferences live in %LOCALAPPDATA%\Redline (set REDLINE_DATA to use another folder). Redline runs its own Edge profile there too. Nothing leaves your machine except what you send to Claude.

Source 2 files
hooks/register.tsx 445 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RedlineComment, RedlineItem, RedlinePending } from '../types'
5
6// Where the helper (app/helper.js) keeps captures and the queue of sent redlines:
7// %REDLINE_DATA% if set, else %LOCALAPPDATA%\Redline. Learned once, handed to every helper run.
8let DATA = ''
9let QUEUE = ''
10async function dataDir($: EngineInterface) {
11  if (!DATA) {
12    DATA = (await $.env.get('REDLINE_DATA')) || `${(await $.env.get('LOCALAPPDATA')) ?? ''}\\Redline`
13    QUEUE = `${DATA}\\queue`
14  }
15  return DATA
16}
17const NODE_PATHS = ['C:\\Program Files\\nodejs\\node.exe', 'C:\\Program Files (x86)\\nodejs\\node.exe']
18const PANE = 'redline'
19
20const queue = atom({ plugin: 'redline', key: 'queue' } as const, [] as RedlineItem[])
21
22type Mark = { kind: string; colour?: string; ocr?: string }
23// A quote from the conversation, with where it is (e.g. "Claude's reply 3 of 5"); plain strings from older items.
24export type Quoted = string | { quote: string; where?: string }
25
26export type Item = {
27  id: string
28  kind?: 'screen' | 'text'
29  image?: string
30  highlights?: Quoted[]
31  crossed?: Quoted[]
32  drawn?: Quoted[]
33  sentSize?: { width: number; height: number } | null
34  thumb?: string
35  message?: string
36  comments?: RedlineComment[]
37  marks?: Mark[]
38}
39
40const COLOUR: Record<string, string> = {
41  '#ff3b30': 'red', '#ffd60a': 'yellow', '#ffffff': 'white', '#30d158': 'green', '#0a84ff': 'blue',
42}
43
44const quote = (s: string | undefined) => (s ? `"${s.replace(/\s+/g, ' ').trim()}"` : '')
45
46// The block the model reads beside the person's message (they never see it).
47export function describe(item: Item): string {
48  if (item.kind === 'text') return describeText(item)
49  const size = item.sentSize ? ` (${item.sentSize.width}x${item.sentSize.height}, marks drawn in)` : ''
50  const lines = [
51    `Redline: the user marked up part of their screen and attached it to this message.`,
52    `Open the image with the Read tool before answering: ${item.image}${size}`,
53  ]
54  if (item.message) lines.push(`His note: ${quote(item.message)}`)
55  const comments = item.comments ?? []
56  const kept = comments.filter(c => !c.removed)
57  if (kept.length) {
58    lines.push('Numbered comment pins on the image:')
59    for (const c of kept) {
60      lines.push(`  #${c.n} ${quote(c.text)}${c.ocr ? ` - pin points at text: ${quote(c.ocr)}` : ''}`)
61    }
62  }
63  const dropped = comments.filter(c => c.removed).map(c => `#${c.n}`)
64  if (dropped.length) lines.push(`He withdrew pin ${dropped.join(', ')} after drawing: ignore ${dropped.length === 1 ? 'it' : 'them'} on the image.`)
65  const marks = (item.marks ?? []).filter(m => m.kind !== 'pen' || m.ocr)
66  if (marks.length) {
67    lines.push('Other marks:')
68    for (const m of marks) {
69      const colour = m.colour && COLOUR[m.colour] ? ` (${COLOUR[m.colour]})` : ''
70      lines.push(`  - ${m.kind}${colour}${m.ocr ? ` on: ${quote(m.ocr)}` : ''}`)
71    }
72  }
73  lines.push('The quoted text was read by OCR from the original screenshot and may contain small errors; the image is the source of truth.')
74  return lines.join('\n')
75}
76
77// A Redline Text review: exact quotes from the last reply, no picture to open.
78function describeText(item: Item): string {
79  const lines = ['Redline Text: the user reviewed this conversation in a mirror window. Every quote below is exact text from it; "where" says which message.']
80  if (item.message) lines.push(`His note: ${quote(item.message)}`)
81  const kept = (item.comments ?? []).filter(c => !c.removed)
82  if (kept.length) {
83    lines.push('Comments:')
84    for (const c of kept) {
85      const on = c.quotes?.length
86        ? c.quotes.map(x => (x.drawn && !x.quote ? 'a drawing' : `${x.drawn ? '(drawn around) ' : x.crossed ? '(crossed out) ' : ''}${quote(x.quote)}`) + (x.where ? ` (${x.where})` : '')).join(' + ')
87        : `${quote(c.ocr)}${c.where ? ` (${c.where})` : ''}`
88      lines.push(`  #${c.n} on ${on}: ${c.text}`)
89    }
90  }
91  const dropped = (item.comments ?? []).filter(c => c.removed).map(c => `#${c.n}`)
92  if (dropped.length) lines.push(`He withdrew comment ${dropped.join(', ')}: ignore ${dropped.length === 1 ? 'it' : 'them'}.`)
93  const one = (x: Quoted) => (typeof x === 'string' ? quote(x) : `${quote(x.quote)}${x.where ? ` (${x.where})` : ''}`)
94  const list = (title: string, xs?: Quoted[]) => { if (xs?.length) { lines.push(title); for (const x of xs) lines.push(`  - ${one(x)}`) } }
95  list('Highlighted (keep / important):', item.highlights)
96  list('Crossed out (remove or wrong):', item.crossed)
97  list('Drew around (text under the drawing):', item.drawn)
98  return lines.join('\n')
99}
100
101export function summarise(p: RedlinePending): string {
102  const n = (k: number, one: string) => (k === 1 ? `1 ${one}` : `${k} ${one}s`)
103  return [n(p.captures, 'capture'), p.comments ? n(p.comments, 'comment') : '', p.marks ? n(p.marks, 'mark') : '']
104    .filter(Boolean).join(' · ')
105}
106
107export function pendingOf(items: readonly RedlineItem[]): RedlinePending {
108  return {
109    captures: items.length,
110    comments: items.reduce((s, i) => s + i.comments.filter(c => !c.removed).length, 0),
111    marks: items.reduce((s, i) => s + i.marks, 0),
112  }
113}
114
115// The image as the desktop app draws it: a JPEG inside an SVG (a mod's picture element).
116export function thumbSvg(item: RedlineItem): string {
117  const { width: w, height: h } = item
118  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}"><image href="${item.thumb}" x="0" y="0" width="${w}" height="${h}"/></svg>`
119}
120
121async function nodeBin($: EngineInterface): Promise<string> {
122  for (const p of NODE_PATHS) if (await $.fs.exists(p)) return p
123  return 'node'
124}
125
126let mySession = ''
127
128// Reads the queue folder; only files that changed since the last look are parsed again.
129const seen = new Map<string, { mtime: number; item: RedlineItem; auto: boolean }>()
130async function scan($: EngineInterface): Promise<RedlineItem[]> {
131  await dataDir($)
132  const files = (await $.fs.exists(QUEUE)) ? (await $.fs.list(QUEUE)).filter(f => f.kind === 'file' && f.name.endsWith('.json')) : []
133  const next: RedlineItem[] = []
134  const auto: string[] = []
135  for (const f of files.sort((a, b) => a.name.localeCompare(b.name))) {
136    const hit = seen.get(f.name)
137    if (hit && hit.mtime === f.mtimeMs) { next.push(hit.item); if (hit.auto) auto.push(hit.item.id); continue }
138    try {
139      const raw = JSON.parse(await $.fs.read(`${QUEUE}\\${f.name}`)) as Item
140      const item: RedlineItem = {
141        id: raw.id,
142        kind: raw.kind === 'text' ? 'text' : 'screen',
143        thumb: raw.thumb ?? '',
144        width: raw.sentSize?.width ?? 400,
145        height: raw.sentSize?.height ?? 300,
146        message: raw.message ?? '',
147        comments: (raw.comments ?? []).map(c => ({ n: c.n, text: c.text, ocr: c.ocr, removed: c.removed, where: c.where })),
148        marks: raw.kind === 'text' ? (raw.highlights?.length ?? 0) + (raw.crossed?.length ?? 0) + (raw.drawn?.length ?? 0) : raw.marks?.length ?? 0,
149      }
150      const mine = !!(raw as { autoSend?: boolean }).autoSend && (raw as { session?: string }).session === mySession && !!mySession
151      seen.set(f.name, { mtime: f.mtimeMs, item, auto: mine })
152      next.push(item)
153      if (mine) auto.push(item.id)
154    } catch {
155      // half-written or foreign file: the next scan sees it whole
156    }
157  }
158  const now = (await read($, queue)) ?? []
159  const key = (l: readonly RedlineItem[]) => JSON.stringify(l.map(i => [i.id, i.message, i.comments, i.marks, i.thumb.length]))
160  if (key(now) !== key(next)) {
161    await update($, queue, () => next)
162    // something new came in: show it in the side panel
163    if (next.some(i => !now.find(o => o.id === i.id) && !auto.includes(i.id))) void $.ui.open({ id: PANE, title: 'Redline' })
164  }
165  if (!next.length && now.length) void $.ui.close({ id: PANE })
166  const fresh = auto.filter(id => !sending.has(id))
167  if (fresh.length) { fresh.forEach(id => sending.add(id)); void sendNow($, fresh) }
168  return next
169}
170
171async function helper($: EngineInterface, args: string[]) {
172  return $.process.run([await nodeBin($), `${$.plugin.root}\\app\\helper.js`, ...args], { timeoutMs: 60_000, env: { REDLINE_DATA: await dataDir($) } })
173}
174
175// Starts the helper detached (through Start-Process), so the editor outlives this call.
176async function launch($: EngineInterface, args: string[]) {
177  const node = await nodeBin($)
178  // session.start doesn't run again when the mod is reloaded mid-session, so ask now if we never learned it
179  if (!mySession) mySession = await $.session.id().catch(() => '')
180  const withSession = mySession ? [...args, `--session=${mySession}`] : args // its Send goes straight into this session
181  const list = [`'"${$.plugin.root}\\app\\helper.js"'`, ...withSession.map(a => `'${a}'`)].join(',')
182  await $.process.run([
183    'powershell.exe', '-NoProfile', '-WindowStyle', 'Hidden', '-Command',
184    `$env:REDLINE_DATA='${await dataDir($)}'; Start-Process -WindowStyle Hidden -FilePath '${node}' -ArgumentList ${list}`,
185  ])
186}
187
188// The conversation as the mirror shows it: your messages and Claude's replies (a reply's pieces between tool
189// calls joined), tool results left out.
190export type MirrorMessage = { role: 'user' | 'assistant'; text: string }
191export function conversation(rows: readonly { role: 'user' | 'assistant'; text: string; toolResults?: readonly unknown[] }[]): MirrorMessage[] {
192  const out: MirrorMessage[] = []
193  for (const r of rows) {
194    const text = r.text.trim()
195    if (!text || (r.role === 'user' && r.toolResults?.length)) continue
196    const last = out[out.length - 1]
197    if (r.role === 'assistant' && last?.role === 'assistant') last.text += `\n\n${text}`
198    else out.push({ role: r.role, text })
199  }
200  return out
201}
202
203// The window that mirrors this conversation (null when none is open), refreshed after every reply.
204let mirror: string | null = null
205let lastWritten = ''
206let busy = false // Claude is working on a turn: the mirror shows "Claude is thinking"
207async function writeConversation($: EngineInterface, file: string): Promise<boolean> {
208  const messages = conversation(await $.session.messages())
209  if (!messages.length) return false
210  const body = JSON.stringify({ busy, messages })
211  if (body === lastWritten) return true // nothing new: the page keeps what it has
212  lastWritten = body
213  await $.fs.write(file, JSON.stringify({ updated: await $.clock.now(), busy, messages }))
214  return true
215}
216
217// Redline Text: the whole conversation mirrored in its own window, kept up to date after every reply.
218async function openMirror($: EngineInterface): Promise<boolean> {
219  mirror = `${await dataDir($)}\\text\\${Date.now()}.json`
220  lastWritten = ''
221  if (!(await writeConversation($, mirror))) { mirror = null; return false }
222  await launch($, ['text', mirror])
223  return true
224}
225
226// The Redline icon (a comment pin), as the Svg element draws it beside the button.
227const ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="15" fill="#1f1f1d"/><path d="M14 50V30a18 18 0 1 1 18 18H14z" fill="#e5484d"/><path d="M23 36c4-3 6 2 10-1s5-6 9-4" fill="none" stroke="#fff" stroke-width="3.4" stroke-linecap="round"/></svg>'
228const menu = atom({ plugin: 'redline', key: 'menu' } as const, false)
229
230// Edits one queued item's JSON in place (the comment texts, a removed pin, the note).
231async function edit($: EngineInterface, id: string, change: (item: Item) => void) {
232  await dataDir($)
233  const file = `${QUEUE}\\${id}.json`
234  if (!(await $.fs.exists(file))) return
235  const item = JSON.parse(await $.fs.read(file)) as Item
236  change(item)
237  await $.fs.write(file, JSON.stringify(item, null, 2))
238  await scan($)
239}
240
241// Sends the waiting redlines now, as their own message: the note is the text (the prompt.submit hook attaches them).
242// Sends redlines now, as their own message. The redlines go in as hidden rows Claude reads (a message a plugin
243// submits doesn't pass the prompt.submit hook), then the note is submitted as the message.
244const sending = new Set<string>()
245async function sendNow($: EngineInterface, ids: string[] = []) {
246  const { exitCode, stdout } = await helper($, ['claim', ...ids])
247  const items = exitCode === 0 ? (JSON.parse(stdout || '[]') as Item[]) : []
248  await scan($)
249  if (!items.length) return
250  for (const item of items) await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: describe(item) }] } })
251  void $.ui.close({ id: PANE })
252  const text = items.map(i => i.message).filter(Boolean).join(' / ') || (items.length === 1 ? "Here's my Redline." : `Here are my ${items.length} Redlines.`)
253  await $.prompt.submit({ text, asUser: true })
254}
255
256// A thumbnail that fits a w x h box, keeping the capture's shape.
257function fit(item: RedlineItem, w: number, h: number) {
258  const k = Math.min(w / item.width, h / item.height)
259  return { width: Math.round(item.width * k), height: Math.round(item.height * k) }
260}
261
262export const register: Register = on => {
263  on('session.start', async ($, e, next) => {
264    await $.command.register({
265      name: 'redline',
266      description: 'Freeze the screen, drag a box, draw and comment on it; sent with your next message',
267      argumentHint: '[text | discard]',
268      immediate: true,
269    })
270    mySession = await $.session.id()
271    await scan($)
272    let tick = 0
273    $.clock.every(1500, () => {
274      void scan($)
275      // the mirror: rewrite every 3 s while open (a reply's last pieces land after the turn ends)
276      if (mirror && ++tick % 2 === 0) void writeConversation($, mirror).catch(() => undefined)
277    })
278    // Ctrl+Alt+Shift+R from anywhere; hotkey.ps1 keeps one copy alive across sessions (named mutex).
279    const node = await nodeBin($)
280    await $.process.run([
281      'powershell.exe', '-NoProfile', '-WindowStyle', 'Hidden', '-Command',
282      `$env:REDLINE_DATA='${await dataDir($)}'; Start-Process -WindowStyle Hidden -FilePath powershell.exe -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File','"${$.plugin.root}\\app\\hotkey.ps1"','-Node','"${node}"','-Helper','"${$.plugin.root}\\app\\helper.js"'`,
283    ]).catch(() => undefined)
284    // Redline's Edge, windowless and kept warm, so the editor opens in under a second
285    await launch($, ['warm']).catch(() => undefined)
286    return next(e)
287  })
288
289  on('command.run', { command: 'redline' }, async ($, e) => {
290    if (e.args.trim() === 'text') {
291      if (!(await openMirror($))) return { text: 'Redline Text: there is nothing in this conversation to review yet.' }
292      return { text: 'Redline Text: this conversation is open in its own window and follows new replies. Select text to comment, highlight or cross out; ✏️ Draw to draw; then Send to Claude.' }
293    }
294    if (e.args.trim() === 'discard') {
295      await helper($, ['discard'])
296      await scan($)
297      return { text: 'Redline: dropped everything that was waiting.' }
298    }
299    await launch($, ['capture'])
300    return { text: 'Redline: drag a box around what you want to mark (Esc cancels), draw on it, then Send. It rides along with your next message.' }
301  })
302
303  // Keep the mirror window up to date: after each turn, write the conversation again (the page polls it).
304  on('turn.start', async ($, e, next) => {
305    busy = true
306    if (mirror) await writeConversation($, mirror).catch(() => undefined)
307    return next(e)
308  })
309  on('turn.complete', async ($, e, next) => {
310    const result = await next(e)
311    busy = false
312    if (mirror) await writeConversation($, mirror).catch(() => undefined)
313    return result
314  })
315
316  // Attach every waiting redline to the next real message (not to slash commands).
317  on('prompt.submit', async ($, e, next) => {
318    const ours = e.origin?.kind === 'plugin' && e.origin.name === 'redline'
319    if (e.command || (e.isMeta && !ours) || !((await read($, queue)) ?? []).length) return next(e)
320    const { exitCode, stdout } = await helper($, ['claim'])
321    const items = exitCode === 0 ? (JSON.parse(stdout || '[]') as Item[]) : []
322    await update($, queue, () => [])
323    void $.ui.close({ id: PANE })
324    if (!items.length) return next(e)
325    return next({ ...e, context: [...(e.context ?? []), ...items.map(describe)] })
326  })
327
328  // The card above the prompt: thumbnail (where the surface draws pictures), what is waiting, Preview / Discard.
329  // Above the chat box: the Redline button (icon + menu: Capture screen / Review this conversation), and, when a
330  // Redline is waiting, what it holds with Send / Preview / Discard.
331  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
332    if (e.props.hasSurvey) return next(e)
333    const items = (await read($, queue)) ?? [] // never written yet reads as nothing waiting
334    const open = !!(await read($, menu))
335    const els = $.ui.resolve(e)
336    const { Box, Button, Text } = els
337    const Svg = 'Svg' in els ? els.Svg : undefined
338    const close = () => update($, menu, () => false)
339    const first = items[0]
340    const said = items.flatMap(i => i.comments.filter(c => !c.removed).map(c => quote(c.text))).slice(0, 2).join(', ')
341    const t = first ? fit(first, 56, 40) : { width: 0, height: 0 }
342    return (
343      <Box flexDirection="row" alignItems="center" justifyContent="flex-start" gap={1}>
344        {/* the logo (a picture can't be clicked in a mod), and right on it the ▾ that opens Redline */}
345        {Svg ? <Svg key="icon" alt="Redline" source={ICON} width={28} height={28} /> : null}
346        <Button key="redline" label={Svg ? (open ? '▴' : '▾') : (open ? 'Redline ▴' : 'Redline ▾')} onPress={() => update($, menu, v => !v)} />
347        {open ? (
348          <Box flexDirection="row" gap={1}>
349            <Button key="capture" label="Capture screen" onPress={async () => { await close(); await launch($, ['capture']) }} />
350            <Button key="review" label="Review conversation" onPress={async () => {
351              await close()
352              if (!(await openMirror($))) $.ui.toast('Redline Text: nothing in this conversation to review yet')
353            }} />
354          </Box>
355        ) : null}
356        {first && !open ? (
357          <Box flexDirection="row" alignItems="center" gap={1} flexGrow={1} flexShrink={1}>
358            {Svg && first.thumb ? <Svg key="thumb" alt="Redline thumbnail" source={thumbSvg(first)} width={t.width} height={t.height} /> : null}
359            <Box flexDirection="column" flexGrow={1} flexShrink={1}>
360              <Text bold><Text color="red">● </Text>Redline ready</Text>
361              <Text dimColor wrap="truncate-end">{summarise(pendingOf(items))}{said ? ` · ${said}` : ''}</Text>
362            </Box>
363            <Button key="send" label="Send to Claude" variant="primary" onPress={() => sendNow($)} />
364            <Button key="preview" label="Preview" onPress={() => { void $.ui.open({ id: PANE, title: 'Redline' }) }} />
365            <Button key="discard" label="Discard" onPress={async () => { await helper($, ['discard']); await scan($) }} />
366          </Box>
367        ) : null}
368        {first && !open ? null : <Box key="spacer" flexGrow={1} />}
369      </Box>
370    )
371  })
372
373  // The side panel: the marked image, every comment editable or removable, the note, Draw more / Discard.
374  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
375    const items = (await read($, queue)) ?? [] // never written yet reads as nothing waiting
376    const els = $.ui.resolve(e)
377    const { Box, Button, Text } = els
378    const Svg = 'Svg' in els ? els.Svg : undefined
379    const Input = 'Input' in els ? els.Input : undefined // the mobile app draws no field
380    if (!items.length) return <Text dimColor>Nothing waiting. Use /redline or Ctrl+Alt+Shift+R.</Text>
381    const width = Math.max(200, Math.min(520, ((e.props as { bodyColumns?: number }).bodyColumns ?? 60) * 8))
382    return (
383      <Box flexDirection="column" gap={1}>
384        {items.map(item => (
385          <Box key={`item-${item.id}`} flexDirection="column" gap={1}>
386            {Svg && item.thumb ? (
387              <Svg key={`img-${item.id}`} alt="The marked-up capture" source={thumbSvg(item)} width={width} height={Math.round((width * item.height) / item.width)} />
388            ) : item.kind === 'text' ? (
389              <Text bold>Redline Text review · {item.marks} mark{item.marks === 1 ? '' : 's'}</Text>
390            ) : (
391              <Text dimColor>{item.width} x {item.height} capture (picture shown in the Claude app)</Text>
392            )}
393            {item.comments.map(c => (
394              <Box key={`row-${item.id}-${c.n}`} flexDirection="row" alignItems="center" gap={1}>
395                <Text color={c.removed ? undefined : 'red'} dimColor={c.removed}>#{c.n}</Text>
396                {c.removed ? (
397                  <Text dimColor strikethrough>{c.text}</Text>
398                ) : Input ? (
399                  <Box flexGrow={1}>
400                    <Input
401                      key={`c-${item.id}-${c.n}`}
402                      value={c.text}
403                      submitLabel="Save"
404                      onSubmit={(value: string) => edit($, item.id, it => { it.comments = (it.comments ?? []).map(x => (x.n === c.n ? { ...x, text: value.trim() || x.text } : x)) })}
405                    />
406                  </Box>
407                ) : (
408                  <Text>{c.text}</Text>
409                )}
410                <Button
411                  key={`rm-${item.id}-${c.n}`}
412                  label={c.removed ? 'Undo' : 'Remove'}
413                  onPress={() => edit($, item.id, it => { it.comments = (it.comments ?? []).map(x => (x.n === c.n ? { ...x, removed: !x.removed } : x)) })}
414                />
415              </Box>
416            ))}
417            {c0(item) && <Text dimColor>{c0(item)}</Text>}
418            {Input ? (
419              <Input
420                key={`note-${item.id}`}
421                label="Note for Claude"
422                placeholder="Optional note…"
423                value={item.message}
424                submitLabel="Save"
425                onSubmit={(value: string) => edit($, item.id, it => { it.message = value.trim() })}
426              />
427            ) : item.message ? <Text>Note: {item.message}</Text> : null}
428            <Button key={`send-${item.id}`} label="Send to Claude" variant="primary" onPress={() => sendNow($)} />
429            <Box flexDirection="row" gap={1}>
430              {item.kind === 'screen' ? <Button key={`more-${item.id}`} label="Draw more" onPress={() => launch($, ['edit', item.id])} /> : null}
431              <Button key={`drop-${item.id}`} label="Discard" onPress={async () => { await helper($, ['discard', item.id]); await scan($) }} />
432            </Box>
433          </Box>
434        ))}
435      </Box>
436    )
437  })
438}
439
440// One line under the comments: what the pins point at is read by OCR when sending.
441function c0(item: RedlineItem): string {
442  const withText = item.comments.filter(c => !c.removed && c.ocr).length
443  return withText ? `OCR found the text under ${withText === 1 ? '1 pin' : `${withText} pins`}.` : ''
444}
445
types/index.d.ts 33 lines
1/** One comment pin as the side panel shows it; `removed` = the user dropped it after drawing. */
2export type RedlineComment = {
3  n: number
4  text: string
5  ocr?: string
6  removed?: boolean
7  where?: string
8  /** Redline Text: one comment for several marks (highlights, cross-outs, drawings), each with its quote */
9  quotes?: { quote: string; where?: string; drawn?: boolean; crossed?: boolean }[]
10}
11
12/** A redline waiting in the queue (sent from the editor, not yet attached to a message). */
13export type RedlineItem = {
14  id: string
15  /** 'screen' = a marked-up capture, 'text' = a Redline Text review (exact quotes, no picture) */
16  kind: 'screen' | 'text'
17  thumb: string
18  width: number
19  height: number
20  message: string
21  comments: RedlineComment[]
22  marks: number
23}
24
25/** What is waiting in the queue, summed up for the band. */
26export type RedlinePending = { captures: number; comments: number; marks: number }
27
28declare module 'claude-code' {
29  interface PluginState {
30    redline: { queue: RedlineItem[]; menu: boolean }
31  }
32}
33