Select text in the fullscreen transcript and attach it, with an optional comment, to your next prompt

Codex's "Add to chat", for Claude Code. In the fullscreen terminal, select any text in the transcript (a line of Claude's reply, one of your own earlier prompts, a tool's output), add an optional comment, and it goes with your next prompt as a numbered annotation. Claude answers each one and cites it as ①②… in its reply.
CLAUDE_CODE_NO_FLICKER=1, or set it under env in your settings. Outside fullscreen the terminal owns the mouse selection and no mod can read it.At the prompt of a Claude Code terminal session:
/plugin install add-to-chat --marketplace FunJim/claude-code-add-to-chat
[ ✚ Add to chat ]; click it, or run /add-to-chat.📎 N annotations ↗, and Claude's reply marks the sentences that answer each annotation.What the mod reads and sends: only the text you select and the comments you type, added to your own next prompt. It makes no network requests. For your latest 200 prompts sent with annotations, it keeps what you typed and those annotations in the plugin's local store, so a resumed session still shows them.
Full documentation, the Annotation Template and the limitations: <https://github.com/FunJim/claude-code-add-to-chat>
MIT licensed.
hooks/register.tsx 631 lines1// The engine glue: every call on `$` lives here (the engine follows `$`
2// only within this file); the decisions are the pure modules beside it.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, SessionMessage, UiPane } from 'claude-code'
5
6import type { Annotation, AnnotationSource, Candidate, Original, PaneMode, SelectionNote } from '../types'
7import { appended, restored, withComment, withComments, without, withoutAll } from './annotations'
8import { hashText } from './hash'
9import { promptsIn, replyCountsOf, sameRecord, sentInBox, sentPrompts, turnReplies, withReplies } from './history'
10import type { NotedReply } from './history'
11import { ALREADY_ADDED, NEEDS_FULLSCREEN, NOT_DRAWN, PANE_TITLE, SEND_PLACEHOLDER, addedToast } from './labels'
12import { PANE_COLUMNS, PANE_ID, commentKey, historyLines, paneView, tabAfterRemoving } from './pane'
13import { promptHint } from './prompt-hint'
14import { promptRow } from './prompt-row'
15import { originalText, promptText, replyText } from './rows'
16import { SYSTEM_ROWS, TOOL_ROWS, isSelectionOf, judge, noteOf, rowSource, selectionKey } from './selection'
17import type { Surroundings, Verdict } from './selection'
18import { annotated, withOriginal } from './send'
19
20const pending = atom({ plugin: 'add-to-chat', key: 'pending' } as const, [])
21const candidate = atom({ plugin: 'add-to-chat', key: 'candidate' } as const, null)
22const handled = atom({ plugin: 'add-to-chat', key: 'handled' } as const, null)
23const selectionNote = atom({ plugin: 'add-to-chat', key: 'selectionNote' } as const, null)
24const isPaneOpen = atom({ plugin: 'add-to-chat', key: 'isPaneOpen' } as const, false)
25const isPaneDrawn = atom({ plugin: 'add-to-chat', key: 'isPaneDrawn' } as const, false)
26const isPaneDismissed = atom({ plugin: 'add-to-chat', key: 'isPaneDismissed' } as const, false)
27const paneTab = atom({ plugin: 'add-to-chat', key: 'paneTab' } as const, 1)
28const commentDrafts = atom({ plugin: 'add-to-chat', key: 'commentDrafts' } as const, {})
29const commentSaves = atom({ plugin: 'add-to-chat', key: 'commentSaves' } as const, 0)
30const isDraftEmpty = atom({ plugin: 'add-to-chat', key: 'isDraftEmpty' } as const, true)
31const recalled = atom({ plugin: 'add-to-chat', key: 'recalled' } as const, null)
32const originals = atom({ plugin: 'add-to-chat', key: 'originals' } as const, {})
33const sessionPrompts = atom({ plugin: 'add-to-chat', key: 'sessionPrompts' } as const, [])
34const replyCounts = atom({ plugin: 'add-to-chat', key: 'replyCounts' } as const, {})
35const paneMode = atom({ plugin: 'add-to-chat', key: 'paneMode' } as const, { kind: 'pending' } as PaneMode)
36
37const POLL_MS = 250
38/** How long after the prompt box empties a submit has surely read what is pending. */
39const CLEAR_GRACE_MS = 1_000
40const STORE_ORIGINALS = 'originals'
41const MAX_ORIGINALS = 200
42
43/**
44 * Each transcript row's Source, by requestId.
45 * Render hooks fill it and may not write state; it is a cache, not drawing
46 * state: after a reload every row in view draws again and refills it, and a
47 * selection in a row out of view carries no requestId anyway.
48 */
49const roles = new Map<string, AnnotationSource>()
50
51function rememberRole(requestId: string | undefined, component: string, props: unknown) {
52 if (requestId !== undefined && requestId !== 'placeholder') roles.set(requestId, rowSource(component, props))
53}
54
55/**
56 * Each rewritten prompt row's requestId, by `hashText` of its text: where
57 * the pane's history jumps to. Filled as rows draw, like `roles`; a prompt
58 * not drawn since the load has none, and the pane offers no jump.
59 */
60const promptRows = new Map<string, string>()
61
62/** Reply rows (requestIds) drawn since the load: a row new to it during a turn is that turn's. */
63const drawnReplies = new Set<string>()
64
65/**
66 * The last prompt sent with Annotations, and the reply rows first drawn
67 * since: the rows noted with their latest text, and which of them the
68 * transcript showed to answer it (`kept`) or not (`rejected`). It stays
69 * open past the turn's end, since a
70 * short reply can first draw after `turn.complete`, until the next typed
71 * prompt or another session. The poll checks noted rows once the turn has
72 * ended (`dirty`) and keeps them with the prompt; a render hook may not
73 * write state.
74 */
75type Answering = {
76 hash: string
77 count: number
78 noted: NotedReply[]
79 kept: string[]
80 rejected: string[]
81 hasEnded: boolean
82 isDirty: boolean
83}
84let answering: Answering | null = null
85
86function noteReply(requestId: string | undefined, text: string) {
87 if (requestId === undefined || requestId === 'placeholder') return
88 const noted = answering?.noted.find(r => r.id === requestId)
89 if (noted !== undefined) {
90 if (noted.text !== text) {
91 noted.text = text
92 answering!.isDirty = true
93 }
94 return
95 }
96 if (drawnReplies.has(requestId)) return
97 drawnReplies.add(requestId)
98 if (answering !== null) {
99 answering.noted.push({ id: requestId, text })
100 answering.isDirty = true
101 }
102}
103
104/**
105 * The Citations a reply row may draw: to the count of the prompt it is
106 * known to answer; else, noted since the last prompt and not let go by the
107 * check, to that prompt's. An older reply first drawn since (scrolled to) is
108 * known to answer its own prompt, if it answered one sent with Annotations.
109 */
110function citationBound(requestId: string | undefined, counts: Record<string, number>): number | undefined {
111 if (requestId === undefined) return undefined
112 const known = counts[requestId]
113 if (known !== undefined) return known
114 const a = answering
115 return a !== null && !a.rejected.includes(requestId) && a.noted.some(r => r.id === requestId) ? a.count : undefined
116}
117
118/**
119 * Once the turn has ended, checks the rows noted since against the
120 * transcript and keeps those that answer the prompt with its original.
121 */
122async function keepReplies($: EngineInterface) {
123 const a = answering
124 if (a === null || !a.hasEnded || !a.isDirty) return
125 a.isDirty = false
126 const messages = await $.session.messages()
127 const transcript = Array.isArray(messages) ? messages : []
128 const kept = turnReplies(transcript, a.hash, a.noted)
129 const fresh = kept.filter(id => !a.kept.includes(id))
130 a.kept = kept
131 a.rejected = a.noted.map(r => r.id).filter(id => !kept.includes(id))
132 if (fresh.length > 0) {
133 const map = await update($, originals, now => (now[a.hash] === undefined ? now : { ...now, [a.hash]: withReplies(now[a.hash]!, fresh) }))
134 await $.store.set(STORE_ORIGINALS, map)
135 }
136 await refreshSession($, false, transcript)
137}
138
139async function surroundings($: EngineInterface): Promise<Surroundings> {
140 const [prompt, list, key, mode, map, inSession] = await Promise.all([
141 $.prompt.read(),
142 read($, pending),
143 read($, handled),
144 read($, paneMode),
145 read($, originals),
146 read($, sessionPrompts),
147 ])
148 return { draft: prompt.text, pending: list, handled: key, paneWords: historyLines(mode, sentPrompts(map, inSession)) }
149}
150
151/** What the poll last wrote, so it writes state only on a change. */
152type PollWatch = { isDraftEmpty?: boolean; isPaneOpen?: boolean; isPaneDrawn?: boolean; sessionId?: string; note?: string }
153
154function offerKey(offer: Candidate | null): string | null {
155 return offer === null ? null : selectionKey(offer)
156}
157
158/** One tick of the selection poll: what the pane offers, the draft's state, whether the pane is open. */
159async function poll($: EngineInterface, watch: PollWatch) {
160 // The offer before what decides it (see markHandled).
161 const offered = await read($, candidate)
162 const [selection, around, panes, sessionId] = await Promise.all([$.ui.selection(), surroundings($), $.ui.panes(), $.session.id()])
163
164 // /branch, /resume and /clear go on under another session id, with
165 // $.state cleared and no session.start: the originals come back from the
166 // store, and the history follows the new transcript.
167 if (watch.sessionId !== undefined && watch.sessionId !== sessionId) {
168 answering = null
169 await refreshSession($, true)
170 }
171 watch.sessionId = sessionId
172 await keepReplies($)
173
174 const isEmpty = around.draft.trim() === ''
175 if (watch.isDraftEmpty !== isEmpty) {
176 watch.isDraftEmpty = isEmpty
177 await update($, isDraftEmpty, () => isEmpty)
178 }
179 const isOpen = panes.some(p => p.id === PANE_ID)
180 if (watch.isPaneOpen !== isOpen) {
181 watch.isPaneOpen = isOpen
182 await update($, isPaneOpen, () => isOpen)
183 }
184 const isDrawn = isPanePlaced(panes)
185 if (watch.isPaneDrawn !== isDrawn) {
186 watch.isPaneDrawn = isDrawn
187 await update($, isPaneDrawn, () => isDrawn)
188 }
189
190 // `handled` holds only while the engine still reports that selection.
191 // Once it reports none or another (a new drag, a command run, the turn's
192 // end), the same text selected again is a new selection. Cleared only if
193 // unchanged since read: a dismissal landing meanwhile stays.
194 let decide = around
195 const was = around.handled
196 if (was !== null && (selection === undefined || selectionKey(selection) !== was)) {
197 await update($, handled, now => (now === was ? null : now))
198 decide = { ...around, handled: null }
199 }
200
201 // The pane previews the Excerpt as it will be kept, marks and 📎 gone;
202 // or says why it offers nothing.
203 const verdict = judge(selection, decide)
204 const offer = 'key' in verdict ? verdict.excerpt : null
205 if (offerKey(offer) !== offerKey(offered)) await update($, candidate, () => offer)
206 const note = noteOf(verdict)
207 const noteKey = JSON.stringify(note)
208 if (watch.note !== noteKey) {
209 watch.note = noteKey
210 await update($, selectionNote, () => note)
211 }
212}
213
214/** Done with a selection: it is not offered again until another is made. */
215async function markHandled($: EngineInterface, key: string | undefined) {
216 // `handled` before the offer goes: a poll that reads the offer gone then
217 // reads the selection handled, and does not offer it again.
218 if (key !== undefined) await update($, handled, () => key)
219 await update($, candidate, () => null)
220}
221
222/**
223 * Adds the selection as an Annotation, if it can be. `isAsked` (from
224 * /add-to-chat) adds a selection last added or dismissed too: the command
225 * is the person asking for it; one pending is still not added twice.
226 */
227async function addSelection($: EngineInterface, isAsked = false): Promise<{ annotation: Annotation } | Exclude<Verdict, { key: string }>> {
228 const around = await surroundings($)
229 const verdict = judge(await $.ui.selection(), isAsked ? { ...around, handled: null } : around)
230 if (!('key' in verdict)) return verdict
231
232 const { excerpt } = verdict
233 const source = (excerpt.requestId !== undefined && roles.get(excerpt.requestId)) || 'transcript'
234 // The open tab's field goes for the new one's.
235 await keepDrafts($)
236 const list = await update($, pending, now => appended(now, excerpt, source))
237 const annotation = list.at(-1)!
238 // The new tab shows, whatever the pane was showing.
239 await Promise.all([markHandled($, verdict.key), update($, paneTab, () => annotation.index), update($, paneMode, () => ({ kind: 'pending' }) as const)])
240 return { annotation }
241}
242
243/**
244 * The offer's ✕: not this time. The engine still reports the selection,
245 * so it is marked handled, or the next poll would offer it again; selected
246 * again, it is offered (see the poll).
247 */
248async function dismissOffer($: EngineInterface) {
249 const selection = await $.ui.selection()
250 await markHandled($, selection === undefined ? undefined : selectionKey(selection))
251}
252
253/** Whether the pane is drawn now: open, and placed by the surface. */
254function isPanePlaced(panes: readonly UiPane[]): boolean {
255 return panes.some(p => p.id === PANE_ID && p.isPlaced)
256}
257
258async function removeAnnotation($: EngineInterface, index: number) {
259 await keepDrafts($)
260 const [list, selection, key] = await Promise.all([read($, pending), $.ui.selection(), read($, handled)])
261 const removed = list.find(a => a.index === index)
262 await Promise.all([update($, pending, now => without(now, index)), update($, paneTab, at => tabAfterRemoving(at, index))])
263 // The selection it came from, still reported, can be added again: offered
264 // once more, as the engine reports it until another is made.
265 if (removed !== undefined && key !== null && selection !== undefined && selectionKey(selection) === key && isSelectionOf(selection, removed)) {
266 await update($, handled, now => (now === key ? null : now))
267 }
268}
269
270async function clearPending($: EngineInterface) {
271 await Promise.all([update($, pending, () => []), update($, commentDrafts, () => ({})), update($, recalled, () => null)])
272}
273
274/**
275 * Keeps what the Comment fields hold as their Comments: a field is left (a
276 * tab shown, added or closed, the history opened) or the prompt goes, saved
277 * with Enter or not.
278 */
279async function keepDrafts($: EngineInterface) {
280 const drafts = await read($, commentDrafts)
281 if (Object.keys(drafts).length === 0) return
282 await update($, pending, now => withComments(now, drafts))
283 await update($, commentDrafts, () => ({}))
284}
285
286/** A Comment saved with Enter: kept, and its field drawn anew holding it, the keyboard still in it. */
287async function saveComment($: EngineInterface, index: number, value: string) {
288 await update($, pending, now => withComment(now, index, value))
289 await update($, commentDrafts, now => Object.fromEntries(Object.entries(now).filter(([at]) => Number(at) !== index)))
290 const saves = await update($, commentSaves, n => n + 1)
291 await $.ui.focus({ requestId: PANE_ID, key: commentKey(index, saves) }).catch(() => undefined)
292}
293
294/**
295 * The prompt box changed by no edit (`by`: a rewind is `engine`, a step
296 * through the prompt history `history`).
297 *
298 * Annotations that came back with a prompt are that prompt's: they are
299 * pending while the box shows it. So a rewind or a step through the
300 * history first takes out those the last prompt brought back (a clear
301 * does too, a moment later: see the prompt.change hook). Then, a sent
302 * prompt with Annotations back in the box gets what was typed in its
303 * place, and its Annotations are pending again, to be edited and sent
304 * anew. From the history only a prompt of this conversation brings its
305 * Annotations back: another's point at rows this transcript does not hold.
306 */
307async function boxChanged($: EngineInterface, text: string, by: string) {
308 const isHistory = by === 'history'
309 if (isHistory || by === 'engine') await dropRecalled($)
310 const [map, inSession] = await Promise.all([read($, originals), read($, sessionPrompts)])
311 const sent = sentInBox(text, map, inSession)
312 if (sent === undefined) return
313 if (!isHistory || sent.isHere) {
314 let added: Annotation[] = []
315 await update($, pending, now => {
316 const back = restored(now, sent.original.annotations)
317 added = back.slice(now.length)
318 return back
319 })
320 if (added.length > 0) {
321 await Promise.all([
322 update($, paneMode, () => ({ kind: 'pending' }) as const),
323 update($, paneTab, () => added[0]!.index),
324 update($, recalled, () => ({ annotations: added.map(a => ({ text: a.text, requestId: a.requestId })) })),
325 ])
326 }
327 }
328 if (text !== sent.original.text) await $.prompt.fill({ text: sent.original.text, mode: 'replace' })
329}
330
331/**
332 * Takes out of pending the Annotations the last prompt put back in the box
333 * brought with it; with `only`, only if they are still those.
334 */
335async function dropRecalled($: EngineInterface, only?: string) {
336 const last = await read($, recalled)
337 if (last === null || (only !== undefined && JSON.stringify(last) !== only)) return
338 await update($, pending, now => withoutAll(now, last.annotations))
339 await update($, recalled, () => null)
340}
341
342/** Opens the pane at the person's asking: placed at any width, and no longer dismissed. */
343async function openPane($: EngineInterface) {
344 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS })
345 await Promise.all([update($, isPaneOpen, () => true), update($, isPaneDismissed, () => false), update($, isPaneDrawn, () => opened.isPlaced)])
346}
347
348/** Closes the pane at the person's asking, from the count under the prompt: closed until they ask for it again. */
349async function hidePane($: EngineInterface) {
350 await Promise.all([update($, isPaneOpen, () => false), update($, isPaneDismissed, () => true), update($, isPaneDrawn, () => false)])
351 await $.ui.close({ id: PANE_ID })
352}
353
354/**
355 * Reads which sent prompts are in this conversation's transcript (or in
356 * `transcript`, when the caller has just read it), for the pane's history;
357 * with `reload`, takes the originals from the store first.
358 */
359async function refreshSession($: EngineInterface, reload: boolean, transcript?: readonly SessionMessage[]) {
360 const [messages, stored] = await Promise.all([transcript ?? $.session.messages(), reload ? $.store.get(STORE_ORIGINALS) : undefined])
361 const map = reload
362 ? await update($, originals, now => (stored !== undefined && typeof stored === 'object' && stored !== null ? (stored as Record<string, Original>) : now))
363 : await read($, originals)
364 const hashes = Array.isArray(messages) ? promptsIn(messages, map) : []
365 const counts = replyCountsOf(map, hashes)
366 // Written only on a change: reply rows read replyCounts, and a write, even
367 // of the same value, may redraw them all.
368 const [shown, bounds] = await Promise.all([read($, sessionPrompts), read($, replyCounts)])
369 if (shown.length !== hashes.length || shown.some((h, i) => h !== hashes[i])) await update($, sessionPrompts, () => hashes)
370 if (!sameRecord(bounds, counts)) await update($, replyCounts, () => counts)
371}
372
373async function rememberOriginal($: EngineInterface, rewritten: string, original: Original) {
374 const map = await update($, originals, now => withOriginal(now, hashText(rewritten), original, MAX_ORIGINALS))
375 await $.store.set(STORE_ORIGINALS, map)
376}
377
378/** Shows a sent prompt's Annotations in the pane, from its row's 📎. */
379async function showSentPrompt($: EngineInterface, hash: string, requestId: string | undefined) {
380 await Promise.all([openPane($), keepDrafts($)])
381 await update($, paneMode, () => ({ kind: 'sent', hash, tab: 1, ...(requestId === undefined ? {} : { requestId }) }) as const)
382}
383
384/** Scrolls the transcript to the sent prompt the pane shows. */
385async function jumpToSent($: EngineInterface) {
386 const mode = await read($, paneMode)
387 if (mode.kind !== 'sent') return
388 const requestId = mode.requestId ?? promptRows.get(mode.hash)
389 const moved =
390 requestId === undefined ? { deny: 'not drawn' } : await $.ui.scroll({ to: { requestId }, block: 'center' }).catch((err: unknown) => ({ deny: String(err) }))
391 if ('deny' in moved) $.ui.toast(NOT_DRAWN)
392}
393
394export const register: Register = on => {
395 on('session.start', async ($, e, next) => {
396 await $.command.register({
397 name: 'add-to-chat',
398 description: 'Add the text selected in the transcript to your next prompt',
399 })
400 await refreshSession($, true)
401 // A reload keeps state but not the pane's drawing: clearing the offer
402 // makes the first poll write it again, so the new code draws it.
403 await update($, candidate, () => null)
404 // A reload drops the previous load's timers and fires session.start
405 // again, so this is the only interval running.
406 // Empty, so its first tick sets the status line too.
407 const watch: PollWatch = {}
408 $.clock.every(POLL_MS, () => poll($, watch))
409 return next(e)
410 })
411
412 // /clear ends the session without a new session.start: the rows the
413 // pending Annotations point at are gone.
414 on('session.end', async ($, e, next) => {
415 if (e.reason === 'clear') {
416 await clearPending($)
417 await update($, candidate, () => null)
418 }
419 return next(e)
420 })
421
422 // Opens the pane (again), and adds the selection if there is one to add.
423 on('command.run', { command: 'add-to-chat' }, async ($, e) => {
424 if (!e.presentation.isFullscreen) {
425 $.ui.toast(NEEDS_FULLSCREEN)
426 return {}
427 }
428 await openPane($)
429 const added = await addSelection($, true)
430 if ('annotation' in added) $.ui.toast(addedToast(added.annotation.index))
431 else if (added.reason === 'pending') $.ui.toast(ALREADY_ADDED)
432 return {}
433 })
434
435 on('prompt.submit', async ($, e, next) => {
436 if (e.origin.kind !== 'composer') return next(e)
437 await keepDrafts($)
438 const list = await read($, pending)
439 if (list.length === 0) {
440 answering = null
441 return next(e)
442 }
443 const sent = annotated(e.text, list)
444 await rememberOriginal($, sent.text, sent.original)
445 const entered = await next({ ...e, text: sent.text })
446 if (!('drop' in entered)) {
447 await clearPending($)
448 const hash = hashText(sent.text)
449 answering = {
450 hash,
451 count: sent.original.count,
452 noted: [],
453 kept: [],
454 rejected: [],
455 hasEnded: false,
456 isDirty: false,
457 }
458 await update($, sessionPrompts, now => (now.includes(hash) ? now : [...now, hash]))
459 }
460 return entered
461 }).catch(($, e, next) => next(e))
462
463 // A long reply's row can stay as it was drawn mid-stream, before its
464 // Citations were complete; drawing the rows again once the turn ends
465 // shows them.
466 on('turn.complete', async ($, e, next) => {
467 const done = await next(e)
468 $.ui.invalidate('ui.render')
469 // The main loop's turn ends (a subagent's carries its agentId): the
470 // replies noted for it are checked and kept, and the poll keeps doing so
471 // for rows drawn after.
472 if (e.agentId === undefined && answering !== null) {
473 answering.hasEnded = true
474 answering.isDirty = true
475 await keepReplies($)
476 } else {
477 await refreshSession($, false)
478 }
479 return done
480 })
481
482 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
483 rememberRole(e.requestId, e.component, e.props)
484 noteReply(e.requestId, e.props.text)
485 // Only a row with a directive reads the counts (not the originals,
486 // written at every start): every reply redrawing, or drawing slower,
487 // just after a launch moves the transcript under a drag about to start.
488 const hasDirective = e.props.text.includes(':annotation{')
489 const [list, counts] = await Promise.all([read($, pending), hasDirective ? read($, replyCounts) : {}])
490 const id = e.requestId
491 const text = replyText(e.props.text, id, list, citationBound(id, counts))
492 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
493 })
494
495 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
496 rememberRole(e.requestId, e.component, e.props)
497 if (e.props.origin.kind !== 'composer') return next(e)
498 const [list, map] = await Promise.all([read($, pending), read($, originals)])
499 const hash = hashText(e.props.text)
500 const original = e.props.isExpanded ? undefined : map[hash]
501 if (original === undefined || original.annotations === undefined || e.surface !== 'terminal') {
502 const text = promptText(e.props.text, e.requestId, list, original)
503 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
504 }
505 // In the terminal Claude Code draws the prompt as typed, and the mod its
506 // 📎 row beneath: a button to the pane, and a card on hover.
507 if (e.requestId !== undefined) promptRows.set(hash, e.requestId)
508 const engine = await next({ ...e, props: { ...e.props, text: originalText(original, e.requestId, list) } })
509 const requestId = e.requestId
510 return promptRow(
511 $.ui.resolve(e),
512 engine,
513 { hash, count: original.count, annotations: original.annotations, columns: e.viewport?.columns ?? 80 },
514 () => showSentPrompt($, hash, requestId),
515 )
516 })
517
518 // The rows nothing here draws on are seen only for their Source.
519 on('ui.render', { component: [...TOOL_ROWS, ...SYSTEM_ROWS] }, async ($, e, next) => {
520 rememberRole(e.requestId, e.component, e.props)
521 return next(e)
522 })
523
524 // The person closing the pane (its ✕, ctrl+x x) keeps it closed until
525 // they ask for it again with /add-to-chat.
526 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
527 await update($, isPaneOpen, () => false)
528 if (e.origin.kind === 'person') await update($, isPaneDismissed, () => true)
529 return next(e)
530 })
531
532 // Claude Code keeps its band; add-to-chat draws in its pane, which the
533 // band's first fullscreen drawing opens unless the person closed it. Open
534 // from the start, it never narrows the transcript under a mouse mid-drag.
535 // (Opened unasked, a terminal under 144 columns waits for /add-to-chat.)
536 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
537 if (e.viewport?.isFullscreen === true) {
538 const [isOpen, isDismissed] = await Promise.all([read($, isPaneOpen), read($, isPaneDismissed)])
539 if (!isOpen && !isDismissed) void $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS })
540 }
541 return next(e)
542 })
543
544 // While Annotations are pending, the line under the prompt counts them,
545 // and a click shows the pane or hides it.
546 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
547 const engine = await next(e)
548 const list = await read($, pending)
549 if (list.length === 0) return engine
550 const isDrawn = await read($, isPaneDrawn)
551 return promptHint($.ui.resolve(e), engine, list.length, isDrawn, () => (isDrawn ? hidePane($) : openPane($)))
552 })
553
554 // A rewind or a step through the prompt history (no edit, and not this
555 // plugin's own fill): see boxChanged.
556 on('prompt.change', async ($, e, next) => {
557 const changed = await next(e)
558 if (e.origin.kind === 'clear') {
559 // An emptied box shows the prompt no more: what came back with it
560 // goes. But a submit empties the box too, before its prompt.submit
561 // has read pending: so a moment later, and only if they are still
562 // pending as that prompt's (a submit has taken them, and forgotten).
563 const last = await read($, recalled)
564 if (last !== null) $.clock.after(CLEAR_GRACE_MS, () => void dropRecalled($, JSON.stringify(last)))
565 } else if (e.origin.kind !== 'plugin') {
566 await boxChanged($, e.text, e.origin.kind)
567 }
568 return changed
569 })
570
571 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
572 // Mobile has no text field to edit a Comment in.
573 if (e.surface === 'mobile') return next(e)
574 const [offer, note, list, tab, saves, isEmpty, mode, map, inSession] = await Promise.all([
575 read($, candidate),
576 read($, selectionNote),
577 read($, pending),
578 read($, paneTab),
579 read($, commentSaves),
580 read($, isDraftEmpty),
581 read($, paneMode),
582 read($, originals),
583 read($, sessionPrompts),
584 ])
585 const canJump = mode.kind === 'sent' && (mode.requestId ?? promptRows.get(mode.hash)) !== undefined
586 return paneView(
587 $.ui.resolve(e),
588 {
589 offer,
590 note,
591 pending: list,
592 tab,
593 commentSaves: saves,
594 isDraftEmpty: isEmpty,
595 isFullscreen: e.viewport?.isFullscreen === true,
596 columns: e.props.bodyColumns,
597 mode,
598 sent: sentPrompts(map, inSession),
599 canJump,
600 },
601 {
602 add: async () => {
603 const added = await addSelection($)
604 // A click leaves the keyboard with the prompt, so this only
605 // moves the ring when the pane was focused.
606 if ('annotation' in added) {
607 await $.ui.focus({ requestId: PANE_ID, key: commentKey(added.annotation.index, await read($, commentSaves)) }).catch(() => undefined)
608 }
609 },
610 dismiss: () => dismissOffer($),
611 show: async index => {
612 await keepDrafts($)
613 await update($, paneTab, () => index)
614 },
615 remove: index => removeAnnotation($, index),
616 draft: (index, value) => update($, commentDrafts, now => ({ ...now, [index]: value })),
617 comment: (index, value) => saveComment($, index, value),
618 send: () => $.prompt.fill({ text: SEND_PLACEHOLDER }),
619 openHistory: async () => {
620 await Promise.all([refreshSession($, false), keepDrafts($)])
621 await update($, paneMode, () => ({ kind: 'history' }) as const)
622 },
623 openSent: hash => update($, paneMode, () => ({ kind: 'sent', hash, tab: 1 }) as const),
624 showSent: index => update($, paneMode, now => (now.kind === 'sent' ? { ...now, tab: index } : now)),
625 back: () => update($, paneMode, now => (now.kind === 'sent' ? { kind: 'history' } : { kind: 'pending' }) as PaneMode),
626 jump: () => jumpToSent($),
627 },
628 )
629 })
630}
631hooks/annotations.ts 42 lines1// The pending list's edits, as pure functions of the list.
2import type { Annotation, AnnotationSource } from '../types'
3import type { Selection } from './selection'
4
5export function appended(list: readonly Annotation[], excerpt: Selection, source: AnnotationSource): Annotation[] {
6 return [...list, { index: list.length + 1, text: excerpt.text, source, requestId: excerpt.requestId }]
7}
8
9export function withComment(list: readonly Annotation[], index: number, value: string): Annotation[] {
10 const comment = value.trim()
11 return list.map(a => (a.index === index ? { ...a, comment: comment === '' ? undefined : comment } : a))
12}
13
14/** The list with each Comment field's text as its Annotation's Comment (`drafts` by index). */
15export function withComments(list: readonly Annotation[], drafts: Readonly<Record<string, string>>): Annotation[] {
16 return Object.entries(drafts).reduce((now, [index, value]) => withComment(now, Number(index), value), [...list])
17}
18
19/** The list without one, renumbered from 1. */
20export function without(list: readonly Annotation[], index: number): Annotation[] {
21 return list.filter(a => a.index !== index).map((a, i) => ({ ...a, index: i + 1 }))
22}
23
24/** The list without those of `taken` (same Excerpt, same row), renumbered from 1. */
25export function withoutAll(list: readonly Annotation[], taken: readonly Pick<Annotation, 'text' | 'requestId'>[]): Annotation[] {
26 return list.filter(a => !taken.some(t => t.text === a.text && t.requestId === a.requestId)).map((a, i) => ({ ...a, index: i + 1 }))
27}
28
29/**
30 * The list with a sent prompt's Annotations back, after those it holds,
31 * numbered on; one already in the list (same Excerpt, same row) is not
32 * added twice.
33 */
34export function restored(list: readonly Annotation[], sent: readonly Annotation[]): Annotation[] {
35 const has = (a: Annotation) => list.some(b => b.text === a.text && b.requestId === a.requestId)
36 return [...list, ...sent.filter(a => !has(a)).map((a, i) => ({ ...a, index: list.length + i + 1 }))]
37}
38
39export function marksFor(list: readonly Annotation[], requestId: string | undefined): Annotation[] {
40 return list.filter(a => a.requestId !== undefined && a.requestId === requestId)
41}
42hooks/hash.ts 14 lines1/** cyrb53: a short, stable key for a rewritten prompt's full text. */
2export function hashText(text: string): string {
3 let h1 = 0xdeadbeef
4 let h2 = 0x41c6ce57
5 for (let i = 0; i < text.length; i += 1) {
6 const c = text.charCodeAt(i)
7 h1 = Math.imul(h1 ^ c, 2654435761)
8 h2 = Math.imul(h2 ^ c, 1597334677)
9 }
10 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
11 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
12 return `h${(4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)}`
13}
14hooks/history.ts 100 lines1// Sent prompts' Annotations: which prompts are this conversation's, and
2// which sent prompt a reply answers.
3import type { Annotation, Original } from '../types'
4import { hashText } from './hash'
5import { oneLine } from './text'
6
7/**
8 * The rewritten prompts in a transcript (its user messages, as
9 * `$.session.messages()` gives them), by the hash `originals` is keyed by,
10 * oldest first; each once.
11 */
12export function promptsIn(messages: readonly { role: string; text: string }[], originals: Record<string, Original>): string[] {
13 const hashes = messages.filter(m => m.role === 'user').map(m => hashText(m.text))
14 return [...new Set(hashes.filter(h => originals[h] !== undefined))]
15}
16
17/** A sent prompt the pane's history lists. */
18export type SentPrompt = { hash: string; text: string; annotations: Annotation[] }
19
20/** The prompts of this conversation whose Annotations are known, newest first. */
21export function sentPrompts(originals: Record<string, Original>, inSession: readonly string[]): SentPrompt[] {
22 return inSession
23 .flatMap(hash => {
24 const o = originals[hash]
25 return o?.annotations !== undefined && o.annotations.length > 0 ? [{ hash, text: o.text, annotations: o.annotations }] : []
26 })
27 .reverse()
28}
29
30/** A sent prompt with Annotations that a text in the prompt box is. */
31export type InBox = {
32 hash: string
33 original: Original & { annotations: Annotation[] }
34 /** Whether it was sent in this conversation (`inSession`). */
35 isHere: boolean
36}
37
38/**
39 * The sent prompt with Annotations that `text` is: its rewritten text, as a
40 * rewind or the prompt history first gives it back, or what was typed for
41 * one of this conversation's, as the history gives it back once the mod
42 * filled that in. The newest of several.
43 */
44export function sentInBox(text: string, originals: Record<string, Original>, inSession: readonly string[]): InBox | undefined {
45 const withAnnotations = (hash: string): InBox | undefined => {
46 const o = originals[hash]
47 return o?.annotations !== undefined && o.annotations.length > 0 ? { hash, original: { ...o, annotations: o.annotations }, isHere: inSession.includes(hash) } : undefined
48 }
49 const rewritten = withAnnotations(hashText(text))
50 if (rewritten !== undefined) return rewritten
51 const typed = [...inSession].reverse().find(hash => originals[hash]?.text === text)
52 return typed === undefined ? undefined : withAnnotations(typed)
53}
54
55/** Each reply row known to answer a prompt in `inSession` → that prompt's Annotation count. */
56export function replyCountsOf(originals: Record<string, Original>, inSession: readonly string[]): Record<string, number> {
57 const counts: Record<string, number> = {}
58 for (const hash of inSession) {
59 const o = originals[hash]
60 for (const id of o?.replies ?? []) counts[id] = o!.count
61 }
62 return counts
63}
64
65/** Whether two records hold the same keys and values. */
66export function sameRecord(a: Record<string, number>, b: Record<string, number>): boolean {
67 const keys = Object.keys(a)
68 return keys.length === Object.keys(b).length && keys.every(k => a[k] === b[k])
69}
70
71/** `replies` with `more` added, each once. */
72export function withReplies(original: Original, more: readonly string[]): Original {
73 return { ...original, replies: [...new Set([...(original.replies ?? []), ...more])] }
74}
75
76/** A reply row noted during a turn: its requestId, and its text as it drew. */
77export type NotedReply = { id: string; text: string }
78
79/**
80 * The noted rows that are the turn's own replies: those whose text is in an
81 * assistant message after the prompt (`hash`) in the transcript, before the
82 * next prompt. An older
83 * reply first drawn during the turn (scrolled to) is not. Without the
84 * prompt in the transcript, every noted row is kept.
85 */
86export function turnReplies(messages: readonly { role: string; text: string }[], hash: string, noted: readonly NotedReply[]): string[] {
87 const at = messages.findLastIndex(m => m.role === 'user' && hashText(m.text) === hash)
88 if (at === -1) return noted.map(r => r.id)
89 // Up to the next prompt (a user message with text; tool results have none).
90 const next = messages.findIndex((m, i) => i > at && m.role === 'user' && m.text !== '')
91 const said = oneLine(
92 messages
93 .slice(at + 1, next === -1 ? undefined : next)
94 .filter(m => m.role === 'assistant')
95 .map(m => m.text)
96 .join('\n'),
97 )
98 return noted.filter(r => said.includes(oneLine(r.text))).map(r => r.id)
99}
100hooks/labels.ts 139 lines1// What the mod draws in its own words and numbers, shared by the pane, the
2// rows, the toasts and the selection filter (which must know the pane's
3// words to tell them from transcript text). A leaf module: the end-to-end
4// harness imports it too.
5import type { Annotation, AnnotationSource } from '../types'
6
7/** ①–⑳, ㉑–㉟, ㊱–㊿; past fifty, the number in parentheses. */
8export function circled(n: number): string {
9 if (n >= 1 && n <= 20) return String.fromCodePoint(0x2460 + n - 1)
10 if (n >= 21 && n <= 35) return String.fromCodePoint(0x3251 + n - 21)
11 if (n >= 36 && n <= 50) return String.fromCodePoint(0x32b1 + n - 36)
12 return `(${n})`
13}
14
15const SUPERSCRIPT = '⁰¹²³⁴⁵⁶⁷⁸⁹'
16
17/** `⁽ⁿ⁾`, the mark drawn after an Excerpt in its row. */
18export function superscript(n: number): string {
19 return `⁽${[...String(n)].map(d => SUPERSCRIPT[Number(d)]).join('')}⁾`
20}
21
22export const SOURCE_LABEL: Record<AnnotationSource, string> = { assistant: 'Claude', user: 'You', tool: 'Tool', system: 'System', transcript: 'Transcript' }
23
24/** Where each Source's Excerpt was selected, as the pane says it. */
25export const SOURCE_HINT: Record<AnnotationSource, string> = {
26 assistant: "from Claude's reply",
27 user: 'from your prompt',
28 tool: "from a tool's row",
29 system: "from Claude Code's own row",
30 transcript: 'from the transcript',
31}
32
33/** The pane's title, and its tab when other panes are open beside it. */
34export const PANE_TITLE = 'Add to chat'
35export const ADD_LABEL = '✚ Add to chat'
36/** What the pane's first row says while nothing is selected. */
37export const IDLE_HINT = 'Select text in the transcript to add it.'
38export const NEEDS_FULLSCREEN_HINT = 'Selections show here in fullscreen (CLAUDE_CODE_NO_FLICKER=1).'
39/** The first row's word for a selection already pending, after its preview. */
40export function addedNote(index: number): string {
41 return `added as ${circled(index)}`
42}
43
44export const PANE_EMPTY = 'Nothing added yet.'
45export const COMMENT_LABEL = 'Comment'
46export const COMMENT_PLACEHOLDER = 'Add an optional comment…'
47/** The Comment field's submit label; the engine draws it as `⏎ save`. */
48export const SAVE_LABEL = 'save'
49export const CLOSE_TAB = '✕'
50/** Beside `[ Send ]`, while the prompt box is empty. */
51export const SEND_HINT = 'now, or type a prompt and press ↵'
52export const SENT_HINT = 'They go with your next prompt ↵'
53/** What `[ Send ]` puts in an empty prompt box. */
54export const SEND_PLACEHOLDER = '(see annotations)'
55
56/** The pane's way to the sent prompts' Annotations, and back. */
57export function historyLabel(count: number): string {
58 return `History (${count})`
59}
60export const BACK_LABEL = '← back'
61export const HISTORY_TITLE = 'Sent with annotations'
62export const HISTORY_EMPTY = 'No prompt sent with annotations yet.'
63export const SENT_TITLE = 'You sent'
64export const NO_COMMENT = 'No comment'
65export const JUMP_LABEL = 'jump to prompt'
66export const NOT_DRAWN = 'That prompt is not drawn yet: scroll to it once, then jump'
67
68export const ALREADY_ADDED = 'That selection is already added'
69export const NEEDS_FULLSCREEN = 'add-to-chat needs fullscreen mode (CLAUDE_CODE_NO_FLICKER=1) to read the selection'
70
71export function addedToast(index: number): string {
72 return `Added ${circled(index)}: a Comment can go with it in the pane`
73}
74
75/** `📎 N annotations`: a rewritten prompt row's second line, and the status line. */
76export function clipLine(count: number): string {
77 return `📎 ${count} annotation${count === 1 ? '' : 's'}`
78}
79
80/** The button under a rewritten prompt row that opens its Annotations in the pane. */
81export function clipButtonLabel(count: number): string {
82 return `${clipLine(count)} ↗`
83}
84
85/** The count under the prompt, a toggle: it shows the pane, or hides the pane drawn. */
86export function hintLabel(count: number, isPaneDrawn: boolean): string {
87 return `${clipLine(count)} · ${isPaneDrawn ? 'hide' : 'show'} pane`
88}
89
90/** Whether a line is a `📎 N annotations` line (or its button), or any head of one. */
91export function isClipLineHead(line: string): boolean {
92 const head = line.trim().replace(/\s*↗$/u, '')
93 const count = /^📎 (\d+)/u.exec(head)?.[1]
94 return head === '📎' || (count !== undefined && clipLine(Number(count)).startsWith(head))
95}
96
97/** The highest tab number a digit key shows. */
98export const MAX_TAB_HOTKEY = 9
99
100/**
101 * One Annotation's tab label in the pane: its Source. The terminal draws a
102 * hotkey's digit before it (`1: Claude`); past nine the label carries the
103 * number itself.
104 */
105export function tabLabel(a: Annotation): string {
106 return a.index <= MAX_TAB_HOTKEY ? SOURCE_LABEL[a.source] : `${a.index}: ${SOURCE_LABEL[a.source]}`
107}
108
109/** A tab as the terminal draws it, hotkey digit included: `1: Claude`. */
110export function drawnTab(a: Annotation): string {
111 return a.index <= MAX_TAB_HOTKEY ? `${a.index}: ${tabLabel(a)}` : tabLabel(a)
112}
113
114/** The open tab's first line: the number its marks carry in the transcript, and where it was selected. */
115export function sourceLine(a: Annotation): string {
116 return `${circled(a.index)} ${SOURCE_HINT[a.source]}`
117}
118
119/**
120 * Every line of words the pane can draw for this list, but the offer: a
121 * selection that matches the offer's preview is the offer's own.
122 */
123export function paneLines(list: readonly Annotation[]): string[] {
124 return [
125 ADD_LABEL,
126 IDLE_HINT,
127 NEEDS_FULLSCREEN_HINT,
128 PANE_EMPTY,
129 `${COMMENT_LABEL}: ${COMMENT_PLACEHOLDER} ⏎ ${SAVE_LABEL}`,
130 `[ Send ] ${SEND_HINT}`,
131 SENT_HINT,
132 ...list.map(a => `${drawnTab(a)} ${CLOSE_TAB}`),
133 ...list.map(a => addedNote(a.index)),
134 ...list.map(sourceLine),
135 ...list.map(a => a.text),
136 ...list.flatMap(a => (a.comment === undefined ? [] : [`${COMMENT_LABEL}: ${a.comment}`])),
137 ]
138}
139hooks/pane.tsx 355 lines1// The add-to-chat pane: everything the mod shows. Its first row offers the
2// selection (or says how to make one); below it, one of three views:
3// - pending: one tab per pending Annotation, the open tab showing where its
4// Excerpt came from, the whole Excerpt in a frame, and its Comment, which
5// can be edited there; closing a tab removes its Annotation;
6// - history: the prompts sent with Annotations, newest first;
7// - sent: one of them, its Annotations in tabs, read-only.
8// A view: it draws from the values and calls back the actions register.tsx
9// hands it.
10//
11// The first row is always there and always one row high: an offer that
12// added a row would move what is under a mouse still dragging. Rows below it
13// change on clicks only.
14import type { BoxProps, ButtonProps, ElementConstructor, InputProps, RenderElement, TextProps } from 'claude-code'
15
16import type { Annotation, Candidate, PaneMode, SelectionNote } from '../types'
17import type { SentPrompt } from './history'
18import {
19 ADD_LABEL,
20 BACK_LABEL,
21 CLOSE_TAB,
22 COMMENT_LABEL,
23 COMMENT_PLACEHOLDER,
24 HISTORY_EMPTY,
25 HISTORY_TITLE,
26 IDLE_HINT,
27 JUMP_LABEL,
28 MAX_TAB_HOTKEY,
29 NEEDS_FULLSCREEN_HINT,
30 NO_COMMENT,
31 PANE_EMPTY,
32 SAVE_LABEL,
33 SEND_HINT,
34 SENT_HINT,
35 SENT_TITLE,
36 SOURCE_HINT,
37 addedNote,
38 circled,
39 clipLine,
40 historyLabel,
41 tabLabel,
42} from './labels'
43import { preview, width } from './text'
44
45/** The pane's id: what opens, closes and draws it. */
46export const PANE_ID = 'add-to-chat'
47
48/** The cells across to ask for when the pane docks beside the transcript. */
49export const PANE_COLUMNS = 56
50
51/** The elements the pane draws with, from `$.ui.resolve(e)`. */
52export type PaneElements = {
53 Box: ElementConstructor<BoxProps>
54 Button: ElementConstructor<ButtonProps>
55 Input: ElementConstructor<InputProps>
56 Text: ElementConstructor<TextProps>
57}
58
59export type PaneValues = {
60 offer: Candidate | null
61 /** The selection reported, not offered as its Excerpt is pending: the row says as which. */
62 note: SelectionNote | null
63 pending: readonly Annotation[]
64 /** The index of the Annotation whose tab is open. */
65 tab: number
66 /** Comments saved with Enter so far: the field's key (`commentKey`). */
67 commentSaves: number
68 isDraftEmpty: boolean
69 /** The fullscreen terminal, the only place a selection reads. */
70 isFullscreen: boolean
71 /** The cells across the pane's body (`bodyColumns`). */
72 columns: number
73 mode: PaneMode
74 /** The prompts sent with Annotations, newest first. */
75 sent: readonly SentPrompt[]
76 /** Whether the sent prompt shown has a row to jump to. */
77 canJump: boolean
78}
79
80export type PaneActions = {
81 add: () => unknown
82 /** The offer's ✕: not this time. */
83 dismiss: () => unknown
84 show: (index: number) => unknown
85 remove: (index: number) => unknown
86 /** The Comment field's text, at each edit. */
87 draft: (index: number, value: string) => unknown
88 /** The Comment field's text, at Enter. */
89 comment: (index: number, value: string) => unknown
90 send: () => unknown
91 openHistory: () => unknown
92 openSent: (hash: string) => unknown
93 showSent: (index: number) => unknown
94 back: () => unknown
95 jump: () => unknown
96}
97
98/**
99 * The key of Annotation `index`'s Comment field after `saves` saves. The
100 * terminal empties a field on Enter and keeps its text by key, filling it
101 * again only with another value: a new key after each save draws the field
102 * anew, holding the Comment.
103 */
104export function commentKey(index: number, saves: number): string {
105 return `comment-${index}-${saves}`
106}
107
108/** The tab open for `count` Annotations: `tab`, kept within them. */
109export function shownTab(tab: number, count: number): number {
110 return Math.min(Math.max(1, tab), count)
111}
112
113/**
114 * The tab open once Annotation `index` is removed: the same Annotation,
115 * renumbered, or where the removed one stood the one that took its place.
116 */
117export function tabAfterRemoving(tab: number, index: number): number {
118 return index < tab ? tab - 1 : tab
119}
120
121/** The sent prompt the mode shows, if it is one the history knows. */
122export function shownSent(mode: PaneMode, sent: readonly SentPrompt[]): SentPrompt | undefined {
123 return mode.kind === 'sent' ? sent.find(p => p.hash === mode.hash) : undefined
124}
125
126/**
127 * The words the history and sent views draw beside the pending view's: a
128 * selection of them is the pane's own, never an Excerpt.
129 */
130export function historyLines(mode: PaneMode, sent: readonly SentPrompt[]): string[] {
131 if (mode.kind === 'pending') return [historyLabel(sent.length)]
132 const words = [BACK_LABEL, HISTORY_TITLE, HISTORY_EMPTY, SENT_TITLE, NO_COMMENT, JUMP_LABEL]
133 if (mode.kind === 'history') return [...words, ...sent.flatMap(p => [p.text, clipLine(p.annotations.length)])]
134 const shown = shownSent(mode, sent)
135 if (shown === undefined) return words
136 return [
137 ...words,
138 shown.text,
139 ...shown.annotations.flatMap(a => [tabLabel(a), `${circled(a.index)} ${SOURCE_HINT[a.source]}`, a.text, ...(a.comment === undefined ? [] : [`${COMMENT_LABEL}: ${a.comment}`])]),
140 ]
141}
142
143/**
144 * A row of tabs, one per Annotation: its label, which shows it, and (when
145 * `remove` is given) a ✕, which closes it and so removes its Annotation. The
146 * first nine answer to their digit, which the terminal draws before the
147 * label. The open tab is drawn bold and underlined in the accent color, the
148 * others dim.
149 */
150function tabRow(el: PaneElements, list: readonly Annotation[], open: number, prefix: string, show: (index: number) => unknown, remove?: (index: number) => unknown) {
151 const { Box, Button, Text } = el
152 const tabs = list.map(a => {
153 const press = {
154 key: `${prefix}show-${a.index}`,
155 plain: true,
156 ...(a.index <= MAX_TAB_HOTKEY ? { hotkey: String(a.index) } : {}),
157 onPress: () => show(a.index),
158 } as const
159 return (
160 <Box key={`${prefix}tab-${a.index}`} gap={1}>
161 {a.index === open ? (
162 <Button {...press}>
163 <Text color="claude" bold underline>
164 {tabLabel(a)}
165 </Text>
166 </Button>
167 ) : (
168 <Button {...press} dimColor label={tabLabel(a)} />
169 )}
170 {remove === undefined ? null : (
171 <Button key={`close-${a.index}`} plain onPress={() => remove(a.index)}>
172 <Text dimColor>{CLOSE_TAB}</Text>
173 </Button>
174 )}
175 </Box>
176 )
177 })
178 return (
179 <Box flexWrap="wrap" columnGap={3}>
180 {tabs}
181 </Box>
182 )
183}
184
185/** Where an Annotation's Excerpt came from, and the Excerpt in a frame. */
186function excerptBlock(el: PaneElements, a: Annotation) {
187 const { Box, Text } = el
188 return [
189 <Text key="source">
190 <Text color="claude" bold>
191 {circled(a.index)}
192 </Text>
193 <Text dimColor> {SOURCE_HINT[a.source]}</Text>
194 </Text>,
195 <Box key="excerpt" borderStyle="round" borderDimColor paddingX={1}>
196 <Text wrap="wrap">{a.text}</Text>
197 </Box>,
198 ]
199}
200
201export function paneView(el: PaneElements, v: PaneValues, act: PaneActions): RenderElement {
202 const { Box, Button, Input, Text } = el
203 const rule = (key: string) => (
204 <Text key={key} dimColor>
205 {'─'.repeat(Math.max(1, v.columns))}
206 </Text>
207 )
208
209 // The offer, cut to one row: the button, the preview, and the ✕ that
210 // dismisses it. With none, that the selection is added (as which), or
211 // the hint.
212 const room = v.columns - (width(ADD_LABEL) + 4) - width(CLOSE_TAB) - 4
213 const noteWords = v.note === null ? '' : addedNote(v.note.index)
214 const offer =
215 v.offer !== null ? (
216 <Box key="offer" gap={1}>
217 <Button key="add" variant="primary" label={ADD_LABEL} onPress={act.add} />
218 <Text italic wrap="truncate">
219 “{preview(v.offer.text, room)}”
220 </Text>
221 <Button key="dismiss" plain onPress={act.dismiss}>
222 <Text dimColor>{CLOSE_TAB}</Text>
223 </Button>
224 </Box>
225 ) : v.note !== null ? (
226 <Text key="offer" dimColor wrap="truncate">
227 “{preview(v.note.text, Math.max(8, v.columns - width(noteWords) - 4))}” {noteWords}
228 </Text>
229 ) : (
230 <Text key="offer" dimColor wrap="truncate">
231 {v.isFullscreen ? IDLE_HINT : NEEDS_FULLSCREEN_HINT}
232 </Text>
233 )
234
235 const header = (title: string) => (
236 <Box key="header" gap={2}>
237 <Button key="back" plain label={BACK_LABEL} onPress={act.back} />
238 <Text bold wrap="truncate">
239 {title}
240 </Text>
241 </Box>
242 )
243
244 if (v.mode.kind === 'history') {
245 // One row per prompt, its first nine answering to their digit.
246 const room = v.columns - 3 - width(clipLine(99)) - 2
247 return (
248 <Box flexDirection="column">
249 {offer}
250 {rule('rule')}
251 {header(HISTORY_TITLE)}
252 <Text> </Text>
253 {v.sent.length === 0 ? (
254 <Text dimColor>{HISTORY_EMPTY}</Text>
255 ) : (
256 v.sent.map((p, i) => (
257 <Button
258 key={`sent-${p.hash}`}
259 plain
260 {...(i < MAX_TAB_HOTKEY ? { hotkey: String(i + 1) } : {})}
261 onPress={() => act.openSent(p.hash)}
262 >
263 {preview(p.text, room)} <Text dimColor>{clipLine(p.annotations.length)}</Text>
264 </Button>
265 ))
266 )}
267 </Box>
268 ) as RenderElement
269 }
270
271 if (v.mode.kind === 'sent') {
272 const shown = shownSent(v.mode, v.sent)
273 if (shown === undefined) {
274 return (
275 <Box flexDirection="column">
276 {offer}
277 {rule('rule')}
278 {header(HISTORY_TITLE)}
279 <Text dimColor>{HISTORY_EMPTY}</Text>
280 </Box>
281 ) as RenderElement
282 }
283 const open = shown.annotations[shownTab(v.mode.tab, shown.annotations.length) - 1]!
284 return (
285 <Box flexDirection="column">
286 {offer}
287 {rule('rule')}
288 {header(`${SENT_TITLE} “${preview(shown.text, Math.max(8, v.columns - width(BACK_LABEL) - width(SENT_TITLE) - 6))}”`)}
289 <Text> </Text>
290 {tabRow(el, shown.annotations, open.index, 'sent-', act.showSent)}
291 <Text> </Text>
292 {excerptBlock(el, open)}
293 {open.comment === undefined ? (
294 <Text dimColor>{NO_COMMENT}</Text>
295 ) : (
296 <Text wrap="wrap">
297 <Text dimColor>{COMMENT_LABEL}: </Text>
298 {open.comment}
299 </Text>
300 )}
301 {rule('rule-foot')}
302 {v.canJump ? <Button key="jump" plain label={`↑ ${JUMP_LABEL}`} onPress={act.jump} /> : null}
303 </Box>
304 ) as RenderElement
305 }
306
307 // The way into the history, under the pending view, once there is one.
308 const historyRow =
309 v.sent.length === 0 ? null : <Button key="history" plain dimColor label={`${historyLabel(v.sent.length)} ›`} onPress={act.openHistory} />
310
311 if (v.pending.length === 0) {
312 return (
313 <Box flexDirection="column">
314 {offer}
315 {rule('rule')}
316 <Text dimColor>{PANE_EMPTY}</Text>
317 {historyRow === null ? null : <Text> </Text>}
318 {historyRow}
319 </Box>
320 ) as RenderElement
321 }
322
323 const open = v.pending[shownTab(v.tab, v.pending.length) - 1]!
324 return (
325 <Box flexDirection="column">
326 {offer}
327 {rule('rule')}
328 {tabRow(el, v.pending, open.index, '', act.show, act.remove)}
329 <Text> </Text>
330 {excerptBlock(el, open)}
331 <Input
332 key={commentKey(open.index, v.commentSaves)}
333 label={COMMENT_LABEL}
334 placeholder={COMMENT_PLACEHOLDER}
335 value={open.comment ?? ''}
336 submitLabel={SAVE_LABEL}
337 onInput={value => act.draft(open.index, value)}
338 onSubmit={value => act.comment(open.index, value)}
339 />
340 {rule('rule-foot')}
341 {v.isDraftEmpty ? (
342 <Box key="footer" gap={1}>
343 <Button key="send" variant="primary" label="Send" onPress={act.send} />
344 <Text dimColor>{SEND_HINT}</Text>
345 </Box>
346 ) : (
347 <Text key="footer" dimColor>
348 {SENT_HINT}
349 </Text>
350 )}
351 {historyRow}
352 </Box>
353 ) as RenderElement
354}
355hooks/prompt-hint.tsx 25 lines1// The line under the prompt while Annotations are pending: Claude Code's
2// own hint as it draws it, and under it `📎 N annotations · show pane`, a
3// toggle of the pane (`· hide pane` while it is drawn). A view: register.tsx
4// hands it the engine's drawing (`next(e)`).
5import type { BoxProps, ButtonProps, ElementConstructor, RenderElement } from 'claude-code'
6
7import { hintLabel } from './labels'
8
9export type PromptHintElements = {
10 Box: ElementConstructor<BoxProps>
11 Button: ElementConstructor<ButtonProps>
12}
13
14export function promptHint(el: PromptHintElements, engine: RenderElement, count: number, isPaneDrawn: boolean, toggle: () => unknown): RenderElement {
15 const { Box, Button } = el
16 return (
17 <Box flexDirection="column">
18 {engine}
19 <Box key="hint-clip-row" paddingLeft={2}>
20 <Button key="hint-clip" plain dimColor label={hintLabel(count, isPaneDrawn)} onPress={toggle} />
21 </Box>
22 </Box>
23 ) as RenderElement
24}
25hooks/prompt-row.tsx 85 lines1// A rewritten prompt row in the terminal: Claude Code's own drawing of the
2// prompt as typed, and under it a row of the mod's: `📎 N annotations ↗`,
3// which opens them in the pane, and a card listing them that shows while
4// the pointer is over that row. A view: register.tsx hands it the engine's
5// drawing (`next(e)`), so the row keeps Claude Code's look and theme.
6import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
7
8import type { Annotation } from '../types'
9import { SOURCE_LABEL, circled, clipButtonLabel } from './labels'
10import { preview } from './text'
11
12/** The most Annotations the card lists; the rest it counts. */
13export const CARD_MAX = 6
14
15/**
16 * The card's background: opaque, or the rows beneath show through its blank
17 * cells. The theme's prompt-row background, which Claude Code draws a sent
18 * prompt on.
19 */
20export const CARD_BACKGROUND = 'userMessageBackground'
21
22export type PromptRowElements = {
23 Box: ElementConstructor<BoxProps>
24 Button: ElementConstructor<ButtonProps>
25 Text: ElementConstructor<TextProps>
26}
27
28export type PromptRowValues = {
29 /** `hashText` of the rewritten prompt: the row's keys. */
30 hash: string
31 count: number
32 /** Its Annotations: the card lists them. */
33 annotations: readonly Annotation[]
34 /** The cells across the terminal. */
35 columns: number
36}
37
38/** The card's rows: one per Annotation listed, and one counting the rest. */
39export function cardRows(count: number): number {
40 return Math.min(count, CARD_MAX) + (count > CARD_MAX ? 1 : 0)
41}
42
43export function promptRow(el: PromptRowElements, engine: RenderElement, v: PromptRowValues, open: () => unknown): RenderElement {
44 const { Box, Button, Text } = el
45 const listed = v.annotations.slice(0, CARD_MAX)
46 const room = Math.max(16, Math.min(64, v.columns - 24))
47 // Above the button, over the prompt and the rows before it: a card below
48 // would be painted over by the rows drawn after this one.
49 const card =
50 listed.length === 0 ? null : (
51 <Box
52 position="absolute"
53 top={-(cardRows(v.annotations.length) + 2)}
54 left={0}
55 display="none"
56 hover={{ display: 'flex' }}
57 flexDirection="column"
58 borderStyle="round"
59 borderDimColor
60 paddingX={1}
61 backgroundColor={CARD_BACKGROUND}
62 >
63 {listed.map(a => (
64 <Text key={`card-${a.index}`} wrap="truncate">
65 <Text color="claude" bold>
66 {circled(a.index)}
67 </Text>{' '}
68 <Text dimColor>{SOURCE_LABEL[a.source]}</Text> “{preview(a.text, room)}”
69 {a.comment === undefined ? null : <Text dimColor> — {preview(a.comment, Math.max(8, Math.floor(room / 2)))}</Text>}
70 </Text>
71 ))}
72 {v.annotations.length > CARD_MAX ? <Text dimColor>{`+${v.annotations.length - CARD_MAX} more`}</Text> : null}
73 </Box>
74 )
75 return (
76 <Box flexDirection="column">
77 {engine}
78 <Box key={`clip-row-${v.hash}`} paddingLeft={2}>
79 <Button key={`clip-${v.hash}`} plain dimColor label={clipButtonLabel(v.count)} onPress={open} />
80 {card}
81 </Box>
82 </Box>
83 ) as RenderElement
84}
85hooks/rows.ts 31 lines1// What a transcript row draws: Citations and marks in replies, and a
2// rewritten prompt as the person typed it.
3import type { Annotation, Original } from '../types'
4import { marksFor } from './annotations'
5import { drawCitations, drawMarks } from './excerpt'
6import { clipLine } from './labels'
7
8/** A reply row's text: its Citations (to `count`, the prompt it answers was sent with, when known) and marks. */
9export function replyText(text: string, requestId: string | undefined, pending: readonly Annotation[], count?: number): string {
10 return drawMarks(drawCitations(text, count), marksFor(pending, requestId), 'assistant')
11}
12
13/**
14 * A prompt row's text: the original for a rewritten prompt (with a `📎` line;
15 * none while expanded, where ctrl+o shows what the model read), marks drawn.
16 */
17/** A rewritten prompt row's original, as typed, with the marks of the Annotations pending in it. */
18export function originalText(original: Original, requestId: string | undefined, pending: readonly Annotation[]): string {
19 return drawMarks(original.text, marksFor(pending, requestId), 'user')
20}
21
22export function promptText(
23 text: string,
24 requestId: string | undefined,
25 pending: readonly Annotation[],
26 original: Original | undefined,
27): string {
28 const drawn = drawMarks(original?.text ?? text, marksFor(pending, requestId), 'user')
29 return original === undefined ? drawn : `${drawn}\n${clipLine(original.count)}`
30}
31hooks/selection.ts 115 lines1// Deciding what a mouse selection is: an Excerpt worth offering, the pane's
2// or the draft's own words, or one already handled. Pure: register.tsx reads
3// the selection and the state and acts on the answer.
4import type { Annotation, AnnotationSource, Candidate, SelectionNote } from '../types'
5import { stripMarks } from './excerpt'
6import { isClipLineHead, paneLines } from './labels'
7import { oneLine } from './text'
8
9/** What `$.ui.selection()` gives; a Candidate is one kept to offer. */
10export type Selection = Candidate
11
12/** Rows the person types into the session from: a terminal, Remote Control, the SDK. */
13const USER_ORIGINS = ['composer', 'bridge', 'sdk']
14
15/** The components a Source is known for, beside the reply and prompt rows. */
16export const TOOL_ROWS = ['ToolUse', 'ToolResult', 'ToolGroup', 'ToolProgress', 'AskUserQuestion'] as const
17export const SYSTEM_ROWS = ['TurnDuration', 'CommandOutput', 'InfoNotice'] as const
18
19/**
20 * The Source of a selection in a row drawn as `component`: a prompt row is
21 * the person's only when they typed it; one a notification, a schedule or
22 * another session sent is Claude Code's own.
23 */
24export function rowSource(component: string, props: unknown): AnnotationSource {
25 if (component === 'AssistantMessage') return 'assistant'
26 if (component === 'UserMessage') {
27 const kind = (props as { origin?: { kind?: string } }).origin?.kind ?? ''
28 return USER_ORIGINS.includes(kind) ? 'user' : 'system'
29 }
30 if ((TOOL_ROWS as readonly string[]).includes(component)) return 'tool'
31 if ((SYSTEM_ROWS as readonly string[]).includes(component)) return 'system'
32 return 'transcript'
33}
34
35export function selectionKey(s: Selection): string {
36 return `${s.requestId ?? ''}\u0000${s.text}`
37}
38
39/**
40 * The Excerpt a selection holds: its text with marks and `📎` lines (this
41 * mod's own, any head of one) gone; null when nothing is left. Plain JSON,
42 * fit for state: no `requestId` key when it lies in no row.
43 */
44export function excerptOf(s: Selection | undefined): Selection | null {
45 if (s === undefined) return null
46 const text = stripMarks(s.text)
47 .split('\n')
48 .filter(line => !isClipLineHead(line))
49 .join('\n')
50 .trim()
51 if (text === '') return null
52 return s.requestId === undefined ? { text } : { text, requestId: s.requestId }
53}
54
55const flatten = oneLine
56
57/** What the pane and the prompt box hold while a selection is judged. */
58export type Surroundings = {
59 draft: string
60 pending: readonly Annotation[]
61 /** `selectionKey` of the selection last added or dismissed. */
62 handled: string | null
63 /** Words the pane draws beyond the pending view's (its history views). */
64 paneWords?: readonly string[]
65}
66
67/**
68 * Whether a selection with no row lies in the prompt box or in the pane
69 * rather than in the transcript: its text is the draft's or the pane's own.
70 */
71function isOutsideTranscript(excerpt: Selection, around: Surroundings): boolean {
72 if (excerpt.requestId !== undefined) return false
73 // A selection across the Excerpt's frame in the pane picks up its border.
74 const text = flatten(excerpt.text.replace(/[│╭╮╰╯─]/g, ' '))
75 return flatten(around.draft).includes(text) || [...paneLines(around.pending), ...(around.paneWords ?? [])].some(line => flatten(line).includes(text))
76}
77
78/** Whether `selection` is where Annotation `a` was added from: its Excerpt, in its row. */
79export function isSelectionOf(selection: Selection | undefined, a: { text: string; requestId?: string }): boolean {
80 const excerpt = excerptOf(selection)
81 return excerpt !== null && excerpt.text === a.text && excerpt.requestId === a.requestId
82}
83
84/**
85 * An Excerpt to offer, with the key that marks its selection handled; or
86 * why not: `nothing` to offer, `pending` as Annotation `index`, or
87 * `handled` (just added, or its offer dismissed).
88 */
89export type Verdict =
90 | { excerpt: Selection; key: string }
91 | { reason: 'nothing' }
92 | { reason: 'pending'; text: string; index: number }
93 | { reason: 'handled' }
94
95/**
96 * What to do with a selection: offer (and add) its Excerpt, or not —
97 * `nothing` when it holds none or lies outside the transcript, `pending`
98 * when its Excerpt is (same Excerpt, same row), `handled` when it was last
99 * added or its offer dismissed.
100 */
101export function judge(selection: Selection | undefined, around: Surroundings): Verdict {
102 const excerpt = excerptOf(selection)
103 if (selection === undefined || excerpt === null || isOutsideTranscript(excerpt, around)) return { reason: 'nothing' }
104 const added = around.pending.find(a => a.text === excerpt.text && a.requestId === excerpt.requestId)
105 if (added !== undefined) return { reason: 'pending', text: excerpt.text, index: added.index }
106 const key = selectionKey(selection)
107 if (key === around.handled) return { reason: 'handled' }
108 return { excerpt, key }
109}
110
111/** What the pane's first row says of a selection it does not offer: that it is added, as which; nothing else. */
112export function noteOf(verdict: Verdict): SelectionNote | null {
113 return 'reason' in verdict && verdict.reason === 'pending' ? { text: verdict.text, index: verdict.index } : null
114}
115hooks/send.ts 21 lines1// What the person's prompt carries once the Annotations go with it.
2import type { Annotation, Original } from '../types'
3import { renderTemplate } from './template'
4
5export type Annotated = {
6 text: string
7 /** What the prompt's row draws instead: the text as typed. */
8 original: Original
9}
10
11/** The prompt rewritten by the Annotation Template, and what was typed. */
12export function annotated(text: string, list: readonly Annotation[]): Annotated {
13 return { text: renderTemplate(text, list), original: { text, count: list.length, annotations: [...list] } }
14}
15
16/** The originals map with one more, the oldest dropped past `max`. */
17export function withOriginal(map: Readonly<Record<string, Original>>, key: string, original: Original, max: number): Record<string, Original> {
18 const kept = Object.entries(map).filter(([k]) => k !== key)
19 return Object.fromEntries([...kept.slice(-(max - 1)), [key, original]])
20}
21hooks/text.ts 32 lines1// Fitting text into terminal cells.
2
3/**
4 * Cells a character takes in the terminal: two for East Asian wide ones and
5 * for emoji drawn as emoji by default (🚀 📎), one for the rest — symbols
6 * such as ⏺ ✚ ✕ ⎿ ① stay one cell wide.
7 */
8export function cells(c: string): number {
9 return /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]|\p{Emoji_Presentation}/u.test(c) ? 2 : 1
10}
11
12/** Cells a string takes. */
13export function width(text: string): number {
14 return [...text].reduce((n, c) => n + cells(c), 0)
15}
16
17/** The text with its runs of whitespace, line ends too, as single spaces. */
18export function oneLine(text: string): string {
19 return text.replace(/\s+/g, ' ').trim()
20}
21
22/** The text on one line, cut with … to fit `room` cells (at least 2). */
23export function preview(text: string, room: number): string {
24 const line = oneLine(text)
25 if (width(line) <= room) return line
26 const chars = [...line]
27 let used = 1
28 let cut = 0
29 while (cut < chars.length && used + cells(chars[cut]!) <= Math.max(2, room)) used += cells(chars[cut++]!)
30 return `${chars.slice(0, cut).join('')}…`
31}
32