SLOPSHOPPER

convo-tree

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…

newpanecommandtoastpromptmodel
v0.1.0no licenseupdated 2026-10-12bracyw/claude-mods/plugins/convo-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · convo-tree
│ ┃ Conversation ✕ › fix the failing auth test and add an audit log call │ ┃ Root › A · New branch │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ branch-root empty │ ⎿ Read 6 lines │ ┃ │ No turns yet. │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ┊ ⏺ Bash(bun test) │ ┃ ┊ ╭──────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ╰┈┈│ branch-A empt │ ┃ │ No turns yet. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ [ ⑂ Split reply ] [ ⧉ Agent ] │ ┃ ╰──────────────────────────────────────── › /tree │ ┃ ⎿ convo-tree: Conversation tree opened. │ ┃ [ + Branch ] [ ↗ Map ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Conversation
Root › A · New branch ╭──────────────────────────────────────────────────────────╮ │ branch-root empty │ │ No turns yet. │ ╰──────────────────────────────────────────────────────────╯ ┊ ┊ ╭──────────────────────────────────────────────────────── ╰┈┈│ branch-A empt │ No turns yet. │ │ │ [ ⑂ Split reply ] [ ⧉ Agent ] ╰──────────────────────────────────────────────────────── [ + Branch ] [ ↗ Map ]
README

convo-tree

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.

What you get

  • Pane (/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.
  • Browser map (/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.
  • Branch subagents: ask Claude for "an agent that only sees branch A". It spawns the 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.
  • Titles and summaries: Haiku titles a branch from its first prompt and keeps a one-sentence summary up to date after each turn.

Commands

CommandDoes
/treeOpen the pane
/tree-webOpen the browser map
/branch [label]Start a branch off the active one; your next prompt goes there
/branchesList branches as text
/branch-agent <branch> <task>Run a subagent on one branch

Cost

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.

Requirements and limits

  • The browser map runs a small local server with bun. It looks for bun on the PATH, then in /opt/homebrew/bin and /usr/local/bin. The pane works without it.
  • The server listens on 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.
  • The tree lives in the session. Each prompt and final reply is kept (cut at 2,000 and 4,000 characters); tool calls aren't.
  • Branches can't be renamed, deleted or merged yet.

Layout

PathWhat it is
hooks/register.tsxHooks, commands, the branch agent, the pane, the browser bridge
hooks/tree.tsThe tree's data functions, with no Claude Code API calls
browser/server.tsThe bun server: serves the page, streams the tree, relays clicks
browser/index.htmlThe browser map, plain JS
hooks/convo-tree.test.tsxTests: claude plugin test plugins/convo-tree
Source 3 files
hooks/register.tsx 512 lines
1import { 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}
512
hooks/tree.ts 171 lines
1import 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')
171
types/index.d.ts 45 lines
1// 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