SLOPSHOPPER

atc-probe

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

newpanebandspinnerrowscommand
v0.2.0MITupdated 2026-10-15FunJim/claude-code-add-to-chat/e2e/probe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · atc-probe
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /probe-out ⎿ atc-probe: PROBE-OUT line one: command output ⎿ atc-probe: PROBE-OUT line two: more output ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

add-to-chat

CI License: MIT

English · 简体中文

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

Requirements

  • Claude Code 2.1.300 or later, with mods. Earlier versions do not load this version of the mod; on them, use 0.2.1.
  • Fullscreen mode: CLAUDE_CODE_NO_FLICKER=1. Outside it the terminal owns the mouse selection and no mod can read it (why).

Install

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.

Use

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.

  1. Select text in the transcript with the mouse. The pane's first row becomes [ ✚ Add to chat ] "…" ✕. Click the button, or run /add-to-chat; ✕ dismisses the offer.
  2. The annotation gets a tab (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.
  3. Repeat. Each annotated text gets a mark like ⁽¹⁾ 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.
  4. Send your prompt as usual. Your message shows as what you typed plus 📎 N annotations ↗; ctrl+o shows what the model received. With nothing to type, [ Send ] puts (see annotations) in the prompt for you to send.
  5. Look back: point at a sent prompt's 📎 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.

The Annotation Template

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

Limitations

  • Fullscreen only (see above).
  • Selecting the same text in the same row twice can't add it twice.
  • An excerpt is marked where it first occurs in its row; text in a code block or across table cells is marked at the start of the row instead.
  • A selection spanning several rows has the Source transcript: it can't be pinned to one row.

Documentation

Contributing

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

License

MIT

Source 2 files
hooks/register.ts 124 lines
1// 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}
124
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'atc-probe': {}
4  }
5}
6