A side pane that tells you what happened in a long conversation: what was done in a few words, what is waiting on you with the command or link to act on it…

A Claude Code mod for coming back to a long conversation and not knowing what happened. It adds a pane on the right that lists what was done, newest at the top, and keeps a separate list of anything waiting on you.
1 waiting on you highlights outputs every prompt
12 updates · 1 waiting on you · quiet since Fri Oct 9 16:13
Fri Oct 9 ────────────────────────────────────────
16:10
• Opened the release pull request
• Release build passing its checks
15:42
• Fixed the settings screen layout
• Found the spacing bug in the sidebar
The pane has these views:
[open]. One you have not opened yet is in bold; once opened it is dimmed[done]. Under each is what you need to do it: the command, with [copy], and any link, with [open]. A pull request named by number gets its link. Commands are only ever copied, never run. Questions Claude asked in the pane are here too, and you answer them thereNothing is drawn in the pane and nothing is copied: a picture or a file is named, described in a line, and opened where it lies.
Hover an entry and press → to jump to that point in the conversation.
[open] uses open)PATH, used to read a conversation back from its transcriptIn a terminal session of Claude Code, type:
/plugin install trail --marketplace thats2easyyy/claude-trail
Answer y to add the marketplace, then press Enter to install it for yourself. It is active from then on, in every session.
To get a newer version later, run claude plugin update trail@claude-trail in your shell and restart Claude Code. A session that is already open keeps the version it started with.
| Type | What it does |
|---|---|
/trail | Opens the pane and writes the latest updates |
/trail close | Closes it, and keeps it closed in new sessions until you type /trail. A closed pane uses no tokens |
/trail describe | Asks the conversation what its recent pictures show (expensive: see what it costs) |
/trail rebuild | Reads the whole conversation again from its transcript, and looks again at the latest of it for what is waiting on you |
/trail where | Says where this session's trail is kept and how much disk the mod is using |
In a terminal at least 144 columns wide the pane opens by itself when a session starts. A conversation that began before you installed the mod is read back from its transcript the next time you resume it.
Nothing while the pane is closed. The mod notes each step as it happens, which is free, and asks no model for anything.
While the pane is showing, it makes one kind of model call, on the session's own model:
That is the only call it makes by itself.
When you open the pane later, one call writes the latest of what you missed, including what is waiting on you. Anything further back stays unsummarised until you press [summarise].
/trail describe asks the conversation itself what its recent pictures show. That gives a better description, but it re-reads the whole conversation, which in a long one is hundreds of thousands of tokens, so it only ever happens when you type it.
A session nobody is at (claude -p, a script) is ignored entirely.
One small file per conversation, in ~/.claude/trail/<session id>/: its trail, a few kilobytes of text. No pictures or copies of files are kept.
A conversation's folder is removed once it has been idle for as long as Claude Code keeps transcripts (cleanupPeriodDays in your settings, 30 days by default). Versions before 0.5 kept copies of pictures there; those are removed once a conversation has been idle a day. The cleanup runs once a day, from whichever session starts first.
claude plugin validate .
claude plugin test .
To run a working copy instead of the installed one, name its folder in CLAUDE_CODE_PLUGIN_DIRS (in the env block of ~/.claude/settings.json) and uninstall the other.
MIT. See LICENSE.
hooks/register.tsx 1228 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Beat, Mode, OutputType, Picked, Question, Saved, Shot, Source, Trail } from '../types'
5import { adopt, backlogOf, feedIn, nextJob, parseBeats, parseCleared, parsePictures, SYSTEM, withPullLinks } from './digest.ts'
6import { captionJob, parseCaptions } from './caption.ts'
7import { candidates, reach } from './jump.ts'
8import { isSession, keepDays, prunable, sized, staleCopies } from './prune.ts'
9import type { Kept } from './prune.ts'
10import { add, basename, clean, emptyTrail, isEarlier, mended, outputType, paneAnswer, publishedAt, SENT, tag, toolNote, videosNamed, waitingCount } from './trail.ts'
11import type { TrailEvent } from './trail.ts'
12import { clock, layout, zoneOf } from './view.ts'
13import type { Target } from './view.ts'
14
15/**
16 * Trail: what this conversation did, as a feed in a side pane.
17 *
18 * Hooks on the conversation's rows and tool calls feed `add`, which folds
19 * them into steps: what was asked, what the model said it was doing with a
20 * count of the work under it, the pictures and files it looked at or made,
21 * how each turn came out. Noting these costs nothing. While the pane is
22 * showing, and only then, a model reads the steps a stretch at a time and
23 * writes an update for each, a few sentences on what was done and what came
24 * of it, the way a match is told as it goes, and says what is left waiting
25 * on the person. Those updates, newest first, with what the work produced
26 * between them, are what the pane lists; what the person asked is one key
27 * away. Nothing is drawn and nothing is copied: a picture or a file is told
28 * of in words and opened where it lies, and reads as new until it has been.
29 * Claude can also put a question to the person in the pane, and their answer
30 * goes back as their next message.
31 *
32 * The trail is kept in a file, by session, so it is there when the session
33 * is opened again; a session that ran before the mod did is read back from
34 * its transcript file by `tools/backfill.ts`.
35 */
36
37const PANE = 'trail'
38const TITLE = 'Trail'
39const COLUMNS = 62
40/** Cells of empty margin each side of the pane's body. */
41const MARGIN = 1
42/** The pane is drawn again for a change no more often than this. */
43const PUSH_MS = 800
44const SAVE_MS = 5000
45const RM = '/bin/rm'
46const DU = '/usr/bin/du'
47/** How often, across every session on the machine, what is past keeping is looked for; and how long after a session starts. */
48const PRUNE_EVERY_MS = 86_400_000
49const PRUNE_AFTER_MS = 8000
50/** How often a drawing of the pane may set the writing of updates going. */
51const NUDGE_MS = 5000
52const COPIES_AT_ONCE = 40
53const OPEN = '/usr/bin/open'
54const DATE = '/bin/date'
55const REBUILD_MS = 120_000
56const USAGE = 'Usage: /trail [close|describe|rebuild|where]'
57const OPENED = 'Trail opened. Point at an entry and click the arrow beside its time to jump to it in the conversation.'
58/**
59 * The way to an entry's place in the conversation: an arrow beside its time,
60 * there only while the pointer is over the entry. Actions are a word or two
61 * at most, so the pane is the work and not its controls.
62 */
63const GO = '→'
64/** The same, where no pointer rests: on a question, which is not passed over but answered. */
65const GO_WORD = '[jump]'
66const NOTED = 60
67const JOURNAL_MOST = 5000
68/** Highlights are written a moment after there is something to write them from, one stretch at a time. */
69const DIGEST_MS = 1500
70const DIGEST_NEXT_MS = 300
71const DIGEST_RETRY_MS = 60_000
72const DIGEST_TIMEOUT_MS = 90_000
73const DIGEST_TOKENS = 1400
74/** Captions are asked for a little after a turn ends, while the conversation is fresh in the provider's cache. */
75/**
76 * The session's own model writes the updates: a stretch is a page of log and a few lines of reply, and a
77 * small model was seen to report what was asked for as done. The small one stands in where the session's cannot be asked.
78 */
79const DIGEST_FALLBACK = 'haiku'
80/** The tool as the model calls it. Matched by pattern: the engine's list of tool names is written before this one exists. */
81const ASK_TOOL = /^mcp__trail__ask$/
82const ASK_DESCRIPTION = [
83 'Puts a question to the person in their Trail side pane, where it stays until they answer, optionally with a picture it is about for them to open.',
84 'Use it whenever you need them to confirm or choose something, and always when the question is about an image: pass the image so they can open it from the question ("Is this the garage where you put the letter H?").',
85 'It is the right way to ask when they may be away from the conversation. It does not wait: their answer arrives later as their next message, so end your turn if you cannot go on without it, or carry on with what does not depend on it.',
86].join(' ')
87const ASK_SCHEMA = {
88 type: 'object',
89 properties: {
90 question: { type: 'string', description: 'The question, in one or two plain sentences.' },
91 options: { type: 'array', items: { type: 'string' }, description: 'Up to four short answers to offer as buttons, such as "Yes" and "No". They can always type their own.' },
92 image: { type: 'string', description: 'Absolute path of a picture the question is about, for the person to open from it.' },
93 },
94 required: ['question'],
95}
96
97const TYPED_MEANWHILE = /The user sent a new message while you were working:\s*([\s\S]*?)\s*(?:\n\s*This is how Claude Code surfaces|<\/system-reminder>|$)/
98
99const EMPTY: Saved = { v: 5, sid: '', trail: emptyTrail() }
100const NONE: Picked = { id: '' }
101const saved = atom({ plugin: 'trail', key: 'saved' } as const, EMPTY)
102const picked = atom({ plugin: 'trail', key: 'picked' } as const, NONE)
103const page = atom({ plugin: 'trail', key: 'page' } as const, 0)
104const mode = atom({ plugin: 'trail', key: 'mode' } as const, 'feed')
105const status = atom({ plugin: 'trail', key: 'status' } as const, '')
106
107type Dollar = EngineInterface
108type Block = { type: string; [field: string]: unknown }
109type Message = { type: string; name?: string; isMeta?: true; content: readonly Block[] }
110type Held = { data: string; type: string }
111
112// The engine reads this file to see what the mod calls: `$` may only be handed to functions declared here at
113// the top of it, so everything that needs `$` is one, and what they share is the module's own.
114
115/** The trail as it stands; the session's state has it as of the last push, the file as of the last save. */
116let live: Trail = emptyTrail()
117/** The session the trail is of, and where its file and pictures are; empty until `begin` has run. */
118let sid = ''
119let dir = ''
120let zone = 0
121let starting: Promise<void> | null = null
122let isRebuilding = false
123/**
124 * What happened since the transcript file was last trusted to hold everything: kept from the moment a trail
125 * begins until its transcript has been read back, to lay over what the file held.
126 */
127let journal: TrailEvent[] | null = null
128let pushTimer: Timer | null = null
129let lastSavedAt = 0
130
131let digestTimer: Timer | null = null
132let isDigesting = false
133/** The model the updates are written with, once one has answered. */
134let digestModel = ''
135/** Steps a model was asked about and gave nothing readable for: they stay as steps rather than be asked about again. */
136const skipped = new Set<string>()
137
138/** The person asked for the updates of what went untold while the pane was closed: written until there are none left. */
139let wantsEarlier = false
140/** When a drawing of the pane last set the writing of updates going: a pane brought to the front has no other event. */
141let nudgedAt = 0
142/**
143 * Nobody is at this session: it was started to run one prompt and print the answer (`claude -p`, a script, another
144 * tool's call). It has no pane to show and may be one of hundreds, so nothing of it is noted, kept or put to a
145 * model. Picked up again by a person, it is read back from its transcript like any session that ran without the mod.
146 */
147let isUnwatched = false
148let isWatchSettled = false
149
150/**
151 * Whether somebody is at this session. The session's start says so; an event
152 * that comes before it settles the same by whether the session draws anywhere.
153 */
154const watched = async ($: Dollar): Promise<boolean> => {
155 if (!isWatchSettled) {
156 isWatchSettled = true
157 const surfaces = await $.session.surfaces().catch(() => null)
158 if (surfaces !== null && surfaces.length === 0) isUnwatched = true
159 }
160 return !isUnwatched
161}
162let isCaptioning = false
163
164/** Questions the person has answered in the pane: a second press before the pane is drawn again sends nothing more. */
165const answered = new Set<string>()
166
167/** A folded run's id in the transcript, by each tool call in it: what a jump to a folded call scrolls to. */
168const groupsByCall = new Map<string, string>()
169
170/** What the mod saw, for whoever is working on it: written beside the trail as diag.json. */
171const diag = {
172 appends: [] as string[],
173 jumps: [] as string[],
174 shots: [] as string[],
175 rebuilds: [] as string[],
176 digests: [] as string[],
177 captions: [] as string[],
178 questions: [] as string[],
179 prunes: [] as string[],
180 faults: [] as string[],
181}
182
183const jot = (list: string[], line: string) => {
184 list.push(line)
185 if (list.length > NOTED) list.shift()
186}
187
188const fault = ($: Dollar, where: string, error: unknown) => {
189 jot(diag.faults, `${where}: ${String(error)}`)
190 $.ui.log(`trail: ${where}: ${String(error)}`, { to: 'debug' })
191}
192
193/** Why a call on the engine did not do what was asked: what it answered, or what it threw. */
194const whyNot = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 120)
195
196
197const save = async ($: Dollar, now: number) => {
198 if (!sid || !dir) return
199 lastSavedAt = now
200 const kept: Saved = { v: 5, sid, trail: live }
201 await $.fs.write(`${dir}/trail.json`, JSON.stringify(kept))
202 await $.fs.write(`${dir}/diag.json`, JSON.stringify({ at: now, sid, zone, items: live.items.length, updates: live.feed.length, model: digestModel, ...diag }, null, 1))
203}
204
205/** Hands the trail to the session's state, which draws the pane again, and to its file when that is due. */
206const push = async ($: Dollar, isSaving: boolean) => {
207 pushTimer?.cancel()
208 pushTimer = null
209 try {
210 const kept: Saved = { v: 5, sid, trail: live }
211 await update($, saved, () => kept)
212 const now = await $.clock.now()
213 if (isSaving || now - lastSavedAt >= SAVE_MS) await save($, now)
214 } catch (error) {
215 fault($, 'push', error)
216 }
217}
218
219const soon = ($: Dollar) => {
220 if (!pushTimer) pushTimer = $.clock.after(PUSH_MS, () => void push($, false))
221}
222
223const tell = async ($: Dollar, text: string) => {
224 try {
225 await update($, status, () => text)
226 } catch {
227 // The line is a courtesy; the feed goes on without it.
228 }
229}
230
231/**
232 * Puts a stretch of steps to a model and resolves its reply, or null when no
233 * model gave one. The first model to answer is the one asked from then on.
234 */
235const complete = async ($: Dollar, prompt: string): Promise<string | null> => {
236 const names = digestModel ? [digestModel] : [await $.session.model().catch(() => ''), DIGEST_FALLBACK]
237 for (const model of names.filter((name, index) => name !== '' && names.indexOf(name) === index)) {
238 try {
239 const reply = await $.model.complete({ model, system: SYSTEM, prompt, maxTokens: DIGEST_TOKENS, effort: 'low', timeoutMs: DIGEST_TIMEOUT_MS })
240 if (reply.isAnswered) {
241 digestModel = model
242 jot(diag.digests, `${model} in=${reply.usage.input_tokens} out=${reply.usage.output_tokens}`)
243 return reply.text
244 }
245 jot(diag.digests, `${model}: ${reply.reason}${reply.reason === 'api-error' ? ` ${reply.error} ${reply.status}` : ''}`)
246 // A call cut short says nothing about the model; one the provider refused may fare better on another.
247 if (reply.reason === 'aborted') return null
248 } catch (error) {
249 jot(diag.digests, `${model} refused: ${whyNot(error)}`)
250 }
251 }
252 return null
253}
254
255/**
256 * Writes the updates for the steps that are ready for them, one model call at
257 * a time and never from inside a hook the conversation is waiting on, and only
258 * while the pane is showing: a closed pane costs nothing. Opened, it writes
259 * the latest of what went untold; the history behind that waits until the
260 * person asks for it. Resolves how long to wait before the next.
261 */
262const digest = async ($: Dollar): Promise<number | null> => {
263 if (isDigesting || isRebuilding || !sid) return null
264 // The pane is the switch: nothing is asked of a model unless the person has the feed in front of them.
265 if (!(await isPaneShown($))) return null
266 const job = nextJob(live, skipped)
267 if (!job) {
268 wantsEarlier = false
269 await tell($, '')
270 return null
271 }
272 // What went untold while the pane was closed is history once the latest is told: written only when asked for.
273 if (job.prompt && !job.isLatest && !wantsEarlier) {
274 await tell($, '')
275 return null
276 }
277 const of = sid
278 isDigesting = true
279 try {
280 let beats: Beat[] = []
281 let cleared: string[] = []
282 let captions: { id: string; text: string }[] = []
283 if (job.prompt) {
284 await tell($, 'writing the latest update…')
285 const reply = await complete($, job.prompt)
286 if (of !== sid) return null
287 if (reply === null) {
288 // No model answered: the steps are shown as they are, and a while later it is tried again.
289 await tell($, 'updates are waiting on a model')
290 return DIGEST_RETRY_MS
291 }
292 // A pull request named by number gets its link, where the session's repository says where that is.
293 beats = withPullLinks(parseBeats(reply, job), await remoteOf($))
294 cleared = parseCleared(reply, job)
295 captions = parsePictures(reply, job)
296 if (beats.length === 0) {
297 for (const id of job.ids) skipped.add(id)
298 jot(diag.digests, `unreadable reply: ${reply.slice(0, 160)}`)
299 return DIGEST_NEXT_MS
300 }
301 }
302 live = await linked($, feedIn(live, job, beats, cleared, captions))
303 await push($, true)
304 return DIGEST_NEXT_MS
305 } catch (error) {
306 fault($, 'digest', error)
307 return DIGEST_RETRY_MS
308 } finally {
309 isDigesting = false
310 }
311}
312
313/** After `ms`, writes the updates the steps are ready for, and goes on until none is. */
314const digestSoon = ($: Dollar, ms: number) => {
315 if (digestTimer) return
316 digestTimer = $.clock.after(ms, () => {
317 digestTimer = null
318 void digest($).then(again => {
319 if (again !== null) digestSoon($, again)
320 })
321 })
322}
323
324/**
325 * Asks the conversation itself what its pictures show and what they were
326 * about, and sets the answers under them. The question is put over the whole
327 * transcript, so it is asked only just after a turn has ended, when the
328 * provider still has that transcript at hand, and for all the pictures at once.
329 */
330const isPaneShown = ($: Dollar): Promise<boolean> =>
331 $.ui.panes().then(
332 panes => panes.some(pane => pane.id === PANE && pane.isShown),
333 () => false,
334 )
335
336/**
337 * Asks the conversation itself what its pictures show, which is the one call
338 * that sees them and the one that costs: it reads the whole conversation
339 * again. So it is never made unasked: only when the person types
340 * `/trail describe`. Otherwise a picture keeps the caption the update's model
341 * made out from the words around it, which costs next to nothing. Resolves
342 * how many pictures it captioned, or null when it could not ask.
343 */
344const caption = async ($: Dollar): Promise<number | null> => {
345 if (isCaptioning || isRebuilding || !sid) return null
346 const job = captionJob(live, at => clock(at, zone))
347 if (!job) return 0
348 const of = sid
349 isCaptioning = true
350 try {
351 const reply = await $.model.fork({ prompt: job.prompt })
352 if (of !== sid) return null
353 if (!reply.isAnswered) {
354 jot(diag.captions, `no answer: ${reply.reason}`)
355 return null
356 }
357 const told = parseCaptions(reply.text, job)
358 jot(diag.captions, `${told.filter(one => one.text).length} of ${job.shots.length} captioned, cache read ${reply.usage.cache_read_input_tokens}, in ${reply.usage.input_tokens}, out ${reply.usage.output_tokens}`)
359 for (const one of told) await record($, now => ({ t: 'caption', id: one.id, at: now, text: one.text }))
360 await push($, true)
361 return told.filter(one => one.text).length
362 } catch (error) {
363 fault($, 'caption', error)
364 return null
365 } finally {
366 isCaptioning = false
367 }
368}
369
370const load = async ($: Dollar): Promise<Trail | null> => {
371 try {
372 const path = `${dir}/trail.json`
373 if (!(await $.fs.exists(path))) return null
374 const kept = JSON.parse(await $.fs.read(path)) as Partial<Saved>
375 return kept.v === 5 && kept.sid === sid ? mended(kept.trail) : null
376 } catch (error) {
377 fault($, 'load', error)
378 return null
379 }
380}
381
382/**
383 * Reads the session's transcript file back into a trail, pictures included: a
384 * host command, since the file is far larger than a mod may read. What only
385 * the mod knew (the updates written so far, the answers given in the pane)
386 * is carried over. Asked for by the person, the latest of it is put to the
387 * model again, which is how a session told before the mod knew to look finds
388 * what is waiting on them. Resolves why not when it could not ('' when it did).
389 */
390const rebuild = async ($: Dollar, isAsked = false): Promise<string> => {
391 if (isRebuilding) return 'already rebuilding'
392 const [of, into] = [sid, dir]
393 if (!of || !into) return 'no session yet'
394 isRebuilding = true
395 journal ??= []
396 let why = ''
397 try {
398 const had = live
399 // Run from the mod's own folder: in the session's, a project's pin may pick a Node that cannot run the tool.
400 const ran = await $.process.run(['node', `${$.plugin.root}/tools/backfill.ts`, of, into], { cwd: $.plugin.root, timeoutMs: REBUILD_MS })
401 const built = ran.exitCode === 0 && of === sid ? await load($) : null
402 if (ran.exitCode !== 0) why = (ran.stderr || ran.stdout || `exit ${ran.exitCode}`).trim().slice(-200)
403 else if (of !== sid) why = 'the session changed meanwhile'
404 else if (!built) why = 'nothing readable was written'
405 else {
406 live = await linked($, journal.reduce(add, adopt(built, had, isAsked)))
407 await push($, true)
408 }
409 jot(diag.rebuilds, why || ran.stdout.trim().slice(0, 300))
410 } catch (error) {
411 why = whyNot(error)
412 fault($, 'rebuild', error)
413 } finally {
414 isRebuilding = false
415 journal = null
416 }
417 digestSoon($, DIGEST_MS)
418 return why
419}
420
421/**
422 * Removes what the mod kept for other sessions that is past keeping: the
423 * pictures of one idle a week, the whole folder of one idle as long as Claude
424 * Code keeps a transcript, and in those that stay the whole-size copies made
425 * a week ago or more. Once a day at most, whichever session comes to it
426 * first, and never this session's own. Only folders the mod made, named by a
427 * session's id inside the mod's own folder, are ever removed.
428 */
429const prune = async ($: Dollar) => {
430 try {
431 const root = dir.slice(0, dir.lastIndexOf('/'))
432 if (!sid || !root.startsWith('/') || !root.endsWith('/.claude/trail')) return
433 const now = await $.clock.now()
434 const last = await $.store.get('prunedAt')
435 if (typeof last === 'number' && now - last < PRUNE_EVERY_MS) return
436 await $.store.set('prunedAt', now)
437 const kept: Kept[] = []
438 for (const entry of await $.fs.list(root)) {
439 if (entry.kind !== 'dir') continue
440 const at = `${root}/${entry.name}`
441 const written = (path: string) =>
442 $.fs.stat(path).then(
443 stat => stat.mtimeMs,
444 () => 0,
445 )
446 kept.push({ name: entry.name, touchedAt: (await written(`${at}/trail.json`)) || (await written(at)), hasShots: await $.fs.exists(`${at}/shots`).catch(() => false) })
447 }
448 const settings = (await $.settings.read().catch(() => ({}))) as Record<string, unknown>
449 const { folders, pictures } = prunable(kept, now, sid, keepDays(settings['cleanupPeriodDays']))
450 for (const name of folders) await $.process.run([RM, '-rf', `${root}/${name}`], { timeoutMs: 60_000 })
451 for (const name of pictures) await $.process.run([RM, '-rf', `${root}/${name}/shots`], { timeoutMs: 60_000 })
452 // In the sessions that stay, this one included, a whole-size copy goes a week after it was made.
453 let copies = 0
454 for (const one of kept) {
455 if (!isSession(one.name) || !one.hasShots || folders.includes(one.name) || pictures.includes(one.name)) continue
456 const shots = `${root}/${one.name}/shots`
457 const stale = staleCopies(await $.fs.list(shots).catch(() => []), now)
458 for (let from = 0; from < stale.length; from += COPIES_AT_ONCE) await $.process.run([RM, '-f', ...stale.slice(from, from + COPIES_AT_ONCE).map(name => `${shots}/${name}`)], { timeoutMs: 60_000 })
459 copies += stale.length
460 }
461 jot(diag.prunes, `${kept.length} sessions kept: removed ${folders.length} whole, the pictures of ${pictures.length}, ${copies} whole-size copies`)
462 } catch (error) {
463 fault($, 'prune', error)
464 }
465}
466
467/** How much the mod keeps on disk, for `/trail where`: this session's folder and all of them together. */
468const keptSize = async ($: Dollar): Promise<string> => {
469 try {
470 const root = dir.slice(0, dir.lastIndexOf('/'))
471 const ran = await $.process.run([DU, '-sk', dir, root], { timeoutMs: 20_000 })
472 const [own, all] = ran.stdout.split('\n').map(line => Number(line.trim().split(/\s+/)[0]))
473 return own !== undefined && all !== undefined && Number.isFinite(own) && Number.isFinite(all) ? ` (${sized(own)}; every conversation together ${sized(all)})` : ''
474 } catch {
475 return ''
476 }
477}
478
479const zoneNow = async ($: Dollar): Promise<number> => {
480 try {
481 const ran = await $.process.run([DATE, '+%z'], { timeoutMs: 5000 })
482 const minutes = zoneOf(ran.stdout)
483 if (minutes !== undefined) return minutes
484 } catch {
485 // Without the host's answer the module's own idea of the zone has to do.
486 }
487 return -new Date().getTimezoneOffset()
488}
489
490/** Finds this session's trail: in the state after a reload, else in its file, else in its transcript. */
491const begin = async ($: Dollar) => {
492 const home = (await $.env.get('HOME')) ?? ''
493 const id = await $.session.id()
494 dir = `${home}/.claude/trail/${id}`
495 zone = await zoneNow($)
496 skipped.clear()
497 const held = await read($, saved)
498 const again = held.v === 5 && held.sid === id ? mended(held.trail) : null
499 sid = id
500 if (again) {
501 live = await linked($, again)
502 digestSoon($, DIGEST_MS)
503 // Told before the mod looked for what waits on the person: its latest is read once more, for that.
504 if (!live.sought) $.clock.after(50, () => void rebuild($, true))
505 $.clock.after(PRUNE_AFTER_MS, () => void prune($))
506 return
507 }
508 live = (await load($)) ?? emptyTrail()
509 journal = []
510 await update($, picked, () => NONE)
511 await update($, page, () => 0)
512 await push($, false)
513 // The file may be from before the session went on without the mod; its transcript has all of it.
514 $.clock.after(PRUNE_AFTER_MS, () => void prune($))
515 const isBehind = !live.sought
516 $.clock.after(50, () => void rebuild($, isBehind).then(why => (why ? $.ui.log(`trail: could not read this session's transcript back (${why}); /trail rebuild tries again`) : undefined)))
517}
518
519const ready = async ($: Dollar) => {
520 if (sid) return
521 starting ??= begin($).finally(() => {
522 starting = null
523 })
524 await starting
525}
526
527/** Folds one thing that happened into the trail, never letting a fault of this mod's reach what it is watching. */
528const record = async ($: Dollar, made: (now: number) => TrailEvent | null) => {
529 try {
530 await ready($)
531 const event = made(await $.clock.now())
532 if (!event) return
533 if (journal && journal.length < JOURNAL_MOST) journal.push(event)
534 const next = add(live, event)
535 if (next === live) return
536 live = next
537 soon($)
538 // A turn that has ended, or a running one that has said enough, has a stretch to write an update for.
539 if (event.t === 'end' || event.t === 'say') digestSoon($, DIGEST_MS)
540 } catch (error) {
541 fault($, 'record', error)
542 }
543}
544
545/**
546 * Notes a picture or a file for the trail: where it lies, and when it was last
547 * written, so it is opened only while it is still the thing that was noted.
548 * Nothing is copied and nothing is drawn: the pane tells of it in words, and
549 * the person opens the file itself. One that never was a file (a picture the
550 * person pasted, a browser's screenshot) is told of and has nothing to open.
551 */
552const locate = async ($: Dollar, source: string, type?: OutputType): Promise<Shot> => {
553 const none: Shot = { file: '', w: 0, h: 0, ...(type ? { type } : {}) }
554 if (!source.startsWith('/')) return none
555 const lies = await $.fs.stat(source).catch(() => null)
556 return lies?.kind === 'file' ? { ...none, full: source, stamp: Math.round(lies.mtimeMs) } : none
557}
558
559/** Something the work produced that is no picture: a video, a file handed over, a page published. Set in the trail where it happened. */
560const output = async ($: Dollar, id: string, shot: Shot, label: string, anchor: string, note?: string) => {
561 await record($, now => ({ t: 'shot', id, at: now, anchor, file: shot.file, w: shot.w, h: shot.h, label, ...(shot.full ? { full: shot.full } : {}), ...(shot.stamp !== undefined ? { stamp: shot.stamp } : {}), ...(shot.type ? { type: shot.type } : {}), ...(note ? { note } : {}) }))
562}
563
564/**
565 * The videos a command left behind: files it names, in what it ran or what
566 * it printed, that were written while it ran. Rendering and recording make
567 * these, and no tool ever opens them, so this is the only place they show.
568 */
569const heedVideos = async ($: Dollar, id: string, text: string, since: number) => {
570 try {
571 const named = videosNamed(text)
572 if (named.length === 0) return
573 const home = (await $.env.get('HOME')) ?? ''
574 const cwd = await $.session.cwd()
575 for (const name of named) {
576 const path = name.startsWith('/') ? name : name.startsWith('~/') ? home + name.slice(1) : `${cwd}/${name.replace(/^\.\//, '')}`
577 const made = await $.fs.stat(path).catch(() => null)
578 // One that was there before the command ran was only named by it, not made by it.
579 if (!made || made.kind !== 'file' || made.size === 0 || made.mtimeMs < since - 2000) continue
580 const key = `out:${tag(path)}:${Math.round(made.mtimeMs)}`
581 if (live.items.some(item => item.id === `${key}:shot`)) continue
582 await output($, key, await locate($, path, 'video'), basename(path), id)
583 }
584 } catch (error) {
585 fault($, 'heedVideos', error)
586 }
587}
588
589/** A picture the model looked at or sent, set in the trail where it happened. Resolves whether the file was there. */
590const shoot = async ($: Dollar, id: string, source: string, label: string, anchor: string, from?: Source, note?: string): Promise<boolean> => {
591 try {
592 const shot = await locate($, source)
593 if (!shot.full) return false
594 await record($, now => ({ t: 'shot', id, at: now, anchor, file: '', w: 0, h: 0, label, full: shot.full!, ...(shot.stamp !== undefined ? { stamp: shot.stamp } : {}), ...(from ? { from } : {}), ...(note ? { note } : {}) }))
595 return true
596 } catch (error) {
597 fault($, 'shoot', error)
598 return false
599 }
600}
601
602/** The same for a picture that never was a file: a browser's screenshot, one the person pasted. It is told of, with nothing to open. */
603const shootInline = async ($: Dollar, id: string, label: string, anchor: string, from?: Source) => {
604 await record($, now => ({ t: 'shot', id, at: now, anchor, file: '', w: 0, h: 0, label, ...(from ? { from } : {}) }))
605}
606
607const textsOf = (message: Message): string[] =>
608 message.content.filter(block => block.type === 'text' && typeof block['text'] === 'string').map(block => block['text'] as string)
609
610const imagesOf = (blocks: readonly Block[]): Held[] =>
611 blocks.flatMap(block => {
612 const source = block.type === 'image' ? (block['source'] as { data?: unknown; media_type?: unknown } | undefined) : undefined
613 return typeof source?.data === 'string' ? [{ data: source.data, type: typeof source.media_type === 'string' ? source.media_type : 'image/png' }] : []
614 })
615
616/** One row the conversation kept: a prompt, a command, what the model said, a recap, a compaction. */
617const heed = async ($: Dollar, door: string, uuid: string, message: Message, origin: string) => {
618 try {
619 const texts = textsOf(message)
620 jot(diag.appends, `${door} ${message.type}${message.name ? '/' + message.name : ''}${message.isMeta ? ' meta' : ''} ${origin} ${uuid}`)
621 if (door === 'response') {
622 let index = 0
623 for (const text of texts) {
624 const id = index === 0 ? uuid : `${uuid}:${index}`
625 index++
626 await record($, now => ({ t: 'say', id, at: now, text }))
627 }
628 return
629 }
630 if (door === 'notice') {
631 const text = texts[0]
632 if (message.name === 'away_summary' && text) await record($, now => ({ t: 'recap', id: uuid, at: now, text }))
633 return
634 }
635 if (door === 'compaction') {
636 await record($, now => ({ t: 'compact', id: uuid, at: now }))
637 return
638 }
639 // What the person typed while the turn ran reaches it wrapped in the engine's words; theirs are inside.
640 if (door === 'delivery' && message.type === 'attachment' && message.name === 'queued_command') {
641 const typed = TYPED_MEANWHILE.exec(texts.join('\n'))?.[1]
642 if (typed) await record($, now => ({ t: 'prompt', id: uuid, at: now, text: typed }))
643 return
644 }
645 if (message.isMeta || message.type !== 'user' || texts.length === 0) return
646 const text = texts.join('\n')
647 // The mod's own command is no part of the work.
648 if (/<command-name>\s*\/?trail\s*</.test(text)) return
649 await record($, now => ({ t: 'prompt', id: uuid, at: now, text, isCommand: door === 'command' }))
650 let index = 0
651 for (const _ of imagesOf(message.content)) await shootInline($, `${uuid}:${index++}`, 'You pasted a picture', uuid, 'person')
652 } catch (error) {
653 fault($, 'heed', error)
654 }
655}
656
657/** Pictures a connected tool answered with: they are in the row its result is kept as, and nowhere on disk. */
658const heedPictures = async ($: Dollar, message: Message, tool: string) => {
659 try {
660 const short = tool.split('__').slice(2).join(' ') || tool
661 for (const block of message.content) {
662 const id = block['tool_use_id']
663 const inside = block['content']
664 if (block.type !== 'tool_result' || typeof id !== 'string' || !Array.isArray(inside)) continue
665 const held = imagesOf(inside as Block[]).at(-1)
666 if (held) await shootInline($, id, `Picture from ${short}`, id)
667 }
668 } catch (error) {
669 fault($, 'heedPictures', error)
670 }
671}
672
673/** What came of a tool call: a failure to count, or a picture the model looked at or sent. */
674const heedResult = async ($: Dollar, id: string, tool: string, input: Record<string, unknown>, since: number, ran: { deny?: string; isError?: true; result?: unknown; text?: string }) => {
675 const file = typeof input['file_path'] === 'string' ? input['file_path'] : undefined
676 try {
677 if (ran.deny !== undefined || ran.isError === true) {
678 await record($, now => ({ t: 'fail', id, at: now }))
679 return
680 }
681 const result = (typeof ran.result === 'object' && ran.result !== null ? ran.result : {}) as { type?: unknown; attachments?: unknown; file?: { base64?: unknown; type?: unknown } }
682 if (tool === 'Read' && file && result.type === 'image') {
683 // Read finds a file whose name it was given nearly right; the picture it returned is there when the name is not.
684 // A picture that was on disk before the session began was looked at, not made: it is no output of Claude's.
685 const written = await $.fs.stat(file).then(
686 stat => stat.mtimeMs,
687 () => 0,
688 )
689 const from = isEarlier(written, live.items[0]?.at ?? 0) ? ('earlier' as const) : undefined
690 const isKept = await shoot($, id, file, basename(file), id, from)
691 const data = result.file?.base64
692 if (!isKept && typeof data === 'string' && data) await shootInline($, id, basename(file), id, from)
693 }
694 if (tool === 'SendUserFile' && Array.isArray(result.attachments)) {
695 const said = typeof input['caption'] === 'string' ? input['caption'] : undefined
696 let index = 0
697 for (const sent of result.attachments as { path?: unknown; isImage?: unknown }[]) {
698 if (typeof sent.path === 'string') {
699 const key = `${id}:${index}`
700 const label = SENT + basename(sent.path)
701 // Whatever was handed over is an output: a picture is kept as one, anything else by its preview. What
702 // Claude said of it as it sent it is its caption, there at once.
703 if (sent.isImage === true) await shoot($, key, sent.path, label, id, undefined, said)
704 else await output($, key, await locate($, sent.path, outputType(sent.path) === 'video' ? 'video' : 'file'), label, id, said)
705 }
706 index++
707 }
708 }
709 if (tool === 'Bash') await heedVideos($, id, `${typeof input['command'] === 'string' ? input['command'] : ''}\n${ran.text ?? ''}`, since)
710 if (tool === 'Artifact') {
711 const url = publishedAt(ran.text ?? '')
712 if (url) await output($, id, { file: '', w: 0, h: 0, full: url, type: 'link' }, `Published ${typeof input['file_path'] === 'string' ? basename(input['file_path']) : 'a page'}`, id)
713 }
714 } catch (error) {
715 fault($, 'heedResult', error)
716 }
717}
718
719const openPane = ($: Dollar) => $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
720
721/** Moves the pane's own window back to its top, where the newest is; a pane not drawn yet has no window to move. */
722const paneToTop = async ($: Dollar) => {
723 try {
724 await $.ui.scroll({ in: PANE, to: 'start' })
725 } catch (error) {
726 jot(diag.faults, `paneToTop: ${whyNot(error)}`)
727 }
728}
729
730/**
731 * Puts Claude's question in the pane, under its picture when it has one, and
732 * resolves what to tell Claude about it. The pane is brought up for it, since
733 * a question nobody sees is no question.
734 */
735const post = async ($: Dollar, id: string, text: string, options: string[], image: string): Promise<string> => {
736 // The picture a question is about is not drawn: the question has the way to open it.
737 const found = image ? await locate($, image).catch(() => null) : null
738 const shot: Shot | null = found?.full ? found : null
739 await record($, now => ({ t: 'question', id, at: now, text, options, ...(shot ? { shot } : {}) }))
740 await push($, true)
741 jot(diag.questions, `asked ${id}${image ? (shot ? ' with a picture' : ' (picture unreadable)') : ''}`)
742 try {
743 // A question is to be answered before anything else is read: the pane turns to it.
744 await update($, mode, () => ASKING)
745 await update($, page, () => 0)
746 const isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
747 if (isShown) await paneToTop($)
748 else {
749 void openPane($).catch(() => undefined)
750 $.ui.toast('Claude has a question for you: /trail')
751 }
752 } catch (error) {
753 fault($, 'post', error)
754 }
755 const pictured = image && !shot ? ' The picture could not be found, so the question shows without it.' : ''
756 return `The question is in the person's Trail pane.${pictured} Their answer will arrive as their next message: end your turn if you cannot go on without it.`
757}
758
759/** The person's answer to a question in the pane: kept beside it, and sent to the conversation as their own words. */
760const answer = async ($: Dollar, question: Question, text: string) => {
761 try {
762 const said = clean(text)
763 if (!said || answered.has(question.id)) return
764 answered.add(question.id)
765 await record($, now => ({ t: 'answer', id: question.id, at: now, text: said }))
766 // With nothing left waiting the pane goes back to the feed.
767 if (waitingCount(live) === 0) await update($, mode, now => (now === ASKING ? 'feed' : now))
768 await push($, true)
769 jot(diag.questions, `answered ${question.id}`)
770 await $.prompt.submit({ text: paneAnswer(question.text, said), asUser: true })
771 } catch (error) {
772 fault($, 'answer', error)
773 }
774}
775
776/** The session's repository's remote, asked once: '' when it has none. */
777let remote: string | undefined
778const remoteOf = async ($: Dollar): Promise<string> => {
779 remote ??= await $.session.repo().then(
780 repo => repo?.remote ?? '',
781 () => '',
782 )
783 return remote
784}
785
786/** A trail whose things waiting that name pull requests by number have the link to each: one waiting since before the mod gave links gains them. */
787const linked = async ($: Dollar, trail: Trail): Promise<Trail> => {
788 const feed = withPullLinks(trail.feed, await remoteOf($))
789 return feed.some((beat, index) => beat !== trail.feed[index]) ? { ...trail, feed } : trail
790}
791
792/** Puts a command a thing waiting names on the clipboard. It is never run from the pane: the person runs it, where and when they choose. */
793const copyRef = async ($: Dollar, text: string) => {
794 try {
795 const copied = await $.ui.copy({ text })
796 $.ui.toast(copied.isCopied ? 'Copied.' : `Could not copy: ${copied.reason}`)
797 } catch (error) {
798 fault($, 'copyRef', error)
799 }
800}
801
802/** Opens a link a thing waiting names, in the person's browser. */
803const openLink = async ($: Dollar, url: string) => {
804 try {
805 if (!isLink(url)) return
806 const ran = await $.process.run([OPEN, url], { timeoutMs: 10_000 })
807 if (ran.exitCode !== 0) $.ui.toast(`Trail could not open it: ${ran.stderr.trim().slice(0, 80)}`)
808 } catch (error) {
809 fault($, 'openLink', error)
810 }
811}
812
813const isLink = (ref: string): boolean => /^https?:\/\/\S+$/.test(ref)
814
815/** The person says a thing the work left for them is done: it stops waiting. */
816const settle = async ($: Dollar, id: string) => {
817 try {
818 const now = await $.clock.now()
819 live = { ...live, feed: live.feed.map(beat => (beat.id === id && beat.over === undefined ? { ...beat, over: now } : beat)) }
820 if (waitingCount(live) === 0) await update($, mode, at => (at === ASKING ? 'feed' : at))
821 await push($, true)
822 } catch (error) {
823 fault($, 'settle', error)
824 }
825}
826
827/**
828 * Scrolls the transcript to the row a line stands for. The scroll is the
829 * first thing awaited: the transcript is the person's to move, and only what
830 * answers their press may move it.
831 */
832const jump = async ($: Dollar, target: Target) => {
833 try {
834 const { why, tried } = await reach(candidates(target, groupsByCall), id =>
835 $.ui.scroll({ to: { requestId: id }, block: 'start' }).then(
836 moved => moved.deny,
837 error => whyNot(error),
838 ),
839 )
840 jot(diag.jumps, `${target.kind} ${target.id} ${tried.join(' | ')}`)
841 await update($, picked, now => ({ id: target.id, jump: why, ...(now.id === target.id && now.isOpen ? { isOpen: true as const } : {}) }))
842 } catch (error) {
843 fault($, 'jump', error)
844 }
845}
846
847/** The person asks for the updates of what happened while the pane was closed, further back than the latest. */
848const writeEarlier = async ($: Dollar) => {
849 wantsEarlier = true
850 await tell($, 'writing the earlier updates…')
851 digestSoon($, 50)
852}
853
854/** Shows all an entry has to say, or goes back to its first rows when it is the one shown whole. */
855const unfold = async ($: Dollar, target: Target) => {
856 try {
857 await update($, picked, now => (now.id === target.id && now.isOpen ? NONE : { id: target.id, isOpen: true }))
858 } catch (error) {
859 fault($, 'unfold', error)
860 }
861}
862
863/**
864 * Opens an output in the person's own viewer: the thing itself where it
865 * lies, or the small copy the mod kept when a picture's file is gone or has
866 * been written over since (the same name now holds another picture).
867 */
868const openPicture = async ($: Dollar, shot: Shot, id?: string) => {
869 try {
870 // Opened from the pane, an output is read from then on, whatever comes of opening it.
871 if (id) {
872 await record($, now => ({ t: 'opened', id, at: now }))
873 await push($, true)
874 }
875 let path = shot.full || shot.file
876 if (shot.full && shot.type !== 'link') {
877 const lies = await $.fs.stat(shot.full).catch(() => null)
878 const isSame = lies?.kind === 'file' && (shot.stamp === undefined || Math.round(lies.mtimeMs) === shot.stamp)
879 // A video or a document has no copy to fall back on: what is there now is opened, if anything is.
880 if (!isSame && shot.file) {
881 path = shot.file
882 $.ui.toast(lies ? 'The file has changed since: opening the copy Trail kept.' : 'The file is gone: opening the copy Trail kept.')
883 } else if (!lies) {
884 $.ui.toast(`Trail could not open it: ${basename(shot.full)} is no longer there.`)
885 return
886 }
887 }
888 if (!path) return
889 const ran = await $.process.run([OPEN, path], { timeoutMs: 10_000 })
890 if (ran.exitCode !== 0) $.ui.toast(`Trail could not open it: ${ran.stderr.trim().slice(0, 80)}`)
891 } catch (error) {
892 fault($, 'openPicture', error)
893 }
894}
895
896const turnPage = async ($: Dollar, by: number) => {
897 try {
898 await update($, page, at => Math.max(0, by === 0 ? 0 : at + by))
899 await paneToTop($)
900 } catch (error) {
901 fault($, 'turnPage', error)
902 }
903}
904
905/** The view for what waits on the person, questions and things left for them to do: among the tabs only while something does. */
906const ASKING: Mode = 'questions'
907
908const EMPTY_VIEW: Record<Mode, string> = {
909 questions: 'Nothing is waiting on you.',
910 feed: 'Nothing yet. The feed fills in as the conversation goes.',
911 outputs: 'Nothing made yet. What Claude produces shows here by name, to open: screenshots of its work, videos, files it sends you, pages it publishes. What you have not opened yet is in bold.',
912 steps: 'Nothing yet. Each thing you ask shows here.',
913}
914
915/** The views, as the tabs name them. No hotkeys: their letters beside each name were more to read than the names. */
916const TABS: readonly { mode: Mode; label: string }[] = [
917 { mode: 'feed', label: 'highlights' },
918 { mode: 'outputs', label: 'outputs' },
919 { mode: 'steps', label: 'every prompt' },
920]
921
922const switchMode = async ($: Dollar, to: Mode) => {
923 try {
924 await update($, mode, () => to)
925 await update($, page, () => 0)
926 await update($, picked, () => NONE)
927 await paneToTop($)
928 } catch (error) {
929 fault($, 'switchMode', error)
930 }
931}
932
933export const register: Register = on => {
934 on('session.start', async ($, e, next) => {
935 isUnwatched = e.isInteractive === false
936 isWatchSettled = true
937 if (isUnwatched) return next(e)
938 await $.command.register({
939 name: 'trail',
940 description: 'Show what this conversation did as a feed in a side pane; click a line to jump there',
941 argumentHint: '[close|describe|rebuild|where]',
942 immediate: true,
943 })
944 await $.tool.register({ name: 'ask', description: ASK_DESCRIPTION, inputSchema: ASK_SCHEMA })
945 try {
946 await ready($)
947 const isClosed = (await $.store.get('closed')) === true
948 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
949 // Unasked, so the engine seats it only where there is room for a sidebar; /trail seats it anywhere.
950 if (!isClosed && !isUp) void openPane($).catch(() => undefined)
951 } catch (error) {
952 fault($, 'session.start', error)
953 }
954
955 return next(e)
956 })
957
958 on('command.run', { command: 'trail' }, async ($, e) => {
959 const verb = e.args.trim().toLowerCase()
960 await ready($)
961 if (verb === 'close') {
962 await $.store.set('closed', true)
963 await $.ui.close({ id: PANE })
964 return { text: 'Trail closed.' }
965 }
966 if (verb === 'where') return { text: `Trail keeps this session in ${dir}${await keptSize($)}` }
967 if (verb === 'describe') {
968 // The person asks for it, knowing what it costs: the conversation reads itself again to say what its pictures show.
969 const count = await caption($)
970 return { text: count === null ? 'Trail could not ask the conversation about its pictures just now. Try again once it is at rest.' : count === 0 ? 'Every recent picture already has a caption from the conversation itself.' : `Trail asked the conversation what its pictures show: ${count} captioned.` }
971 }
972 if (verb === 'rebuild') {
973 const why = await rebuild($, true)
974 if (why) return { text: `Trail could not read the transcript back: ${why}` }
975 } else if (verb !== '' && verb !== 'open') return { text: USAGE }
976
977 await $.store.set('closed', false)
978 await push($, false)
979 const opened = await openPane($)
980 if (!opened.isPlaced) return { text: `Trail is open but not shown: ${opened.reason}` }
981 await paneToTop($)
982 // Opened, the feed catches up on the latest of what happened while it was closed.
983 digestSoon($, 50)
984
985 return { text: verb === 'rebuild' ? `Trail rebuilt from the transcript: ${live.items.length} steps.` : OPENED }
986 })
987
988 on('session.append', { door: ['prompt', 'command', 'response', 'delivery', 'compaction', 'notice'] }, async ($, e, next) => {
989 // The row is the session's to store: it is read on its way there and passed on as it came.
990 if (e.agentId === undefined && (await watched($))) await heed($, e.door, e.uuid, e.message, e.origin.kind)
991
992 return next(e)
993 })
994
995 on('session.append', { door: 'tool-result', origin: { kind: 'tool', tool: /^mcp__/ } }, async ($, e, next) => {
996 if (e.agentId === undefined && (await watched($))) await heedPictures($, e.message, e.origin.kind === 'tool' ? e.origin.tool : '')
997
998 return next(e)
999 })
1000
1001 on('tool.call', { tool: ASK_TOOL }, async ($, e) => {
1002 const input = e as unknown as Record<string, unknown>
1003 const question = typeof input['question'] === 'string' ? clean(input['question']) : ''
1004 if (!question) return { isError: true, result: 'No question was given: pass `question`.' }
1005 const options = Array.isArray(input['options']) ? input['options'].filter((option): option is string => typeof option === 'string') : []
1006 const image = typeof input['image'] === 'string' ? input['image'] : ''
1007 try {
1008 return { result: await post($, e.tool_use_id, question, options, image) }
1009 } catch (error) {
1010 fault($, 'ask', error)
1011 return { isError: true, result: `The question could not be put in the pane: ${whyNot(error)}. Ask it in the conversation instead.` }
1012 }
1013 })
1014
1015 on('tool.call', async ($, e, next) => {
1016 if (e.agentId !== undefined || !(await watched($))) return next(e)
1017 const id = e.tool_use_id
1018 const tool = String(e.tool)
1019 const input = e as unknown as Record<string, unknown>
1020 const file = typeof input['file_path'] === 'string' ? input['file_path'] : undefined
1021 const since = await $.clock.now().catch(() => 0)
1022 await record($, now => ({ t: 'tool', id, at: now, tool, note: toolNote(tool, input), file }))
1023 const ran = await next(e)
1024 await heedResult($, id, tool, input, since, ran)
1025
1026 return ran
1027 })
1028
1029 on('turn.complete', async ($, e, next) => {
1030 if (e.agentId === undefined && (await watched($))) {
1031 // A turn the person stopped, or that broke, did not come out as what was last said.
1032 const isCut = e.isAborted || e.reason !== 'answer'
1033 await record($, now => ({ t: 'end', at: now, ...(isCut ? { aborted: true } : {}) }))
1034 // A reload of this module comes when a turn ends, so the trail is handed over and saved before then.
1035 await push($, true)
1036 }
1037
1038 return next(e)
1039 })
1040
1041 on('session.end', async ($, e, next) => {
1042 if (!(await watched($))) return next(e)
1043 await push($, true)
1044 if (e.reason === 'clear' || e.reason === 'resume') {
1045 // The process goes on under another session id, and no session.start follows: the next row begins its trail.
1046 sid = ''
1047 live = emptyTrail()
1048 await update($, saved, () => EMPTY).catch(() => undefined)
1049 }
1050
1051 return next(e)
1052 })
1053
1054 // The engine's word that the process now runs under a session: after /resume or /clear no session.start says so.
1055 on('classic.SessionStart', async ($, e, next) => {
1056 if (!(await watched($))) return next(e)
1057 try {
1058 if (sid && e.session_id !== sid) {
1059 sid = ''
1060 live = emptyTrail()
1061 }
1062 await ready($)
1063 } catch (error) {
1064 fault($, 'classic.SessionStart', error)
1065 }
1066
1067 return next(e)
1068 })
1069
1070 on('ui.close', async ($, e, next) => {
1071 const closed = await next(e)
1072 if (e.id === PANE && e.origin.kind === 'person') await $.store.set('closed', true).catch(() => undefined)
1073
1074 return closed
1075 })
1076
1077 // A run of calls the transcript folded into one line, left as it is: its id is what a jump to one of them scrolls to.
1078 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
1079 for (const call of e.props.calls) if (call.tool_use_id) groupsByCall.set(call.tool_use_id, e.requestId)
1080
1081 return next(e)
1082 })
1083
1084 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1085 const kept = await read($, saved)
1086 const pick = await read($, picked)
1087 const back = await read($, page)
1088 const kind = await read($, mode)
1089 const trail = mended(kept.trail) ?? emptyTrail()
1090 const waiting = waitingCount(trail)
1091 // A pane brought to the front is drawn, and that is all the mod hears of it: the drawing sets the updates going.
1092 if (!digestTimer && !isDigesting && Date.now() - nudgedAt > NUDGE_MS) {
1093 nudgedAt = Date.now()
1094 digestSoon($, 300)
1095 }
1096 // The tab for what waits on the person is there only while something does; with nothing left, the pane is back at the feed.
1097 const tabs = waiting > 0 ? [{ mode: ASKING, label: `${waiting} waiting on you` }, ...TABS] : TABS
1098 const listing: Mode = tabs.some(tab => tab.mode === kind) ? kind : 'feed'
1099 // Steps from while the pane was closed, further back than the latest, are summarised only when asked for.
1100 const untold = listing === 'feed' && !wantsEarlier ? backlogOf(trail) : 0
1101 const doing = await read($, status)
1102 // A cell of margin each side keeps the words off the pane's frame.
1103 const width = Math.max(18, Math.min(200, e.props.bodyColumns) - 2 * MARGIN)
1104 const { rows, earlier, later, foot } = layout(trail, { width, picked: pick, page: back, mode: listing, zone })
1105 const { Box, Button, Text } = $.ui.resolve(e)
1106 const Input = e.surface === 'mobile' ? null : $.ui.resolve(e).Input
1107
1108 return (
1109 <Box flexDirection="column" paddingX={MARGIN}>
1110 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1111 {tabs.map(tab =>
1112 tab.mode === listing ? (
1113 <Text bold inverse>{` ${tab.label} `}</Text>
1114 ) : (
1115 <Button key={`tab:${tab.mode}`} plain dimColor={tab.mode !== ASKING} label={tab.label} onPress={() => void switchMode($, tab.mode)} />
1116 ),
1117 )}
1118 </Box>
1119 <Text dimColor wrap="truncate-end">
1120 {doing ? `${foot} · ${doing}` : foot}
1121 </Text>
1122 {later > 0 && (
1123 <Box flexDirection="row" columnGap={2}>
1124 <Button key="later" plain dimColor label={`[${later} later]`} onPress={() => void turnPage($, -1)} />
1125 <Button key="latest" plain dimColor label="[latest]" onPress={() => void turnPage($, 0)} />
1126 </Box>
1127 )}
1128 <Box flexDirection="column" marginTop={1} rowGap={1}>
1129 {rows.length === 0 && <Text dimColor>{EMPTY_VIEW[listing]}</Text>}
1130 {rows.map(row => {
1131 if (row.kind === 'day') {
1132 return (
1133 <Text dimColor wrap="truncate-end">
1134 {`${row.text} ${'─'.repeat(Math.max(0, width - row.text.length - 1))}`}
1135 </Text>
1136 )
1137 }
1138 if (row.kind === 'question') {
1139 const { question } = row
1140 const whole = question.shot && (question.shot.full || question.shot.file) ? question.shot : undefined
1141 return (
1142 <Box key={`row:${row.key}`} flexDirection="column" rowGap={1}>
1143 <Text dimColor wrap="truncate-end">{`Claude is asking you · ${row.time}`}</Text>
1144 <Box flexDirection="column">
1145 {row.lines.map(text => (
1146 <Text bold wrap="truncate-end">
1147 {text}
1148 </Text>
1149 ))}
1150 </Box>
1151 {question.options.length > 0 && (
1152 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1153 {question.options.map((option, index) => (
1154 <Button key={`answer:${question.id}:${index}`} label={option} onPress={() => void answer($, question, option)} />
1155 ))}
1156 </Box>
1157 )}
1158 {Input && <Input key={`say:${question.id}`} placeholder="Type your answer" submitLabel="Send" onSubmit={value => void answer($, question, value)} />}
1159 <Box flexDirection="row" columnGap={2}>
1160 {whole && <Button key={`open:${row.key}`} plain dimColor label="[open]" onPress={() => void openPicture($, whole)} />}
1161 <Button key={`go:${row.key}`} plain dimColor label={GO_WORD} onPress={() => void jump($, { id: row.key, anchor: question.id, kind: 'question' })} />
1162 </Box>
1163 </Box>
1164 )
1165 }
1166 const { target, open, settles, refs } = row
1167 const isAsk = row.tone === 'ask'
1168 const isQuiet = row.tone === 'quiet'
1169 const pad = ' '.repeat(row.pad)
1170 return (
1171 <Box key={`row:${row.key}`} flexDirection="column">
1172 <Box flexDirection="row" columnGap={2}>
1173 <Text dimColor>{row.time}</Text>
1174 {open && <Button key={`open:${row.key}`} plain dimColor label="[open]" onPress={() => void openPicture($, open, row.key)} />}
1175 {row.hasMore && <Button key={`more:${row.key}`} plain dimColor label={row.isOpen ? '[less]' : '[more]'} onPress={() => void unfold($, target)} />}
1176 {settles && <Button key={`settle:${row.key}`} plain dimColor label="[done]" onPress={() => void settle($, settles)} />}
1177 <Box display="none" hover={{ display: 'flex' }}>
1178 <Button key={`go:${row.key}`} plain label={GO} onPress={() => void jump($, target)} />
1179 </Box>
1180 </Box>
1181 {row.note !== '' && (
1182 <Text dimColor wrap="truncate-end">
1183 {row.note}
1184 </Text>
1185 )}
1186 {row.lines.map((text, index) => (
1187 <Text bold={isAsk} dimColor={isQuiet} wrap="truncate-end">
1188 {index === 0 && row.pad > 0 ? `${row.glyph} ${text}` : pad + text}
1189 </Text>
1190 ))}
1191 {refs?.map((ref, index) => (
1192 <Box key={`ref:${row.key}:${index}`} flexDirection="row" columnGap={1}>
1193 <Text>{pad}</Text>
1194 {isLink(ref) ? (
1195 <Button key={`link:${row.key}:${index}`} plain dimColor label="[open]" onPress={() => void openLink($, ref)} />
1196 ) : (
1197 <Button key={`copy:${row.key}:${index}`} plain dimColor label="[copy]" onPress={() => void copyRef($, ref)} />
1198 )}
1199 <Text dimColor wrap="truncate-end">
1200 {ref}hooks/digest.ts 394 lines1// The feed's updates: the steps of the work, put to a model as a log divided into stretches, and its reply
2// read back as one short account of each stretch, the way a match is told as it goes. Pure: the model call is
3// the hooks module's; this makes the prompt and reads the answer.
4
5import type { Beat, Item, Trail } from '../types'
6import { clean, clip, MAX_BEATS, SENT, waitsOf, workCount, workLine } from './trail.ts'
7
8/** Steps to write updates for. */
9export type Job = {
10 /** The steps it covers, which are marked once the updates are in. */
11 ids: string[]
12 /** The log's numbered stretches, by number: each the step its update is tied to, the last of the stretch. */
13 slots: Item[]
14 /** The log, when a model is to read it. */
15 prompt: string
16 /** The updates, when the work is too slight to need a model: an exchange with no work in it. */
17 local?: Beat[]
18 /** The log runs to the latest of the session: only then does it show what is left waiting on the person. */
19 isLatest: boolean
20 /** The things still waiting on the person that the log names, in the order it marks them w1, w2 and so on. */
21 open: string[]
22 /** The pictures and files without a caption that the log names, in the order it marks them p1, p2 and so on. */
23 pics: string[]
24}
25
26/** Characters of log put to the model at once. */
27const LOG_MAX = 9000
28/** Characters of the newest turns told again when the person asks for the transcript to be read back: under one log's worth. */
29const RETOLD_MAX = 6000
30/** Steps to a stretch: each stretch is told in one update, so this is how often a long run is told of. */
31const STRETCH_STEPS = 4
32const UPDATE_MAX = 420
33/** Actions to a stretch, and the length of one: an update is a few short lines, not a paragraph. */
34const ACTIONS_MOST = 3
35const ACTION_MAX = 90
36/** What stands in for an exchange with no work in it: the start of the answer. */
37const ANSWER_SHOWN = 110
38/** Things one reply may leave waiting on the person, the length of each, and how many still waiting are put to the model. */
39const WAITS_MOST = 3
40const WAIT_MAX = 200
41const OPEN_MOST = 8
42/** Commands and links one thing waiting may carry, and the length of one. */
43const REFS_MOST = 4
44const REF_MAX = 240
45/** Pictures one log asks captions for, and the length of a caption. */
46const PICS_MOST = 12
47const CAPTION_MAX = 320
48const STILL_WAITING = 'STILL WAITING ON THE PERSON FROM BEFORE THIS LOG:'
49/** What in an answer with no work in it says something may be left with the person: it is then put to a model like any other. */
50const LEFT_WITH_THEM = /\?|\b(?:waits?|waiting|blocked) on you\b|\bneeds? you(?:r)?\b|\byou(?:'ll| will)? (?:need|have) to\b|\b(?:once|until|after) you\b|\byour (?:call|go-ahead|approval|answer|decision|choice|pick|sign-off)\b|\b(?:let|tell) me\b|\bsay which\b|\bup to you\b|\bconfirm\b/i
51
52export const SYSTEM = [
53 'You write the activity feed of a coding session between a person and Claude, an AI coding agent: a terse list of what was done, so the person can see what happened at a glance without reading the conversation.',
54 '',
55 'You are given a log of part of the session: what the person asked, then what Claude said and did, divided into numbered stretches marked [1], [2] and so on.',
56 'For each stretch, write the things done in it that matter, as one to three actions. An action is a few words, eight at most, in the past tense, starting with a verb and naming the thing: "Fixed the scroll jump in the list", "Pushed the new look, 228 tests passing", "Opened PR 8", "Found the leak in the cache". Put the outcome or the number in it where there is one. Separate the actions of a stretch with " ; ".',
57 'Only what was done or found. Leave out reading files, re-running commands, plans, explanations, what will be done next, and anything about how Claude went about it. No sentences, no "Claude" or "you" as the subject, no filler. Fewer actions is better: a stretch that did one thing gets one.',
58 'Report only what the log shows was done or found. What the person asked for is there so you know what the work was for: never report it as done unless the log shows it.',
59 '',
60 'Reply with one line per stretch:',
61 'N|action ; action',
62 'where N is the number of the stretch.',
63 '',
64 'Then, only if the end of the log leaves something waiting on the person, add one line for each such thing:',
65 'wait|N|what they are to do, in at most twenty words ::: command or link ;; command or link',
66 'where N is the stretch that left it waiting. Something waits on the person when a piece of the work cannot go on, or cannot be finished, until they act: a command only they can run, a sign-in or a permission, an approval, a choice or a question Claude put to them, something to try and report back on. Begin with the verb and say what it unblocks ("Run `gh auth refresh -s workflow` so the packaging branch can be pushed"). An offer of more work, a suggestion, and anything Claude will do by itself are not waiting on them, and most logs leave nothing waiting.',
67 'After " ::: " give what they need to do it with, up to four, separated by " ;; ": the exact command to run, or the link to open (a pull request, a page, a dashboard). Copy each exactly from the log. Where the log names the thing but not the command, give the command only if it is the plain, standard one for that action and tool (merging pull request 13 with the GitHub CLI is "gh pr merge 13"); never guess at flags, names or addresses. With nothing to give, leave " ::: " out.',
68 'The log may open with things still waiting on the person from before, marked w1, w2 and so on. Never write a wait line for one of those again. If the log shows that one of them was done or answered, or no longer matters, add a line:',
69 'clear|w1',
70 'with its mark.',
71 '',
72 'Then, for each picture or file the log marks p1, p2 and so on, add one line:',
73 'pic|p1|what it shows and what it was for, in at most thirty words',
74 'going only by what the log says around it: what the person said as they pasted it, what Claude said it saw in it, what Claude made or sent it for. You cannot see the picture, so never guess at what is in it: if the log does not say, leave the caption empty ("pic|p1|").',
75 'Write nothing else.',
76].join('\n')
77
78const bearing = (item: Item): boolean => item.kind === 'ask' || item.kind === 'step' || item.kind === 'done'
79const isStep = (item: Item): boolean => item.kind === 'step' || item.kind === 'done'
80
81const beatAt = (item: Item, text: string): Beat => ({
82 id: 'b:' + item.id,
83 ref: item.id,
84 turn: item.turn,
85 at: item.at,
86 kind: 'summary',
87 title: clip(clean(text), UPDATE_MAX),
88 detail: '',
89 anchor: item.anchor,
90 ...(item.alt ? { alt: item.alt } : {}),
91})
92
93/** Work too slight to need a model: something asked and answered with nothing done, or a command nothing followed. */
94const slight = (items: Item[]): Beat[] | undefined => {
95 const steps = items.filter(isStep)
96 const worked = items.some(item => item.work !== undefined && workCount(item.work) > 0) || items.some(item => item.kind === 'shot')
97 if (worked || steps.length > 1) return undefined
98 const [answer] = steps
99 // An answer that may leave something with the person is read by a model, which says what waits on them.
100 if (answer && LEFT_WITH_THEM.test(answer.more ?? answer.line)) return undefined
101 // The answer is the whole of what happened: its first sentence, kept short, is the update.
102 return answer ? [beatAt(answer, clip(answer.line, ANSWER_SHOWN))] : []
103}
104
105/** A turn's steps as stretches, each to be told in one update; a lone last step joins the stretch before it. */
106const stretchesOf = (items: Item[]): Item[][] => {
107 const parts: Item[][] = [[]]
108 let steps = 0
109 for (const item of items) {
110 if (isStep(item) && steps >= STRETCH_STEPS) {
111 parts.push([])
112 steps = 0
113 }
114 parts[parts.length - 1]!.push(item)
115 if (isStep(item)) steps++
116 }
117 const last = parts[parts.length - 1]!
118 if (parts.length > 1 && last.filter(isStep).length < 2) parts.splice(parts.length - 2, 2, [...parts[parts.length - 2]!, ...last])
119 return parts.filter(part => part.length > 0)
120}
121
122/** How the log tells of a picture or a file, by where it came from; marked when a caption is wanted for it. */
123const pictureText = (item: Item, mark: string): string => {
124 const marked = mark ? `[${mark}] ` : ''
125 const type = item.shot?.type ?? 'image'
126 if (item.shot?.from === 'person') return `${marked}the person pasted a picture with what they asked`
127 if (item.line.startsWith(SENT)) return `${marked}sent the person ${type === 'video' ? 'a video' : type === 'file' ? 'a file' : 'a picture'}: ${item.line.slice(SENT.length)}`
128 if (type === 'link') return `${marked}published a page: ${item.line.replace(/^Published /, '')}`
129 if (type === 'video') return `${marked}a command made a video: ${item.line}`
130 return `${marked}looked at a picture: ${item.line}`
131}
132
133const entryText = (item: Item, isLast: boolean, mark = ''): string => {
134 const did = item.work && workCount(item.work) > 0 ? ` [did: ${[...item.work.notes, workLine(item.work)].join('; ')}]` : ''
135 if (item.kind === 'shot') return pictureText(item, mark)
136 if (item.kind === 'ask') return `(before saying anything)${did}`
137 const said = item.more ?? item.line
138 return `${item.kind === 'done' && isLast ? 'final answer' : 'said'}: ${said}${did}`
139}
140
141const logOf = (turns: Item[][]): { prompt: string; slots: Item[]; pics: string[] } => {
142 const slots: Item[] = []
143 const pics: string[] = []
144 const lines: string[] = []
145 // An output nothing has said anything of yet is marked, for the model to say what the log tells of it. A picture
146 // the person pasted, or one from before the session, is listed nowhere and gets no caption.
147 const markOf = (item: Item): string => (item.kind === 'shot' && item.shot?.note === undefined && item.shot?.from === undefined && pics.length < PICS_MOST ? `p${pics.push(item.id)}` : '')
148 for (const items of turns) {
149 const ask = items.find(item => item.kind === 'ask')
150 if (lines.length > 0) lines.push('')
151 // A request whose first stretch is told already is there for what the work is about, not to be told again.
152 lines.push(ask?.fed ? 'EARLIER THE PERSON ASKED (already told of: do not repeat it):' : lines.length === 0 ? 'THE PERSON ASKED:' : 'THEN THE PERSON ASKED:')
153 lines.push(ask ? (ask.more ?? ask.line) : '(the work carried on from before)')
154 lines.push(ask?.fed ? 'CLAUDE WENT ON:' : 'CLAUDE:')
155 const shown = items.filter(item => bearing(item) || item.kind === 'shot').filter(item => item.kind !== 'ask' || (item.work !== undefined && workCount(item.work) > 0))
156 for (const stretch of stretchesOf(shown)) {
157 slots.push([...stretch].reverse().find(bearing) ?? stretch[stretch.length - 1]!)
158 lines.push(`[${slots.length}]`)
159 for (const item of stretch) lines.push(entryText(item, item === shown[shown.length - 1], markOf(item)))
160 }
161 }
162 return { prompt: lines.join('\n'), slots, pics }
163}
164
165/**
166 * The next steps worth writing updates for, newest first so what the person
167 * sees on opening the pane is written before the history behind it: the
168 * steps of a finished turn not yet told, or of a running one once a stretch
169 * of them is there. Finished turns with little in them are taken together.
170 */
171export const nextJob = (trail: Trail, skipped: ReadonlySet<string> = new Set()): Job | null => {
172 const byTurn = new Map<number, Item[]>()
173 for (const item of trail.items) {
174 if (item.kind === 'recap' || item.kind === 'compact') continue
175 byTurn.set(item.turn, [...(byTurn.get(item.turn) ?? []), item])
176 }
177 const waiting: Item[][] = []
178 for (const turn of [...byTurn.keys()].sort((a, b) => b - a)) {
179 const items = byTurn.get(turn)!
180 const isRunning = trail.isOpen && turn === trail.turn
181 // The newest step of a running turn is still gathering its work; the rest can be told.
182 const lastBearer = [...items].reverse().find(bearing)
183 const open = items.filter(item => !item.fed && !skipped.has(item.id) && (!isRunning || item !== lastBearer))
184 const fresh = open.filter(item => bearing(item) || item.kind === 'shot')
185 if (fresh.filter(bearing).length === 0) continue
186 if (isRunning && fresh.filter(isStep).length < STRETCH_STEPS) continue
187 const stretch = [...(open.some(item => item.kind === 'ask') ? [] : items.filter(item => item.kind === 'ask')), ...open]
188 if (waiting.length > 0 && logOf([...waiting, stretch]).prompt.length > LOG_MAX) break
189 waiting.push(stretch)
190 if (isRunning) break
191 }
192 if (waiting.length === 0) return null
193
194 // Oldest first in the log, as it happened.
195 waiting.reverse()
196 const ids = waiting.flat().map(item => item.id)
197 // Only a log that runs to the latest shows how things now stand: what is left with the person, and what no
198 // longer is. One with something already told after it is history being caught up on.
199 const last = Math.max(...waiting.flat().map(item => item.turn))
200 const isLatest = !trail.items.some(item => item.turn > last && item.fed)
201 const open = isLatest ? waitsOf(trail).slice(-OPEN_MOST) : []
202 // While something waits on the person every exchange is read by a model: any of them may be where it was done.
203 const isSlight = (items: Item[]): Beat[] | undefined => (open.length > 0 ? undefined : slight(items))
204 const told = waiting.filter(items => isSlight(items) === undefined)
205 const local = waiting.flatMap(items => isSlight(items) ?? [])
206 if (told.length === 0) return { ids, slots: [], prompt: '', local, isLatest, open: [], pics: [] }
207 const { prompt, slots, pics } = logOf(told)
208 const still = open.length > 0 ? [STILL_WAITING, ...open.map((beat, index) => `w${index + 1}: ${beat.title}`), '', ''].join('\n') : ''
209 return { ids, slots, prompt: still + clip(prompt, LOG_MAX * 2), ...(local.length > 0 ? { local } : {}), isLatest, open: open.map(beat => beat.id), pics }
210}
211
212/**
213 * How many steps have no update and will get none unasked: those of turns
214 * with something already told after them. They went by while the pane was
215 * closed, and are history now that the latest is told.
216 */
217export const backlogOf = (trail: Trail): number => {
218 const told = trail.items.reduce((latest, item) => (item.fed ? Math.max(latest, item.turn) : latest), -1)
219 return trail.items.filter(item => isStep(item) && !item.fed && item.turn < told).length
220}
221
222/** The model's reply as updates: each line it wrote in the form asked for, tied to the last step of the stretch it numbers. */
223export const parseBeats = (reply: string, job: Job): Beat[] => {
224 const beats: Beat[] = []
225 const told = new Set<number>()
226 let waits = 0
227 for (const raw of reply.split('\n')) {
228 // Something left waiting on the person: tied to the stretch that left it, and listed apart until it is done.
229 const wait = /^\s*[-*]?\s*wait\s*\|\s*\[?(\d+)\]?\s*\|\s*(.+?)\s*$/i.exec(raw)
230 if (wait) {
231 const left = job.slots[Number(wait[1]) - 1]
232 const [said = '', withIt = ''] = wait[2]!.split(/\s+:::\s*/, 2)
233 const what = clip(clean(said), WAIT_MAX)
234 // What they need to do it with: commands to copy and links to open, as the model gave them.
235 const refs = withIt
236 .split(/\s*;;\s*/)
237 .map(ref => clean(ref).replace(/^`+|`+$/g, ''))
238 .filter(ref => ref.length >= 2 && ref.length <= REF_MAX)
239 .slice(0, REFS_MOST)
240 if (left && what.length >= 4 && waits < WAITS_MOST) beats.push({ ...beatAt(left, what), id: `w:${left.id}:${waits++}`, kind: 'waiting', ...(refs.length > 0 ? { refs } : {}) })
241 continue
242 }
243 if (/^\s*[-*]?\s*(?:clear|pic)\s*\|/i.test(raw)) continue
244 const found = /^\s*[-*]?\s*\[?(\d+)\]?\s*\|\s*(.+?)\s*$/.exec(raw)
245 if (!found) continue
246 const slot = Number(found[1])
247 const at = job.slots[slot - 1]
248 // The actions of the stretch, each kept short and on a line of its own.
249 const actions = found[2]!
250 .split(/\s+;\s+|\s*;\s*$/)
251 .map(action => clip(clean(action).replace(/^[;\s]+|[.;,\s]+$/g, ''), ACTION_MAX))
252 .filter(action => action.length >= 3)
253 .slice(0, ACTIONS_MOST)
254 // One update to a stretch; a number that names none, or a line left empty, says nothing.
255 if (!at || told.has(slot) || actions.join('').length < 4) continue
256 told.add(slot)
257 beats.push({ ...beatAt(at, ''), title: actions.join('\n') })
258 }
259 return beats
260}
261
262/** The things still waiting on the person that the model's reply says no longer are: done, answered, or not needed now. */
263export const parseCleared = (reply: string, job: Job): string[] => {
264 const cleared = new Set<string>()
265 for (const raw of reply.split('\n')) {
266 const found = /^\s*[-*]?\s*clear\s*\|\s*\[?w?(\d+)\]?\s*$/i.exec(raw)
267 const id = found ? job.open[Number(found[1]) - 1] : undefined
268 if (id) cleared.add(id)
269 }
270 return [...cleared]
271}
272
273/**
274 * What the model's reply says of the pictures the log marked, by picture: only where it had something to say.
275 * One the log told nothing of keeps no caption, so the conversation itself can still be asked about it.
276 */
277export const parsePictures = (reply: string, job: Job): { id: string; text: string }[] => {
278 const told = new Map<string, string>()
279 for (const raw of reply.split('\n')) {
280 const found = /^\s*[-*]?\s*pic\s*\|\s*\[?p?(\d+)\]?\s*\|\s*(.*?)\s*$/i.exec(raw)
281 const id = found ? job.pics[Number(found[1]) - 1] : undefined
282 const text = found ? clean(found[2]!) : ''
283 if (id && !told.has(id) && text.length >= 4 && !/^[-–—]+$/.test(text)) told.set(id, clip(text, CAPTION_MAX))
284 }
285 return [...told].map(([id, text]) => ({ id, text }))
286}
287
288/** The web page of a repository on GitHub, from its remote as git has it; '' for any other. */
289export const repoPage = (remote: string | null | undefined): string => {
290 const found = /^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec((remote ?? '').trim())
291 return found ? `https://github.com/${found[1]}/${found[2]}` : ''
292}
293
294/**
295 * Things waiting on the person that name pull requests by number, with the
296 * link to each added when the session's repository is on GitHub: the number
297 * and the repository are all a link is made of, so it is no guess.
298 */
299export const withPullLinks = (beats: Beat[], remote: string | null | undefined): Beat[] => {
300 const page = repoPage(remote)
301 if (!page) return beats
302 return beats.map(beat => {
303 if (beat.kind !== 'waiting') return beat
304 const numbers: string[] = []
305 for (const named of beat.title.matchAll(/\b(?:PRs?|pull requests?)\s*#?(\d+)((?:\s*(?:,|or|and|&)\s*#?\d+)*)/gi)) {
306 for (const one of [named[1]!, ...[...named[2]!.matchAll(/\d+/g)].map(found => found[0])]) if (!numbers.includes(one)) numbers.push(one)
307 }
308 const had = beat.refs ?? []
309 const links = numbers.map(number => `${page}/pull/${number}`).filter(link => !had.some(ref => ref.replace(/\/$/, '') === link))
310 return links.length > 0 ? { ...beat, refs: [...links, ...had].slice(0, REFS_MOST + links.length) } : beat
311 })
312}
313
314const sameWords = (a: string, b: string): boolean => a.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() === b.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
315
316/**
317 * The trail with the updates in, the steps they tell of marked as told and relieved of what was kept only for
318 * the telling. What the reply left waiting on the person is taken only from a log that ran to the latest (an
319 * older stretch's is long since dealt with), and what it cleared stops waiting as of the log's last step.
320 * What it said of the pictures becomes their captions.
321 */
322export const feedIn = (trail: Trail, job: Job, beats: Beat[], cleared: string[] = [], captions: { id: string; text: string }[] = []): Trail => {
323 const covered = new Set(job.ids)
324 const notes = new Map(captions.map(one => [one.id, one.text]))
325 const known = new Set(trail.feed.map(beat => beat.id))
326 const over = new Set(cleared)
327 const overAt = job.slots.reduce((latest, slot) => Math.max(latest, slot.at), 0)
328 // A thing already waiting that the reply tells of again, now with what it is done with, gains that.
329 const gained = (beat: Beat): string[] | undefined =>
330 beat.kind === 'waiting' && beat.over === undefined && !beat.refs?.length ? beats.find(told => told.kind === 'waiting' && told.refs?.length && (told.id === beat.id || sameWords(told.title, beat.title)))?.refs : undefined
331 const had = trail.feed.map(beat => {
332 const refs = gained(beat)
333 const kept = refs ? { ...beat, refs } : beat
334 return over.has(kept.id) && kept.over === undefined ? { ...kept, over: overAt } : kept
335 })
336 const stillOpen = had.filter(beat => beat.kind === 'waiting' && beat.over === undefined)
337 const fresh = [...(job.local ?? []), ...beats].filter(beat => !known.has(beat.id) && (beat.kind !== 'waiting' || (job.isLatest && !stillOpen.some(other => sameWords(other.title, beat.title)))))
338 const feed = [...had, ...fresh].sort((a, b) => a.turn - b.turn || a.at - b.at)
339 return {
340 ...trail,
341 feed: feed.slice(-MAX_BEATS),
342 items: trail.items.map(found => {
343 const note = notes.get(found.id)
344 const item = note !== undefined && found.shot && found.shot.note === undefined ? { ...found, shot: { ...found.shot, note, guess: true as const } } : found
345 if (!covered.has(item.id)) return item
346 // What was kept only for the telling is let go; a request keeps its words, which are the person's to read back.
347 const { more, ...told } = item
348 return { ...told, ...(item.kind === 'ask' && more ? { more } : {}), fed: true as const, ...(told.work ? { work: { ...told.work, notes: [] } } : {}) }
349 }),
350 }
351}
352
353/**
354 * A trail read back from its transcript, with what only the mod knew kept:
355 * the updates already written for it (nothing is told twice), the captions
356 * of its pictures and the answers given to its questions. Asked to tell the
357 * latest again, it leaves the newest turns that fit one log untold: their
358 * updates stand, and the model reads them once more for what they left
359 * waiting on the person.
360 */
361export const adopt = (built: Trail, had: Trail, isRetelling = false): Trail => {
362 const again = new Set<number>()
363 if (isRetelling) {
364 let room = RETOLD_MAX
365 for (const turn of [...new Set(built.items.map(item => item.turn))].sort((a, b) => b - a)) {
366 if (built.isOpen && turn === built.turn) continue
367 room -= built.items.filter(item => item.turn === turn).reduce((sum, item) => sum + (item.more ?? item.line).length + 60, 0)
368 if (room < 0 && again.size > 0) break
369 again.add(turn)
370 }
371 }
372 const told = new Set(had.items.filter(item => item.fed).map(item => item.id))
373 for (const item of built.items) if (again.has(item.turn)) told.delete(item.id)
374 const notes = new Map(had.items.flatMap(item => (item.shot?.note === undefined ? [] : [[item.id, { note: item.shot.note, ...(item.shot.guess ? { guess: true as const } : {}) }] as const])))
375 const turnOf = new Map(built.items.map(item => [item.id, item.turn]))
376 const asked = new Map(had.questions.map(question => [question.id, question]))
377 const { sought: _, ...rest } = built
378 return {
379 ...rest,
380 // What was told before the mod looked for what waits on the person stays so marked until its latest is told again.
381 ...(isRetelling || had.sought ? { sought: true as const } : {}),
382 // An update keeps to the step it was tied to, wherever that step's turn now counts from.
383 feed: had.feed.map(beat => ({ ...beat, turn: turnOf.get(beat.ref) ?? beat.turn })),
384 questions: [...built.questions.map(question => asked.get(question.id) ?? question), ...had.questions.filter(question => !built.questions.some(other => other.id === question.id))].sort((a, b) => a.at - b.at),
385 items: built.items.map(found => {
386 const note = notes.get(found.id)
387 const item = note !== undefined && found.shot ? { ...found, shot: { ...found.shot, ...note } } : found
388 if (!told.has(item.id)) return item
389 const { more, ...kept } = item
390 return { ...kept, ...(item.kind === 'ask' && more ? { more } : {}), fed: true as const }
391 }),
392 }
393}
394hooks/caption.ts 86 lines1// What each picture of the feed shows and what it was about, asked of the conversation itself: the model that
2// writes the updates reads a log and sees no picture, while the conversation holds them and what was said of
3// them. Pure: the hooks module puts the question; this words it and reads the answer.
4
5import type { Item, Trail } from '../types'
6import { clean, clip } from './trail.ts'
7
8/** Pictures to ask about. */
9export type CaptionJob = {
10 /** The pictures, by the number each has in the question. */
11 shots: Item[]
12 prompt: string
13}
14
15/** Pictures asked about at once, newest first: more would be a long answer and old ones may have left the conversation. */
16const MOST = 10
17const CAPTION_MAX = 320
18const SAID_MAX = 110
19
20const INTRO = [
21 'This message is not from the person. It is the Trail side pane, which shows them an activity feed of this conversation, asking you for captions for the pictures and other outputs in that feed. Do not go on with the work and do not use any tool: only answer this.',
22 '',
23 'For each picture listed below, write a caption of one or two plain sentences, at most thirty-five words, that says what the picture shows and what it was about:',
24 '- for a picture the person pasted: what they were showing you and what they were telling you with it;',
25 '- for a picture you opened or were given by a tool: why you looked at it and what you saw in it;',
26 '- for a picture you sent them: what it shows and why you sent it.',
27 'Say what you made of it, concretely, so that someone who has not seen the conversation understands the picture. Write to the person as "you". No filler.',
28 'If you cannot find a picture in this conversation, write a single dash for it.',
29 '',
30 'The pictures, oldest first:',
31]
32
33const OUTRO = ['', 'Reply with one line per picture and nothing else:', 'N|caption']
34
35/** How the question names a picture, so the conversation can tell which is meant. */
36const described = (shot: Item, trail: Trail, time: (at: number) => string): string => {
37 const when = shot.at > 0 ? ` at ${time(shot.at)}` : ''
38 const ask = trail.items.find(item => item.kind === 'ask' && item.id === shot.anchor)
39 if (ask) {
40 const fellows = trail.items.filter(item => item.kind === 'shot' && item.anchor === shot.anchor)
41 const place = fellows.length > 1 ? ` (picture ${fellows.indexOf(shot) + 1} of ${fellows.length} in that message)` : ''
42 return `a picture the person pasted${when}, with their message beginning "${clip(ask.line, SAID_MAX)}"${place}`
43 }
44 const type = shot.shot?.type ?? 'image'
45 if (type === 'link') return `the page you published${when} (${shot.line}): say what it is and what it is for`
46 if (type === 'video') return `the video ${shot.line.replace(/^Sent you /, '')}, which ${shot.line.startsWith('Sent you ') ? 'you sent the person' : 'a command you ran made'}${when}: you cannot see it here, so say what it is of and what it was made for`
47 if (type === 'file') return `the file ${shot.line.replace(/^Sent you /, '')}, which you sent the person${when}: say what it is and what it is for`
48 if (shot.line.startsWith('Sent you ')) return `the picture ${shot.line.slice('Sent you '.length)}, which you sent the person${when}`
49 if (shot.line.startsWith('Picture from ')) return `a picture a tool returned to you${when} (${shot.line.slice('Picture from '.length)})`
50 return `the picture ${shot.line}, which you opened${when}`
51}
52
53/**
54 * The pictures still without a caption of the conversation's own (none at
55 * all, or one only made out from the words around it), as a question for it:
56 * only those since the context was last compacted, which are the ones it
57 * still holds. Null when there are none.
58 */
59export const captionJob = (trail: Trail, time: (at: number) => string): CaptionJob | null => {
60 const since = trail.items.map(item => item.kind).lastIndexOf('compact')
61 const shots = trail.items.filter((item, index) => index > since && item.kind === 'shot' && item.shot !== undefined && (item.shot.note === undefined || item.shot.guess === true)).slice(-MOST)
62 if (shots.length === 0) return null
63 const lines = shots.map((shot, index) => `${index + 1}. ${described(shot, trail, time)}`)
64 return { shots, prompt: [...INTRO, ...lines, ...OUTRO].join('\n') }
65}
66
67/**
68 * The answer as captions, by picture. One the conversation could not find,
69 * or gave no line for, gets an empty caption: it has been asked about, and
70 * is not asked about again.
71 */
72export const parseCaptions = (reply: string, job: CaptionJob): { id: string; text: string }[] => {
73 const told = new Map<number, string>()
74 for (const raw of reply.split('\n')) {
75 const found = /^\s*[-*]?\s*\[?(\d+)\]?\s*[|.:)]\s*(.*?)\s*$/.exec(raw)
76 if (!found) continue
77 const index = Number(found[1]) - 1
78 if (index < 0 || index >= job.shots.length || told.has(index)) continue
79 const text = clean(found[2]!)
80 told.set(index, /^[-–—]*$/.test(text) ? '' : clip(text, CAPTION_MAX))
81 }
82 // An answer with no line in the form asked for is no answer: the pictures are asked about another time.
83 if (told.size === 0) return []
84 return job.shots.map((shot, index) => ({ id: shot.id, text: told.get(index) ?? '' }))
85}
86hooks/jump.ts 37 lines1// Which transcript row a line jumps to, and what to try when that row will not scroll.
2// Pure: the scroll itself is handed in, so the order of tries can be tested without a transcript.
3
4/** The engine's word for a scroll nothing the person did asked for: no other row will fare better. */
5const NOT_ASKED = 'not person-initiated'
6
7/**
8 * The ids a line's row may answer to, likeliest first. A message's row is
9 * drawn under its own id. A tool call's row is too, unless the transcript
10 * folded it into a run of calls ("Read 3 files"): then the run's id is the
11 * one that scrolls. After those, the same for the tool call beside the row.
12 */
13export const candidates = (target: { anchor: string; alt?: string }, groupsByCall: ReadonlyMap<string, string>): string[] => {
14 const all = [groupsByCall.get(target.anchor), target.anchor, target.alt ? groupsByCall.get(target.alt) : undefined, target.alt]
15 return all.filter((id, index): id is string => typeof id === 'string' && id !== '' && all.indexOf(id) === index)
16}
17
18export type Reached = {
19 /** Why the transcript did not move; '' when it did. */
20 why: string
21 /** Each id tried and what came of it, for whoever is working on the mod. */
22 tried: string[]
23}
24
25/** Tries each id in turn until one scrolls; `scroll` resolves why not, or undefined when it moved. */
26export const reach = async (ids: readonly string[], scroll: (id: string) => Promise<string | undefined>): Promise<Reached> => {
27 const tried: string[] = []
28 let why = 'no row is known for this line'
29 for (const id of ids) {
30 const deny = await scroll(id)
31 tried.push(`${id}=${deny ?? 'ok'}`)
32 why = deny ?? ''
33 if (why === '' || why === NOT_ASKED) break
34 }
35 return { why, tried }
36}
37hooks/prune.ts 53 lines1// What of the mod's kept data is past keeping. Pure: the hooks module lists the folders and removes what this names.
2//
3// Each session has a folder with its trail in it, which is small. The mod no longer keeps pictures at all (it tells
4// of them in words and opens the file itself), so the `shots` folders that older versions filled are only there to
5// be cleared: a session's goes once it has been idle a day, and a whole-size copy a day after it was made. The
6// folder itself goes when the transcript would, after the days Claude Code keeps one.
7
8/** Days a session may be idle before the pictures an older version kept for it are let go. */
9export const PICTURES_DAYS = 1
10/** Days a session's folder is kept when the person's settings do not say how long transcripts are. */
11export const KEEP_DAYS = 30
12const DAY_MS = 86_400_000
13/** A session's folder is named by its id; nothing else in the mod's folder is ever removed. */
14const SESSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
15/** A whole-size copy of a picture, as the mod names one beside its small copy. */
16const WHOLE_COPY = /^[A-Za-z0-9_-]+\.full\.[A-Za-z0-9]+$/
17
18export const isSession = (name: string): boolean => SESSION.test(name)
19
20/** One folder in the mod's own: its name, when its trail was last written (0 when that is not known), whether it has pictures. */
21export type Kept = { name: string; touchedAt: number; hasShots: boolean }
22
23/** The days a session's folder is kept: as long as Claude Code keeps its transcript (`cleanupPeriodDays`), a month when that is not set. */
24export const keepDays = (setting: unknown): number => (typeof setting === 'number' && Number.isFinite(setting) && setting >= 1 ? Math.floor(setting) : KEEP_DAYS)
25
26/**
27 * The folders to remove whole, and those to remove only the pictures of.
28 * The session asking is never among them, nor is a folder whose age is not
29 * known or whose name is not a session's.
30 */
31export const prunable = (kept: readonly Kept[], now: number, own: string, days: number): { folders: string[]; pictures: string[] } => {
32 const folders: string[] = []
33 const pictures: string[] = []
34 for (const one of kept) {
35 if (!SESSION.test(one.name) || one.name === own || one.touchedAt <= 0) continue
36 const idle = (now - one.touchedAt) / DAY_MS
37 if (idle >= days) folders.push(one.name)
38 else if (idle >= Math.min(PICTURES_DAYS, days) && one.hasShots) pictures.push(one.name)
39 }
40 return { folders, pictures }
41}
42
43/**
44 * The whole-size copies in one session's pictures that are past keeping: made
45 * a day ago or more. Versions before 0.4 copied every picture whole, which
46 * was most of what the mod kept; none is made any more.
47 */
48export const staleCopies = (files: readonly { name: string; kind: string; mtimeMs: number }[], now: number): string[] =>
49 files.filter(file => file.kind === 'file' && WHOLE_COPY.test(file.name) && file.mtimeMs > 0 && now - file.mtimeMs >= PICTURES_DAYS * DAY_MS).map(file => file.name)
50
51/** A size in bytes as a person reads it: "143 MB". */
52export const sized = (kilobytes: number): string => (kilobytes >= 1024 * 1024 ? (kilobytes / 1024 / 1024).toFixed(1) + ' GB' : kilobytes >= 1024 ? Math.round(kilobytes / 1024) + ' MB' : Math.max(0, Math.round(kilobytes)) + ' KB')
53hooks/trail.ts 547 lines1// The trail's steps: conversation events in, a list of what happened out.
2// Pure (no `$`, no Node, no DOM), so the hooks, the tests and the rebuild tool share it.
3
4import type { Beat, Item, ItemKind, OutputType, Question, Shot, Source, Trail, Work } from '../types'
5
6export type TrailEvent =
7 | { t: 'prompt'; id: string; at: number; text: string; isCommand?: boolean }
8 | { t: 'say'; id: string; at: number; text: string }
9 | { t: 'tool'; id: string; at: number; tool: string; note: string; file?: string }
10 | { t: 'fail'; id: string; at: number }
11 | { t: 'shot'; id: string; at: number; anchor: string; file: string; w: number; h: number; label: string; full?: string; stamp?: number; type?: OutputType; from?: Source; note?: string }
12 | { t: 'recap'; id: string; at: number; text: string }
13 | { t: 'compact'; id: string; at: number }
14 | { t: 'question'; id: string; at: number; text: string; options: string[]; shot?: Shot }
15 | { t: 'answer'; id: string; at: number; text: string }
16 | { t: 'caption'; id: string; at: number; text: string }
17 | { t: 'opened'; id: string; at: number }
18 | { t: 'end'; at: number; aborted?: boolean }
19
20export const MAX_ITEMS = 400
21export const MAX_BEATS = 300
22const MAX_QUESTIONS = 40
23const QUESTION_MAX = 400
24const OPTIONS_MOST = 4
25const OPTION_MAX = 28
26/** What a question the conversation went on past is taken to have been answered with. */
27export const ANSWERED_THERE = 'answered in the conversation'
28const FILES_KEPT = 3
29const EXTRA_KEPT = 24
30const NOTES_KEPT = 8
31const RECENT_KEPT = 60
32const LINE_MAX = 200
33const RECAP_MAX = 600
34const NOTE_MAX = 320
35/** How much of what was said is kept for a highlight to be written from: all of an answer, the start of a step. */
36const ANSWER_MAX = 1600
37/** Of an answer longer than that, how much is its end: where it says how things stand and what waits on the person. */
38const ANSWER_END = 600
39const STEP_MAX = 320
40const SENTENCE_MIN = 32
41/** A line this long before a list or a break stands on its own. */
42const LEAD_MIN = 20
43const CARRIED_ON = 'Carried on without a new prompt'
44const TASK = 'Background task: '
45
46/** How an answer given in the pane reads when it reaches the conversation, and how it is known again in a transcript. */
47export const paneAnswer = (question: string, answer: string): string => `About your question in the Trail pane ("${question}"): ${answer}`
48const PANE_ANSWER = /^About your question in the Trail pane \("[\s\S]*?"\): ([\s\S]+)$/
49
50export const emptyTrail = (): Trail => ({ items: [], feed: [], questions: [], turn: 0, isOpen: false, isFresh: false, lastTool: '', lastAt: 0, recent: [], sought: true })
51
52const emptyWork = (): Work => ({ cmds: 0, reads: 0, edits: 0, agents: 0, other: 0, fails: 0, files: [], moreFiles: 0, extra: [], now: '', notes: [] })
53
54const READS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'ToolSearch'])
55const EDITS = new Set(['Write', 'Edit', 'NotebookEdit'])
56const AGENTS = new Set(['Agent', 'Task', 'Workflow'])
57
58/** A short name for a long string, the same wherever it is worked out: what a file made from a path is called by. */
59export const tag = (text: string): string => {
60 let hash = 5381
61 for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
62 return (hash >>> 0).toString(36)
63}
64
65const MOVING = /\.(mp4|mov|m4v|webm|mkv|gif)$/i
66const STILL = /\.(png|jpe?g|webp|heic|tiff?|bmp)$/i
67
68/** A file last written this long before a session's first event was there before it: looked at, not made. */
69const EARLIER_MS = 60_000
70
71/** Whether a file Claude opened is from before the session, given when it was last written and when the session began. */
72export const isEarlier = (writtenAt: number, beganAt: number): boolean => beganAt > 0 && writtenAt > 0 && writtenAt < beganAt - EARLIER_MS
73
74/**
75 * Whether a picture of the trail is Claude's own output: something the
76 * session made, sent or published, and not one the person pasted or a file
77 * from before the session that Claude only looked at.
78 */
79export const isOwnOutput = (item: Item, trail: Trail): boolean =>
80 item.kind === 'shot' && item.shot?.from === undefined && !trail.items.some(other => other.kind === 'ask' && other.id === item.anchor)
81
82/** The things the work left for the person to do that they have not done yet, oldest first. */
83export const waitsOf = (trail: Trail): Beat[] => trail.feed.filter(beat => beat.kind === 'waiting' && beat.over === undefined)
84
85/** How many things wait on the person: questions put to them in the pane, and what the work left for them to do. */
86export const waitingCount = (trail: Trail): number => trail.questions.filter(question => question.answer === undefined).length + waitsOf(trail).length
87
88/** How a file Claude handed the person is named in the trail. */
89export const SENT = 'Sent you '
90
91/** What kind of output a file is, by its name. */
92export const outputType = (path: string): OutputType => (MOVING.test(path) ? 'video' : STILL.test(path) ? 'image' : 'file')
93
94/**
95 * The video files a command names, in what it ran and what it printed: what
96 * rendering or recording leaves behind and nobody opens with a tool. Paths
97 * as written, a few at most; whether each is a file just made is the caller's
98 * to find out.
99 */
100export const videosNamed = (text: string): string[] => {
101 const found = new Set<string>()
102 for (const match of text.matchAll(/(?:^|[\s'"=(:])((?:~\/|\.{0,2}\/)?[\w@%+,.\-\/]+\.(?:mp4|mov|m4v|webm|mkv|gif))(?=$|[\s'"),;:])/gim)) {
103 found.add(match[1]!)
104 if (found.size >= 5) break
105 }
106 return [...found]
107}
108
109/** The address a page was published at, in what the tool that published it answered. */
110export const publishedAt = (text: string): string | undefined => /\bPublished\b[^\n]*?\bat (https:\/\/[^\s)]+)/.exec(text)?.[1]
111
112export const basename = (path: string): string => {
113 const cut = path.replace(/\/+$/, '')
114 return cut.slice(cut.lastIndexOf('/') + 1)
115}
116
117const span = (from: number, to: number): string => String.fromCharCode(from) + '-' + String.fromCharCode(to)
118/** Control characters and the marks that turn text around or hide in it: one of them in a tree refuses the whole pane. */
119const UNSEEN = new RegExp('[' + span(0, 0x1f) + span(0x7f, 0x9f) + span(0x200b, 0x200f) + span(0x2028, 0x202e) + String.fromCharCode(0xfeff) + ']', 'g')
120
121/** One line of plain characters. */
122export const clean = (text: string): string => text.replace(UNSEEN, ' ').replace(/\s+/g, ' ').trim()
123
124/** Cut by characters, never through one, at a word where there is one near. */
125export const clip = (text: string, max: number): string => {
126 const chars = Array.from(text)
127 if (chars.length <= max) return text
128 const cut = chars.slice(0, max - 1).join('')
129 const space = cut.lastIndexOf(' ')
130 return (space > cut.length * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:]+$/, '') + '…'
131}
132
133const plain = (text: string): string =>
134 clean(
135 text
136 .replace(/```[\s\S]*?(```|$)/g, ' ')
137 .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, '')
138 .replace(/\*\*([^*]+)\*\*/g, '$1')
139 .replace(/`([^`]+)`/g, '$1')
140 .replace(/\[([^\]]+)\]\((?:[^)]+)\)/g, '$1'),
141 )
142
143/** Characters kept of one block of code in what is put to the model. */
144const CODE_KEPT = 300
145
146/**
147 * What was said, flattened for the model that tells of it: as `plain`, but
148 * with what the person may have to act on kept. The commands in its code
149 * blocks stay, each on one line between backticks, and a link keeps where it
150 * leads. Without these the model cannot say what to run or what to open.
151 */
152const telling = (text: string): string =>
153 clean(
154 text
155 .replace(/```(?:[A-Za-z0-9_+-]*\n)?([\s\S]*?)(?:```|$)/g, (_, code: string) => (code.trim() ? ' `' + clip(code.replace(/\s+/g, ' ').trim(), CODE_KEPT) + '` ' : ' '))
156 .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, '')
157 .replace(/\*\*([^*]+)\*\*/g, '$1')
158 .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label: string, to: string) => (/^https?:/.test(to) && to !== label ? `${label} (${to})` : label)),
159 )
160
161/** What is kept of an answer: all of it, or of a long one its start and its end, the middle left out. */
162const headAndEnd = (flat: string): string => {
163 const chars = Array.from(flat)
164 if (chars.length <= ANSWER_MAX) return flat
165 const end = chars.slice(-ANSWER_END).join('')
166 return clip(flat, ANSWER_MAX - ANSWER_END) + ' ' + end.slice(end.indexOf(' ') + 1)
167}
168
169/** Where the first sentence of a plain line ends; a very short one takes the next along. */
170const sentenceEnd = (flat: string): number => {
171 const stop = /[.!?:](?=\s+[A-Z"'`(\[]|$)/g
172 for (let match = stop.exec(flat); match; match = stop.exec(flat)) {
173 const isLast = match.index + 1 >= flat.length
174 if (!isLast && (match[0] === ':' || match.index + 1 < SENTENCE_MIN)) continue
175 return match.index + 1
176 }
177 return flat.length
178}
179
180/** A line that leads into a list or a new paragraph says its piece alone: the first item is not the rest of its sentence. */
181const lead = (text: string): string => {
182 const cut = /\n(?=\s*\n|\s{0,3}(?:[-*+]|\d+\.)\s)/.exec(text)
183 return cut && plain(text.slice(0, cut.index)).length >= LEAD_MIN ? text.slice(0, cut.index) : text
184}
185
186/** The first sentence of what the model said, as one plain line. */
187export const firstSentence = (text: string): string => {
188 const flat = plain(lead(text))
189 return clip(flat.slice(0, sentenceEnd(flat)), LINE_MAX)
190}
191
192const TAGGED = /<(system-reminder|local-command-stdout|local-command-stderr|local-command-caveat|task-notification|command-message|skill-format|bash-stdout|bash-stderr)>[\s\S]*?<\/\1>/g
193const INTERRUPTED = /^\s*\[Request interrupted by user/
194
195/** The person's prompt as one plain line; empty for rows that are not a prompt at all. */
196export const promptLine = (text: string, max = LINE_MAX): string => {
197 const command = /<command-name>\s*(\/?[^<\s]+)\s*<\/command-name>/.exec(text)
198 if (command) {
199 const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text)
200 const name = command[1]!.startsWith('/') ? command[1]! : '/' + command[1]!
201 return clip(clean(name + ' ' + (args?.[1] ?? '')), max)
202 }
203 const task = /<task-notification>([\s\S]*?)(?:<\/task-notification>|$)/.exec(text)
204 const summary = task && /<summary>([\s\S]*?)<\/summary>/.exec(task[1]!)
205 if (summary) {
206 // How the task came out is what matters about it: said first, where no clip takes it.
207 const said = clean(summary[1]!)
208 const status = clean(/<status>([\s\S]*?)<\/status>/.exec(task![1]!)?.[1] ?? '')
209 return clip((status && !said.toLowerCase().includes(status.toLowerCase()) ? `Background task ${status}: ` : TASK) + said, max)
210 }
211 // The engine's own mark for a turn the person stopped is no prompt of theirs.
212 if (INTERRUPTED.test(text)) return ''
213 const shell = /<bash-input>([\s\S]*?)<\/bash-input>/.exec(text)
214 if (shell) return clip(clean('! ' + shell[1]!), max)
215 const said = text
216 .replace(TAGGED, ' ')
217 .replace(/<\/?pasted_content[^>]*>/g, ' ')
218 .replace(/\[Image(?: #\d+|: source: [^\]]*)\]/g, ' ')
219 return clip(clean(said), max)
220}
221
222/** One short phrase for a tool call, from its own arguments. */
223export const toolNote = (tool: string, input: Record<string, unknown>): string => {
224 const text = (key: string): string => (typeof input[key] === 'string' ? (input[key] as string) : '')
225 const note =
226 text('description') ||
227 (text('file_path') && basename(text('file_path'))) ||
228 text('skill') ||
229 text('query') ||
230 text('url') ||
231 text('pattern') ||
232 text('command') ||
233 text('name')
234 const short = tool.startsWith('mcp__') ? tool.split('__').slice(2).join(' ') || tool : tool
235 return clip(clean(note || short), 80)
236}
237
238export const workCount = (work: Work): number => work.cmds + work.reads + work.edits + work.agents + work.other
239
240const tally = (work: Work, tool: string, note: string, file?: string): Work => {
241 const next = { ...work, now: note }
242 // A call that says what it is for is what a highlight is written from; a bare file name says little.
243 const isTelling = note !== '' && (tool === 'Bash' || AGENTS.has(tool) || tool.startsWith('mcp__') || tool === 'Skill' || tool === 'WebSearch')
244 if (isTelling && next.notes.length < NOTES_KEPT && !next.notes.includes(note)) next.notes = [...next.notes, note]
245 if (tool === 'Bash') next.cmds++
246 else if (READS.has(tool)) next.reads++
247 else if (EDITS.has(tool)) {
248 next.edits++
249 const name = file ? clean(basename(file)) : ''
250 if (name && !next.files.includes(name) && !next.extra.includes(name)) {
251 if (next.files.length < FILES_KEPT) next.files = [...next.files, name]
252 else {
253 next.moreFiles++
254 if (next.extra.length < EXTRA_KEPT) next.extra = [...next.extra, name]
255 }
256 }
257 } else if (AGENTS.has(tool)) next.agents++
258 else next.other++
259 return next
260}
261
262const merge = (into: Work, from: Work): Work => {
263 const files = [...into.files]
264 const extra = [...into.extra]
265 let moreFiles = into.moreFiles
266 for (const name of [...from.files, ...from.extra]) {
267 if (files.includes(name) || extra.includes(name)) continue
268 if (files.length < FILES_KEPT) files.push(name)
269 else {
270 moreFiles++
271 if (extra.length < EXTRA_KEPT) extra.push(name)
272 }
273 }
274 // Names past what either kept are counted, though not known.
275 moreFiles += Math.max(0, from.moreFiles - from.extra.length)
276 return {
277 cmds: into.cmds + from.cmds,
278 reads: into.reads + from.reads,
279 edits: into.edits + from.edits,
280 agents: into.agents + from.agents,
281 other: into.other + from.other,
282 fails: into.fails + from.fails,
283 files,
284 moreFiles,
285 extra,
286 now: from.now || into.now,
287 notes: [...into.notes, ...from.notes.filter(note => !into.notes.includes(note))].slice(0, NOTES_KEPT),
288 }
289}
290
291export const change = (items: Item[], index: number, to: (item: Item) => Item): Item[] => {
292 const next = items.slice()
293 next[index] = to(next[index]!)
294 return next
295}
296
297const withKind = (items: Item[], index: number, kind: ItemKind): Item[] => change(items, index, item => ({ ...item, kind }))
298
299/** The line of the turn that tool calls count under: its newest ask, step or result. */
300const bearerOf = (items: Item[], turn: number, before = items.length): number => {
301 for (let i = before - 1; i >= 0; i--) {
302 const item = items[i]!
303 if (item.kind === 'recap' || item.kind === 'compact' || item.kind === 'shot') continue
304 return item.turn === turn ? i : -1
305 }
306 return -1
307}
308
309/**
310 * Keeps the list bounded. Steps go first, their work kept under the line
311 * before them: those of older turns, then the oldest of the running turn. A
312 * turn never loses its ask, result or pictures that way; only when nothing
313 * but those is left do whole turns go, oldest first.
314 */
315const thin = (items: Item[]): Item[] => {
316 if (items.length <= MAX_ITEMS) return items
317 const out = items.slice()
318 let over = out.length - MAX_ITEMS
319 const newest = out[out.length - 1]!.turn
320 for (const ofNewest of [false, true]) {
321 for (let i = 0; i < out.length - 1 && over > 0; i++) {
322 const item = out[i]!
323 if (item.kind !== 'step' || (item.turn === newest) !== ofNewest) continue
324 const bearer = bearerOf(out, item.turn, i)
325 if (bearer < 0) continue
326 if (item.work) out[bearer] = { ...out[bearer]!, work: merge(out[bearer]!.work ?? emptyWork(), item.work) }
327 out.splice(i, 1)
328 i--
329 over--
330 }
331 }
332 if (over <= 0) return out
333 let cut = over
334 while (cut < out.length - 1 && out[cut]!.turn === out[cut - 1]!.turn) cut++
335 return out.slice(cut)
336}
337
338const has = (trail: Trail, id: string): boolean => trail.items.some(item => item.id === id)
339
340/** A turn to put work under: the one running, the newest ask's if it has not ended, else one nobody asked for. */
341const reopen = (trail: Trail, id: string, at: number): Trail => {
342 if (trail.isOpen) return trail
343 if (trail.isFresh) return { ...trail, isOpen: true }
344 const turn = trail.turn + 1
345 const ask: Item = { id: id + ':turn', anchor: id, kind: 'ask', at, turn, line: CARRIED_ON }
346 return { ...trail, items: [...trail.items, ask], turn, isOpen: true, isFresh: true }
347}
348
349const seenOnce = (trail: Trail, key: string): Trail | null =>
350 trail.recent.includes(key) ? null : { ...trail, recent: [...trail.recent, key].slice(-RECENT_KEPT) }
351
352/** Folds one thing that happened into the trail. */
353export const add = (trail: Trail, event: TrailEvent): Trail => {
354 const next = fold(trail, event)
355 // A caption is written after the fact: it is no sign of when the work last moved.
356 return event.t !== 'caption' && event.at > next.lastAt ? { ...next, lastAt: event.at } : next
357}
358
359const fold = (trail: Trail, event: TrailEvent): Trail => {
360 switch (event.t) {
361 case 'prompt': {
362 if (has(trail, event.id)) return trail
363 const line = promptLine(event.text)
364 if (!line) return trail
365 // A command typed while a turn runs is beside the point of that turn: it neither ends it nor heads the work after.
366 if (event.isCommand && trail.isOpen) return trail
367 // A turn never seen to end stopped when it last did something, not when the person next spoke, and with no result.
368 const closed = trail.isOpen ? fold(trail, { t: 'end', at: trail.lastAt || event.at, aborted: true }) : trail
369 const turn = closed.turn + 1
370 const ask: Item = { id: event.id, anchor: event.id, kind: 'ask', at: event.at, turn, line }
371 const whole = promptLine(event.text, ANSWER_MAX)
372 if (whole.length > line.length) ask.more = whole
373 // Whatever the person says next is taken as their answer to what was waiting on them: said in the pane, it
374 // names the question; said in the conversation, the conversation has it.
375 const isTheirs = event.isCommand !== true && !/^Background task\b/.test(line)
376 const given = PANE_ANSWER.exec(whole)?.[1]
377 const questions = isTheirs
378 ? closed.questions.map(question => (question.answer === undefined ? { ...question, answer: clip(given ?? ANSWERED_THERE, QUESTION_MAX), answeredAt: event.at } : question))
379 : closed.questions
380 return { ...closed, items: thin([...closed.items, ask]), questions, turn, isOpen: event.isCommand !== true, isFresh: true }
381 }
382 case 'say': {
383 if (has(trail, event.id)) return trail
384 const flat = plain(event.text)
385 const line = clip(flat.slice(0, sentenceEnd(flat)), LINE_MAX)
386 if (!line) return trail
387 const base = reopen(trail, event.id, event.at)
388 // What was said before this turned out not to be the turn's answer: its start is enough to keep.
389 const before = bearerOf(base.items, base.turn)
390 const items =
391 before >= 0 && base.items[before]!.kind === 'step' && (base.items[before]!.more?.length ?? 0) > STEP_MAX
392 ? change(base.items, before, item => ({ ...item, more: clip(item.more ?? '', STEP_MAX) }))
393 : base.items
394 const step: Item = { id: event.id, anchor: event.id, kind: 'step', at: event.at, turn: base.turn, line }
395 // What is kept for the telling has the commands and the links of what was said, which the line shown does not.
396 const told = telling(event.text)
397 if (flat.length > line.length || told !== flat) step.more = headAndEnd(told)
398 if (base.lastTool) step.alt = base.lastTool
399 return { ...base, items: thin([...items, step]) }
400 }
401 case 'tool': {
402 const counted = seenOnce(trail, event.id)
403 if (!counted) return trail
404 const base = { ...reopen(counted, event.id, event.at), lastTool: event.id }
405 const bearer = bearerOf(base.items, base.turn)
406 if (bearer < 0) return base
407 const items = base.items[bearer]!.kind === 'done' ? withKind(base.items, bearer, 'step') : base.items
408 return {
409 ...base,
410 items: change(items, bearer, item => ({
411 ...item,
412 // The row a line stands for may not scroll; the first call made under it is beside it and does.
413 alt: item.work && workCount(item.work) > 0 ? item.alt : event.id,
414 work: tally(item.work ?? emptyWork(), event.tool, event.note, event.file),
415 })),
416 }
417 }
418 case 'fail': {
419 const counted = seenOnce(trail, 'fail:' + event.id)
420 if (!counted) return trail
421 const bearer = bearerOf(counted.items, counted.turn)
422 if (bearer < 0) return counted
423 return { ...counted, items: change(counted.items, bearer, item => ({ ...item, work: { ...(item.work ?? emptyWork()), fails: (item.work?.fails ?? 0) + 1 } })) }
424 }
425 case 'shot': {
426 const id = event.id + ':shot'
427 if (has(trail, id)) return trail
428 const base = reopen(trail, event.id, event.at)
429 const shot: Item = {
430 id,
431 anchor: event.anchor,
432 kind: 'shot',
433 at: event.at,
434 turn: base.turn,
435 line: clip(clean(event.label), LINE_MAX),
436 shot: { file: event.file, w: event.w, h: event.h },
437 }
438 if (event.full) shot.shot!.full = event.full
439 if (event.stamp !== undefined) shot.shot!.stamp = event.stamp
440 if (event.type && event.type !== 'image') shot.shot!.type = event.type
441 if (event.from) shot.shot!.from = event.from
442 // What Claude said of a file as it handed it over is its caption from the start.
443 if (event.note && clean(event.note)) shot.shot!.note = clip(clean(event.note), NOTE_MAX)
444 return { ...base, items: thin([...base.items, shot]) }
445 }
446 case 'recap': {
447 const line = clip(plain(event.text), RECAP_MAX)
448 if (!line || has(trail, event.id)) return trail
449 // Only where things stand now is worth its rows: a newer recap takes the older one's place.
450 const recap: Item = { id: event.id, anchor: event.id, kind: 'recap', at: event.at, turn: trail.turn, line }
451 return { ...trail, items: thin([...trail.items.filter(item => item.kind !== 'recap'), recap]) }
452 }
453 case 'compact': {
454 if (has(trail, event.id) || trail.items[trail.items.length - 1]?.kind === 'compact') return trail
455 const at: Item = { id: event.id, anchor: event.id, kind: 'compact', at: event.at, turn: trail.turn, line: 'Context was compacted here' }
456 if (trail.lastTool) at.alt = trail.lastTool
457 return { ...trail, items: thin([...trail.items, at]) }
458 }
459 case 'question': {
460 const text = clip(clean(event.text), QUESTION_MAX)
461 if (!text || trail.questions.some(question => question.id === event.id)) return trail
462 const options = event.options.map(option => clip(clean(option), OPTION_MAX)).filter(Boolean).slice(0, OPTIONS_MOST)
463 const question: Question = { id: event.id, at: event.at, text, options }
464 if (event.shot) question.shot = event.shot
465 return { ...trail, questions: [...trail.questions, question].slice(-MAX_QUESTIONS) }
466 }
467 case 'answer': {
468 const text = clip(clean(event.text), QUESTION_MAX)
469 const index = trail.questions.findIndex(question => question.id === event.id && question.answer === undefined)
470 if (index < 0 || !text) return trail
471 const questions = trail.questions.slice()
472 questions[index] = { ...questions[index]!, answer: text, answeredAt: event.at }
473 return { ...trail, questions }
474 }
475 case 'opened': {
476 // `id` is the output's own line: the person has opened it, so it reads as read from now on.
477 const index = trail.items.findIndex(item => item.id === event.id && item.shot !== undefined)
478 if (index < 0) return trail
479 return { ...trail, items: change(trail.items, index, item => ({ ...item, shot: { ...item.shot!, opened: event.at } })) }
480 }
481 case 'caption': {
482 // `id` is the picture's own line; an empty caption still says the picture was asked about.
483 const index = trail.items.findIndex(item => item.id === event.id && item.shot !== undefined)
484 if (index < 0) return trail
485 const { guess: _, ...seen } = trail.items[index]!.shot!
486 if (seen.note === event.text && trail.items[index]!.shot!.guess === undefined) return trail
487 // The conversation saw the picture: what it says replaces what was only made out from the words around it.
488 return { ...trail, items: change(trail.items, index, item => ({ ...item, shot: { ...seen, note: event.text || seen.note || '' } })) }
489 }
490 case 'end': {
491 if (!trail.isOpen) return trail.isFresh ? { ...trail, isFresh: false } : trail
492 let items = trail.items
493 const last = bearerOf(items, trail.turn)
494 // What the model said last, with nothing done after it, is how the turn came out, unless it was cut short.
495 if (!event.aborted && last >= 0 && items[last]!.kind === 'step' && workCount(items[last]!.work ?? emptyWork()) === 0) items = withKind(items, last, 'done')
496 const ask = items.findIndex(item => item.turn === trail.turn && item.kind === 'ask')
497 if (ask >= 0) items = change(items, ask, item => ({ ...item, ms: Math.max(0, event.at - item.at) }))
498 return { ...trail, items, isOpen: false, isFresh: false }
499 }
500 }
501}
502
503const isQuestion = (question: unknown): question is Question =>
504 typeof question === 'object' && question !== null && typeof (question as Question).id === 'string' && typeof (question as Question).text === 'string' && Array.isArray((question as Question).options)
505
506const isBeat = (beat: unknown): beat is Beat =>
507 typeof beat === 'object' && beat !== null && typeof (beat as Beat).id === 'string' && typeof (beat as Beat).title === 'string' && typeof (beat as Beat).anchor === 'string'
508
509/** A trail read back from a file or the state, with anything missing made whole. */
510export const mended = (value: unknown): Trail | null => {
511 if (typeof value !== 'object' || value === null) return null
512 const from = value as Partial<Trail>
513 if (!Array.isArray(from.items)) return null
514 const items = from.items
515 .filter((item): item is Item => typeof item === 'object' && item !== null && typeof item.id === 'string' && typeof item.line === 'string' && typeof item.kind === 'string')
516 .map(item => (item.work && (!Array.isArray(item.work.notes) || !Array.isArray(item.work.extra)) ? { ...item, work: { ...emptyWork(), ...item.work, notes: item.work.notes ?? [], extra: item.work.extra ?? [] } } : item))
517 return {
518 items,
519 feed: Array.isArray(from.feed) ? from.feed.filter(isBeat) : [],
520 questions: Array.isArray(from.questions) ? from.questions.filter(isQuestion) : [],
521 turn: typeof from.turn === 'number' ? from.turn : 0,
522 isOpen: from.isOpen === true,
523 isFresh: from.isFresh === true,
524 lastTool: typeof from.lastTool === 'string' ? from.lastTool : '',
525 lastAt: typeof from.lastAt === 'number' ? from.lastAt : items.reduce((latest, item) => Math.max(latest, item.at), 0),
526 recent: Array.isArray(from.recent) ? from.recent.filter((id): id is string => typeof id === 'string') : [],
527 ...(from.sought === true ? { sought: true as const } : {}),
528 }
529}
530
531/** "1 failed · 6 commands · edited draw.ts, png.ts +2" */
532export const workLine = (work: Work): string => {
533 const parts: string[] = []
534 const count = (n: number, one: string, many: string): void => {
535 if (n > 0) parts.push(n + ' ' + (n === 1 ? one : many))
536 }
537 // What failed is said first: the line is clipped from its end, and that is the last thing to lose.
538 count(work.fails, 'failed', 'failed')
539 count(work.cmds, 'command', 'commands')
540 if (work.files.length > 0) parts.push('edited ' + work.files.join(', ') + (work.moreFiles > 0 ? ' +' + work.moreFiles : ''))
541 else count(work.edits, 'edit', 'edits')
542 count(work.reads, 'read', 'reads')
543 count(work.agents, 'agent run', 'agent runs')
544 count(work.other, 'other call', 'other calls')
545 return parts.join(' · ')
546}
547hooks/view.ts 392 lines1// What the pane shows, as plain rows: the trail laid out for a width, before any element is made.
2// Pure, so a test or the preview tool can read a page without a terminal.
3
4import type { Beat, Item, ItemKind, Mode, OutputType, Picked, Question, Shot, Trail } from '../types'
5import { clip, isOwnOutput, waitingCount, workCount, workLine } from './trail.ts'
6
7/** What a press on a line jumps to: the transcript row it stands for, and a tool call beside it. */
8export type Target = { id: string; anchor: string; alt?: string; kind: string }
9
10
11/**
12 * One entry of the pane, whatever the view. Every entry is drawn the same
13 * way: a quiet row with its time and its actions in brackets, then what it
14 * says under that, then its picture if it has one.
15 */
16export type Row =
17 | { kind: 'day'; key: string; text: string }
18 | {
19 kind: 'line'
20 key: string
21 /** What `[go to conversation]` scrolls the transcript to. */
22 target: Target
23 time: string
24 /** The mark before the words, or a space for an entry that has none (an update is read as a short piece). */
25 glyph: string
26 /** The words, wrapped: a few rows at rest, all of them once `[more]` is pressed. */
27 lines: string[]
28 /** Cells the words after the first row are set in by: the mark's width. */
29 pad: number
30 /** There is more to read than is shown: the entry has `[more]`, or `[less]` while it is all shown. */
31 hasMore: boolean
32 isOpen: boolean
33 work: string
34 /** Once the person has asked to go to the entry's place in the conversation: that it went, or why it did not. */
35 note: string
36 /** How the words are weighted: what was asked, something to be read, or an aside. */
37 tone: 'ask' | 'plain' | 'quiet'
38 /** An output's entry: the thing itself, for `[open]`. */
39 open?: Shot
40 /** A thing the work left for the person to do: what `[done]` stops waiting. */
41 settles?: string
42 /** And what they need to do it with: links to open and commands to copy. */
43 refs?: string[]
44 }
45 | {
46 kind: 'question'
47 key: string
48 question: Question
49 time: string
50 /** The question, wrapped. */
51 lines: string[]
52 }
53
54export type Page = {
55 rows: Row[]
56 /** Entries older than the page shows, and newer. */
57 earlier: number
58 later: number
59 foot: string
60}
61
62export type Look = {
63 width: number
64 picked: Picked
65 page: number
66 mode: Mode
67 /** Minutes east of UTC where the person is. */
68 zone: number
69}
70
71/** Lines to a page, and how far a page back moves: pages overlap so a place is not lost. */
72export const PAGE_LINES = 120
73export const PAGE_STEP = 80
74/** Cells an entry's mark takes before its words, and that the rows under the first are set in by. */
75const MARK = 2
76/** The widest a line of text is set, in cells: wider is tiring to read. */
77const MEASURE = 72
78const NOBODYS = /^(Background task\b|Carried on without a new prompt$)/
79
80const GLYPH: Record<ItemKind, string> = { ask: '❯', step: '•', done: '✓', shot: '▣', recap: '≡', compact: '~' }
81/** Lines of a request or a step shown once it is opened, and of an update always: an update is there to be read whole. */
82const DETAIL_OPEN = 12
83const UPDATE_LINES = 12
84/** Rows one action of an update may take, and the longest an update made of actions is when it is one line of text. */
85const ACTION_LINES = 3
86const ACTION_WIDEST = 110
87const CAPTION_LINES = 6
88const OUTPUT_GLYPH: Record<OutputType, string> = { image: '▣', video: '▶', file: '▤', link: '↗' }
89const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
90const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
91
92const isWide = (code: number): boolean =>
93 (code >= 0x1100 && code <= 0x115f) ||
94 (code >= 0x2e80 && code <= 0xa4cf) ||
95 (code >= 0xac00 && code <= 0xd7a3) ||
96 (code >= 0xf900 && code <= 0xfaff) ||
97 (code >= 0xfe30 && code <= 0xfe4f) ||
98 (code >= 0xff00 && code <= 0xff60) ||
99 (code >= 0xffe0 && code <= 0xffe6) ||
100 (code >= 0x1f300 && code <= 0x1faff) ||
101 (code >= 0x20000 && code <= 0x3fffd)
102
103/** How many terminal cells a string takes, near enough to cut it by. */
104export const cells = (text: string): number => {
105 let count = 0
106 for (const char of text) count += isWide(char.codePointAt(0) ?? 0) ? 2 : 1
107 return count
108}
109
110const cutTo = (text: string, width: number): string => {
111 let out = ''
112 let used = 0
113 for (const char of text) {
114 const size = isWide(char.codePointAt(0) ?? 0) ? 2 : 1
115 if (used + size > width) break
116 out += char
117 used += size
118 }
119 return out
120}
121
122/** Wraps at words into at most `max` lines of `width` cells; what does not fit ends in an ellipsis. */
123export const wrap = (text: string, width: number, max: number): string[] => {
124 const room = Math.max(4, width)
125 const out: string[] = []
126 let line = ''
127 const words = text.split(' ').filter(Boolean)
128 let index = 0
129 for (; index < words.length; index++) {
130 let word = words[index]!
131 const joined = line ? line + ' ' + word : word
132 if (cells(joined) <= room) {
133 line = joined
134 continue
135 }
136 if (line) {
137 out.push(line)
138 line = ''
139 if (out.length === max) break
140 }
141 // A word wider than the line is broken across lines.
142 while (cells(word) > room) {
143 const piece = cutTo(word, room)
144 out.push(piece)
145 word = word.slice(piece.length)
146 if (out.length === max) break
147 }
148 if (out.length === max) break
149 line = word
150 }
151 if (line && out.length < max) {
152 out.push(line)
153 index = words.length
154 }
155 if (index < words.length && out.length > 0) {
156 const last = out[out.length - 1]!
157 out[out.length - 1] = (cells(last) >= room ? cutTo(last, room - 1) : last).replace(/[\s,;:.]+$/, '') + '…'
158 }
159 return out
160}
161
162/** Wrapped lines with a tail set after the last of them, the line cut to make room: how long a turn ran is said whatever the length of what was asked. */
163const tailed = (lines: string[], tail: string, room: number): string[] => {
164 if (!tail || lines.length === 0) return lines
165 const last = lines[lines.length - 1]!
166 const fits = cells(last) + 2 + cells(tail) <= room
167 const kept = fits ? last : cutTo(last, Math.max(1, room - cells(tail) - 3)).replace(/[\s,;:.…]+$/, '') + '…'
168 return [...lines.slice(0, -1), kept + ' ' + tail]
169}
170
171const local = (at: number, zone: number): Date => new Date(at + zone * 60_000)
172const two = (n: number): string => String(n).padStart(2, '0')
173
174export const clock = (at: number, zone: number): string => {
175 const date = local(at, zone)
176 return two(date.getUTCHours()) + ':' + two(date.getUTCMinutes())
177}
178
179const dayOf = (at: number, zone: number): string => {
180 const date = local(at, zone)
181 return DAYS[date.getUTCDay()]! + ' ' + MONTHS[date.getUTCMonth()]! + ' ' + date.getUTCDate()
182}
183
184/** "42s", "3m", "2h 05m", "1d 4h": how long something took, or how long ago it was. */
185export const span = (ms: number): string => {
186 const minutes = Math.round(ms / 60_000)
187 if (minutes < 1) return Math.max(1, Math.round(ms / 1000)) + 's'
188 if (minutes < 60) return minutes + 'm'
189 const hours = Math.floor(minutes / 60)
190 if (hours < 24) return hours + 'h ' + two(minutes % 60) + 'm'
191 return Math.floor(hours / 24) + 'd ' + (hours % 24) + 'h'
192}
193
194/** "+0900" or "-1000", as `date +%z` prints it, in minutes east of UTC; undefined when it is neither. */
195export const zoneOf = (text: string): number | undefined => {
196 const found = /^([+-])(\d\d)(\d\d)$/.exec(text.trim())
197 if (!found) return undefined
198 return (found[1] === '-' ? -1 : 1) * (Number(found[2]) * 60 + Number(found[3]))
199}
200
201const counted = (count: number, one: string, many: string): string => count + ' ' + (count === 1 ? one : many)
202
203const footOf = (trail: Trail, look: Look): string => {
204 const prompts = trail.items.filter(item => item.kind === 'ask' && !NOBODYS.test(item.line)).length
205 const unopened = (of: Item[]): string => {
206 const left = of.filter(item => item.shot !== undefined && Boolean(item.shot.full || item.shot.file) && !item.shot.opened).length
207 return left > 0 ? ` (${left} not opened)` : ''
208 }
209 const outputs = trail.items.filter(item => isOwnOutput(item, trail)).length
210 const waiting = waitingCount(trail)
211 const told = trail.feed.filter(beat => beat.kind !== 'built' && beat.kind !== 'waiting').length
212 const parts =
213 look.mode === 'questions'
214 ? []
215 : [look.mode === 'feed' ? counted(told, 'update', 'updates') : look.mode === 'outputs' ? counted(outputs, 'output', 'outputs') + unopened(trail.items.filter(item => isOwnOutput(item, trail))) : counted(prompts, 'prompt', 'prompts')]
216 if (waiting > 0) parts.push(waiting + ' waiting on you')
217 const last = Math.max(trail.lastAt, trail.items.reduce((latest, item) => Math.max(latest, item.at), 0))
218 // The day, not how long ago: the pane may sit undrawn for a night, and an age would go stale in it.
219 if (trail.isOpen) parts.push('working now')
220 else if (last > 0) parts.push('quiet since ' + dayOf(last, look.zone) + ' ' + clock(last, look.zone))
221 return parts.join(' · ')
222}
223
224type Entry = { at: number; rank: number; seq: number; row: () => Row }
225
226/** Rows of an entry's words shown at rest and once opened, by what the entry is. */
227const AT_REST: Record<ItemKind, number> = { ask: 3, step: 2, done: 4, shot: 1, recap: 8, compact: 1 }
228
229export const layout = (trail: Trail, look: Look): Page => {
230 // Text is set no wider than reads well, however wide the pane is drawn.
231 const width = Math.max(16, Math.min(look.width, MEASURE))
232 const isOpen = (id: string): boolean => look.picked.id === id && look.picked.isOpen === true
233 const noteFor = (id: string): string => {
234 if (look.picked.id !== id || look.picked.jump === undefined) return ''
235 return look.picked.jump ? clip("can't go there: " + look.picked.jump, width) : 'the conversation is at this point now'
236 }
237 const timeOf = (at: number): string => (at > 0 ? clock(at, look.zone) : '--:--')
238 const entries: Entry[] = []
239 let seq = 0
240
241 // The feed is the updates and nothing else: what was done, as it has been told. What was asked, and what the
242 // work produced, are the other views'; a step no update tells of yet is not shown, the line under the tabs saying
243 // that work is going on.
244 for (const item of trail.items) {
245 if (look.mode === 'questions' || look.mode === 'feed') break
246 // The outputs are Claude's: what the session made, sent or published, not what the person showed it.
247 if (look.mode === 'outputs' && !isOwnOutput(item, trail)) continue
248 // The last view is the person's own side of the conversation: each thing they asked, and nothing of what was done about it.
249 if (look.mode === 'steps' && (item.kind !== 'ask' || NOBODYS.test(item.line))) continue
250 entries.push({
251 at: item.at,
252 rank: item.kind === 'ask' ? 0 : 1,
253 seq: seq++,
254 row: () => {
255 const opened = isOpen(item.id)
256 const isOutput = item.kind === 'shot'
257 const room = Math.max(8, width - MARK)
258 const took = item.kind === 'ask' && item.ms !== undefined && item.ms >= 60_000 ? '(' + span(item.ms) + ')' : ''
259 // Opened, a request or a step shows all that was said; at rest, its first sentence.
260 const rest = AT_REST[item.kind] ?? 2
261 const name = item.line
262 const atRest = wrap(name, room, rest)
263 const whole = wrap(item.more ?? name, room, DETAIL_OPEN)
264 const hasMore = whole.length > atRest.length || (item.more !== undefined && item.more.length > item.line.length) || (atRest.at(-1)?.endsWith('…') ?? false)
265 const said = tailed(opened ? whole : atRest, took, room)
266 // An output is told of too: what it shows and what it was about, under its name.
267 const lines = item.shot?.note ? [...said, ...wrap(item.shot.note, room, CAPTION_LINES)] : said
268 // Something to open that the person has not opened yet is new to them; opened, it is read.
269 const canOpen = isOutput && item.shot !== undefined && Boolean(item.shot.full || item.shot.file)
270 return {
271 kind: 'line',
272 key: item.id,
273 target: { id: item.id, anchor: item.anchor, kind: item.kind, ...(item.alt ? { alt: item.alt } : {}) },
274 time: timeOf(item.at),
275 glyph: isOutput ? OUTPUT_GLYPH[item.shot?.type ?? 'image'] : (GLYPH[item.kind] ?? '•'),
276 lines,
277 pad: MARK,
278 hasMore: hasMore && !isOutput,
279 isOpen: opened,
280 // In the feed the tally is noise; under a step it is what the step came to.
281 work: look.mode === 'steps' && item.work && workCount(item.work) > 0 ? clip(workLine(item.work), Math.max(8, room - 2)) : '',
282 note: noteFor(item.id),
283 // A request is the person's; work a finished background task set off, or that simply carried on, is nobody's.
284 tone: canOpen ? (item.shot!.opened ? 'quiet' : 'ask') : item.kind === 'ask' ? (NOBODYS.test(item.line) ? 'quiet' : 'ask') : item.kind === 'recap' || item.kind === 'compact' ? 'quiet' : 'plain',
285 ...(canOpen ? { open: item.shot! } : {}),
286 }
287 },
288 })
289 }
290
291 const beatRow = (beat: Beat): Row => {
292 const isTask = beat.kind === 'built'
293 const isWait = beat.kind === 'waiting'
294 // A thing to do is a line with its mark. An update is its actions, each a short line of its own with a
295 // mark; one written before updates were actions is a short piece, read across the whole pane.
296 const pad = isTask || isWait ? MARK : 0
297 const words = beat.detail ? beat.title + ' ' + beat.detail : beat.title
298 const isActions = !isTask && !isWait && (words.includes('\n') || words.length <= ACTION_WIDEST)
299 const lines = isActions
300 ? words.split('\n').flatMap(action => wrap(action, Math.max(8, width - MARK), ACTION_LINES).map((text, index) => (index === 0 ? '• ' : ' ') + text))
301 : wrap(words, width - pad, UPDATE_LINES)
302 return {
303 kind: 'line',
304 key: beat.id,
305 target: { id: beat.id, anchor: beat.anchor, kind: isTask ? 'task' : isWait ? 'wait' : 'update', ...(beat.alt ? { alt: beat.alt } : {}) },
306 time: timeOf(beat.at),
307 glyph: isTask ? '✓' : isWait ? '○' : ' ',
308 lines,
309 pad,
310 hasMore: false,
311 isOpen: false,
312 work: '',
313 note: noteFor(beat.id),
314 tone: isTask || isWait ? 'ask' : 'plain',
315 ...(isWait ? { settles: beat.id } : {}),
316 ...(isWait && beat.refs && beat.refs.length > 0 ? { refs: beat.refs } : {}),
317 }
318 }
319 if (look.mode === 'feed' || look.mode === 'questions') {
320 for (const beat of trail.feed) {
321 // A thing left with the person has a list of its own; the feed is the updates. (A big task done, which older versions listed, is no longer shown.)
322 const isWait = beat.kind === 'waiting'
323 if (isWait ? look.mode !== 'questions' || beat.over !== undefined : beat.kind === 'built' || look.mode === 'questions') continue
324 // An update tells of what came before it, pictures included: it sits above them.
325 entries.push({ at: beat.at, rank: 3, seq: seq++, row: () => beatRow(beat) })
326 }
327 }
328
329 // A question asked and answered is a thing that happened: it is told among the steps, and nowhere else.
330 for (const question of trail.questions) {
331 if (question.answer === undefined || look.mode !== 'steps') continue
332 const id = 'q:' + question.id
333 entries.push({
334 at: question.at,
335 rank: 2,
336 seq: seq++,
337 row: () => {
338 const room = Math.max(8, width - MARK)
339 return {
340 kind: 'line',
341 key: id,
342 target: { id, anchor: question.id, kind: 'question' },
343 time: timeOf(question.at),
344 glyph: '?',
345 lines: [...wrap(question.text, room, 3), ...wrap('You: ' + question.answer, room, 3)],
346 pad: MARK,
347 hasMore: false,
348 isOpen: false,
349 work: '',
350 note: noteFor(id),
351 tone: 'plain',
352 }
353 },
354 })
355 }
356
357 // The page reads from the latest back, strictly by the clock: what a request came to sits above the things
358 // that happened in it, and the request itself below them, where it began. What has no time keeps its order.
359 entries.sort((a, b) => (a.at && b.at ? a.at - b.at : 0) || a.rank - b.rank || a.seq - b.seq)
360 entries.reverse()
361
362 const total = entries.length
363 const [size, step] = [PAGE_LINES, PAGE_STEP]
364 const start = Math.min(Math.max(0, look.page) * step, Math.max(0, total - 1))
365 const shown = entries.slice(start, start + size)
366 const rows: Row[] = []
367 // What waits on the person has a view of its own, there only while something waits: it is to be answered, not read past.
368 if (look.mode === 'questions') {
369 for (const question of [...trail.questions].reverse()) {
370 if (question.answer !== undefined) continue
371 rows.push({
372 kind: 'question',
373 key: 'q:' + question.id,
374 question,
375 time: timeOf(question.at),
376 lines: wrap(question.text, width, 8),
377 })
378 }
379 }
380 let day = ''
381 for (const entry of shown) {
382 const today = dayOf(entry.at, look.zone)
383 if (entry.at > 0 && today !== day) {
384 day = today
385 rows.push({ kind: 'day', key: 'day:' + entry.seq, text: today })
386 }
387 rows.push(entry.row())
388 }
389
390 return { rows, earlier: Math.max(0, total - start - shown.length), later: start, foot: footOf(trail, look) }
391}
392types/index.d.ts 189 lines1/** The work done under one step: what was called, what was edited, what failed. */
2export type Work = {
3 cmds: number
4 reads: number
5 edits: number
6 agents: number
7 other: number
8 fails: number
9 /** Basenames written or edited, oldest first, a few at most. */
10 files: string[]
11 /** How many more files were edited than `files` names, and which (a few at most), so one edited twice counts once. */
12 moreFiles: number
13 extra: string[]
14 /** What the latest call said it was doing. */
15 now: string
16 /** What the calls said they were doing, oldest first, a few at most: what a highlight is written from. */
17 notes: string[]
18}
19
20/** What an output is: a picture (when nothing says otherwise), a video, some other file handed over, or a page published. */
21export type OutputType = 'image' | 'video' | 'file' | 'link'
22
23/**
24 * Something the work produced or looked at, kept for the trail: a small PNG
25 * of the mod's own to draw in the pane (a video's or a document's is its
26 * preview; `file` is empty where there is none), its shape, and the whole
27 * thing to open: a file's path, or a page's address. A picture that is a
28 * file on disk is not copied: `full` is the file itself and `stamp` when it
29 * was last written as the picture was kept, so it is opened only while it
30 * is still that picture, and the small PNG otherwise. `note` is its caption,
31 * what it shows and what it was about: absent until the conversation has been
32 * asked, empty when it had nothing to say. `guess` marks a caption written
33 * from what was said around the picture by a model that did not see it
34 * (which costs next to nothing): the conversation, which did see it, may
35 * still be asked for a better one. `opened` is when the person last opened
36 * it from the pane: what they have opened reads as read, the rest as new.
37 * Nothing is drawn in the pane and nothing is copied: `file` is empty for
38 * whatever the mod has kept since it stopped keeping pictures.
39 */
40export type Shot = { file: string; w: number; h: number; full?: string; stamp?: number; note?: string; guess?: true; opened?: number; type?: OutputType; from?: Source }
41
42/**
43 * Where a picture came from when it is not Claude's own work: the `person`
44 * pasted it, or it is a file from `earlier` than the session that Claude only
45 * looked at. Absent, it is something the session made, sent or published.
46 */
47export type Source = 'person' | 'earlier'
48
49/**
50 * What a step is: the person's `ask`, a `step` the model narrated, the turn's
51 * `done` line, a picture (`shot`), the engine's `recap` of where things
52 * stand, or a mark where the context was compacted.
53 */
54export type ItemKind = 'ask' | 'step' | 'done' | 'shot' | 'recap' | 'compact'
55
56/** One thing the conversation holds, as it happened: the layer the highlights are written from, and shown under them on request. */
57export type Item = {
58 /** Unique in the trail. */
59 id: string
60 /** The transcript row a click scrolls to: the row's id, or a tool call's. */
61 anchor: string
62 /** A tool call beside the row, tried when the row itself will not scroll. */
63 alt?: string
64 kind: ItemKind
65 /** Milliseconds since the epoch. */
66 at: number
67 turn: number
68 line: string
69 /** More of what was said than its first sentence, kept until a highlight has been written from it. */
70 more?: string
71 work?: Work
72 shot?: Shot
73 /** On an ask: how long its turn ran, once it has ended. */
74 ms?: number
75 /** A highlight has been written that covers this. */
76 fed?: true
77}
78
79/**
80 * An update of the feed is a `summary`: a short account of a stretch of work.
81 * A big task brought to a finish is `built`: it is what the list of things
82 * done holds. The other kinds are from when the feed listed single things
83 * that happened: something `built` or changed,
84 * something `found` out, something `decided`, a `problem` met, one `fixed`,
85 * something `checked` and how it came out, something `asked` of the person
86 * (it waits on them), or a plain `note`. A `summary` is not one thing but
87 * what a whole request came to, in a few sentences. Something `waiting` is
88 * not a telling at all: it is a thing the work left for the person to do,
89 * listed apart until it is done.
90 */
91export type BeatKind = 'summary' | 'built' | 'waiting' | 'found' | 'decided' | 'problem' | 'fixed' | 'checked' | 'asked' | 'note'
92
93/** One update of the feed: what was done in a stretch of work and what came of it, in a few sentences, tied to the row where the stretch ended. */
94export type Beat = {
95 id: string
96 /** The step it tells of. */
97 ref: string
98 turn: number
99 /** When the step it tells of happened. */
100 at: number
101 kind: BeatKind
102 /** The update's text. */
103 title: string
104 /** More of it, where an older kind kept a headline apart from its telling. */
105 detail: string
106 /** The transcript row a click scrolls to, and a tool call beside it. */
107 anchor: string
108 alt?: string
109 /** Of something waiting on the person: when it stopped waiting, done or no longer needed. Absent while it waits. */
110 over?: number
111 /** Of something waiting on the person: what they need to do it with, in order. A link to open (it begins `http`), or a command to run, which is only ever copied. */
112 refs?: string[]
113}
114
115/**
116 * Something Claude asked the person in the pane, with the picture it is about
117 * when there is one. It waits there until answered; the answer goes back to
118 * the conversation as the person's next message.
119 */
120export type Question = {
121 /** The tool call that asked it, which is also the transcript row a click scrolls to. */
122 id: string
123 at: number
124 text: string
125 /** Answers offered as buttons; the person may always type their own. */
126 options: string[]
127 shot?: Shot
128 answer?: string
129 answeredAt?: number
130}
131
132export type Trail = {
133 items: Item[]
134 /** The feed's updates, oldest first. */
135 feed: Beat[]
136 questions: Question[]
137 turn: number
138 /** A turn is running. */
139 isOpen: boolean
140 /** The newest ask has seen no turn end yet, so work that follows is its own. */
141 isFresh: boolean
142 /** The latest tool call's id. */
143 lastTool: string
144 /** When the latest thing happened: where a turn that was never seen to end is taken to have stopped. */
145 lastAt: number
146 /** Tool calls already counted, newest last: an event seen twice counts once. */
147 recent: string[]
148 /**
149 * Its updates are written by a model that also looks for what waits on the
150 * person. Absent on a trail told before the mod looked for that: the latest
151 * of such a trail is read once more.
152 */
153 sought?: true
154}
155
156/** The trail as kept in the session's state and in its file; `v` is the shape's, so one kept by older code is read back from the transcript instead. */
157export type Saved = { v: 5; sid: string; trail: Trail }
158
159/**
160 * The entry the person last pressed an action of. `isOpen`: they asked for
161 * more of it, and it shows all it has. `jump` is what came of going to it in
162 * the conversation: absent until they ask, '' once it landed, else why it did
163 * not.
164 */
165export type Picked = { id: string; jump?: string; isOpen?: boolean }
166
167/**
168 * What the pane lists: the feed of updates (its highlights), everything the
169 * work produced, each thing the person asked (`steps`, shown as "every
170 * prompt"), or what waits on the person (`questions`: the questions Claude
171 * put to them in the pane and the things the work left for them to do), which
172 * is a view only while something does.
173 */
174export type Mode = 'feed' | 'outputs' | 'steps' | 'questions'
175
176declare module 'claude-code' {
177 interface PluginState {
178 trail: {
179 saved: Saved
180 picked: Picked
181 /** How many pages back from the newest lines the pane shows. */
182 page: number
183 mode: Mode
184 /** How the writing of updates is going, when there is something to say about it. */
185 status: string
186 }
187 }
188}
189