SLOPSHOPPER

thread-chat

Conversation threads by # heading: colour-coded sections, a thread picker above the prompt, a pane to read the threads you pick, optional agent-created threads

newpanebandrowsguardcommand
v0.3.0no licenseupdated 2026-10-10othrayte/claude-multitask/thread-chat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thread-chat
│ ┃ Thread view ✕ › fix the failing auth test and add an audit log call │ ┃ fix the failing auth test │ ⏺ 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 │ │ › /threads │ ⎿ thread-chat: Threads are on: start or reply to one with a # name │ │ start a thread with # name [ ⚙ ▾ ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
start a thread with # name [ ⚙ ▾ ]
Pane · Thread view
fix the failing auth test
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 5 files
hooks/register.tsx 3018 lines
1import { atom, read, update } from 'claude-code'
2import type { BoxProps, Elements, EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
3
4import type { Brief, Thread, ViewImage, ViewRow } from '../types'
5import {
6  PALETTE,
7  closestThread,
8  headingStart,
9  headingsIn,
10  holdsQuote,
11  isOthersPrompt,
12  keyOf,
13  leadingQuoteOf,
14  nameOf,
15  replyQuotesOf,
16  rewriteHeadings,
17  splitRunOns,
18  splitSections,
19  withOpeningQuote,
20} from './parse'
21import type { RunOn, Section } from './parse'
22import {
23  FILTER_THREAD_KEY,
24  SECTIONS_MORE,
25  chunksOf,
26  dataFolderOf,
27  drawView,
28  drawViewFailure,
29  fingerprintOf,
30  grownOffsetOf,
31  hasToolCall,
32  imagePathsOf,
33  imageSizeOf,
34  imageTypeOf,
35  isDrawable,
36  isKeptView,
37  markHashOf,
38  markOf,
39  marksOf,
40  needsMessages,
41  noteDrawnReply,
42  nudgeOf,
43  rowsOfChunks,
44  rowsUpTo,
45  thumbNameOf,
46  toggledShown,
47  viewKey,
48  viewMoveOf,
49  viewPlaceOf,
50  viewRowOf,
51  withImagePaths,
52  withInterim,
53  withKeptPaths,
54  withViewRow,
55} from './pane'
56import type { Picture, ViewDeps, ViewGrowth, ViewInputs, ViewPlace, ViewSpan } from './pane'
57
58const isOn = atom({ plugin: 'thread-chat', key: 'isOn' } as const, false)
59const threads = atom({ plugin: 'thread-chat', key: 'threads' } as const, [])
60const aliases = atom({ plugin: 'thread-chat', key: 'aliases' } as const, {})
61// The menu's agent and filter switches, kept with the session's record for its next run;
62// a session that never flipped one starts with the agent kept out and the chat unfiltered.
63const agentMayCreate = atom({ plugin: 'thread-chat', key: 'agentMayCreate' } as const, false)
64const filtersChat = atom({ plugin: 'thread-chat', key: 'filtersChat' } as const, false)
65const snaps = atom({ plugin: 'thread-chat', key: 'snaps' } as const, {})
66const recent = atom({ plugin: 'thread-chat', key: 'recent' } as const, [])
67const recentTexts = atom({ plugin: 'thread-chat', key: 'recentTexts' } as const, [])
68const current = atom({ plugin: 'thread-chat', key: 'current' } as const, '')
69const deleted = atom({ plugin: 'thread-chat', key: 'deleted' } as const, [])
70const sentTo = atom({ plugin: 'thread-chat', key: 'sentTo' } as const, {})
71const moved = atom({ plugin: 'thread-chat', key: 'moved' } as const, {})
72const picking = atom({ plugin: 'thread-chat', key: 'picking' } as const, '')
73const naming = atom({ plugin: 'thread-chat', key: 'naming' } as const, false)
74const untold = atom({ plugin: 'thread-chat', key: 'untold' } as const, [])
75const briefed = atom({ plugin: 'thread-chat', key: 'briefed' } as const, null)
76const viewRows = atom({ plugin: 'thread-chat', key: 'viewRows' } as const, [])
77const picturesRead = atom({ plugin: 'thread-chat', key: 'picturesRead' } as const, 0)
78const viewFrom = atom({ plugin: 'thread-chat', key: 'viewFrom' } as const, null)
79const viewThreads = atom({ plugin: 'thread-chat', key: 'viewThreads' } as const, [])
80const menuOpen = atom({ plugin: 'thread-chat', key: 'menuOpen' } as const, false)
81const pickerOpen = atom({ plugin: 'thread-chat', key: 'pickerOpen' } as const, false)
82const menuMode = atom({ plugin: 'thread-chat', key: 'menuMode' } as const, '')
83// The react mod's switch and reactions, which the thread view draws as the chat does
84// (types/react.d.ts); never written without react, so off and none.
85const reactIsOn = atom({ plugin: 'react', key: 'isOn' } as const, false)
86const reactReactions = atom({ plugin: 'react', key: 'reactions' } as const, [])
87
88const CREATE_TOOL = 'mcp__thread-chat__create_thread'
89// Thread-chat's menu in the band: a "Threads" button, and a row per switch, which a
90// press flips: threads on in this chat, and, while on, whether the agent may start them
91// and whether the chat is filtered to the picked thread. On the desktop the rows are a
92// card shown while the pointer is on the button or it has the focus; on the terminal a
93// press on the button shows or hides them in the band.
94const MENU = 'threads'
95const THREADS_SWITCH = 'threads-on'
96const AGENT_SWITCH = 'agent-threads'
97const FILTER_SWITCH = 'filter-chat'
98// A switch's hit area over its picture, keyed by this and the switch's key.
99const SWITCH_HIT = 'switch:'
100// The thread view's pane id, which is also its command's name and its menu row's key.
101const VIEW = 'thread-view'
102const VIEW_TITLE = 'Thread view'
103
104// The ways a person's own prompt arrives: typed in the terminal, the desktop
105// app (an SDK host) or Remote Control.
106const PERSON = new Set(['composer', 'sdk', 'bridge'])
107
108function withThreads(list: Thread[], names: string[], createdBy: Thread['createdBy']): Thread[] {
109  const known = new Set(list.map(t => keyOf(t.name)))
110  const added = names
111    .filter(name => !known.has(keyOf(name)))
112    .map((name, i) => ({
113      name,
114      color: PALETTE[(list.length + i) % PALETTE.length] ?? '#888888',
115      createdBy,
116    }))
117  return added.length === 0 ? list : [...list, ...added]
118}
119
120// Starts the threads named that are new, and brings back those named that were
121// archived: a heading, the agent's create_thread or a typed name used them. A name of
122// a deleted thread starts it again, its key no longer deleted.
123async function addThreads($: EngineInterface, names: string[], createdBy: Thread['createdBy']) {
124  if (names.length === 0) return
125  const keys = new Set(names.map(keyOf))
126  await update($, threads, list =>
127    withThreads(list, names, createdBy).map(t => (t.isArchived === true && keys.has(keyOf(t.name)) ? withoutArchived(t) : t)),
128  )
129  if ((await read($, deleted)).some(key => keys.has(key))) await update($, deleted, all => all.filter(key => !keys.has(key)))
130}
131
132// A thread as this version keeps it: the fields it draws, and no others (an earlier
133// version's filter pick, `isSelected`, goes).
134function cleanThread(t: Partial<Thread> & { name: string }): Thread {
135  return {
136    name: t.name,
137    color: t.color ?? '#888888',
138    createdBy: t.createdBy ?? 'user',
139    ...(t.isArchived === true ? { isArchived: true } : {}),
140  }
141}
142
143const withoutArchived = (t: Thread): Thread => cleanThread({ ...t, isArchived: false })
144
145// The key of the one thread an earlier version's filter had picked alone, where messages
146// with no heading went: this version's picked thread. '' where none or several were.
147function pickedByFilter(list: readonly unknown[]): string {
148  const picked = list.filter(t => (t as { isSelected?: unknown }).isSelected === true)
149  const only = picked.length === 1 ? (picked[0] as { name?: unknown }) : undefined
150  return typeof only?.name === 'string' ? keyOf(only.name) : ''
151}
152
153// Where a deleted thread's key points in the links a heading is resolved by: a key no
154// thread has, so the heading means no thread, never another thread by a close name.
155const GONE = '\u0000deleted'
156
157// The links a heading is resolved by: the meanings decided at send, the merges, and each
158// deleted thread's key pointing to none.
159function linksFor(merged: Record<string, string>, gone: readonly string[], snapped: Record<string, string> = {}) {
160  return { ...snapped, ...merged, ...Object.fromEntries(gone.map(key => [key, GONE])) }
161}
162
163// Whether a name means a thread the person deleted: its own name, or one a merge or an
164// earlier send pointed to it, followed to a deleted thread's key.
165function isGone(name: string, list: Thread[], links: Record<string, string>): boolean {
166  let key = keyOf(name)
167  for (let hop = 0; hop < 8; hop++) {
168    if (key === GONE) return true
169    if (list.some(t => keyOf(t.name) === key)) return false
170    const next = links[key]
171    if (next === undefined) return false
172    key = next
173  }
174  return false
175}
176
177// Sections under a deleted thread's heading as sections of no thread: the heading line
178// goes with the thread, and the text stays.
179function withoutGone(sections: Section[], isGoneName: (name: string) => boolean): Section[] {
180  return sections.map(s => (s.thread !== null && isGoneName(s.thread) ? { thread: null, text: s.text } : s))
181}
182
183// Threads are on in a session from the person's /threads until they run it again. Off,
184// the mod leaves the session be: nothing beside the person's messages, whose `#`
185// headings are plain text, no tags or "+ thread" round the chat's rows, no band, no
186// tool, nothing kept on disk. Turned off, the session keeps its threads for coming on again.
187const OFF_TEXT = 'Threads are off in this chat; /threads turns them on.'
188
189// Whether a session kept by a version of this mod from before /threads used threads:
190// it had one (or a merge), or the person let the agent start them. It counts as on.
191async function wasInUse($: EngineInterface): Promise<boolean> {
192  const list = await read($, threads)
193  const merged = await read($, aliases)
194  return list.length > 0 || Object.keys(merged).length > 0 || (await read($, agentMayCreate))
195}
196
197const CREATE_SPEC = {
198  name: 'create_thread',
199  description:
200    'Start a new conversation thread the user can follow and read on its own. Then write to it under a level-1 heading with its exact name. Refused unless the user has enabled agent-created threads.',
201  inputSchema: {
202    type: 'object',
203    properties: { name: { type: 'string', description: 'Short thread name, 1-4 words' } },
204    required: ['name'],
205  },
206}
207
208// create_thread is the model's once threads are on and the person lets the agent start
209// them: a session that never does has no tool of this mod's. Registered again, it is replaced.
210async function offerCreateTool($: EngineInterface) {
211  try {
212    await $.tool.register(CREATE_SPEC)
213  } catch {
214    // the switch stands; the model is told it may, and the next flip tries again
215  }
216}
217
218// Flips whether the agent may start threads, offering it the tool as it comes on, and
219// keeps the switch for the session's next run.
220async function flipAgentSwitch($: EngineInterface) {
221  let now = false
222  await update($, agentMayCreate, may => (now = !may))
223  if (now) await offerCreateTool($)
224  await save($)
225}
226
227// Flips whether the chat is filtered to the picked thread, and keeps it for the
228// session's next run.
229async function flipFilter($: EngineInterface) {
230  await update($, filtersChat, is => !is)
231  await save($)
232}
233
234// Turns threads on or off, as /threads does, and keeps the switch for the session's
235// next run. Coming on where the agent may start threads, it is offered its tool.
236async function flipThreads($: EngineInterface): Promise<boolean> {
237  let now = false
238  await update($, isOn, was => (now = !was))
239  if (now && (await read($, agentMayCreate))) await offerCreateTool($)
240  await save($)
241  return now
242}
243
244// Flips a switch of the band's menu. The agent's switch or the filter's while threads
245// are off leaves things be.
246async function flipMenuSwitch($: EngineInterface, key: string) {
247  if (key === THREADS_SWITCH) await flipThreads($)
248  else if (key === AGENT_SWITCH && (await read($, isOn))) await flipAgentSwitch($)
249  else if (key === FILTER_SWITCH && (await read($, isOn))) await flipFilter($)
250}
251
252// What a heading means, by one rule for sending and drawing alike: `+name` a new
253// thread by that name; else the thread by that name, or the one a merge or an
254// earlier send recorded for it; else the closest thread (a typo, another
255// spelling, the start of a name); else a new thread by that name.
256function resolveHeading(raw: string, list: Thread[], links: Record<string, string>): string {
257  const name = nameOf(raw)
258  if (raw.startsWith('+')) return name
259  const known = threadOf(name, list, links)
260  if (known !== undefined) return known.name
261  const hit = closestThread(name, [...list.map(t => keyOf(t.name)), ...Object.keys(links)])
262  return (hit === undefined ? undefined : threadOf(hit, list, links)?.name) ?? name
263}
264
265// Where a heading was typed with its section's first line on the same line: the
266// thread name its text starts with (a thread's, or a merged thread's old one; the
267// longest, as whole words) and the rest of the line. Never for `+name`, a heading
268// that is a thread's or a merged thread's name, or one decided at an earlier send;
269// nor where the whole heading is another thread's name abbreviated or mistyped.
270// Decided by the threads as they stand, at send and at drawing alike: it is not
271// recorded, as a recorded heading would count as a name abbreviations must tell apart.
272function runOnOf(
273  typed: string,
274  list: Thread[],
275  links: Record<string, string>,
276  merged: Record<string, string>,
277): RunOn | undefined {
278  if (typed.startsWith('+')) return undefined
279  const key = keyOf(typed)
280  if (list.some(t => keyOf(t.name) === key) || links[key] !== undefined) return undefined
281  const start = headingStart(typed, [...list.map(t => t.name), ...Object.keys(merged)])
282  if (start === undefined) return undefined
283  const hit = closestThread(typed, [...list.map(t => keyOf(t.name)), ...Object.keys(links)])
284  const other = hit === undefined ? undefined : threadOf(hit, list, links)
285  if (other !== undefined && other !== threadOf(start.name, list, links)) return undefined
286  return start
287}
288
289// A person's message in sections, each heading typed with its first line on the
290// same line split from it first. A section under a deleted thread's heading is of no
291// thread, its heading gone. A quoted selection that opens it, right before its first
292// thread heading, goes in that heading's section, whichever thread it came from.
293function personSectionsOf(
294  text: string,
295  list: Thread[],
296  snapped: Record<string, string>,
297  merged: Record<string, string>,
298  gone: readonly string[] = [],
299): Section[] {
300  const links = linksFor(merged, gone, snapped)
301  const sections = splitSections(splitRunOns(text, typed => runOnOf(typed, list, links, merged)), () => true)
302  const kept = withoutGone(sections, name => isGone(resolveHeading(name, list, links), list, links))
303  return withOpeningQuote(kept, name => personThreadOf(name, list, links) !== undefined)
304}
305
306// A reply in sections: a level-1 heading naming a thread starts its section, and one
307// naming no thread is the reply's own text. A section under a deleted thread's heading
308// is of no thread, its heading gone.
309function replySectionsOf(text: string, list: Thread[], merged: Record<string, string>, gone: readonly string[] = []): Section[] {
310  const links = linksFor(merged, gone)
311  const sections = splitSections(text, name => threadOf(name, list, links) !== undefined || isGone(name, list, links))
312  return withoutGone(sections, name => isGone(name, list, links))
313}
314
315type Meaning = { typed: string; meant: string; isRunOn?: boolean }
316
317// What the headings of a prompt mean: the thread names, in order, the name its last
318// heading means, and each heading not written as its thread's exact name (a typo, a
319// start of a name, `+name`, a name with the first line on its line) as typed with the
320// thread it means. A heading may also mean a thread introduced earlier in the same prompt.
321function decideHeadings(text: string, list: Thread[], links: Record<string, string>, merged: Record<string, string>) {
322  let known = list
323  const meanings: Meaning[] = []
324  const meant: string[] = []
325  // A heading with its first line on its line becomes its thread's heading by its
326  // exact name, so the headings after are read as they would be on their own.
327  const split = splitRunOns(text, typed => {
328    const runOn = runOnOf(typed, list, links, merged)
329    const thread = runOn === undefined ? undefined : threadOf(runOn.name, list, links)
330    if (runOn === undefined || thread === undefined) return undefined
331    meanings.push({ typed, meant: thread.name, isRunOn: true })
332    return { name: thread.name, rest: runOn.rest }
333  })
334  const resolved = rewriteHeadings(split, raw => {
335    const name = resolveHeading(raw, known, links)
336    if (raw.startsWith('+') || keyOf(nameOf(raw)) !== keyOf(name)) meanings.push({ typed: raw, meant: name })
337    known = withThreads(known, [name], 'user')
338    meant.push(name)
339    return name
340  })
341  return { names: headingsIn(resolved), last: meant.at(-1), meanings }
342}
343
344// The thread a name means: the thread by that name, else the one a merge or a
345// send-time decision points it to, followed through later merges.
346function threadOf(name: string, list: Thread[], links: Record<string, string>): Thread | undefined {
347  let key = keyOf(name)
348  for (let hop = 0; hop < 8; hop++) {
349    const exact = list.find(t => keyOf(t.name) === key)
350    if (exact !== undefined) return exact
351    const next = links[key]
352    if (next === undefined) return undefined
353    key = next
354  }
355  return undefined
356}
357
358// The thread a heading in the person's message means, by the rule its send used.
359function personThreadOf(name: string, list: Thread[], links: Record<string, string>): Thread | undefined {
360  return threadOf(resolveHeading(name, list, links), list, links)
361}
362
363// Where a session's threads are kept between runs of the app.
364const storeKey = (sessionId: string) => `session:${sessionId}`
365
366type Saved = {
367  // absent from a record kept before /threads
368  isOn: boolean
369  // the menu's agent and filter switches; absent from a record kept before they were kept
370  agentMayCreate?: boolean
371  filtersChat?: boolean
372  threads: Thread[]
373  aliases: Record<string, string>
374  snaps: Record<string, string>
375  // absent from a record kept before the filter switch was
376  recent?: string[]
377  recentTexts?: string[]
378  // absent from a record kept before the thread picker, whose threads may carry the
379  // filter's `isSelected`
380  current: string
381  deleted: string[]
382  sentTo: Record<string, string>
383  moved: Record<string, string>
384  untold: string[]
385  briefed: Brief | null
386  // absent from a record kept before the thread view's own filter
387  viewThreads: string[]
388}
389
390// Keeps the session's switches, threads, merges, recorded meanings, latest rows, picked
391// thread and the thread view's filter for its next run.
392async function save($: EngineInterface) {
393  const saved: Saved = {
394    isOn: await read($, isOn),
395    agentMayCreate: await read($, agentMayCreate),
396    filtersChat: await read($, filtersChat),
397    threads: await read($, threads),
398    aliases: await read($, aliases),
399    snaps: await read($, snaps),
400    recent: await read($, recent),
401    recentTexts: await read($, recentTexts),
402    current: await read($, current),
403    deleted: await read($, deleted),
404    sentTo: await read($, sentTo),
405    moved: await read($, moved),
406    untold: await read($, untold),
407    briefed: await read($, briefed),
408    viewThreads: await read($, viewThreads),
409  }
410  await $.store.set(storeKey(await $.session.id()), saved)
411}
412
413// A mark too short to tell sessions apart ("ok", "(quiet)") does not count.
414const MIN_PARENT_MARK = 12
415
416// The session this one was rewound or forked from. Claude Code gives such a session a
417// new id and copies the rows it keeps from the other, so it is found as the saved
418// session whose thread view knows the most of this one's messages, by a clear margin.
419// Its rows' marks are read as hashes: from the file kept beside its rows, or from
420// the rows an earlier version kept in the store.
421async function parentOf($: EngineInterface, id: string): Promise<string | undefined> {
422  const mine = new Set(
423    (await $.session.messages())
424      .map(m => markOf(m.text))
425      .filter(mark => mark.length >= MIN_PARENT_MARK)
426      .map(markHashOf),
427  )
428  if (mine.size === 0) return undefined
429  const scores: { id: string; score: number }[] = []
430  for (const key of await $.store.keys()) {
431    if (!key.startsWith('view:') || key === viewKey(id)) continue
432    const other = key.slice('view:'.length)
433    const kept = await $.store.get(key)
434    let marks: string[] = []
435    if (Array.isArray(kept)) marks = (kept as ViewRow[]).map(row => markHashOf(row.mark))
436    else if (isKeptView(kept)) {
437      const folder = await rowsFolder($, other).catch(() => undefined)
438      const text = folder === undefined ? undefined : await $.fs.read(`${folder}/${MARKS_FILE}`).catch(() => undefined)
439      marks = text === undefined ? [] : text.split('\n')
440    }
441    const known = new Set(marks.filter(mark => mine.has(mark)))
442    if (known.size > 0) scores.push({ id: other, score: known.size })
443  }
444  scores.sort((a, b) => b.score - a.score)
445  const [best, next] = scores
446  if (best === undefined || best.score < 3 || (next !== undefined && next.score * 2 > best.score)) return undefined
447  return best.id
448}
449
450// Joins a saved record to what this run holds, so neither loses anything: the threads
451// either knows (this run's own kept as they are), the merges, deletions and recorded
452// meanings of both (this run's winning), and this run's picked thread and the thread
453// view's filter when it has them.
454// A thread this run holds is not merged away or deleted by an older record. A record
455// kept before the thread picker gives the one thread its filter picked alone.
456async function takeUp($: EngineInterface, saved: Partial<Saved>) {
457  const held = new Set((await read($, threads)).map(t => keyOf(t.name)))
458  const older = Object.fromEntries(Object.entries(saved.aliases ?? {}).filter(([key]) => !held.has(key)))
459  const links = { ...older, ...(await read($, aliases)) }
460  const gone = new Set([...(saved.deleted ?? []).filter(key => !held.has(key)), ...(await read($, deleted))])
461  await update($, aliases, () => links)
462  await update($, deleted, () => [...gone])
463  await update($, threads, list => {
464    const mine = new Map(list.map(t => [keyOf(t.name), t]))
465    const savedKeys = new Set((saved.threads ?? []).map(t => keyOf(t.name)))
466    const ordered = [
467      ...(saved.threads ?? []).map(t => mine.get(keyOf(t.name)) ?? cleanThread(t)),
468      ...list.filter(t => !savedKeys.has(keyOf(t.name))),
469    ]
470    return ordered.filter(t => links[keyOf(t.name)] === undefined && !gone.has(keyOf(t.name)))
471  })
472  await update($, snaps, all => ({ ...(saved.snaps ?? {}), ...all }))
473  await update($, sentTo, all => ({ ...(saved.sentTo ?? {}), ...all }))
474  await update($, moved, all => ({ ...(saved.moved ?? {}), ...all }))
475  await update($, recent, all => (all.length > 0 ? all : (saved.recent ?? [])))
476  await update($, recentTexts, all => (all.length > 0 ? all : (saved.recentTexts ?? [])))
477  await update($, current, key => key || (saved.current ?? pickedByFilter(saved.threads ?? [])))
478  await update($, untold, all => [...(saved.untold ?? []).filter(note => !all.includes(note)), ...all])
479  await update($, briefed, brief => brief ?? saved.briefed ?? null)
480  await update($, viewThreads, keys => (keys.length > 0 ? keys : (saved.viewThreads ?? [])))
481}
482
483// A row's id as the screen knows it: blocks of one stored row drawn apart take
484// ids that keep the row's first 24 characters.
485const stemOf = (id: string) => id.slice(0, 24)
486
487// Whether a drawn row (by any of its ids) came since the person's last message.
488async function isRecent($: EngineInterface, ids: string[]): Promise<boolean> {
489  const stems = new Set((await read($, recent)).map(stemOf))
490  return ids.some(id => stems.has(stemOf(id)))
491}
492
493// A reply text's start, as the mark it is matched by: spacing evened out.
494const textMarkOf = (text: string) => text.replace(/\s+/g, ' ').trim().slice(0, 160)
495
496// Whether a drawn reply came since the person's last message: by its id, or by its
497// text where the screen knows it by an id of its own.
498async function isRecentReply($: EngineInterface, id: string, text: string): Promise<boolean> {
499  if (await isRecent($, [id])) return true
500  return (await read($, recentTexts)).includes(textMarkOf(text))
501}
502
503// The marks of a stored reply's text blocks, each as a screen may draw it alone.
504function textMarksOf(content: readonly { type: string; [field: string]: unknown }[]): string[] {
505  return content.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [textMarkOf(block.text)] : []))
506}
507
508// The ids of the tool calls a stored row makes; each draws as a row of its own.
509function toolCallIds(content: readonly { type: string; [field: string]: unknown }[]): string[] {
510  return content.flatMap(block => (block.type === 'tool_use' && typeof block.id === 'string' ? [block.id] : []))
511}
512
513// The keys of the threads the chat is filtered to: the picked thread while the menu's
514// filter is on, else none, and the chat shows every row. Read while a row draws, so a
515// flip or a new pick draws it again.
516async function chatFilterKeys($: EngineInterface): Promise<Set<string>> {
517  if (!(await read($, filtersChat))) return new Set()
518  const cur = await read($, current)
519  return cur === '' ? new Set() : new Set([cur])
520}
521
522// Whether a row that belongs to no thread is filtered out: while threads are on and the
523// chat is filtered to a thread, all but those since the person's last message are.
524async function isFilteredOut($: EngineInterface, ids: string[]): Promise<boolean> {
525  if (!(await read($, isOn)) || (await chatFilterKeys($)).size === 0) return false
526  return !(await isRecent($, ids))
527}
528
529// Whether a section stays in view: all of them while the chat is not filtered, else the
530// filtered thread's sections, and unthreaded ones in rows since the person's last message.
531function isShown(
532  s: Section,
533  selected: Set<string>,
534  resolve: (name: string) => Thread | undefined,
535  isRecentRow: boolean,
536): boolean {
537  if (selected.size === 0) return true
538  const thread = s.thread === null ? undefined : resolve(s.thread)
539  if (thread === undefined) return isRecentRow
540  return selected.has(keyOf(thread.name))
541}
542
543// Brings the chat's end into view: the latest row the screen knows by its id.
544// Rows that draw nothing of their own (a tool's result, a reminder) are passed over;
545// where nothing scrolls (the desktop scrolls its chat for no plugin), the chat stays put.
546async function scrollToEnd($: EngineInterface) {
547  const ids = (await read($, recent)).slice(-20).reverse()
548  try {
549    for (const id of ids) {
550      const scrolled = await $.ui.scroll({ to: { requestId: id }, block: 'end' })
551      if (scrolled.deny === undefined || /person|not scrollable/.test(scrolled.deny)) return
552    }
553  } catch {
554    return
555  }
556}
557
558// Messages sent with no heading to one thread (the one picked, or the one a reply's
559// quotes came from), until their rows are stored.
560const unsent: { text: string; key: string }[] = []
561
562// The ids of the main conversation's replies with text in the turn so far, since its
563// last tool call: interim if another tool call follows them in the turn.
564let turnReplies = new Set<string>()
565
566// The text blocks of a stored row, as one text.
567function textOf(content: readonly { type: string; [field: string]: unknown }[]): string {
568  return content.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])).join('\n')
569}
570
571// The thread a person's message went to without a heading, by its row's id.
572async function sentThreadOf(
573  $: EngineInterface,
574  id: string,
575  list: Thread[],
576  links: Record<string, string>,
577): Promise<Thread | undefined> {
578  const all = await read($, sentTo)
579  const key = all[id] ?? Object.entries(all).find(([row]) => stemOf(row) === stemOf(id))?.[1]
580  return key === undefined ? undefined : threadOf(key, list, links)
581}
582
583// Where a quote came from: the place ('' no thread) of the newest reply's part that
584// holds it, read as the reply draws (`replies` newest first); undefined where no reply
585// holds it, or the newest that does holds it in parts of different places, or across them.
586function quotedPlaceOf(
587  replies: string[],
588  quote: string,
589  sectionsOf: (text: string) => Section[],
590  resolve: (name: string) => Thread | undefined,
591): string | undefined {
592  for (const reply of replies) {
593    if (!holdsQuote(reply, quote)) continue
594    const places = new Set(
595      sectionsOf(reply)
596        .filter(s => holdsQuote(s.text, quote))
597        .map(s => (s.thread === null ? '' : keyOf(resolve(s.thread)?.name ?? s.thread))),
598    )
599    return places.size === 1 ? [...places][0] : undefined
600  }
601  return undefined
602}
603
604// The thread a message replying to parts of earlier replies belongs to: the one every
605// quote that is found came from. Quotes from different threads, or from text of no
606// thread, give none: each quote carries the person's own words on it, so such a
607// message speaks to several places, and filing it under one would hide it from the rest.
608async function quotedThreadOf($: EngineInterface, quotes: string[], list: Thread[]): Promise<Thread | undefined> {
609  if (quotes.length === 0) return undefined
610  let rows: { role: string; text: string }[]
611  try {
612    rows = await $.session.messages()
613  } catch {
614    return undefined
615  }
616  const merged = await read($, aliases)
617  const gone = await read($, deleted)
618  const links = linksFor(merged, gone)
619  const resolve = (name: string) => threadOf(name, list, links)
620  const sectionsOf = (text: string) => replySectionsOf(text, list, merged, gone)
621  const replies = rows.filter(r => r.role === 'assistant' && r.text !== '').map(r => r.text).reverse()
622  const places = new Set(quotes.flatMap(q => quotedPlaceOf(replies, q, sectionsOf, resolve) ?? []))
623  const only = places.size === 1 ? [...places][0] : undefined
624  return only === undefined || only === '' ? undefined : threadOf(only, list, links)
625}
626
627// The decisions in `meanings` as links from a typed heading's key to its thread's.
628// A heading with its first line on its line is decided again where it is drawn.
629function linksOf(meanings: Meaning[]): Record<string, string> {
630  return Object.fromEntries(
631    meanings
632      .filter(m => m.isRunOn !== true)
633      .map(m => [keyOf(nameOf(m.typed)), keyOf(m.meant)])
634      .filter(([typed, meant]) => typed !== meant),
635  )
636}
637
638// A typed line quoted to the model, cut short where long: the model has it whole.
639const clip = (text: string) => (text.length > 48 ? `${text.slice(0, 48).trimEnd()}…` : text)
640
641// What a heading in the person's message means, as the model is told it.
642function meaningNote(m: Meaning): string {
643  if (m.isRunOn === true) return `"#${clip(m.typed)}" is the thread "${m.meant}", its first line typed on the heading's line`
644  if (m.typed.startsWith('+')) return `"#${m.typed}" starts the new thread "${m.meant}"`
645  return `"#${m.typed}" is the thread "${m.meant}"`
646}
647
648// How threads work, and where they stand. It goes beside the person's message: the
649// system prompt cannot carry it, as the engine sends the prompt it recorded at the
650// first request until a compaction, and on a machine with managed settings a user
651// plugin's prompt.compose hook is skipped.
652function protocol(list: Thread[], merged: Record<string, string>, mayCreate: boolean): string {
653  return [
654    '# Conversation threads',
655    'This conversation can hold several threads at once. The user starts or replies to a thread with a level-1 heading in their message: `# name` or `#name` (with or without the space). Text before the first heading is the unthreaded conversation, except a quote of part of your message (a `<!-- reply -->` line and its `>` lines) right before a heading, which belongs to that heading\'s thread. A heading may be abbreviated or mistyped, or written `#+name` to start a new thread; a note beside the message then says which thread each heading means.',
656    'Reply the same way, under whichever thread or threads your reply\'s content belongs to: each part under a level-1 heading carrying that thread\'s exact name, one section per thread. The thread the user wrote in, or picked to post to, is usually the right one. A message with no heading may just be missing one: if it clearly continues an existing thread, reply under that thread\'s heading. Keep only material that fits no thread unthreaded, before any thread heading. Use `##` or deeper for structure inside a section: a level-1 heading that is not a thread name drops back into the unthreaded conversation.',
657    'This holds for the rest of the conversation; a note beside a later message says when threads are merged, archived or deleted, or your leave to start them changes.',
658    threadState(list, merged, mayCreate),
659  ].join('\n\n')
660}
661
662// Where the threads stand: those so far, those archived, the merges into a thread that
663// still stands, and whether the model may start one.
664function threadState(list: Thread[], merged: Record<string, string>, mayCreate: boolean): string {
665  const active = list.filter(t => t.isArchived !== true)
666  const archived = list.filter(t => t.isArchived === true)
667  const names = active.length === 0 ? 'none yet' : active.map(t => `"${t.name}"`).join(', ')
668  const mergedNames = Object.entries(merged)
669    .filter(([from]) => threadOf(from, list, merged) !== undefined)
670    .map(([from, to]) => `"${from}" is now "${to}"`)
671  return [
672    `Threads so far: ${names}.`,
673    ...(archived.length === 0
674      ? []
675      : [
676          `Archived threads (hidden from the user's thread picker; write in one only if the user brings it up): ${archived.map(t => `"${t.name}"`).join(', ')}.`,
677        ]),
678    ...(mergedNames.length === 0 ? [] : [`Merged threads (an old heading means the new one): ${mergedNames.join('; ')}.`]),
679    mayCreate
680      ? `You may start a new thread with ${CREATE_TOOL} when a distinct topic deserves its own thread, then write to it under its heading. Do it sparingly.`
681      : 'Do not start new threads yourself; the user has not enabled that.',
682  ].join(' ')
683}
684
685const mergesKey = (merged: Record<string, string>) => JSON.stringify(Object.entries(merged).sort())
686
687// What the model is told of the threads beside the person's message while they are on:
688// how they work, once they come on (and again after a compaction), then where they
689// stand whenever a merge or the agent switch changed it. A thread it sees made (by a
690// heading, its own create_thread, a move it is told of) needs no telling.
691async function briefing($: EngineInterface): Promise<string[]> {
692  const list = await read($, threads)
693  const merged = await read($, aliases)
694  const mayCreate = await read($, agentMayCreate)
695  const was = await read($, briefed)
696  if (was !== null && mergesKey(was.merged) === mergesKey(merged) && was.mayCreate === mayCreate) return []
697  await update($, briefed, () => ({ merged, mayCreate }))
698  await save($)
699  return [was === null ? protocol(list, merged, mayCreate) : `The threads changed. ${threadState(list, merged, mayCreate)}`]
700}
701
702// Told beside the person's first message once threads go off, if the model was told how
703// they work: it is told again, whole, should they come on again.
704async function offNote($: EngineInterface): Promise<string | undefined> {
705  if ((await read($, briefed)) === null) return undefined
706  await update($, briefed, () => null)
707  await save($)
708  return 'The user turned conversation threads off. Reply without thread headings; a level-1 heading in their messages is plain text now.'
709}
710
711// The SVG of a slide switch: a rounded track with the knob to the right when on.
712function switchPicture(isOn: boolean): string {
713  const track = isOn ? '#3b82f6' : '#8c8c8c'
714  const knob = isOn ? 23 : 11
715  return `<svg xmlns="http://www.w3.org/2000/svg" width="34" height="20" viewBox="0 0 34 20"><rect x="1" y="2" width="32" height="16" rx="8" fill="${track}"/><circle cx="${knob}" cy="10" r="6" fill="#ffffff"/></svg>`
716}
717
718// The desktop's Select caret, a chevron 14px square in the secondary text colour of
719// the theme, after a 6px gap: the picture is the gap and the caret.
720const CARET_GAP = 6
721const CARET_SIZE = 14
722const CARET_PICTURE = [
723  `<svg xmlns="http://www.w3.org/2000/svg" width="${CARET_GAP + CARET_SIZE}" height="${CARET_SIZE}"`,
724  ` viewBox="${-CARET_GAP * (16 / CARET_SIZE)} 0 ${(CARET_GAP + CARET_SIZE) * (16 / CARET_SIZE)} 16">`,
725  '<style>.c{stroke:#c3c2b7}@media (prefers-color-scheme: light){.c{stroke:#52514e}}</style>',
726  '<path class="c" d="M4 6l4 4 4-4" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
727  '</svg>',
728].join('')
729
730// The menu button's settings cog, 14px square in the theme's text colour: the desktop
731// draws an Svg as an image, so it takes no colour from the text around it.
732const COG_PICTURE = [
733  `<svg xmlns="http://www.w3.org/2000/svg" width="${CARET_SIZE}" height="${CARET_SIZE}" viewBox="0 0 16 16">`,
734  '<style>.g{fill:#faf9f5}@media (prefers-color-scheme: light){.g{fill:#141413}}</style>',
735  '<path class="g" fill-rule="evenodd" d="M6.81 2.84L6.86 0.79L9.14 0.79L9.19 2.84A5.3 5.3 0 0 1 10.81 3.51L12.29 2.09',
736  'L13.91 3.71L12.49 5.19A5.3 5.3 0 0 1 13.16 6.81L15.21 6.86L15.21 9.14L13.16 9.19A5.3 5.3 0 0 1 12.49 10.81L13.91 12.29',
737  'L12.29 13.91L10.81 12.49A5.3 5.3 0 0 1 9.19 13.16L9.14 15.21L6.86 15.21L6.81 13.16A5.3 5.3 0 0 1 5.19 12.49L3.71 13.91',
738  'L2.09 12.29L3.51 10.81A5.3 5.3 0 0 1 2.84 9.19L0.79 9.14L0.79 6.86L2.84 6.81A5.3 5.3 0 0 1 3.51 5.19L2.09 3.71L3.71 2.09',
739  'L5.19 3.51A5.3 5.3 0 0 1 6.81 2.84ZM5.7 8a2.3 2.3 0 1 0 4.6 0a2.3 2.3 0 1 0 -4.6 0Z"/>',
740  '</svg>',
741].join('')
742
743const CHIP_HEIGHT = 20
744
745const escapeXml = (text: string) =>
746  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
747
748
749// Thread names draw as tags: small semibold capitals, a hair apart.
750const PILL_TAG_SIZE = 10.5
751const CHAT_TAG_SIZE = 9
752// On the desktop the engine draws reply text as prose, whose paragraphs sit
753// `--cds-assistant-message-text-inset` (4px) in from the left; reply parts drawn
754// as Markdown have no such inset.
755const PROSE_TEXT_INSET = 4
756// Px a reply part's tag moves right to line up with the engine's text: that inset,
757// and 1px more seen on screen, the tag's capitals starting a hair left of the prose's.
758const REPLY_TAG_INSET = PROSE_TEXT_INSET + 1
759// The padding inside a thread's border round its reply part, in cells, on every side.
760// The desktop gives a bordered Box 8px above and below and 12px at the sides
761// (--cds-pad-md, --cds-pad-lg) unless told otherwise, so paddingX alone left that 8px
762// above and below. The `padding` shorthand overrides all four sides as one. A cell
763// there is half a line down but one sans `ch` across (about 10px and 8px), so the
764// sides are equal in cells and near-equal in px; the engine part's prose inset adds
765// to the left. On the terminal a row is a whole line, so it pads only the sides.
766const REPLY_BORDER_PADDING = 1
767const TAG_SPACING = 0.4
768const TAG_FONT = 'font-family="Segoe UI, system-ui, -apple-system, sans-serif"'
769
770// Each capital's advance in a semibold sans-serif, in ems.
771const CAP_EMS: Record<string, number> = {
772  A: 0.66, B: 0.6, C: 0.62, D: 0.7, E: 0.52, F: 0.5, G: 0.7, H: 0.72, I: 0.28, J: 0.38, K: 0.6, L: 0.49,
773  M: 0.87, N: 0.74, O: 0.75, P: 0.58, Q: 0.75, R: 0.61, S: 0.54, T: 0.55, U: 0.7, V: 0.64, W: 0.95,
774  X: 0.62, Y: 0.58, Z: 0.58, ' ': 0.27,
775}
776
777// How wide a tag draws at its natural spacing, in px: the picture's width, its
778// text left as the font sets it.
779function tagWidth(label: string, size: number): number {
780  let ems = 0
781  for (const ch of label) ems += CAP_EMS[ch] ?? (ch >= '0' && ch <= '9' ? 0.56 : 0.6)
782  return Math.ceil(ems * size + TAG_SPACING * Math.max(0, label.length - 1)) + 1
783}
784
785// The SVG of a thread's tag: its name in small capitals, in its colour, set
786// against the edge the tag aligns to, `inset` px in from it. The capitals are set
787// to the width guessed for them (`textLength`, the spacing giving way), so the
788// picture ends where they do: what follows the tag, its "▾", sits right after
789// the name, alike in messages and replies.
790function tagPicture(name: string, color: string, align: 'start' | 'end', inset = 0) {
791  const label = name.toUpperCase()
792  const text = tagWidth(label, CHAT_TAG_SIZE)
793  const width = text + inset
794  const height = 12
795  const x = align === 'end' ? width - inset : inset
796  const source =
797    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
798    `<text x="${x}" y="9.5" text-anchor="${align}" ${TAG_FONT} font-size="${CHAT_TAG_SIZE}" font-weight="600"` +
799    ` letter-spacing="${TAG_SPACING}" textLength="${text - 1}" lengthAdjust="spacing" fill="${color}">${escapeXml(label)}</text></svg>`
800  return { source, width, height }
801}
802
803// The SVG of a thread's filter pill: one row high, its coloured dot and tag inside,
804// outlined and tinted in the thread's colour when picked, faded when another is,
805// and with an arrow after the tag when it alone is picked: messages with no
806// heading go to it. With no colour it has no dot; its outline is dashed unless
807// told otherwise, as the pill of no thread. Hot, it is the same pill under the
808// pointer: its fill and outline stronger and a faded one less faded, as the
809// desktop lights its own controls on hover (and leaves the cursor as it is).
810function chipPicture(
811  name: string,
812  color: string | undefined,
813  isSelected: boolean,
814  isDimmed: boolean,
815  isSendTarget: boolean,
816  isDashed = color === undefined,
817  isHot = false,
818) {
819  const label = name.toUpperCase()
820  const text = tagWidth(label, PILL_TAG_SIZE * (isSelected ? 1.03 : 1))
821  const textAt = color === undefined ? 9 : 19
822  const arrowAt = textAt + text + 5
823  const width = isSendTarget ? arrowAt + 8 + 8 : textAt + text + 9
824  const h = CHIP_HEIGHT
825  const fill =
826    color === undefined
827      ? `class="bg"${isDashed ? ' stroke-dasharray="3 2"' : ''}`
828      : isSelected
829        ? `fill="${color}" fill-opacity="${isHot ? 0.32 : 0.2}" stroke="${color}" stroke-width="1.5"`
830        : 'class="bg"'
831  const [dark, light] = isHot
832    ? ['fill:#ffffff29;stroke:#ffffff73', 'fill:#00000014;stroke:#00000073']
833    : ['fill:#ffffff14;stroke:#ffffff38', 'fill:#0000000a;stroke:#00000038']
834  const opacity = isDimmed ? ` opacity="${isHot ? 0.75 : 0.45}"` : ''
835  const source = [
836    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}"${opacity}>`,
837    `<style>.bg{${dark}}.t{fill:#e6e6e6}`,
838    `@media (prefers-color-scheme: light){.bg{${light}}.t{fill:#1f1f1f}}</style>`,
839    `<rect ${fill} x="0.75" y="0.75" width="${width - 1.5}" height="${h - 1.5}" rx="${(h - 1.5) / 2}"/>`,
840    color === undefined ? '' : `<circle cx="10.5" cy="${h / 2}" r="3" fill="${color}"/>`,
841    `<text class="t" x="${textAt}" y="${h / 2 + 3.8}" ${TAG_FONT} font-size="${PILL_TAG_SIZE}"`,
842    ` font-weight="${isSelected ? 700 : 600}" letter-spacing="${TAG_SPACING}">${escapeXml(label)}</text>`,
843    isSendTarget
844      ? `<path d="M${arrowAt} ${h / 2 - 4}L${arrowAt + 8} ${h / 2}L${arrowAt} ${h / 2 + 4}L${arrowAt + 2} ${h / 2}Z" fill="${color}"/>`
845      : '',
846    '</svg>',
847  ].join('')
848  return { source, width }
849}
850
851// A pill that is a switch on the desktop, in the keyed Box `key`: its picture, the
852// hot one laid over it while the pointer is on the pill, and a hit area over both.
853// The light is the surface's own hover, so it adds no Client: still one per pill.
854// The hot picture is revealed in the flow of an absolute Box always shown, never
855// as one: the desktop lifts an absolute Box the pointer reveals into a floating
856// card, as it does a tip, and a hover can only reveal, never hide the picture at rest.
857function drawPillSwitch(
858  elements: Elements['desktop'],
859  key: string,
860  hitKey: string,
861  isOn: boolean,
862  alt: string,
863  pictureOf: (isHot: boolean) => { source: string; width: number },
864  layout: BoxProps = {},
865): RenderElement {
866  const { Box, Svg, Client } = elements
867  const rest = pictureOf(false)
868  const hot = pictureOf(true)
869  return (
870    <Box key={key} {...layout}>
871      <Svg source={rest.source} alt={alt} width={rest.width} height={CHIP_HEIGHT} />
872      <Box position="absolute" top={0} left={0}>
873        <Box display="none" hover={{ display: 'flex' }}>
874          <Svg source={hot.source} alt={alt} width={hot.width} height={CHIP_HEIGHT} />
875        </Box>
876      </Box>
877      <Box position="absolute" top={0} left={0} width="100%" height="100%">
878        <Client key={hitKey} module="./switch.tsx" props={{ isOn }} width="100%" height="100%" />
879      </Box>
880    </Box>
881  )
882}
883
884// A thread's name as a tag: small capitals in its colour, a picture where the
885// surface draws one (`alt` its name, the thread's unless given), its capitals alone
886// on the terminal.
887function drawTag(
888  $: EngineInterface,
889  e: Parameters<EngineInterface['ui']['resolve']>[0],
890  thread: { name: string; color: string },
891  align: 'start' | 'end',
892  inset = 0,
893  alt = thread.name,
894): RenderElement {
895  if (e.surface === 'terminal') {
896    const { Text } = $.ui.resolve(e)
897    return <Text color={thread.color}>{thread.name.toUpperCase()}</Text>
898  }
899  const { Svg } = $.ui.resolve(e)
900  const tag = tagPicture(thread.name, thread.color, align, inset)
901  return <Svg source={tag.source} alt={alt} width={tag.width} height={tag.height} />
902}
903
904// Whether the surface puts the person's messages, and so their tags, on the right.
905const isOnRightOf = (surface: RenderSurface) => surface === 'desktop' || surface === 'mobile'
906
907// The row of thread tags above a person's message, at the side the surface puts the
908// message; `side` ("+ thread", or an open move picker) on the side away from the
909// edge the tags keep to, so showing it moves none. The thread view draws a person's
910// section's tag so too.
911function drawTagRow(
912  $: EngineInterface,
913  e: Parameters<EngineInterface['ui']['resolve']>[0],
914  tags: RenderElement[],
915  side?: RenderElement,
916  fit: BoxProps = {},
917): RenderElement {
918  const { Box } = $.ui.resolve(e)
919  const isOnRight = isOnRightOf(e.surface)
920  return (
921    <Box flexDirection="row" columnGap={2} justifyContent={isOnRight ? 'flex-end' : 'flex-start'} {...fit}>
922      {isOnRight && side}
923      {tags}
924      {!isOnRight && side}
925    </Box>
926  )
927}
928
929// A reply's part of a thread: in a round border of the thread's colour, the same
930// cells off it on every side (REPLY_BORDER_PADDING), `head` (its tag) at the top.
931// The thread view draws a reply's section so too.
932function drawThreadPart(
933  $: EngineInterface,
934  e: Parameters<EngineInterface['ui']['resolve']>[0],
935  thread: { color: string },
936  head: RenderElement,
937  body: RenderElement | undefined,
938): RenderElement {
939  const { Box } = $.ui.resolve(e)
940  const padding = e.surface === 'desktop' ? { padding: REPLY_BORDER_PADDING } : { paddingX: REPLY_BORDER_PADDING }
941  return (
942    <Box flexDirection="column" borderStyle="round" borderColor={thread.color} {...padding}>
943      {head}
944      {body}
945    </Box>
946  )
947}
948
949// A Markdown element takes at most this many characters.
950const MARKDOWN_LIMIT = 10000
951
952// Markdown text in pieces a Markdown element takes, split between lines where it can.
953function markdownPieces(text: string): string[] {
954  const clean = text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
955  const pieces: string[] = []
956  let piece = ''
957  for (const line of clean.split('\n')) {
958    for (let at = 0; at < Math.max(1, line.length); at += MARKDOWN_LIMIT) {
959      const part = line.slice(at, at + MARKDOWN_LIMIT)
960      const joined = piece === '' ? part : `${piece}\n${part}`
961      if (joined.length <= MARKDOWN_LIMIT) {
962        piece = joined
963      } else {
964        pieces.push(piece)
965        piece = part
966      }
967    }
968  }
969  if (piece !== '') pieces.push(piece)
970  return pieces
971}
972
973// Part of a reply drawn as markdown, as the screen draws a reply's text.
974function drawMarkdown($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], text: string): RenderElement {
975  const { Box, Markdown } = $.ui.resolve(e)
976  return (
977    <Box flexDirection="column">
978      {markdownPieces(text).map(piece => (
979        <Markdown text={piece} />
980      ))}
981    </Box>
982  )
983}
984
985// Where a part of a row now sits, as the key of its thread ('' none): where it was
986// first placed (`from`), unless the person moved it.
987const placeOf = (moves: Record<string, string>, row: string, from: string) => moves[`${row}|${from}`] ?? from
988
989// The moves once the person moves what sits in `from` of a row to `to`.
990function withMove(moves: Record<string, string>, row: string, from: string, to: string): Record<string, string> {
991  const prefix = `${row}|`
992  const next: Record<string, string> = {}
993  for (const [k, v] of Object.entries(moves)) next[k] = k.startsWith(prefix) && v === from ? to : v
994  if (!(`${prefix}${from}` in moves)) next[`${prefix}${from}`] = to
995  // a part back where it was first placed needs no entry
996  for (const [k, v] of Object.entries(next)) if (k === `${prefix}${v}`) delete next[k]
997  return next
998}
999
1000// A section where the person last placed it: where it was written or sent, or
1001// where they moved it. A heading naming no thread stays as typed.
1002function placeSection(
1003  s: Section,
1004  row: string,
1005  moves: Record<string, string>,
1006  resolve: (name: string) => Thread | undefined,
1007  list: Thread[],
1008  links: Record<string, string>,
1009): Section {
1010  const first = s.thread === null ? undefined : resolve(s.thread)
1011  if (s.thread !== null && first === undefined) return s
1012  const from = first === undefined ? '' : keyOf(first.name)
1013  const place = placeOf(moves, row, from)
1014  if (place === from) return s
1015  return { ...s, thread: place === '' ? null : (threadOf(place, list, links)?.name ?? null) }
1016}
1017
1018// How each movable part was last seen drawn, by `${row}|${place}`, to tell the model
1019// what the person moved.
1020const glimpses = new Map<string, string>()
1021
1022const glimpseOf = (text: string) => {
1023  const flat = text.replace(/\s+/g, ' ').trim()
1024  return flat.length > 60 ? `${flat.slice(0, 60)}…` : flat
1025}
1026
1027// Opens the move picker for what sits in `place` of a row, or closes it if open.
1028async function togglePicker($: EngineInterface, row: string, place: string) {
1029  const id = `${row}|${place}`
1030  await update($, naming, () => false)
1031  await update($, picking, open => (open === id ? '' : id))
1032}
1033
1034// Moves what the open picker is for to the thread `to` ('' none), to be told to
1035// the model beside the person's next message; `isNew` when the person started `to` for it.
1036async function moveTo($: EngineInterface, to: string, isNew = false) {
1037  const open = await read($, picking)
1038  const at = open.indexOf('|')
1039  await update($, picking, () => '')
1040  await update($, naming, () => false)
1041  if (at < 0) return
1042  const row = open.slice(0, at)
1043  const from = open.slice(at + 1)
1044  if (to === from) return
1045  await update($, moved, all => withMove(all, row, from, to))
1046  const list = await read($, threads)
1047  const where = (key: string) =>
1048    key === '' ? 'no thread' : `the thread "${list.find(t => keyOf(t.name) === key)?.name ?? key}"`
1049  const what = glimpses.get(open) ?? 'a message'
1050  const into = isNew ? `the new thread "${list.find(t => keyOf(t.name) === to)?.name ?? to}" they started for it` : where(to)
1051  await update($, untold, all => [...all, `The user moved ${what} from ${where(from)} to ${into}.`])
1052  await save($)
1053}
1054
1055// Starts the thread the person named in the open picker, as a heading or create_thread
1056// would (the next colour), and moves what the picker is for into it. A name already a
1057// thread's moves it there; an empty one does nothing.
1058async function moveToNewThread($: EngineInterface, typed: string) {
1059  const name = nameOf(typed.replace(/^#+/, ''))
1060  if (name === '' || name.length > 48 || (await read($, picking)) === '') return
1061  const isNew = !(await read($, threads)).some(t => keyOf(t.name) === keyOf(name))
1062  await addThreads($, [name], 'user')
1063  await moveTo($, keyOf(name), isNew)
1064}
1065
1066// A part's thread tag with a small button beside it that opens the move picker, or
1067// with no thread, a "+ thread" button. A Button, never a Client: every Client of a
1068// plugin shares one frame on the desktop, and one per tag of a long transcript
1069// overran its render deadline, which tore the frame down for every Client.
1070function drawMovableTag(
1071  $: EngineInterface,
1072  e: Parameters<EngineInterface['ui']['resolve']>[0] & { requestId: string },
1073  thread: Thread | undefined,
1074  align: 'start' | 'end',
1075  inset = 0,
1076): RenderElement {
1077  const row = e.requestId
1078  const place = thread === undefined ? '' : keyOf(thread.name)
1079  const { Box, Button } = $.ui.resolve(e)
1080  // The desktop draws it chromeless and faint, bright under the pointer.
1081  const look = e.surface === 'desktop' ? ({ plain: true, dimColor: true } as const) : ({ variant: 'secondary' } as const)
1082  // With the pointer anywhere on the tag, its name included, the button lights in the
1083  // thread's colour, showing where to press; the cursor stays the surface's own.
1084  return (
1085    <Box key={`tagbox:${place}`} flexDirection="row" columnGap={e.surface === 'desktop' ? 0 : 1} alignItems="center">
1086      {thread !== undefined && drawTag($, e, thread, align, inset)}
1087      <Button
1088        key={`tag:${place}`}
1089        label={thread === undefined ? '+ thread' : '▾'}
1090        {...look}
1091        hover={{ color: thread?.color ?? 'text' }}
1092        onPress={() => togglePicker($, row, place)}
1093      />
1094    </Box>
1095  )
1096}
1097
1098// What the multitask plugin's voice says when it says nothing; that plugin draws it as nothing.
1099const QUIET = '(quiet)'
1100
1101// A row's text with nothing of its own to show: none, or the voice's "(quiet)".
1102const isBlankText = (text: string) => text.trim() === '' || text.trim() === QUIET
1103
1104// Props that give a Box something to show, or room, though it holds nothing.
1105const SHAPED = /^(border|padding|margin|height|minHeight|width|minWidth|background)/
1106
1107// A drawing that shows nothing, as a plugin beneath draws a row it hides: none, a Box
1108// drawn "none" that no hover reveals, or a Box or Text of only such with no shape of
1109// its own. Anything else, the engine's own drawing among it, shows something.
1110function isBlank(node: unknown): boolean {
1111  if (node === null || node === undefined || node === false || node === '') return true
1112  if (typeof node !== 'object') return false
1113  const el = node as { type?: unknown; props?: Record<string, unknown>; hover?: Record<string, unknown>; children?: unknown[] }
1114  if (el.type === 'Box' && el.props?.display === 'none') return el.hover?.display === undefined
1115  if (el.type !== 'Box' && el.type !== 'Text') return false
1116  if (Object.keys(el.props ?? {}).some(prop => SHAPED.test(prop))) return false
1117  return (el.children ?? []).every(isBlank)
1118}
1119
1120// A row that holds a "+ thread" keeps one fixed row of room for it, its tags (if
1121// any) against the part below, so the pointer revealing it moves nothing. Only a
1122// row with something of its own to show gets one: never one drawn as nothing.
1123const ADD_ROW = { height: 1, flexShrink: 0, alignItems: 'flex-end' } as const
1124
1125// The faint "+ thread" of a part of no thread, shown while the pointer is over the
1126// keyed Box it sits in. It goes in the flow of an ADD_ROW row above the part: an
1127// absolute Box is placed against the whole message, whose top is the part itself.
1128function drawAddThread(
1129  $: EngineInterface,
1130  e: Parameters<EngineInterface['ui']['resolve']>[0] & { requestId: string },
1131  align: 'start' | 'end',
1132): RenderElement {
1133  const { Box } = $.ui.resolve(e)
1134  return (
1135    <Box display="none" hover={{ display: 'flex' }}>
1136      {drawMovableTag($, e, undefined, align)}
1137    </Box>
1138  )
1139}
1140
1141type MenuSwitch = { key: string; label: string; isOn: boolean }
1142
1143// The switches of the band's menu: threads on in this chat, and, while they are, whether
1144// the agent may start them and whether the chat is filtered to the picked thread.
1145async function menuSwitches($: EngineInterface): Promise<MenuSwitch[]> {
1146  const isThreadsOn = await read($, isOn)
1147  return [
1148    { key: THREADS_SWITCH, label: 'On in this chat', isOn: isThreadsOn },
1149    ...(isThreadsOn
1150      ? [
1151          { key: AGENT_SWITCH, label: 'Agent may start threads', isOn: await read($, agentMayCreate) },
1152          { key: FILTER_SWITCH, label: 'Filter the chat', isOn: await read($, filtersChat) },
1153        ]
1154      : []),
1155  ]
1156}
1157
1158// A row of the desktop's menu card: its label, a Button that flips it, then its slide
1159// switch, a picture with a hit area laid over it that flips it too.
1160function drawMenuRow($: EngineInterface, elements: Elements['desktop'], s: MenuSwitch): RenderElement {
1161  const { Box, Button, Svg, Client } = elements
1162  return (
1163    <Box flexDirection="row" alignItems="center" justifyContent="space-between" columnGap={2}>
1164      <Button key={s.key} label={s.label} plain onPress={() => flipMenuSwitch($, s.key)} />
1165      <Box flexShrink={0}>
1166        <Svg source={switchPicture(s.isOn)} alt={`${s.label}: ${s.isOn ? 'on' : 'off'}`} width={34} height={CHIP_HEIGHT} />
1167        <Box position="absolute" top={0} left={0} width="100%" height="100%">
1168          <Client key={SWITCH_HIT + s.key} module="./switch.tsx" props={{ isOn: s.isOn }} width="100%" height="100%" />
1169        </Box>
1170      </Box>
1171    </Box>
1172  )
1173}
1174
1175// The thread view's filter: one thread (by its key) picked, or left out where it was.
1176const toggleViewThread = ($: EngineInterface, key: string) => filterView($, (keys, list) => toggledShown(list, keys, key))
1177
1178// The desktop's hit area over a thread's tag in the thread view's filter, as over a menu
1179// switch: a click on it posts, and the post toggles the thread.
1180const filterHitOf = (elements: Elements['desktop']) => (key: string, isPicked: boolean) => {
1181  const { Client } = elements
1182  return <Client key={FILTER_THREAD_KEY + key} module="./switch.tsx" props={{ isOn: isPicked }} width="100%" height="100%" />
1183}
1184
1185// The menu's row that opens the thread view, as /thread-view does; only while threads are on.
1186const VIEW_ROW = 'Open thread view'
1187
1188// The fill of the desktop's Select field: white at 5% over the band in the dark theme
1189// (no theme colour names it). The light theme's is white at 50%, over a near-white band.
1190const FIELD_FILL = 'rgba(255,255,255,0.05)'
1191// The Select's 6px either side of its text and caret, from its outer edge: 5px inside
1192// the Box's 1px border, in the band's ch.
1193const FIELD_PADDING = 0.7
1194// The gap between the rows of the menu's card, in the desktop's half lines: a quarter of
1195// the band's line, about 5px, so a row (one line, about 19px, or a 20px slide switch)
1196// comes about 24px after the last, as the options of the desktop's Select do.
1197const MENU_ROW_GAP = 0.5
1198
1199// The band's threads menu, a settings cog: its switches, then, while threads are on,
1200// the thread actions (opening the thread view, starting a thread, managing the threads),
hooks/parse.ts 270 lines
1// A thread heading is a level-1 heading, with or without the space:
2// `# design` or `#design`. `##` and deeper stay inside the current section.
3// A leading `+` (`#+design`) asks for a new thread even when one is close.
4const HEADING = /^#(?!#)[ \t]*(\+?[\p{L}\p{N}][^\n]{0,47}?)[ \t#]*$/u
5const FENCE = /^\s*(```|~~~)/
6
7export const PALETTE = [
8  '#4fa3e0',
9  '#d9636a',
10  '#58b36b',
11  '#c98a2e',
12  '#9b6fd6',
13  '#2fb3a6',
14  '#d66bb0',
15  '#8a9a3a',
16]
17
18export type Section = {
19  // null is the unthreaded conversation
20  thread: string | null
21  text: string
22}
23
24export const keyOf = (name: string) => name.trim().replace(/\s+/g, ' ').toLowerCase()
25
26export const nameOf = (name: string) => name.trim().replace(/^\+/, '').trim().replace(/\s+/g, ' ')
27
28// The engine's frame around a prompt a plugin submitted, still on the text where a
29// surface shows the stored message (the desktop), whatever origin it gives the row.
30const PLUGIN_FRAME = /^\s*The [^\n]{1,80} plugin sent a message(?: while you were working)?:[ \t]*\r?\n/
31
32export const isPluginFramed = (text: string) => PLUGIN_FRAME.test(text)
33
34// The multitask plugin's relay of its thoughts' updates, as a surface may hold it
35// bare: its header, now or as it was first worded, ending its line, so a person's
36// own words starting the same way stay theirs.
37const RELAY_HEAD = /^\s*Thought updates(?::|\. Reply \(quiet\) unless something here needs you\.)[ \t]*\r?\n/
38
39// A prompt another party sent, though a surface may name its row the person's.
40export const isOthersPrompt = (text: string) => PLUGIN_FRAME.test(text) || RELAY_HEAD.test(text)
41
42// The heading's text as written, `+` included.
43function rawHeadingOf(line: string): string | undefined {
44  return HEADING.exec(line.trimEnd())?.[1]
45}
46
47function headingOf(line: string): string | undefined {
48  const raw = rawHeadingOf(line)
49  return raw === undefined ? undefined : nameOf(raw)
50}
51
52// Every thread heading in a text, outside code fences, in order, first spelling kept.
53export function headingsIn(text: string): string[] {
54  const seen = new Map<string, string>()
55  let isFenced = false
56  for (const line of text.split('\n')) {
57    if (FENCE.test(line)) isFenced = !isFenced
58    if (isFenced) continue
59    const name = headingOf(line)
60    if (name !== undefined && !seen.has(keyOf(name))) seen.set(keyOf(name), name)
61  }
62  return [...seen.values()]
63}
64
65// Rewrites each thread heading outside code fences to the name `resolve`
66// answers for it; a heading already spelled that way is left as typed.
67export function rewriteHeadings(text: string, resolve: (raw: string) => string): string {
68  let isFenced = false
69  return text
70    .split('\n')
71    .map(line => {
72      if (FENCE.test(line)) isFenced = !isFenced
73      const raw = isFenced ? undefined : rawHeadingOf(line)
74      if (raw === undefined) return line
75      const name = resolve(raw)
76      return name === nameOf(raw) && !raw.startsWith('+') ? line : `# ${name}`
77    })
78    .join('\n')
79}
80
81// A level-1 heading line of any length, its text starting with a letter or digit:
82// a heading typed with its section's first line on the same line may run past
83// what a heading may hold.
84const RUN_ON = /^#(?!#)[ \t]*([\p{L}\p{N}][^\n]*)$/u
85// Between a name and the words after it on its line: spaces, after a `:`, `-` or
86// the like where one is typed.
87const AFTER_NAME = /^(?:[ \t]*[:,;.!?\-–—])?[ \t]+/u
88const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
89
90export type RunOn = { name: string; rest: string }
91
92// The longest of `names` a heading's text starts with, as whole words and in any
93// case, and the rest of the line after it; undefined when it starts with none, or
94// nothing but marks follows the name.
95export function headingStart(text: string, names: string[]): RunOn | undefined {
96  const longestFirst = [...new Set(names.map(nameOf))].filter(n => n !== '').sort((a, b) => b.length - a.length)
97  for (const name of longestFirst) {
98    const words = name.split(' ').map(escapeRegExp).join('\\s+')
99    const start = new RegExp(`^${words}`, 'iu').exec(text)
100    if (start === null) continue
101    const after = text.slice(start[0].length)
102    const gap = AFTER_NAME.exec(after)
103    if (gap === null) continue
104    const rest = after.slice(gap[0].length).trimEnd()
105    if (/[\p{L}\p{N}]/u.test(rest)) return { name: nameOf(start[0]), rest }
106  }
107  return undefined
108}
109
110// Splits each level-1 heading line outside code fences that `split` finds typed
111// with its section's first line on it: the name it answers stays the heading and
112// the rest of the line goes on the line below.
113export function splitRunOns(text: string, split: (typed: string) => RunOn | undefined): string {
114  let isFenced = false
115  return text
116    .split('\n')
117    .map(line => {
118      if (FENCE.test(line)) isFenced = !isFenced
119      const typed = isFenced ? undefined : RUN_ON.exec(line.trimEnd())?.[1]
120      const runOn = typed === undefined ? undefined : split(typed)
121      return runOn === undefined ? line : `# ${runOn.name}\n${runOn.rest}`
122    })
123    .join('\n')
124}
125
126const squash = (name: string) => name.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
127
128function distance(a: string, b: string): number {
129  let row = Array.from({ length: b.length + 1 }, (_, i) => i)
130  for (let i = 1; i <= a.length; i++) {
131    const next = [i]
132    for (let j = 1; j <= b.length; j++) {
133      const swap = (row[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)
134      next[j] = Math.min((row[j] ?? 0) + 1, (next[j - 1] ?? 0) + 1, swap)
135    }
136    row = next
137  }
138  return row[b.length] ?? 0
139}
140
141// The one candidate a typed name most likely means: the same name spelled
142// differently, the start of exactly one (3+ characters), or a near miss
143// (1 typo from 4 characters, 2 from 8). Undefined when none, or a tie.
144export function closestThread(name: string, candidates: string[]): string | undefined {
145  const wanted = squash(name)
146  if (wanted === '') return undefined
147  const same = candidates.find(c => squash(c) === wanted)
148  if (same !== undefined) return same
149
150  if (wanted.length >= 3) {
151    const starting = candidates.filter(c => squash(c).startsWith(wanted))
152    if (starting.length === 1) return starting[0]
153  }
154
155  const limit = wanted.length >= 8 ? 2 : wanted.length >= 4 ? 1 : 0
156  const near = candidates
157    .map(c => ({ c, d: distance(wanted, squash(c)) }))
158    .filter(x => x.d <= limit)
159    .sort((x, y) => x.d - y.d)
160  if (near.length === 0 || (near.length > 1 && near[1]?.d === near[0]?.d)) return undefined
161  return near[0]?.c
162}
163
164// Splits a message into sections. A heading `isThread` accepts opens that
165// thread's section (the heading line itself is dropped: the drawing labels it);
166// any other level-1 heading returns to the unthreaded conversation and is kept.
167export function splitSections(text: string, isThread: (name: string) => boolean): Section[] {
168  const sections: Section[] = []
169  let current: { thread: string | null; lines: string[] } = { thread: null, lines: [] }
170  let isFenced = false
171
172  const close = () => {
173    const body = current.lines.join('\n').trim()
174    if (current.thread !== null || body !== '') sections.push({ thread: current.thread, text: body })
175  }
176
177  for (const line of text.split('\n')) {
178    if (FENCE.test(line)) isFenced = !isFenced
179    const name = isFenced ? undefined : headingOf(line)
180    if (name === undefined) {
181      current.lines.push(line)
182      continue
183    }
184    close()
185    current = isThread(name) ? { thread: name, lines: [] } : { thread: null, lines: [line] }
186  }
187  close()
188
189  return sections
190}
191
192// The desktop's reply to a selected part of a message: a marker line, the selection
193// quoted line by line (`> `), then the person's words on it. The first marker counts
194// the blocks (`<!-- reply 2 -->`), later ones are bare; an older desktop wrote one bare.
195const REPLY_MARKER = /^<!-- reply(?: [1-9][0-9]?)? -->$/
196const QUOTED = /^>(?: |$)/
197
198// The selections a message quotes as replies to parts of earlier messages, in order,
199// each as selected (its `> ` gone); none for a message that is no such reply.
200export function replyQuotesOf(text: string): string[] {
201  if (!text.includes('<!-- reply')) return []
202  const lines = text.split(/\r?\n/)
203  const quotes: string[] = []
204  for (let at = 0; at < lines.length; at++) {
205    if (!REPLY_MARKER.test(lines[at] ?? '')) continue
206    const quoted: string[] = []
207    while (QUOTED.test(lines[at + 1] ?? '')) quoted.push((lines[++at] ?? '').replace(/^> ?/, ''))
208    const quote = quoted.join('\n').trim()
209    if (quote !== '') quotes.push(quote)
210  }
211  return quotes
212}
213
214export type LeadingQuote = { quote: string; rest: string }
215
216// The replies to selected parts a text opens with (their markers, quoted lines and the
217// blank lines between them), and the rest of it from the first other line; none where
218// it opens with no quoted selection. The lines are kept as typed, line ends and all.
219export function leadingQuoteOf(text: string): LeadingQuote | undefined {
220  const lines = text.split('\n')
221  let end = 0
222  let isQuoting = false
223  let hasQuote = false
224  for (const [at, line] of lines.entries()) {
225    const bare = line.trimEnd()
226    if (REPLY_MARKER.test(bare)) {
227      isQuoting = true
228    } else if (isQuoting && QUOTED.test(bare)) {
229      hasQuote = true
230    } else if (bare.trim() === '') {
231      isQuoting = false
232      continue
233    } else {
234      break
235    }
236    end = at + 1
237  }
238  if (!hasQuote) return undefined
239  return { quote: lines.slice(0, end).join('\n').trim(), rest: lines.slice(end).join('\n').trim() }
240}
241
242// A person's sections with a quoted selection that opens the message, right before its
243// first thread heading, in that heading's section, the quote first: they replied to it
244// there. `isThread` tells whether a heading means a thread; the opening text stays
245// unthreaded where the heading means none, or where it holds words of their own.
246export function withOpeningQuote(sections: Section[], isThread: (name: string) => boolean): Section[] {
247  const [first, next, ...rest] = sections
248  if (first === undefined || next === undefined || first.thread !== null || next.thread === null) return sections
249  if (!isThread(next.thread) || leadingQuoteOf(first.text)?.rest !== '') return sections
250  return [{ thread: next.thread, text: `${first.text}\n\n${next.text}`.trim() }, ...rest]
251}
252
253// A text in the letters and digits a reader sees of it drawn: a selection of drawn
254// Markdown lacks its marks, link targets, list numbers and fence lines, and may join
255// or split its lines.
256function seenOf(text: string): string {
257  return squash(
258    text
259      .replace(/^\s*(```|~~~).*$/gm, '')
260      .replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, '')
261      .replace(/\]\([^)]*\)/g, ']'),
262  )
263}
264
265// Whether `text` holds what a reader quoted from it as drawn.
266export function holdsQuote(text: string, quote: string): boolean {
267  const wanted = seenOf(quote)
268  return wanted !== '' && seenOf(text).includes(wanted)
269}
270
hooks/pane.tsx 1206 lines
1import type { Args, ElementTable, RenderElement, RenderSurface, SessionMessage } from 'claude-code'
2
3import type { Thread, ViewImage, ViewRow } from '../types'
4import { isOthersPrompt, isPluginFramed, keyOf } from './parse'
5import type { Section } from './parse'
6
7// The thread view: a pane of the conversation in the threads its own filter picked
8// (every thread's while none is; sections of no thread as the chat's filter shows them),
9// the person's messages and each turn's last reply a section at a time, with no tool
10// rows, no quiet replies or others' prompts. Its filter is its own: the thread picked
11// above the prompt, where messages go, does not change it.
12// It looks as the transcript does: the person's sections in bubbles under the pictures
13// they pasted (read from the files Claude Code saved them to), with the react mod's
14// reactions on them where it is on, replies as text, and each thread's tags and borders
15// drawn by the code that draws the transcript's (register.tsx).
16//
17// It draws from the rows it kept as the session stored them (`session.append`),
18// each with its id and text: the transcript as the person sees it. A compaction
19// rewrites the conversation `$.session.messages()` reads (its summary in place of
20// what it replaced) but not the stored rows, so the view keeps what came before
21// it. That read only fills in rows kept without their text, and stands in for a
22// session the view kept no rows of.
23//
24// Here are its rules and its layout, as plain functions; register.tsx owns every
25// hook and every call on `$`. A plugin hooks an event once without a matcher, and
26// the engine follows `$` only into functions of the file that uses it.
27
28type Links = Record<string, string>
29
30// What the view borrows from register.tsx, which owns these rules, so a section
31// lands in the view where the chat draws it.
32export type ViewDeps = {
33  threadOf: (name: string, list: Thread[], links: Links) => Thread | undefined
34  personThreadOf: (name: string, list: Thread[], links: Links) => Thread | undefined
35  personSectionsOf: (text: string, list: Thread[], snapped: Links, merged: Links, gone: readonly string[]) => Section[]
36  replySectionsOf: (text: string, list: Thread[], merged: Links, gone: readonly string[]) => Section[]
37  linksFor: (merged: Links, gone: readonly string[], snapped?: Links) => Links
38  placeSection: (
39    s: Section,
40    row: string,
41    moves: Links,
42    resolve: (name: string) => Thread | undefined,
43    list: Thread[],
44    links: Links,
45  ) => Section
46  stemOf: (id: string) => string
47}
48
49// Characters a file of kept rows holds at most: under the 4 MiB a file read or
50// write takes, however many bytes each character comes to.
51const CHUNK_CHARS = 1_000_000
52// A long text is kept in pieces of this many characters, a line each, so one line
53// fits a file however its characters are escaped.
54const PIECE_CHARS = 100_000
55// Sections the view draws at first, the newest, and how many earlier ones it draws above
56// them each time the person scrolls near its top or presses its line of earlier messages.
57export const SECTIONS_FIRST = 30
58export const SECTIONS_MORE = 30
59// The key of that line, a Button.
60export const EARLIER_KEY = 'thread-view-earlier'
61// The keys of the filter's switches, each thread's by its key: a Button, or on the desktop
62// the hit area laid over its tag.
63export const FILTER_THREAD_KEY = 'thread-view-filter:thread:'
64const MARK_LENGTH = 80
65
66// The person's sections are bubbles, as the chat draws their messages. On a page
67// the fill is grey a tenth opaque: a shade lighter than a dark page (#151515 to
68// about #212121, the desktop's own bubble), a shade darker than a light one. The
69// theme's key for the bubble (`userMessageBackground`) is the page's own colour
70// there; the terminal fills it with that key.
71const BUBBLE_FILL = '#8888881a'
72const TERMINAL_BUBBLE_FILL = 'userMessageBackground'
73// A round border is what rounds a Box's corners on a page; drawn clear, the fill
74// alone shows.
75const CLEAR = '#00000000'
76// What a bubble leaves free at its left: the desktop's stop at about 85% of the
77// transcript's width.
78const BUBBLE_ROOM = '15%'
79// Rows between sections, half a line each on a page (about 10px): a message's or
80// reply's own sections as close as the chat's parts of a reply, separate ones as
81// far apart as the chat's messages (about 40px). The terminal leaves a line.
82const PART_GAP = 1
83const MESSAGE_GAP = 4
84
85// Rows short of its end the view's window may stand and still keep to its end as new
86// messages come.
87const NEAR_END_ROWS = 3
88
89type WindowMove = { offset: number; bodyRows: number; contentRows: number }
90
91// Whether a move left the view's window at or near the end of what it shows.
92export const isNearEnd = (move: WindowMove) => move.contentRows - move.bodyRows - move.offset <= NEAR_END_ROWS
93
94// Where the person left the view's window: at its end (or near it), or this many rows
95// from its top.
96export type ViewPlace = 'end' | number
97
98export const viewPlaceOf = (move: WindowMove): ViewPlace => (isNearEnd(move) ? 'end' : move.offset)
99
100// Earlier sections drawn above the view's window, to be kept out of it: the rows the view
101// had before them (`rows`), and the rows the window was moved by meanwhile to have the
102// surface say how many it has now (`nudge`).
103export type ViewGrowth = { rows: number; nudge: number }
104
105// Where a move to row `at` goes while earlier sections are drawn above the window, the view
106// `contentRows` rows as the surface last said: down by the rows they added, less the nudge,
107// so the window shows what it showed. None until the surface says it has more rows: a
108// desktop keeps its window as many pixels down as it was, and says so only as it moves.
109export function grownOffsetOf(growth: ViewGrowth, at: number, contentRows: number): number | undefined {
110  const added = contentRows - growth.rows
111  return added > 0 ? Math.max(0, at - growth.nudge + added) : undefined
112}
113
114// The row's move from `at` that has a surface say how many rows the view has (a desktop says
115// so as its window moves): down where it can, else up but not to the top (where a surface
116// draws the view afresh, viewMoveOf); none where the window cannot move.
117export const nudgeOf = (at: number, contentRows: number, bodyRows: number): -1 | 0 | 1 =>
118  at < contentRows - bodyRows ? 1 : at > 1 ? -1 : 0
119
120// Where the drawn sections start among the view's (by their rows' ids, in order): at `from`,
121// or the newest SECTIONS_FIRST where none is given, never fewer than those; taken back to the
122// start of the message it falls in.
123export function drawnFromOf(ids: readonly (string | undefined)[], from: number | undefined): number {
124  const newest = Math.max(0, ids.length - SECTIONS_FIRST)
125  let at = Math.max(0, Math.min(from ?? newest, newest))
126  while (at > 0 && ids[at] !== undefined && ids[at] === ids[at - 1]) at -= 1
127  return at
128}
129
130// How many messages the sections before `from` hold: a message's sections share its row's id.
131export function messagesBefore(ids: readonly (string | undefined)[], from: number): number {
132  let count = 0
133  for (let i = 0; i < from && i < ids.length; i++) if (i === 0 || ids[i] === undefined || ids[i] !== ids[i - 1]) count += 1
134  return count
135}
136
137// The sections a drawing of the view drew: from `from` on, of `count`.
138export type ViewSpan = { from: number; count: number }
139
140// How the view's window is put back where the person left it, as a surface draws the
141// view with its window `offset` rows down, `bodyRows` tall; `back` a row it is still to go
142// back to. A surface that draws the view afresh (on opening it, on coming back to its tab)
143// starts at the top, and the engine does not move it back:
144// - at the top though the person left it further than a window below it: to the end, and
145//   on to their row (`back`) where they left it short of it, once the surface has measured
146//   its rows;
147// - away from the top with a row to go back to: to that row;
148// - else nothing: a window at the top shows where the person left it near the top, where
149//   earlier sections are drawn as they scroll up and a surface draws them there.
150export function viewMoveOf(
151  place: ViewPlace,
152  back: number | undefined,
153  offset: number,
154  bodyRows: number,
155): { to: 'end'; back?: number } | { to: number } | undefined {
156  if (back !== undefined && offset > 0) return { to: back }
157  if (offset !== 0 || (place !== 'end' && place <= bodyRows)) return undefined
158  return place === 'end' ? { to: 'end' } : { to: 'end', back: place }
159}
160
161// Where the store notes how a session's view rows are kept between runs of the app
162// (KeptView); an earlier version kept the rows themselves there.
163export const viewKey = (sessionId: string) => `view:${sessionId}`
164
165// A text's start with its spacing dropped: the same however a row's text blocks
166// were joined.
167export const markOf = (text: string) => text.replace(/\s+/g, '').slice(0, MARK_LENGTH)
168
169type Blocks = readonly { type: string; [field: string]: unknown }[]
170
171function textBlocks(content: Blocks): string[] {
172  return content.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
173}
174
175// Whether a row of Claude's holds a tool call.
176export const hasToolCall = (content: Blocks) => content.some(block => block.type === 'tool_use')
177
178// What the multitask plugin's voice replies when it has nothing to say, and what such a
179// reply is kept as (the react plugin keeps a reply it made a reaction as that too).
180const QUIET = /^(?:\(quiet\)|No response requested\.)$/
181// The engine's own rows in the person's place, known by the tag their text opens with:
182// a command's echo or output (the multitask plugin's wake-up is one), a background
183// task's notice, a reminder.
184const ENGINE_TAGGED =
185  /^\s*<(?:command-(?:name|message|args)|local-command-(?:stdout|stderr|caveat)|task-notification|system-reminder|bash-(?:input|stdout|stderr))>/
186// Another Claude session's message, a thought's hand-back among them.
187const PEER_FRAME = /^\s*Another Claude session sent a message/
188
189// Whether a row's text is a message the view shows: Claude's words to the person, or
190// the person's own; not a quiet reply, nor another party's prompt in their place.
191const isMessageText = (role: 'user' | 'assistant', text: string) =>
192  role === 'assistant' ? !QUIET.test(text.trim()) : !isOthersPrompt(text) && !ENGINE_TAGGED.test(text) && !PEER_FRAME.test(text)
193
194// The media types a picture's bytes are drawn for, and the most base64 characters of one
195// drawn: what fits in the desktop's largest Svg (131072 characters) around it.
196const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
197export const IMAGE_DATA_MAX = 130_000
198const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/
199// The first base64 characters of a JPEG read for its size: 64 KiB, past the metadata
200// that may come before its start-of-frame segment.
201const JPEG_HEAD = 87_384
202
203// A picture's size in pixels, read from its header (PNG, GIF, JPEG); none where unread.
204export function imageSizeOf(type: string, data: string): { width?: number; height?: number } {
205  try {
206    const bytes = atob(type === 'image/jpeg' ? data.slice(0, JPEG_HEAD) : data.slice(0, 32))
207    const at = (i: number) => bytes.charCodeAt(i)
208    const word = (i: number) => (at(i) << 8) | at(i + 1)
209    if (type === 'image/png') return { width: word(16) * 65536 + word(18), height: word(20) * 65536 + word(22) }
210    if (type === 'image/gif') return { width: at(6) | (at(7) << 8), height: at(8) | (at(9) << 8) }
211    // a JPEG's size is in its first start-of-frame segment
212    for (let i = 2; type === 'image/jpeg' && i + 9 < bytes.length && at(i) === 0xff; i += 2 + word(i + 2)) {
213      const marker = at(i + 1)
214      if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
215        return { width: word(i + 7), height: word(i + 5) }
216      }
217    }
218  } catch {
219    // not base64 after all: no size
220  }
221  return {}
222}
223
224// A picture's media type, known by its first bytes (PNG, JPEG, GIF, WebP); none for another.
225export function imageTypeOf(data: string): string | undefined {
226  if (data.startsWith('iVBORw0KGgo')) return 'image/png'
227  if (data.startsWith('/9j/')) return 'image/jpeg'
228  if (data.startsWith('R0lGOD')) return 'image/gif'
229  if (data.startsWith('UklGR') && data.slice(11, 16) === 'XRUJQ') return 'image/webp'
230  return undefined
231}
232
233// A picture's bytes as the view draws them: whole, of a type drawn, and few enough.
234export const isDrawable = (type: string, data: string) => IMAGE_TYPES.has(type) && data.length <= IMAGE_DATA_MAX && BASE64.test(data)
235
236// The pictures in a row's blocks, in order: each one's media type and size. Their bytes
237// stay in the transcript; the files they were saved to come in a row of their own
238// (imagePathsOf).
239function imagesOf(content: Blocks): ViewImage[] {
240  return content.flatMap((block): ViewImage[] => {
241    if (block.type !== 'image') return []
242    const source = (block.source ?? {}) as { type?: unknown; media_type?: unknown; data?: unknown }
243    const type = typeof source.media_type === 'string' ? source.media_type : 'image'
244    const data = source.type === 'base64' && typeof source.data === 'string' ? source.data : undefined
245    return [data === undefined ? { type } : { type, ...imageSizeOf(type, data) }]
246  })
247}
248
249// The line Claude Code stores for a picture the person pasted, naming the file it saved
250// it to: in a hidden row of its own after their message, a text block a picture.
251const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/
252
253// The files a stored row names for the pictures pasted with the person's message before
254// it, in order; none for any other row.
255export function imagePathsOf(e: Args<'session.append'>): string[] | undefined {
256  if (e.agentId !== undefined || e.message.type !== 'user' || e.message.isMeta !== true) return undefined
257  const paths: string[] = []
258  for (const block of e.message.content) {
259    const path = block.type === 'text' && typeof block.text === 'string' ? IMAGE_SOURCE.exec(block.text.trim())?.[1]?.trim() : undefined
260    if (path === undefined || path === '') return undefined
261    paths.push(path)
262  }
263  return paths.length > 0 ? paths : undefined
264}
265
266// The rows known once row `id`'s pictures are known by their files, in order.
267export function withImagePaths(all: ViewRow[], id: string, paths: readonly string[]): ViewRow[] {
268  return all.map(row =>
269    row.id !== id || row.images === undefined
270      ? row
271      : { ...row, images: row.images.map((image, i) => (paths[i] === undefined ? image : { ...image, path: paths[i] })) },
272  )
273}
274
275// The rows known once the same rows as a session's files hold them (`kept`) lend them
276// the pictures' files they lack: a picture's file noted there since. `all` itself when
277// they lend none.
278export function withKeptPaths(all: ViewRow[], kept: readonly ViewRow[]): ViewRow[] {
279  const lent = new Map(kept.flatMap(row => (row.images?.some(image => image.path !== undefined) === true ? [[row.id, row.images]] : [])))
280  if (lent.size === 0) return all
281  let isChanged = false
282  const rows = all.map(row => {
283    const from = lent.get(row.id)
284    if (from === undefined || row.role !== 'user') return row
285    if (row.images === undefined || row.images.length === 0) {
286      isChanged = true
287      return { ...row, images: from }
288    }
289    const images = row.images.map((image, i) => {
290      const path = from[i]?.path
291      if (image.path !== undefined || path === undefined) return image
292      isChanged = true
293      return { ...image, path }
294    })
295    return { ...row, images }
296  })
297  return isChanged ? rows : all
298}
299
300// A stored row of the main conversation as the view knows it: a person's message
301// (with the pictures they pasted) or a reply's text, with the text the transcript
302// shows; none else. A compaction's summary is the engine's (door `compaction`), not
303// the person's.
304export function viewRowOf(e: Args<'session.append'>, person: ReadonlySet<string>): ViewRow | undefined {
305  if (e.agentId !== undefined) return undefined
306  const blocks = textBlocks(e.message.content)
307  const text = blocks.join('\n')
308  if (e.message.type === 'assistant') {
309    if (text.trim() === '') return undefined
310    const row: ViewRow = { id: e.uuid, role: 'assistant', mark: markOf(text), text }
311    return hasToolCall(e.message.content) ? { ...row, interim: true } : row
312  }
313  const isPerson =
314    e.message.type === 'user' &&
315    e.message.isMeta !== true &&
316    person.has(e.origin.kind) &&
317    (e.door === 'prompt' || e.door === 'delivery')
318  if (!isPerson || isPluginFramed(text)) return undefined
319  const images = imagesOf(e.message.content)
320  if (text.trim() === '' && images.length === 0) return undefined
321  // Notes for the model follow the prompt as typed, a block each; the transcript
322  // shows the prompt alone.
323  const typed = blocks[0] ?? ''
324  const row: ViewRow =
325    blocks.length > 1
326      ? { id: e.uuid, role: 'user', mark: markOf(text), text: typed, typed: typed.length }
327      : { id: e.uuid, role: 'user', mark: markOf(text), text }
328  return images.length > 0 ? { ...row, images } : row
329}
330
331// The rows known once `row` is: every one, one per id (a row stored again replaces
332// itself, still interim if it was).
333export function withViewRow(all: ViewRow[], row: ViewRow): ViewRow[] {
334  const at = all.findIndex(known => known.id === row.id)
335  if (at < 0) return [...all, row]
336  return all.map((known, i) => (i !== at ? known : known.interim === true ? { ...row, interim: true } : row))
337}
338
339// The rows known once those of `ids` are known as interim: replies a tool call
340// followed in their turn, which the view leaves out (it shows a turn's last reply).
341export function withInterim(all: ViewRow[], ids: ReadonlySet<string>): ViewRow[] {
342  return all.map(row => (ids.has(row.id) && row.interim !== true ? { ...row, interim: true } : row))
343}
344
345// How the store notes a session's kept rows: a fingerprint of each of its files'
346// text, in order. The files are written before the note, and only those whose text
347// changed; the note's count is how many there are.
348export type KeptView = { chunks: string[] }
349
350export const isKeptView = (value: unknown): value is KeptView =>
351  typeof value === 'object' &&
352  value !== null &&
353  Array.isArray((value as { chunks?: unknown }).chunks) &&
354  (value as { chunks: unknown[] }).chunks.every(chunk => typeof chunk === 'string')
355
356// A 32-bit FNV-1a hash, in hex.
357function hashOf(text: string) {
358  let hash = 0x811c9dc5
359  for (let i = 0; i < text.length; i++) {
360    hash ^= text.charCodeAt(i)
361    hash = Math.imul(hash, 0x01000193)
362  }
363  return (hash >>> 0).toString(16).padStart(8, '0')
364}
365
366// A file's text as the store notes it: its length and hash.
367export const fingerprintOf = (text: string) => `${text.length.toString(36)}.${hashOf(text)}`
368
369// A session's rows as files: a JSON line a row, its text's first piece with it, a
370// line `{ id, more }` for each further piece of a long text, and a line `{ id, image }`
371// for each picture; as many lines a file as fit in `size` characters.
372export function chunksOf(rows: readonly ViewRow[], size = CHUNK_CHARS, piece = PIECE_CHARS): string[] {
373  const chunks: string[] = []
374  let lines: string[] = []
375  let length = 0
376  const put = (line: string) => {
377    if (lines.length > 0 && length + line.length + 1 > size) {
378      chunks.push(`${lines.join('\n')}\n`)
379      lines = []
380      length = 0
381    }
382    lines.push(line)
383    length += line.length + 1
384  }
385  for (const { images, ...row } of rows) {
386    const text = row.text
387    if (text === undefined || text.length <= piece) {
388      put(JSON.stringify(row))
389    } else {
390      put(JSON.stringify({ ...row, text: text.slice(0, piece) }))
391      for (let at = piece; at < text.length; at += piece) put(JSON.stringify({ id: row.id, more: text.slice(at, at + piece) }))
392    }
393    for (const image of images ?? []) put(JSON.stringify({ id: row.id, image }))
394  }
395  if (lines.length > 0) chunks.push(`${lines.join('\n')}\n`)
396  return chunks
397}
398
399// The rows a session's files hold, in order (chunksOf). A line that does not read
400// as a row (a write cut short) is passed over.
401export function rowsOfChunks(chunks: readonly string[]): ViewRow[] {
402  const rows: ViewRow[] = []
403  for (const chunk of chunks) {
404    for (const line of chunk.split('\n')) {
405      if (line.trim() === '') continue
406      let value: Partial<ViewRow> & { more?: unknown; image?: Partial<ViewImage> }
407      try {
408        value = JSON.parse(line) as Partial<ViewRow> & { more?: unknown; image?: Partial<ViewImage> }
409      } catch {
410        continue
411      }
412      if (typeof value !== 'object' || value === null || typeof value.id !== 'string') continue
413      const last = rows[rows.length - 1]
414      if (typeof value.more === 'string') {
415        if (last?.id === value.id) rows[rows.length - 1] = { ...last, text: (last.text ?? '') + value.more }
416        continue
417      }
418      if (typeof value.image === 'object' && value.image !== null) {
419        const image = value.image
420        if (last?.id === value.id && typeof image.type === 'string') {
421          rows[rows.length - 1] = { ...last, images: [...(last.images ?? []), image as ViewImage] }
422        }
423        continue
424      }
425      if ((value.role === 'user' || value.role === 'assistant') && typeof value.mark === 'string') rows.push(value as ViewRow)
426    }
427  }
428  return rows
429}
430
431// A row's mark as another session's rows are matched to it, without reading their
432// text: a file of these, a line a row, is kept beside a session's rows.
433export const markHashOf = (mark: string) => hashOf(mark)
434export const marksOf = (rows: readonly ViewRow[]) => rows.map(row => markHashOf(row.mark)).join('\n')
435
436// The folder Claude Code names for a plugin's data, under its configuration folder:
437// `<name>-<marketplace>` for one installed from a marketplace (beneath
438// `plugins/cache/<marketplace>/`, whose configuration folder it takes),
439// `<name>-inline` for one loaded from a folder as it is. None where neither tells
440// the configuration folder.
441export function dataFolderOf(root: string, name: string, config: string | undefined): string | undefined {
442  const installed = /^(.*)[\\/]plugins[\\/]cache[\\/]([^\\/]+)[\\/]/.exec(root)
443  const base = installed?.[1] ?? config
444  if (base === undefined || base === '') return undefined
445  const folder = `${name}-${installed?.[2] ?? 'inline'}`.replace(/[^A-Za-z0-9_-]/g, '-')
446  return `${base.replace(/[\\/]+$/, '')}/plugins/data/${folder}`
447}
448
449// The name of a picture's smaller copy, kept under the plugin's data folder: one per
450// file and size across, made again when the file changes.
451export const thumbNameOf = (path: string, size: number, mtimeMs: number, across: number) =>
452  `${hashOf(path)}-${size.toString(36)}-${Math.floor(mtimeMs).toString(36)}-${across}.jpg`
453
454// Each transcript message's stored row (its index in `known`), matched in order by
455// role and mark; none for a message stored before the view kept rows, or not the
456// person's or a reply (a compaction's summary).
457function alignRows(messages: readonly SessionMessage[], known: readonly ViewRow[]): (number | undefined)[] {
458  const byMark = new Map<string, number[]>()
459  for (const [i, row] of known.entries()) {
460    const at = `${row.role}|${row.mark}`
461    const list = byMark.get(at)
462    if (list === undefined) byMark.set(at, [i])
463    else list.push(i)
464  }
465  let next = 0
466  return messages.map(m => {
467    if (m.text.trim() === '') return undefined
468    const list = byMark.get(`${m.role}|${markOf(m.text)}`)
469    while (list !== undefined && (list[0] ?? Infinity) < next) list.shift()
470    const i = list?.shift()
471    if (i === undefined) return undefined
472    next = i + 1
473    return i
474  })
475}
476
477// The rows a session rewound or forked from another takes up of that one's: up to
478// the last one its own transcript holds, as those after it were rewound away. All
479// of them where the transcript holds none (it may not be read yet).
480export function rowsUpTo(rows: readonly ViewRow[], messages: readonly SessionMessage[]): ViewRow[] {
481  const last = alignRows(messages, rows).reduce<number>((max, i) => (i === undefined ? max : Math.max(max, i)), -1)
482  return last < 0 ? [...rows] : rows.slice(0, last + 1)
483}
484
485// Whether a user message with no stored row reads as one the person typed: not a
486// tool's result, a plugin's prompt, or the engine's tagged text (a reminder, a notice).
487const looksTyped = (m: SessionMessage) =>
488  (m.toolResults?.length ?? 0) === 0 && !isPluginFramed(m.text) && !/^\s*</.test(m.text)
489
490// The id each reply was last drawn under in the chat, with its text's mark: the
491// desktop knows a reply by its API message's id, and moves of its parts are kept
492// under that id.
493const drawnMarks = new Map<string, string>()
494const DRAWN_KEPT = 2000
495
496// As a reply draws in the chat, before it is split: the id the screen knows it by,
497// with its whole text's mark.
498export function noteDrawnReply(requestId: string, text: string) {
499  drawnMarks.delete(requestId)
500  drawnMarks.set(requestId, markOf(text))
501  if (drawnMarks.size > DRAWN_KEPT) {
502    const oldest = drawnMarks.keys().next().value
503    if (oldest !== undefined) drawnMarks.delete(oldest)
504  }
505}
506
507// The id the person's moves of a row's parts are kept under: the row's own (as
508// the screen knows it, by its first characters), or the id a screen drew it by.
509function movedRowOf(moves: Links, id: string | undefined, marks: Set<string>, stemOf: (id: string) => string) {
510  for (const key of Object.keys(moves)) {
511    const row = key.slice(0, key.indexOf('|'))
512    if (id !== undefined && stemOf(row) === stemOf(id)) return row
513    const drawn = drawnMarks.get(row)
514    if (drawn !== undefined && marks.has(drawn)) return row
515  }
516  return id
517}
518
519// What the view is drawn from, as read while drawing it. `messages` is the
520// conversation as `$.session.messages()` reads it, needed only while a kept row
521// lacks its text or none is kept (`needsMessages`); else it may be left empty.
522export type ViewInputs = {
523  list: Thread[]
524  merged: Links
525  snapped: Links
526  sent: Links
527  moves: Links
528  // what the view's filter picked: threads' keys; none, every section
529  shown: string[]
530  // the ids of the rows since the person's last message, as the chat notes them: while
531  // the filter picks threads, sections of no thread show only in those, as in the chat
532  recent: string[]
533  // the keys of deleted threads, whose sections are of no thread
534  deleted: string[]
535  known: ViewRow[]
536  messages: SessionMessage[]
537  // the react mod's reactions on the person's messages while it draws them in the chat;
538  // none while it is off or not installed
539  reactions: readonly ViewReaction[]
540}
541
542// A reaction of the react mod's, as it keeps it: the emoji the agent answered a
543// person's message with instead of a reply, the message by its stored row id and the
544// start of its text (markOf).
545export type ViewReaction = { id: string; mark: string; emoji: string }
546
547// A start of a text this short ("ok", "thanks") is said too often to know a message by.
548const MIN_REACTION_MARK = 12
549
550// The emoji on a person's message, as the react mod finds it for the chat: the newest
551// reaction kept for its row id or, a long id, for one the screen knows by its stem;
552// failing that, the newest kept for a message whose start is the same and long enough
553// to tell messages apart (a session the view kept no rows of has no ids).
554export function reactionOn(
555  reactions: readonly ViewReaction[],
556  id: string | undefined,
557  text: string,
558  stemOf: (id: string) => string,
559): string | undefined {
560  const newest = (is: (r: ViewReaction) => boolean) => reactions.findLast(is)?.emoji
561  const byId =
562    id === undefined ? undefined : (newest(r => r.id === id) ?? (id.length >= 24 ? newest(r => stemOf(r.id) === stemOf(id)) : undefined))
563  if (byId !== undefined) return byId
564  const mark = markOf(text)
565  return mark.length < MIN_REACTION_MARK ? undefined : newest(r => r.mark === mark)
566}
567
568// Whether the view reads the conversation as `$.session.messages()` has it: for a
569// session it kept no rows of, or to fill in rows kept without their text.
570export const needsMessages = (known: readonly ViewRow[]) => known.length === 0 || known.some(row => row.text === undefined)
571
572// The view's filter: the threads it offers (those not archived, as the pickers offer),
573// and what of them it picked (`shown`), passing over a pick no longer offered (a thread
574// archived, merged away or deleted since, or an earlier version's pick of no thread);
575// none picked, it shows every section.
576export function viewFilterOf(list: readonly Thread[], shown: readonly string[]): { offered: Thread[]; picks: Set<string> } {
577  const offered = list.filter(t => t.isArchived !== true)
578  const keys = new Set(offered.map(t => keyOf(t.name)))
579  return { offered, picks: new Set(shown.filter(key => keys.has(key))) }
580}
581
582// What the filter picks once a thread's `key` is pressed: it joins the picks, or leaves
583// them where it was one; the last one leaving shows every section again.
584export function toggledShown(list: readonly Thread[], shown: readonly string[], key: string): string[] {
585  const picks = [...viewFilterOf(list, shown).picks]
586  return picks.includes(key) ? picks.filter(k => k !== key) : [...picks, key]
587}
588
589// One message the view draws: its row where one was kept (its id: where its moves
590// and sends are kept, and where the transcript shows it), who wrote it, and the
591// text the transcript shows.
592type Source = { row: ViewRow | undefined; role: 'user' | 'assistant'; text: string }
593
594// The conversation's replies a tool call followed in their turn (by index): a turn's
595// words before its last reply. A message of the person's, or another's prompt, starts
596// a turn; a tool's result does not.
597function interimIn(messages: readonly SessionMessage[]): Set<number> {
598  const interim = new Set<number>()
599  let said: number[] = []
600  for (const [i, m] of messages.entries()) {
601    if (m.role === 'user') {
602      if ((m.toolResults?.length ?? 0) === 0) said = []
603      continue
604    }
605    if (m.text.trim() !== '') said.push(i)
606    if (m.toolUses.length > 0) {
607      for (const at of said) interim.add(at)
608      said = []
609    }
610  }
611  return interim
612}
613
614// The messages the view draws, oldest first: the rows kept as the session stored
615// them, each with its text. A row an earlier version kept without its text draws
616// while the conversation still holds it, as that version drew it. For a session
617// with no rows kept, the conversation as it reads now. Only messages: the person's
618// own and each turn's last reply, never a quiet one.
619function sourcesOf(known: readonly ViewRow[], messages: readonly SessionMessage[]): Source[] {
620  if (known.length === 0) {
621    const interim = interimIn(messages)
622    return messages
623      .filter((m, i) => m.text.trim() !== '' && !interim.has(i) && (m.role === 'assistant' || looksTyped(m)) && isMessageText(m.role, m.text))
624      .map(m => ({ row: undefined, role: m.role, text: m.text }))
625  }
626  const held = new Map<number, SessionMessage>()
627  if (known.some(row => row.text === undefined)) {
628    for (const [i, at] of alignRows(messages, known).entries()) {
629      const m = messages[i]
630      if (at !== undefined && m !== undefined) held.set(at, m)
631    }
632  }
633  const sources: Source[] = []
634  for (const [i, row] of known.entries()) {
635    if (row.interim === true) continue
636    const m = held.get(i)
637    const text = row.text ?? (m === undefined ? undefined : row.typed === undefined ? m.text : m.text.slice(0, row.typed))
638    const hasImages = (row.images?.length ?? 0) > 0
639    if (text !== undefined && (text.trim() !== '' || hasImages) && isMessageText(row.role, text)) sources.push({ row, role: row.role, text })
640  }
641  return sources
642}
643
644// One section the view draws; `id` its row's, where one was kept; `images` the
645// pictures pasted with its message, on the first of its sections drawn; `reaction` the
646// emoji the react mod put on a person's message, on the last of its sections drawn.
647type Entry = {
648  id: string | undefined
649  role: 'user' | 'assistant'
650  thread: Thread | undefined
651  text: string
652  images?: ViewImage[]
653  reaction?: string
654}
655
656// The conversation's sections, in order, where the person last placed them, kept
657// to what the view's filter picked (all of them while it picked none).
658export function viewEntries(inputs: ViewInputs, deps: ViewDeps) {
659  const { list, snapped, sent, moves, shown, deleted, known, messages } = inputs
660  const merged = deps.linksFor(inputs.merged, deleted)
661  const { offered, picks } = viewFilterOf(list, shown)
662  const sources = sourcesOf(known, messages)
663  // Whether a message came since the person's last one, as the chat's filter tells: by its
664  // row's id. A session with no rows kept has no ids, so there it is told, near enough, by
665  // its place after the person's last message the view shows.
666  const recentStems = new Set(inputs.recent.map(deps.stemOf))
667  const lastPersonAt = sources.map(source => source.role).lastIndexOf('user')
668  const isRecentAt = (i: number) => {
669    const row = sources[i]?.row
670    return known.length === 0 ? i >= lastPersonAt : row !== undefined && recentStems.has(deps.stemOf(row.id))
671  }
672
673  const entries: Entry[] = []
674  let isRecentSource = false
675  // Whether the section is drawn: in a thread the filter picked, or of no thread in a
676  // message since the person's last one, or it picked none; and with words or pictures.
677  const keep = (id: string | undefined, role: Entry['role'], s: Section, resolve: (name: string) => Thread | undefined, images?: ViewImage[]) => {
678    const thread = s.thread === null ? undefined : resolve(s.thread)
679    if (picks.size > 0 && (thread === undefined ? !isRecentSource : !picks.has(keyOf(thread.name)))) return false
680    // a heading that names no thread stays as typed
681    const text = thread === undefined && s.thread !== null ? `# ${s.thread}\n${s.text}`.trim() : s.text
682    if (text === '' && images === undefined) return false
683    entries.push(images === undefined ? { id, role, thread, text } : { id, role, thread, text, images })
684    return true
685  }
686
687  for (const [i, { row, role, text }] of sources.entries()) {
688    isRecentSource = isRecentAt(i)
689    if (role === 'assistant') {
690      const resolve = (name: string) => deps.threadOf(name, list, merged)
691      const written = deps.replySectionsOf(text, list, inputs.merged, deleted)
692      const marks = new Set([markOf(text), ...written.map(s => markOf(s.text))])
693      const at = movedRowOf(moves, row?.id, marks, deps.stemOf)
694      for (const s of written) {
695        keep(row?.id, 'assistant', at === undefined ? s : deps.placeSection(s, at, moves, resolve, list, merged), resolve)
696      }
697      continue
698    }
699    const resolve = (name: string) => deps.personThreadOf(name, list, { ...snapped, ...merged })
700    const typed = deps.personSectionsOf(text, list, snapped, inputs.merged, deleted)
701    // A message with no heading may have gone to the one thread picked as it was sent.
702    const sentKey =
703      row === undefined || typed.some(s => s.thread !== null)
704        ? undefined
705        : (sent[row.id] ?? Object.entries(sent).find(([id]) => deps.stemOf(id) === deps.stemOf(row.id))?.[1])
706    const sentThread = sentKey === undefined ? undefined : deps.threadOf(sentKey, list, merged)
707    const sections = sentThread === undefined ? typed : [{ thread: sentThread.name, text }]
708    const at = movedRowOf(moves, row?.id, new Set([markOf(text)]), deps.stemOf)
709    // The pictures pasted with it go with its first section drawn; a message of
710    // pictures alone is one section of no words.
711    let images = (row?.images?.length ?? 0) > 0 ? row?.images : undefined
712    let last: Entry | undefined
713    for (const s of sections.length > 0 ? sections : [{ thread: null, text: '' }]) {
714      const placed = at === undefined ? s : deps.placeSection(s, at, moves, resolve, list, merged)
715      if (!keep(row?.id, 'user', placed, resolve, images)) continue
716      images = undefined
717      last = entries.at(-1)
718    }
719    // its reaction, as the chat draws it on the message, on its last section drawn
720    const reaction = last === undefined ? undefined : reactionOn(inputs.reactions, row?.id, text, deps.stemOf)
721    if (last !== undefined && reaction !== undefined) last.reaction = reaction
722  }
723  return { entries, offered, picks }
724}
725
726// How the pane draws, as the chat draws the same: the surface and its elements; a
727// reply's text as markdown (kept to what a Markdown element takes); the row holding a
728// thread's tag above a person's message, at the side their messages sit; and a
729// reply's part of a thread, in a border of its colour under its tag.
730export type ViewDrawing = {
731  surface: RenderSurface
732  ui: ElementTable
733  markdown: (text: string) => RenderElement
734  tagRow: (thread: Thread) => RenderElement
735  // a thread's tag as the chat draws it, set against the start; `alt` names its picture
736  tag: (thread: Thread, alt: string) => RenderElement
737  threadPart: (thread: Thread, body: RenderElement) => RenderElement
738  // a pasted picture's bytes to draw, read from its file (or a smaller copy of it):
739  // `loading` while they are read, none where they cannot be
740  picture: (image: ViewImage) => Picture | 'loading' | undefined
741  // where the window stands over the view: 0 at its top, 1 at its end (when not given)
742  near?: number
743  // the first section drawn, by its place among the view's sections; none: the newest
744  // SECTIONS_FIRST (drawnFromOf)
745  from?: number
746  // what a press of the line of earlier messages does: draw some of them
747  showEarlier: () => void
748  // what a press of one of the filter's Buttons does: its thread (by its key) picked, or
749  // left out where it was picked
750  toggleShown: (key: string) => void
751  // where the surface has one, an invisible hit area under FILTER_THREAD_KEY and the
752  // thread's key, as wide and high as its parent, whose press does as `toggleShown` does;
753  // the filter then draws each thread's `tag` under it
754  filterHit?: (key: string, isPicked: boolean) => RenderElement
755}
756
757// The most picture data one drawing of the view holds, in base64 characters. The desktop
758// draws nothing of a view that holds much more (one with 158,000 drew nothing; 92,000
759// drew), so the pictures nearest the window draw their bytes and the rest empty cards.
760export const PICTURE_CHARS_DRAWN = 80_000
761
762// What each of the view's pictures draws as, the sections' pictures in order: its bytes,
763// for those nearest the window (`near`, 0 at the top and 1 at the end) while their data
764// fits in `room`; else an empty card (`loading`), the rest not read; none (a mark) where
765// it has no bytes to be had.
766export function picturesNear(
767  sections: readonly (readonly ViewImage[])[],
768  near: number,
769  room: number,
770  pictureOf: (image: ViewImage) => Picture | 'loading' | undefined,
771): Map<ViewImage, Picture | 'loading' | undefined> {
772  const drawn = new Map<ViewImage, Picture | 'loading' | undefined>()
773  const at = Math.round(Math.min(1, Math.max(0, near)) * Math.max(0, sections.length - 1))
774  // nearest first; of two as near, the newer
775  const order = sections.map((_, i) => i).sort((a, b) => Math.abs(a - at) - Math.abs(b - at) || b - a)
776  let left = room
777  for (const i of order) {
778    for (const image of sections[i] ?? []) {
779      if (left <= 0) {
780        drawn.set(image, image.path !== undefined || image.data !== undefined ? 'loading' : undefined)
781        continue
782      }
783      const picture = pictureOf(image)
784      if (picture === undefined || picture === 'loading') {
785        drawn.set(image, picture)
786      } else if (picture.data.length <= left) {
787        drawn.set(image, picture)
788        left -= picture.data.length
789      } else {
790        drawn.set(image, 'loading')
791      }
792    }
793  }
794  return drawn
795}
796
797// A picture's bytes as drawn, base64, few enough for an Svg, with its media type and
798// its size in pixels where its header tells it.
799export type Picture = { type: string; data: string; width?: number; height?: number }
800
801// A text as a drawn string may hold it: no control characters but tab and newline.
802const drawableOf = (text: string) => text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
803
804// The desktop's reply to a selected part of a message, as parse.ts's replyQuotesOf
805// reads it: a marker line, then the selection quoted line by line (`> `).
806const REPLY_MARKER = /^<!-- reply(?: [1-9][0-9]?)? -->$/
807const QUOTED = /^>(?: |$)/
808
809// A part of a person's bubble: a selection they replied to, on one line, or a run of
810// their own words between such selections.
811type BubblePart = { kind: 'quote' | 'words'; text: string }
812
813// A person's text as their bubble shows it, in the order they typed it: each
814// selection they replied to, on one line, and their own words around them, without
815// the markers.
816function bubbleOf(text: string): BubblePart[] {
817  const lines = drawableOf(text).split('\n')
818  const parts: BubblePart[] = []
819  let words: string[] = []
820  const closeWords = () => {
821    const typed = words.join('\n').trim()
822    if (typed !== '') parts.push({ kind: 'words', text: typed })
823    words = []
824  }
825  for (let at = 0; at < lines.length; at++) {
826    const line = lines[at] ?? ''
827    if (!REPLY_MARKER.test(line)) {
828      words.push(line)
829      continue
830    }
831    closeWords()
832    const quoted: string[] = []
833    while (QUOTED.test(lines[at + 1] ?? '')) quoted.push((lines[++at] ?? '').replace(/^> ?/, ''))
834    const quote = quoted.join(' ').replace(/\s+/g, ' ').trim()
835    if (quote !== '') parts.push({ kind: 'quote', text: quote })
836  }
837  closeWords()
838  return parts
839}
840
841// A reaction of the react mod's as it draws one, 30 by 20px: the emoji in a small faint
842// pill, light or dark with the app. A solid one (`isSolid`) first paints the pill in the
843// page's colour, so the bubble's corner it floats over does not show through.
844const REACTION_WIDTH = 30
845const REACTION_HEIGHT = 20
846
847function reactionPicture(emoji: string, isSolid: boolean): string {
848  const w = REACTION_WIDTH
849  const h = REACTION_HEIGHT
850  const escaped = emoji.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
851  return [
852    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`,
853    '<style>.bg{fill:#ffffff14;stroke:#ffffff38}.base{fill:#151515}',
854    '@media (prefers-color-scheme: light){.bg{fill:#0000000a;stroke:#00000038}.base{fill:#fcfcfb}}</style>',
855    isSolid ? `<rect class="base" x="0.25" y="0.25" width="${w - 0.5}" height="${h - 0.5}" rx="${(h - 0.5) / 2}"/>` : '',
856    `<rect class="bg" x="0.75" y="0.75" width="${w - 1.5}" height="${h - 1.5}" rx="${(h - 1.5) / 2}"/>`,
857    `<text x="${w / 2}" y="${h / 2}" text-anchor="middle" dominant-baseline="central" font-size="12"`,
858    ` font-family="Segoe UI Emoji, Apple Color Emoji, Noto Color Emoji, sans-serif">${escaped}</text>`,
859    '</svg>',
860  ].join('')
861}
862
863// Where the desktop's reaction floats on its bubble: a column in from the bubble's right
864// edge and a row (half a line, about 10px) down, so the pill sits across its bottom edge;
865// the bubble then keeps a row below it, so the pill clears what follows.
866const REACTION_INSET = 1
867const REACTION_DROP = 1
868
869// A reaction on a line of its own, below what it is on: on a page the pill at the right,
870// where the person's messages sit; on the terminal, as react draws it there, the emoji
871// in dim brackets.
872function drawReactionRow(draw: ViewDrawing, isPage: boolean, emoji: string): RenderElement {
873  const { Box, Text } = draw.ui
874  const Svg = isPage ? (draw.ui as Partial<ElementTable<'desktop'>>).Svg : undefined
875  if (Svg === undefined) {
876    return (
877      <Box flexDirection="row" marginLeft={2}>
878        <Text dimColor>(</Text>
879        <Text>{emoji}</Text>
880        <Text dimColor>)</Text>
881      </Box>
882    )
883  }
884  return (
885    <Box flexDirection="row" justifyContent="flex-end">
886      <Svg source={reactionPicture(emoji, false)} alt={`Reaction: ${emoji}`} width={REACTION_WIDTH} height={REACTION_HEIGHT} />
887    </Box>
888  )
889}
890
891// A person's section as the chat's bubble: their words as typed (the chat draws no
892// markdown in them), each selection they replied to a dim line where they quoted it,
893// a row below any words before it. On a page it sits at the right, rounded and a
894// shade off the page; on the terminal it spans the pane in the theme's fill. With the
895// react mod's `reaction` on it, as the chat draws one: on the desktop a solid pill over
896// the bubble's bottom-right corner; elsewhere on the line below it.
897function drawBubble(draw: ViewDrawing, isPage: boolean, text: string, reaction?: string): RenderElement {
898  const { Box, Text } = draw.ui
899  const body = bubbleOf(text).map((part, at, parts) =>
900    part.kind === 'words' ? (
901      <Text>{part.text}</Text>
902    ) : (
903      <Box flexDirection="row" columnGap={1} marginTop={parts[at - 1]?.kind === 'words' ? 1 : 0}>
904        <Text dimColor>│</Text>
905        <Text dimColor wrap="truncate-end">
906          {part.text}
907        </Text>
908      </Box>
909    ),
910  )
911  if (!isPage) {
912    const bubble = (
913      <Box flexDirection="column" backgroundColor={TERMINAL_BUBBLE_FILL} paddingX={1}>
914        {body}
915      </Box>
916    )
917    if (reaction === undefined) return bubble
918    return (
919      <Box flexDirection="column">
920        {bubble}
921        {drawReactionRow(draw, isPage, reaction)}
922      </Box>
923    )
924  }
925  const Svg = draw.surface === 'desktop' ? (draw.ui as Partial<ElementTable<'desktop'>>).Svg : undefined
926  const corner = reaction !== undefined && Svg !== undefined
927  const bubble = (
928    <Box flexDirection="row" justifyContent="flex-end" {...(corner ? { marginBottom: REACTION_DROP } : {})}>
929      <Box minWidth={BUBBLE_ROOM} flexShrink={0} />
930      <Box
931        flexDirection="column"
932        flexShrink={1}
933        minWidth={0}
934        borderStyle="round"
935        borderColor={CLEAR}
936        backgroundColor={BUBBLE_FILL}
937        paddingX={2}
938        paddingY={1}
939      >
940        {body}
941        {corner && (
942          <Box position="absolute" right={REACTION_INSET} bottom={-REACTION_DROP}>
943            <Svg source={reactionPicture(reaction, true)} alt={`Reaction: ${reaction}`} width={REACTION_WIDTH} height={REACTION_HEIGHT} />
944          </Box>
945        )}
946      </Box>
947    </Box>
948  )
949  if (reaction === undefined || corner) return bubble
950  return (
951    <Box flexDirection="column">
952      {bubble}
953      {drawReactionRow(draw, isPage, reaction)}
954    </Box>
955  )
956}
957
958// A pasted picture as the chat's thumbnail on a page: a card of at most THUMB_WIDTH
959// pixels across with rounded corners and a thin border, as tall as the picture's shape
960// gives within its bounds (THUMB_HEIGHT where its shape is not known).
961const THUMB_WIDTH = 200
962const THUMB_HEIGHT = 150
963const THUMB_MIN = 40
964const THUMB_MAX_HEIGHT = 300
965const THUMB_RADIUS = 8
966const THUMB_BORDER = '#8888884d'
967
968// A picture's thumbnail as SVG markup, with its size in pixels: its card, the bytes
969// drawn in it; an empty card while they are read. As tall as its shape gives, from its
970// own size or else the bytes'.
971function thumbOf(image: ViewImage, picture: Picture | 'loading'): { source: string; width: number; height: number } {
972  const known: { width?: number; height?: number } =
973    image.width !== undefined && image.height !== undefined ? image : picture === 'loading' ? {} : picture
974  const { width, height } = known
975  const isSized = width !== undefined && height !== undefined && width > 0 && height > 0
976  const w = isSized ? Math.max(THUMB_MIN, Math.min(THUMB_WIDTH, width)) : THUMB_WIDTH
977  const h = isSized ? Math.max(THUMB_MIN, Math.min(THUMB_MAX_HEIGHT, Math.round((w * height) / width))) : THUMB_HEIGHT
978  const source = [
979    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`,
980    picture !== 'loading' && `<clipPath id="card"><rect width="${w}" height="${h}" rx="${THUMB_RADIUS}"/></clipPath>`,
981    picture !== 'loading' &&
982      `<image href="data:${picture.type};base64,${picture.data}" width="${w}" height="${h}" preserveAspectRatio="xMidYMid slice" clip-path="url(#card)"/>`,
983    `<rect x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="${THUMB_RADIUS - 0.5}" fill="none" stroke="${THUMB_BORDER}"/>`,
984    '</svg>',
985  ]
986    .filter(part => part !== false)
987    .join('')
988  return { source, width: w, height: h }
989}
990
991// A picture's bytes as the view draws them: those a row of an earlier version kept, or
992// those read from its file; none where neither is to be had.
993function pictureOf(draw: ViewDrawing, image: ViewImage): Picture | 'loading' | undefined {
994  const { type, data, width, height } = image
995  if (data !== undefined && isDrawable(type, data)) return { type, data, width, height }
996  return image.path === undefined ? undefined : draw.picture(image)
997}
998
999// The pictures pasted with a person's message, above their words as the chat draws
1000// them: on a page, thumbnails side by side at the right (`drawn`: what each draws as);
1001// on the terminal, which draws none in the pane, a dim mark for each, as for a picture
1002// whose file is gone.
1003type Drawn = ReadonlyMap<ViewImage, Picture | 'loading' | undefined>
1004
1005function drawImages(draw: ViewDrawing, isPage: boolean, images: readonly ViewImage[], drawn: Drawn, gapBelow: number): RenderElement {
1006  const { Box, Text } = draw.ui
1007  const Svg = isPage ? (draw.ui as Partial<ElementTable<'desktop'>>).Svg : undefined
1008  const marks = images.map(image => {
1009    const picture = Svg === undefined ? undefined : drawn.get(image)
1010    if (Svg === undefined || picture === undefined) return <Text dimColor>[image]</Text>
1011    const thumb = thumbOf(image, picture)
1012    return <Svg source={thumb.source} alt="Pasted image" width={thumb.width} height={thumb.height} />
1013  })
1014  return (
1015    <Box flexDirection="row" flexWrap="wrap" justifyContent={isPage ? 'flex-end' : 'flex-start'} columnGap={1} rowGap={1} marginBottom={gapBelow}>
1016      {marks}
1017    </Box>
1018  )
1019}
1020
1021// One section, `gap` rows below the one before, its thread marked as the chat marks
1022// it. Claude's is its text as markdown, as the chat draws a reply's, in its thread's
1023// border under its tag; the person's a bubble under its thread's tag and the pictures
1024// they pasted with it.
1025function drawEntry(draw: ViewDrawing, isPage: boolean, entry: Entry, drawn: Drawn, gap: number): RenderElement {
1026  const { Box } = draw.ui
1027  const { thread } = entry
1028  if (entry.role === 'assistant') {
1029    const body = draw.markdown(entry.text)
1030    return (
1031      <Box flexDirection="column" marginTop={gap}>
1032        {thread === undefined ? body : draw.threadPart(thread, body)}
1033      </Box>
1034    )
1035  }
1036  const hasWords = entry.text.trim() !== ''
1037  return (
1038    <Box flexDirection="column" marginTop={gap}>
1039      {thread !== undefined && draw.tagRow(thread)}
1040      {entry.images !== undefined && drawImages(draw, isPage, entry.images, drawn, hasWords && isPage ? PART_GAP : 0)}
1041      {hasWords && drawBubble(draw, isPage, entry.text, entry.reaction)}
1042      {!hasWords && entry.reaction !== undefined && drawReactionRow(draw, isPage, entry.reaction)}
1043    </Box>
1044  )
1045}
1046
1047// A switch of the view's filter on the desktop: a round border, the same picked or not,
1048// its own padding set to about 3px above and below the tag and a column either side, so
1049// with the tag's 12px it is about as high as the chat's pills (20px). Its colour while
1050// not picked is clear, and under the pointer the thread's colour at about 45%.
1051const FILTER_SHAPE = { borderStyle: 'round', paddingX: 1, paddingY: 0.3 }
1052const FILTER_HOVER_ALPHA = '73'
1053// Rows between the desktop's rows of switches, once they wrap: about 5px, so two picked
1054// switches' borders do not touch.
1055const FILTER_ROW_GAP = 0.5
1056
1057// One thread of the view's filter, a switch. Where the surface has a hit area (the
1058// desktop) it is the thread's tag, as the chat draws it, in a round border of the
1059// thread's colour while picked; while not, the same border drawn clear, so picking
1060// moves nothing, and faint under the pointer; the hit area laid over it. Elsewhere it is
1061// a Button, whose label takes no colour at rest: the name in capitals, as the terminal
1062// draws a tag, in brackets while picked and between spaces, dim, while not; in the
1063// thread's colour under the pointer.
1064function drawFilterChoice(draw: ViewDrawing, thread: Thread, isPicked: boolean): RenderElement {
1065  const { Box, Button } = draw.ui
1066  const key = keyOf(thread.name)
1067  const boxKey = `thread-view-filter-box:${key}`
1068  if (draw.filterHit !== undefined) {
1069    // kept whole, as a Button is, so a long name moves to the next row rather than wrap
1070    return (
1071      <Box
1072        key={boxKey}
1073        flexDirection="row"
1074        flexShrink={0}
1075        {...FILTER_SHAPE}
1076        borderColor={isPicked ? thread.color : CLEAR}
1077        {...(isPicked ? {} : { hover: { borderColor: thread.color + FILTER_HOVER_ALPHA } })}
1078      >
1079        {draw.tag(thread, `${thread.name}: ${isPicked ? 'on' : 'off'}`)}
1080        <Box position="absolute" top={0} left={0} width="100%" height="100%">
1081          {draw.filterHit(key, isPicked)}
1082        </Box>
1083      </Box>
1084    )
1085  }
1086  const name = thread.name.toUpperCase()
1087  return (
1088    <Box key={boxKey} flexDirection="row">
1089      <Button
1090        key={FILTER_THREAD_KEY + key}
1091        label={isPicked ? `[${name}]` : ` ${name} `}
1092        plain
1093        {...(isPicked ? {} : { dimColor: true })}
1094        hover={{ color: thread.color }}
1095        onPress={() => draw.toggleShown(key)}
1096      />
1097    </Box>
1098  )
1099}
1100
1101// The line between the view's sections and its filter, faint as the transcript's own
1102// separators. On a page a hairline across the pane: a tenth of a row (about 1px) of grey
1103// about 30% opaque, as a thumbnail's border, which shows on a light page and a dark. On
1104// the terminal a dim line of `─` across the pane: a run longer than any pane is wide,
1105// wrapped and cut to its first row (a Box has no border on one side alone, and a Text
1106// cut short ends in an ellipsis).
1107const DIVIDER_HEIGHT = 0.1
1108const DIVIDER_FILL = '#8888884d'
1109const DIVIDER_CHARS = 500
1110
1111function drawDivider(draw: ViewDrawing, isPage: boolean): RenderElement {
1112  const { Box, Text } = draw.ui
1113  if (isPage) return <Box height={DIVIDER_HEIGHT} backgroundColor={DIVIDER_FILL} />
1114  return (
1115    <Box height={1} overflow="hidden">
1116      <Text dimColor>{'─'.repeat(DIVIDER_CHARS)}</Text>
1117    </Box>
1118  )
1119}
1120
1121// The view's filter, below its newest section, where the view opens and keeps to as
1122// messages come (a pane has no row that stays as it scrolls): under the divider, a switch
1123// for each thread it offers, in one row while they fit. On a page the divider has a row
1124// either side; on the terminal the filter sits right under it.
1125function drawFilter(draw: ViewDrawing, isPage: boolean, offered: readonly Thread[], picks: ReadonlySet<string>): RenderElement {
1126  const { Box } = draw.ui
1127  return (
1128    <Box flexDirection="column" rowGap={isPage ? 1 : 0}>
1129      {drawDivider(draw, isPage)}
1130      <Box
1131        flexDirection="row"
1132        flexWrap="wrap"
1133        alignItems="center"
1134        columnGap={1}
1135        rowGap={draw.filterHit !== undefined ? FILTER_ROW_GAP : 0}
1136      >
1137        {offered.map(t => drawFilterChoice(draw, t, picks.has(keyOf(t.name))))}
1138      </Box>
1139    </Box>
1140  )
1141}
1142
1143// The pane's body: the sections of the threads its filter picked, oldest first, the newest
1144// at the bottom, and below a line the filter while there are threads to pick; the sections
1145// from `draw.from` on, under a line saying how many messages come before them, which
1146// draws some of them when pressed. With the sections it drew.
1147export function drawView(draw: ViewDrawing, inputs: ViewInputs, deps: ViewDeps): { tree: RenderElement; span: ViewSpan } {
1148  const { Box, Text, Button } = draw.ui
1149  const { entries, offered, picks } = viewEntries(inputs, deps)
1150  const ids = entries.map(entry => entry.id)
1151  const from = drawnFromOf(ids, draw.from)
1152  const shown = entries.slice(from)
1153  const earlier = messagesBefore(ids, from)
1154  // An app lays the view out as a page (the desktop, the phone, an editor), a row
1155  // there half a line; the terminal lays it out in cells.
1156  const isPage = draw.surface !== 'terminal'
1157  const gapBefore = (entry: Entry, i: number) => {
1158    const before = shown[i - 1]
1159    if (before === undefined) return 0
1160    if (!isPage) return 1
1161    return entry.id !== undefined && entry.id === before.id ? PART_GAP : MESSAGE_GAP
1162  }
1163  // the pictures' bytes drawn, nearest the window first, within what the desktop draws
1164  const drawn = isPage
1165    ? picturesNear(
1166        shown.map(entry => entry.images ?? []),
1167        draw.near ?? 1,
1168        PICTURE_CHARS_DRAWN,
1169        image => pictureOf(draw, image),
1170      )
1171    : new Map<ViewImage, undefined>()
1172
1173  const tree = (
1174    <Box flexDirection="column" rowGap={1}>
1175      {earlier > 0 && (
1176        <Button
1177          key={EARLIER_KEY}
1178          label={`${earlier} earlier ${earlier === 1 ? 'message' : 'messages'}`}
1179          plain
1180          dimColor
1181          onPress={() => draw.showEarlier()}
1182        />
1183      )}
1184      {shown.length === 0 && <Text dimColor>{picks.size === 0 ? 'Nothing here yet.' : 'Nothing in the threads shown yet.'}</Text>}
1185      {shown.length > 0 && <Box flexDirection="column">{shown.map((entry, i) => drawEntry(draw, isPage, entry, drawn, gapBefore(entry, i)))}</Box>}
1186      {offered.length > 0 && drawFilter(draw, isPage, offered, picks)}
1187    </Box>
1188  )
1189  return { tree, span: { from, count: entries.length } }
1190}
1191
1192// What the pane says when drawing it failed: the failure, so the view never goes blank
1193// without saying why (the engine would draw its own, an empty pane, in its place).
1194const FAILURE_CHARS = 500
1195
1196export function drawViewFailure(ui: ElementTable, error: unknown): RenderElement {
1197  const { Box, Text } = ui
1198  const said = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
1199  const reason = drawableOf(said).replace(/[\u0080-\u009f\ud800-\udfff]/g, '').slice(0, FAILURE_CHARS)
1200  return (
hooks/switch.tsx 30 lines
1import type { ClientModule } from 'claude-code'
2
3type State = { isHeld: boolean }
4
5// An invisible hit area laid over a switch or pill picture: a click, Enter or Space
6// posts `{ flip: true }` to the hooks module, which flips the value it draws.
7const Switch: ClientModule<{ isOn: boolean }, State> = (props, surface) => {
8  const { Text } = surface.elements
9  const flip = () => surface.post({ flip: true })
10
11  // A click flips once, whether the surface reports its down, its up or both.
12  surface.onPointer(e => {
13    if (e.button !== undefined && e.button !== 'left') return
14    if (e.type === 'down') {
15      surface.setState({ isHeld: true })
16      flip()
17    } else if (e.type === 'up') {
18      if (surface.state?.isHeld === true) surface.setState({ isHeld: false })
19      else flip()
20    }
21  })
22  surface.onKey(e => {
23    if (e.key === 'return' || e.key === ' ') flip()
24  })
25
26  return <Text>{' '.repeat(Math.max(1, surface.columns))}</Text>
27}
28
29export default Switch
30
types/index.d.ts 118 lines
1export type Thread = {
2  name: string
3  color: string
4  createdBy: 'user' | 'agent'
5  // hidden from the thread picker, the move picker and the thread view's filter, but
6  // kept: its messages keep their tags, and a heading naming it brings it back
7  isArchived?: boolean
8}
9
10// A row of the main conversation the thread view draws: a person's message or a
11// reply's text, as stored, by its row id and the start of its text.
12export type ViewRow = {
13  id: string
14  role: 'user' | 'assistant'
15  // the text's start, whitespace dropped: how a transcript row is matched to it
16  mark: string
17  // the text as the transcript shows it (a person's message as typed, without the
18  // notes for the model beside it), so the view does not depend on the conversation
19  // a compaction rewrites. Absent on rows kept by an earlier version.
20  text?: string
21  // the length of the text as typed, where notes for the model follow it in the row
22  // (rows of an earlier version, which kept no text)
23  typed?: number
24  // a reply a tool call followed in its turn: words on the way, not the turn's last
25  // reply, so the view leaves it out
26  interim?: true
27  // the pictures the person pasted with their message, in order
28  images?: ViewImage[]
29}
30
31// A picture the person pasted: its media type, its size where its header tells it, and
32// the file Claude Code saved it to, read as the view draws it. Rows kept by an earlier
33// version hold its bytes as base64 instead, where they were few enough to draw.
34export type ViewImage = {
35  type: string
36  width?: number
37  height?: number
38  path?: string
39  data?: string
40}
41
42// Where the threads stood when the model was last told, beside the person's message:
43// the merges (a merged-away thread's key -> its thread's key) and the agent switch.
44export type Brief = {
45  merged: Record<string, string>
46  mayCreate: boolean
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    'thread-chat': {
52      // whether threads are on in the session: the person turns them on and off with
53      // /threads; off, the mod leaves the session be
54      isOn: boolean
55      threads: Thread[]
56      // a merged-away thread's key -> the key of the thread it now belongs to
57      aliases: Record<string, string>
58      agentMayCreate: boolean
59      // whether the chat is filtered to the thread picked above the prompt; off (as at
60      // first), the chat shows every row
61      filtersChat: boolean
62      // a heading's key as typed -> the key of the thread it was decided to mean at send
63      snaps: Record<string, string>
64      // the main conversation's rows since the person's last message, that message
65      // first: each row's id and each tool call's, in the order they were stored
66      recent: string[]
67      // the start of each reply text in those rows: a screen may know a reply by an
68      // id of its own (the desktop's is the API message's), so a reply is matched by text
69      recentTexts: string[]
70      // the key of the thread picked above the prompt ('' none): where a message with
71      // no heading goes. The thread view does not follow it (viewThreads).
72      current: string
73      // the keys of threads the person deleted: a heading that names one, or a merge or
74      // an earlier send that points to one, belongs to no thread. A thread started again
75      // by that name takes its key back.
76      deleted: string[]
77      // a person's message row id -> the key of the thread it went to: sent with no
78      // heading while that thread was picked
79      sentTo: Record<string, string>
80      // `${row id}|${key of the thread a part was first placed in, '' none}` -> the key
81      // of the thread the person moved it to ('' none)
82      moved: Record<string, string>
83      // the open move picker: `${row id}|${key of the thread its part sits in}`; '' none
84      picking: string
85      // whether the open move picker shows a field for a new thread's name in place
86      // of its pills
87      naming: boolean
88      // moves, archives and deletions to tell the model beside the person's next message
89      untold: string[]
90      // what the model was last told of the threads; null until it is told how they
91      // work (and again after a compaction)
92      briefed: Brief | null
93      // the person's messages and reply texts as stored, in order: what the thread
94      // view draws, each by its transcript row's id
95      viewRows: ViewRow[]
96      // how many of the thread view's pictures have been read from their files: each
97      // one read draws the view again
98      picturesRead: number
99      // the first of the thread view's sections it draws, by its place among them; null
100      // while it draws the newest (as opened, or another pick in its filter)
101      viewFrom: number | null
102      // what the thread view's own filter picked: the keys of the threads it shows, and
103      // '' for the sections of no thread; none, it shows every section
104      viewThreads: string[]
105      // whether the band's menu shows its switches, on a surface where a press opens it
106      menuOpen: boolean
107      // what the band's menu shows of the threads: '' its first rows (the switches, opening
108      // the thread view, starting a thread, managing them), 'name' the field for a new
109      // thread's name, 'manage' each thread's merge, archive and delete, `merge:<key>` the
110      // threads that one can merge into, `delete:<key>` the question whether to delete it
111      menuMode: string
112      // whether the thread picker's list is open in the mobile app, which draws no Select:
113      // its button opens and closes it
114      pickerOpen: boolean
115    }
116  }
117}
118