SLOPSHOPPER

react

Emoji reactions: the agent can acknowledge a message with one emoji, shown as a reaction on it instead of a reply

newrowscommandprompt
v0.3.0no licenseupdated 2026-10-10othrayte/claude-multitask/react
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · react
› 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 › /react ⎿ react: Reactions are off in this chat. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-multitask

Talk to Claude while it works, keep every topic in its own thread, and get a quick emoji when a reply would be overkill. Three Claude Code mods: multitask, thread-chat and react.

Demo: replies sorted into colour-coded threads, the thread view filtered to one thread, a background task running as a dot while the chat carries on, and an emoji reaction

Why

  • Talk while it works. With multitask you talk to one voice, and its thoughts (background agents) do the work. The voice is always free to talk.
  • No waiting, nothing held back. Ask something new, add a detail or change your mind whenever it occurs to you. The voice passes it to the right piece of work.
  • Questions come straight away. Your message isn't queued behind work in progress, so clarifying questions come at once and the right work starts sooner.
  • Topics stay apart. With thread-chat, a # heading puts a message in its topic's thread: colour-coded, readable on its own and easy to find later.
  • Emoji reactions. With react, the agent can acknowledge a message with an emoji reaction instead of a reply.

Examples: docs/why.md.

Install

Requires Claude Code 2.1.290 or later. Made for the Code tab in the Claude desktop app.

claude plugin marketplace add othrayte/claude-multitask
claude plugin install multitask@claude-multitask
claude plugin install thread-chat@claude-multitask
claude plugin install react@claude-multitask

Install any or all of them.

Use

In the band above the prompt, Multitask has a dropdown, and thread-chat a thread picker followed by its threads menu, a cog (⚙), with their switches for the chat. The mobile app shows only the thread picker: use the commands there. React has no control in the band: /react is its switch.

Multitask

  • /multitask turns Multitask on in a chat (run it again to turn it off). The chat becomes the voice and hands all work to thoughts.
  • Multitask's dropdown reads Multitask while it is on and Focus while it is off; pick the other to switch.
  • Left of the dropdown, a dot in its colour for each running thought pulses at each of its notes and fades out as it ends, as a check, or a cross if it failed. In the desktop app, hover a dot for the thought's name, latest note and its age.
  • /thoughts, or the thoughts button above the prompt, opens the Thoughts pane: what each thought is doing and how long since its last note, its report, tell to send it a message yourself, and stop. It lists the 16 most recently ended thoughts (N more shows the rest). The thoughts are per chat, and kept when the chat is restarted.
  • The voiceEffort setting (Voice effort, in /config) sets how hard the voice thinks: low (default), medium, high, or session to match the session. Thoughts keep the session's setting.

Threads

  • /threads, or the On in this chat switch in the threads menu (the cog), turns threads on in a chat (again to turn them off). The menu opens while the pointer is on the cog or it has the focus in the desktop app, and with a press on it in the terminal.
  • A # name heading in a message starts a thread, or replies to it if it exists. Close or abbreviated names find the existing thread. The agent replies under the same headings.
  • #+name starts a new thread even when an existing one is close.
  • To put a message in a thread, hover it for + thread (desktop app); to move it to another, press ▾ beside its thread tag.
  • The thread picker above the prompt, a dropdown named for the picked thread and tinted in its colour, picks the thread messages without a heading go to, or No thread. A heading still wins, and moves the picker to its thread.
  • New thread in the threads menu starts a thread by name and picks it; Manage threads there merges, archives or deletes threads.
  • Archived threads leave the pickers but keep their messages; Manage threads brings them back, as does a heading naming one. Deleting a thread takes its tag off its messages.
  • Filter the chat, in the threads menu while threads are on, is off at first, so the chat shows every message. On, with a thread picked, the chat shows only that thread's messages, and those since your last message that are in no thread. It is per chat, and kept when the chat is restarted.
  • /thread-view, or Open thread view in the threads menu, opens the thread view: a pane with a switch for each thread below its newest message. With none on it shows every message; with some on, only those threads' messages, and those since your last message that are in no thread, as Filter the chat does. The thread picker above the prompt and Filter the chat do not change it.
  • /merge-threads <thread> into <thread> also merges two threads.
  • Agent may start threads, in the threads menu while threads are on, lets the agent start threads itself. It is per chat, and kept when the chat is restarted.

Reactions

  • Reactions are on in a new chat. /react turns them off in that chat (again to turn them back on). The choice is per chat, and kept when the chat is restarted.
  • /react default switches whether reactions are on by default: in new chats, and in chats where you have not used /react. A chat's own /react choice wins over the default. The default applies to every chat and is kept across restarts.
  • While they are on, a reply of a single emoji shows as a reaction on your message, not as a reply. With thread-chat, reactions show in its thread view too.
  • On the desktop the reaction sits on your message's bottom-right corner. /react place under moves it under the message, where its time and copy buttons show; /react place corner moves it back. The choice applies to every chat.
Source 3 files
hooks/register.tsx 467 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, Register, RenderElement, TurnStepChunk, TurnStepResult } from 'claude-code'
3
4import type { Place, Reaction } from '../types'
5import {
6  BRIEF,
7  NO_RESPONSE,
8  OFF_NOTE,
9  couldBeEmoji,
10  isOneEmoji,
11  joinReactions,
12  markOf,
13  marksOf,
14  reactionFor,
15  reactionsHeld,
16  withReaction,
17} from './reactions'
18
19const isOn = atom({ plugin: 'react', key: 'isOn' } as const, false)
20const choice = atom({ plugin: 'react', key: 'choice' } as const, null)
21const isOnByDefault = atom({ plugin: 'react', key: 'isOnByDefault' } as const, true)
22const isBriefed = atom({ plugin: 'react', key: 'isBriefed' } as const, false)
23const reactions = atom({ plugin: 'react', key: 'reactions' } as const, [])
24const answering = atom({ plugin: 'react', key: 'answering' } as const, null)
25const place = atom({ plugin: 'react', key: 'place' } as const, 'corner')
26
27// The ways a person's own prompt arrives: typed in the terminal, the desktop
28// app (an SDK host) or Remote Control.
29const PERSON = new Set(['composer', 'sdk', 'bridge'])
30
31type Block = { type: string; [field: string]: unknown }
32
33function textsOf(content: readonly Block[]): string[] {
34  return content.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
35}
36
37// Reactions are on in a session as the person last chose there with /react, else as
38// the default (`/react default`), on until they choose. Off, the mod leaves the session
39// be: nothing beside the person's messages, replies go on as they come, and messages
40// and replies are the engine's drawing alone.
41async function flip($: EngineInterface): Promise<boolean> {
42  let now = false
43  await update($, isOn, was => (now = !was))
44  await update($, choice, () => now)
45  await keep($)
46  return now
47}
48
49// Sets the switch to the session's own choice, else the default.
50async function settle($: EngineInterface) {
51  const chosen = await read($, choice)
52  const now = chosen ?? (await read($, isOnByDefault))
53  await update($, isOn, () => now)
54}
55
56// Whether reactions are on in new sessions, and in those never switched with /react:
57// the person's choice for every session, kept under its own key; on until they choose.
58const DEFAULT_KEY = 'isOnByDefault'
59
60async function takeUpDefault($: EngineInterface) {
61  const chosen = await $.store.get(DEFAULT_KEY).catch(() => undefined)
62  if (typeof chosen === 'boolean') await update($, isOnByDefault, () => chosen)
63}
64
65// `/react default`: flips the default, which this session follows too unless the person
66// switched it with /react; says what the default now is.
67async function flipDefault($: EngineInterface): Promise<string> {
68  let now = true
69  await update($, isOnByDefault, was => (now = !was))
70  await $.store.set(DEFAULT_KEY, now).catch(() => undefined)
71  await settle($)
72  await keep($)
73  const word = now ? 'on' : 'off'
74  const said = `Reactions are now ${word} by default, in new chats and in chats where you have not used \`/react\`.`
75  const chosen = await read($, choice)
76  if (chosen === null || chosen === now) return said
77  return `${said} This chat keeps them ${chosen ? 'on' : 'off'}; \`/react\` turns them ${chosen ? 'off' : 'on'} here.`
78}
79
80// Where a session's switch and reactions are kept between runs of the app: whether it
81// is on, the person's own choice there (null: never switched, so it follows the
82// default), when it was kept, whether the model was told, its reactions, and, once it
83// has been on, the marks of its newest messages.
84const recordKey = (sessionId: string) => `session:${sessionId}`
85type Kept = { isOn: boolean; choice: boolean | null; at: number; isBriefed: boolean; reactions: Reaction[]; marks: string[] }
86
87// The person's choice a session kept. One kept before choices were (off was then the
88// default) chose only if it was on.
89const choiceOf = (record: Partial<Kept>): boolean | null =>
90  record.choice !== undefined ? record.choice : record.isOn === true ? true : null
91
92const MARKS_KEPT = 200
93
94// Keeps the switch and reactions for the session's next run. A session never on keeps
95// no marks: one rewound or forked from it starts off as it would anyway. Best effort: a
96// store or transcript that cannot be read or written keeps what it kept.
97async function keep($: EngineInterface) {
98  try {
99    const key = recordKey(await $.session.id())
100    const was = (await $.store.get(key)) as Partial<Kept> | undefined
101    const now = await read($, isOn)
102    const isMarked = now || (was?.marks?.length ?? 0) > 0
103    const marks = isMarked ? marksOf(await $.session.messages()).slice(-MARKS_KEPT) : []
104    await $.store.set(key, {
105      isOn: now,
106      choice: await read($, choice),
107      at: await $.clock.now(),
108      isBriefed: await read($, isBriefed),
109      reactions: await read($, reactions),
110      marks,
111    } satisfies Kept)
112  } catch {
113    // kept as it was
114  }
115}
116
117// What a session kept, and whether it is its own: for one rewound or forked, which starts
118// under a new id with copies of the messages it kept, that of the session it came from:
119// the kept one that knows the most of its messages, else, of those near that, the one
120// kept last (the one it was just in). None when no session is known.
121async function kept($: EngineInterface): Promise<{ record: Partial<Kept>; isOwn: boolean } | undefined> {
122  const own = (await $.store.get(recordKey(await $.session.id()))) as Partial<Kept> | undefined
123  if (own !== undefined) return { record: own, isOwn: true }
124  const mine = new Set(marksOf(await $.session.messages()))
125  if (mine.size === 0) return undefined
126  const scored: { record: Partial<Kept>; score: number }[] = []
127  for (const key of await $.store.keys()) {
128    if (!key.startsWith('session:')) continue
129    const record = ((await $.store.get(key)) ?? {}) as Partial<Kept>
130    const score = new Set((record.marks ?? []).filter(mark => mine.has(mark))).size
131    if (score > 0) scored.push({ record, score })
132  }
133  const best = Math.max(0, ...scored.map(s => s.score))
134  if (best < 3) return undefined
135  const near = scored.filter(s => s.score * 2 > best).sort((a, b) => (b.record.at ?? 0) - (a.record.at ?? 0))
136  return near[0] === undefined ? undefined : { record: near[0].record, isOwn: false }
137}
138
139// Takes up what the session, or the one it came from, kept: the person's choice (one
140// already made, by a reload, stays), the reactions on messages it still holds, and, its
141// own, whether the model was told.
142async function takeUp($: EngineInterface) {
143  const found = await kept($).catch(() => undefined)
144  if (found === undefined) return
145  const { record, isOwn } = found
146  const chosen = choiceOf(record)
147  if (chosen !== null) await update($, choice, was => was ?? chosen)
148  const theirs = record.reactions ?? []
149  const held = isOwn ? theirs : reactionsHeld(theirs, await $.session.messages().catch(() => []))
150  if (held.length > 0) await update($, reactions, mine => joinReactions(held, mine))
151  if (isOwn && record.isBriefed === true) await update($, isBriefed, () => true)
152}
153
154// Where the desktop draws reactions is the person's choice for every session, kept
155// under its own key; the corner until they choose. Best effort, as `keep` is.
156const PLACE_KEY = 'place'
157const PLACES: Record<Place, string> = {
158  corner: "on your message's bottom-right corner",
159  under: 'under your message, where its time and copy buttons show',
160}
161const isPlace = (value: unknown): value is Place => value === 'corner' || value === 'under'
162
163async function takeUpPlace($: EngineInterface) {
164  const chosen = await $.store.get(PLACE_KEY).catch(() => undefined)
165  if (isPlace(chosen)) await update($, place, () => chosen)
166}
167
168// `/react place`: says where the desktop draws reactions; `/react place corner|under`
169// moves them there, in every session from now on.
170async function choosePlace($: EngineInterface, asked: string): Promise<string> {
171  const how = 'Use `/react place corner` or `/react place under`.'
172  if (asked === '') return `On the desktop, reactions sit ${PLACES[await read($, place)]}. ${how}`
173  if (!isPlace(asked)) return `"${asked}" is not a place for reactions. ${how}`
174  await update($, place, () => asked)
175  await $.store.set(PLACE_KEY, asked).catch(() => undefined)
176  return `On the desktop, reactions now sit ${PLACES[asked]}.`
177}
178
179// What the model is told beside the person's message: how reactions work once they come
180// on (and again after a compaction), and once that they have gone off.
181async function noteFor($: EngineInterface): Promise<string | undefined> {
182  const now = await read($, isOn)
183  if (now === (await read($, isBriefed))) return undefined
184  await update($, isBriefed, () => now)
185  await keep($)
186  return now ? BRIEF : OFF_NOTE
187}
188
189// Puts `emoji` on the person's message the turn answers; whether there was one.
190async function react($: EngineInterface, emoji: string): Promise<boolean> {
191  const to = await read($, answering)
192  if (to === null) return false
193  await update($, reactions, list => withReaction(list, { id: to.id, mark: to.mark, emoji }))
194  return true
195}
196
197// The main conversation's reply as it streams, its text held back while it could still
198// be one emoji: then passed on as it came or, being one emoji on a person's message, as
199// NO_RESPONSE, which the desktop draws as nothing, the emoji put on the message. A reply
200// that calls a tool, or thinks after its text, is passed on as it came.
201async function* reacting(
202  $: EngineInterface,
203  stream: HookStream<TurnStepChunk, TurnStepResult>,
204): AsyncGenerator<TurnStepChunk, TurnStepResult> {
205  // Every piece since the reply's text began, in order (null once passed on), and that text.
206  let held: TurnStepChunk[] | null = []
207  let text = ''
208  let isReaction = false
209  const settle = async () => isOneEmoji(text) && (await react($, text.trim()))
210  for await (const chunk of stream) {
211    if (held === null || (held.length === 0 && chunk.kind !== 'text')) {
212      yield chunk
213      continue
214    }
215    if (chunk.kind === 'stop') {
216      isReaction = await settle()
217      yield* isReaction ? asNoResponse(held) : held
218      held = null
219      yield chunk
220      continue
221    }
222    held.push(chunk)
223    if (chunk.kind === 'engine') continue
224    if (chunk.kind === 'text') {
225      text += chunk.text
226      if (couldBeEmoji(text)) continue
227    }
228    yield* held
229    held = null
230  }
231  if (held !== null) {
232    isReaction = await settle()
233    yield* isReaction ? asNoResponse(held) : held
234  }
235  const result = await stream.result
236  return isReaction ? { ...result, answer: NO_RESPONSE } : result
237}
238
239// The pieces of a reply with its text as NO_RESPONSE, in the first piece of text.
240function asNoResponse(held: TurnStepChunk[]): TurnStepChunk[] {
241  const first = held.findIndex(chunk => chunk.kind === 'text')
242  return held.flatMap((chunk, i): TurnStepChunk[] => {
243    if (chunk.kind !== 'text') return [chunk]
244    return i === first ? [{ ...chunk, text: NO_RESPONSE }] : []
245  })
246}
247
248// A stored reply's blocks with its text as NO_RESPONSE, in its first text block.
249function asNoResponseContent(content: readonly Block[]): Block[] {
250  const first = content.findIndex(block => block.type === 'text')
251  return content.flatMap((block, i) => (block.type !== 'text' ? [block] : i === first ? [{ type: 'text', text: NO_RESPONSE }] : []))
252}
253
254// Props that give a Box something to show, or room, though it holds nothing.
255const SHAPED = /^(border|padding|margin|height|minHeight|width|minWidth|background)/
256
257// A drawing that shows nothing, as a plugin beneath draws a row it hides: none, a Box
258// drawn "none" that no hover reveals, or a Box or Text of only such with no shape of
259// its own. Anything else, the engine's own drawing among it, shows something.
260function isBlank(node: unknown): boolean {
261  if (node === null || node === undefined || node === false || node === '') return true
262  if (typeof node !== 'object') return false
263  const el = node as { type?: unknown; props?: Record<string, unknown>; hover?: Record<string, unknown>; children?: unknown[] }
264  if (el.type === 'Box' && el.props?.display === 'none') return el.hover?.display === undefined
265  if (el.type !== 'Box' && el.type !== 'Text') return false
266  if (Object.keys(el.props ?? {}).some(prop => SHAPED.test(prop))) return false
267  return (el.children ?? []).every(isBlank)
268}
269
270const BADGE_HEIGHT = 20
271const BADGE_WIDTH = 30
272
273// The SVG of a reaction: the emoji in a small faint pill, as thread-chat draws its
274// filter pills, light or dark with the app. A solid one (`isSolid`) first paints the
275// pill, its outline included, in the desktop's transcript colour, so what it floats over
276// (the bubble's corner) does not show through and it looks as it does on the bare page.
277// The desktop draws an Svg as an image, so it takes no colour from the page around it.
278function badgePicture(emoji: string, isSolid = false): string {
279  const escaped = emoji.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
280  const h = BADGE_HEIGHT
281  const w = BADGE_WIDTH
282  return [
283    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`,
284    '<style>.bg{fill:#ffffff14;stroke:#ffffff38}.base{fill:#151515}',
285    '@media (prefers-color-scheme: light){.bg{fill:#0000000a;stroke:#00000038}.base{fill:#fcfcfb}}</style>',
286    isSolid ? `<rect class="base" x="0.25" y="0.25" width="${w - 0.5}" height="${h - 0.5}" rx="${(h - 0.5) / 2}"/>` : '',
287    `<rect class="bg" x="0.75" y="0.75" width="${w - 1.5}" height="${h - 1.5}" rx="${(h - 1.5) / 2}"/>`,
288    `<text x="${w / 2}" y="${h / 2}" text-anchor="middle" dominant-baseline="central" font-size="12"`,
289    ` font-family="Segoe UI Emoji, Apple Color Emoji, Noto Color Emoji, sans-serif">${escaped}</text>`,
290    '</svg>',
291  ].join('')
292}
293
294// How far up from the bottom of the message's row the desktop's reaction floats, in
295// lines: the row ends with the room the desktop keeps under the bubble for the message's
296// time and copy buttons (shown on hover), so one line up sits on the bubble's corner.
297const RISE: Record<Place, number> = { corner: 1, under: 0 }
298
299// How far in from the right edge of the message's row the desktop's reaction floats, in
300// columns: off the bubble's right edge, so the pill sits on its corner, not flush with it.
301const INSET = 1
302
303// A message with its reaction. On the desktop, a small solid pill floating over the
304// message's row near its right edge, on the bubble's bottom-right corner or under it
305// (`where`): the desktop draws the bubble and the time and copy buttons under it as one
306// piece, so a pill drawn after it in the flow would sit below those buttons, away from
307// the bubble. It floats beside the message, never round it: the engine refuses a drawing
308// that puts position or size on a box holding its message. On the terminal, the emoji in
309// faint brackets on the line under the message. Elsewhere, a pill on the line above the
310// bubble, at the edge the person's messages keep to. Never beside the bubble in the flow:
311// the desktop sizes its bubble to a share of the row it is drawn in, and a row shared
312// with the badge shrinks to the message, so the bubble would narrow and its words wrap.
313function withBadge(
314  $: EngineInterface,
315  e: Parameters<EngineInterface['ui']['resolve']>[0],
316  message: RenderElement,
317  emoji: string,
318  where: Place,
319): RenderElement {
320  if (e.surface === 'desktop') {
321    const { Box, Svg } = $.ui.resolve(e)
322    return (
323      <Box flexDirection="column">
324        {message}
325        <Box position="absolute" right={INSET} bottom={RISE[where]}>
326          <Svg source={badgePicture(emoji, true)} alt={`Reaction: ${emoji}`} width={BADGE_WIDTH} height={BADGE_HEIGHT} />
327        </Box>
328      </Box>
329    )
330  }
331  if (e.surface === 'terminal') {
332    const { Box, Text } = $.ui.resolve(e)
333    return (
334      <Box flexDirection="column">
335        {message}
336        <Box flexDirection="row" marginLeft={2}>
337          <Text dimColor>(</Text>
338          <Text>{emoji}</Text>
339          <Text dimColor>)</Text>
340        </Box>
341      </Box>
342    )
343  }
344  const { Box, Svg } = $.ui.resolve(e)
345  const isOnRight = e.surface === 'mobile'
346  return (
347    <Box flexDirection="column">
348      <Box flexDirection="row" justifyContent={isOnRight ? 'flex-end' : 'flex-start'}>
349        <Svg source={badgePicture(emoji)} alt={`Reaction: ${emoji}`} width={BADGE_WIDTH} height={BADGE_HEIGHT} />
350      </Box>
351      {message}
352    </Box>
353  )
354}
355
356export const register: Register = on => {
357  on('session.start', async ($, e, next) => {
358    await $.command.register({
359      name: 'react',
360      description: 'Turn emoji reactions on or off in this chat',
361      argumentHint: '[default | place corner|under]',
362    })
363    // A restarted or resumed session takes up the choice and reactions it kept; a
364    // rewound or forked one, those of the session it came from. Never switched, it
365    // follows the default.
366    await takeUpDefault($)
367    await takeUp($)
368    await settle($)
369    await takeUpPlace($)
370    await keep($)
371    return next(e)
372  })
373
374  // `/react` flips reactions on or off in this chat; `/react default` flips the default;
375  // `/react place …` says or moves where the desktop draws them.
376  on('command.run', { command: 'react' }, async ($, e) => {
377    const [word = '', ...rest] = e.args.trim().split(/\s+/)
378    if (word.toLowerCase() === 'place') return { text: await choosePlace($, rest.join(' ').toLowerCase()) }
379    if (word.toLowerCase() === 'default') return { text: await flipDefault($) }
380    const now = await flip($)
381    return {
382      text: now
383        ? 'Reactions are on in this chat: the agent may acknowledge a message with an emoji reaction on it instead of a reply.'
384        : 'Reactions are off in this chat.',
385    }
386  })
387
388  // Beside the person's message the model is told when reactions came on or went off:
389  // that is the one way it is told. A system prompt section would not follow the switch,
390  // as the engine sends the prompt it recorded at the first request until a compaction.
391  on('prompt.submit', async ($, e, next) => {
392    if (!PERSON.has(e.origin.kind)) return next(e)
393    const note = await noteFor($)
394    // While on, the marks a session rewound or forked from this one is known by are kept fresh.
395    if (note === undefined && (await read($, isOn))) await keep($)
396    return note === undefined ? next(e) : next({ ...e, context: [...(e.context ?? []), note] })
397  })
398
399  // A compaction may summarise how reactions work away: while they are on, the model is
400  // told again beside the person's next message. (Gone off, it is still told so.)
401  on('session.compact', async ($, e, next) => {
402    const result = await next(e)
403    const isCompacted = e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined
404    if (isCompacted && (await read($, isOn)) && (await read($, isBriefed))) {
405      await update($, isBriefed, () => false)
406      await keep($)
407    }
408    return result
409  })
410
411  // The person's message a turn answers is noted as it is stored: one sent to a running
412  // turn too. A turn started by anything else (a notification, a command, a plugin's
413  // prompt) answers none of theirs. While on, a reply of one emoji that reached here as
414  // it came is stored as NO_RESPONSE all the same, and put on the message.
415  on('session.append', async ($, e, next) => {
416    if (e.agentId !== undefined) return next(e)
417    const isTurnStart = e.door === 'prompt' || e.door === 'delivery' || e.door === 'command'
418    if (e.message.type === 'user' && isTurnStart) {
419      if (PERSON.has(e.origin.kind) && e.door !== 'command') {
420        await update($, answering, () => ({ id: e.uuid, mark: markOf(textsOf(e.message.content)[0] ?? '') }))
421      } else if (e.door !== 'delivery') {
422        await update($, answering, () => null)
423      }
424    }
425    if (e.door !== 'response' || e.message.type !== 'assistant' || !(await read($, isOn))) return next(e)
426    const isText = e.message.content.every(block => block.type === 'text' || block.type === 'thinking' || block.type === 'redacted_thinking')
427    const text = textsOf(e.message.content).join('')
428    if (!isText || !isOneEmoji(text) || !(await react($, text.trim()))) return next(e)
429    return next({ ...e, message: { ...e.message, content: asNoResponseContent(e.message.content) } })
430  })
431
432  // While on, the main conversation's reply of one emoji goes on as NO_RESPONSE, the
433  // emoji put on the person's message it answers.
434  on('turn.step', async function* ($, e, next) {
435    if (e.agentId !== undefined || !(await read($, isOn))) return yield* next(e)
436    return yield* reacting($, next(e))
437  })
438
439  // The reactions are kept for the session's next run as each turn ends.
440  on('turn.complete', async ($, e, next) => {
441    const result = await next(e)
442    if (e.agentId === undefined && (await read($, isOn))) await keep($)
443    return result
444  })
445
446  // A reply that became a reaction draws as nothing, on every surface. Off, a reply is
447  // the engine's drawing alone.
448  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
449    if (e.props.text.trim() !== NO_RESPONSE || !(await read($, isOn))) return next(e)
450    const { Box } = $.ui.resolve(e)
451    return <Box display="none" />
452  })
453
454  // A message with a reaction is the engine's drawing (or another mod's), the reaction on
455  // it (on the desktop, its bubble's corner or under it, as the person chose; under it on
456  // the terminal). A row drawn as nothing stays nothing.
457  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
458    if (!(await read($, isOn))) return next(e)
459    const isPerson = PERSON.has(e.props.origin.kind)
460    const reaction = reactionFor(await read($, reactions), e.requestId, isPerson ? e.props.text : undefined)
461    if (reaction === undefined) return next(e)
462    const message = await next(e)
463    if (isBlank(message)) return message
464    return withBadge($, e, message, reaction.emoji, await read($, place))
465  })
466}
467
hooks/reactions.ts 100 lines
1import type { Reaction } from '../types'
2
3// What a reply that became a reaction goes on as, and is kept as: a reply the desktop
4// draws as nothing, which the model reads as having said nothing.
5export const NO_RESPONSE = 'No response requested.'
6
7// What the model is told beside the person's message once reactions come on (and again
8// after a compaction), and once after they go off.
9export const BRIEF = `# Emoji reactions
10When your whole reply would only acknowledge the user's message (such as "on it", "done", "got it" or "thanks"), you may reply with exactly one emoji and nothing else, for example 👍, ✅ or 🙏. It is shown as a reaction on the user's message instead of as a reply, and your history keeps that reply as "${NO_RESPONSE}". Any other reply, more than one emoji included, is shown as a normal reply. This holds for the rest of the conversation, until a note says the user turned reactions off.`
11
12export const OFF_NOTE =
13  'The user turned emoji reactions off. Acknowledge in words again: a reply of one emoji is now shown as a normal reply.'
14
15// One emoji: a flag, a keycap, or a pictograph with its presentation and skin tone
16// marks, joined to others by zero-width joiners, and a subdivision flag's tags.
17const PICTOGRAPH = '\\p{Extended_Pictographic}\\uFE0F?\\p{Emoji_Modifier}?'
18const ONE_EMOJI = new RegExp(
19  `^(?:\\p{Regional_Indicator}{2}|[#*0-9]\\uFE0F?\\u20E3|${PICTOGRAPH}(?:\\u200D${PICTOGRAPH})*[\\u{E0020}-\\u{E007F}]*)$`,
20  'u',
21)
22// What an emoji is made of, a lone surrogate included: a reply streamed so far that
23// holds nothing else may still turn out to be one.
24const EMOJI_PARTS =
25  /^[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Regional_Indicator}‍️⃣\u{E0020}-\u{E007F}#*0-9\uD800-\uDFFF]*$/u
26// The longest emoji held for, in UTF-16 units: a family or a subdivision flag is
27// under 20.
28const EMOJI_MAX = 32
29
30// Whether a reply's whole text is one emoji, spacing round it aside.
31export const isOneEmoji = (text: string) => ONE_EMOJI.test(text.trim())
32
33// Whether a reply streamed so far could still be one emoji once it has all come.
34export function couldBeEmoji(text: string): boolean {
35  const t = text.trim()
36  return t.length <= EMOJI_MAX && EMOJI_PARTS.test(t)
37}
38
39// A text's start with its spacing dropped: the same however the text's blocks were
40// joined, and with notes for the model after it.
41export const markOf = (text: string) => text.replace(/\s+/g, '').slice(0, 80)
42
43// A mark this short ("ok", "thanks") is said too often to know a message by.
44const MIN_MARK = 12
45
46// A row's id as the screen may know it: blocks of one stored row drawn apart take ids
47// that keep the row's first 24 characters.
48const stemOf = (id: string) => id.slice(0, 24)
49
50function newest(list: readonly Reaction[], is: (r: Reaction) => boolean): Reaction | undefined {
51  for (let i = list.length - 1; i >= 0; i--) {
52    const r = list[i] as Reaction
53    if (is(r)) return r
54  }
55  return undefined
56}
57
58// The reaction drawn on a message: the one kept for its row id; failing that, for a
59// person's message (`text` given), the newest kept for a message that starts the same,
60// where that start is long enough to tell messages apart.
61export function reactionFor(list: readonly Reaction[], id: string, text?: string): Reaction | undefined {
62  const byId = newest(list, r => r.id === id) ?? (id.length >= 24 ? newest(list, r => stemOf(r.id) === stemOf(id)) : undefined)
63  if (byId !== undefined || text === undefined) return byId
64  const mark = markOf(text)
65  return mark.length < MIN_MARK ? undefined : newest(list, r => r.mark === mark)
66}
67
68// How many reactions a session keeps: the newest.
69const REACTIONS_KEPT = 500
70
71// The reactions once `reaction` is on its message, in place of one it had.
72export function withReaction(list: readonly Reaction[], reaction: Reaction): Reaction[] {
73  return [...list.filter(r => r.id !== reaction.id), reaction].slice(-REACTIONS_KEPT)
74}
75
76// The reactions of both lists, `newer` winning for a message both have.
77export function joinReactions(older: readonly Reaction[], newer: readonly Reaction[]): Reaction[] {
78  return newer.reduce((all, r) => withReaction(all, r), [...older])
79}
80
81// The reactions on messages a conversation still holds: those of a session rewound or
82// forked from another, which may have left some of its messages behind.
83export function reactionsHeld(list: readonly Reaction[], messages: readonly { role: string; text: string }[]): Reaction[] {
84  const marks = messages.filter(m => m.role === 'user').map(m => markOf(m.text))
85  return list.filter(r => r.mark !== '' && marks.some(mark => mark.startsWith(r.mark)))
86}
87
88// Hashes of the starts of a conversation's messages, long enough to tell it apart:
89// what a session rewound or forked from this one is known by.
90export function marksOf(messages: readonly { text: string }[]): string[] {
91  return messages
92    .map(m => markOf(m.text))
93    .filter(start => start.length >= MIN_MARK)
94    .map(start => {
95      let hash = 0x811c9dc5
96      for (let i = 0; i < start.length; i++) hash = Math.imul(hash ^ start.charCodeAt(i), 0x01000193)
97      return (hash >>> 0).toString(16).padStart(8, '0')
98    })
99}
100
types/index.d.ts 47 lines
1// An emoji the agent answered a person's message with, shown on that message: the
2// message by its stored row id and the start of its text as typed.
3export type Reaction = {
4  id: string
5  // the text's start, whitespace dropped: how the message is known where the screen
6  // draws it under another id
7  mark: string
8  emoji: string
9}
10
11// The person's message the main conversation's current turn answers.
12export type Answering = { id: string; mark: string }
13
14// Where the desktop draws a reaction on the person's message: on the bubble's
15// bottom-right corner, or under it, where the desktop shows the message's time and copy.
16export type Place = 'corner' | 'under'
17
18// thread-chat's thread view reads `isOn` and `reactions` to draw the reactions on the
19// person's messages as the chat does (its types/react.d.ts): keep their keys and shape,
20// or change both mods together.
21declare module 'claude-code' {
22  interface PluginState {
23    react: {
24      // whether reactions are on in the session: as the person last chose there with
25      // /react, else as the default; off, the mod leaves the session be
26      isOn: boolean
27      // the person's own choice in the session with /react, kept over the default; null
28      // until they make one
29      choice: boolean | null
30      // whether reactions are on in a session with no choice of its own, as the person
31      // last set with /react default (kept for every session); on until they set it
32      isOnByDefault: boolean
33      // whether the model was last told, beside the person's message, that reactions
34      // are on; false again after a compaction while on, so it is told again
35      isBriefed: boolean
36      // the reactions so far, oldest first, one per message
37      reactions: Reaction[]
38      // the person's message the turn running now answers; null when the turn came
39      // from anything else (a notification, a plugin's prompt)
40      answering: Answering | null
41      // where the desktop draws a reaction, as the person last chose with /react place
42      // (kept for every session); the corner until they choose
43      place: Place
44    }
45  }
46}
47