SLOPSHOPPER

breadcrumbs

An always-on pane with what this session is about: the task, your last prompt, what Claude is doing and last said, and the explanations it wrote, saved as…

newpanerowsguardcommandtoast
★ 1v0.9.0no licenseupdated 2026-10-08crockalet/claude-mods/plugins/breadcrumbs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · breadcrumbs
│ ┃ tests ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ No manual tests yet. ⏺ Read(src/auth.ts) │ ┃ Ask Claude for a manual test planTests and ⎿ Read 6 lines │ ┃ it lists the tests here. ⏺ Update(src/auth.ts) │ ┃ ⎿ 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 │ │ › /whereami │ ⎿ breadcrumbs: Breadcrumbs pane hidden. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tests
No manual tests yet. Ask Claude for a manual test planTests and it lists the tests here.
Pane · breadcrumbs
app · feat/auth-refresh ● Waiting for the first prompt… You asked · now fix the failing auth test and add an audit log call Claude · now ⎿ Done. I made refresh reject expired claims, added an audit call, and created src/audit.ts. One test still fails (revokes on logout), which looks unrelated to this change. Done more ⎇ app · M src/auth.ts · 1 uncommitted · no upstream 3 files edited
README

breadcrumbs

A Claude Code mod that keeps a pane beside the session with what it's about, so you can switch worktrees and pick up where you left off.

  • Task: the session's goal, kept current by a cheap side pass after each turn
  • Needs you: questions and decisions Claude left for you, and any question it's waiting on now
  • You asked / Claude: your last prompt, what Claude is running and the start of its last reply
  • Notes: explanations and summaries you asked for, saved as Markdown and previewable in the pane (Pin keeps one for good)
  • Decided / Tried: choices Claude made on its own, and approaches it tried (✗ marks a dead end)

Mods need Claude Code 2.1.287 or later.

Install

/plugin marketplace add crockalet/claude-mods
/plugin install breadcrumbs@claude-mods

/whereami shows or hides the pane. It opens by itself at 144+ columns.

Manual tests

When you need to run tests by hand, Claude lists them in a second pane (/tests) with their steps. Start tester hands one test to a tester subagent that walks you through it, follows the logs while you go, and sends its final report to the main session, so the main agent stays the orchestrator.

  • Claude plans like a planner: a shared brief every tester trusts, tests grouped by physical setup, and the remaining tests revised (or marked blocked) after each report.
  • Each test names its trigger (the code that makes the expected signal happen), what it assumes, and which tests it needs first. A test waits on those and is blocked when one fails.
  • A tester that gets stuck asks Claude instead of giving up. Claude fixes what it can and replies, and the same tester carries on with its setup intact.
  • A tester hands you a run of steps at once and stops only at checkpoints. Its questions come with their own answer buttons; plain steps get Done / Can't. You can always type a reply, and the status line counts what's waiting.
  • Feedback typed on a test goes to the step it's waiting on, else its running tester, else Claude.
  • A bare Pass costs no turn; it rides along with your next prompt.

Files

Notes live outside your repos, one folder per session:

~/.agents/notes/<repo>/<worktree>/
  index.md                        one line per session
  <date>-<task>-<id>/
    context.md                    the pane as a file
    NN-<title>.md                 notes
    state.json                    restores the pane on resume
~/.agents/notes/<repo>/_pinned/   pinned notes

Session folders are archived after 30 days, or as soon as their worktree is gone, and deleted 60 days after that. /whereami clean shows what would go and asks first. Both periods, the side-pass model, the testers' model (Sonnet unless a test asks for another) and when the pane opens are settings in /plugin.

Developing

claude --plugin-dir plugins/breadcrumbs   # hot-reloads on save
claude plugin validate plugins/breadcrumbs
claude plugin test plugins/breadcrumbs
Source 7 files
hooks/register.tsx 1786 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Ask, Crumbs, Entry, ManualTest, Note, Repo, TestRun, TestStatus, TestsView, View, Wait, Where } from '../types'
5import { contextMarkdown, repoState } from './markdown'
6import { normalize } from './state'
7import { ICON, STATUSES, TESTER, TESTER_PROMPT, TESTS_GUIDANCE, TESTS_PANE, TOOL, WAIT_LIMIT_MS, isLive, listOf, openWait, settleNeeds, str, testOf, unmet } from './tests'
8import type { Saved } from './markdown'
9import { richLines, type Line } from './rich'
10import { ago, asks, basename, cdTarget, clip, day, doneOf, head, narrowTables, parseObject, parseStatus, slug, stamp, strings, toolLabel } from './text'
11
12const PANE = 'breadcrumbs'
13const PAD = 1
14// Below this many columns per cell a wrapped table reads worse than the same rows as a list.
15const MIN_CELL = 10
16
17// The terminal's own Markdown draws flat; there the pane lays markdown out itself in the theme's colors.
18const drawRich = (el: ReturnType<$['ui']['resolve']>, lines: Line[], dim = false) => {
19  const { Box, Text, Link } = el
20  return (
21    <Box flexDirection="column">
22      {lines.map(line => (
23        <Text wrap="truncate-end">
24          {line.length === 0
25            ? ' '
26            : line.map(s => {
27                const text = (
28                  <Text {...s.style} dimColor={dim || undefined}>
29                    {s.text}
30                  </Text>
31                )
32                return s.href ? <Link href={s.href}>{text}</Link> : text
33              })}
34        </Text>
35      ))}
36    </Box>
37  )
38}
39const SAVE_NOTE = 'mcp__breadcrumbs__save_note'
40const EDIT_NOTE = 'mcp__breadcrumbs__edit_note'
41
42const crumbs = atom({ plugin: 'breadcrumbs', key: 'crumbs' } as const, normalize({}))
43const where = atom({ plugin: 'breadcrumbs', key: 'where' } as const, null)
44const VIEW: View = {
45  openNote: null,
46  isShowingPrompts: false,
47  isShowingMore: false,
48  isShowingStatus: false,
49  openDone: null,
50  picked: {},
51  typed: {},
52  details: {},
53  expanded: null,
54}
55const view = atom({ plugin: 'breadcrumbs', key: 'view' } as const, VIEW)
56const pending = atom({ plugin: 'breadcrumbs', key: 'pending' } as const, null)
57const testRun = atom({ plugin: 'breadcrumbs', key: 'tests' } as const, { brief: '', tests: [], waits: [], unreported: [] })
58const testsView = atom({ plugin: 'breadcrumbs', key: 'testsView' } as const, { expanded: null, typed: {} })
59
60const NOTE_GUIDANCE = [
61  'The user keeps a "breadcrumbs" pane open beside this session.',
62  `When the user asks for an explanation, a summary, a walkthrough or a comparison, write it with the ${SAVE_NOTE} tool`,
63  '(a short title and the full markdown body) instead of only in your reply, then reply with one line saying it is saved in the breadcrumbs pane.',
64  `To change a note you saved, call ${EDIT_NOTE} with its id and only the text that changes; never save the same note again.`,
65  'Keep doing any task the same message asked for. Do not save notes for status updates or for answers of a sentence or two.',
66  'The pane reflows notes to its current width, which the person resizes: headings, paragraphs, lists and tables all rewrap (table cells wrap), so write for reading, not for a column count. Only code and diagram lines are cut where the pane ends, so keep those short.',
67].join(' ')
68
69type $ = EngineInterface
70
71// Session state outlives reloads, so it can predate fields added since; every access goes through normalize.
72const readCrumbs = async ($: $): Promise<Crumbs> => normalize(await read($, crumbs))
73
74const mutate = ($: $, fn: (c: Crumbs) => Crumbs) => update($, crumbs, c => fn(normalize(c)))
75
76const notesRoot = async ($: $): Promise<string> =>
77  `${(await $.env.get('HOME')) ?? '~'}/.agents/notes`
78
79const git = async ($: $, cwd: string, ...args: string[]): Promise<string | null> => {
80  try {
81    const run = await $.process.run(['git', ...args], { cwd, timeoutMs: 3000 })
82
83    return run.exitCode === 0 ? run.stdout : null
84  } catch {
85    return null
86  }
87}
88
89const locate = async ($: $): Promise<Where> => {
90  const cwd = await $.session.cwd()
91  const repo = await $.session.repo()
92  const worktree = (await git($, cwd, 'rev-parse', '--show-toplevel'))?.trim() || cwd
93
94  return {
95    repo: repo ? basename(repo.root) : basename(worktree),
96    branch: (await git($, cwd, 'branch', '--show-current'))?.trim() ?? '',
97    worktree,
98  }
99}
100
101const refreshRepos = async ($: $) => {
102  const c = await readCrumbs($)
103  const place = await read($, where)
104  const places = new Set([...c.touched, ...c.edited.map(f => f.slice(0, f.lastIndexOf('/')))])
105  if (place) places.add(place.worktree)
106  const roots = new Set<string>()
107  for (const dir of places) {
108    const root = (await git($, dir, 'rev-parse', '--show-toplevel'))?.trim()
109    if (root) roots.add(root)
110  }
111  const repos: Repo[] = []
112  for (const root of roots) {
113    const status = await git($, root, 'status', '--porcelain=v1', '-b')
114    if (status !== null) repos.push({ root, ...parseStatus(status) })
115  }
116  await mutate($, old => ({ ...old, repos }))
117}
118
119const worktreeDir = (root: string, where: Where) =>
120  `${root}/${where.repo}/${basename(where.worktree)}`
121
122const readSaved = async ($: $, dir: string): Promise<Saved | null> => {
123  try {
124    return JSON.parse(await $.fs.read(`${dir}/state.json`)) as Saved
125  } catch {
126    return null
127  }
128}
129
130const writeIndex = async ($: $, wtDir: string) => {
131  let entries
132  try {
133    entries = await $.fs.list(wtDir)
134  } catch {
135    return
136  }
137  const rows: { at: number; line: string }[] = []
138  for (const entry of entries) {
139    if (entry.kind !== 'dir') continue
140    const saved = await readSaved($, `${wtDir}/${entry.name}`)
141    if (!saved) continue
142    const task = clip(saved.tasks[0]?.title ?? 'No task yet', 70)
143    const notes = saved.notes.length === 1 ? '1 note' : `${saved.notes.length} notes`
144    const waiting = asks(saved.needsYou).length > 0 ? ' · needs you' : ''
145    rows.push({
146      at: saved.updatedAt,
147      line: `- ${task}${waiting} · ${notes} · ${stamp(saved.updatedAt)} → [${entry.name}/](${entry.name}/context.md)`,
148    })
149  }
150  if (rows.length === 0) return
151  rows.sort((a, b) => b.at - a.at)
152  await $.fs.write(`${wtDir}/index.md`, `# ${basename(wtDir)}\n\n${rows.map(r => r.line).join('\n')}\n`)
153}
154
155const persist = async ($: $, dir: string, saved: Saved, where: Where, status: string) => {
156  await $.fs.write(`${dir}/state.json`, JSON.stringify(saved, null, 2))
157  await $.fs.write(`${dir}/context.md`, contextMarkdown(saved, where, status))
158  await writeIndex($, dir.slice(0, dir.lastIndexOf('/')))
159}
160
161type CleanPlan = {
162  archive: { from: string; to: string }[]
163  remove: string[]
164  touched: string[]
165}
166
167type CleanOptions = { now: number; retentionDays: number; archiveDays: number; isArchiving: boolean; keep: string }
168
169const DAY = 86_400_000
170
171const dirs = async ($: $, path: string) => {
172  try {
173    return (await $.fs.list(path)).filter(e => e.kind === 'dir' && !e.name.startsWith('.'))
174  } catch {
175    return []
176  }
177}
178
179const planClean = async ($: $, root: string, o: CleanOptions): Promise<CleanPlan> => {
180  const plan: CleanPlan = { archive: [], remove: [], touched: [] }
181  for (const repo of await dirs($, root)) {
182    if (repo.name.startsWith('_')) continue
183    for (const wt of await dirs($, `${root}/${repo.name}`)) {
184      if (wt.name.startsWith('_')) continue
185      const wtDir = `${root}/${repo.name}/${wt.name}`
186      for (const session of await dirs($, wtDir)) {
187        const dir = `${wtDir}/${session.name}`
188        if (dir === o.keep) continue
189        const saved = await readSaved($, dir)
190        const updatedAt = saved?.updatedAt ?? (await $.fs.stat(dir)).mtimeMs
191        const isGone = saved !== null && !(await $.fs.exists(saved.worktree))
192        const isOld = o.now - updatedAt > o.retentionDays * DAY
193        if (!isGone && !isOld) continue
194        if (o.isArchiving) {
195          plan.archive.push({ from: dir, to: `${root}/_archive/${repo.name}/${wt.name}--${session.name}` })
196        } else {
197          plan.remove.push(dir)
198        }
199        if (!plan.touched.includes(wtDir)) plan.touched.push(wtDir)
200      }
201    }
202  }
203  for (const repo of await dirs($, `${root}/_archive`)) {
204    for (const entry of await dirs($, `${root}/_archive/${repo.name}`)) {
205      const dir = `${root}/_archive/${repo.name}/${entry.name}`
206      let archivedAt: number
207      try {
208        archivedAt = Number(await $.fs.read(`${dir}/.archived-at`))
209      } catch {
210        archivedAt = (await $.fs.stat(dir)).mtimeMs
211      }
212      if (o.now - archivedAt > o.archiveDays * DAY) plan.remove.push(dir)
213    }
214  }
215
216  return plan
217}
218
219const applyClean = async ($: $, root: string, plan: CleanPlan, now: number) => {
220  // Every path came from listing `root`, but a bad join must never reach rm.
221  const isInside = (p: string) => p.startsWith(`${root}/`) && !p.includes('/../')
222  for (const { from, to } of plan.archive) {
223    if (!isInside(from) || !isInside(to)) continue
224    await $.process.run(['mkdir', '-p', to.slice(0, to.lastIndexOf('/'))])
225    const moved = await $.process.run(['mv', from, to])
226    if (moved.exitCode === 0) await $.fs.write(`${to}/.archived-at`, String(now))
227  }
228  for (const dir of plan.remove) {
229    if (isInside(dir)) await $.process.run(['rm', '-rf', dir])
230  }
231  for (const wtDir of plan.touched) {
232    if ((await dirs($, wtDir)).length === 0) {
233      await $.process.run(['rm', '-rf', wtDir])
234    } else {
235      await writeIndex($, wtDir)
236    }
237  }
238}
239
240const cfg = { model: 'haiku', testerModel: 'sonnet', retentionDays: 30, archiveDays: 60, isArchiving: true }
241
242let sessionId = ''
243let dir: string | null = null
244// Identifies the conversation across a resume, which starts it under a new session id but keeps its transcript.
245let transcript: string | null = null
246let turnTools: string[] = []
247let hasSavedNote = false
248
249const ensureDir = async ($: $, title: string): Promise<string> => {
250  if (dir) return dir
251  const place = (await read($, where)) ?? (await locate($))
252  const now = await $.clock.now()
253  dir = `${worktreeDir(await notesRoot($), place)}/${day(now)}-${slug(title, 32)}-${sessionId.slice(0, 6)}`
254  await $.store.set(`dir:${sessionId}`, dir)
255
256  return dir
257}
258
259const save = async ($: $, status: string) => {
260  const state = await readCrumbs($)
261  const place = await read($, where)
262  if (!place || (state.tasks.length === 0 && state.notes.length === 0)) return
263  const target = await ensureDir($, state.tasks[0]?.title ?? 'session')
264  const saved: Saved = { ...state, session: sessionId, worktree: place.worktree, updatedAt: await $.clock.now(), ...(transcript ? { transcript } : {}) }
265  await persist($, target, saved, place, status)
266}
267
268const clean = async ($: $) => {
269  const root = await notesRoot($)
270  const now = await $.clock.now()
271  const plan = await planClean($, root, { ...cfg, now, keep: dir ?? '' })
272
273  return { root, now, plan }
274}
275
276// `transcript` set means a one-off catch-up over a session that predates the mod.
277type Turn = { answer: string; hasSavedNote: boolean; tools: string[]; transcript?: string }
278
279const sidePass = async ($: $, { answer, hasSavedNote, tools, transcript }: Turn) => {
280  const cap = transcript ? 6 : 3
281  const state = await readCrumbs($)
282  const prompt = state.prompts.at(-1)?.text ?? ''
283  const request = [
284    'You keep a running log of a coding session for a developer who switches between many sessions.',
285    transcript
286      ? 'Read this transcript of the session so far and answer with one JSON object and nothing else. It is a catch-up: read "this turn" below as "the session so far", allow up to 6 decisions, attempts and done items, and take needsYou only from the last assistant message.'
287      : 'Read the latest exchange and answer with one JSON object and nothing else:',
288    '{"task": string, "isNewTask": boolean, "decisions": string[], "attempts": [{"text": string, "isOk": boolean}], "needsYou": [{"question": string, "context": string, "options": [{"label": string, "description": string}]}], "done": {"title": string, "items": string[]} | null, "note": {"title": string, "markdown": string} | null}',
289    '- task: the overall goal of the whole session in under 60 characters, imperative ("Fix websocket reconnect loop"), judged from all the recent prompts, not just this turn\'s step. Keep the current task\'s wording unless it is wrong or too narrow: when the prompts show the current task is one step of a bigger goal, widen it to that goal ("Verify the restart" becomes "Build the breadcrumbs mod").',
290    '- isNewTask: true only when the user clearly moved on to a different goal, not a follow-up.',
291    '- decisions: design or approach choices the assistant made on its own this turn where another option was reasonable and the user did not specify it ("Capped backoff at 30s instead of 60s"). Not actions taken, checks run or instructions given to the user. At most 3, under 90 characters each. Usually empty.',
292    '- done: what this turn changed outside the conversation, from the assistant reply and tools used. Not reads, checks, explanations or plans; null when nothing changed. title: the turn\'s outcome in under 40 characters, past tense ("Added the secrets mod", "Pushed main"). items: the concrete results behind it, naming what changed ("Pushed main to crockalet/breadcrumbs", "Fixed reconnect race in socket.ts"), at most 3, under 90 characters each; empty when the title says it all.',
293    '- needsYou: what the assistant reply itself asks the user to answer or decide (never questions inferred from the earlier prompts or the task): explicit questions, approvals, choices between options. Each question is short, under 80 characters ("Approve the PR description?"). context: one or two sentences from the reply that someone needs to answer well (what is at stake, what each choice leads to). options: 2 to 4 when the question has discrete choices, each a short label ("Yes, I\'ll run it") and a one-line description of what that choice means, else []. Empty list when the reply asks nothing.',
294    '- attempts: approaches tried this turn, isOk false when one failed or was abandoned (at most 3). Empty when none.',
295    '- note: only when the user asked for an explanation or summary, the reply contains it at a paragraph or more (not a one-line answer), and it was not saved already. markdown is that explanation, kept close to the reply\'s own words. Otherwise null.',
296    '',
297    `Current task: ${state.tasks[0]?.title ?? '(none yet)'}`,
298    `Earlier prompts, oldest first (context for the task only; already handled): ${state.prompts.slice(0, -1).map(p => JSON.stringify(clip(p.text, 200))).join(' | ') || 'none'}`,
299    `Already saved a note this turn: ${hasSavedNote}`,
300    `Notes saved earlier (reuse a title exactly to update that note instead of adding one): ${state.notes.map(n => JSON.stringify(n.title)).join(', ') || 'none'}`,
301    `Tools used this turn: ${tools.slice(0, 30).join('; ') || 'none'}`,
302    '',
303    ...(transcript
304      ? ['<transcript>', transcript, '</transcript>']
305      : ['<user_prompt>', clip(prompt, 4000), '</user_prompt>', '<assistant_reply>', answer.slice(0, 12000), '</assistant_reply>']),
306  ].join('\n')
307
308  const reply = await $.model.complete({ model: cfg.model, prompt: request, maxTokens: 4000, effort: 'low', timeoutMs: 60_000 })
309  if (!reply.isAnswered) {
310    $.ui.log(`side pass skipped (${reply.reason})`)
311    return
312  }
313  const out = parseObject(reply.text)
314  if (!out) {
315    $.ui.log(`side pass: unparseable reply (${clip(reply.text, 120)})`, { to: 'debug' })
316    return
317  }
318
319  const now = await $.clock.now()
320  const task = typeof out.task === 'string' ? clip(out.task, 70) : ''
321  const attempts = Array.isArray(out.attempts)
322    ? out.attempts
323        .filter((a): a is { text: string; isOk?: unknown } => typeof a?.text === 'string')
324        .slice(0, cap)
325        .map(a => ({ text: clip(a.text, 100), isOk: a.isOk !== false }))
326    : []
327  await mutate($, c => {
328    let tasks = c.tasks
329    if (task && (tasks.length === 0 || out.isNewTask === true)) {
330      tasks = [{ title: task, at: now }, ...tasks].slice(0, 6)
331    } else if (task && tasks[0]) {
332      tasks = [{ ...tasks[0], title: task }, ...tasks.slice(1)]
333    }
334
335    const done = Array.isArray(out.done)
336      ? doneOf(null, strings(out.done, cap, 90), now)
337      : doneOf((out.done as { title?: unknown } | null)?.title, strings((out.done as { items?: unknown } | null)?.items, cap, 90), now)
338
339    return {
340      ...c,
341      tasks,
342      decided: [...c.decided, ...strings(out.decisions, cap, 90)].slice(-20),
343      tried: [...c.tried, ...attempts].slice(-20),
344      // Haiku sometimes turns the user's own requests into questions; a reply that asks nothing has none.
345      needsYou: answer.includes('?') ? asks(out.needsYou) : [],
346      done: done ? [...c.done, done].slice(-30) : c.done,
347    }
348  })
349
350  const note = out.note as { title?: unknown; markdown?: unknown } | null
351  if (!hasSavedNote && note && typeof note.title === 'string' && typeof note.markdown === 'string' && note.markdown.trim()) {
352    await saveNote($, note.title, note.markdown)
353  }
354}
355
356// Pending lives in session state, so a reload (which drops timers) can pick the pass back up.
357const runPending = async ($: $) => {
358  try {
359    const raw = await read($, pending)
360    if (raw === null) return
361    await update($, pending, () => null)
362    await sidePass($, JSON.parse(raw) as Turn)
363    await save($, 'idle')
364  } catch (error) {
365    // A timer callback's rejection is otherwise swallowed without a trace.
366    $.ui.log(`side pass failed: ${error instanceof Error ? error.message : String(error)}`)
367  }
368}
369
370const noteFile = (title: string, task: string, markdown: string) =>
371  `---\ntitle: ${title.replace(/\n/g, ' ')}\ntask: ${task}\nsession: ${sessionId}\n---\n\n# ${title}\n\n${markdown.trim()}\n`
372
373const FRONTMATTER = /^---\n[\s\S]*?\n---\n+/
374
375// A rewritten note moves to the top of the list but keeps its file and number.
376const touchNote = async ($: $, id: string) => {
377  const now = await $.clock.now()
378  const pinned = (await readCrumbs($)).notes.find(n => n.id === id && n.isPinned)
379  if (pinned) await copyPinned($, pinned)
380  await mutate($, c => {
381    const note = c.notes.find(n => n.id === id)
382    return note ? { ...c, notes: [...c.notes.filter(n => n.id !== id), { ...note, at: now }] } : c
383  })
384  await save($, 'working')
385}
386
387const saveNote = async ($: $, title: string, markdown: string): Promise<Note> => {
388  const state = await readCrumbs($)
389  const task = state.tasks[0]?.title ?? ''
390  // Saving under a title already used replaces that note rather than listing a second copy.
391  const same = state.notes.find(n => slug(n.title) === slug(title))
392  if (same) {
393    await $.fs.write(same.file, noteFile(title, task, markdown))
394    await touchNote($, same.id)
395    return same
396  }
397  const target = await ensureDir($, task || title)
398  const now = await $.clock.now()
399  const number = String(state.notes.length + 1).padStart(2, '0')
400  const file = `${target}/${number}-${slug(title)}.md`
401  await $.fs.write(file, noteFile(title, task, markdown))
402  const note: Note = { id: `${now}-${number}`, title: clip(title, 80), file, at: now, isPinned: false }
403  await mutate($, c => ({ ...c, notes: [...c.notes, note] }))
404  await save($, 'working')
405
406  return note
407}
408
409type NoteEdit = { old: string; new: string }
410
411const editNote = async ($: $, ref: string, edits: NoteEdit[], markdown: string): Promise<{ note: Note } | { error: string }> => {
412  const { notes } = await readCrumbs($)
413  const note = notes.find(n => n.id === ref) ?? [...notes].reverse().find(n => slug(n.title) === slug(ref))
414  if (!note) return { error: `No note "${ref}". Notes: ${notes.map(n => `${n.id} (${n.title})`).join(', ') || 'none'}.` }
415  let text: string
416  try {
417    text = await $.fs.read(note.file)
418  } catch {
419    return { error: `The file for "${note.title}" is gone; save it again with save_note.` }
420  }
421  const header = FRONTMATTER.exec(text)?.[0] ?? ''
422  let body = markdown.trim() ? `# ${note.title}\n\n${markdown.trim()}\n` : text.slice(header.length)
423  for (const edit of edits) {
424    const count = body.split(edit.old).length - 1
425    if (count !== 1) {
426      return { error: `"${clip(edit.old, 60)}" appears ${count} times in "${note.title}"; ${count === 0 ? 'copy it exactly from the note' : 'include more of the text around it'}. Nothing was changed.` }
427    }
428    body = body.replace(edit.old, () => edit.new)
429  }
430  await $.fs.write(note.file, `${header}${body}`)
431  await touchNote($, note.id)
432
433  return { note }
434}
435
436const copyPinned = async ($: $, note: Note): Promise<boolean> => {
437  const place = await read($, where)
438  if (!place) return false
439  await $.fs.write(`${await notesRoot($)}/${place.repo}/_pinned/${basename(note.file)}`, await $.fs.read(note.file))
440  return true
441}
442
443const pin = async ($: $, note: Note) => {
444  if (!(await copyPinned($, note))) return
445  await mutate($, c => ({ ...c, notes: c.notes.map(n => (n.id === note.id ? { ...n, isPinned: true } : n)) }))
446  await save($, 'working')
447  $.ui.toast(`Pinned "${note.title}"`)
448}
449
450const track = async ($: $, e: Record<string, unknown>) => {
451  const file = typeof e.file_path === 'string' ? e.file_path : typeof e.notebook_path === 'string' ? e.notebook_path : ''
452  if ((e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') && file) {
453    await mutate($, c => ({ ...c, edited: [...c.edited.filter(f => f !== file), file].slice(-50) }))
454  }
455  if (e.tool === 'Bash' && typeof e.command === 'string') {
456    const dir = cdTarget(e.command, (await $.env.get('HOME')) ?? '')
457    if (dir) await mutate($, c => ({ ...c, touched: [...c.touched.filter(d => d !== dir), dir].slice(-20) }))
458  }
459}
460
461const isTyped = (m: SessionMessage) =>
462  m.role === 'user' && m.text.trim() !== '' && !m.toolResults?.length && !m.text.trimStart().startsWith('<')
463
464// The newest session folder in this worktree that holds the same conversation, by its transcript.
465const findResumed = async ($: $, wtDir: string, path: string): Promise<string | null> => {
466  let best: { dir: string; at: number } | null = null
467  for (const entry of await dirs($, wtDir)) {
468    const saved = await readSaved($, `${wtDir}/${entry.name}`)
469    if (saved?.transcript === path && (!best || saved.updatedAt > best.at)) best = { dir: `${wtDir}/${entry.name}`, at: saved.updatedAt }
470  }
471
472  return best?.dir ?? null
473}
474
475const restoreFrom = async ($: $, from: string) => {
476  const current = await readCrumbs($)
477  const saved = await readSaved($, from)
478  if (saved && current.tasks.length === 0 && current.notes.length === 0) {
479    const { session: _s, worktree: _w, updatedAt: _u, transcript: _t, ...restored } = saved
480    await mutate($, () => ({ ...normalize(restored), activity: null, asking: null }))
481  }
482  // Restored on its own: on a reload the breadcrumbs state can survive while the test list doesn't.
483  const savedTests = await readTests($, from)
484  if (savedTests && (await read($, testRun)).tests.length === 0) {
485    // A tester or a waiting step doesn't outlive its session.
486    await update($, testRun, () => ({
487      brief: savedTests.brief ?? '',
488      tests: savedTests.tests.map(t => ({ ...t, status: t.status === 'running' ? 'todo' : t.status, agentId: null, asking: null })),
489      waits: [],
490      unreported: savedTests.unreported,
491    }))
492  }
493}
494
495// A resumed conversation starts under a new session id, so its folder is found by its transcript.
496const adoptResumed = async ($: $, path: string) => {
497  transcript = path
498  const place = (await read($, where)) ?? (await locate($))
499  const found = dir ? null : await findResumed($, worktreeDir(await notesRoot($), place), path)
500  if (!found) return
501  dir = found
502  await $.store.set(`dir:${sessionId}`, found)
503  await restoreFrom($, found)
504}
505
506// A session that was running before the mod was installed has history but no breadcrumbs yet.
507const backfill = async ($: $) => {
508  const current = await readCrumbs($)
509  if (current.tasks.length > 0 || current.prompts.length > 0) return
510  let rows: readonly SessionMessage[]
511  try {
512    rows = await $.session.messages()
513  } catch {
514    return
515  }
516  if (!rows.some(isTyped)) return
517
518  const now = await $.clock.now()
519  const home = (await $.env.get('HOME')) ?? ''
520  const edited: string[] = []
521  const touched: string[] = []
522  const lines: string[] = []
523  for (const m of rows) {
524    if (isTyped(m)) lines.push(`User: ${clip(m.text, 1500)}`)
525    if (m.role !== 'assistant') continue
526    if (m.text.trim()) lines.push(`Claude: ${clip(m.text, 2500)}`)
527    for (const use of m.toolUses) {
528      lines.push(`  [${toolLabel(use.tool, use.input)}]`)
529      if (use.isError) continue
530      const file = use.input.file_path ?? use.input.notebook_path
531      if ((use.tool === 'Edit' || use.tool === 'Write' || use.tool === 'NotebookEdit') && typeof file === 'string') edited.push(file)
532      const dir = use.tool === 'Bash' && typeof use.input.command === 'string' ? cdTarget(use.input.command, home) : null
533      if (dir) touched.push(dir)
534    }
535  }
536  const lastSaid = [...rows].reverse().find(m => m.role === 'assistant' && m.text.trim())
537  await mutate($, c => ({
538    ...c,
539    prompts: rows.filter(isTyped).slice(-5).map(m => ({ text: m.text.trim(), at: now })),
540    lastSaid: lastSaid ? { text: head(lastSaid.text.trim(), 600), at: now } : c.lastSaid,
541    edited: [...new Set(edited)].slice(-50),
542    touched: [...new Set(touched)].slice(-20),
543  }))
544  await refreshRepos($)
545  await sidePass($, { answer: lastSaid?.text ?? '', hasSavedNote: true, tools: [], transcript: lines.join('\n').slice(-30_000) })
546  await save($, 'idle')
547}
548
549const dropAsk = ($: $, question: string) =>
550  mutate($, c => ({ ...c, needsYou: c.needsYou.filter(a => a.question !== question) }))
551
552const answerAsk = async ($: $, ask: Ask, option: string) => {
553  await dropAsk($, ask.question)
554  // Queued by the engine until the session is idle, so pressing mid-turn is safe.
555  await $.prompt.submit({ text: `Re: "${ask.question}" — ${option}`, asUser: true })
556}
557
558const setView = ($: $, fn: (v: View) => View) => update($, view, old => fn({ ...VIEW, ...old }))
559
560const answerOf = (v: View, question: string) => v.typed[question]?.trim() || v.picked[question] || ''
561
562const withDetails = (v: View, question: string, answer: string) => {
563  const details = v.details[question]?.trim()
564  if (!details) return answer
565  return answer ? `${answer} (details: ${details})` : details
566}
567
568// With one question a choice is the whole answer; with several, choices collect until Send.
569const pickAsk = async ($: $, ask: Ask, option: string) => {
570  const { needsYou } = await readCrumbs($)
571  const v = { ...VIEW, ...(await read($, view)) }
572  // An open question is where details get added, so a choice there waits for Send.
573  if (needsYou.length <= 1 && v.expanded !== ask.question) return answerAsk($, ask, withDetails(v, ask.question, option))
574  await setView($, v => {
575    const picked = { ...v.picked }
576    if (picked[ask.question] === option) delete picked[ask.question]
577    else picked[ask.question] = option
578    return { ...v, picked }
579  })
580}
581
582const typeAsk = ($: $, ask: Ask, text: string) =>
583  setView($, v => ({ ...v, typed: { ...v.typed, [ask.question]: text } }))
584
585const submitTyped = async ($: $, ask: Ask, text: string) => {
586  const { needsYou } = await readCrumbs($)
587  const v = { ...VIEW, ...(await read($, view)) }
588  if (needsYou.length <= 1 && v.expanded !== ask.question && text.trim()) return answerAsk($, ask, withDetails(v, ask.question, text.trim()))
589  await typeAsk($, ask, text)
590}
591
592const sendAnswers = async ($: $) => {
593  const { needsYou } = await readCrumbs($)
594  const v = { ...VIEW, ...(await read($, view)) }
595  const answered = needsYou
596    .map(a => ({ ask: a, answer: withDetails(v, a.question, answerOf(v, a.question)) }))
597    .filter(a => a.answer)
598  if (answered.length === 0) {
599    $.ui.toast('Pick or type an answer first')
600    return
601  }
602  const text =
603    answered.length === 1
604      ? `Re: "${answered[0]?.ask.question}" — ${answered[0]?.answer}`
605      : ['Answers:', ...answered.map(a => `- "${a.ask.question}" — ${a.answer}`)].join('\n')
606  const done = new Set(answered.map(a => a.ask.question))
607  await mutate($, c => ({ ...c, needsYou: c.needsYou.filter(a => !done.has(a.question)) }))
608  await setView($, old => ({ ...old, picked: {}, typed: {}, details: {}, expanded: null }))
609  await $.prompt.submit({ text, asUser: true })
610}
611
612const detailAsk = ($: $, ask: Ask, text: string) =>
613  setView($, v => ({ ...v, details: { ...v.details, [ask.question]: text } }))
614
615const toggleAsk = ($: $, ask: Ask) =>
616  setView($, v => ({ ...v, expanded: v.expanded === ask.question ? null : ask.question }))
617
618const draftAsk = async ($: $, ask: Ask) => {
619  await $.prompt.fill({ text: `Re: "${ask.question}" — `, mode: 'insert' })
620  $.ui.toast('Finish your answer in the prompt box')
621}
622
623// A run saved before the brief existed has none.
624const readRun = async ($: $): Promise<TestRun> => {
625  const r = await read($, testRun)
626  return { ...r, brief: r.brief ?? '' }
627}
628
629// Kept in its own file and written only when a test changes, so a turn's save never rewrites it from an empty live list.
630const mutateTests = async ($: $, fn: (r: TestRun) => TestRun) => {
631  const now = await $.clock.now()
632  await update($, testRun, r => settleNeeds(fn(r), now))
633  const state = await readCrumbs($)
634  const target = await ensureDir($, state.tasks[0]?.title ?? 'tests')
635  await $.fs.write(`${target}/tests.json`, JSON.stringify(await read($, testRun), null, 2))
636}
637
638const readTests = async ($: $, dir: string): Promise<TestRun | null> => {
639  try {
640    return JSON.parse(await $.fs.read(`${dir}/tests.json`)) as TestRun
641  } catch {
642    return null
643  }
644}
645
646const setTestsView = ($: $, fn: (v: TestsView) => TestsView) => update($, testsView, fn)
647
648const patchTest = ($: $, id: string, fn: (t: ManualTest) => ManualTest) =>
649  mutateTests($, r => ({ ...r, tests: r.tests.map(t => (t.id === id ? fn(t) : t)) }))
650
651const logTest = async ($: $, id: string, from: Entry['from'], text: string) => {
652  const at = await $.clock.now()
653  await patchTest($, id, t => ({ ...t, log: [...t.log, { from, text: clip(text, 400), at }].slice(-30) }))
654}
655
656const showWaiting = async ($: $) => {
657  const open = (await readRun($)).waits.filter(w => w.answer === null).length
658  $.ui.status(open > 0 ? `⏳ ${open} test step${open > 1 ? 's' : ''} waiting on you` : undefined)
659}
660
661// A hook's own state reads don't see writes made while it runs, so a waiting step polls this instead.
662const answered = new Map<string, string>()
663
664const answerWait = async ($: $, waitId: string, answer: string) => {
665  answered.set(waitId, answer)
666  await mutateTests($, r => ({ ...r, waits: r.waits.map(w => (w.id === waitId ? { ...w, answer } : w)) }))
667  await showWaiting($)
668}
669
670// Where a message about a test goes: the step it is waiting on, its live tester, or the main agent.
671const routeTest = async ($: $, t: ManualTest, message: string, forMain: string) => {
672  const r = await readRun($)
673  const wait = openWait(r, t.id)
674  if (wait) return answerWait($, wait.id, message)
675  if (isLive(t) && t.agentId) {
676    await $.session.send({ to: { agentId: t.agentId }, text: message })
677    return
678  }
679  await $.prompt.submit({ text: forMain, asUser: true })
680}
681
682const replyTest = async ($: $, t: ManualTest, typed: string) => {
683  const message = typed.trim()
684  if (!message) return
685  await logTest($, t.id, 'you', message)
686  await setTestsView($, v => ({ ...v, typed: { ...v.typed, [t.id]: '' } }))
687  await routeTest($, t, message, `[manual test ${t.id} · ${t.title}] ${message}`)
688}
689
690const markTest = async ($: $, t: ManualTest, status: 'passed' | 'failed', typed: string) => {
691  const why = typed.trim()
692  await patchTest($, t.id, old => ({ ...old, status }))
693  await logTest($, t.id, 'you', `${status === 'passed' ? '✓ passed' : '✗ failed'}${why ? `: ${why}` : ''}`)
694  await setTestsView($, v => ({ ...v, typed: { ...v.typed, [t.id]: '' } }))
695  const line = `${ICON[status]} test ${t.id} ${t.title}${why ? `: ${why}` : ''}`
696  const r = await readRun($)
697  if (openWait(r, t.id) || isLive(t)) {
698    await routeTest($, t, `The person marked this test ${status}${why ? `: ${why}` : ''}. Wrap up.`, line)
699    return
700  }
701  // A pass with nothing to logTest waits for the next prompt instead of costing a turn.
702  if (status === 'passed' && !why) {
703    await mutateTests($, old => ({ ...old, unreported: [...old.unreported, line] }))
704    return
705  }
706  await $.prompt.submit({ text: `[manual test ${t.id} ${ICON[status]}] ${t.title}${why ? `: ${why}` : ''}`, asUser: true })
707}
708
709const startTester = async ($: $, t: ManualTest) => {
710  const { brief } = await readRun($)
711  const prompt = [
712    `Test ${t.id}: ${t.title}`,
713    brief ? `Brief from the planner:\n${brief}` : '',
714    t.steps.length > 0 ? `Steps:\n${t.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}` : '',
715    t.expect ? `Expected: ${t.expect}` : '',
716    t.trigger ? `Trigger: ${t.trigger}` : '',
717    (t.assumes ?? []).length > 0 ? `Assumes:\n${(t.assumes ?? []).map(a => `- ${a}`).join('\n')}` : '',
718    t.watch ? `Follow along with: ${t.watch}` : '',
719    t.log.length > 0 ? `Earlier on this test:\n${t.log.map(l => `- ${l.from}: ${l.text}`).join('\n')}` : '',
720  ]
721    .filter(Boolean)
722    .join('\n\n')
723  // Plugin hooks don't run inside an agent a plugin spawns, so the tester's own tool calls would go unanswered; the main agent spawns it instead.
724  await patchTest($, t.id, old => ({ ...old, status: 'running', agentId: null }))
725  await setTestsView($, v => ({ ...v, expanded: t.id }))
726  await $.prompt.submit({
727    text: [
728      `Start the tester for manual test ${t.id}: call the Agent tool with subagent_type "${TESTER}", run_in_background true, description "${clip(`Test ${t.id}: ${t.title}`, 40)}" and exactly this prompt. Then end your turn without walking the user through the test yourself.`,
729      '',
730      prompt,
731    ].join('\n'),
732  })
733}
734
735// Links a tester the main agent spawned to its test, by the "Test <id>:" line its prompt opens with.
736const bindTester = async ($: $, prompt: string, agentId: string) => {
737  const id = /^Test (\S+):/.exec(prompt.trim())?.[1]
738  if (!id) return
739  await patchTest($, id, old => ({ ...old, status: 'running', agentId }))
740}
741
742const planTests = async ($: $, e: Record<string, unknown>) => {
743  const raw = Array.isArray(e.tests) ? (e.tests as Record<string, unknown>[]) : []
744  const isAppending = e.mode === 'append'
745  const r = await readRun($)
746  const kept = isAppending ? r.tests : []
747  const incoming: ManualTest[] = raw
748    .filter(t => str(t?.title))
749    .map((t, i) => ({
750      id: str(t.id) || String(kept.length + i + 1),
751      title: clip(str(t.title), 60),
752      steps: listOf(t.steps),
753      expect: str(t.expect),
754      watch: str(t.watch),
755      trigger: str(t.trigger),
756      assumes: listOf(t.assumes),
757      needs: listOf(t.needs),
758      ...(str(t.model) ? { model: str(t.model) } : {}),
759      status: 'todo',
760      agentId: null,
761      log: [],
762    }))
763  if (incoming.length === 0) return { deny: 'test_plan needs at least one test with a title.' }
764  const untriggered = incoming.filter(t => t.expect && !t.trigger)
765  if (untriggered.length > 0) {
766    return {
767      deny: `Give tests ${untriggered.map(t => t.id).join(', ')} a trigger: the code that produces the expected signal and what decides whether it fires, checked against the code. Write "none: <why>" when no code path is involved.`,
768    }
769  }
770  const ids = new Set(incoming.map(t => t.id))
771  const brief = str(e.brief)
772  await mutateTests($, old => ({
773    ...old,
774    brief: brief || (isAppending ? old.brief ?? '' : ''),
775    tests: [...kept.filter(t => !ids.has(t.id)), ...incoming],
776    waits: isAppending ? old.waits : [],
777    unreported: isAppending ? old.unreported : [],
778  }))
779  void $.ui.open({ id: TESTS_PANE, title: 'tests' })
780
781  return {
782    result: `Listed ${incoming.length} test(s) in the tests pane (/tests). The user starts each one there; a tester subagent guides them and its final report reaches you when it finishes. Tell the user in one line that the tests are in the pane.`,
783  }
784}
785
786const updateTest = async ($: $, e: Record<string, unknown> & { agentId?: string }) => {
787  const brief = str(e.brief)
788  if (brief) await mutateTests($, r => ({ ...r, brief }))
789  const t = testOf(await readRun($), e)
790  if (!t) return brief && e.id === undefined ? { result: 'Updated the brief.' } : { deny: 'No such test: pass the id from test_plan.' }
791  const status = STATUSES.find(s => s === e.status)
792  const note = str(e.note)
793  const steps = listOf(e.steps)
794  const expect = str(e.expect)
795  const reply = str(e.reply)
796  const trigger = str(e.trigger)
797  const assumes = listOf(e.assumes)
798  await patchTest($, t.id, old => ({
799    ...old,
800    status: status ?? old.status,
801    steps: steps.length > 0 ? steps : old.steps,
802    expect: expect || old.expect,
803    trigger: trigger || old.trigger,
804    assumes: assumes.length > 0 ? assumes : old.assumes,
805    needs: Array.isArray(e.needs) ? listOf(e.needs) : old.needs,
806    model: str(e.model) || old.model,
807  }))
808  if (note || status) await logTest($, t.id, e.agentId === undefined ? 'claude' : 'tester', `${status && status !== t.status ? `${ICON[status]} ` : ''}${note || status}`)
809  if (!reply) return { result: `Updated test ${t.id}.` }
810  await logTest($, t.id, 'claude', `↩ ${reply}`)
811  if (!t.asking || !t.agentId) return { result: `Test ${t.id}'s tester is not waiting on a question; the reply is only in the pane log.` }
812  await patchTest($, t.id, old => ({ ...old, asking: null }))
813  const text = [
814    `The planner answered: ${reply}`,
815    steps.length > 0 ? `Steps are now:\n${steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}` : '',
816    expect ? `Expected now: ${expect}` : '',
817  ]
818    .filter(Boolean)
819    .join('\n\n')
820
821  // A tester the plugin resumes from inside this hook gets no answer from this hook again, so the main agent sends it.
822  return { result: `Now resume test ${t.id}'s tester: call SendMessage with to "${t.agentId}" and exactly this message:\n\n${text}` }
823}
824
825const awaitUser = async ($: $, e: Record<string, unknown> & { agentId?: string }, signal: AbortSignal) => {
826  const instruction = str(e.instruction)
827  const steps = listOf(e.steps).map(s => clip(s, 120))
828  if (!instruction && steps.length === 0) return { deny: 'await_user needs an instruction or steps.' }
829  const t = testOf(await readRun($), e)
830  const now = await $.clock.now()
831  const wait: Wait = {
832    id: `${now}-${Math.random().toString(36).slice(2, 7)}`,
833    testId: t?.id ?? null,
834    step: typeof e.step === 'number' ? e.step : null,
835    instruction: clip(instruction, 200),
836    steps,
837    options: listOf(e.options).slice(0, 4).map(o => clip(o, 30)),
838    at: now,
839    answer: null,
840  }
841  await mutateTests($, r => ({ ...r, waits: [...r.waits.filter(w => w.answer === null || w.testId !== wait.testId), wait] }))
842  if (t) await setTestsView($, v => ({ ...v, expanded: t.id }))
843  await showWaiting($)
844  $.ui.toast(t ? `Test ${t.id} is waiting on you` : 'A test step is waiting on you')
845
846  // Waiting on a button inside the hook counts against its budget; time inside a $ call does not.
847  let answer: string | null = null
848  while (answer === null && !signal.aborted && (await $.clock.now()) - now < WAIT_LIMIT_MS) {
849    await $.process.run(['sleep', '1'])
850    answer = answered.get(wait.id) ?? null
851  }
852  answered.delete(wait.id)
853  await mutateTests($, r => ({ ...r, waits: r.waits.filter(w => w.id !== wait.id) }))
854  await showWaiting($)
855  if (answer === null) {
856    return { result: signal.aborted ? 'Interrupted before the person answered.' : 'No answer after 30 minutes. Ask again, or end the test as blocked.' }
857  }
858
859  return { result: `The person answered: ${answer}` }
860}
861
862// The question reaches the main agent as the tester's hand-back; a reply resumes the same tester.
863const askPlanner = async ($: $, e: Record<string, unknown> & { agentId?: string }) => {
864  const question = str(e.question)
865  if (!question) return { deny: 'ask_planner needs a question.' }
866  const t = testOf(await readRun($), e)
867  if (!t) return { deny: 'No such test: pass the id from the brief.' }
868  await patchTest($, t.id, old => ({ ...old, asking: clip(question, 200) }))
869  await logTest($, t.id, 'tester', `? ${question}`)
870
871  return {
872    result: [
873      `Your question is with the planner. End your turn now, with this as your whole final message: "[tester asks · manual test ${t.id} · ${t.title}] ${question}".`,
874      'Leave your background captures running: the planner fixes what it can and its reply resumes you in this same run.',
875    ].join(' '),
876  }
877}
878
879const testerDone = async ($: $, agentId: string, answer: string, isAborted: boolean) => {
880  const t = (await readRun($)).tests.find(x => x.agentId === agentId)
881  if (!t) return
882  // Stopped to ask the planner: still this test's tester, resumed by the reply.
883  if (t.asking && !isAborted) return
884  const status: TestStatus = t.status === 'running' ? 'todo' : t.status
885  await patchTest($, t.id, old => ({ ...old, status, agentId: null, asking: null }))
886  await mutateTests($, r => ({ ...r, waits: r.waits.filter(w => w.testId !== t.id || w.answer !== null) }))
887  await showWaiting($)
888  // Claude Code hands the tester's final report to the main agent itself; the pane just records it.
889  await logTest($, t.id, 'tester', answer || (isAborted ? 'stopped before it reported' : 'ended without a summary'))
890}
891
892const TEST_TOOLS = [TOOL('test_plan'), TOOL('test_update'), TOOL('await_user'), TOOL('ask_planner')]
893
894const testTool = async ($: $, e: Record<string, unknown> & { tool: unknown; agentId?: string }, signal: AbortSignal) => {
895  if (e.tool === TOOL('test_plan')) return planTests($, e)
896  if (e.tool === TOOL('test_update')) return updateTest($, e)
897  if (e.tool === TOOL('ask_planner')) return askPlanner($, e)
898
899  return awaitUser($, e, signal)
900}
901
902const OWN_TOOLS = [SAVE_NOTE, EDIT_NOTE, ...TEST_TOOLS]
903
904const briefInput = (tool: string, input: unknown): Record<string, string> | null => {
905  const i = (input ?? {}) as Record<string, unknown>
906  if (tool === SAVE_NOTE) return { title: str(i.title) }
907  if (tool === EDIT_NOTE) {
908    const n = Array.isArray(i.edits) ? i.edits.length : 0
909    return { note: str(i.id), ...(str(i.markdown) ? { markdown: 'rewritten' } : { edits: `${n} edit${n === 1 ? '' : 's'}` }) }
910  }
911  if (tool === TOOL('test_plan')) {
912    const n = Array.isArray(i.tests) ? i.tests.length : 0
913    return { tests: `${n} test${n === 1 ? '' : 's'}`, ...(i.mode === 'append' ? { mode: 'append' } : {}) }
914  }
915  if (tool === TOOL('test_update')) {
916    const text = str(i.reply) || str(i.note)
917    const changed = ['steps', 'expect', 'brief'].filter(k => i[k] !== undefined)
918    return {
919      ...(str(i.id) ? { id: str(i.id) } : {}),
920      ...(str(i.status) ? { status: str(i.status) } : {}),
921      ...(text ? { [str(i.reply) ? 'reply' : 'note']: clip(text, 60) } : {}),
922      ...(changed.length > 0 ? { changed: changed.join(', ') } : {}),
923    }
924  }
925  if (tool === TOOL('ask_planner')) return { question: clip(str(i.question), 60) }
926  return null
927}
928
929// Results end with a note to the model that the person needn't read.
930const firstSentence = (output: unknown): unknown => {
931  const cut = (s: string) => s.match(/^[\s\S]*?\.(?=\s|$)/)?.[0] ?? s
932  if (typeof output === 'string') return cut(output)
933  if (Array.isArray(output))
934    return output.map(b => (b && typeof b === 'object' && typeof b.text === 'string' ? { ...b, text: cut(b.text) } : b))
935  return output
936}
937
938// Passes with nothing to say ride along with the next prompt instead of costing a turn each.
939const takePasses = async ($: $): Promise<string | null> => {
940  const { unreported } = await readRun($)
941  if (unreported.length === 0) return null
942  await mutateTests($, r => ({ ...r, unreported: [] }))
943
944  return `Manual tests the user passed since your last turn:\n${unreported.join('\n')}`
945}
946
947export const register: Register = (on, options) => {
948  cfg.model = String(options.model ?? 'haiku')
949  cfg.testerModel = String(options.testerModel ?? 'sonnet')
950  cfg.retentionDays = Number(options.retentionDays ?? 30)
951  cfg.archiveDays = Number(options.archiveDays ?? 60)
952  cfg.isArchiving = options.archive !== false
953
954  on('session.start', async ($, e, next) => {
955    const started = await next(e)
956    sessionId = await $.session.id()
957    const place = await locate($)
958    await update($, where, () => place)
959
960    const known = await $.store.get(`dir:${sessionId}`)
961    dir = typeof known === 'string' ? known : null
962    if (dir) await restoreFrom($, dir)
963
964    await $.command.register({
965      name: 'whereami',
966      description: 'Show or hide the breadcrumbs pane; `/whereami clean` tidies old session notes',
967    })
968    await $.tool.register({
969      name: 'save_note',
970      description:
971        'Save an explanation, summary or walkthrough the user asked for as a markdown note in their breadcrumbs pane, so it does not get buried in the transcript. The pane reflows the note to its width, tables included.',
972      inputSchema: {
973        type: 'object',
974        properties: {
975          title: { type: 'string', description: 'A short title, under 60 characters' },
976          markdown: { type: 'string', description: 'The full explanation in markdown' },
977        },
978        required: ['title', 'markdown'],
979      },
980    })
981    await $.tool.register({
982      name: 'edit_note',
983      description:
984        'Change a note saved with save_note in place, sending only the text that changes. Each edit replaces one exact, unique piece of the note; markdown instead replaces the whole body.',
985      inputSchema: {
986        type: 'object',
987        properties: {
988          id: { type: 'string', description: 'The id save_note returned, or the note\'s title' },
989          edits: {
990            type: 'array',
991            items: {
992              type: 'object',
993              properties: {
994                old: { type: 'string', description: 'Text in the note, exactly as written and found once' },
995                new: { type: 'string', description: 'What replaces it' },
996              },
997              required: ['old', 'new'],
998            },
999          },
1000          markdown: { type: 'string', description: 'A whole new body, for a rewrite' },
1001        },
1002        required: ['id'],
1003      },
1004    })
1005    await $.command.register({ name: 'tests', description: 'Show or hide the manual tests pane' })
1006    try {
1007      await $.agent.register({
1008        name: 'tester',
1009        description: 'Guides the person through one manual end-to-end test. Spawn it only when the tests pane asks you to, with the prompt it gives.',
1010        prompt: TESTER_PROMPT,
1011      })
1012    } catch (error) {
1013      // Without the tester the rest of the pane still works; Start reports the missing agent.
1014      $.ui.log(`breadcrumbs: tester agent not registered (${error instanceof Error ? error.message : String(error)})`)
1015    }
1016    await $.tool.register({
1017      name: 'test_plan',
1018      description: 'List manual or end-to-end tests the user has to run by hand in their tests pane, each with its steps. The user starts each test from the pane.',
1019      inputSchema: {
1020        type: 'object',
1021        properties: {
1022          brief: {
1023            type: 'string',
1024            description: 'Read by every tester before its test: the change, the environment, how to observe results, what is already verified',
1025          },
1026          tests: {
1027            type: 'array',
1028            items: {
1029              type: 'object',
1030              properties: {
1031                id: { type: 'string', description: 'Short id, e.g. "1"; defaults to its position' },
1032                title: { type: 'string', description: 'Under 40 characters' },
1033                steps: { type: 'array', items: { type: 'string' }, description: 'What the person does, each under 70 characters' },
1034                expect: { type: 'string', description: 'What should happen' },
1035                watch: { type: 'string', description: 'Logs or watchers to follow along with' },
1036                trigger: {
1037                  type: 'string',
1038                  description: 'The code that produces the expected signal and what decides whether it fires, e.g. "sent by completeRide() in rides.ts, driver app only"; required with expect',
1039                },
1040                assumes: { type: 'array', items: { type: 'string' }, description: 'What the plan relies on that the code cannot show' },
1041                needs: { type: 'array', items: { type: 'string' }, description: 'Ids of tests that must pass first' },
1042                model: { type: 'string', description: 'The tester\'s model for this test, e.g. opus; leave out for the default' },
1043              },
1044              required: ['title'],
1045            },
1046          },
1047          mode: { type: 'string', enum: ['replace', 'append'], description: 'replace (default) starts a new list' },
1048        },
1049        required: ['tests'],
1050      },
1051    })
1052    await $.tool.register({
1053      name: 'test_update',
1054      description:
1055        'Change a manual test: its status (todo, running, passed, failed, blocked, retest), a short note shown in the pane, new steps or a new expect, or a reply to its tester\'s question. With brief and no id, replaces the brief testers read.',
1056      inputSchema: {
1057        type: 'object',
1058        properties: {
1059          id: { type: 'string', description: 'The test id; a tester may leave it out' },
1060          status: { type: 'string', enum: [...STATUSES] },
1061          note: { type: 'string', description: 'One line' },
1062          steps: { type: 'array', items: { type: 'string' } },
1063          expect: { type: 'string' },
1064          brief: { type: 'string', description: 'The whole new brief' },
1065          trigger: { type: 'string' },
1066          assumes: { type: 'array', items: { type: 'string' } },
1067          needs: { type: 'array', items: { type: 'string' } },
1068          model: { type: 'string' },
1069          reply: { type: 'string', description: 'Your answer to the tester\'s ask_planner question; the result says how to send it' },
1070        },
1071      },
1072    })
1073    await $.tool.register({
1074      name: 'await_user',
1075      description:
1076        'Show the person steps to do by hand in the tests pane and wait until they answer: an option, Done, Can\'t or a reply. Returns their answer.',
1077      inputSchema: {
1078        type: 'object',
1079        properties: {
1080          id: { type: 'string', description: 'The test id; a tester may leave it out' },
1081          step: { type: 'number', description: 'The number of the first step shown' },
1082          steps: { type: 'array', items: { type: 'string' }, description: 'Steps to do in a row before answering, each under 100 characters' },
1083          instruction: { type: 'string', description: 'A single step, or the question to answer after the steps; under 100 characters' },
1084          options: { type: 'array', items: { type: 'string' }, description: '2 to 4 short answers to the question; they replace Done and Can\'t' },
1085        },
1086      },
1087    })
1088    await $.tool.register({
1089      name: 'ask_planner',
1090      description:
1091        'For a tester: ask the main agent, who planned the test, when a step is impossible, the setup breaks or a fixable failure shows up. Then end your turn with the question; the planner\'s reply, maybe with new steps, resumes you.',
1092      inputSchema: {
1093        type: 'object',
1094        properties: {
1095          id: { type: 'string', description: 'The test id; a tester may leave it out' },
1096          question: { type: 'string', description: 'What you saw, and what you need from the planner' },
1097        },
1098        required: ['question'],
1099      },
1100    })
1101    await showWaiting($)
1102
1103    if (e.isInteractive && options.panel !== 'command') void $.ui.open({ id: PANE, title: 'breadcrumbs' })
1104    $.clock.every(30_000, () => $.ui.invalidate('ui.render'))
1105    $.clock.after(0, () => refreshRepos($))
1106    $.clock.after(0, () => backfill($))
1107    // A hot reload drops the old module's timers, including a pass it just queued; poll so this copy picks it up.
1108    $.clock.every(15_000, () => runPending($))
1109
1110    const now = await $.clock.now()
1111    const lastClean = Number((await $.store.get('lastClean')) ?? 0)
1112    if (now - lastClean > 86_400_000) {
1113      await $.store.set('lastClean', now)
1114      $.clock.after(5000, async () => {
1115        const { root, plan } = await clean($)
1116        await applyClean($, root, plan, now)
1117        const count = plan.archive.length + plan.remove.length
1118        if (count > 0) $.ui.log(`breadcrumbs: tidied ${count} old session folder(s)`, { to: 'debug' })
1119      })
1120    }
1121
1122    return started
1123  })
1124
1125  on('classic.SessionStart', async ($, e, next) => {
1126    const done = await next(e)
1127    if (e.transcript_path) await adoptResumed($, e.transcript_path)
1128
1129    return done
1130  })
1131
1132  on('prompt.compose', async ($, e, next) => {
1133    const composed = await next(e)
1134
1135    return {
1136      sections: [
1137        ...composed.sections,
1138        { id: 'breadcrumbs:notes', text: NOTE_GUIDANCE, scope: 'session' },
1139        { id: 'breadcrumbs:tests', text: TESTS_GUIDANCE, scope: 'session' },
1140      ],
1141    }
1142  })
1143
1144  on('prompt.submit', async ($, e, next) => {
1145    const isPerson = e.origin.kind === 'composer' || (e.origin.kind === 'plugin' && e.origin.name === 'breadcrumbs')
1146    if (isPerson && e.text.trim() && !e.text.trimStart().startsWith('/')) {
1147      const now = await $.clock.now()
1148      // A new prompt usually answers what was pending; the next side pass re-asks anything still open.
1149      await mutate($, c => ({ ...c, needsYou: [], prompts: [...c.prompts, { text: e.text.trim(), at: now }].slice(-5) }))
1150      await setView($, v => ({ ...v, picked: {}, typed: {}, details: {}, expanded: null }))
1151      if (e.turnId === undefined) {
1152        turnTools = []
1153        hasSavedNote = false
1154      }
1155    }
1156    const passes = await takePasses($)
1157
1158    return next(passes ? { ...e, context: [...(e.context ?? []), passes] } : e)
1159  })
1160
1161  // A subagent's call to a plugin tool is answered only by a hook matched on that tool.
1162  for (const tool of TEST_TOOLS) {
1163    on('tool.call', { tool: tool as typeof SAVE_NOTE }, async ($, e, next) => {
1164      try {
1165        return await testTool($, e as Record<string, unknown> & { tool: unknown; agentId?: string }, next.signal)
1166      } catch (error) {
1167        return { deny: `breadcrumbs: ${error instanceof Error ? error.message : String(error)}` }
1168      }
1169    })
1170  }
1171
1172  on('tool.call', { tool: SAVE_NOTE }, async ($, e) => {
1173    const title = typeof e.title === 'string' ? e.title : 'Note'
1174    const markdown = typeof e.markdown === 'string' ? e.markdown : ''
1175    if (!markdown.trim()) return { deny: 'save_note needs a non-empty markdown body.' }
1176    hasSavedNote = true
1177    const note = await saveNote($, title, markdown)
1178
1179    return {
1180      result: `Saved "${note.title}" to the breadcrumbs pane (${note.file}). Its id is ${note.id}; change it later with edit_note. Tell the user in one line where to find it.`,
1181    }
1182  })
1183
1184  on('tool.call', { tool: EDIT_NOTE as typeof SAVE_NOTE }, async ($, e) => {
1185    const i = e as Record<string, unknown>
1186    const edits = (Array.isArray(i.edits) ? i.edits : []).filter(
1187      (x): x is NoteEdit => typeof x?.old === 'string' && x.old !== '' && typeof x?.new === 'string',
1188    )
1189    const markdown = str(i.markdown)
1190    if (!str(i.id) || (edits.length === 0 && !markdown)) return { deny: 'edit_note needs an id and either edits or markdown.' }
1191    const edited = await editNote($, str(i.id), edits, markdown)
1192    if ('error' in edited) return { deny: edited.error }
1193    hasSavedNote = true
1194
1195    return { result: `Updated "${edited.note.title}" in the breadcrumbs pane. Tell the user in one line that it changed.` }
1196  })
1197
1198  on('tool.call', async ($, e, next) => {
1199    if (e.agentId !== undefined || OWN_TOOLS.includes(String(e.tool))) return next(e)
1200    const label = toolLabel(String(e.tool), e as Record<string, unknown>)
hooks/markdown.ts 56 lines
1import type { Crumbs, Where } from '../types'
2import type { Repo } from '../types'
3import { asks, basename, dones, stamp } from './text'
4
5export type Saved = Crumbs & { session: string; worktree: string; updatedAt: number; transcript?: string }
6
7export const repoState = (r: Repo): string => {
8  const parts: string[] = []
9  if (r.changed > 0) parts.push(`${r.changed} uncommitted`)
10  if (!r.hasUpstream) parts.push('no upstream')
11  else if (r.ahead > 0) parts.push(`↑${r.ahead} unpushed`)
12  if (r.behind > 0) parts.push(`↓${r.behind} behind`)
13
14  return parts.length > 0 ? parts.join(' · ') : '✓ pushed'
15}
16
17export const contextMarkdown = (saved: Saved, where: Where, status: string): string => {
18  const task = saved.tasks[0]?.title ?? 'No task yet'
19  const lines = [
20    '---',
21    `session: ${saved.session}`,
22    `worktree: ${saved.worktree}`,
23    `repo: ${where.repo}`,
24    `branch: ${where.branch}`,
25    `status: ${status}`,
26    `updated: ${stamp(saved.updatedAt)}`,
27    '---',
28    '',
29    `# ${task}`,
30    '',
31  ]
32  if (saved.tasks.length > 1) {
33    lines.push('Earlier in this session:', ...saved.tasks.slice(1).map(t => `- ${t.title}`), '')
34  }
35  const waiting = asks(saved.needsYou)
36  if (waiting.length > 0) lines.push('## Needs you', '', ...waiting.map(a => `- [ ] ${a.question}`), '')
37  const done = dones(saved.done)
38  if (done.length > 0) lines.push('## Done', '', ...done.flatMap(d => [`- ${d.title}`, ...d.items.map(i => `  - ${i}`)]), '')
39  const repos = saved.repos ?? []
40  if (repos.length > 0) {
41    lines.push('## Repos', '', ...repos.map(r => `- ${basename(r.root)} · ${r.branch} · ${repoState(r)}`), '')
42  }
43  const prompt = saved.prompts.at(-1)
44  if (prompt) lines.push('## You asked', '', ...prompt.text.split('\n').map(l => `> ${l}`), '')
45  if (saved.lastSaid) lines.push('## Claude last said', '', saved.lastSaid.text, '')
46  if (saved.notes.length > 0) {
47    lines.push('## Notes', '', ...saved.notes.map(n => `- [${n.title}](${basename(n.file)})`), '')
48  }
49  if (saved.decided.length > 0) lines.push('## Decided', '', ...saved.decided.map(d => `- ${d}`), '')
50  if (saved.tried.length > 0) {
51    lines.push('## Tried', '', ...saved.tried.map(t => `- ${t.isOk ? '✓' : '✗'} ${t.text}`), '')
52  }
53
54  return lines.join('\n')
55}
56
hooks/state.ts 25 lines
1import type { Crumbs } from '../types'
2import { asks, dones } from './text'
3
4const list = <T>(value: T[] | undefined): T[] => (Array.isArray(value) ? value : [])
5
6export const normalize = (value: Partial<Crumbs> | null | undefined): Crumbs => {
7  const c = value ?? {}
8
9  return {
10    tasks: list(c.tasks),
11    prompts: list(c.prompts),
12    activity: c.activity ?? null,
13    lastSaid: c.lastSaid ?? null,
14    notes: list(c.notes),
15    decided: list(c.decided),
16    tried: list(c.tried),
17    needsYou: asks(c.needsYou),
18    done: dones(c.done),
19    edited: list(c.edited),
20    touched: list(c.touched),
21    repos: list(c.repos),
22    asking: c.asking ?? null,
23  }
24}
25
hooks/tests.ts 69 lines
1import type { ManualTest, TestRun, TestStatus } from '../types'
2
3// Prompts, tool schemas and pure helpers for the manual tests pane; everything that touches $ is in register.tsx.
4export const TESTS_PANE = 'tests'
5export const TOOL = (name: string) => `mcp__breadcrumbs__${name}`
6export const TESTER = 'breadcrumbs:tester'
7export const WAIT_LIMIT_MS = 30 * 60_000
8
9export const TESTS_GUIDANCE = [
10  `When the user needs to run manual or end-to-end tests that you cannot run yourself, list them with the ${TOOL('test_plan')} tool instead of writing the steps in your reply.`,
11  'The user starts each test from their tests pane, where a tester subagent guides them through it and sends you its final report when it finishes.',
12  'You are the planner. Give test_plan a brief that every tester reads first and trusts without re-checking: what the change does, the environment (devices, builds, servers and how to reach them), how to observe results (log tags, commands, signals known to mislead) and what is already verified.',
13  'Before listing, make sure every step can actually happen, checked against the code and the setup; testers do not re-check. Do the preconditions you can check yourself (builds, deploys, registrations) instead of making them a test.',
14  'For each test, trigger names the code that produces the expected signal and what decides whether it fires ("ride_ended is sent by completeRide() in rides.ts, from the driver app only"), so a step never takes a path that skips it; assumes lists what you could not check in the code (one ride per driver at a time, how another agent will behave); needs lists ids of tests that must pass first. Testers run on a fast default model; set model "opus" on a test only when the tester will have to diagnose something subtle.',
15  'Group by physical setup: one setup such as a single ride can verify several behaviours, so prefer fewer, longer tests. Steps are only what the person does by hand; checks you or the tester run go in expect or watch, and each expect names a signal that tells a pass from a fail.',
16  'After each tester report, revise the tests still to run with test_update: new steps or expect, status blocked with the reason when one can no longer be done or failed when an earlier result already decides it, and new facts added to the brief.',
17  'A tester may hand back a question instead of a report ("[tester asks · manual test …] …"). It is paused with its setup still running, so fix what it needs if you can (rebuild, restart a server, sync data), then answer with test_update: its id and reply, plus new steps or expect if they change, and send the message it gives you to the tester with SendMessage, which resumes it. Reply "end the test" when it should stop.',
18  'When the tests pane asks you to start a tester, spawn it exactly as asked and end your turn. Act on testers\' reports as the orchestrator: fix what failed, then mark the test for a retest with test_update. Do not walk the user through a listed test yourself unless they ask.',
19  'The pane can be narrow: keep titles under 40 characters and each step under 70.',
20].join(' ')
21
22export const TESTER_PROMPT = [
23  'You guide a person through one manual end-to-end test. You cannot see their screen or device; they do every step by hand.',
24  '- The planner wrote the brief and the test and already checked the steps can be done. Trust the brief: do not re-verify the environment, tooling or facts it states; start the test straight away and read code only to explain a result.',
25  `- When a step turns out to be impossible, the setup breaks (a watcher or server dies, data or config is missing) or a result fails in a way the planner could fix, call ${TOOL('ask_planner')} with what you saw and what you need, then end your turn as it says. The planner fixes things or revises the steps, and its reply resumes you in the same run with the same setup.`,
26  '- Do not swap in a different path to the same end on your own. End the test as blocked or failed only when the planner says to or does not answer.',
27  '- Before the first step, start one background capture of the logs or watchers named in the test and note its PID. Check it at checkpoints and analyse it in full at the end.',
28  `- Hand the person steps with ${TOOL('await_user')}. Put a run of steps they can do without stopping in one call (steps, step = the first one's number), and stop only at a checkpoint: where the result decides whether the remaining steps still make sense, or where only the person can see the result (screen, notification tray).`,
29  '- When you ask the person something, put the question in instruction and give 2 to 4 short answer options ("Gone", "Still there", "Not sure"). Leave options out for plain actions; they then get Done and Can\'t.',
30  `- Post short observations with ${TOOL('test_update')} (note), so the person sees what you saw.`,
31  '- When an expected signal does not show, check the test\'s trigger first: say whether the steps reached that code path, and if an assumption turned out wrong, say which.',
32  '- Do not edit code or config. Diagnose and propose; the main agent decides on fixes.',
33  '- Before your final report, kill every background process you started; keep them while you wait on ask_planner.',
34  '- When the test is settled, call test_update with status passed, failed or blocked and a one-line note, then end with at most five lines: the result, the evidence, the likely cause if it failed, and anything you learned that affects the tests still to run.',
35].join('\n')
36
37export const ICON: Record<TestStatus, string> = { todo: '·', running: '●', passed: '✓', failed: '✗', blocked: '⊘', retest: '⟳' }
38
39export const STATUSES: readonly TestStatus[] = ['todo', 'running', 'passed', 'failed', 'blocked', 'retest']
40
41export const listOf = (value: unknown): string[] =>
42  Array.isArray(value) ? value.filter((s): s is string => typeof s === 'string' && s.trim() !== '').map(s => s.trim()) : []
43
44export const str = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
45
46export const testOf = (r: TestRun, e: { id?: unknown; agentId?: string }) =>
47  r.tests.find(t => (typeof e.id === 'string' && e.id ? t.id === e.id : e.agentId !== undefined && t.agentId === e.agentId))
48
49// Ids this test needs that have not passed yet.
50export const unmet = (r: TestRun, t: ManualTest): string[] =>
51  (t.needs ?? []).filter(id => r.tests.find(x => x.id === id)?.status !== 'passed')
52
53// A test whose prerequisite failed or was blocked cannot run either.
54export const settleNeeds = (r: TestRun, at: number): TestRun => ({
55  ...r,
56  tests: r.tests.map(t => {
57    if (t.status !== 'todo' && t.status !== 'retest') return t
58    const dead = r.tests.find(x => (t.needs ?? []).includes(x.id) && (x.status === 'failed' || x.status === 'blocked'))
59    if (!dead) return t
60    const text = `${ICON.blocked} needs test ${dead.id}, which ${dead.status === 'failed' ? 'failed' : 'is blocked'}`
61    return { ...t, status: 'blocked', log: [...t.log, { from: 'claude', text, at }].slice(-30) }
62  }),
63})
64
65export const isLive = (t: ManualTest) => t.status === 'running' && t.agentId !== null
66
67export const openWait = (r: TestRun, testId: string) => r.waits.find(w => w.testId === testId && w.answer === null)
68
69
hooks/rich.ts 464 lines
1// Lays markdown out as styled, pre-wrapped lines for the terminal pane, which has no Markdown element of its own worth reading.
2
3export type Style = {
4  color?: string
5  bold?: boolean
6  italic?: boolean
7  underline?: boolean
8  strikethrough?: boolean
9}
10export type Span = { text: string; style: Style; href?: string }
11export type Line = Span[]
12
13// Theme keys, not raw colors, so the pane follows whichever theme the person picked.
14const S = {
15  text: { color: 'text' },
16  h1: { color: 'claude', bold: true, underline: true },
17  h2: { color: 'claude', bold: true },
18  h3: { color: 'suggestion', bold: true },
19  h4: { color: 'text', bold: true },
20  strong: { bold: true },
21  em: { italic: true },
22  del: { color: 'inactive', strikethrough: true },
23  code: { color: 'permission' },
24  link: { color: 'suggestion', underline: true },
25  bullet: { color: 'claude' },
26  number: { color: 'claude' },
27  done: { color: 'success' },
28  todo: { color: 'inactive' },
29  quote: { color: 'subtle', italic: true },
30  bar: { color: 'subtle' },
31  rule: { color: 'subtle' },
32  th: { color: 'claude', bold: true },
33  block: { color: 'text' },
34  lang: { color: 'inactive', italic: true },
35} satisfies Record<string, Style>
36
37const CODE = {
38  comment: { color: 'inactive', italic: true },
39  string: { color: 'success' },
40  number: { color: 'warning' },
41  keyword: { color: 'merged' },
42  call: { color: 'suggestion' },
43  type: { color: 'claude' },
44  operator: { color: 'permission' },
45} satisfies Record<string, Style>
46
47// MARK: Blocks
48
49type Align = 'left' | 'center' | 'right'
50type Item = { marker: string; task?: boolean; blocks: Block[] }
51type Block =
52  | { kind: 'heading'; level: number; text: string }
53  | { kind: 'paragraph'; text: string }
54  | { kind: 'code'; lang: string; lines: string[] }
55  | { kind: 'quote'; blocks: Block[] }
56  | { kind: 'list'; ordered: boolean; items: Item[] }
57  | { kind: 'table'; header: string[]; align: Align[]; rows: string[][] }
58  | { kind: 'rule' }
59
60const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]*)/
61const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
62const RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/
63const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
64const QUOTE = /^ {0,3}>\s?(.*)$/
65const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
66
67const cells = (row: string) =>
68  row
69    .trim()
70    .replace(/^\||(?<!\\)\|$/g, '')
71    .split(/(?<!\\)\|/)
72    .map(c => c.trim().replace(/\\\|/g, '|'))
73
74const startsBlock = (line: string, next: string | undefined) =>
75  FENCE.test(line) ||
76  HEADING.test(line) ||
77  RULE.test(line) ||
78  QUOTE.test(line) ||
79  ITEM.test(line) ||
80  (line.includes('|') && next !== undefined && TABLE_RULE.test(next) && next.includes('-'))
81
82const indentOf = (line: string) => (/^\s*/.exec(line)?.[0] ?? '').replace(/\t/g, '    ').length
83
84export const parse = (markdown: string): Block[] => {
85  const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
86  const blocks: Block[] = []
87  let i = 0
88  while (i < lines.length) {
89    const line = lines[i] ?? ''
90    if (!line.trim()) {
91      i++
92      continue
93    }
94    const fence = FENCE.exec(line)
95    if (fence) {
96      const close = new RegExp(`^ {0,3}${fence[1]?.[0] === '`' ? '`' : '~'}{${fence[1]?.length ?? 3},}\\s*$`)
97      const body: string[] = []
98      i++
99      while (i < lines.length && !close.test(lines[i] ?? '')) body.push(lines[i++] ?? '')
100      i++
101      blocks.push({ kind: 'code', lang: fence[2] ?? '', lines: body })
102      continue
103    }
104    const heading = HEADING.exec(line)
105    if (heading) {
106      blocks.push({ kind: 'heading', level: heading[1]?.length ?? 1, text: heading[2] ?? '' })
107      i++
108      continue
109    }
110    if (RULE.test(line)) {
111      blocks.push({ kind: 'rule' })
112      i++
113      continue
114    }
115    if (QUOTE.test(line)) {
116      const body: string[] = []
117      while (i < lines.length && (lines[i] ?? '').trim() && QUOTE.test(lines[i] ?? '')) body.push(QUOTE.exec(lines[i++] ?? '')?.[1] ?? '')
118      blocks.push({ kind: 'quote', blocks: parse(body.join('\n')) })
119      continue
120    }
121    const next = lines[i + 1]
122    if (line.includes('|') && next !== undefined && TABLE_RULE.test(next) && next.includes('-')) {
123      const header = cells(line)
124      const align = cells(next).map((c): Align => (c.endsWith(':') ? (c.startsWith(':') ? 'center' : 'right') : 'left'))
125      const rows: string[][] = []
126      i += 2
127      while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim()) rows.push(cells(lines[i++] ?? ''))
128      blocks.push({ kind: 'table', header, align, rows })
129      continue
130    }
131    const item = ITEM.exec(line)
132    if (item) {
133      const base = indentOf(line)
134      const ordered = /\d/.test(item[2] ?? '')
135      const items: Item[] = []
136      while (i < lines.length) {
137        const head = ITEM.exec(lines[i] ?? '')
138        if (!head || indentOf(lines[i] ?? '') !== base || /\d/.test(head[2] ?? '') !== ordered) break
139        const body = [head[3] ?? '']
140        const contentIndent = base + (head[2]?.length ?? 1) + 1
141        i++
142        while (i < lines.length) {
143          const l = lines[i] ?? ''
144          if (!l.trim()) {
145            const after = lines[i + 1] ?? ''
146            if (after.trim() && indentOf(after) >= contentIndent) {
147              body.push('')
148              i++
149              continue
150            }
151            break
152          }
153          if (indentOf(l) < contentIndent && (ITEM.test(l) || startsBlock(l, lines[i + 1]))) break
154          body.push(indentOf(l) >= contentIndent ? l.replace(/\t/g, '    ').slice(contentIndent) : l.trim())
155          i++
156        }
157        const task = /^\[([ xX])\]\s+/.exec(body[0] ?? '')
158        if (task) body[0] = (body[0] ?? '').slice(task[0].length)
159        items.push({ marker: head[2] ?? '-', task: task ? task[1] !== ' ' : undefined, blocks: parse(body.join('\n')) })
160        while (i < lines.length && !(lines[i] ?? '').trim() && ITEM.test(lines[i + 1] ?? '') && indentOf(lines[i + 1] ?? '') === base) i++
161      }
162      blocks.push({ kind: 'list', ordered, items })
163      continue
164    }
165    const text = [line.trim()]
166    i++
167    while (i < lines.length && (lines[i] ?? '').trim() && !startsBlock(lines[i] ?? '', lines[i + 1])) text.push((lines[i++] ?? '').trim())
168    blocks.push({ kind: 'paragraph', text: text.join(' ') })
169  }
170
171  return blocks
172}
173
174// MARK: Inline
175
176const merge = (a: Style, b: Style): Style => ({ ...a, ...b })
177
178export const inline = (text: string, base: Style): Span[] => {
179  const out: Span[] = []
180  const push = (t: string, style: Style, href?: string) => {
181    if (!t) return
182    const last = out.at(-1)
183    if (last && !href && !last.href && JSON.stringify(last.style) === JSON.stringify(style)) last.text += t
184    else out.push(href ? { text: t, style, href } : { text: t, style })
185  }
186  let i = 0
187  while (i < text.length) {
188    const rest = text.slice(i)
189    const ch = rest[0] ?? ''
190    if (ch === '\\' && /^\\[\\`*_{}[\]()#+\-.!|~<>]/.test(rest)) {
191      push(rest[1] ?? '', base)
192      i += 2
193      continue
194    }
195    if (ch === '`') {
196      const run = /^`+/.exec(rest)?.[0] ?? '`'
197      const end = rest.indexOf(run, run.length)
198      if (end > 0) {
199        push(rest.slice(run.length, end).trim(), merge(base, S.code))
200        i += end + run.length
201        continue
202      }
203    }
204    const link = /^\[((?:[^\]\\]|\\.)*)\]\(\s*<?([^)\s>]*)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
205    if (link) {
206      for (const s of inline(link[1] ?? '', merge(base, S.link))) push(s.text, s.style, link[2] || undefined)
207      i += link[0].length
208      continue
209    }
210    const auto = /^<(https?:\/\/[^>\s]+)>/.exec(rest) ?? /^(https?:\/\/[^\s<]*[^\s<.,;:!?)'"])/.exec(rest)
211    if (auto && (i === 0 || /[\s(]/.test(text[i - 1] ?? ''))) {
212      push(auto[1] ?? '', merge(base, S.link), auto[1])
213      i += auto[0].length
214      continue
215    }
216    const strike = /^~~(?=\S)([\s\S]*?\S)~~/.exec(rest)
217    if (strike) {
218      for (const s of inline(strike[1] ?? '', merge(base, S.del))) push(s.text, s.style, s.href)
219      i += strike[0].length
220      continue
221    }
222    const strong = /^(\*\*|__)(?=\S)([\s\S]*?\S)\1/.exec(rest)
223    if (strong && (ch === '*' || !/\w/.test(text[i - 1] ?? ''))) {
224      for (const s of inline(strong[2] ?? '', merge(base, S.strong))) push(s.text, s.style, s.href)
225      i += strong[0].length
226      continue
227    }
228    const em = /^([*_])(?=[^\s*_])([\s\S]*?[^\s\\])\1(?![*_])/.exec(rest)
229    if (em && (ch === '*' || !/\w/.test(text[i - 1] ?? ''))) {
230      for (const s of inline(em[2] ?? '', merge(base, S.em))) push(s.text, s.style, s.href)
231      i += em[0].length
232      continue
233    }
234    const plain = /^[^\\`[<h~*_]+/.exec(rest)?.[0] ?? ch
235    push(plain, base)
236    i += plain.length
237  }
238
239  return out
240}
241
242// MARK: Wrapping
243
244const width = (spans: Span[]) => spans.reduce((n, s) => n + s.text.length, 0)
245
246const pad = (n: number, style: Style = {}): Span => ({ text: ' '.repeat(Math.max(0, n)), style })
247
248// Greedy word wrap across spans; `first` and `rest` prefix the first and later lines and count toward `max`.
249export const wrap = (spans: Span[], max: number, first: Span[] = [], rest: Span[] = first): Line[] => {
250  const lines: Line[] = []
251  let line: Line = [...first]
252  let used = width(first)
253  let room = Math.max(4, max - used)
254  const flush = () => {
255    const last = line.at(-1)
256    if (last) last.text = last.text.replace(/ +$/, '')
257    lines.push(line)
258    line = [...rest]
259    used = width(rest)
260    room = Math.max(4, max - used)
261  }
262  const add = (text: string, span: Span) => {
263    const last = line.at(-1)
264    if (last && last.style === span.style && last.href === span.href) last.text += text
265    else line.push({ ...span, text })
266    used += text.length
267  }
268  for (const span of spans) {
269    for (const token of span.text.match(/\s+|\S+/g) ?? []) {
270      if (/^\s/.test(token)) {
271        if (used > width(lines.length === 0 ? first : rest) && used < max) add(' ', span)
272        continue
273      }
274      let word = token
275      while (word.length > 0) {
276        const left = max - used
277        if (word.length <= left) {
278          add(word, span)
279          word = ''
280        } else if (word.length > room) {
281          if (left <= 0) flush()
282          const cut = Math.max(1, max - used)
283          add(word.slice(0, cut), span)
284          word = word.slice(cut)
285          if (word) flush()
286        } else {
287          flush()
288        }
289      }
290    }
291  }
292  if (line.length > rest.length || lines.length === 0) flush()
293
294  return lines
295}
296
297// MARK: Code
298
299const KEYWORDS = new Set(
300  (
301    'abstract as async await break case catch class const continue def default defer del delete do elif else enum export extends ' +
302    'false final finally fn for from func function go if impl implements import in interface is lambda let loop match mod mut new nil ' +
303    'none null package pass private protected pub public raise return self static struct super switch this throw true try type typeof ' +
304    'undefined unless until use val var void when where while with yield local then end fi done esac echo'
305  ).split(' '),
306)
307
308const TOKEN =
309  /(\/\/.*|#(?![!\[]).*|--\s.*)|("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|`(?:[^`\\]|\\.)*`?)|(\b0x[\da-f]+\b|\b\d[\d_.]*\b)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)|([^\w\s"'`]+)|(\s+)/gi
310
311const highlight = (line: string, lang: string): Span[] => {
312  const spans: Span[] = []
313  const hashComments = /^(sh|bash|zsh|shell|py|python|rb|ruby|ya?ml|toml|conf|ini|dockerfile|make|r)$/i.test(lang)
314  for (const m of line.matchAll(TOKEN)) {
315    const [text, comment, str, num, call, word, punct] = m
316    let style: Style = S.block
317    if (comment && (comment.startsWith('//') || hashComments || comment.startsWith('--'))) style = CODE.comment
318    else if (str) style = CODE.string
319    else if (num) style = CODE.number
320    else if (call) style = KEYWORDS.has(call) ? CODE.keyword : CODE.call
321    else if (word && KEYWORDS.has(word)) style = CODE.keyword
322    else if (word && /^[A-Z]/.test(word)) style = CODE.type
323    else if (punct && /[=<>!+\-*/%&|^?:]/.test(punct)) style = CODE.operator
324    spans.push({ text, style })
325  }
326
327  return spans
328}
329
330const codeBlock = (lang: string, body: string[], max: number): Line[] => {
331  const bar: Span = { text: '▎ ', style: S.bar }
332  const inner = Math.max(4, max - 2)
333  const out: Line[] = lang ? [[bar, { text: lang.slice(0, inner), style: S.lang }]] : []
334  for (const raw of body.length > 0 ? body : ['']) {
335    // Code keeps its indentation, so it is cut into rows rather than word-wrapped.
336    let row: Span[] = []
337    let used = 0
338    const rows: Span[][] = []
339    for (const s of highlight(raw.replace(/\t/g, '  '), lang)) {
340      let t = s.text
341      while (t.length > 0) {
342        const take = t.slice(0, inner - used)
343        row.push({ ...s, text: take })
344        used += take.length
345        t = t.slice(take.length)
346        if (used >= inner) {
347          rows.push(row)
348          row = []
349          used = 0
350        }
351      }
352    }
353    if (row.length > 0 || rows.length === 0) rows.push(row)
354    out.push(...rows.map(r => [bar, ...r]))
355  }
356
357  return out
358}
359
360// MARK: Tables
361
362const fit = (natural: number[], max: number): number[] => {
363  const widths = natural.map(n => Math.max(1, n))
364  const budget = max - (widths.length - 1) * 3 - 2
365  while (widths.reduce((a, b) => a + b, 0) > budget) {
366    const big = widths.indexOf(Math.max(...widths))
367    if ((widths[big] ?? 0) <= 3) break
368    widths[big] = (widths[big] ?? 0) - 1
369  }
370
371  return widths
372}
373
374const table = (header: string[], align: Align[], rows: string[][], max: number): Line[] => {
375  const columns = Math.max(header.length, ...rows.map(r => r.length))
376  const head = Array.from({ length: columns }, (_, k) => inline(header[k] ?? '', S.th))
377  const body = rows.map(r => Array.from({ length: columns }, (_, k) => inline(r[k] ?? '', S.text)))
378  const widths = fit(
379    Array.from({ length: columns }, (_, k) => Math.max(width(head[k] ?? []), ...body.map(r => width(r[k] ?? [])))),
380    max,
381  )
382  const sep: Span = { text: ' │ ', style: S.bar }
383  const row = (cellsOf: Span[][]): Line[] => {
384    const wrapped = cellsOf.map((c, k) => wrap(c, widths[k] ?? 1))
385    const height = Math.max(1, ...wrapped.map(w => w.length))
386    return Array.from({ length: height }, (_, y) => {
387      const line: Line = [pad(1)]
388      wrapped.forEach((w, k) => {
389        if (k > 0) line.push(sep)
390        const cell = w[y] ?? []
391        const gap = (widths[k] ?? 1) - width(cell)
392        const a = align[k] ?? 'left'
393        const left = a === 'right' ? gap : a === 'center' ? Math.floor(gap / 2) : 0
394        line.push(pad(left), ...cell, pad(gap - left))
395      })
396      return line
397    })
398  }
399  const rule: Line = [{ text: `─${widths.map(w => '─'.repeat(w)).join('─┼─')}─`, style: S.rule }]
400
401  return [...row(head), rule, ...body.flatMap(row)]
402}
403
404// MARK: Layout
405
406const indent = (lines: Line[], first: Span[], rest: Span[]): Line[] => lines.map((l, i) => [...(i === 0 ? first : rest), ...l])
407
408const headingStyle = (level: number): Style => (level === 1 ? S.h1 : level === 2 ? S.h2 : level === 3 ? S.h3 : S.h4)
409
410const layoutBlocks = (blocks: Block[], max: number, base: Style, tight = false): Line[] => {
411  const out: Line[] = []
412  blocks.forEach((b, n) => {
413    if (n > 0 && !tight) out.push([])
414    out.push(...layoutBlock(b, max, base))
415  })
416
417  return out
418}
419
420const layoutBlock = (b: Block, max: number, base: Style): Line[] => {
421  switch (b.kind) {
422    case 'heading':
423      return wrap(
424        inline(b.text, headingStyle(b.level)).map(s => ({ ...s, style: { ...s.style, ...headingStyle(b.level) } })),
425        max,
426        [{ text: `${'#'.repeat(b.level)} `, style: { ...headingStyle(b.level), bold: false, underline: false } }],
427        [pad(b.level + 1)],
428      )
429    case 'paragraph':
430      return wrap(inline(b.text, base), max)
431    case 'rule':
432      return [[{ text: '─'.repeat(max), style: S.rule }]]
433    case 'code':
434      return codeBlock(b.lang, b.lines, max)
435    case 'table':
436      return table(b.header, b.align, b.rows, max)
437    case 'quote': {
438      const bar: Span = { text: '│ ', style: S.bar }
439      return indent(layoutBlocks(b.blocks, max - 2, S.quote), [bar], [bar])
440    }
441    case 'list': {
442      const loose = b.items.some(it => it.blocks.length > 1 && it.blocks.some(x => x.kind === 'paragraph') && it.blocks.filter(x => x.kind === 'paragraph').length > 1)
443      const out: Line[] = []
444      b.items.forEach((it, n) => {
445        if (n > 0 && loose) out.push([])
446        const marker: Span[] =
447          it.task !== undefined
448            ? [{ text: it.task ? '✓ ' : '☐ ', style: it.task ? S.done : S.todo }]
449            : b.ordered
450              ? [{ text: `${it.marker} `, style: S.number }]
451              : [{ text: '• ', style: S.bullet }]
452        const w = width(marker)
453        const itemBase = it.task ? { ...base, ...S.del } : base
454        const lines = layoutBlocks(it.blocks, max - w, itemBase, true)
455        out.push(...indent(lines.length > 0 ? lines : [[]], marker, [pad(w)]))
456      })
457      return out
458    }
459  }
460}
461
462/** The lines a markdown text draws as, each at most `max` columns wide. */
463export const richLines = (markdown: string, max: number): Line[] => layoutBlocks(parse(markdown), Math.max(12, max), S.text)
464
hooks/text.ts 209 lines
1export const slug =(text: string, max = 40): string =>
2  text
3    .toLowerCase()
4    .replace(/[^a-z0-9]+/g, '-')
5    .replace(/^-+|-+$/g, '')
6    .slice(0, max)
7    .replace(/-+$/, '') || 'untitled'
8
9export const clip = (text: string, max: number): string => {
10  const flat = text.replace(/\s+/g, ' ').trim()
11
12  return flat.length <= max ? flat : `${flat.slice(0, max - 1).trimEnd()}…`
13}
14
15// Unlike clip, keeps newlines so a markdown preview still renders as markdown.
16export const head = (text: string, max: number): string =>
17  text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`
18
19const cells = (row: string) =>
20  row
21    .trim()
22    .replace(/^\||\|$/g, '')
23    .split(/(?<!\\)\|/)
24    .map(c => c.trim())
25
26const isRule = (row: string) => /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(row) && row.includes('-')
27
28// A table wider than the pane wraps into an unreadable grid, so the preview restacks it as a list.
29// With `minCell`, the drawing wraps cells itself, so only a table that can't give each column that much is restacked.
30export const narrowTables = (markdown: string, width: number, minCell?: number): string => {
31  const lines = markdown.split('\n')
32  const out: string[] = []
33  let isFenced = false
34  for (let i = 0; i < lines.length; i++) {
35    const line = lines[i] ?? ''
36    if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
37    if (isFenced || !line.includes('|') || !isRule(lines[i + 1] ?? '')) {
38      out.push(line)
39      continue
40    }
41    let end = i + 2
42    while (end < lines.length && (lines[end] ?? '').includes('|') && (lines[end] ?? '').trim()) end++
43    const header = cells(line)
44    const rows = lines.slice(i + 2, end).map(cells)
45    const widths = header.map((h, k) => Math.max(h.length, ...rows.map(r => (r[k] ?? '').length)))
46    if (widths.reduce((sum, w) => sum + (minCell ? Math.min(w, minCell) : w) + 3, 1) <= width) {
47      out.push(...lines.slice(i, end))
48    } else {
49      for (const row of rows) {
50        out.push(`- ${row[0] ?? ''}`)
51        for (let k = 1; k < header.length; k++) if (row[k]) out.push(`  - *${header[k]}*: ${row[k]}`)
52      }
53    }
54    i = end - 1
55  }
56
57  return out.join('\n')
58}
59
60export const ago =(at: number, now: number): string => {
61  const seconds = Math.max(0, Math.round((now - at) / 1000))
62  if (seconds < 45) return 'now'
63  const minutes = Math.round(seconds / 60)
64  if (minutes < 60) return `${minutes}m ago`
65  const hours = Math.round(minutes / 60)
66  if (hours < 24) return `${hours}h ago`
67
68  return `${Math.round(hours / 24)}d ago`
69}
70
71const pad = (n: number) => String(n).padStart(2, '0')
72
73export const day = (at: number): string => {
74  const d = new Date(at)
75
76  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
77}
78
79export const stamp = (at: number): string => {
80  const d = new Date(at)
81
82  return `${day(at)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
83}
84
85export const basename = (path: string): string => path.replace(/\/+$/, '').split('/').pop() ?? path
86
87// Models wrap JSON in fences or prose often enough that a strict parse loses turns.
88export const parseObject = (text: string): Record<string, unknown> | null => {
89  const start = text.indexOf('{')
90  const end = text.lastIndexOf('}')
91  if (start < 0 || end <= start) return null
92  try {
93    const value: unknown = JSON.parse(text.slice(start, end + 1))
94
95    return value !== null && typeof value === 'object' && !Array.isArray(value)
96      ? (value as Record<string, unknown>)
97      : null
98  } catch {
99    return null
100  }
101}
102
103export const strings = (value: unknown, max: number, each = 100): string[] =>
104  Array.isArray(value)
105    ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '').map(v => clip(v, each)).slice(0, max)
106    : []
107
108const str = (value: unknown): string => (typeof value === 'string' ? value : '')
109
110// Older state.json files stored needs-you items as bare strings.
111type RawAsk = { question: string; context?: unknown; options?: unknown; optionNotes?: unknown }
112type RawOption = { label: string; description?: unknown }
113
114const text = (value: unknown, max: number) => (typeof value === 'string' ? clip(value, max) : '')
115
116// Accepts every shape this has had: bare strings, string options, and {label, description} options.
117export const asks = (value: unknown): { question: string; context: string; options: string[]; optionNotes: string[] }[] =>
118  Array.isArray(value)
119    ? value
120        .map(v => (typeof v === 'string' ? { question: v } : v))
121        .filter((v): v is RawAsk => typeof v?.question === 'string' && v.question.trim() !== '')
122        .slice(0, 4)
123        .map(v => {
124          const notes = Array.isArray(v.optionNotes) ? v.optionNotes : []
125          const options = (Array.isArray(v.options) ? v.options : [])
126            .map((o, i): RawOption | null =>
127              typeof o === 'string' ? { label: o, description: notes[i] } : typeof o?.label === 'string' ? o : null,
128            )
129            .filter((o): o is RawOption => o !== null && o.label.trim() !== '')
130            .slice(0, 4)
131          return {
132            question: clip(v.question, 160),
133            context: text(v.context, 400),
134            options: options.map(o => clip(o.label, 80)),
135            optionNotes: options.map(o => text(o.description, 160)),
136          }
137        })
138    : []
139
140export const DONE_TITLE = 40
141
142// One turn's results as a done entry; a single result short enough to be the title needs no list under it.
143export const doneOf = (title: unknown, items: string[], at: number): { title: string; items: string[]; at: number } | null => {
144  const named = typeof title === 'string' ? clip(title, DONE_TITLE) : ''
145  const first = items[0]
146  if (!named && first === undefined) return null
147  if (!named && items.length === 1 && first !== undefined && first.length <= DONE_TITLE) return { title: first, items: [], at }
148
149  return { title: named || clip(first ?? '', DONE_TITLE), items, at }
150}
151
152// Older state.json files stored one {text, at} per result.
153export const dones = (value: unknown): { title: string; items: string[]; at: number }[] =>
154  Array.isArray(value)
155    ? value.flatMap(v => {
156        const at = typeof v?.at === 'number' ? v.at : 0
157        const done = typeof v?.text === 'string' ? doneOf(null, [clip(v.text, 100)], at) : doneOf(v?.title, strings(v?.items, 6, 100), at)
158        return done ? [done] : []
159      })
160    : []
161
162export const parseStatus =(out: string) => {
163  const [first = '', ...rest] = out.split('\n')
164  const header = first.replace(/^## /, '')
165  const [branchPart = '', trackPart = ''] = header.split('...')
166  const branch = branchPart.replace(/^No commits yet on /, '').replace(/ \[.*$/, '')
167
168  return {
169    branch: branch === 'HEAD (no branch)' ? 'detached' : branch,
170    hasUpstream: trackPart !== '',
171    ahead: Number(/ahead (\d+)/.exec(header)?.[1] ?? 0),
172    behind: Number(/behind (\d+)/.exec(header)?.[1] ?? 0),
173    changed: rest.filter(line => line.trim() !== '').length,
174  }
175}
176
177// `cd <dir> && …` is how most commands reach another repo, so it marks that repo as touched.
178export const cdTarget = (command: string, home: string): string | null => {
179  const match = /^\s*cd\s+("([^"]+)"|'([^']+)'|(\S+))/.exec(command)
180  const raw = match?.[2] ?? match?.[3] ?? match?.[4]
181  if (!raw) return null
182  const dir = raw.replace(/^~(?=\/|$)/, home).replace(/\/+$/, '')
183
184  return dir.startsWith('/') ? dir : null
185}
186
187export const toolLabel = (tool: string, input: Record<string, unknown>): string => {
188  const file = str(input.file_path) || str(input.notebook_path)
189  switch (tool) {
190    case 'Bash':
191      return `Bash: ${clip(str(input.description) || str(input.command), 48)}`
192    case 'Read':
193    case 'Edit':
194    case 'Write':
195    case 'NotebookEdit':
196      return `${tool} ${basename(file)}`
197    case 'Grep':
198    case 'Glob':
199      return `${tool} ${clip(str(input.pattern), 40)}`
200    case 'Agent':
201      return `Agent: ${clip(str(input.description), 44)}`
202    case 'WebFetch':
203    case 'WebSearch':
204      return `${tool} ${clip(str(input.url) || str(input.query), 40)}`
205    default:
206      return tool.startsWith('mcp__') ? tool.split('__').slice(1).join(' ') : tool
207  }
208}
209
types/index.d.ts 114 lines
1export type Note = {
2  id: string
3  title: string
4  file: string
5  at: number
6  isPinned: boolean
7}
8
9export type Task = { title: string; at: number }
10
11export type Attempt = { text: string; isOk: boolean }
12
13export type Ask = { question: string; context: string; options: string[]; optionNotes: string[] }
14
15export type Repo = {
16  root: string
17  branch: string
18  changed: number
19  ahead: number
20  behind: number
21  hasUpstream: boolean
22}
23
24export type Stamped = { text: string; at: number }
25
26// One per turn: a one-line title, with what changed listed under it.
27export type Done = { title: string; items: string[]; at: number }
28
29export type Crumbs = {
30  tasks: Task[]
31  prompts: Stamped[]
32  activity: string | null
33  lastSaid: Stamped | null
34  notes: Note[]
35  decided: string[]
36  tried: Attempt[]
37  needsYou: Ask[]
38  done: Done[]
39  edited: string[]
40  touched: string[]
41  repos: Repo[]
42  asking: string | null
43}
44
45export type Where = {
46  repo: string
47  branch: string
48  worktree: string
49}
50
51export type View = {
52  openNote: string | null
53  isShowingPrompts: boolean
54  isShowingMore: boolean
55  isShowingStatus: boolean
56  openDone: number | null
57  picked: Record<string, string>
58  typed: Record<string, string>
59  details: Record<string, string>
60  expanded: string | null
61}
62
63export type TestStatus = 'todo' | 'running' | 'passed' | 'failed' | 'blocked' | 'retest'
64
65export type Entry = { from: 'you' | 'tester' | 'claude'; text: string; at: number }
66
67export type ManualTest = {
68  id: string
69  title: string
70  steps: string[]
71  expect: string
72  watch: string
73  status: TestStatus
74  agentId: string | null
75  log: Entry[]
76  // The tester's open question to the planner; absent on tests saved before it existed.
77  asking?: string | null
78  // What makes the expected signal happen; absent on older tests, as are the two below.
79  trigger?: string
80  assumes?: string[]
81  needs?: string[]
82  // The tester's model for this test, over the testerModel setting.
83  model?: string
84}
85
86export type Wait = {
87  id: string
88  testId: string | null
89  step: number | null
90  instruction: string
91  // Absent on waits from before these fields existed.
92  steps?: string[]
93  options?: string[]
94  at: number
95  answer: string | null
96}
97
98export type TestRun = { brief: string; tests: ManualTest[]; waits: Wait[]; unreported: string[] }
99
100export type TestsView = { expanded: string | null; typed: Record<string, string> }
101
102declare module 'claude-code' {
103  interface PluginState {
104    breadcrumbs: {
105      crumbs: Crumbs
106      where: Where | null
107      view: View
108      pending: string | null
109      tests: TestRun
110      testsView: TestsView
111    }
112  }
113}
114