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

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.

# heading puts a message in its topic's thread: colour-coded, readable on its own and easy to find later.Examples: docs/why.md.
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.
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 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 while it is on and Focus while it is off; pick the other to switch./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.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, 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.# 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.+ thread (desktop app); to move it to another, press ▾ beside its thread tag.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.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./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./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.hooks/register.tsx 467 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
467hooks/reactions.ts 100 lines1import 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}
100types/index.d.ts 47 lines1// 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