A pane of this session's prompts that opens on a tree of every branch a rewind or a fork left, each prompt with what its turn did, its context usage and when…

A pane beside the chat that shows every prompt in the current session, as a tree of every branch a rewind left behind or as a list, with how much of the context window was in use and when you sent it.

/resume-at command. /resume-at when it copies a session. The tree finds every chat in the project that starts with this one's first prompt, and draws each fork's own turns as a branch under the turn it was forked at. A fork's card names its chat. In a terminal, "continue here" opens that chat at the turn./timeline shows or hides the pane./resume-at <session-id>:<message-id> opens a session at one of its messages, from any Claude Code session. /resume-at <message-id> does it in the current session./resume-at points the session itself at the message, the way a rewind or the tree's "continue from here" does, so every branch stays in one session. A session open in another Claude Code window is never written to: it is copied instead, every branch included, and the copy opens at that message. /resume-at --copy … always copies. Ids can be shortened to their first characters, as long as they are unique.
Once a session's transcript passes 5 MB, Claude Code only reads it from the last /compact (or auto-compaction) on, so a turn before that can't be continued in that session. The tree still shows those turns as a branch, and "continue here" or /resume-at on one opens a copy of the session instead: every branch included, with each compaction kept as a note, so any turn in it can be continued.
The pane remembers whether it was open, and whether ctx is shown, between sessions. Each session opens on the tree.
The pane is drawn for the app rather than for a terminal. It has native buttons for the views and toggles, the app's own type sizes, and the tree's lines drawn as small pictures, so a branch curves off its line and the lines join up.
The app draws its own transcript, and a plugin can't scroll it. So clicking a prompt opens its card in the pane instead: the prompt, its turn's last reply, and the /resume-at command for it. In the list, clicking a prompt opens its full text under the row.
A branch's card has "Continue in a new chat", which works like the app's own Fork. It copies the chat that holds the turn, with every branch, points the copy at the end of that turn, names it after the chat and the turn, and asks the app to open it. The chat you're in is left as it is. A fork's card also has "Open its chat", which opens the fork as it stands. Both ask the app through its claude://resume link, which the pane hands to Windows (from WSL too), macOS or a Linux desktop.
/plugin install prompt-timeline --marketplace xRyul/claude-plugins-and-mods-by-xryul
MIT
hooks/register.tsx 2299 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 Register,
5 RenderChildren,
6 RenderInput,
7 RenderSurface,
8 TextProps,
9 Timer,
10 UiPressArgument,
11} from 'claude-code'
12
13import type { Outcome, SearchHit, SearchResults, TimelineEntry, TreeLine, TreeNode } from '../types'
14
15const PANE = 'prompt-timeline'
16const TITLE = 'Timeline'
17// Room for the header's controls.
18const COLUMNS = 32
19const MAX_ENTRIES = 1000
20// The most of a prompt any pane draws: wider than a docked pane gets.
21const PREVIEW = 200
22// The most of a prompt the search reads: a long paste is searched this far.
23const FULL = 32_000
24// The most prompt text the scan hands back in all, under the 4 MiB of output
25// `$.process.run` keeps: in a long session each prompt's share is smaller.
26const BUDGET = 3_000_000
27// Who typed it: the composer, or the Remote Control bridge. Peers, task
28// notifications and plugins are not the person's own prompts.
29const PERSON_KINDS = ['composer', 'bridge']
30// Reads the transcript for what came before the mod loaded: one record per
31// prompt the person typed (`P line uuid timestamp text`, the text as its JSON
32// string, closing quote and all when it ends within the share) and per
33// response (`R line tokens`, the input tokens its usage counts). Responses
34// print as they come, prompts at the end, so both still ascend by line. Keys
35// are matched as JSON writes them, so no text inside a message (where quotes
36// are escaped) matches; a prompt's pasted images stay in the file, never
37// copied out.
38const SCAN = `
39function field(key, start, rest) {
40 if (!(start = index($0, key))) return ""
41 rest = substr($0, start + length(key))
42 return substr(rest, 1, index(rest, "\\"") - 1)
43}
44function count(key) {
45 if (!match($0, "\\"" key "\\":[0-9]+")) return 0
46 return substr($0, RSTART + length(key) + 3, RLENGTH - length(key) - 3) + 0
47}
48index($0, "\\"origin\\":{\\"kind\\":\\"human\\"}") || index($0, "\\"origin\\":{\\"kind\\":\\"bridge\\"}") {
49 if (!index($0, "\\"type\\":\\"user\\"") || index($0, "\\"isMeta\\":true") || index($0, "\\"isSidechain\\":true")) next
50 key = "\\"message\\":{\\"role\\":\\"user\\",\\"content\\":\\""
51 if (!(start = index($0, key))) start = index($0, key = "\\"type\\":\\"text\\",\\"text\\":\\"")
52 text = start ? substr($0, start + length(key), ${FULL}) : ""
53 if (match(text, /^([^"\\\\]|\\\\.)*/) && RLENGTH < length(text)) text = substr(text, 1, RLENGTH + 1)
54 n++
55 line[n] = NR; id[n] = field("\\"uuid\\":\\""); at[n] = field("\\"timestamp\\":\\""); body[n] = text
56 next
57}
58index($0, "\\"type\\":\\"assistant\\"") {
59 total = count("input_tokens") + count("cache_creation_input_tokens") + count("cache_read_input_tokens")
60 if (total > 0) print "R\\t" NR "\\t" total
61}
62END {
63 first = n > ${MAX_ENTRIES} ? n - ${MAX_ENTRIES} + 1 : 1
64 share = n ? int(${BUDGET} / (n - first + 1)) : 0
65 for (i = first; i <= n; i++) {
66 print "P\\t" line[i] "\\t" id[i] "\\t" at[i] "\\t" (length(body[i]) > share ? substr(body[i], 1, share) : body[i])
67 }
68}`
69// The age column's width: its longest label, "just now".
70const AGE_COLUMNS = 8
71// The context column's width: "100%".
72const CONTEXT_COLUMNS = 4
73// The context column's colour as the window fills: a dim grey when empty,
74// yellow at 30%, orange at 50%, bright red when full. Each percent between two
75// of these is a step from one to the next.
76const TONES_AT = [
77 { at: 0, rgb: [0x99, 0x99, 0x99] },
78 { at: 30, rgb: [0xff, 0xff, 0x00] },
79 { at: 50, rgb: [0xff, 0x8c, 0x00] },
80 { at: 100, rgb: [0xff, 0x00, 0x00] },
81]
82// The header's controls: the switch between the list and the tree, ` list
83// tree `, ` ○ find`, and ` ● ctx`; and its title, `◆ Timeline`.
84const SWITCH_COLUMNS = 14
85const FIND_COLUMNS = 8
86const CONTEXT_TOGGLE_COLUMNS = 7
87const TITLE_COLUMNS = 10
88
89// How much of a prompt, and of the turn's last reply, the tree scan copies out
90// of the transcript, as JSON strings: more than a row or a card draws.
91const PROMPT_CHARS = 400
92const REPLY_CHARS = 800
93// Reads the transcript's whole tree, the branches a rewind left included: a
94// record naming the prompt the conversation stands at (`H owner`), one naming
95// the chat as its title last read (`N title`, a JSON string's opening), then
96// one per prompt the person typed (`T uuid parent timestamp end cmds files
97// tool git text reply tokens`). Each row belongs to the nearest prompt above
98// it, so `parent` is the prompt whose turn this one was asked after, `end` the
99// last row of the prompt's own turn, `reply` the opening of that turn's last
100// text, and `tokens` the input tokens its first response's usage counts, the
101// context it was answered over. `cmds` counts the turn's shell commands,
102// `files` the files it changed, `tool` names another tool it used (not the
103// desktop app's own `mcp__ccd_` tools, which close every turn there), and
104// `git` is 2 where a command pushed, 1 where one committed. The conversation
105// stands where the newest row is, or at the leaf a `last-prompt` record after
106// it names (a rewind writes one). A compacted row's parent is the row it
107// summed up; a subagent's rows are left out. A prompt Claude Code numbers 1
108// starts the conversation over, after a rewind to the top, so it has no
109// parent, whatever row it names: Claude Code can name the last row of the
110// conversation it left.
111const TREE_SCAN = `
112function field(key, start, rest) {
113 if (!(start = index($0, key))) return ""
114 rest = substr($0, start + length(key))
115 return substr(rest, 1, index(rest, "\\"") - 1)
116}
117function after(text, key, start, rest) {
118 if (!(start = index(text, key))) return ""
119 rest = substr(text, start + length(key))
120 return substr(rest, 1, index(rest, "\\"") - 1)
121}
122function tokens(key) {
123 if (!match($0, "\\"" key "\\":[0-9]+")) return 0
124 return substr($0, RSTART + length(key) + 3, RLENGTH - length(key) - 3) + 0
125}
126function opening(key, size, start, text) {
127 if (!(start = index($0, key))) return ""
128 text = substr($0, start + length(key), size)
129 if (match(text, /^([^"\\\\]|\\\\.)*/) && RLENGTH < length(text)) text = substr(text, 1, RLENGTH + 1)
130 return text
131}
132function owner(id, steps, i, found, path) {
133 while (id != "" && !(id in own) && steps < 100000) {
134 path[++steps] = id
135 id = (id in up) ? up[id] : ""
136 }
137 found = (id in own) ? own[id] : ""
138 for (i = 1; i <= steps; i++) own[path[i]] = found
139 return found
140}
141index($0, "{\\"parentUuid\\":") == 1 {
142 if (match($0, /^\\{"parentUuid":("[^"]*"|null),"isSidechain":true/)) next
143 id = field("\\"uuid\\":\\"")
144 if (id == "") next
145 up[id] = substr($0, 15, 4) == "null" ? field("\\"logicalParentUuid\\":\\"") : field("\\"parentUuid\\":\\"")
146 rows[++count] = id
147 last = id; lastLine = NR
148 if (index($0, "\\"type\\":\\"assistant\\",\\"uuid\\":\\"")) {
149 text = opening("\\"type\\":\\"text\\",\\"text\\":\\"", ${REPLY_CHARS})
150 if (text != "") said[id] = text
151 spent = tokens("input_tokens") + tokens("cache_creation_input_tokens") + tokens("cache_read_input_tokens")
152 if (spent > 0) asked[id] = spent
153 if (start = index($0, "\\"type\\":\\"tool_use\\"")) {
154 call = substr($0, start)
155 used[id] = after(call, "\\"name\\":\\"")
156 if (used[id] ~ /^(Edit|Write|NotebookEdit)$/) {
157 touched[id] = after(call, "\\"file_path\\":\\"")
158 if (touched[id] == "") touched[id] = after(call, "\\"notebook_path\\":\\"")
159 }
160 if (used[id] ~ /^(Bash|PowerShell)$/) {
161 if (index(call, "git push") || index(call, "gt submit")) git[id] = 2
162 else if (index(call, "git commit")) git[id] = 1
163 }
164 }
165 next
166 }
167 if (!index($0, "\\"origin\\":{\\"kind\\":\\"human\\"}") && !index($0, "\\"origin\\":{\\"kind\\":\\"bridge\\"}")) next
168 if (index($0, "\\"isMeta\\":true")) next
169 if (match($0, /"turnPosition":\\{"promptIndex":1[,}]/)) up[id] = ""
170 key = "\\"message\\":{\\"role\\":\\"user\\",\\"content\\":\\""
171 if (!index($0, key)) key = "\\"type\\":\\"text\\",\\"text\\":\\""
172 text = opening(key, ${PROMPT_CHARS})
173 bare = text
174 sub(/^([ \\t]|\\\\[ntr])+/, "", bare)
175 if (bare == "" || bare ~ /^</) next
176 own[id] = id
177 n++
178 prompt[n] = id; at[n] = field("\\"timestamp\\":\\""); body[n] = text
179 next
180}
181index($0, "{\\"type\\":\\"last-prompt\\"") == 1 {
182 leaf = field("\\"leafUuid\\":\\""); leafLine = NR
183}
184index($0, "{\\"type\\":\\"custom-title\\"") == 1 { named = field("\\"customTitle\\":\\"") }
185index($0, "{\\"type\\":\\"ai-title\\"") == 1 { titled = field("\\"aiTitle\\":\\"") }
186END {
187 for (i = 1; i <= count; i++) {
188 found = owner(rows[i])
189 if (found == "") continue
190 end[found] = rows[i]
191 if (rows[i] in said) reply[found] = said[rows[i]]
192 if ((rows[i] in asked) && !(found in context)) context[found] = asked[rows[i]]
193 if (!(rows[i] in used)) continue
194 name = used[rows[i]]
195 if (git[rows[i]] > made[found]) made[found] = git[rows[i]]
196 if (name ~ /^(Bash|PowerShell)$/) cmds[found]++
197 else if (rows[i] in touched) {
198 if (!((found, touched[rows[i]]) in seen)) files[found]++
199 seen[found, touched[rows[i]]] = 1
200 }
201 else if (name !~ /^(Read|Grep|Glob|LS|TodoWrite|ToolSearch|Skill)$/ && name !~ /^mcp__ccd_/) tool[found] = name
202 }
203 print "H\\t" owner((leafLine > lastLine && (leaf in up)) ? leaf : last)
204 print "N\\t" (named != "" ? named : titled)
205 for (i = (n > ${MAX_ENTRIES}) ? n - ${MAX_ENTRIES} + 1 : 1; i <= n; i++) {
206 id = prompt[i]
207 print "T\\t" id "\\t" owner(up[id]) "\\t" at[i] "\\t" end[id] "\\t" (cmds[id] + 0) "\\t" (files[id] + 0) "\\t" tool[id] "\\t" (made[id] + 0) "\\t" body[i] "\\t" reply[id] "\\t" (context[id] + 0)
208 }
209}`
210// The most of a turn's last reply its card shows.
211const REPLY = 320
212// What a card's switch button's key opens with, before the prompt's id, and
213// what the button says.
214const SWITCH = 'switch:'
215const CONTINUE = ' ↪ continue here '
216// What a card's copy button's key opens with, before the prompt's id: it
217// copies the `/resume-at` command that opens the session at that turn.
218const COPY = 'copy:'
219// What a fork's card's button that opens its chat opens with, where the
220// person picks chats.
221const CHAT = 'chat:'
222// Copies every message of a transcript, every branch, to `dst` under another
223// session's id: `from` and `to` are the two `"sessionId":"…"` pairs. Rows that
224// are not messages (titles, costs, checkpoints) stay with the original. Each
225// compaction is kept as a note, not a boundary, so Claude Code reads the copy
226// whole (see WHOLE_UP_TO): every branch can be resumed in it.
227const COPY_SCAN = `index($0, "\\"uuid\\":\\"") {
228 gsub(from, to)
229 sub(/"subtype":"compact_boundary"/, "\\"subtype\\":\\"informational\\"")
230 print > dst
231}`
232// Claude Code reads a transcript larger than this from its last compaction on,
233// and resumes it there: a message before that is out of its reach in that
234// session, and can only be resumed in a copy.
235const WHOLE_UP_TO = 5 * 1024 * 1024
236const RESUME_AT_USAGE =
237 'Usage: /resume-at [--copy] <session-id>:<message-id>, or /resume-at <message-id> for this session.'
238// Each prompt's dot on its line: where the conversation stands, a prompt it
239// passes through, and one on a branch a rewind left.
240const DOTS = { here: '◉', path: '●', off: '○' } as const
241// The line the conversation runs down is the accent; every other branch takes
242// the next of these, so a branch keeps its colour however deep it sits. Theme
243// keys, so the lines follow the person's theme.
244const PATH = -1
245const TONES = ['merged', 'planMode', 'bashBorder', 'ide', 'success', 'warning']
246// The width of what a tree row ends with: what the turn did, then the context
247// its first response was answered over (CONTEXT_COLUMNS, as the list's), then
248// when it was asked ("◂ here" on the row the conversation stands at).
249const OUTCOME_COLUMNS = 11
250const AT_COLUMNS = 6
251// How many lines of its last reply an opened card shows.
252const REPLY_LINES = 4
253// Behind the row the conversation stands at, and an opened card: the theme's
254// own shade behind the person's messages.
255const HIGHLIGHT = 'userMessageBackground'
256
257// Where a surface draws the tree's lines as pictures (the desktop app, VS
258// Code, the phone): each lane's width and each row's height in CSS pixels.
259// Every row's picture is as tall as the row, so the lines run on unbroken.
260const LANE_PX = 14
261const ROW_PX = 28
262// The most the lanes take across, however deep the branches nest.
263const LANES_PX = 112
264// About how many CSS pixels such a surface gives each column it counts in
265// `bodyColumns`, and how many more letters of a prompt's proportional text fit
266// in a column's room than monospaced ones.
267const COLUMN_PX = 8
268const PROSE = 1.25
269// The lines' colours there: a picture cannot name a theme key. The accent,
270// then one for each of TONES, mid tones that read on a light page and a dark.
271const ACCENT_HEX = '#d97757'
272const TONES_HEX = ['#8b5cf6', '#14b8a6', '#ec4899', '#0ea5e9', '#10b981', '#f59e0b']
273// Pads a number to the width of the longest: a space as wide as a digit.
274const FIGURE = '\u2007'
275// What a turn did, in a picture surface's caption type: a tool's whole name.
276const TOLD_COLUMNS = OUTCOME_COLUMNS + 3
277// The most of a prompt the list opens under its row there.
278const WHOLE = 600
279
280// What the search field's key opens with: the one element the focus ring may
281// rest on.
282const SEARCH = 'search:'
283// How long typing pauses before the timeline filters: a key costs no search
284// and no redraw.
285const SETTLE = 150
286// The most matches drawn, newest first: a query that matches nearly every
287// prompt still draws a short list.
288const MAX_HITS = 100
289// How much of a prompt a hit keeps before its match.
290const LEAD = 40
291
292const ACCENT = 'claude'
293const MATCH = 'warning'
294
295const NOTHING_FOUND: SearchResults = { query: '', total: 0, hits: [] }
296
297const entries = atom({ plugin: 'prompt-timeline', key: 'entries' } as const, [])
298const picked = atom({ plugin: 'prompt-timeline', key: 'picked' } as const, '')
299const showContext = atom({ plugin: 'prompt-timeline', key: 'showContext' } as const, true)
300// Bumped every minute so the "5m ago" labels redraw.
301const tick = atom({ plugin: 'prompt-timeline', key: 'tick' } as const, 0)
302// The open search field's key, empty while search is closed. The surface keeps
303// what was typed in a field by its key after the field closes: a new key each
304// time it opens is a new, empty field.
305const field = atom({ plugin: 'prompt-timeline', key: 'field' } as const, '')
306const found = atom({ plugin: 'prompt-timeline', key: 'found' } as const, NOTHING_FOUND)
307// The pane opens on the tree. The list, once chosen, lasts the session.
308const isTree = atom({ plugin: 'prompt-timeline', key: 'isTree' } as const, true)
309// Shaped: a tree an earlier version laid out reads as empty until it is read
310// again, never as rows of the old shape.
311const tree = atom({ plugin: 'prompt-timeline', key: 'tree' } as const, [], { shape: 'tree-2' })
312// The prompt on an abandoned branch whose card is open, or ''.
313const opened = atom({ plugin: 'prompt-timeline', key: 'opened' } as const, '')
314
315// Prompts sent on main that no response has answered yet. A reload loses it;
316// the scan at load fills in what it missed.
317let waiting: string[] = []
318// Each prompt's whole text on one line, by id, for the search. Kept out of
319// `$.state`, which would copy it on every write. A reload loses it and the
320// scan at load reads it again; until then the search reads the previews.
321const corpus = new Map<string, string>()
322// The search waiting for typing to pause, and a count of searches asked, so
323// one that finishes after a newer one was asked writes nothing.
324let settling: Timer | undefined
325let searches = 0
326// Each prompt of the tree as the scan read it, by id: the turn's last row and
327// last reply stay here, out of `$.state`, for the cards and the switch, beside
328// the tokens its first response was answered over. A turn only a fork holds
329// names that fork's session, its transcript and the title of its chat. A
330// reload loses it and the scan at load reads it again.
331type Turn = {
332 id: string
333 parent: string
334 at: number
335 end: string
336 text: string
337 reply: string
338 outcome: Outcome
339 tokens: number
340 session?: string
341 path?: string
342 chat?: string
343}
344let turns = new Map<string, Turn>()
345// A transcript's tree as the scan last read it, by path, at the size it had
346// then: where it stands, its turns, and the title of its chat.
347type Scan = { size: number; here: string; turns: Turn[]; title: string }
348let scans = new Map<string, Scan>()
349// Whether each other transcript of the project, by path, holds a first prompt
350// of this session's (`roots`): a fork of the conversation, as the desktop app
351// makes one, or a copy /resume-at made. One that does not never will.
352let kin = new Map<string, boolean>()
353let roots = ''
354// The transcript's size and its forks' when the tree was last read, and the
355// other transcripts beside it then: a scan that would read the same bytes is
356// skipped. And a count of scans asked, so one that finishes after a newer one
357// was asked writes nothing.
358let grownAt = ''
359let grows = 0
360// How many times the session was resumed: a switch reads it to tell whether
361// its `/resume` took.
362let resumes = 0
363
364function textOf(content: unknown): string {
365 if (typeof content === 'string') return content
366 if (!Array.isArray(content)) return ''
367 if (content.some(block => block?.type === 'tool_result')) return ''
368
369 return content
370 .filter(block => block?.type === 'text' && typeof block.text === 'string')
371 .map(block => block.text as string)
372 .join('\n')
373}
374
375// A slash command's record, a bash-mode line and the engine's own wrappers
376// all open with a tag: none of them is a prompt the person wrote.
377function isTyped(text: string): boolean {
378 const trimmed = text.trim()
379
380 return trimmed !== '' && !trimmed.startsWith('<')
381}
382
383// Adds the entries not yet listed, and the context of those listed without one.
384function merge(list: TimelineEntry[], more: TimelineEntry[]): TimelineEntry[] {
385 const byId = new Map(list.map(entry => [entry.id, entry]))
386 for (const entry of more) {
387 const known = byId.get(entry.id)
388 if (known === undefined) byId.set(entry.id, entry)
389 else if (known.context === undefined && entry.context !== undefined) {
390 byId.set(entry.id, { ...known, context: entry.context })
391 }
392 }
393
394 return [...byId.values()].sort((a, b) => a.at - b.at).slice(-MAX_ENTRIES)
395}
396
397// A new prompt is the newest: one scan for a repeat, no sort.
398function append(list: TimelineEntry[], entry: TimelineEntry): TimelineEntry[] {
399 if (list.some(one => one.id === entry.id)) return list
400 if ((list.at(-1)?.at ?? 0) > entry.at) return merge(list, [entry])
401
402 return [...list.slice(1 - MAX_ENTRIES), entry]
403}
404
405function cut(line: string, room: number): string {
406 if (room <= 0) return ''
407
408 return line.length <= room ? line : `${line.slice(0, Math.max(1, room - 1)).trimEnd()}…`
409}
410
411// A prompt on one line, as far as the search reads it.
412function flatten(text: string): string {
413 return text.slice(0, FULL).replace(/\s+/g, ' ').trim()
414}
415
416// What the timeline keeps of a prompt: one line, no longer than a pane shows.
417// The full text would be copied on every write and scanned on every draw.
418function preview(text: string): string {
419 return cut(flatten(text), PREVIEW)
420}
421
422// The corpus drops prompts the timeline no longer lists.
423function prune(list: TimelineEntry[]) {
424 if (corpus.size <= MAX_ENTRIES) return
425 const listed = new Set(list.map(entry => entry.id))
426 for (const id of corpus.keys()) if (!listed.has(id)) corpus.delete(id)
427}
428
429// Each word of a query, matched anywhere and in any case.
430function termsOf(query: string): RegExp[] {
431 return query
432 .trim()
433 .split(/\s+/)
434 .filter(term => term !== '')
435 .map(term => new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'))
436}
437
438// The prompts holding every word of the query, newest first: how many, and
439// the first MAX_HITS of them with the line around the first word's match.
440function search(list: TimelineEntry[], query: string): SearchResults {
441 const terms = termsOf(query)
442 if (terms.length === 0) return NOTHING_FOUND
443
444 const hits: SearchHit[] = []
445 let total = 0
446 for (let index = list.length - 1; index >= 0; index--) {
447 const entry = list[index]!
448 const text = corpus.get(entry.id) ?? entry.text
449 if (!terms.every(term => term.test(text))) continue
450
451 total++
452 if (hits.length === MAX_HITS) continue
453 const match = terms[0]!.exec(text)!
454 const from = Math.max(0, match.index - LEAD)
455 hits.push({
456 id: entry.id,
457 before: `${from > 0 ? '…' : ''}${text.slice(from, match.index)}`,
458 match: match[0],
459 after: cut(text.slice(match.index + match[0].length), PREVIEW),
460 })
461 }
462
463 return { query, total, hits }
464}
465
466// A hit's line in `room` columns: the match whole where it fits, after as much
467// of what leads to it as leaves the match within the first two thirds.
468function fit(hit: SearchHit, room: number): [string, string, string] {
469 const match = cut(hit.match, room)
470 const spare = room - match.length
471 const lead = Math.floor((room * 2) / 3) - match.length
472 let before = hit.before
473 if (before.length > Math.max(0, lead)) {
474 const kept = Math.max(0, Math.min(lead, spare))
475 before = kept > 1 ? `…${before.slice(before.length - kept + 1)}` : ''
476 }
477
478 return [before, match, cut(hit.after, spare - before.length)]
479}
480
481function age(at: number, now: number): string {
482 if (at === 0) return ''
483 const minutes = Math.floor((now - at) / 60_000)
484 if (minutes < 1) return 'just now'
485 if (minutes < 60) return `${minutes}m ago`
486 const hours = Math.floor(minutes / 60)
487
488 return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
489}
490
491// Grey while the window has room, through yellow and orange as it fills, to
492// bright red when it is full.
493function contextTone(percent: number | undefined) {
494 if (percent === undefined) return { dimColor: true }
495 const end = Math.max(1, TONES_AT.findIndex(tone => percent <= tone.at))
496 const from = TONES_AT[end - 1]!
497 const to = TONES_AT[end]!
498 const share = (percent - from.at) / (to.at - from.at)
499 const channels = from.rgb.map((channel, i) => Math.round(channel + (to.rgb[i]! - channel) * share))
500
501 return { color: `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('')}` }
502}
503
504// The Claude Code folder that holds every project's transcripts.
505async function configDir($: EngineInterface): Promise<string | undefined> {
506 const home = await $.env.get('HOME')
507
508 return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home && `${home}/.claude`)
509}
510
511async function transcriptPath($: EngineInterface): Promise<string | undefined> {
512 const id = await $.session.id()
513 const config = await configDir($)
514 if (!config) return undefined
515
516 const projects = `${config}/projects`
517 const cwd = await $.session.cwd()
518 const guess = `${projects}/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
519 if (await $.fs.exists(guess)) return guess
520 if (!(await $.fs.exists(projects))) return undefined
521
522 for (const dir of await $.fs.list(projects)) {
523 const path = `${projects}/${dir.name}/${id}.jsonl`
524 if (dir.kind === 'dir' && (await $.fs.exists(path))) return path
525 }
526
527 return undefined
528}
529
530// A JSON string's opening as the scan cut it, decoded: up to its closing
531// quote, or where it was cut, short of a broken escape and marked as cut.
532function decode(escaped: string): string {
533 // Most come back whole, closing quote and all: one parse.
534 if (escaped.endsWith('"')) {
535 try {
536 return JSON.parse(`"${escaped}`)
537 } catch {
538 // Something after the string, or a quote that was escaped: walk it.
539 }
540 }
541 let end = 0
542 while (end < escaped.length && escaped[end] !== '"') {
543 const size = escaped[end] !== '\\' ? 1 : escaped[end + 1] === 'u' ? 6 : 2
544 if (end + size > escaped.length) break
545 end += size
546 }
547 try {
548 const text: string = JSON.parse(`"${escaped.slice(0, end)}"`)
549
550 return escaped[end] === '"' ? text : `${text.trimEnd()}…`
551 } catch {
552 return ''
553 }
554}
555
556function percentOf(tokens: number, window: number): number {
557 return Math.min(100, Math.round((tokens / window) * 100))
558}
559
560// The prompts in the transcript, each with the context its first response was
561// answered over: those before the mod loaded, and a resumed session's earlier
562// ones. Live prompts take theirs from the API instead (turn.step), so this
563// runs once per load, and its output is a short record per row.
564async function seed($: EngineInterface): Promise<void> {
565 const path = await transcriptPath($)
566 if (path === undefined) return
567
568 const scan = await $.process.run(['awk', SCAN, path])
569 if (scan.exitCode !== 0) return
570
571 const prompts: [number, TimelineEntry][] = []
572 const answered: [number, number][] = []
573 for (const record of scan.stdout.split('\n')) {
574 const [kind, line = '', first = '', timestamp = '', escaped = ''] = record.split('\t')
575 if (kind === 'R') answered.push([Number(line), Number(first)])
576 if (kind !== 'P') continue
577
578 const text = decode(escaped)
579 if (first === '' || !isTyped(text)) continue
580 const whole = flatten(text)
581 corpus.set(first, whole)
582 prompts.push([Number(line), { id: first, text: cut(whole, PREVIEW), at: Date.parse(timestamp) || 0 }])
583 }
584
585 // Both lists ascend by line: one walk pairs each prompt with the first
586 // response after it and before the next prompt.
587 const { window } = (await $.session.usage()).context
588 let next = 0
589 for (const [index, [line, entry]] of prompts.entries()) {
590 const before = prompts[index + 1]?.[0] ?? Infinity
591 while (next < answered.length && answered[next]![0] < line) next++
592 const response = answered[next]
593 if (response !== undefined && response[0] < before && window > 0) {
594 entry.context = percentOf(response[1], window)
595 }
596 }
597
598 // Entries an earlier load kept whole are cut down too.
599 await update($, entries, list =>
600 merge(
601 list.map(entry => ({ ...entry, text: preview(entry.text) })),
602 prompts.map(([, entry]) => entry),
603 ),
604 )
605 await refind($)
606}
607
608// Runs the query over the timeline and shows what it matched, unless a newer
609// search was asked meanwhile.
610async function find($: EngineInterface, query: string) {
611 const ask = ++searches
612 const results = search(await read($, entries), query)
613 if (ask !== searches) return
614
615 await update($, found, () => results)
616}
617
618// The search shown, run again over a timeline that changed under it.
619async function refind($: EngineInterface) {
620 const { query } = await read($, found)
621 if (query !== '') await find($, query)
622}
623
624// The query typed so far: searched once typing pauses, or at once on Enter.
625function typed($: EngineInterface, query: string, isSubmitted = false) {
626 settling?.cancel()
627 if (isSubmitted) {
628 void find($, query).catch(() => undefined)
629
630 return
631 }
632 settling = $.clock.after(SETTLE, () => void find($, query).catch(() => undefined))
633}
634
635async function toggleSearch($: EngineInterface) {
636 settling?.cancel()
637 searches++
638 // Each search starts from an empty field and the whole timeline.
639 const key = (await read($, field)) === '' ? `${SEARCH}${await $.clock.now()}` : ''
640 await update($, found, () => NOTHING_FOUND)
641 await update($, field, () => key)
642 if (key === '') return
643
644 // The field sits at the top: scroll it into view. A press leaves the keys
645 // with the prompt, so the pane asks for them (granted over an empty prompt),
646 // and the ring goes to the field.
647 await $.ui.scroll({ in: PANE, to: 'start' }).catch(() => undefined)
648 await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS, focus: true }).catch(() => undefined)
649 await $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
650}
651
652async function closeSearch($: EngineInterface) {
653 settling?.cancel()
654 searches++
655 await update($, found, () => NOTHING_FOUND)
656 await update($, field, () => '')
657}
658
659async function toggleContext($: EngineInterface) {
660 await update($, showContext, isShown => !isShown)
661 await $.store.set('showContext', await read($, showContext))
662}
663
664async function open($: EngineInterface) {
665 await $.store.set('isOpen', true)
666
667 return $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
668}
669
670async function jump($: EngineInterface, id: string, surface: RenderSurface) {
671 // A graphical surface's transcript is the app's own, which nothing here can
672 // scroll: there a press opens the prompt whole under its row, or closes it.
673 if (surface !== 'terminal') {
674 await update($, picked, shown => (shown === id ? '' : id))
675
676 return
677 }
678 // Scroll first: a transcript row moves only while answering the press.
679 const moved = await $.ui
680 .scroll({ to: { requestId: id }, block: 'start' })
681 .catch((error: unknown) => ({ deny: String(error) }))
682 await update($, picked, () => id)
683 if (moved.deny !== undefined) $.ui.toast(`Couldn't scroll to that prompt: ${moved.deny}`)
684}
685
686// What a turn did, by what most deserves its row: a push, a commit, the files
687// it changed, another tool it used, the commands it ran, or a reply alone.
688function outcomeOf(cmds: number, files: number, tool: string, git: number, reply: string): Outcome {
689 if (git === 2) return { label: '⇡ pushed', kind: 'push' }
690 if (git === 1) return { label: '● committed', kind: 'commit' }
691 if (files > 0) return { label: `✎ ${files} file${files === 1 ? '' : 's'}`, kind: 'files' }
692 if (tool !== '') return { label: `◇ ${tool.replace(/^mcp__.*__/, '')}`, kind: 'tool' }
693 if (cmds > 0) return { label: `› ${cmds} cmd${cmds === 1 ? '' : 's'}`, kind: 'cmds' }
694
695 return reply === '' ? { label: 'no reply', kind: 'none' } : { label: 'reply', kind: 'reply' }
696}
697
698// A line's colour, by its tone.
699function toneColor(tone: number): string {
700 return tone === PATH ? ACCENT : TONES[tone % TONES.length]!
701}
702
703// The same, where it is drawn in a picture.
704function toneHex(tone: number): string {
705 return tone === PATH ? ACCENT_HEX : TONES_HEX[tone % TONES_HEX.length]!
706}
707
708// One row of the tree's lanes as a picture, `lanes` of `lane` pixels across.
709// The lines beside the prompt run through the row, as the terminal's `│` do.
710// Its own line comes down to its dot from the row above, or curves in from the
711// lane it forks off, from partway down where the fork opens the tree (`┬`);
712// and it goes on below where the next row carries it on, in that row's tone.
713// The dot is hollow on a branch the conversation left: no line runs into it.
714function lanesSvg(node: TreeNode, before: TreeNode | undefined, after: TreeNode | undefined, lanes: number, lane: number) {
715 const x = (at: number) => lane / 2 + at * lane
716 const mid = ROW_PX / 2
717 const at = node.cells.length - 1
718 const radius = node.place === 'here' ? 5 : 4
719 const stroke = (tone: number) => `stroke="${toneHex(tone)}" stroke-width="2" fill="none"`
720 const isFork = node.above.some(cell => cell.text === '─╮')
721 const from = node.above.some(cell => cell.text === '┬') ? mid / 2 : 0
722 const marks = node.cells
723 .slice(0, -1)
724 .map((cell, index) => `<path d="M${x(index)} ${isFork && index === at - 1 ? from : 0}V${ROW_PX}" ${stroke(cell.tone)}/>`)
725 if (isFork) {
726 marks.push(`<path d="M${x(at - 1)} ${from}C${x(at - 1)} ${mid} ${x(at)} ${from} ${x(at)} ${mid - radius}" ${stroke(node.tone)}/>`)
727 } else if (before !== undefined && before.cells.length > at) {
728 marks.push(`<path d="M${x(at)} 0V${mid - radius}" ${stroke(node.tone)}/>`)
729 }
730 const below = after?.cells[at]
731 const isNewBranch = after !== undefined && after.cells.length - 1 === at && after.above.some(cell => cell.text === '─╮')
732 if (below !== undefined && !isNewBranch) marks.push(`<path d="M${x(at)} ${mid + radius}V${ROW_PX}" ${stroke(below.tone)}/>`)
733 const color = toneHex(node.tone)
734 if (node.place === 'here') {
735 marks.push(`<circle cx="${x(at)}" cy="${mid}" r="${radius + 2.5}" fill="${color}" fill-opacity="0.25"/>`)
736 }
737 marks.push(
738 node.place === 'off'
739 ? `<circle cx="${x(at)}" cy="${mid}" r="${radius - 1}" ${stroke(node.tone)}/>`
740 : `<circle cx="${x(at)}" cy="${mid}" r="${radius}" fill="${color}"/>`,
741 )
742 const width = lanes * lane
743
744 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${ROW_PX}" viewBox="0 0 ${width} ${ROW_PX}">${marks.join('')}</svg>`
745}
746
747// An empty picture as wide as the lanes: what an opened card is set in by, so
748// it lines up with the prompts.
749function blankSvg(width: number) {
750 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="1" viewBox="0 0 ${width} 1"/>`
751}
752
753// When a prompt was sent: its time today, its date before.
754function sentAt(at: number, now: number): string {
755 if (at === 0) return ''
756 const date = new Date(at)
757 if (date.toDateString() !== new Date(now).toDateString()) {
758 return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
759 }
760
761 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
762}
763
764// A text in lines of at most `room` columns, broken between words, the last
765// of `most` lines cut short with an ellipsis where more was left.
766function wrap(text: string, room: number, most: number): string[] {
767 const lines: string[] = []
768 let line = ''
769 for (const word of text.split(/\s+/).filter(one => one !== '')) {
770 if (line !== '' && line.length + 1 + word.length > room) {
771 lines.push(line)
772 line = ''
773 }
774 line = line === '' ? cut(word, room) : `${line} ${word}`
775 }
776 if (line !== '') lines.push(line)
777 if (lines.length <= most) return lines
778
779 return [...lines.slice(0, most - 1), cut(`${lines[most - 1]} …`, room)]
780}
781
782// The tree drawn as lanes, top down, each prompt a dot on its branch's line,
783// below the one it was asked after. At every split the newest branch carries
784// on down the lane and older ones fork off one lane to the right, each drawn
785// whole before the lane goes on. Each prompt has the row of lines above it:
786// it carries each line on, or forks a branch off (`├─╮`). Where the
787// conversation stands decides only which dots are lit and which lines are the
788// accent, so a switch moves nothing; a new branch takes its lane. A rewind to
789// the top leaves more than one first prompt: they split the same way, from a
790// fork above the first row (`┬─╮`).
791function layout(list: Turn[], here: string, window = 0): TreeNode[] {
792 const byId = new Map(list.map(turn => [turn.id, turn]))
793 const roots: Turn[] = []
794 const children = new Map<string, Turn[]>()
795 for (const turn of list) {
796 const siblings = children.get(turn.parent)
797 if (!byId.has(turn.parent)) roots.push(turn)
798 else if (siblings === undefined) children.set(turn.parent, [turn])
799 else siblings.push(turn)
800 }
801
802 // The prompts the conversation passes through on its way to here.
803 const path = new Set<string>()
804 for (let id = here; byId.has(id) && !path.has(id); id = byId.get(id)!.parent) path.add(id)
805
806 const nodes: TreeNode[] = []
807 let branches = 0
808 // Draws the prompts asked at one point, at `depth`, beside `lanes`: older
809 // ones fork off one lane to the right, each drawn whole, and the newest is
810 // returned to carry on down the lane, whose own turns are `branch`.
811 const split = (next: Turn[], depth: number, lanes: number[], branch: number): Turn | undefined => {
812 const sides = next.slice(0, -1)
813 const onward: Turn | undefined = next[next.length - 1]
814 sides.forEach((side, index) => {
815 // The path runs on down this lane past the branch to the onward
816 // prompt or to a later branch.
817 const leadsOn = [onward!, ...sides.slice(index + 1)].some(later => path.has(later.id))
818 const lane = leadsOn ? PATH : branch
819 draw(side, depth, [...lanes, lane], path.has(side.id) ? PATH : lane)
820 })
821
822 return onward
823 }
824 // Draws a branch from its first prompt, right of `lanes`, each the tone its
825 // line goes on below in. Its first row forks from the lane left of it, in
826 // the `fork` tone.
827 const draw = (first: Turn, depth: number, lanes: number[], fork: number | undefined, branch = branches++) => {
828 const beside = lanes.map(tone => ({ text: '│ ', tone }))
829 const own: TreeNode[] = []
830 let turn: Turn | undefined = first
831 for (; turn !== undefined; depth++) {
832 const place = turn.id === here ? 'here' : path.has(turn.id) ? 'path' : 'off'
833 const tone = place === 'off' ? branch : PATH
834 const above =
835 own.length > 0
836 ? [...beside, { text: '│', tone }]
837 : fork === undefined
838 ? []
839 : [...beside.slice(0, -1), { text: '├', tone: fork }, { text: '─╮', tone }]
840 const node: TreeNode = {
841 id: turn.id,
842 text: turn.text,
843 at: turn.at,
844 depth,
845 lane: lanes.length,
846 above,
847 cells: [...beside, { text: DOTS[place], tone }],
848 tone,
849 place,
850 outcome: turn.outcome,
851 ...(turn.chat === undefined ? {} : { chat: turn.chat }),
852 ...(window > 0 && turn.tokens > 0 ? { context: percentOf(turn.tokens, window) } : {}),
853 }
854 nodes.push(node)
855 own.push(node)
856 turn = split(children.get(turn.id) ?? [], depth + 1, lanes, branch)
857 }
858
859 // The path runs down the start of a branch, if at all: the rest of it is a
860 // branch the conversation left, and its first row says how long it is.
861 const left = own.filter(node => node.place === 'off')
862 if (left[0] !== undefined) {
863 const isUnanswered = left.length === 1 && left[0].outcome.kind === 'none'
864 left[0].chip = isUnanswered ? 'no reply' : `${left.length} turn${left.length === 1 ? '' : 's'}`
865 }
866 }
867 // The first prompts split as any turn's do. Nothing runs down into the
868 // first fork, and the lane runs on past the older starts to the newest.
869 const main = branches++
870 const newest = split(roots, 1, [], main)
871 const older = nodes.length
872 if (newest !== undefined) draw(newest, 1, [], undefined, main)
873 if (older > 0) {
874 nodes[0]!.above = nodes[0]!.above.map(cell => (cell.text === '├' ? { ...cell, text: '┬' } : cell))
875 nodes[older]!.above = [{ text: '│', tone: nodes[older]!.tone }]
876 }
877
878 return nodes
879}
880
881// Reads one transcript's tree (TREE_SCAN), at the size it has now.
882async function scanTree($: EngineInterface, path: string, size: number): Promise<Scan | undefined> {
883 const scan = await $.process.run(['awk', TREE_SCAN, path])
884 if (scan.exitCode !== 0) return undefined
885
886 let here = ''
887 let title = ''
888 const list: Turn[] = []
889 for (const record of scan.stdout.split('\n')) {
890 const [kind, id = '', parent = '', timestamp = '', end = '', cmds = '', files = '', tool = '', git = '', text = '', reply = '', spent = ''] =
891 record.split('\t')
892 if (kind === 'H') here = id
893 if (kind === 'N' && id !== '') title = decode(`${id}"`)
894 if (kind !== 'T' || id === '') continue
895 // A turn with no text has no reply to decode, not one cut short.
896 const said = reply === '' ? '' : cut(flatten(decode(reply)), REPLY)
897 list.push({
898 id,
899 parent,
900 at: Date.parse(timestamp) || 0,
901 end,
902 // A pasted image is a mark, not its placeholder's words.
903 text: preview(decode(text)).replace(/\[Image #\d+\]/g, '▣'),
904 reply: said,
905 outcome: outcomeOf(Number(cmds) || 0, Number(files) || 0, tool, Number(git) || 0, said),
906 tokens: Number(spent) || 0,
907 })
908 }
909
910 return { size, here, turns: list, title }
911}
912
913// A transcript's tree, read again only where it has grown since.
914async function scanned($: EngineInterface, path: string, size: number): Promise<Scan | undefined> {
915 const known = scans.get(path)
916 if (known?.size === size) return known
917 const scan = await scanTree($, path, size)
918 if (scan !== undefined) scans.set(path, scan)
919
920 return scan
921}
922
923// The forks of the conversation among the other transcripts of its project:
924// those holding one of its first prompts, as the desktop app's fork, a copy
925// /resume-at made, or the session this one was forked from all do. Each is
926// looked for once, by a grep that stops at a file's first match.
927async function forksOf(
928 $: EngineInterface,
929 own: Scan,
930 others: { path: string; size: number }[],
931): Promise<{ sessionId: string; path: string; scan: Scan }[]> {
932 const firsts = own.turns.filter(turn => turn.parent === '').map(turn => turn.id)
933 // A rewind to the top adds a first prompt: a fork made since holds that one.
934 if (firsts.join(',') !== roots) {
935 kin = new Map()
936 roots = firsts.join(',')
937 }
938 if (firsts.length === 0) return []
939
940 const unknown = others.filter(other => !kin.has(other.path))
941 if (unknown.length > 0) {
942 const found = await $.process.run([
943 'grep',
944 '-l',
945 '-F',
946 ...firsts.flatMap(id => ['-e', `"uuid":"${id}"`]),
947 '--',
948 ...unknown.map(other => other.path),
949 ])
950 // grep exits 1 where no file held one, 2 where one could not be read.
951 const held = new Set(found.stdout.split('\n'))
952 for (const other of unknown) kin.set(other.path, held.has(other.path))
953 }
954
955 const forks: { sessionId: string; path: string; scan: Scan }[] = []
956 for (const other of others) {
957 if (kin.get(other.path) !== true) continue
958 const scan = await scanned($, other.path, other.size).catch(() => undefined)
959 const sessionId = other.path.slice(other.path.lastIndexOf('/') + 1, -'.jsonl'.length)
960 if (scan !== undefined) forks.push({ sessionId, path: other.path, scan })
961 }
962
963 return forks
964}
965
966// Reads the transcript's tree again, and its forks', unless none of them has
967// grown since it was last read and no transcript has joined them.
968async function grow($: EngineInterface, isForced = false): Promise<void> {
969 const path = await transcriptPath($)
970 if (path === undefined) return
971 const { size } = await $.fs.stat(path)
972 const folder = path.slice(0, path.lastIndexOf('/'))
973 const others = (await $.fs.list(folder).catch(() => []))
974 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl') && `${folder}/${entry.name}` !== path)
975 .map(entry => ({ path: `${folder}/${entry.name}`, size: entry.size }))
976 // Another session of the project that is no fork changes nothing here,
977 // however it grows.
978 const seen = [
979 size,
980 ...others.map(other => (kin.get(other.path) === false ? other.path : `${other.path}:${other.size}`)),
981 ].join('\n')
982 if (!isForced && seen === grownAt) return
983
984 const ask = ++grows
985 const own = await scanned($, path, size)
986 if (own === undefined || ask !== grows) return
987 const forks = await forksOf($, own, others).catch(() => [])
988 if (ask !== grows) return
989
990 // A fork holds the turns before it as the session does: each joins the
991 // tree once, the session's own first, and a fork's own turns are its
992 // branches, under the turn the fork was made at.
993 const list = [...own.turns]
994 const listed = new Set(list.map(turn => turn.id))
995 for (const { sessionId, path: holder, scan } of forks) {
996 for (const turn of scan.turns) {
997 if (listed.has(turn.id)) continue
998 listed.add(turn.id)
999 list.push({ ...turn, session: sessionId, path: holder, chat: scan.title || sessionId.slice(0, 8) })
1000 }
1001 }
1002 // Among the turns asked at one point, the newest carries on down the lane.
1003 if (list.length > own.turns.length) list.sort((one, other) => one.at - other.at)
1004 // A fork's turns take their context from its transcript, as a share of
1005 // this session's window.
1006 const { window } = (await $.session.usage().catch(() => undefined))?.context ?? { window: 0 }
1007 grownAt = seen
1008 turns = new Map(list.map(turn => [turn.id, turn]))
1009 await update($, tree, () => layout(list, own.here, window))
1010}
1011
1012async function regrow($: EngineInterface) {
1013 if (await read($, isTree)) await grow($).catch(() => undefined)
1014}
1015
1016async function toggleTree($: EngineInterface) {
1017 const isShown = !(await read($, isTree))
1018 await update($, opened, () => '')
1019 await update($, isTree, () => isShown)
1020 if (!isShown) return
1021
1022 await grow($).catch(() => undefined)
1023 // A search stays open across the switch, its field at the top in view.
1024 if ((await read($, field)) === '') void $.ui.scroll({ in: PANE, to: 'end' }).catch(() => undefined)
1025}
1026
1027// A press opens the prompt's card, or closes it. A prompt the conversation
1028// passes through is in the terminal's transcript too: the press scrolls to it.
1029async function pick($: EngineInterface, id: string, surface: RenderSurface) {
1030 const node = (await read($, tree)).find(one => one.id === id)
1031 if (node?.place !== 'off' && surface === 'terminal') await jump($, id, surface)
1032 await update($, opened, shown => (shown === id ? '' : id))
1033}
1034
1035// The command that opens a session at the end of the prompt's turn from
1036// another terminal, the message named by its first eight characters as the
1037// status line names it; a fork's turn, in that fork's session.
1038function resumeCommand(turn: Turn | undefined, sessionId: string): string | undefined {
1039 if (!turn?.end) return undefined
1040
1041 return `/resume-at ${turn.session ?? sessionId}:${turn.end.slice(0, 8)}`
1042}
1043
1044async function copyResume($: EngineInterface, id: string, surface: RenderSurface) {
1045 const command = resumeCommand(turns.get(id), await $.session.id())
1046 if (command === undefined) {
1047 $.ui.toast("Couldn't find that turn in the transcript.")
1048
1049 return
1050 }
1051 const { isCopied } = await $.ui.copy({ text: command, surface }).catch(() => ({ isCopied: false }))
1052 $.ui.toast(isCopied ? `Copied ${command}` : `Couldn't copy it: ${command}`)
1053}
1054
1055// Points a session at a message with a `last-prompt` record, the one Claude
1056// Code writes when it rewinds: resuming the session follows the last one
1057// written. No row is changed or removed. Resolves why not, if it could not.
1058async function pointAt(
1059 $: EngineInterface,
1060 path: string,
1061 sessionId: string,
1062 leaf: string,
1063 lastPrompt: string,
1064): Promise<string | undefined> {
1065 return appendRecord($, path, {
1066 type: 'last-prompt',
1067 ...(lastPrompt === '' ? {} : { lastPrompt }),
1068 leafUuid: leaf,
1069 explicit: true,
1070 sessionId,
1071 })
1072}
1073
1074// Appends one record to a transcript, as Claude Code writes its own. Resolves
1075// why not, if it could not.
1076async function appendRecord($: EngineInterface, path: string, record: object): Promise<string | undefined> {
1077 const wrote = await $.process.run(['sh', '-c', 'printf "%s\\n" "$1" >> "$2"', 'sh', JSON.stringify(record), path])
1078
1079 return wrote.exitCode === 0 ? undefined : `Couldn't write to the transcript: ${wrote.stderr.trim()}`
1080}
1081
1082// Resumes a session, so the conversation reads from where it was pointed.
1083// Resolves whether the resume took, and what `/resume` said.
1084async function resume($: EngineInterface, sessionId: string): Promise<{ isResumed: boolean; said: string }> {
1085 const before = resumes
1086 const resumed = await $.command
1087 .run({ command: 'resume', args: sessionId })
1088 .catch((error: unknown) => ({ text: String(error) }))
1089
1090 return { isResumed: resumes !== before, said: resumed.text?.trim() ?? '' }
1091}
1092
1093// Whether Claude Code, resuming the transcript, leaves the message out: the
1094// transcript is past WHOLE_UP_TO and a compaction comes after the message.
1095async function isOutOfReach($: EngineInterface, path: string, leaf: string): Promise<boolean> {
1096 if ((await $.fs.stat(path)).size <= WHOLE_UP_TO) return false
1097 const found = await $.process.run([
1098 'grep',
1099 '-n',
1100 '-o',
1101 '-F',
1102 '-e',
1103 '"subtype":"compact_boundary"',
1104 '-e',
1105 `"uuid":"${leaf}"`,
1106 path,
1107 ])
1108 const lines = found.stdout.split('\n')
1109 const at = lines.find(line => line.endsWith(`"uuid":"${leaf}"`))
1110 const compacted = lines.findLast(line => line.endsWith('"compact_boundary"'))
1111
1112 return at !== undefined && compacted !== undefined && parseInt(compacted) > parseInt(at)
1113}
1114
1115// Copies a session, every branch, into a new one beside it (COPY_SCAN).
1116// Resolves the copy, or why it could not.
1117async function copySession(
1118 $: EngineInterface,
1119 path: string,
1120 sessionId: string,
1121): Promise<{ path: string; sessionId: string } | string> {
1122 const copyId = crypto.randomUUID()
1123 const copyPath = `${path.slice(0, path.lastIndexOf('/'))}/${copyId}.jsonl`
1124 const copied = await $.process.run([
1125 'awk',
1126 '-v',
1127 `from="sessionId":"${sessionId}"`,
1128 '-v',
1129 `to="sessionId":"${copyId}"`,
1130 '-v',
1131 `dst=${copyPath}`,
1132 COPY_SCAN,
1133 path,
1134 ])
1135
1136 return copied.exitCode === 0 ? { path: copyPath, sessionId: copyId } : `Couldn't copy session ${sessionId}: ${copied.stderr.trim()}`
1137}
1138
1139// Points this session at the end of the prompt's turn, so the next turn grows
1140// from there; every branch stays in the transcript. A turn out of Claude
1141// Code's reach there opens in a copy of the session instead.
1142async function switchTo($: EngineInterface, id: string) {
1143 const turn = turns.get(id)
1144 const path = await transcriptPath($)
1145 if (turn === undefined || turn.end === '' || path === undefined) {
1146 $.ui.toast("Couldn't find that branch in the transcript.")
1147
1148 return
1149 }
1150
1151 await update($, opened, () => '')
1152 // A fork's own turn goes on in the fork, opened as /resume-at opens it: a
1153 // copy of it where another window has it open.
1154 if (turn.session !== undefined) {
1155 const { text } = await resumeAt($, `${turn.session}:${turn.end}`)
1156 if (text !== undefined) $.ui.toast(text)
1157
1158 return
1159 }
1160 const depth = (await read($, tree)).find(one => one.id === id)?.depth
1161 const sessionId = await $.session.id()
1162 const isInCopy = await isOutOfReach($, path, turn.end).catch(() => false)
1163 const opening = isInCopy ? await copySession($, path, sessionId) : { path, sessionId }
1164 if (typeof opening === 'string') {
1165 $.ui.toast(opening)
1166
1167 return
1168 }
1169 const unwritten = await pointAt($, opening.path, opening.sessionId, turn.end, turn.text)
1170 if (unwritten !== undefined) {
1171 $.ui.toast(unwritten)
1172
1173 return
1174 }
1175 const { isResumed, said } = await resume($, opening.sessionId)
1176 if (isResumed) {
1177 $.ui.toast(
1178 `Back at turn ${depth ?? '?'}, “${cut(turn.text, 40)}”: your next prompt continues from there.${isInCopy ? ` It is before a compaction Claude Code no longer reads here, so this is ${opening.sessionId}, a copy of the session with every branch.` : ''}`,
1179 )
1180
1181 return
1182 }
1183
1184 // The record stands, so the next resume opens the branch, as long as no
1185 // turn is taken there first.
1186 const command = `claude --resume ${opening.sessionId}`
1187 const isCopied = (await $.ui.copy({ text: command }).catch(() => ({ isCopied: false }))).isCopied
1188 $.ui.toast(
1189 `${said ? `${said} ` : ''}Branch saved: quit, then run ${command}${isCopied ? ' (copied)' : ''}.`,
1190 )
1191}
1192
1193// Hands a URL to whatever the machine opens it with, from wherever the session
1194// runs: Windows, by way of WSL or not, macOS, or a Linux desktop. Resolves
1195// whether it was handed on: what opens it says nothing back.
1196async function openUrl($: EngineInterface, url: string): Promise<boolean> {
1197 const explorer = '/mnt/c/Windows/explorer.exe'
1198 const isWsl = await $.fs.exists(explorer).catch(() => false)
1199 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
1200 const isMac = !isWsl && !isWindows && (await $.process.run(['uname']).catch(() => undefined))?.stdout.trim() === 'Darwin'types/index.d.ts 63 lines1// `context`: how full the context window was, in percent, when the prompt's
2// first response was answered; absent until that response is in the transcript.
3export type TimelineEntry = { id: string; text: string; at: number; context?: number }
4
5// A prompt the search matched: its first match, and the line either side of it.
6export type SearchHit = { id: string; before: string; match: string; after: string }
7
8// The search last applied: its query, how many prompts matched it, and the
9// newest of them; an empty query filters nothing.
10export type SearchResults = { query: string; total: number; hits: SearchHit[] }
11
12// A run of the tree's lines, and its colour: -1 for the line the conversation
13// runs down to where it stands, otherwise its branch's colour by number.
14export type TreeLine = { text: string; tone: number }
15
16// What a turn did, as the tree's row ends with it: pushed, committed, the files
17// it changed, another tool it used, the commands it ran, or a reply alone.
18export type Outcome = {
19 label: string
20 kind: 'push' | 'commit' | 'files' | 'tool' | 'cmds' | 'reply' | 'none'
21}
22
23// One prompt of the session's tree, as the tree view draws it: its turn number
24// on its own branch, its dot and the lines beside it (`cells`), the row of
25// lines between it and the row above (`above`), and whether the conversation
26// stands at it (`here`), passes through it (`path`), or left it on a branch a
27// rewind abandoned (`off`). `chip` names how many turns a branch the
28// conversation does not run down has, on its first. `chat` is the title of
29// the chat a fork's own turn is in, a session of its own. `context` is how
30// full the window was, in percent, when its first response was answered, as
31// its transcript records it.
32export type TreeNode = {
33 id: string
34 text: string
35 at: number
36 depth: number
37 lane: number
38 above: TreeLine[]
39 cells: TreeLine[]
40 tone: number
41 place: 'here' | 'path' | 'off'
42 outcome: Outcome
43 chip?: string
44 chat?: string
45 context?: number
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 'prompt-timeline': {
51 entries: TimelineEntry[]
52 picked: string
53 tick: number
54 showContext: boolean
55 field: string
56 found: SearchResults
57 isTree: boolean
58 tree: Shaped<TreeNode[]>
59 opened: string
60 }
61 }
62}
63