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

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.
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).

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.

Also included: dictation in comments (🎤), seven colour palettes, and the global hotkey Ctrl+Alt+Shift+R.
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).
| Where | How |
|---|---|
| Claude app (Code tab) | the Redline logo + ▾ above the chat box, after the first message |
| Claude Code CLI | the [ ▾ ] row above the prompt |
| Anywhere | /redline (capture), /redline text (review conversation), /redline discard |
| Outside Claude | Ctrl+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.
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.
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.
hooks/register.tsx 445 lines1import { 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}
445types/index.d.ts 33 lines1/** 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