Test-only: records what the e2e harness asserts on (rows, selection, prompts, turns, add-to-chat's state). Changes nothing.

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.
https://github.com/user-attachments/assets/ee4c18ac-a04b-49d0-989e-e27b3fb5bcc1
⏺ The quick brown fox⁽¹⁾ jumps over the │ ✕
lazy dog. │[ ✚ Add to chat ] “capital of France” ✕
│────────────────────────────────────────────
Paris is the capital of France. │1: Claude ✕
│
│① from Claude's reply
│╭──────────────────────────────────────────╮
││ quick brown fox │
│╰──────────────────────────────────────────╯
│Comment: which animal is this?
│────────────────────────────────────────────
│They go with your next prompt ↵
───────────────────────────────────────────────────────────────────────────────────────────
❯ for each annotation, answer its comment or say what it refers to
Sent, the prompt row shows what you typed; Claude's reply cites each annotation:
❯ for each annotation, answer its comment or say what it refers to
📎 2 annotations ↗
⏺ The "quick brown fox" is a fox, a small wild canine. ①
The "capital of France" refers to Paris, the country's capital city. ②
CLAUDE_CODE_NO_FLICKER=1. Outside it the terminal owns the mouse selection and no mod can read it (why).At the prompt of a Claude Code terminal session:
/plugin install add-to-chat --marketplace FunJim/claude-code-add-to-chat
Answer y to add the marketplace, then pick a scope. Start Claude Code with CLAUDE_CODE_NO_FLICKER=1, or set it under env in your settings.
A pane sits beside the transcript, open from the start (in a terminal under 144 columns, run /add-to-chat once to open it; under 110 it sits above the prompt instead). Its first row says Select text in the transcript to add it.
[ ✚ Add to chat ] "…" ✕. Click the button, or run /add-to-chat; ✕ dismisses the offer.1: Claude ✕ 2: You ✕); press its digit to switch to it. The open tab shows the whole excerpt, where it came from, and a Comment field: click it and type (Enter saves it; switching tabs or sending keeps it too). Or leave it empty.⁽¹⁾ in the transcript. Close a tab with its ✕ to remove that annotation. 📎 N annotations · hide pane under the prompt counts them; click it to hide the pane or show it again.📎 N annotations ↗; ctrl+o shows what the model received. With nothing to type, [ Send ] puts (see annotations) in the prompt for you to send.📎 N annotations ↗ to see its annotations in a card; click it to open them in the pane, read-only, with ↑ jump to prompt. History (N) › under the pane's tabs lists every prompt you sent with annotations.Claude's reply cites what it answers: :annotation{index="1"} in its text is drawn as ①.
The pane opens before you select anything and its first row never grows, so nothing moves under a mouse still dragging. Closed with its ✕, it stays closed until you run /add-to-chat.
Your prompt is rewritten as the annotations in XML, a few lines telling Claude how to cite them, then what you typed in <request>:
<annotations>
<annotation index="1" source="assistant" requestId="…">
<excerpt>Paris is the capital of France.</excerpt>
<comment>is this still true?</comment>
</annotation>
</annotations>
These are passages the user selected from earlier in this conversation (…)
When a sentence of your reply addresses an annotation, end it with :annotation{index="N"} (…)
<request>
answer my comment
</request>
Each annotation's source is assistant (Claude's reply), user (your prompt), tool (a tool call or result), system (Claude Code's own rows: notices, command output, turn duration) or transcript (no single row). Excerpts and comments are escaped, so an excerpt cannot close the <annotations> block (SECURITY.md). ctrl+o on a sent prompt shows what was sent.
The wording is fixed: it was chosen by an eval over haiku and sonnet (ADR 0007). Custom templates (the templatePath option) are no longer supported (ADR 0010).
transcript: it can't be pinned to one row.Issues and pull requests are welcome; see CONTRIBUTING.md. npm run e2e runs the end-to-end showcase in real Claude Code sessions (e2e/README.md).
hooks/register.ts 124 lines1// Test-only plugin loaded beside add-to-chat by the e2e harness: it records,
2// as JSON lines in $ATC_PROBE_LOG, what scenarios assert on, and changes
3// nothing. It sits beneath add-to-chat, so a prompt it records is the one
4// the engine receives.
5//
6// session the session's id (where its transcript is), at start
7// session-id each change of $.session.id() (/branch, /clear…)
8// selection each change of $.ui.selection()
9// row each transcript row drawn: component, requestId, text
10// turn each turn's start and end
11// state each change of add-to-chat's pending/candidate/paneTab/replyCounts/commentDrafts
12// toast each toast add-to-chat raises
13// input each edit and Enter in a plugin's Input: element, kind, value
14// prompt-change each change of the prompt box no edit made: who (origin), the text's head
15// submit each prompt as it reaches the engine: its origin, whether it carries Annotations, its head
16//
17// Two commands make rows the harness cannot get otherwise: /probe-out
18// prints a command's output, /probe-say submits a prompt as a plugin.
19import type { EngineInterface, Register } from 'claude-code'
20
21const ROWS = new Set(['UserMessage', 'AssistantMessage', 'ToolUse', 'ToolResult', 'ToolGroup', 'ToolProgress', 'AskUserQuestion', 'CommandOutput', 'TurnDuration', 'InfoNotice'])
22
23let file: string | undefined
24const lines: string[] = []
25let writing: Promise<void> = Promise.resolve()
26const last = new Map<string, string>()
27
28/** Rows a resumed session draws come before session.start names the file: they wait in `lines`. */
29function log($: EngineInterface, kind: string, data: object) {
30 writing = writing.then(async () => {
31 lines.push(JSON.stringify({ t: await $.clock.now(), kind, ...data }))
32 if (file !== undefined) await $.fs.write(file, `${lines.join('\n')}\n`)
33 })
34 return writing
35}
36
37/** Logs `data` under `kind` when it differs from what was logged last for `kind` and `of`. */
38function logChange($: EngineInterface, kind: string, data: object, of = '') {
39 const value = JSON.stringify(data)
40 if (last.get(`${kind}:${of}`) === value) return
41 last.set(`${kind}:${of}`, value)
42 void log($, kind, data)
43}
44
45// add-to-chat's state, read as another plugin may: references of literals.
46const PENDING = { plugin: 'add-to-chat', key: 'pending' } as never
47const CANDIDATE = { plugin: 'add-to-chat', key: 'candidate' } as never
48const PANE_TAB = { plugin: 'add-to-chat', key: 'paneTab' } as never
49const REPLY_COUNTS = { plugin: 'add-to-chat', key: 'replyCounts' } as never
50const COMMENT_DRAFTS = { plugin: 'add-to-chat', key: 'commentDrafts' } as never
51
52export const register: Register = on => {
53 on('session.start', async ($, e, next) => {
54 file = await $.env.get('ATC_PROBE_LOG')
55 void log($, 'session', { id: await $.session.id(), cwd: await $.session.cwd() })
56 await $.command.register({ name: 'probe-out', description: 'Print a two-line command output (e2e)' })
57 await $.command.register({ name: 'probe-say', description: 'Submit a prompt as a plugin (e2e)' })
58 $.clock.every(200, async () => {
59 const [sessionId, selection, pending, candidate, paneTab, replyCounts, commentDrafts] = await Promise.all([
60 $.session.id(),
61 $.ui.selection(),
62 $.state.get(PENDING),
63 $.state.get(CANDIDATE),
64 $.state.get(PANE_TAB),
65 $.state.get(REPLY_COUNTS),
66 $.state.get(COMMENT_DRAFTS),
67 ])
68 logChange($, 'session-id', { id: sessionId })
69 logChange($, 'selection', { selection: selection ?? null })
70 logChange($, 'state', { pending: pending.value ?? [], candidate: candidate.value ?? null, paneTab: paneTab.value ?? 1, replyCounts: replyCounts.value ?? {}, commentDrafts: commentDrafts.value ?? {} })
71 })
72 return next(e)
73 })
74
75 on('command.run', { command: 'probe-out' }, async () => ({
76 text: 'PROBE-OUT line one: command output\nPROBE-OUT line two: more output',
77 }))
78
79 on('command.run', { command: 'probe-say' }, async $ => {
80 // A prompt submitted from inside the command's own dispatch is dropped
81 // with it; from a timer it queues as a plugin's prompt does.
82 $.clock.after(10, () => void $.prompt.submit({ text: 'PLUGIN-PROMPT: reply with exactly PLUGIN-ACK' }))
83 return {}
84 })
85
86 on('turn.start', async ($, e, next) => {
87 void log($, 'turn', { phase: 'start' })
88 return next(e)
89 })
90
91 on('turn.complete', async ($, e, next) => {
92 const done = await next(e)
93 void log($, 'turn', { phase: 'complete' })
94 return done
95 })
96
97 on('prompt.submit', async ($, e, next) => {
98 void log($, 'submit', { origin: e.origin.kind, hasAnnotations: /<annotation /.test(e.text), text: e.text.slice(0, 60) })
99 return next(e)
100 })
101
102 on('prompt.change', async ($, e, next) => {
103 void log($, 'prompt-change', { origin: e.origin.kind, text: e.text.slice(0, 80) })
104 return next(e)
105 })
106
107 on('ui.input', async ($, e, next) => {
108 void log($, 'input', { plugin: e.plugin, element: e.element, kind: e.kind, value: e.value })
109 return next(e)
110 })
111
112 on('ui.toast', async ($, e, next) => {
113 void log($, 'toast', { text: e.text })
114 return next(e)
115 }).catch(($, e, next) => next(e))
116
117 on('ui.render', async ($, e, next) => {
118 if (!ROWS.has(e.component)) return next(e)
119 const text = (e.props as { text?: unknown }).text
120 logChange($, 'row', { component: e.component, requestId: e.requestId, text: typeof text === 'string' ? text : undefined }, `${e.component}:${e.requestId}`)
121 return next(e)
122 })
123}
124types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'atc-probe': {}
4 }
5}
6