Maps the conversation into branches: a pane of branch cards with Haiku titles and summaries, a live browser map with click-to-switch, and subagents that see…

A map of where a Claude Code conversation has gone. You split the conversation into branches as you explore, and convo-tree shows them as cards, keeps a one-line summary of each, and can hand a subagent just one branch.
The conversation itself stays linear. Branches are a layer on top that groups turns: each turn belongs to the branch that was active when you sent it. Switching to an old branch changes where your next prompts go, and tells Claude you're back on that thread.
/tree): one bordered card per branch, indented by depth and joined by dotted guides. Each card has the branch's title, its summary and its turn count. Open a card to see its last turns and replies, with Switch here, ⑂ Split reply and ⧉ Agent./tree-web, or ↗ Map in the pane): the same branches as a left-to-right map that updates live. Click a card to read it; double-click or Switch here to switch the session to it.convo-tree:branch agent, which gets that branch's turns (plus the turns leading up to it) and nothing else. /branch-agent A <task> does the same.| Command | Does |
|---|---|
/tree | Open the pane |
/tree-web | Open the browser map |
/branch [label] | Start a branch off the active one; your next prompt goes there |
/branches | List branches as text |
/branch-agent <branch> <task> | Run a subagent on one branch |
Only Haiku calls cost tokens: a title (at most 20 output tokens) once per branch, a summary (at most 50) after each turn, and a split (at most 120) when you press Split reply. After you switch branches, your next prompt carries a short note of about 60 tokens saying which thread you're back on.
bun on the PATH, then in /opt/homebrew/bin and /usr/local/bin. The pane works without it.127.0.0.1 only and refuses any request without the random token in its URL. It exits when the session does./tree-web opens the page with macOS open. On other systems, open the URL it prints.| Path | What it is |
|---|---|
hooks/register.tsx | Hooks, commands, the branch agent, the pane, the browser bridge |
hooks/tree.ts | The tree's data functions, with no Claude Code API calls |
browser/server.ts | The bun server: serves the page, streams the tree, relays clicks |
browser/index.html | The browser map, plain JS |
hooks/convo-tree.test.tsx | Tests: claude plugin test plugins/convo-tree |
hooks/register.tsx 512 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, Register } from 'claude-code'
3
4import type { Bridge, Tree, View } from '../types'
5import {
6 ROOT,
7 addBranches,
8 branchContext,
9 branchOf,
10 compact,
11 emptyTree,
12 findBranch,
13 finishTurn,
14 isReturning,
15 labelOf,
16 listText,
17 pathTo,
18 patchBranch,
19 ranges,
20 rows,
21 startTurn,
22 switchTo,
23 tokensOf,
24 turnsOf,
25} from './tree'
26import type { Row } from './tree'
27
28type Api = Parameters<Hook<'session.start'>>[0]
29
30const PANE = 'convo-tree'
31const TITLE = 'Conversation'
32const COLORS = ['claude', 'suggestion', 'success', 'warning', 'planMode', 'autoAccept', 'ide', 'merged'] as const
33
34const tree = atom({ plugin: 'convo-tree', key: 'tree' } as const, emptyTree())
35const view = atom({ plugin: 'convo-tree', key: 'view' } as const, { openId: null, openTurnId: null } as View)
36const bridge = atom({ plugin: 'convo-tree', key: 'bridge' } as const, null as Bridge | null)
37
38// Hands the tree to the browser view's server, when one is up
39const publish = async ($: Api) => {
40 const at = await read($, bridge)
41 if (at === null) return
42 await $.http
43 .fetch(`http://127.0.0.1:${at.port}/tree`, {
44 method: 'POST',
45 headers: { 'x-token': at.token, 'content-type': 'application/json' },
46 body: JSON.stringify(await read($, tree)),
47 })
48 .catch(() => undefined)
49}
50
51// Every tree write goes through here, so the browser view sees it too
52const change = async ($: Api, fn: (t: Tree) => Tree) => {
53 await update($, tree, fn)
54 await publish($)
55}
56
57// Creation order, as the browser page colors them
58const colorOf = (t: Tree, id: string) =>
59 id === ROOT ? 'text' : COLORS[(t.branches.findIndex(b => b.id === id) - 1) % COLORS.length]
60
61const cut = (text: string, room: number) => (text.length <= room ? text : `${text.slice(0, Math.max(0, room - 1))}…`)
62
63const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
64
65const haiku = async ($: Api, prompt: string, maxTokens: number) => {
66 const reply = await $.model.complete({ model: 'haiku', prompt, maxTokens, timeoutMs: 20000 })
67 return reply.isAnswered ? reply.text.trim() : undefined
68}
69
70const labelBranch = async ($: Api, id: string, prompt: string) => {
71 const label = await haiku(
72 $,
73 `Give a 2-5 word title for a conversation thread that starts with this message. Answer with the title alone, no quotes.\n\n<message>\n${prompt.slice(0, 1500)}\n</message>`,
74 20,
75 )
76 if (label !== undefined) await change($, t => patchBranch(t, id, { label: cut(oneLine(label), 40) }))
77}
78
79const finishedOf = (t: Tree, id: string) => turnsOf(t, id).filter(x => x.reply !== null)
80
81const summarizeBranch = async ($: Api, id: string) => {
82 const all = finishedOf(await read($, tree), id)
83 const turns = all.slice(-6)
84 if (turns.length === 0) return
85 const body = turns
86 .map(t => `User: ${t.prompt.slice(0, 600)}\nAssistant: ${(t.reply ?? '').slice(0, 600)}`)
87 .join('\n\n')
88 const summary = await haiku(
89 $,
90 `Summarize where this conversation thread stands in one sentence of at most 18 words: what is being explored and what was decided or is open. Answer with the sentence alone.\n\n<thread>\n${body}\n</thread>`,
91 50,
92 )
93 if (summary !== undefined) {
94 await change($, t => patchBranch(t, id, { summary: oneLine(summary), summarizedTurns: all.length }))
95 }
96}
97
98// Titles every untitled branch that has turns and refreshes every stale summary,
99// so work a reload dropped is picked up by the next turn or load
100const catchUp = async ($: Api) => {
101 for (const { id } of (await read($, tree)).branches) {
102 const t = await read($, tree)
103 const branch = branchOf(t, id)
104 const first = turnsOf(t, id).find(x => x.prompt !== '')
105 if (branch === undefined || first === undefined) continue
106 if (branch.label === null) await labelBranch($, id, first.prompt)
107 if (branch.summarizedTurns !== finishedOf(t, id).length) await summarizeBranch($, id)
108 }
109}
110
111let isBusy = false
112
113// Runs catchUp outside the hook's budget, one at a time; a call skipped meanwhile is caught up next turn
114const settle = ($: Api) =>
115 $.clock.after(0, () => {
116 if (isBusy) return
117 isBusy = true
118 void catchUp($)
119 .catch(() => undefined)
120 .finally(() => {
121 isBusy = false
122 })
123 })
124
125const newBranch = async ($: Api, label: string | null) => {
126 let id = ''
127 await change($, t => {
128 const made = addBranches(t, [label])
129 id = made.branches.at(-1)?.id ?? ROOT
130 return switchTo(made, id)
131 })
132 await update($, view, () => ({ openId: id, openTurnId: null }))
133 return id
134}
135
136const splitReply = async ($: Api) => {
137 const t = await read($, tree)
138 const last = finishedOf(t, t.activeId).at(-1)
139 if (last === undefined) {
140 $.ui.toast('Nothing to split: this branch has no reply yet.')
141 return
142 }
143 const answer = await haiku(
144 $,
145 `The assistant reply below may offer several distinct options, ideas or directions to explore. List each as a 2-5 word title, one per line, at most 6, no numbering. If it offers no distinct options, answer NONE.\n\n<reply>\n${last.reply ?? ''}\n</reply>`,
146 120,
147 )
148 const labels = (answer ?? '')
149 .split('\n')
150 .map(line => oneLine(line.replace(/^[-*\d.)\s]+/, '')))
151 .filter(line => line !== '' && line !== 'NONE')
152 .slice(0, 6)
153 .map(line => cut(line, 40))
154 if (labels.length === 0) {
155 $.ui.toast('No distinct options found in the last reply.')
156 return
157 }
158 await change($, cur => addBranches(cur, labels))
159 $.ui.toast(`Split into ${labels.length} branches.`)
160}
161
162const AGENT = 'convo-tree:branch'
163const BRANCH_LINE = /^\s*Branch:\s*(.+?)\s*$/m
164
165const AGENT_PROMPT = `You are a subagent that sees one branch of the user's conversation with Claude, not the whole conversation. Its turns (user prompts and final assistant replies; tool calls omitted) open your task message, under "# Conversation branch". Other branches of the conversation are deliberately left out: work from this branch alone, and say so when the task needs something it does not hold. Finish with a concise answer for the main conversation.`
166
167// The branch's turns, then the task: what a branch subagent is handed
168const withBranch = (t: Tree, id: string, task: string) => `${branchContext(t, id) ?? ''}\n\n---\n\nTask: ${task}`
169
170const recordAgent =
171 (agentId: string, branchId: string, task: string) =>
172 (cur: Tree): Tree => ({
173 ...cur,
174 agents: [...cur.agents, { agentId, branchId, task, isDone: false }],
175 })
176
177const startAgent = async ($: Api, ref: string, task: string) => {
178 const t = await read($, tree)
179 const branch = findBranch(t, ref)
180 if (branch === undefined) return `No branch "${ref}". Branches:\n${listText(t)}`
181 if (task.trim() === '') return `Give the subagent a task: /branch-agent ${branch.id} <task>`
182 const spawned = await $.agent.spawn({
183 subagentType: AGENT,
184 prompt: withBranch(t, branch.id, task),
185 description: `Branch ${branch.id}: ${labelOf(branch)}`,
186 })
187 if (spawned.deny !== undefined) return `Subagent refused: ${spawned.deny}`
188 if (spawned.agentId !== undefined) await change($, recordAgent(spawned.agentId, branch.id, task))
189 return `Started a subagent on branch ${branch.id} "${labelOf(branch)}" with ${turnsOf(t, branch.id).length} of its turns.`
190}
191
192const showBranch = async ($: Api, id: string) => {
193 await change($, cur => switchTo(cur, id))
194 await update($, view, () => ({ openId: id, openTurnId: null }))
195}
196
197// The browser view: browser/server.ts, run with bun, serves the page and prints its clicks
198const BUNS = ['bun', '/opt/homebrew/bin/bun', '/usr/local/bin/bun']
199
200const onAction = async ($: Api, action: Record<string, unknown>) => {
201 if (action.action === 'switch' && typeof action.id === 'string') await showBranch($, action.id)
202 if (action.action === 'new') await newBranch($, null)
203 if (action.action === 'split') await splitReply($)
204}
205
206// `READY {"port","token"}` once the server listens, `ACTION {...}` per click on the page
207const onLine = async ($: Api, line: string) => {
208 const [, kind, json = ''] = /^(READY|ACTION) (.+)$/.exec(line) ?? []
209 let body: Record<string, unknown>
210 try {
211 body = JSON.parse(json) ?? {}
212 } catch {
213 return
214 }
215 if (kind === 'READY' && typeof body.port === 'number' && typeof body.token === 'string') {
216 const at = { port: body.port, token: body.token }
217 await update($, bridge, () => at)
218 await publish($)
219 }
220 if (kind === 'ACTION') await onAction($, body)
221}
222
223// Runs one server until it exits; false when no bun could start it
224const runServer = async ($: Api) => {
225 const saved = await read($, bridge)
226 const script = `${$.plugin.root}/browser/server.ts`
227 for (const bun of BUNS) {
228 try {
229 const child = $.process.spawn({ argv: [bun, script, String(saved?.port ?? 0), saved?.token ?? ''] })
230 let rest = ''
231 for await (const piece of child) {
232 if (piece.stream !== 'stdout') continue
233 const lines = (rest + piece.text).split('\n')
234 rest = lines.pop() ?? ''
235 for (const line of lines) await onLine($, line)
236 }
237 return true
238 } catch {
239 // not at this path: try the next
240 }
241 }
242 return false
243}
244
245// For the module's life: a server that exits starts again on the same port, five times at most.
246// A reload kills the child and drops this loop's timer, and the next load starts its own.
247const serve = async ($: Api) => {
248 for (let restarts = 0; restarts < 5; restarts++) {
249 if (!(await runServer($))) break
250 await $.clock.sleep(1000)
251 }
252 await update($, bridge, () => null)
253}
254
255const browserUrl = (at: Bridge) => `http://127.0.0.1:${at.port}/?t=${at.token}`
256
257const openBrowser = async ($: Api) => {
258 const at = await read($, bridge)
259 if (at === null) return 'The browser view is not running: it needs bun (https://bun.sh).'
260 await $.process.run(['open', browserUrl(at)]).catch(() => undefined)
261 return `Opened ${browserUrl(at)}`
262}
263
264export const register: Register = on => {
265 on('session.start', async ($, e, next) => {
266 await Promise.all([
267 $.command.register({ name: 'tree', description: 'Open the conversation tree pane' }),
268 $.command.register({ name: 'branches', description: 'List the conversation branches' }),
269 $.command.register({ name: 'tree-web', description: 'Open the conversation tree in your browser' }),
270 $.command.register({
271 name: 'branch',
272 description: 'Start a new conversation branch off the active one',
273 argumentHint: '[label]',
274 }),
275 $.command.register({
276 name: 'branch-agent',
277 description: 'Run a subagent that sees only one branch of the conversation',
278 argumentHint: '<branch> <task>',
279 }),
280 $.agent.register({
281 name: 'branch',
282 description:
283 'Runs a task with only one branch of this conversation as context, the branches the user mapped in the convo-tree pane (A, B, … or "root"). Use when the user asks for an agent that sees just one branch. Start the prompt with a line "Branch: <id or label>", then the task; the branch\'s turns are added for you, so do not restate them.',
284 prompt: AGENT_PROMPT,
285 }),
286 ])
287 void $.ui.open({ id: PANE, title: TITLE })
288 void serve($).catch(() => undefined) // an unload cancels its sleep
289 settle($)
290
291 return next(e)
292 })
293
294 on('command.run', { command: 'tree' }, async $ => {
295 await $.ui.open({ id: PANE, title: TITLE, focus: true })
296 return { text: 'Conversation tree opened.' }
297 })
298
299 on('command.run', { command: 'branches' }, async $ => ({ text: listText(await read($, tree)) }))
300
301 on('command.run', { command: 'tree-web' }, async $ => ({ text: await openBrowser($) }))
302
303 on('command.run', { command: 'branch' }, async ($, e) => {
304 const label = oneLine(e.args)
305 const id = await newBranch($, label === '' ? null : cut(label, 40))
306 return { text: `On new branch ${id}. Your next prompt starts it.` }
307 }).catch(() => ({ text: 'convo-tree: could not start a branch.' }))
308
309 on('command.run', { command: 'branch-agent' }, async ($, e) => {
310 const [ref = '', ...rest] = e.args.trim().split(/\s+/)
311 return { text: await startAgent($, ref, rest.join(' ')) }
312 })
313
314 // Claude spawns convo-tree:branch with "Branch: A"; the branch's turns are spliced in here
315 on('agent.spawn', async ($, e, next) => {
316 if (e.subagentType !== AGENT) return next(e)
317 const t = await read($, tree)
318 const ref = BRANCH_LINE.exec(e.prompt)?.[1]
319 const branch = ref === undefined ? undefined : findBranch(t, ref)
320 if (branch === undefined) {
321 return {
322 deny: `Start the prompt with a line "Branch: <id or label>" naming one of these branches:\n${listText(t)}`,
323 }
324 }
325 const task = e.prompt.replace(BRANCH_LINE, '').trim()
326 const spawned = await next({ ...e, prompt: withBranch(t, branch.id, task) })
327 if (spawned.agentId !== undefined) await change($, recordAgent(spawned.agentId, branch.id, task))
328 return spawned
329 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'convo-tree: could not attach the branch.' }))
330
331 // Back on a branch after talking elsewhere: tell the model where the person is now
332 on('prompt.submit', async ($, e, next) => {
333 const t = await read($, tree)
334 const branch = branchOf(t, t.activeId)
335 if (branch === undefined || !isReturning(t)) return next(e)
336 const note = `[convo-tree] The user switched the conversation back to branch ${branch.id} "${labelOf(branch)}" (${ranges(turnsOf(t, branch.id).map(x => x.n))}).${branch.summary === '' ? '' : ` Where it stood: ${branch.summary}`} Treat this message as continuing that thread; later turns on other branches are not the current topic.`
337 return next({ ...e, context: [...(e.context ?? []), note] })
338 }).catch(($, e, next) => next(e))
339
340 on('turn.start', async ($, e, next) => {
341 await change($, t => startTurn(t, e.turnId, e.text))
342 return next(e)
343 })
344
345 on('turn.complete', async ($, e, next) => {
346 const done = await next(e)
347
348 if (e.agentId !== undefined) {
349 const mine = (await read($, tree)).agents.find(a => a.agentId === e.agentId)
350 if (mine !== undefined) {
351 await change($, cur => ({
352 ...cur,
353 agents: cur.agents.map(a => (a.agentId === e.agentId ? { ...a, isDone: true } : a)),
354 }))
355 $.ui.toast(`Branch ${mine.branchId} subagent finished.`)
356 }
357 return done
358 }
359
360 const u = e.usage
361 const tokens = u === undefined ? 0 : u.input_tokens + u.cache_creation_input_tokens + u.output_tokens
362 await change($, t => finishTurn(t, e.turnId, e.answer, tokens))
363 settle($)
364
365 return done
366 })
367
368 // One bordered card per branch, indented by depth; opening a card shows its last turns and actions
369 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
370 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
371 const t = await read($, tree)
372 const v = await read($, view)
373 const width = Math.max(28, e.props.bodyColumns)
374 const path = pathTo(t, t.activeId)
375 const trail = path.map(b => (b.id === ROOT ? labelOf(b) : `${b.id} · ${labelOf(b)}`))
376
377 const toggleCard = (id: string) => () =>
378 update($, view, cur => ({ openId: cur.openId === id ? null : id, openTurnId: null }))
379 const toggleTurn = (id: string) => () =>
380 update($, view, cur => ({ ...cur, openTurnId: cur.openTurnId === id ? null : id }))
381
382 // Dotted guides beside a card: ancestors' lines carry on, its own meets the card's title row
383 const guides = (prefix: string, height: number) => {
384 const chunks = prefix.match(/.{3}/g) ?? []
385 const isLast = chunks.pop()?.startsWith('└') ?? true
386 const lead = chunks.map(c => (c.startsWith('│') ? '┊ ' : ' ')).join('')
387 const own = (i: number) => (i < 2 ? '┊ ' : i === 2 ? (isLast ? '╰┈┈' : '├┈┈') : isLast ? ' ' : '┊ ')
388 return (
389 <Box flexDirection="column">
390 {Array.from({ length: height }, (_, i) => (
391 <Text dimColor>{lead + own(i)}</Text>
392 ))}
393 </Box>
394 )
395 }
396
397 // Lines a card takes, to draw its guides as tall: exact when closed, an estimate when open
398 const heightOf = (id: string, room: number) => {
399 if (id !== v.openId) return 4
400 const turns = turnsOf(t, id)
401 const shown = turns.slice(-3)
402 const reply = shown.find(x => x.id === v.openTurnId)?.reply ?? ''
403 const replyLines = cut(reply, 1500)
404 .split('\n')
405 .reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / Math.max(8, room - 2))), 0)
406 const agents = Math.min(3, t.agents.filter(a => a.branchId === id).length)
407 return 4 + 1 + (turns.length > 0 ? 1 : 0) + shown.length + (reply === '' ? 0 : replyLines) + agents + 2
408 }
409
410 const card = ({ branch, depth, prefix }: Row) => {
411 const id = branch.id
412 const turns = turnsOf(t, id)
413 const isActive = id === t.activeId
414 const isOpen = id === v.openId
415 const room = width - depth * 3 - 4
416 const count = turns.length === 0 ? 'empty' : `${turns.length} turn${turns.length === 1 ? '' : 's'}`
417 const tag = id === ROOT ? '' : `${id} `
418 const summary =
419 branch.summary !== ''
420 ? branch.summary
421 : turns.length === 0
422 ? 'No turns yet.'
423 : oneLine(turns.at(-1)?.prompt ?? '')
424 const border = isActive ? { borderColor: colorOf(t, id) } : { borderDimColor: true }
425 return (
426 <Box key={`row-${id}`}>
427 {depth > 0 && guides(prefix, 1 + heightOf(id, room))}
428 <Box
429 key={`card-${id}`}
430 flexDirection="column"
431 flexGrow={1}
432 marginTop={depth > 0 ? 1 : 0}
433 borderStyle="round"
434 paddingX={1}
435 {...border}
436 >
437 <Box justifyContent="space-between">
438 <Button key={`branch-${id}`} plain onPress={toggleCard(id)}>
439 <Text color={colorOf(t, id)}>
440 {isActive ? '●' : turns.length === 0 ? '◌' : '○'} {tag}
441 </Text>
442 <Text bold={isActive}>{cut(labelOf(branch), Math.max(4, room - tag.length - count.length - 4))}</Text>
443 </Button>
444 <Text dimColor>{count}</Text>
445 </Box>
446 <Text dimColor>{cut(oneLine(summary), Math.max(8, room))}</Text>
447 {isOpen && (
448 <Box flexDirection="column" marginTop={1}>
449 {turns.length > 0 && (
450 <Text dimColor>
451 {ranges(turns.map(x => x.n))} · {compact(tokensOf(t, id))} tokens
452 </Text>
453 )}
454 {turns.slice(-3).map(turn => (
455 <Box key={`turn-${turn.id}`} flexDirection="column">
456 <Button
457 key={`open-${turn.id}`}
458 plain
459 dimColor={v.openTurnId !== turn.id}
460 label={`${v.openTurnId === turn.id ? '▾' : '▸'} T${turn.n} ${cut(oneLine(turn.prompt), room - 6 - String(turn.n).length)}`}
461 onPress={toggleTurn(turn.id)}
462 />
463 {v.openTurnId === turn.id && (
464 <Markdown key={`reply-${turn.id}`} text={cut(turn.reply ?? '_Still running…_', 1500)} />
465 )}
466 </Box>
467 ))}
468 {t.agents
469 .filter(a => a.branchId === id)
470 .slice(-3)
471 .map(a => (
472 <Text key={`agent-${a.agentId}`} dimColor>
473 ⧉ {cut(oneLine(a.task), room - 12)} {a.isDone ? '· done' : '· running'}
474 </Text>
475 ))}
476 <Box marginTop={1} flexWrap="wrap" columnGap={1}>
477 {isActive ? (
478 <Button key={`split-${id}`} label="⑂ Split reply" onPress={() => void splitReply($)} />
479 ) : (
480 <Button
481 key={`switch-${id}`}
482 variant="primary"
483 label="Switch here"
484 onPress={() => void showBranch($, id)}
485 />
486 )}
487 <Button
488 key={`agent-${id}`}
489 label="⧉ Agent"
490 onPress={() => void $.prompt.fill({ text: `/branch-agent ${id} `, mode: 'replace' })}
491 />
492 </Box>
493 </Box>
494 )}
495 </Box>
496 </Box>
497 )
498 }
499
500 return (
501 <Box flexDirection="column">
502 <Text dimColor>{cut(trail.join(' › '), width)}</Text>
503 {rows(t).map(card)}
504 <Box marginTop={1} flexWrap="wrap" columnGap={1}>
505 <Button key="new" label="+ Branch" onPress={() => void newBranch($, null)} />
506 <Button key="web" label="↗ Map" onPress={() => void openBrowser($)} />
507 </Box>
508 </Box>
509 )
510 })
511}
512hooks/tree.ts 171 lines1import type { Branch, Tree, Turn } from '../types'
2
3export const ROOT = 'root'
4const MAX_PROMPT = 2000
5const MAX_REPLY = 4000
6const MAX_CONTEXT = 60000
7
8export const emptyTree = (): Tree => ({
9 branches: [{ id: ROOT, parentId: null, label: null, summary: '', summarizedTurns: 0 }],
10 turns: [],
11 agents: [],
12 activeId: ROOT,
13})
14
15// 0 → A, 25 → Z, 26 → AA
16const keyOf = (n: number): string =>
17 (n >= 26 ? keyOf(Math.floor(n / 26) - 1) : '') + String.fromCharCode(65 + (n % 26))
18
19export const labelOf = (b: Branch) => b.label ?? (b.id === ROOT ? 'Root' : 'New branch')
20
21export const branchOf = (tree: Tree, id: string) => tree.branches.find(b => b.id === id)
22
23export const findBranch = (tree: Tree, ref: string) => {
24 const want = ref.trim().toLowerCase()
25 return (
26 tree.branches.find(b => b.id.toLowerCase() === want) ??
27 tree.branches.find(b => labelOf(b).toLowerCase() === want) ??
28 tree.branches.find(b => labelOf(b).toLowerCase().includes(want))
29 )
30}
31
32const childrenOf = (tree: Tree, id: string) => tree.branches.filter(b => b.parentId === id)
33
34export const turnsOf = (tree: Tree, id: string) => tree.turns.filter(t => t.branchId === id)
35
36export const tokensOf = (tree: Tree, id: string) => turnsOf(tree, id).reduce((sum, t) => sum + t.tokens, 0)
37
38export const pathTo = (tree: Tree, id: string): Branch[] => {
39 const branch = branchOf(tree, id)
40 if (branch === undefined) return []
41 return branch.parentId === null ? [branch] : [...pathTo(tree, branch.parentId), branch]
42}
43
44// Children of the active branch; ids follow on from the last, as branches are never removed
45export const addBranches = (tree: Tree, labels: readonly (string | null)[]): Tree => ({
46 ...tree,
47 branches: [
48 ...tree.branches,
49 ...labels.map((label, i): Branch => ({
50 id: keyOf(tree.branches.length - 1 + i),
51 parentId: tree.activeId,
52 label,
53 summary: '',
54 summarizedTurns: 0,
55 })),
56 ],
57})
58
59export const switchTo = (tree: Tree, id: string): Tree =>
60 branchOf(tree, id) === undefined ? tree : { ...tree, activeId: id }
61
62// What the host wraps around a typed prompt (reminders, slash-command echoes), not the person's words
63// A tag with no closing tag runs to the end
64const HOST_TAGS =
65 /<(system-reminder|local-command-caveat|local-command-stdout|command-name|command-message|command-args)>[\s\S]*?(<\/\1>|$)/g
66
67const cleanPrompt = (text: string) => text.replace(HOST_TAGS, '').trim()
68
69// A turn belongs to the branch that is active when it starts
70export const startTurn = (tree: Tree, id: string, prompt: string): Tree => ({
71 ...tree,
72 turns: [
73 ...tree.turns,
74 {
75 id,
76 branchId: tree.activeId,
77 n: tree.turns.length + 1,
78 prompt: cleanPrompt(prompt).slice(0, MAX_PROMPT),
79 reply: null,
80 tokens: 0,
81 },
82 ],
83})
84
85export const finishTurn = (tree: Tree, id: string, reply: string, tokens: number): Tree => ({
86 ...tree,
87 turns: tree.turns.map(t => (t.id === id ? { ...t, reply: reply.slice(0, MAX_REPLY), tokens } : t)),
88})
89
90export const patchBranch = (tree: Tree, id: string, patch: Partial<Branch>): Tree => ({
91 ...tree,
92 branches: tree.branches.map(b => (b.id === id ? { ...b, ...patch } : b)),
93})
94
95// The person is on a branch with turns, and the last turn was on another one
96export const isReturning = (tree: Tree) => {
97 const last = tree.turns.at(-1)
98 return last !== undefined && last.branchId !== tree.activeId && turnsOf(tree, tree.activeId).length > 0
99}
100
101// "T2–3, T6" from turn numbers
102export const ranges = (ns: readonly number[]): string => {
103 const runs: [number, number][] = []
104 for (const n of ns) {
105 const last = runs.at(-1)
106 if (last !== undefined && n === last[1] + 1) last[1] = n
107 else runs.push([n, n])
108 }
109 return runs.map(([a, b]) => (a === b ? `T${a}` : `T${a}–${b}`)).join(', ')
110}
111
112export const compact = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : `${n}`)
113
114export type Row = { branch: Branch; prefix: string; depth: number }
115
116// Depth-first rows with box-drawing prefixes
117export const rows = (tree: Tree): Row[] => {
118 const walk = (id: string, lead: string, isLast: boolean, depth: number): Row[] => {
119 const branch = branchOf(tree, id)
120 if (branch === undefined) return []
121 const prefix = depth === 0 ? '' : lead + (isLast ? '└─ ' : '├─ ')
122 const childLead = depth === 0 ? '' : lead + (isLast ? ' ' : '│ ')
123 const kids = childrenOf(tree, id)
124 return [
125 { branch, prefix, depth },
126 ...kids.flatMap((kid, i) => walk(kid.id, childLead, i === kids.length - 1, depth + 1)),
127 ]
128 }
129 return walk(ROOT, '', true, 0)
130}
131
132const nameOf = (b: Branch) => (b.id === ROOT ? `root "${labelOf(b)}"` : `${b.id} "${labelOf(b)}"`)
133
134const turnText = (t: Turn) =>
135 `### T${t.n} · user\n${t.prompt}\n\n### T${t.n} · assistant\n${t.reply ?? '(still running)'}`
136
137// Root → branch: each ancestor's turns from before the branch began, then the branch's own
138export const branchContext = (tree: Tree, id: string): string | undefined => {
139 const path = pathTo(tree, id)
140 const branch = path.at(-1)
141 if (branch === undefined) return undefined
142 const own = turnsOf(tree, id)
143 const start = own[0]?.n ?? Infinity
144 const sections = path.slice(0, -1).flatMap(a => {
145 const lead = turnsOf(tree, a.id).filter(t => t.n < start)
146 return lead.length === 0 ? [] : [`## Lead-in: ${nameOf(a)}\n\n${lead.map(turnText).join('\n\n')}`]
147 })
148 sections.push(
149 `## Branch ${nameOf(branch)}\n\n${own.length === 0 ? '(no turns yet)' : own.map(turnText).join('\n\n')}`,
150 )
151 const head = [
152 `# Conversation branch ${nameOf(branch)}`,
153 `Path: ${path.map(nameOf).join(' › ')}`,
154 branch.summary === '' ? '' : `Summary: ${branch.summary}`,
155 'Only the user prompts and final assistant replies are kept; tool calls and outputs are omitted.',
156 ].filter(Boolean)
157 const text = [...head, ...sections].join('\n\n')
158 return text.length > MAX_CONTEXT ? `…(earlier text cut)\n${text.slice(-MAX_CONTEXT)}` : text
159}
160
161export const listText = (tree: Tree): string =>
162 rows(tree)
163 .map(({ branch, prefix }) => {
164 const turns = turnsOf(tree, branch.id).map(t => t.n)
165 const mark = branch.id === tree.activeId ? ' (active)' : ''
166 const where = turns.length === 0 ? 'no turns' : ranges(turns)
167 const summary = branch.summary === '' ? '' : ` — ${branch.summary}`
168 return `${prefix}${nameOf(branch)}${mark}: ${where}${summary}`
169 })
170 .join('\n')
171types/index.d.ts 45 lines1// A prompt and its reply, on the branch that was active when it was sent
2export type Turn = {
3 id: string
4 branchId: string
5 n: number
6 prompt: string
7 reply: string | null // null while the turn runs
8 tokens: number
9}
10
11export type Branch = {
12 id: string
13 parentId: string | null
14 label: string | null // null until Haiku titles it
15 summary: string
16 summarizedTurns: number // how many finished turns the summary covers
17}
18
19export type BranchAgent = {
20 agentId: string
21 branchId: string
22 task: string
23 isDone: boolean
24}
25
26// Branch ids, turn counts and token totals are all derived from these
27export type Tree = {
28 branches: Branch[]
29 turns: Turn[]
30 agents: BranchAgent[]
31 activeId: string
32}
33
34// Which card in the pane is opened up, and which of its turns shows its reply
35export type View = { openId: string | null; openTurnId: string | null }
36
37// Where the browser view's server listens; kept so a reload asks for the same port
38export type Bridge = { port: number; token: string }
39
40declare module 'claude-code' {
41 interface PluginState {
42 'convo-tree': { tree: Tree; view: View; bridge: Bridge | null }
43 }
44}
45