SLOPSHOPPER

prompt-timeline

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…

newpanecommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-timeline
│ ┃ Timeline ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Timeline list-viewtree-toggle context │ ┃ ──────────────────────────────────────────… ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ○ No prompts yet ⏺ Update(src/auth.ts) │ ┃ Send one and it lands here. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /timeline │ ⎿ prompt-timeline: Timeline opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Timeline
◆ Timeline list-viewtree-toggle context search-toggle0 p ─────────────────────────────────────────────────────── ○ No prompts yet Send one and it lands here.
README

prompt-timeline

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.

The tree view, each part labelled: the branches a rewind left, what each turn did, its context use and when it was sent

  • Click a prompt to scroll the transcript to it. Clicking prompts in the tree scrolls the transcript to each one
  • Use list to see the prompts in the order you sent them, with how long ago, and ctx to show or hide the context column in both views.
  • The context column is grey while the window has room and turns yellow by 30%, orange by 50% and bright red at 100%, shading smoothly between them.
  • Use find to search the full text of every prompt, in either view: the tree lights each match where it sits and dims the rest, and the list shows only the matches, newest first.
  • The pane opens on the tree: every branch a rewind left behind, to continue from one. A rewind to the very first prompt is a branch too, forking off above it. Each prompt is a numbered dot on its branch's line, followed by what its turn did (files changed, commands run, a commit or a push), how much of the context window was in use, and when you sent it. Your own line is in the accent colour, and every other branch has a colour of its own. Click a prompt on another branch to read its last reply and continue from it. Each card copies a /resume-at command. The tree before and after continue here on another branch: the accent line and the lit row move to the branch picked, and the line left behind stays as a branch of its own
  • A fork is a branch too. The desktop app's Fork makes a new chat, and so does /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.

In the desktop app

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.

Install

/plugin install prompt-timeline --marketplace xRyul/claude-plugins-and-mods-by-xryul

Licence

MIT

Source 2 files
hooks/register.tsx 2299 lines
1import { 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 lines
1// `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