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

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

# heading puts a message in its topic's thread: colour-coded, readable on its own and easy to find later.Examples: docs/why.md.
Requires Claude Code 2.1.290 or later. Made for the Code tab in the Claude desktop app.
claude plugin marketplace add othrayte/claude-multitask
claude plugin install multitask@claude-multitask
claude plugin install thread-chat@claude-multitask
claude plugin install react@claude-multitask
Install any or all of them.
In the band above the prompt, Multitask has a dropdown, and thread-chat a thread picker followed by its threads menu, a cog (⚙), with their switches for the chat. The mobile app shows only the thread picker: use the commands there. React has no control in the band: /react is its switch.
/multitask turns Multitask on in a chat (run it again to turn it off). The chat becomes the voice and hands all work to thoughts.Multitask while it is on and Focus while it is off; pick the other to switch./thoughts, or the thoughts button above the prompt, opens the Thoughts pane: what each thought is doing and how long since its last note, its report, tell to send it a message yourself, and stop. It lists the 16 most recently ended thoughts (N more shows the rest). The thoughts are per chat, and kept when the chat is restarted.voiceEffort setting (Voice effort, in /config) sets how hard the voice thinks: low (default), medium, high, or session to match the session. Thoughts keep the session's setting./threads, or the On in this chat switch in the threads menu (the cog), turns threads on in a chat (again to turn them off). The menu opens while the pointer is on the cog or it has the focus in the desktop app, and with a press on it in the terminal.# name heading in a message starts a thread, or replies to it if it exists. Close or abbreviated names find the existing thread. The agent replies under the same headings.#+name starts a new thread even when an existing one is close.+ thread (desktop app); to move it to another, press ▾ beside its thread tag.No thread. A heading still wins, and moves the picker to its thread.New thread in the threads menu starts a thread by name and picks it; Manage threads there merges, archives or deletes threads.Manage threads brings them back, as does a heading naming one. Deleting a thread takes its tag off its messages.Filter the chat, in the threads menu while threads are on, is off at first, so the chat shows every message. On, with a thread picked, the chat shows only that thread's messages, and those since your last message that are in no thread. It is per chat, and kept when the chat is restarted./thread-view, or Open thread view in the threads menu, opens the thread view: a pane with a switch for each thread below its newest message. With none on it shows every message; with some on, only those threads' messages, and those since your last message that are in no thread, as Filter the chat does. The thread picker above the prompt and Filter the chat do not change it./merge-threads <thread> into <thread> also merges two threads.Agent may start threads, in the threads menu while threads are on, lets the agent start threads itself. It is per chat, and kept when the chat is restarted./react turns them off in that chat (again to turn them back on). The choice is per chat, and kept when the chat is restarted./react default switches whether reactions are on by default: in new chats, and in chats where you have not used /react. A chat's own /react choice wins over the default. The default applies to every chat and is kept across restarts./react place under moves it under the message, where its time and copy buttons show; /react place corner moves it back. The choice applies to every chat.hooks/register.tsx 3018 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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 lines1// 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}
270hooks/pane.tsx 1206 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>')
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 lines1import 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
30types/index.d.ts 118 lines1export 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