SLOPSHOPPER

add-to-chat

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

newpanebandspinnerrowscommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · add-to-chat
│ ┃ Add to chat ✕ › fix the failing auth test and add an audit log call │ ┃ Select text in the transcript to add it. │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ Nothing added yet. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /add-to-chat │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Add to chat
Select text in the transcript to add it. ──────────────────────────────────────────────────────── Nothing added yet.
README

add-to-chat

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.

Requirements

  • Claude Code 2.1.300 or later, with mods.
  • Fullscreen mode: start Claude Code with 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.

Install

At the prompt of a Claude Code terminal session:

/plugin install add-to-chat --marketplace FunJim/claude-code-add-to-chat

Use

  1. Select text in the transcript with the mouse. The pane beside the transcript offers [ ✚ Add to chat ]; click it, or run /add-to-chat.
  2. Each annotation gets a tab with its excerpt, where it came from, and a Comment field. Leave the comment empty, or type a question about the excerpt.
  3. Send your prompt as usual. It shows as what you typed plus 📎 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.

Source 15 files
hooks/register.tsx 631 lines
1// 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}
631
hooks/annotations.ts 42 lines
1// 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}
42
hooks/hash.ts 14 lines
1/** 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}
14
hooks/history.ts 100 lines
1// 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}
100
hooks/labels.ts 139 lines
1// 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}
139
hooks/pane.tsx 355 lines
1// 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}
355
hooks/prompt-hint.tsx 25 lines
1// 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}
25
hooks/prompt-row.tsx 85 lines
1// 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}
85
hooks/rows.ts 31 lines
1// 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}
31
hooks/selection.ts 115 lines
1// 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}
115
hooks/send.ts 21 lines
1// 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}
21
hooks/text.ts 32 lines
1// 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