SLOPSHOPPER

toc

A table of contents for long Claude Code work: a sidebar with the project's knowledge files, the DEV_PLAN.md progress tree, and each step's commits, files and…

newpanebandguardcommandtoast
v0.1.0GPL-3.0-onlyupdated 2026-10-12aksholokhov/claude-toc
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · toc
│ ┃ toc ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ toc: run /project <dir> │ toc │ │ ⏺ Read(src/auth.ts) │ toc: no DEV_PLAN.md here; run /project │ │ ⎿ Read 6 lines │ <dir> │ │ ⏺ 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 │ │ › /toc │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · toc
toc: run /project <dir>
README

<img src="docs/media/banner.svg" alt="claude-toc: a table of contents for long Claude Code sessions" width="100%">

<img src="docs/media/walkthrough.gif" alt="A 40-second walkthrough: moving through the plan, briefs in Preview, drilling into a step's commit and its diff, delta and lazygit beside Claude, the key legend" width="100%">

delta and lazygit beside Claude, and back. toc itself never calls a model to do any of this.</sub></p>

Long Claude Code sessions lose the plot. Three hours in, the knowledge is spread over a SPEC, a plan, a findings file and forty commits, and neither you nor Claude can say at a glance which step you are on or what it produced.

toc is a sidebar inside Claude Code that answers exactly that. It reads the files — never a model — so it can't drift: where the project's knowledge lives, how far DEV_PLAN.md has got, and which commits and files each step produced.

┤ What it shows ├

┌─┤ THE FIVE SECTIONS ├───────────────────────────────────────────
│ CONTEXT    the knowledge files and their age; missing ones dim
│ PLAN       milestones → steps   ✓ done  ◐ doing  ○ todo  ✗ dropped
│            ⇡ commits per step · ☐ acceptance still open
│ ARTIFACTS  the step's notes, its commits (unfold to files), and the
│            files it touched but hasn't committed yet:  A  M  ✓
│ PREVIEW    the selected diff, or the step's own lines of the plan
│            — its brief — wrapped and paged
│ KEYS       folded to one line until you want them
└─────────────────────────────────────────────────────────────────

Commits find their step through a Plan-Step: git trailer that toc adds to Claude's git commit while a step is in progress; files find it through a small ledger of what Claude wrote, edited or changed from Bash. Nothing to remember, nothing to tag by hand.

┤ Look deeper ├

<table> <tr> <td width="50%"><img src="docs/media/plan-brief.png" alt="The plan with a milestone's brief in Preview"><br><sub><b>The plan and its brief.</b> Moving through milestones and steps shows each one's own lines of DEV_PLAN.md — its why, deliverables and acceptance — with the file's line numbers.</sub></td> <td width="50%"><img src="docs/media/diff-preview.png" alt="A step's commit unfolded to files, with the file's diff in Preview"><br><sub><b>A step's work.</b> Its commits unfold to files; <code>l</code> on one pages through exactly that file's diff, wrapped to the sidebar.</sub></td> </tr> <tr> <td><img src="docs/media/delta.png" alt="The whole commit in delta, left of Claude"><br><sub><b>Read a change properly.</b> <code>⏎</code> on a commit opens it in your pager (delta here) in the left half of Claude's tmux pane; <code>q</code> hands the keys back to the sidebar.</sub></td> <td><img src="docs/media/lazygit.png" alt="lazygit filtered to one file's history"><br><sub><b>Or its history.</b> <code>v</code> opens lazygit there, filtered to the file under the cursor.</sub></td> </tr> <tr> <td><img src="docs/media/sidebar-plan.png" alt="The sidebar alone: context, plan, artifacts, preview" width="100%"></td> <td><img src="docs/media/sidebar-artifacts.png" alt="The sidebar alone, a commit's file diff in Preview" width="100%"></td> </tr> </table>

┤ Install ├

claude plugin marketplace add aksholokhov/claude-toc
claude plugin install toc@claude-toc

Then once, in a session: /toc keys. It adds Ctrl+X T and the optional Alt+j/k/h/l to ~/.claude/keybindings.json, and never overrides a key you bound yourself.

  • Claude Code ≥ 2.1.287 with the fullscreen renderer ("tui": "fullscreen") for the docked sidebar; narrower windows get it above the prompt. Built and tested on v2.1.296.
  • tmux + nvim to open files beside Claude; delta and lazygit for the diff and history views (plain less works without delta).
  • Runs on a Claude Pro or Max login: toc is a mod, and the only model call it ever makes is the optional plan repair (one Haiku run, when you press y).

┤ Use ├

/project [dir] points toc at a project folder — it scaffolds a DEV_PLAN.md if there isn't one and remembers the folder. Claude can do the same itself (the mcp__toc__activate tool) — the bundled skill tells it to, when you ask it to write a dev plan and start toc.

┌─┤ KEYS ├────────────────────────────────────────────────────────
│ ^X T    focus the sidebar · again to close    q · Esc   leave
│ 
│ j k     move; flows from one section into the next
│ f b     page                       g e   top · bottom
│ 1 … 5   jump to a section          z     zoom a section
│ l h     drill in / back out: step → Artifacts → Preview
│         (and unfold / fold a milestone or a commit)
│ 
│ o ⏎     open: a file in nvim, a commit as its diff
│ d       the change, in delta       v     its history, in lazygit
│ p       this step in DEV_PLAN.md   5     these keys
│ 
│ ⌥j ⌥k  page   ⌥h ⌥l  section       (terminals that send Alt)
└─────────────────────────────────────────────────────────────────

┤ The plan ├

A DEV_PLAN.md is an engineering document written for people — why, scope, milestones with deliverables, acceptance criteria and tests, open decisions, definition of done — of which toc reads a small, fixed part. The bundled toc:dev-plan skill teaches Claude to write it well, with the syntax last. This repo's own DEV_PLAN.md is a full example.

## Milestones
### M2 — What this milestone delivers
Why it exists: what it proves, the risk it retires.     ← the brief

**Deliverables**
- [x] S2.1 Short title — one sentence: the artifact
  indented detail
- [~] S2.2 The one step in progress — …

**Acceptance**
- [ ] A binary, testable statement.        ← counted (☐2/3), never ID'd

**Tests**
1. How it is demonstrated.

Step IDs are stable (commits point at them), at most one step is [~], and a milestone is done only when its steps are done and its acceptance is met. If the file stops parsing, the sidebar offers Repair? y/n and a Haiku subagent fixes it, after backing the file up. Full grammar: SPEC.md §5.

┤ How it's built ├

A Claude Code mod: a plugin whose hooks/register.ts holds every call to the host API, over pure, unit-tested modules in hooks/lib/ (the plan parser, git and diff parsing, the sidebar's state and drawing). SPEC.md is the design with its decision log, DEV_PLAN.md the plan this sidebar was built with, KNOWLEDGE.md the platform facts learned the hard way.

claude --plugin-dir .          # run from the working tree (edits hot-reload)
claude plugin validate .       # static checks
claude plugin test             # unit + pane tests, no session needed

┤ License ├

GPL-3.0.

Source 10 files
hooks/register.ts 838 lines
1// claude-toc: the hooks module. Everything that touches `$` lives here (the
2// mods validator only follows `$` into top-level functions of this file);
3// hooks/lib/* is pure and unit-tested.
4
5import { parseDiff } from './lib/diff.ts'
6import {
7  aliveArgs, diffViewArgs, isPaneAlive, lazygitArgs, nvimArgs, leftSplitArgs, paneAliveArgs, parseWindowId, remoteTabArgs, respawnArgs, selectPaneArgs, socketPath,
8  tmuxInfoArgs, viewerEnv,
9} from './lib/editor.ts'
10import type { Spawn, ViewTarget } from './lib/editor.ts'
11import {
12  changedBetween, commitsByStep, diffArgs, fileState, logArgs, parseLog, parseStatus, relativeTo, rootArgs, showArgs, statusArgs,
13  supportsTrailer, withTrailer,
14} from './lib/git.ts'
15import type { Snapshot } from './lib/git.ts'
16import { appendEntry, editedPath, filesByStep, LEDGER_DIR, LEDGER_FILE, parseLedger, WRITE_TOOLS } from './lib/ledger.ts'
17import {
18  PLAN, PREV, allocate, fileRowIndex, focusRow, initialUI, isShown, joinPath, planSignature,
19  press, previewCosts, previewRowCount, previewTarget, reconcile, rowsOf, targetKey,
20} from './lib/model.ts'
21import type { ArtFile, CtxFile, Data, Effect, Key, Preview, Repair, Target, UI } from './lib/model.ts'
22import { parseMode, rolesFor } from './lib/palette.ts'
23import { activeStep, errorsOf, orphanTrailers, parsePlan } from './lib/plan.ts'
24import { REPAIR_SYSTEM, devPlanTemplate, mergeKeybindings, repairTask, rulesBlock, transcriptExcerpt } from './lib/text.ts'
25import { ACTIONS, drawnOrder, isRepairAsk, legendRows, render } from './lib/view.ts'
26
27const PANE = 'toc'
28const PLAN_FILE = 'DEV_PLAN.md'
29const DEFAULT_CONTEXT = ['SPEC.md', 'DEV_PLAN.md', 'KNOWLEDGE.md', 'SCRATCHPAD.md']
30const COLUMNS = 42
31const REPAIR_AGENT = 'toc:repair'
32
33// ── session state (module-level; lost on reload, rebuilt by refresh) ────
34let base: string | null = null
35let root: string | null = null
36let home = ''
37let data: Data | null = null
38let ui: UI = initialUI()
39let repair: Repair = null
40let repairAgent: string | null = null
41let preview: Preview | null = null
42let previewWanted = ''
43let roles = rolesFor(null)
44let stamp = ''
45let width = COLUMNS
46let bodyRows = 30
47let lastFocus: string | null = null
48let refreshing = false
49let again = false
50let trailerOk: boolean | null = null
51
52// ── small helpers ───────────────────────────────────────────────────────
53
54async function run($, argv: string[], cwd?: string): Promise<{ ok: boolean; out: string; err: string }> {
55  try {
56    const r = await $.process.run(argv, { cwd: cwd ?? base ?? undefined, timeoutMs: 15000 })
57    return { ok: r.exitCode === 0, out: r.stdout, err: r.stderr }
58  } catch (err) {
59    return { ok: false, out: '', err: String(err) }
60  }
61}
62
63async function readText($, path: string): Promise<string | null> {
64  try {
65    return await $.fs.read(path)
66  } catch {
67    return null
68  }
69}
70
71async function mtime($, path: string): Promise<number> {
72  try {
73    if (!(await $.fs.exists(path))) return 0
74    return (await $.fs.stat(path)).mtimeMs
75  } catch {
76    return 0
77  }
78}
79
80function abs(dir: string, p: string): string {
81  return joinPath(dir, p)
82}
83
84function under(dir: string, p: string): boolean {
85  const d = dir.endsWith('/') ? dir : dir + '/'
86  return p === dir || p.startsWith(d)
87}
88
89function counts(): number[] {
90  if (!data) return [0, 0, 0, 0, 0]
91  return [0, 1, 2].map((s) => rowsOf(s, ui, data!, repair).length).concat([previewRowCount(preview), 0])
92}
93
94function alloc(): number[] {
95  return allocate(ui, counts(), bodyRows, data ? legendRows(width - 1, isRepairAsk(repair, data)).length : undefined)
96}
97
98// ── data ────────────────────────────────────────────────────────────────
99
100async function stampOf($): Promise<string> {
101  if (!base) return ''
102  const parts = [
103    await mtime($, abs(base, PLAN_FILE)),
104    await mtime($, abs(base, LEDGER_FILE)),
105    root ? await mtime($, abs(root, '.git/index')) : 0,
106    root ? await mtime($, abs(root, '.git/HEAD')) : 0,
107    root ? await mtime($, abs(root, '.git/logs/HEAD')) : 0,
108    home ? await mtime($, home + '/.config/theme/mode') : 0,
109  ]
110  for (const name of data?.context.map((c) => c.path) ?? []) parts.push(await mtime($, name))
111  return parts.join(':')
112}
113
114async function refresh($) {
115  if (!base) return
116  if (refreshing) {
117    again = true
118    return
119  }
120  refreshing = true
121  try {
122    do {
123      again = false
124      await load($)
125    } while (again)
126  } finally {
127    refreshing = false
128  }
129}
130
131async function load($) {
132  const b = base!
133  const planPath = abs(b, PLAN_FILE)
134  const text = await readText($, planPath)
135  const plan = text === null ? null : parsePlan(text)
136
137  const names = plan?.context ?? DEFAULT_CONTEXT
138  const context: CtxFile[] = []
139  for (const name of names) {
140    const path = abs(b, name)
141    const m = await mtime($, path)
142    context.push({ name, path, exists: m > 0, mtimeMs: m })
143  }
144
145  let commits = new Map()
146  let status = new Map<string, string>()
147  if (root) {
148    const log = await run($, logArgs(root), root)
149    if (log.ok) commits = commitsByStep(parseLog(log.out))
150    const st = await run($, statusArgs(root), root)
151    if (st.ok) status = parseStatus(st.out)
152  }
153  if (plan) plan.issues.push(...orphanTrailers(plan, commits.keys()))
154
155  const ledger = parseLedger((await readText($, abs(b, LEDGER_FILE))) ?? '')
156  const files = new Map<string, ArtFile[]>()
157  for (const [step, paths] of filesByStep(ledger)) {
158    const list: ArtFile[] = []
159    for (const path of paths) {
160      const exists = (await mtime($, path)) > 0
161      const rel = root ? relativeTo(root, path) : relativeTo(b, path)
162      list.push({ path, rel, state: root ? fileState(status.get(rel), exists) : exists ? 'M' : 'D' })
163    }
164    files.set(step, list)
165  }
166
167  roles = rolesFor(home ? parseMode(await readText($, home + '/.config/theme/mode')) : null)
168  data = { base: b, root, plan, planPath, context, commits, files, now: await $.clock.now() }
169
170  const sig = planSignature(plan)
171  const broken = errorsOf(plan).length > 0
172  if (!broken) repair = repair && repair.phase === 'running' ? repair : null
173  else if (!repair || (repair.phase === 'dismissed' && repair.sig !== sig)) repair = { phase: 'ask', sig }
174
175  ui = reconcile(ui, data, repair)
176  stamp = await stampOf($)
177  await loadPreview($)
178  $.ui.invalidate('ui.render')
179}
180
181async function loadPreview($) {
182  if (!data) return
183  const t = previewTarget(ui, data)
184  if (!t) {
185    preview = null
186    return
187  }
188  const key = targetKey(t)
189  previewWanted = key
190  if (preview && preview.key === key) {
191    if (t.kind === 'cfile') ui = { ...ui, previewOffset: fileRowIndex(preview, t.path) }
192    return
193  }
194  const p = await fetchPreview($, t, key)
195  if (previewWanted !== key) return
196  preview = p
197  ui = { ...ui, previewOffset: 0 }
198  if (t.kind === 'cfile') ui = { ...ui, previewOffset: fileRowIndex(p, t.path) }
199}
200
201async function fetchPreview($, t: Target, key: string): Promise<Preview> {
202  const r = root ?? base!
203  if (t.kind === 'brief') {
204    const text = data ? await readText($, data.planPath) : null
205    if (text === null) return { key, files: null, source: null, path: PLAN_FILE, error: 'cannot read ' + PLAN_FILE }
206    const lines = text.replace(/\r\n?/g, '\n').split('\n').slice(t.from - 1, t.to)
207    return { key, files: null, source: lines.join('\n'), path: PLAN_FILE, firstLine: t.from }
208  }
209  if (t.kind === 'commit' || t.kind === 'cfile') {
210    const show = await run($, showArgs(r, t.sha), r)
211    return show.ok ? { key, files: parseDiff(show.out), source: null, path: '' } : { key, files: null, source: null, path: '', error: 'git show failed' }
212  }
213  if (t.state === 'A' || !root) {
214    const text = await readText($, t.path)
215    return text === null ? { key, files: null, source: null, path: t.rel, error: 'cannot read ' + t.rel } : { key, files: null, source: text, path: t.rel }
216  }
217  if (t.state === '✓') {
218    const text = await readText($, t.path)
219    return { key, files: null, source: text ?? '', path: t.rel }
220  }
221  const diff = await run($, diffArgs(root, t.rel), root)
222  return { key, files: diff.ok ? parseDiff(diff.out) : [], source: null, path: t.rel, error: diff.ok ? undefined : 'git diff failed' }
223}
224
225async function poll($) {
226  if (!base || refreshing) return
227  const s = await stampOf($)
228  if (s !== stamp) await refresh($)
229}
230
231// ── ledger ──────────────────────────────────────────────────────────────
232
233async function activeStepId($): Promise<string | null> {
234  if (!base) return null
235  const text = await readText($, abs(base, PLAN_FILE))
236  const step = text ? activeStep(parsePlan(text)) : null
237  return step ? step.id : null
238}
239
240async function record($, paths: string[]) {
241  if (!base || !paths.length) return
242  const scope = root ?? base
243  // Knowledge files have their own section; logging them would only fill
244  // the unattributed bucket with plan edits.
245  const known = new Set((data?.context ?? []).map((c) => c.path).concat(abs(base, PLAN_FILE)))
246  const keep = paths.filter((p) => under(scope, p) && !under(abs(base!, LEDGER_DIR), p) && !known.has(p))
247  if (!keep.length) return
248  const step = await activeStepId($)
249  const ledgerPath = abs(base, LEDGER_FILE)
250  let text = (await readText($, ledgerPath)) ?? ''
251  const ts = await $.clock.now()
252  const session = await $.session.id()
253  for (const path of keep) text = appendEntry(text, { ts, path, step, session })
254  await $.fs.write(ledgerPath, text)
255}
256
257// git status plus mtimes: what a Bash command changed shows up as a new
258// entry or a moved mtime.
259async function snapshot($): Promise<Snapshot | null> {
260  if (!root) return null
261  const st = await run($, statusArgs(root), root)
262  if (!st.ok) return null
263  const snap: Snapshot = new Map()
264  for (const [rel, code] of parseStatus(st.out)) snap.set(rel, code + ' ' + (await mtime($, abs(root, rel))))
265  return snap
266}
267
268// ── activation & scaffolding ────────────────────────────────────────────
269
270// ui.log already prefixes the plugin name; activate's text is also read
271// where it doesn't (/project, the tool result), so drop ours only here.
272const headline = (msg: string) => msg.split('\n')[0].replace(/^toc: /, '')
273
274async function activate($, dir: string, opts: { create: boolean }): Promise<string> {
275  const cwd = await $.session.cwd()
276  const target = abs(cwd, dir || '.').replace(/\/\.$/, '').replace(/\/$/, '') || '/'
277  let isDir = false
278  try {
279    isDir = (await $.fs.stat(target)).kind === 'dir'
280  } catch {
281    isDir = false
282  }
283  if (!isDir) return `toc: ${target} is not a directory`
284
285  const planPath = abs(target, PLAN_FILE)
286  const hasPlan = await $.fs.exists(planPath)
287  if (!hasPlan && !opts.create) return `toc: no ${PLAN_FILE} in ${target}`
288
289  base = target
290  const top = await run($, rootArgs(target), target)
291  root = top.ok ? top.out.trim() : null
292
293  const notes: string[] = []
294  if (!hasPlan) {
295    await $.fs.write(planPath, devPlanTemplate(target.split('/').pop() || 'project'))
296    notes.push('created ' + PLAN_FILE)
297  }
298  const ledgerPath = abs(target, LEDGER_FILE)
299  if (!(await $.fs.exists(ledgerPath))) await $.fs.write(ledgerPath, '')
300  if (root) {
301    const ex = await run($, ['git', '-C', root, 'rev-parse', '--git-path', 'info/exclude'], root)
302    if (ex.ok) {
303      const exPath = abs(root, ex.out.trim())
304      const rel = relativeTo(root, abs(target, LEDGER_DIR))
305      const pattern = '/' + rel.replace(/^\/+/, '') + '/'
306      const cur = (await readText($, exPath)) ?? ''
307      if (!cur.split('\n').includes(pattern)) {
308        await $.fs.write(exPath, cur + (cur && !cur.endsWith('\n') ? '\n' : '') + pattern + '\n')
309        notes.push('excluded ' + pattern + ' from git')
310      }
311    }
312  }
313  const map = ((await $.store.get('bases')) ?? {}) as Record<string, string>
314  if (map[cwd] !== target) await $.store.set('bases', { ...map, [cwd]: target })
315
316  ui = initialUI()
317  preview = null
318  repair = null
319  await refresh($)
320  return ['toc: showing ' + target + (notes.length ? ' (' + notes.join(', ') + ')' : ''), '', rulesBlock(planPath)].join('\n')
321}
322
323async function openPane($, focus: boolean) {
324  // rows only matters inline (narrow terminal): a third of the screen is too
325  // little for a plan.
326  const pane = { id: PANE, title: 'toc', columns: COLUMNS, rows: 26 }
327  await $.ui.open(focus ? { ...pane, focus: true } : pane)
328}
329
330async function togglePane($) {
331  const panes = await $.ui.panes()
332  const mine = panes.find((p) => p.id === PANE)
333  if (mine && mine.isShown && mine.isFocused) {
334    await $.ui.close({ id: PANE })
335    return
336  }
337  if (!base) {
338    const msg = await activate($, '.', { create: false })
339    if (!base) {
340      $.ui.toast('toc: no DEV_PLAN.md here; run /project <dir>')
341      return
342    }
343    $.ui.log(headline(msg))
344  }
345  await openPane($, true)
346}
347
348// The band click: open it when it's away, put it away when it's up.
349async function clickPane($) {
350  const mine = (await $.ui.panes()).find((p) => p.id === PANE)
351  if (mine && mine.isShown) return $.ui.close({ id: PANE })
352  return togglePane($)
353}
354
355async function installKeys($): Promise<string> {
356  if (!home) return 'toc: HOME is not set'
357  const path = home + '/.claude/keybindings.json'
358  const cur = await readText($, path)
359  try {
360    const merged = mergeKeybindings(cur)
361    if (merged.added.length) await $.fs.write(path, merged.text)
362    const lines = []
363    lines.push(merged.added.length ? 'toc: added ' + merged.added.join(', ') + ' to ' + path : 'toc: keybindings already in place')
364    if (merged.conflicts.length) lines.push('toc: left alone (already bound): ' + merged.conflicts.join('; '))
365    return lines.join('\n')
366  } catch (err) {
367    return 'toc: ' + String((err as Error).message ?? err)
368  }
369}
370
371// ── keys & effects ──────────────────────────────────────────────────────
372
373// `before`: the state Preview was last loaded for, when the caller moved the
374// cursor itself first (a click).
375async function handleKey($, key: Key, surface?: string, before: UI = ui) {
376  if (!data) return
377  const r = press(ui, key, { data, repair, alloc: alloc(), previewCosts: previewCosts(preview, width) })
378  ui = r.ui
379  if (shownKey(before) !== shownKey(ui)) await loadPreview($)
380  $.ui.invalidate('ui.render')
381  await effect($, r.effect, surface)
382  syncRing($)
383}
384
385// What Preview shows for this state; a change means reload it. A commit and
386// its files share one fetched diff (targetKey), but each file is its own
387// position in it, so the file is part of what's shown.
388function shownKey(u: UI): string {
389  const t = data ? previewTarget(u, data) : null
390  return !t ? '' : t.kind === 'cfile' ? targetKey(t) + ':' + t.path : targetKey(t)
391}
392
393// Keep the focus ring on the cursor row after our own moves.
394function syncRing($) {
395  const sec = ui.sec
396  const key = sec === PREV || ui.cursor[sec] === null ? 'h:' + sec : ui.cursor[sec]
397  if (key === lastFocus) return
398  lastFocus = key
399  $.clock.after(40, () => {
400    $.ui.focus({ requestId: PANE, key: 'r:' + key }).catch(() => undefined)
401  })
402}
403
404async function effect($, e: Effect, surface?: string) {
405  if (e.kind === 'open') return openInEditor($, e.path, surface, e.line)
406  if (e.kind === 'view') return viewDiff($, e.target, surface)
407  if (e.kind === 'history') return history($, e.path)
408  if (e.kind === 'repair') return e.yes ? startRepair($) : dismissRepair($)
409  if (e.kind === 'close') return $.ui.close({ id: PANE })
410  if (e.kind === 'scaffold') {
411    const msg = await activate($, base ?? '.', { create: true })
412    $.ui.log(headline(msg))
413  }
414}
415
416// Since 2.1.29x the session runs under a daemon's pty host, which doesn't
417// inherit TMUX/TMUX_PANE. Fall back to asking the default tmux server for
418// the pane its most recent client is on: the one the key was just pressed in.
419async function tmuxPane($): Promise<string | null> {
420  const fromEnv = await $.env.get('TMUX_PANE')
421  if (fromEnv) return fromEnv
422  const r = await run($, ['tmux', 'display-message', '-p', '#{pane_id}'])
423  const id = r.out.trim()
424  return r.ok && /^%\d+$/.test(id) ? id : null
425}
426
427// Claude's tmux pane and window, or null (toasted) when there's no tmux.
428async function claudeWindow($, fallback: () => Promise<void>) {
429  const pane = await tmuxPane($)
430  if (!pane) {
431    await fallback()
432    return null
433  }
434  const windowId = parseWindowId((await run($, tmuxInfoArgs(pane))).out)
435  if (!windowId) {
436    $.ui.toast('toc: could not read the tmux window')
437    return null
438  }
439  return { pane, windowId }
440}
441
442// The left column of a tmux window: the nvim and viewer panes toc opened
443// there, one record per window in $.store, read back only while alive.
444type LeftKind = 'nvim' | 'viewer'
445type Column = { nvim?: string; viewer?: string }
446const COLUMNS_KEY = 'leftColumns'
447
448async function columnOf($, windowId: string): Promise<Column> {
449  const kept = (((await $.store.get(COLUMNS_KEY)) ?? {}) as Record<string, Column>)[windowId] ?? {}
450  const alive = async (pane?: string) => (pane && isPaneAlive((await run($, paneAliveArgs(pane))).out, pane, windowId) ? pane : undefined)
451  return { nvim: await alive(kept.nvim), viewer: await alive(kept.viewer) }
452}
453
454async function keepColumn($, windowId: string, column: Column) {
455  const all = ((await $.store.get(COLUMNS_KEY)) ?? {}) as Record<string, Column>
456  await $.store.set(COLUMNS_KEY, { ...all, [windowId]: column })
457}
458
459// Open `kind` in the left column: nvim above the viewer, the viewer below
460// nvim, either as the left half of Claude's pane when it is alone.
461async function openLeft($, w: { pane: string; windowId: string }, kind: LeftKind, spawn: Spawn): Promise<boolean> {
462  const column = await columnOf($, w.windowId)
463  const other = kind === 'nvim' ? column.viewer : column.nvim
464  const above = kind === 'nvim'
465  let split = await run($, leftSplitArgs(w.pane, other ?? null, above, spawn))
466  if (!split.ok && other) split = await run($, leftSplitArgs(w.pane, null, above, spawn)) // it died just now
467  if (!split.ok) {
468    $.ui.toast('toc: tmux split failed: ' + split.err.trim().slice(0, 80))
469    return false
470  }
471  const pane = split.out.trim()
472  await keepColumn($, w.windowId, { ...column, [kind]: pane })
473  watchPane($, pane, w.pane)
474  return true
475}
476
477function workDir(): string {
478  return root ?? base ?? '.'
479}
480
481async function openInEditor($, path: string, surface?: string, line?: number) {
482  const w = await claudeWindow($, async () => {
483    await $.ui.copy(surface ? { text: path, surface } : { text: path }).catch(() => undefined)
484    $.ui.toast('toc: not in tmux — copied ' + path)
485  })
486  if (!w) return
487  const runtime = (await $.env.get('XDG_RUNTIME_DIR')) || '/tmp'
488  const sock = socketPath(runtime, w.windowId)
489  if ((await run($, aliveArgs(sock))).ok) {
490    const r = await run($, remoteTabArgs(sock, path, line))
491    if (!r.ok) $.ui.toast('toc: nvim refused ' + path)
492    const { nvim } = await columnOf($, w.windowId)
493    if (nvim) await run($, selectPaneArgs(nvim))
494    return
495  }
496  await openLeft($, w, 'nvim', { cwd: workDir(), env: [], cmd: nvimArgs(sock, path, line) })
497}
498
499// Panes toc started (nvim, the viewer), watched while they live: when one
500// quits, tmux returns to Claude's pane and Claude to its prompt; the person
501// came from toc, so hand the keys back to it.
502let watched: { pane: string; claude: string }[] = []
503let watchTimer: { cancel: () => void } | null = null
504
505function watchPane($, pane: string, claude: string) {
506  watched = [...watched.filter((w) => w.pane !== pane), { pane, claude }]
507  if (watchTimer) return
508  watchTimer = $.clock.every(400, () => {
509    checkWatched($).catch(() => undefined)
510  })
511}
512
513async function checkWatched($) {
514  const r = await run($, ['tmux', 'list-panes', '-a', '-F', '#{pane_id}'])
515  if (!r.ok) return
516  const live = new Set(r.out.split('\n').map((l) => l.trim()))
517  const gone = watched.filter((w) => !live.has(w.pane))
518  if (!gone.length) return
519  watched = watched.filter((w) => live.has(w.pane))
520  if (!watched.length && watchTimer) {
521    watchTimer.cancel()
522    watchTimer = null
523  }
524  // Only once tmux is back on Claude's pane: the other left pane may have
525  // taken the focus instead, and then it's the person's.
526  const claude = gone[gone.length - 1].claude
527  const active = await run($, ['tmux', 'display-message', '-p', '-t', claude, '#{pane_id} #{pane_active}'])
528  if (active.out.trim() !== claude + ' 1') return
529  await openPane($, true)
530}
531
532// The theme for delta and lazygit, as the person's shell would set it.
533async function viewerEnvFor($): Promise<string[]> {
534  const mode = parseMode(home ? await readText($, home + '/.config/theme/mode') : null) ?? 'dark'
535  const config = (await $.env.get('XDG_CONFIG_HOME')) || (home ? home + '/.config' : '')
536  if (!config) return viewerEnv(mode, null)
537  const dir = config + '/lazygit'
538  const both = (await $.fs.exists(dir + '/config.yml')) && (await $.fs.exists(dir + '/theme-' + mode + '.yml'))
539  return viewerEnv(mode, both ? dir + '/config.yml,' + dir + '/theme-' + mode + '.yml' : null)
540}
541
542// Run cmd in the viewer pane: reuse it, else open it in the left column.
543async function inViewer($, cmd: string[]) {
544  const w = await claudeWindow($, async () => {
545    $.ui.toast('toc: not in tmux — run: ' + cmd.join(' ').slice(0, 120))
546  })
547  if (!w) return
548  const spawn = { cwd: workDir(), env: await viewerEnvFor($), cmd }
549  const { viewer } = await columnOf($, w.windowId)
550  if (viewer && (await run($, respawnArgs(viewer, spawn))).ok) {
551    await run($, selectPaneArgs(viewer))
552    watchPane($, viewer, w.pane)
553    return
554  }
555  await openLeft($, w, 'viewer', spawn)
556}
557
558// A commit, a file in a commit, or a working-tree change, in the pager.
559async function viewDiff($, t: Target, surface?: string) {
560  const r = root ?? base!
561  if (t.kind === 'brief') return data ? openInEditor($, data.planPath, surface, t.from) : undefined
562  if (t.kind === 'file' && (t.state === '✓' || !root)) return openInEditor($, t.path, surface)
563  const vt: ViewTarget =
564    t.kind === 'commit' ? { kind: 'commit', sha: t.sha }
565      : t.kind === 'cfile' ? { kind: 'cfile', sha: t.sha, path: t.path }
566        : { kind: 'file', path: t.path, untracked: t.state === 'A' } // new: diff it against nothing
567  return inViewer($, diffViewArgs(r, vt))
568}
569
570async function history($, path: string | null) {
571  if (!root) return $.ui.toast('toc: not a git repository')
572  return inViewer($, lazygitArgs(root, path === null ? null : relativeTo(root, path)))
573}
574
575// ── repair (SPEC §7.6) ──────────────────────────────────────────────────
576
577async function startRepair($) {
578  if (!data || !data.plan || !base) return
579  const text = (await readText($, data.planPath)) ?? ''
580  await $.fs.write(abs(base, LEDGER_DIR + '/DEV_PLAN.md.bak'), text)
581  let excerpt = ''
582  try {
583    excerpt = transcriptExcerpt((await $.session.messages()).slice(-40))
584  } catch {
585    excerpt = ''
586  }
587  const task = repairTask(data.planPath, data.plan.issues, [...data.commits.keys()], excerpt)
588  repair = { phase: 'running', sig: planSignature(data.plan) }
589  $.ui.invalidate('ui.render')
590  const r = await $.agent.spawn({ subagentType: REPAIR_AGENT, prompt: task, description: 'repair DEV_PLAN.md' })
591  if (r.deny || !r.agentId) {
592    repair = { phase: 'ask', sig: planSignature(data.plan) }
593    $.ui.toast('toc: repair could not start: ' + (r.deny ?? 'no agent'))
594  } else {
595    repairAgent = r.agentId
596    // Kept in the store too: a reload between spawn and hand-back would
597    // otherwise forget it and let the report cost a main-model turn.
598    const kept = ((await $.store.get('repairAgents')) ?? []) as string[]
599    await $.store.set('repairAgents', [...kept, r.agentId].slice(-20))
600  }
601  $.ui.invalidate('ui.render')
602}
603
604async function dismissRepair($) {
605  if (!data) return
606  repair = { phase: 'dismissed', sig: planSignature(data.plan) }
607  $.ui.invalidate('ui.render')
608}
609
610// ── register ────────────────────────────────────────────────────────────
611
612export function register(on) {
613  on('session.start', async ($, e, next) => {
614    home = (await $.env.get('HOME')) ?? ''
615    await $.agent.register({
616      name: 'repair',
617      description: 'Repairs a DEV_PLAN.md that the toc sidebar cannot parse. Dispatched by the toc plugin only.',
618      prompt: REPAIR_SYSTEM,
619      tools: ['Read', 'Edit'],
620      model: 'haiku',
621    })
622    await $.tool.register({
623      name: 'activate',
624      description:
625        'Show the toc sidebar for a project folder that holds (or should hold) DEV_PLAN.md: knowledge files, plan progress, and per-step commits and files. Idempotent; creates DEV_PLAN.md from a template only if missing. Call it once you know the project folder, e.g. after writing or finding a DEV_PLAN.md.',
626      inputSchema: { type: 'object', properties: { dir: { type: 'string', description: 'Project folder, absolute or relative to the working directory' } }, required: ['dir'] },
627      isDeferred: false,
628    })
629    await $.command.register({ name: 'toc', description: 'Show or hide the toc sidebar (also: /toc keys, /toc refresh)', argumentHint: '[keys|refresh]', immediate: true })
630    await $.command.register({ name: 'project', description: 'Point the toc sidebar at a project folder and scaffold DEV_PLAN.md', argumentHint: '[dir]' })
631
632    const cwd = await $.session.cwd()
633    const map = ((await $.store.get('bases')) ?? {}) as Record<string, string>
634    if (map[cwd] && (await $.fs.exists(abs(map[cwd], PLAN_FILE)))) {
635      base = map[cwd]
636      const top = await run($, rootArgs(base), base)
637      root = top.ok ? top.out.trim() : null
638      await refresh($)
639      await openPane($, false)
640    }
641    $.clock.every(2000, () => {
642      poll($).catch(() => undefined)
643    })
644    return next(e)
645  })
646
647  on('agent.offer', { agent: REPAIR_AGENT }, () => ({ isOffered: false })).catch(() => ({ isOffered: false }))
648
649  on('command.run', { command: 'toc' }, async ($, e) => {
650    const arg = (e.args ?? '').trim()
651    if (arg === 'keys') return { text: await installKeys($) }
652    if (arg === 'refresh') {
653      stamp = ''
654      await refresh($)
655      return {}
656    }
657    await togglePane($)
658    return {}
659  })
660
661  on('command.run', { command: 'project' }, async ($, e) => {
662    const arg = (e.args ?? '').trim()
663    if (!arg) {
664      const cwd = await $.session.cwd()
665      if (!(await $.fs.exists(abs(cwd, PLAN_FILE)))) {
666        return { text: `toc: no ${PLAN_FILE} in ${cwd}. Run /project <dir> to point at a project folder, or /project . to create one here.` }
667      }
668    }
669    const msg = await activate($, arg || '.', { create: true })
670    // Unfocused: the next thing typed is usually a prompt, not panel keys.
671    if (base) await openPane($, false)
672    return { text: msg }
673  })
674
675  on('tool.call', { tool: 'mcp__toc__activate' }, async ($, e) => {
676    const msg = await activate($, String(e.dir ?? '.'), { create: true })
677    if (base) await openPane($, false)
678    return { result: msg }
679  }).catch(() => ({ result: 'toc: activate failed; see the debug log' }))
680
681  on('prompt.context', async ($, e, next) => {
682    const r = await next(e)
683    if (!base) return r
684    return { ...r, blocks: [...r.blocks, { name: 'toc', text: rulesBlock(abs(base, PLAN_FILE)) }] }
685  })
686
687  // Claude's commits carry the in-progress step: git adds the trailer itself,
688  // so it doesn't depend on the model following an instruction (SPEC §6.1).
689  // The same hook snapshots git status around the command so files a Bash
690  // command creates or edits reach the ledger too (SPEC §6.2).
691  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
692    if (!base || !root) return next(e)
693    const command = String(e.command ?? '')
694    let ev = e
695    if (/\bgit\b/.test(command) && /\bcommit\b/.test(command)) {
696      if (trailerOk === null) trailerOk = supportsTrailer((await run($, ['git', '--version'])).out)
697      const step = trailerOk ? await activeStepId($) : null
698      if (step) {
699        const rewritten = withTrailer(command, step)
700        if (rewritten !== command) ev = { ...e, command: rewritten }
701      }
702    }
703    const before = await snapshot($)
704    const result = await next(ev)
705    const after = before ? await snapshot($) : null
706    if (before && after) await record($, changedBetween(before, after).map((rel) => abs(root!, rel)))
707    await refresh($)
708    return result
709  }).catch(($, e, next) => next(e))
710
711  on('classic.PostToolUse', async ($, e, next) => {
712    const result = await next(e)
713    if (!base || !WRITE_TOOLS.includes(e.tool_name)) return result
714    const raw = editedPath(e.tool_input)
715    if (!raw) return result
716    await record($, [abs(e.cwd ?? (await $.session.cwd()), raw)])
717    await refresh($)
718    return result
719  }).catch(($, e, next) => next(e))
720
721  on('turn.complete', async ($, e, next) => {
722    const r = await next(e)
723    if (repairAgent && e.agentId === repairAgent) {
724      repairAgent = null
725      repair = null
726      const said = String(e.answer ?? '').trim().split('\n').pop() ?? ''
727      $.ui.toast('toc: ' + (said || 'repair finished').slice(0, 120))
728      stamp = ''
729    }
730    if (base) await refresh($)
731    return r
732  })
733
734  // The repair agent's report reaches the main model as a peer message
735  // (<agent-message from="<agentId>">); swallow it so a repair costs no
736  // main-model turn. The toast already told the person what changed.
737  on('prompt.submit', async ($, e, next) => {
738    const from = /<agent-message from="([^"]+)"/.exec(String(e.text ?? ''))
739    if (!from) return next(e)
740    const kept = ((await $.store.get('repairAgents')) ?? []) as string[]
741    if (!kept.includes(from[1])) return next(e)
742    await $.store.set('repairAgents', kept.filter((id) => id !== from[1]))
743    return { drop: 'toc: the plan repair already reported in the sidebar' }
744  }).catch(($, e, next) => next(e))
745
746  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
747    const theirs = await next(e)
748    if (!base) return theirs
749    const { Box, Button } = $.ui.resolve(e)
750    // A press doesn't say whether it was a click or the chord, and a click
751    // leaves the band holding the keys (so the pane can't take focus): the
752    // chord's toggle is a zero-height button of its own.
753    const mine = Box({ flexDirection: 'row', children: [
754      Button({ key: 'toc-click', label: '≡ toc ⌃X T', plain: true, dimColor: true, onPress: () => clickPane($) }),
755      Box({ height: 0, overflow: 'hidden', children: [
756        Button({ key: 'toc-toggle', label: 'toc', plain: true, action: ACTIONS.toggle, onPress: () => togglePane($) }),
757      ] }),
758    ] })
759    return theirs ? Box({ flexDirection: 'column', children: [theirs, mine] }) : mine
760  })
761
762  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
763    if (e.requestId !== PANE) return next(e)
764    const el = $.ui.resolve(e)
765    width = Math.max(24, e.props.bodyColumns ?? COLUMNS)
766    bodyRows = Math.max(10, (e.props.scroll && e.props.scroll.bodyRows) || 30)
767    if (!data) {
768      return el.Text({ color: 'subtle', children: [base ? 'loading…' : 'toc: run /project <dir>'] })
769    }
770    return render({
771      el,
772      ui,
773      data,
774      repair,
775      preview,
776      alloc: alloc(),
777      width,
778      roles,
779      focused: !!e.props.isFocused,
780      act: {
781        row: (key: string) => () => onRow($, key),
782        key: (k: string) => () => handleKey($, k as Key),
783      },
784    })
785  })
786
787  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
788    const el = e.element ?? ''
789    if (!data || !el.startsWith('r:')) return next(e)
790    const key = el.slice(2)
791    if (e.origin.kind === 'person' && lastFocus && lastFocus !== key) {
792      const order = drawnOrder(ui, data, repair, alloc())
793      const from = order.indexOf(lastFocus)
794      const to = order.indexOf(key)
795      const edge = edgeMove(from, to)
796      if (edge) {
797        await handleKey($, edge)
798        return {}
799      }
800    }
801    lastFocus = key
802    const before = ui
803    ui = focusRow(ui, data, repair, key, alloc())
804    if (shownKey(before) !== shownKey(ui)) await loadPreview($)
805    $.ui.invalidate('ui.render')
806    return next(e)
807  }).catch(($, e, next) => next(e))
808
809  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
810    if (!data || e.origin.kind !== 'person') return next(e)
811    const big = Math.abs(e.by) >= 3
812    await handleKey($, (e.by > 0 ? (big ? 'pagedown' : 'j') : big ? 'pageup' : 'k') as Key)
813    return {}
814  }).catch(($, e, next) => next(e))
815}
816
817// Arrow onto the next section while this one still has hidden rows → scroll it.
818function edgeMove(from: number, to: number): Key | null {
819  if (from < 0 || to < 0 || !data) return null
820  const sec = ui.sec
821  if (sec === PREV || !isShown(ui, sec)) return null
822  const rows = rowsOf(sec, ui, data, repair)
823  const a = alloc()[sec]
824  const idx = rows.findIndex((r) => r.key === ui.cursor[sec])
825  if (idx < 0) return null
826  if (to > from && idx === ui.offset[sec] + a - 1 && idx < rows.length - 1) return 'j'
827  if (to < from && idx === ui.offset[sec] && idx > 0) return 'k'
828  return null
829}
830
831async function onRow($, key: string) {
832  if (!data) return
833  const before = ui
834  ui = focusRow(ui, data, repair, key, alloc())
835  lastFocus = key
836  await handleKey($, 'o', undefined, before)
837}
838
hooks/lib/diff.ts 167 lines
1// Unified-diff parsing and paging. A page must hand `Code format:'diff'`
2// whole hunks, so each page slice gets a synthetic, correct `@@` header.
3
4export type Hunk = { oldStart: number; newStart: number; lines: string[] }
5export type DiffFile = { path: string; hunks: Hunk[]; binary: boolean; add: number; del: number }
6
7export type PRow =
8  | { kind: 'file'; file: number }
9  | { kind: 'line'; file: number; hunk: number; line: number }
10
11export type Segment =
12  | { kind: 'file'; file: DiffFile }
13  | { kind: 'diff'; path: string; source: string; rows: number }
14  | { kind: 'source'; path: string; source: string; startLine: number; rows: number }
15
16export function parseDiff(text: string): DiffFile[] {
17  const files: DiffFile[] = []
18  let file: DiffFile | null = null
19  let hunk: Hunk | null = null
20  for (const line of text.split('\n')) {
21    const head = /^diff --git a\/(.*?) b\/(.*)$/.exec(line)
22    if (head) {
23      file = { path: head[2], hunks: [], binary: false, add: 0, del: 0 }
24      files.push(file)
25      hunk = null
26      continue
27    }
28    if (!file) continue
29    if (/^Binary files /.test(line)) {
30      file.binary = true
31      continue
32    }
33    const h = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
34    if (h) {
35      hunk = { oldStart: Number(h[1]), newStart: Number(h[2]), lines: [] }
36      file.hunks.push(hunk)
37      continue
38    }
39    if (!hunk) continue
40    const c = line[0]
41    if (c === '+' || c === '-' || c === ' ' || c === '\\') {
42      hunk.lines.push(line)
43      if (c === '+') file.add++
44      if (c === '-') file.del++
45    }
46  }
47  return files
48}
49
50export function diffRows(files: DiffFile[]): PRow[] {
51  const rows: PRow[] = []
52  files.forEach((f, fi) => {
53    rows.push({ kind: 'file', file: fi })
54    f.hunks.forEach((h, hi) => h.lines.forEach((_, li) => rows.push({ kind: 'line', file: fi, hunk: hi, line: li })))
55  })
56  return rows
57}
58
59function counts(lines: string[]): { old: number; neu: number } {
60  let old = 0
61  let neu = 0
62  for (const l of lines) {
63    if (l[0] === ' ') {
64      old++
65      neu++
66    } else if (l[0] === '-') old++
67    else if (l[0] === '+') neu++
68  }
69  return { old, neu }
70}
71
72// The visible window [offset, offset+count) as drawable segments.
73export function diffPage(files: DiffFile[], rows: PRow[], offset: number, count: number): Segment[] {
74  const out: Segment[] = []
75  const slice = rows.slice(offset, offset + count)
76  let i = 0
77  while (i < slice.length) {
78    const r = slice[i]
79    if (r.kind === 'file') {
80      out.push({ kind: 'file', file: files[r.file] })
81      i++
82      continue
83    }
84    let j = i
85    while (j + 1 < slice.length) {
86      const n = slice[j + 1]
87      if (n.kind !== 'line' || n.file !== r.file || n.hunk !== r.hunk) break
88      j++
89    }
90    const last = slice[j] as Extract<PRow, { kind: 'line' }>
91    const hunk = files[r.file].hunks[r.hunk]
92    const before = counts(hunk.lines.slice(0, r.line))
93    const body = hunk.lines.slice(r.line, last.line + 1)
94    const c = counts(body)
95    const header = `@@ -${hunk.oldStart + before.old},${c.old} +${hunk.newStart + before.neu},${c.neu} @@`
96    out.push({ kind: 'diff', path: files[r.file].path, source: header + '\n' + body.join('\n'), rows: body.length })
97    i = j + 1
98  }
99  return out
100}
101
102// A whole file (untracked, so no diff): plain source, paged by line.
103export function sourcePage(path: string, text: string, offset: number, count: number, firstLine = 1): Segment[] {
104  const lines = text.split('\n')
105  const body = lines.slice(offset, offset + count)
106  return [{ kind: 'source', path, source: body.join('\n'), startLine: firstLine + offset, rows: body.length }]
107}
108
109export function sourceRowCount(text: string): number {
110  return text.split('\n').length
111}
112
113// ── wrapped paging ──────────────────────────────────────────────────────
114// Code wraps long lines onto rows under its gutter, so a page holds as many
115// diff/source lines as fit in its screen rows, not a fixed count. Costs are
116// screen rows per PRow (or per source line); an overestimate only leaves a
117// page a row short, an underestimate would push rows out of the pane.
118
119// Rows `text` takes when Code wraps it at `room` cells: it breaks at the
120// cell, not the word (tabs drawn as 4).
121export function wrapRows(text: string, room: number): number {
122  return Math.max(1, Math.ceil(Array.from(text.replace(/\t/g, '    ')).length / Math.max(1, room)))
123}
124
125export function diffCosts(files: DiffFile[], room: number): number[] {
126  return diffRows(files).map((r) => (r.kind === 'file' ? 1 : wrapRows(files[r.file].hunks[r.hunk].lines[r.line].slice(1), room)))
127}
128
129export function sourceCosts(text: string, room: number): number[] {
130  return text.split('\n').map((l) => wrapRows(l, room))
131}
132
133// How many rows from `offset` fit in `budget` screen rows (at least one).
134export function fitCount(costs: number[], offset: number, budget: number): number {
135  let used = 0
136  let n = 0
137  for (let i = offset; i < costs.length; i++) {
138    if (n > 0 && used + costs[i] > budget) break
139    used += costs[i]
140    n++
141  }
142  return n
143}
144
145// The earliest offset whose page still reaches the end: the last page.
146export function lastOffset(costs: number[], budget: number): number {
147  let used = 0
148  let i = costs.length
149  while (i > 0 && used + costs[i - 1] <= budget) used += costs[--i]
150  return Math.min(i, Math.max(0, costs.length - 1))
151}
152
153// The offset of the page that ends just above `offset` (paging back).
154export function pageBack(costs: number[], offset: number, budget: number): number {
155  let used = 0
156  let i = offset
157  while (i > 0 && used + costs[i - 1] <= budget) used += costs[--i]
158  return i === offset ? Math.max(0, offset - 1) : i
159}
160
161// Page starts from the top, for an "n/m" counter.
162export function pageStarts(costs: number[], budget: number): number[] {
163  const out: number[] = []
164  for (let i = 0; i < costs.length; i += fitCount(costs, i, budget)) out.push(i)
165  return out.length ? out : [0]
166}
167
hooks/lib/editor.ts 100 lines
1// The editor half (SPEC §7.5): one nvim per tmux window, reached over a
2// socket; files open as tabs. Pure argv builders — register.ts runs them.
3
4
5export function tmuxInfoArgs(pane: string): string[] {
6  return ['tmux', 'display-message', '-p', '-t', pane, '#{window_id}']
7}
8
9export function parseWindowId(out: string): string | null {
10  const m = /^(@\d+)/.exec(out.trim())
11  return m ? m[1] : null
12}
13
14export function socketPath(runtimeDir: string, windowId: string): string {
15  return runtimeDir.replace(/\/$/, '') + '/toc-nvim-' + windowId.replace('@', 'w') + '.sock'
16}
17
18export function aliveArgs(socket: string): string[] {
19  return ['nvim', '--server', socket, '--remote-expr', '1']
20}
21
22export function remoteTabArgs(socket: string, path: string, line?: number): string[] {
23  if (!line) return ['nvim', '--server', socket, '--remote-tab', path]
24  // --remote-tab takes no +line; drive the running nvim instead.
25  const file = path.replace(/[\\ %#|"]/g, '\\$&').replace(/</g, '<lt>')
26  return ['nvim', '--server', socket, '--remote-send', '<C-\\><C-N>:tabedit +' + line + ' ' + file + '<CR>zt']
27}
28
29export function nvimArgs(socket: string, path: string, line?: number): string[] {
30  return ['nvim', '--listen', socket, ...(line ? ['+' + line, '+normal! zt'] : []), path]
31}
32
33export function selectPaneArgs(pane: string): string[] {
34  return ['tmux', 'select-pane', '-t', pane]
35}
36
37export function paneAliveArgs(pane: string): string[] {
38  return ['tmux', 'display-message', '-p', '-t', pane, '#{pane_id} #{window_id}']
39}
40
41// display-message exits 0 with empty output for a pane that is gone, so
42// liveness is the pane answering with its own id, in the window we expect.
43export function isPaneAlive(out: string, pane: string, windowId: string): boolean {
44  return out.trim() === pane + ' ' + windowId
45}
46
47// ── the viewer: a diff or lazygit in the left column ────────────────────
48// The left of Claude's pane is one column shared by nvim and the viewer:
49// the first to open splits Claude's pane in half, the second splits that
50// pane top/bottom so Claude keeps its width. The viewer runs in the pane
51// itself and the pane closes when it quits (q in the pager / lazygit).
52
53export type ViewTarget =
54  | { kind: 'commit'; sha: string }
55  | { kind: 'cfile'; sha: string; path: string }
56  | { kind: 'file'; path: string; untracked: boolean }
57
58// `git show`/`git diff` through the pager the person configured (core.pager:
59// delta here); viewerEnv keeps the pager from quitting on a short diff.
60export function diffViewArgs(root: string, t: ViewTarget): string[] {
61  const git = ['git', '-C', root]
62  if (t.kind === 'commit') return [...git, 'show', '--stat', '--patch', '-M', t.sha]
63  if (t.kind === 'cfile') return [...git, 'show', '-M', t.sha, '--', t.path]
64  if (t.untracked) return [...git, 'diff', '--no-index', '--', '/dev/null', t.path]
65  return [...git, 'diff', 'HEAD', '--', t.path]
66}
67
68// lazygit at the commit log, or filtered to one file's history.
69export function lazygitArgs(root: string, path: string | null): string[] {
70  return path === null ? ['lazygit', '-p', root, 'log'] : ['lazygit', '-p', root, '-f', path]
71}
72
73// The environment the viewer needs from the person's shell, which a pane
74// spawned by tmux doesn't get (the tmux global env can be stale): the theme
75// for delta and lazygit (THEME.md), and a less that waits for q: delta
76// runs its own `less --quit-if-one-screen` unless DELTA_PAGER says otherwise.
77export function viewerEnv(mode: 'dark' | 'light', lazygitConfig: string | null): string[] {
78  const env = ['LESS=-R', 'DELTA_PAGER=less -R', 'DELTA_FEATURES=+gm-' + mode]
79  if (lazygitConfig) env.push('LG_CONFIG_FILE=' + lazygitConfig)
80  return env.flatMap((kv) => ['-e', kv])
81}
82
83// What runs in a left-column pane, and where.
84export type Spawn = { cwd: string; env: string[]; cmd: string[] }
85
86// Open a pane in the left column: above (nvim) or below (the viewer) the
87// other left pane when there is one (`beside`), else as the left half of
88// Claude's pane.
89export function leftSplitArgs(claudePane: string, beside: string | null, above: boolean, s: Spawn): string[] {
90  const where = beside
91    ? ['-v', ...(above ? ['-b'] : []), '-l', '50%', '-t', beside]
92    : ['-h', '-b', '-l', '50%', '-t', claudePane]
93  return ['tmux', 'split-window', ...where, '-c', s.cwd, ...s.env, '-P', '-F', '#{pane_id}', ...s.cmd]
94}
95
96// Reuse the viewer pane: replace what runs in it.
97export function respawnArgs(pane: string, s: Spawn): string[] {
98  return ['tmux', 'respawn-pane', '-k', '-t', pane, '-c', s.cwd, ...s.env, ...s.cmd]
99}
100
hooks/lib/git.ts 148 lines
1// Git argv builders and output parsers. Pure — register.ts runs the commands.
2
3export type FileStat = { path: string; add: number; del: number }
4
5export type Commit = {
6  sha: string
7  short: string
8  time: number
9  subject: string
10  steps: string[]
11  files: FileStat[]
12  add: number
13  del: number
14}
15
16// Artifact file state: A new/untracked, M modified, D deleted, ✓ clean (committed).
17export type FileState = 'A' | 'M' | 'D' | '✓'
18
19const RS = '\x1e'
20const US = '\x1f'
21export const TRAILER = 'Plan-Step'
22const LOG_FORMAT = `${'%x1e'}%H${'%x1f'}%h${'%x1f'}%ct${'%x1f'}%s${'%x1f'}%(trailers:key=${TRAILER},valueonly,separator=%x2c)`
23
24export function rootArgs(dir: string): string[] {
25  return ['git', '-C', dir, 'rev-parse', '--show-toplevel']
26}
27
28// Only commits whose message has a Plan-Step line; numstat for their files.
29const LOG_LIMIT = 2000
30
31export function logArgs(root: string): string[] {
32  return ['git', '-C', root, 'log', '-E', `--grep=^${TRAILER}: `, `--format=${LOG_FORMAT}`, '--numstat', '--no-color', '-n', String(LOG_LIMIT)]
33}
34
35export function statusArgs(root: string): string[] {
36  return ['git', '-C', root, 'status', '--porcelain=v1', '-z', '--untracked-files=all']
37}
38
39export function showArgs(root: string, sha: string): string[] {
40  return ['git', '-C', root, 'show', '--format=', '--patch', '--no-color', '--no-ext-diff', '-M', sha]
41}
42
43export function diffArgs(root: string, rel: string): string[] {
44  return ['git', '-C', root, 'diff', '--no-color', '--no-ext-diff', 'HEAD', '--', rel]
45}
46
47export function parseLog(out: string): Commit[] {
48  const commits: Commit[] = []
49  for (const record of out.split(RS)) {
50    if (!record.trim()) continue
51    const [head, ...rest] = record.split('\n')
52    const [sha, short, time, subject, trailers] = head.split(US)
53    if (!sha) continue
54    const steps = (trailers ?? '')
55      .split(',')
56      .map((s) => s.trim())
57      .filter((s) => /^S\d+\.\d+$/.test(s))
58    const files: FileStat[] = []
59    for (const line of rest) {
60      const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
61      if (!m) continue
62      files.push({ path: renamedTo(m[3]), add: m[1] === '-' ? 0 : Number(m[1]), del: m[2] === '-' ? 0 : Number(m[2]) })
63    }
64    commits.push({
65      sha,
66      short,
67      time: Number(time),
68      subject: subject ?? '',
69      steps,
70      files,
71      add: files.reduce((n, f) => n + f.add, 0),
72      del: files.reduce((n, f) => n + f.del, 0),
73    })
74  }
75  return commits
76}
77
78// numstat spells renames as "a => b" or "dir/{a => b}/f".
79export function renamedTo(path: string): string {
80  const brace = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(path)
81  if (brace) return (brace[1] + brace[3] + brace[4]).replace(/\/\//g, '/')
82  const plain = /^(.*) => (.*)$/.exec(path)
83  return plain ? plain[2] : path
84}
85
86export function commitsByStep(commits: Commit[]): Map<string, Commit[]> {
87  const map = new Map<string, Commit[]>()
88  for (const c of commits) {
89    for (const s of c.steps) {
90      if (!map.has(s)) map.set(s, [])
91      map.get(s)!.push(c)
92    }
93  }
94  return map
95}
96
97// porcelain v1 -z: "XY path\0" and, for renames, "XY new\0old\0".
98export function parseStatus(out: string): Map<string, string> {
99  const map = new Map<string, string>()
100  const parts = out.split('\0')
101  for (let i = 0; i < parts.length; i++) {
102    const p = parts[i]
103    if (p.length < 4) continue
104    const code = p.slice(0, 2)
105    map.set(p.slice(3), code)
106    if (code[0] === 'R' || code[0] === 'C') i++
107  }
108  return map
109}
110
111export function fileState(code: string | undefined, exists: boolean): FileState {
112  if (!exists) return 'D'
113  if (!code) return '✓'
114  if (code === '??' || code.includes('A')) return 'A'
115  if (code.includes('D')) return 'D'
116  return 'M'
117}
118
119export function relativeTo(root: string, abs: string): string {
120  const r = root.endsWith('/') ? root : root + '/'
121  return abs.startsWith(r) ? abs.slice(r.length) : abs
122}
123
124// `git commit` → `git commit --trailer 'Plan-Step: <id>'` (git ≥ 2.32), for
125// every commit in a Bash command line; untouched when it already names a step.
126const COMMIT_RE = /\bgit((?:\s+-[cC]\s+(?:'[^']*'|"[^"]*"|\S+))*)\s+commit(?![-\w])/g
127
128export function withTrailer(command: string, stepId: string): string {
129  if (command.includes(TRAILER + ':')) return command
130  return command.replace(COMMIT_RE, (_m, opts) => `git${opts} commit --trailer '${TRAILER}: ${stepId}'`)
131}
132
133export function supportsTrailer(versionOut: string): boolean {
134  const m = /(\d+)\.(\d+)/.exec(versionOut)
135  if (!m) return false
136  const [maj, min] = [Number(m[1]), Number(m[2])]
137  return maj > 2 || (maj === 2 && min >= 32)
138}
139
140// Files a command changed: new status entries, or listed ones whose mtime moved.
141export type Snapshot = Map<string, string>
142
143export function changedBetween(before: Snapshot, after: Snapshot): string[] {
144  const out: string[] = []
145  for (const [path, sig] of after) if (before.get(path) !== sig) out.push(path)
146  return out
147}
148
hooks/lib/ledger.ts 52 lines
1// The step ledger: one JSON line per file Claude wrote or edited, stamped with
2// the step that was in progress (SPEC §6.2).
3
4export type Entry = { ts: number; path: string; step: string | null; session?: string }
5
6export const LEDGER_DIR = '.toc'
7export const LEDGER_FILE = '.toc/ledger.jsonl'
8export const MAX_LINES = 5000
9
10export function parseLedger(text: string): Entry[] {
11  const out: Entry[] = []
12  for (const line of text.split('\n')) {
13    if (!line.trim()) continue
14    try {
15      const e = JSON.parse(line)
16      if (e && typeof e.path === 'string') out.push({ ts: Number(e.ts) || 0, path: e.path, step: e.step ?? null, session: e.session })
17    } catch {
18      // A torn or hand-edited line is skipped, never fatal.
19    }
20  }
21  return out
22}
23
24// Append, keeping the file bounded; returns the new text.
25export function appendEntry(text: string, entry: Entry): string {
26  const lines = text.split('\n').filter((l) => l.trim())
27  lines.push(JSON.stringify(entry))
28  return lines.slice(-MAX_LINES).join('\n') + '\n'
29}
30
31// step id ('' = unattributed) → paths, most recent first, each path once per step.
32export function filesByStep(entries: Entry[]): Map<string, string[]> {
33  const map = new Map<string, string[]>()
34  for (let i = entries.length - 1; i >= 0; i--) {
35    const e = entries[i]
36    const key = e.step ?? ''
37    if (!map.has(key)) map.set(key, [])
38    const list = map.get(key)!
39    if (!list.includes(e.path)) list.push(e.path)
40  }
41  return map
42}
43
44export const WRITE_TOOLS = ['Write', 'Edit', 'NotebookEdit', 'MultiEdit']
45
46export function editedPath(toolInput: unknown): string | null {
47  if (!toolInput || typeof toolInput !== 'object') return null
48  const t = toolInput as Record<string, unknown>
49  const p = t.file_path ?? t.notebook_path
50  return typeof p === 'string' ? p : null
51}
52
hooks/lib/model.ts 561 lines
1// Sidebar state, rows and navigation. Pure: (UI, Data, key) → (UI, Effect).
2
3import type { Commit, FileState } from './git.ts'
4import type { DiffFile } from './diff.ts'
5import { diffCosts, diffRows, fitCount, lastOffset, pageBack, sourceCosts, sourceRowCount } from './diff.ts'
6import { activeStep, allSteps, errorsOf, findStep, isActive, isComplete } from './plan.ts'
7import type { Plan, Step } from './plan.ts'
8
9export const SECTIONS = ['Context', 'Plan', 'Artifacts', 'Preview', 'Keys'] as const
10export const CTX = 0
11export const PLAN = 1
12export const ART = 2
13export const PREV = 3
14export const KEYS = 4
15export const UNATTRIBUTED = ''
16
17export type CtxFile = { name: string; path: string; exists: boolean; mtimeMs: number }
18export type ArtFile = { path: string; rel: string; state: FileState }
19
20export type Data = {
21  base: string
22  root: string | null
23  plan: Plan | null
24  planPath: string
25  context: CtxFile[]
26  commits: Map<string, Commit[]>
27  files: Map<string, ArtFile[]>
28  now: number
29}
30
31export type RepairPhase = 'ask' | 'running' | 'dismissed'
32export type Repair = { phase: RepairPhase; sig: string } | null
33
34export type Target =
35  | { kind: 'commit'; sha: string; short: string }
36  | { kind: 'cfile'; sha: string; short: string; path: string }
37  | { kind: 'file'; path: string; rel: string; state: FileState }
38  | { kind: 'brief'; id: string; from: number; to: number }
39
40// firstLine: where `source` starts in its file (a brief is a slice of DEV_PLAN.md).
41export type Preview = { key: string; files: DiffFile[] | null; source: string | null; path: string; firstLine?: number; error?: string }
42
43export type UI = {
44  sec: number
45  zoom: number | null
46  collapsed: number[]
47  cursor: (string | null)[]
48  offset: number[]
49  fold: Record<string, boolean>
50  open: Record<string, boolean>
51  step: string | null
52  previewOffset: number
53  // Preview shows the Plan row's brief (true) or the Artifacts row (false):
54  // whichever list the person last moved in.
55  brief: boolean
56}
57
58export type Row =
59  | { key: string; kind: 'ctx'; file: CtxFile }
60  | { key: string; kind: 'ms'; id: string }
61  | { key: string; kind: 'step'; step: Step }
62  | { key: string; kind: 'unattr'; count: number }
63  | { key: string; kind: 'issue'; text: string; severity: 'error' | 'warn' }
64  | { key: string; kind: 'repair'; phase: RepairPhase; count: number }
65  | { key: string; kind: 'note'; text: string }
66  | { key: string; kind: 'commit'; commit: Commit; isOpen: boolean }
67  | { key: string; kind: 'cfile'; commit: Commit; path: string; add: number; del: number }
68  | { key: string; kind: 'file'; file: ArtFile }
69  | { key: string; kind: 'empty'; text: string }
70
71export type Effect =
72  | { kind: 'none' }
73  | { kind: 'open'; path: string; line?: number }
74  | { kind: 'view'; target: Target }
75  | { kind: 'history'; path: string | null }
76  | { kind: 'repair'; yes: boolean }
77  | { kind: 'scaffold' }
78  | { kind: 'close' }
79
80export function initialUI(): UI {
81  // Keys starts folded: its header row still carries the hotkeys (view.ts).
82  return { sec: PLAN, zoom: null, collapsed: [KEYS], cursor: [null, null, null, null, null], offset: [0, 0, 0, 0, 0], fold: {}, open: {}, step: null, previewOffset: 0, brief: true }
83}
84
85// ── rows ────────────────────────────────────────────────────────────────
86
87export function isFolded(ui: UI, plan: Plan, id: string): boolean {
88  if (id in ui.fold) return ui.fold[id]
89  const m = plan.milestones.find((x) => x.id === id)
90  return !!m && isComplete(m) && !isActive(m)
91}
92
93export function selectedStep(ui: UI, data: Data): string | null {
94  if (ui.step !== null) return ui.step
95  const plan = data.plan
96  const a = plan ? activeStep(plan) : null
97  if (a) return a.id
98  const steps = plan ? allSteps(plan) : []
99  return steps.length ? steps[0].id : null
100}
101
102export function planRows(ui: UI, data: Data, repair: Repair): Row[] {
103  const plan = data.plan
104  if (!plan) return [{ key: 'p:none', kind: 'empty', text: 'no DEV_PLAN.md: run /project' }]
105  const rows: Row[] = []
106  const errors = errorsOf(plan)
107  if (errors.length && repair && repair.phase !== 'dismissed') {
108    rows.push({ key: 'p:repair', kind: 'repair', phase: repair.phase, count: errors.length })
109  }
110  if (errors.length) {
111    errors.slice(0, 4).forEach((i, n) => rows.push({ key: 'p:issue:' + n, kind: 'issue', text: (i.line ? 'L' + i.line + ' ' : '') + i.message, severity: 'error' }))
112  }
113  for (const m of plan.milestones) {
114    rows.push({ key: 'm:' + m.id, kind: 'ms', id: m.id })
115    if (isFolded(ui, plan, m.id)) continue
116    for (const s of m.steps) rows.push({ key: 's:' + s.id, kind: 'step', step: s })
117  }
118  const loose = data.files.get(UNATTRIBUTED)
119  if (loose && loose.length) rows.push({ key: 's:' + UNATTRIBUTED, kind: 'unattr', count: loose.length })
120  if (!plan.milestones.length) rows.push({ key: 'p:empty', kind: 'empty', text: 'no milestones yet' })
121  return rows
122}
123
124function isCommitOpen(ui: UI, commits: Commit[], sha: string): boolean {
125  if (sha in ui.open) return ui.open[sha]
126  return commits.length > 0 && commits[0].sha === sha
127}
128
129function artRows(ui: UI, data: Data): Row[] {
130  const id = selectedStep(ui, data)
131  if (id === null) return [{ key: 'a:none', kind: 'empty', text: 'select a step' }]
132  const rows: Row[] = []
133  const step = data.plan ? findStep(data.plan, id) : null
134  // The brief (4) and p show all of it; here a glance.
135  if (step) {
136    step.notes.slice(0, NOTE_ROWS).forEach((t, n) => rows.push({ key: 'n:' + n, kind: 'note', text: t }))
137    const more = step.notes.length - NOTE_ROWS
138    if (more > 0) rows.push({ key: 'n:more', kind: 'note', text: '… ' + more + ' more line' + (more > 1 ? 's' : '') + ' (p)' })
139  }
140  const commits = data.commits.get(id) ?? []
141  for (const c of commits) {
142    const isOpen = isCommitOpen(ui, commits, c.sha)
143    rows.push({ key: 'k:' + c.sha, kind: 'commit', commit: c, isOpen })
144    if (isOpen) for (const f of c.files) rows.push({ key: 'kf:' + c.sha + ':' + f.path, kind: 'cfile', commit: c, path: f.path, add: f.add, del: f.del })
145  }
146  for (const f of data.files.get(id) ?? []) rows.push({ key: 'f:' + f.path, kind: 'file', file: f })
147  if (!commits.length && !(data.files.get(id) ?? []).length) rows.push({ key: 'a:empty', kind: 'empty', text: 'no commits or files yet' })
148  return rows
149}
150
151function ctxRows(data: Data): Row[] {
152  return data.context.map((f) => ({ key: 'c:' + f.name, kind: 'ctx', file: f }) as Row)
153}
154
155export function rowsOf(sec: number, ui: UI, data: Data, repair: Repair): Row[] {
156  if (sec === CTX) return ctxRows(data)
157  if (sec === PLAN) return planRows(ui, data, repair)
158  if (sec === ART) return artRows(ui, data)
159  return []
160}
161
162// ── preview target ──────────────────────────────────────────────────────
163
164function targetOf(row: Row | undefined): Target | null {
165  if (!row) return null
166  if (row.kind === 'commit') return { kind: 'commit', sha: row.commit.sha, short: row.commit.short }
167  if (row.kind === 'cfile') return { kind: 'cfile', sha: row.commit.sha, short: row.commit.short, path: row.path }
168  if (row.kind === 'file') return { kind: 'file', path: row.file.path, rel: row.file.rel, state: row.file.state }
169  return null
170}
171
172const NOTE_ROWS = 3
173
174// A milestone's or step's lines in DEV_PLAN.md.
175function briefOf(plan: Plan | null, id: string | null): Target | null {
176  if (!plan || id === null) return null
177  const m = plan.milestones.find((x) => x.id === id)
178  if (m) return { kind: 'brief', id, from: m.line, to: m.end }
179  const s = findStep(plan, id)
180  return s ? { kind: 'brief', id, from: s.line, to: s.end } : null
181}
182
183// In brief mode the Plan row under the cursor (else the selected step);
184// otherwise the Artifacts row under the cursor, else the first previewable one.
185export function previewTarget(ui: UI, data: Data): Target | null {
186  if (ui.brief) {
187    const row = planRows(ui, data, null).find((r) => r.key === ui.cursor[PLAN])
188    const id = row?.kind === 'ms' ? row.id : row?.kind === 'step' ? row.step.id : selectedStep(ui, data)
189    const b = briefOf(data.plan, id)
190    if (b) return b
191  }
192  const rows = artRows(ui, data)
193  const at = rows.find((r) => r.key === ui.cursor[ART])
194  return targetOf(at) ?? rows.map(targetOf).find((t) => t !== null) ?? null
195}
196
197export function targetKey(t: Target): string {
198  if (t.kind === 'brief') return 'brief:' + t.id + ':' + t.from + '-' + t.to
199  if (t.kind === 'commit') return 'commit:' + t.sha
200  if (t.kind === 'cfile') return 'commit:' + t.sha
201  return 'file:' + t.path + ':' + t.state
202}
203
204export function targetTitle(t: Target | null): string {
205  if (!t) return '—'
206  if (t.kind === 'brief') return t.id + ' brief'
207  if (t.kind === 'file') return t.rel.split('/').pop() ?? t.rel
208  if (t.kind === 'cfile') return t.short + ' ' + (t.path.split('/').pop() ?? '')
209  return t.short
210}
211
212export function previewRowCount(p: Preview | null): number {
213  if (!p) return 0
214  if (p.files) return diffRows(p.files).length
215  if (p.source !== null) return sourceRowCount(p.source)
216  return 1
217}
218
219// Cells Code's gutter takes beside a line, measured live on 2.1.296: the
220// line number padded by one, a space, then the +/-/space marker for a diff.
221function gutterWidth(p: Preview): number {
222  if (p.files) {
223    const top = Math.max(1, ...p.files.flatMap((f) => f.hunks.map((h) => Math.max(h.oldStart, h.newStart) + h.lines.length)))
224    return String(top).length + 3
225  }
226  return String((p.firstLine ?? 1) + (p.source === null ? 1 : sourceRowCount(p.source))).length + 2
227}
228
229// Screen rows each preview row takes at this pane width (Code wraps).
230export function previewCosts(p: Preview | null, width: number): number[] {
231  if (!p || p.error) return []
232  const room = Math.max(8, width - gutterWidth(p))
233  if (p.files) return diffCosts(p.files, room)
234  if (p.source !== null) return sourceCosts(p.source, room)
235  return []
236}
237
238// For a commit-file target the preview starts at that file's header.
239export function fileRowIndex(p: Preview | null, path: string): number {
240  if (!p || !p.files) return 0
241  const rows = diffRows(p.files)
242  const fi = p.files.findIndex((f) => f.path === path)
243  const idx = rows.findIndex((r) => r.kind === 'file' && r.file === fi)
244  return Math.max(0, idx)
245}
246
247// ── layout ──────────────────────────────────────────────────────────────
248
249const LEGEND_ROWS = 3
250const ALL = SECTIONS.map((_, i) => i)
251
252export function isShown(ui: UI, sec: number): boolean {
253  return ui.zoom === null ? !ui.collapsed.includes(sec) : ui.zoom === sec
254}
255
256// Body rows for each section's content (headers and legend excluded).
257export function allocate(ui: UI, counts: number[], bodyRows: number, legendRows = LEGEND_ROWS): number[] {
258  const al = ALL.map(() => 0)
259  const keys = !ui.collapsed.includes(KEYS) || ui.zoom === KEYS ? legendRows : 0
260  const avail = Math.max(4, bodyRows - SECTIONS.length - keys)
261  if (ui.zoom !== null) {
262    al[ui.zoom] = ui.zoom === KEYS ? legendRows : avail
263    return al
264  }
265  al[KEYS] = keys
266  const shown = ALL.map((s) => isShown(ui, s))
267  if (shown[CTX]) al[CTX] = Math.min(counts[CTX], 6)
268  let rest = avail - al[CTX]
269  if (shown[ART]) al[ART] = Math.min(counts[ART], Math.max(3, Math.round(rest * 0.3)))
270  if (shown[PREV]) al[PREV] = counts[PREV] ? Math.max(4, Math.round(rest * 0.3)) : 1
271  rest -= al[ART] + al[PREV]
272  if (shown[PLAN]) al[PLAN] = Math.max(0, rest)
273  else if (shown[PREV]) al[PREV] += Math.max(0, rest)
274  else if (shown[ART]) al[ART] += Math.max(0, rest)
275  else if (shown[CTX]) al[CTX] += Math.max(0, rest)
276  return al
277}
278
279// ── navigation ──────────────────────────────────────────────────────────
280
281function indexOf(rows: Row[], key: string | null): number {
282  if (key === null) return -1
283  const i = rows.findIndex((r) => r.key === key)
284  return i < 0 ? (rows.length ? 0 : -1) : i
285}
286
287function withCursor(ui: UI, sec: number, rows: Row[], idx: number, alloc: number): UI {
288  const i = Math.max(-1, Math.min(idx, rows.length - 1))
289  const cursor = ui.cursor.slice()
290  cursor[sec] = i < 0 ? null : rows[i].key
291  const offset = ui.offset.slice()
292  offset[sec] = keepVisible(offset[sec], i, alloc, rows.length)
293  const next = { ...ui, cursor, offset }
294  return sec === ART ? { ...next, previewOffset: 0 } : sec === PLAN ? followStep(next, rows[i]) : next
295}
296
297// Artifacts follows the step under the Plan cursor.
298function followStep(ui: UI, row: Row | undefined): UI {
299  if (!row || (row.kind !== 'step' && row.kind !== 'unattr')) return ui
300  const id = row.kind === 'step' ? row.step.id : UNATTRIBUTED
301  if (ui.step === id) return ui
302  const cursor = ui.cursor.slice()
303  cursor[ART] = null
304  const offset = ui.offset.slice()
305  offset[ART] = 0
306  return { ...ui, step: id, cursor, offset, previewOffset: 0 }
307}
308
309function keepVisible(offset: number, idx: number, alloc: number, n: number): number {
310  let o = offset
311  if (idx >= 0) {
312    if (idx < o) o = idx
313    if (idx >= o + alloc) o = idx - alloc + 1
314  }
315  return Math.max(0, Math.min(o, Math.max(0, n - alloc)))
316}
317
318export type Ctx = { data: Data; repair: Repair; alloc: number[]; previewCosts: number[] }
319
320export type Key =
321  | 'j' | 'k' | 'h' | 'l' | 'g' | 'e' | 'o' | 'd' | 'v' | 'p' | 'z' | 'y' | 'n' | 'q' | 'f' | 'b'
322  | '1' | '2' | '3' | '4' | '5' | 'pagedown' | 'pageup' | 'prevsec' | 'nextsec'
323
324// Move to a section; under zoom, the zoom follows.
325function goTo(ui: UI, sec: number): UI {
326  return { ...ui, sec, zoom: ui.zoom !== null ? sec : null }
327}
328
329// Move to a section and make sure it is drawn: zoomed to if zoom is on, unfolded.
330function reveal(ui: UI, sec: number): UI {
331  return { ...goTo(ui, sec), collapsed: ui.collapsed.filter((s) => s !== sec) }
332}
333
334// A step (or the unattributed bucket) opened into Artifacts.
335function openStep(ui: UI, row: Row): UI {
336  const id = row.kind === 'step' ? row.step.id : UNATTRIBUTED
337  return reveal({ ...followStep(ui, row), step: id }, ART)
338}
339
340// Enter a section from the top (its header) or the bottom (its last row).
341function enter(ui: UI, sec: number, from: 'top' | 'bottom', c: Ctx): UI {
342  const next = goTo(ui, sec)
343  if (sec === PREV) return { ...next, previewOffset: from === 'top' ? 0 : next.previewOffset }
344  const rows = rowsOf(sec, next, c.data, c.repair)
345  if (from === 'top' || !isShown(next, sec) || !rows.length) {
346    const cursor = next.cursor.slice()
347    cursor[sec] = null
348    return { ...next, cursor }
349  }
350  return withCursor(next, sec, rows, rows.length - 1, Math.max(1, c.alloc[sec]))
351}
352
353// Moving in Plan shows briefs in Preview; moving in Artifacts, their diffs.
354function settle(ui: UI): UI {
355  if (ui.sec === PLAN && !ui.brief) return { ...ui, brief: true, previewOffset: 0 }
356  if (ui.sec === ART && ui.brief) return { ...ui, brief: false, previewOffset: 0 }
357  return ui
358}
359
360export function press(ui: UI, pressed: Key, c: Ctx): { ui: UI; effect: Effect } {
361  const r = pressIn(ui, pressed, c)
362  return { ...r, ui: settle(r.ui) }
363}
364
365function pressIn(ui: UI, pressed: Key, c: Ctx): { ui: UI; effect: Effect } {
366  let key = pressed
367  const none: Effect = { kind: 'none' }
368  const sec = ui.sec
369  const rows = rowsOf(sec, ui, c.data, c.repair)
370  const idx = indexOf(rows, ui.cursor[sec])
371  const alloc = Math.max(1, c.alloc[sec])
372  const page = Math.max(1, alloc - 1)
373
374  if (key === 'q') return { ui, effect: { kind: 'close' } }
375  if (key === 'f') key = 'pagedown'
376  if (key === 'b') key = 'pageup'
377  if (key === '1' || key === '2' || key === '3' || key === '4' || key === '5') {
378    const n = Number(key) - 1
379    return { ui: enter(reveal(ui, n), n, 'top', c), effect: none }
380  }
381  if (key === 'y' || key === 'n') return { ui, effect: c.repair && c.repair.phase === 'ask' ? { kind: 'repair', yes: key === 'y' } : none }
382  if (key === 'prevsec' || key === 'nextsec') return { ui: enter(ui, (sec + (key === 'nextsec' ? 1 : SECTIONS.length - 1)) % SECTIONS.length, 'top', c), effect: none }
383  if (key === 'z') return { ui: { ...ui, zoom: ui.zoom === sec ? null : sec, collapsed: ui.collapsed.filter((s) => s !== sec) }, effect: none }
384
385  if (sec === KEYS) {
386    if (key === 'k' || key === 'g') return { ui: enter(ui, PREV, 'bottom', c), effect: none }
387    if (key === 'h') return { ui: collapse(ui, KEYS, true), effect: none }
388    if (key === 'l') return { ui: collapse(ui, KEYS, false), effect: none }
389    if (key === 'o') return { ui: collapse(ui, KEYS, isShown(ui, KEYS)), effect: none }
390    return { ui, effect: none }
391  }
392
393  if (sec === PREV) {
394    const costs = c.previewCosts
395    const off = ui.previewOffset
396    const max = lastOffset(costs, alloc)
397    const at = (o: number) => ({ ui: { ...ui, previewOffset: Math.max(0, Math.min(o, max)) }, effect: none })
398    if (key === 'j' && (off >= max || !isShown(ui, PREV))) return { ui: enter(ui, KEYS, 'top', c), effect: none }
399    if (key === 'j') return at(off + 1)
400    if (key === 'k' && off === 0) return { ui: enter(ui, ART, 'bottom', c), effect: none }
401    if (key === 'k') return at(off - 1)
402    if (key === 'pagedown') return at(off + fitCount(costs, off, alloc))
403    if (key === 'pageup') return at(pageBack(costs, off, alloc))
404    if (key === 'g') return at(0)
405    if (key === 'e') return at(max)
406    if (key === 'h') return { ui: isShown(ui, PREV) ? { ...ui, sec: ui.brief ? PLAN : ART } : ui, effect: none }
407    if (key === 'l' || key === 'o') return { ui: collapse(ui, PREV, false), effect: none }
408    if (key === 'p') return { ui, effect: planLine(ui, c.data) }
409    const t = previewTarget(ui, c.data)
410    if (key === 'd' && t?.kind === 'brief') return { ui, effect: { kind: 'open', path: c.data.planPath, line: t.from } }
411    if (key === 'd' && t) return { ui, effect: { kind: 'view', target: t } }
412    if (key === 'v' && t) return { ui, effect: historyOf(t, c.data) }
413    return { ui, effect: none }
414  }
415
416  // j/k flow through the sections as one list.
417  const last = isShown(ui, sec) ? rows.length - 1 : -1
418  if (key === 'j' && idx >= last) return { ui: enter(ui, sec + 1, 'top', c), effect: none }
419  if (key === 'k' && idx === -1) return { ui: sec > 0 ? enter(ui, sec - 1, 'bottom', c) : ui, effect: none }
420
421  if (!isShown(ui, sec)) {
422    if (key === 'l' || key === 'o') return { ui: collapse(ui, sec, false), effect: none }
423    return { ui, effect: none }
424  }
425
426  if (key === 'j') return { ui: withCursor(ui, sec, rows, idx + 1, alloc), effect: none }
427  if (key === 'k') return { ui: withCursor(ui, sec, rows, idx - 1, alloc), effect: none }
428  if (key === 'pagedown') return { ui: withCursor(ui, sec, rows, Math.max(0, idx) + page, alloc), effect: none }
429  if (key === 'pageup') return { ui: withCursor(ui, sec, rows, idx - page, alloc), effect: none }
430  if (key === 'g') return { ui: withCursor(ui, sec, rows, -1, alloc), effect: none }
431  if (key === 'e') return { ui: withCursor(ui, sec, rows, rows.length - 1, alloc), effect: none }
432
433  const row = idx >= 0 ? rows[idx] : undefined
434  if (!row) {
435    // The section header.
436    if (key === 'h') return { ui: collapse(ui, sec, true), effect: none }
437    if (key === 'l') return { ui: collapse(ui, sec, false), effect: none }
438    if (key === 'o') return { ui: collapse(ui, sec, isShown(ui, sec)), effect: none }
439    return { ui, effect: none }
440  }
441
442  if (key === 'p') {
443    if (row.kind === 'ms') return { ui, effect: { kind: 'open', path: c.data.planPath, line: c.data.plan?.milestones.find((m) => m.id === row.id)?.line } }
444    if (row.kind === 'step') return { ui, effect: { kind: 'open', path: c.data.planPath, line: row.step.line } }
445    if (sec === ART) return { ui, effect: planLine(ui, c.data) }
446    return { ui, effect: none }
447  }
448
449  // h/l also move along the drill-down: step → its Artifacts → a commit or
450  // file → Preview, and h back out again.
451  if (key === 'h' || key === 'l') {
452    const folding = key === 'h'
453    const back = (to: number) => ({ ui: { ...ui, sec: to }, effect: none })
454    if (sec === ART && folding && !(row.kind === 'cfile' || (row.kind === 'commit' && row.isOpen))) return back(PLAN)
455    if (sec === ART && !folding && (row.kind === 'cfile' || row.kind === 'file' || (row.kind === 'commit' && row.isOpen))) {
456      return { ui: reveal(ui, PREV), effect: none }
457    }
458    if (!folding && (row.kind === 'step' || row.kind === 'unattr')) return { ui: openStep(ui, row), effect: none }
459    if (row.kind === 'ms') return { ui: { ...ui, fold: { ...ui.fold, [row.id]: folding } }, effect: none }
460    if (row.kind === 'step' && folding) {
461      const next = { ...ui, fold: { ...ui.fold, [row.step.milestone]: true } }
462      const r2 = rowsOf(PLAN, next, c.data, c.repair)
463      return { ui: withCursor(next, PLAN, r2, r2.findIndex((r) => r.key === 'm:' + row.step.milestone), alloc), effect: none }
464    }
465    if (row.kind === 'commit') return { ui: { ...ui, open: { ...ui.open, [row.commit.sha]: !folding } }, effect: none }
466    if (row.kind === 'cfile' && folding) {
467      const next = { ...ui, open: { ...ui.open, [row.commit.sha]: false } }
468      const r2 = rowsOf(ART, next, c.data, c.repair)
469      return { ui: withCursor(next, ART, r2, r2.findIndex((r) => r.key === 'k:' + row.commit.sha), alloc), effect: none }
470    }
471    if (sec === CTX) return { ui: collapse(ui, sec, folding), effect: none }
472    return { ui, effect: none }
473  }
474
475  if (key === 'o') {
476    if (row.kind === 'ctx') return { ui, effect: row.file.exists ? { kind: 'open', path: row.file.path } : none }
477    if (row.kind === 'ms') return { ui: { ...ui, fold: { ...ui.fold, [row.id]: !isFolded(ui, c.data.plan!, row.id) } }, effect: none }
478    if (row.kind === 'step' || row.kind === 'unattr') return { ui: openStep(ui, row), effect: none }
479    if (row.kind === 'repair') return { ui, effect: row.phase === 'ask' ? { kind: 'repair', yes: true } : none }
480    // A past commit is read as its change, not as today's file.
481    if (row.kind === 'commit' || row.kind === 'cfile') return { ui, effect: { kind: 'view', target: targetOf(row)! } }
482    if (row.kind === 'file') return { ui, effect: row.file.state === 'D' ? none : { kind: 'open', path: row.file.path } }
483    if (row.kind === 'empty' && row.key === 'p:none') return { ui, effect: { kind: 'scaffold' } }
484    return { ui, effect: none }
485  }
486
487  if (key === 'd') {
488    const t = targetOf(row)
489    if (t) return { ui, effect: { kind: 'view', target: t } }
490    if (row.kind === 'ctx' && row.file.exists) return { ui, effect: { kind: 'open', path: row.file.path } }
491  }
492  if (key === 'v') {
493    const t = targetOf(row)
494    if (t) return { ui, effect: historyOf(t, c.data) }
495    if (row.kind === 'ctx' && row.file.exists) return { ui, effect: { kind: 'history', path: row.file.path } }
496  }
497  return { ui, effect: none }
498}
499
500// The selected step's line in DEV_PLAN.md, where its notes live.
501function planLine(ui: UI, data: Data): Effect {
502  const id = selectedStep(ui, data)
503  const step = id && data.plan ? findStep(data.plan, id) : null
504  return step ? { kind: 'open', path: data.planPath, line: step.line } : { kind: 'none' }
505}
506
507// lazygit: a commit opens the log; a file opens that file's history.
508function historyOf(t: Target, data: Data): Effect {
509  if (t.kind === 'brief') return { kind: 'history', path: data.planPath }
510  if (t.kind === 'commit') return { kind: 'history', path: null }
511  if (t.kind === 'cfile') return { kind: 'history', path: joinPath(data.root ?? data.base, t.path) }
512  return { kind: 'history', path: t.path }
513}
514
515export function collapse(ui: UI, sec: number, on: boolean): UI {
516  const collapsed = on ? [...new Set([...ui.collapsed, sec])] : ui.collapsed.filter((s) => s !== sec)
517  const zoom = on && ui.zoom === sec ? null : !on && ui.zoom !== null && ui.zoom !== sec ? null : ui.zoom
518  const cursor = ui.cursor.slice()
519  if (on) cursor[sec] = null
520  return { ...ui, collapsed, zoom, cursor, sec }
521}
522
523export function joinPath(dir: string, rel: string): string {
524  return rel.startsWith('/') ? rel : dir.replace(/\/$/, '') + '/' + rel
525}
526
527// A person's ring move onto a row button: adopt it as the cursor.
528export function focusRow(ui: UI, data: Data, repair: Repair, rowKey: string, alloc: number[]): UI {
529  for (let sec = 0; sec < 3; sec++) {
530    const rows = rowsOf(sec, ui, data, repair)
531    const i = rows.findIndex((r) => r.key === rowKey)
532    if (i >= 0) return settle(withCursor({ ...ui, sec }, sec, rows, i, Math.max(1, alloc[sec])))
533  }
534  const h = /^h:(\d)$/.exec(rowKey)
535  if (h) {
536    const sec = Number(h[1])
537    const cursor = ui.cursor.slice()
538    if (sec !== PREV) cursor[sec] = null
539    return { ...ui, sec, cursor }
540  }
541  return ui
542}
543
544// Keep cursors and selection valid after the data changed underneath.
545export function reconcile(ui: UI, data: Data, repair: Repair): UI {
546  const cursor = ui.cursor.slice()
547  for (let sec = 0; sec < 3; sec++) {
548    if (cursor[sec] === null) continue
549    const rows = rowsOf(sec, ui, data, repair)
550    if (!rows.some((r) => r.key === cursor[sec])) cursor[sec] = rows.length ? rows[0].key : null
551  }
552  let step = ui.step
553  if (step !== null && step !== UNATTRIBUTED && !(data.plan && findStep(data.plan, step))) step = null
554  return { ...ui, cursor, step }
555}
556
557export function planSignature(plan: Plan | null): string {
558  if (!plan) return 'none'
559  return errorsOf(plan).map((i) => i.code + i.line).join('|')
560}
561
hooks/lib/palette.ts 53 lines
1// gruvbox-material (medium) hues, mirrored from dotfiles nvim/lua/config/palette.lua.
2// The mode comes from ~/.config/theme/mode; unknown → Claude Code theme keys.
3
4export type Mode = 'dark' | 'light' | null
5
6export type Roles = {
7  text: string
8  strong: string
9  dim: string
10  faint: string
11  done: string
12  doneDim: string
13  doing: string
14  cancelled: string
15  accent: string
16  add: string
17  del: string
18  err: string
19  sha: string
20  file: string
21  sel: string | undefined
22}
23
24const DARK = {
25  fg0: '#d4be98', fg1: '#ddc7a1', red: '#ea6962', orange: '#e78a4e', yellow: '#d8a657', green: '#a9b665',
26  aqua: '#89b482', blue: '#7daea3', purple: '#d3869b', grey0: '#7c6f64', grey1: '#928374', grey2: '#a89984', sel: '#45403d', greenDim: '#757d4d',
27}
28const LIGHT = {
29  fg0: '#654735', fg1: '#4f3829', red: '#c14a4a', orange: '#c35e0a', yellow: '#b47109', green: '#6c782e',
30  aqua: '#4c7a5d', blue: '#45707a', purple: '#945e80', grey0: '#a89984', grey1: '#928374', grey2: '#7c6f64', sel: '#eee0b7', greenDim: '#a5a86b',
31}
32
33export function parseMode(text: string | null | undefined): Mode {
34  const t = (text ?? '').trim()
35  return t === 'dark' || t === 'light' ? t : null
36}
37
38export function rolesFor(mode: Mode): Roles {
39  if (!mode) {
40    return {
41      text: 'text', strong: 'text', dim: 'inactive', faint: 'subtle', done: 'success', doneDim: 'success', doing: 'warning',
42      cancelled: 'subtle', accent: 'suggestion', add: 'success', del: 'error', err: 'error',
43      sha: 'warning', file: 'suggestion', sel: undefined,
44    }
45  }
46  const p = mode === 'dark' ? DARK : LIGHT
47  return {
48    text: p.fg0, strong: p.fg1, dim: p.grey1, faint: p.grey0, done: p.green, doneDim: p.greenDim, doing: p.yellow, cancelled: p.grey0,
49    accent: p.aqua, add: p.green, del: p.red, err: p.red, sha: p.orange, file: p.blue,
50    sel: p.sel,
51  }
52}
53
hooks/lib/plan.ts 301 lines
1// DEV_PLAN.md parser: the v2 grammar in SPEC §5 (v1 is its subset). Pure —
2// text in, Plan out. Only milestone headings, label lines and checkboxes
3// inside milestones are read; everything else is prose for people.
4
5type Status = 'todo' | 'doing' | 'done' | 'cancelled'
6
7export type Step = {
8  id: string
9  status: Status
10  title: string
11  notes: string[]
12  line: number
13  end: number
14  milestone: string
15}
16
17export type Milestone = {
18  id: string
19  title: string
20  line: number
21  end: number
22  steps: Step[]
23  acceptance: { done: number; total: number }
24}
25
26type IssueCode =
27  | 'dup-id'
28  | 'multi-doing'
29  | 'bad-marker'
30  | 'missing-id'
31  | 'orphan-step'
32  | 'dup-milestone'
33  | 'long-title'
34  | 'orphan-trailer'
35
36export type Issue = {
37  code: IssueCode
38  severity: 'error' | 'warn'
39  line: number
40  message: string
41}
42
43export type Plan = {
44  context: string[] | null
45  milestones: Milestone[]
46  issues: Issue[]
47}
48
49export const MAX_TITLE = 28
50
51const MARKERS: Record<string, Status> = { ' ': 'todo', '~': 'doing', x: 'done', X: 'done', '-': 'cancelled' }
52
53const MILESTONE_RE = /^(#{2,3})\s+(M\d+)\b\s*(?:[—:-]\s*)?(.*?)\s*$/
54const HEADING_RE = /^(#{1,6})\s+(.*?)\s*$/
55const LABEL_RE = /^\*\*([^*]+?):?\*\*:?\s*$/
56const STEP_RE = /^- \[(.)\]\s+(S\d+\.\d+)\s+(.*?)\s*$/
57const CHECKBOX_RE = /^- \[(.)\]\s*(.*?)\s*$/
58const NOTE_RE = /^\s+(?:[-*+]\s+)?(.*?)\s*$/
59
60export function parsePlan(text: string): Plan {
61  const lines = text.replace(/\r\n?/g, '\n').split('\n')
62  const issues: Issue[] = []
63  const milestones: Milestone[] = []
64  const seen = new Map<string, number>()
65  const seenMilestones = new Set<string>()
66  let context: string[] | null = null
67  let start = 0
68
69  if (lines[0] === '---') {
70    const end = lines.indexOf('---', 1)
71    if (end > 0) {
72      context = frontMatterContext(lines.slice(1, end))
73      start = end + 1
74    }
75  }
76
77  let milestone: Milestone | null = null
78  let level = 0
79  let label = ''
80  let step: Step | null = null
81  let inComment = false
82  let inFence = false
83  let lastText = start
84
85  const close = (at: number) => {
86    if (milestone) milestone.end = Math.max(milestone.line, at)
87  }
88
89  for (let i = start; i < lines.length; i++) {
90    const raw = lines[i]
91    const lineNo = i + 1
92
93    if (inComment) {
94      if (raw.includes('-->')) inComment = false
95      continue
96    }
97    if (/^\s*```/.test(raw)) {
98      inFence = !inFence
99      step = null
100      lastText = lineNo
101      continue
102    }
103    if (inFence) {
104      lastText = lineNo
105      continue
106    }
107    if (/^\s*<!--/.test(raw)) {
108      if (!raw.includes('-->')) inComment = true
109      continue
110    }
111
112    const m = MILESTONE_RE.exec(raw)
113    if (m) {
114      close(lastText)
115      if (seenMilestones.has(m[2])) {
116        issues.push(issue('dup-milestone', 'error', lineNo, `duplicate milestone ${m[2]}`))
117      }
118      seenMilestones.add(m[2])
119      milestone = { id: m[2], title: m[3], line: lineNo, end: lineNo, steps: [], acceptance: { done: 0, total: 0 } }
120      milestones.push(milestone)
121      level = m[1].length
122      label = ''
123      step = null
124      lastText = lineNo
125      continue
126    }
127    const h = HEADING_RE.exec(raw)
128    if (h) {
129      step = null
130      if (milestone && h[1].length > level) {
131        // Deeper than the milestone: a heading for people, inside it.
132        label = ''
133        lastText = lineNo
134        continue
135      }
136      close(lastText)
137      milestone = null
138      label = ''
139      continue
140    }
141    if (raw.trim() !== '') lastText = lineNo
142
143    const lab = milestone ? LABEL_RE.exec(raw.trim()) : null
144    if (lab) {
145      label = lab[1].trim().toLowerCase()
146      step = null
147      continue
148    }
149
150    const tracked = label === '' || label === 'deliverables'
151    const s = STEP_RE.exec(raw)
152    if (s && (tracked || !milestone)) {
153      const status = MARKERS[s[1]]
154      const [, marker, id, text] = s
155      if (!status) {
156        issues.push(issue('bad-marker', 'error', lineNo, `${id}: unknown marker [${marker}]`))
157      }
158      if (seen.has(id)) {
159        issues.push(issue('dup-id', 'error', lineNo, `duplicate ID ${id} (first on line ${seen.get(id)})`))
160      } else {
161        seen.set(id, lineNo)
162      }
163      const cut = text.indexOf(' — ')
164      const title = cut >= 0 ? text.slice(0, cut).trim() : text
165      const desc = cut >= 0 ? text.slice(cut + 3).trim() : ''
166      if (title.length > MAX_TITLE) {
167        issues.push(issue('long-title', 'warn', lineNo, `${id}: title over ${MAX_TITLE} chars`))
168      }
169      if (!milestone) {
170        issues.push(issue('orphan-step', 'error', lineNo, `${id} is outside any milestone`))
171        step = null
172        continue
173      }
174      step = { id, status: status ?? 'todo', title, notes: desc ? [desc] : [], line: lineNo, end: lineNo, milestone: milestone.id }
175      milestone.steps.push(step)
176      continue
177    }
178
179    const c = CHECKBOX_RE.exec(raw)
180    if (c && milestone) {
181      step = null
182      if (label === 'acceptance') {
183        milestone.acceptance.total++
184        if (c[1] === 'x' || c[1] === 'X') milestone.acceptance.done++
185      } else if (tracked) {
186        issues.push(issue('missing-id', 'error', lineNo, `deliverable without an ID: "${c[2].slice(0, 30)}"`))
187      }
188      continue
189    }
190
191    if (step && raw.trim() !== '') {
192      const n = NOTE_RE.exec(raw)
193      if (n) {
194        step.notes.push(n[1])
195        step.end = lineNo
196        continue
197      }
198    }
199    if (raw.trim() !== '') step = null
200  }
201  close(lastText)
202
203  const doing = milestones.flatMap((ms) => ms.steps).filter((st) => st.status === 'doing') // allSteps needs a Plan
204  if (doing.length > 1) {
205    issues.push(
206      issue('multi-doing', 'error', doing[1].line, `${doing.length} steps in progress: ${doing.map((d) => d.id).join(', ')}`),
207    )
208  }
209
210  issues.sort((a, b) => a.line - b.line)
211  return { context, milestones, issues }
212}
213
214function issue(code: IssueCode, severity: Issue['severity'], line: number, message: string): Issue {
215  return { code, severity, line, message }
216}
217
218// Minimal YAML for the one key we read: toc.context as an inline or block list.
219function frontMatterContext(lines: string[]): string[] | null {
220  let inToc = false
221  let list: string[] | null = null
222  let inList = false
223  for (const raw of lines) {
224    if (/^toc:\s*$/.test(raw)) {
225      inToc = true
226      continue
227    }
228    if (/^\S/.test(raw)) {
229      inToc = false
230      inList = false
231      continue
232    }
233    if (!inToc) continue
234    const inline = /^\s+context:\s*\[(.*)\]\s*$/.exec(raw)
235    if (inline) {
236      list = inline[1].split(',').map(unquote).filter(Boolean)
237      inList = false
238      continue
239    }
240    if (/^\s+context:\s*$/.test(raw)) {
241      list = []
242      inList = true
243      continue
244    }
245    const item = /^\s+-\s+(.*)$/.exec(raw)
246    if (inList && item && list) {
247      list.push(unquote(item[1]))
248      continue
249    }
250    inList = false
251  }
252  return list
253}
254
255function unquote(s: string): string {
256  return s.trim().replace(/^['"]|['"]$/g, '')
257}
258
259export function allSteps(plan: Plan): Step[] {
260  return plan.milestones.flatMap((m) => m.steps)
261}
262
263export function activeStep(plan: Plan): Step | null {
264  return allSteps(plan).find((s) => s.status === 'doing') ?? null
265}
266
267export function findStep(plan: Plan, id: string): Step | null {
268  return allSteps(plan).find((s) => s.id === id) ?? null
269}
270
271// What "broken" means: the repair offer and the red header both use this.
272export function errorsOf(plan: Plan | null): Issue[] {
273  return plan ? plan.issues.filter((i) => i.severity === 'error') : []
274}
275
276export function progress(steps: Step[]): { done: number; live: number } {
277  const live = steps.filter((s) => s.status !== 'cancelled').length
278  const done = steps.filter((s) => s.status === 'done').length
279  return { done, live }
280}
281
282// Done when every live step is done and every acceptance box is checked.
283export function isComplete(m: Milestone): boolean {
284  const { done, live } = progress(m.steps)
285  return m.steps.length > 0 && done === live && m.acceptance.done === m.acceptance.total
286}
287
288export function isActive(m: Milestone): boolean {
289  return m.steps.some((s) => s.status === 'doing')
290}
291
292// Trailer IDs that point at no step in the plan: warns, never errors.
293export function orphanTrailers(plan: Plan, trailerIds: Iterable<string>): Issue[] {
294  const ids = new Set(allSteps(plan).map((s) => s.id))
295  const out: Issue[] = []
296  for (const id of new Set(trailerIds)) {
297    if (!ids.has(id)) out.push(issue('orphan-trailer', 'warn', 0, `commits name ${id}, which the plan no longer has`))
298  }
299  return out
300}
301
hooks/lib/text.ts 152 lines
1// Fixed text: the DEV_PLAN.md template, the rules block Claude reads, the
2// repair agent's prompts, and the keybindings toc needs.
3
4import { MAX_TITLE } from './plan.ts'
5import type { Issue } from './plan.ts'
6import { ACTIONS } from './view.ts'
7
8// The machine part of DEV_PLAN.md (SPEC §5). Everything else is prose.
9export const GRAMMAR = `### M<n> — Title            milestone (H3 under "## Milestones"; runs to the next H3/H2)
10**Deliverables**               label line; below it (or before any label), checkboxes are steps:
11- [ ] S<n>.<k> Short title — one-sentence description
12  indented lines / sub-bullets: the step's detail
13**Acceptance**                 checkboxes here are counted, never given IDs
14**Tests** (or any other label) checkboxes here are ignored
15Markers: [ ] todo · [~] in progress · [x] done · [-] cancelled`
16
17export function devPlanTemplate(name: string): string {
18  return `---
19toc:
20  context: [SPEC.md, DEV_PLAN.md, KNOWLEDGE.md, SCRATCHPAD.md]
21---
22<!-- toc reads milestones, steps and acceptance (grammar in the toc:dev-plan
23skill); all other text is for people. Step IDs are stable, one [~] at a time. -->
24
25# ${name} — dev plan
26
27> **Status:** not started.
28> **Goal:** <the outcome, in a sentence or two>
29
30## Scope
31
32**In:** …
33**Out, deliberately:** …
34
35## Milestones
36
37### M1 — <What the first milestone delivers>
38<Why it exists: what it proves, the risk it retires.>
39
40**Deliverables**
41- [ ] S1.1 <Short title> — <one-sentence description>
42
43**Acceptance**
44- [ ] <A binary, testable statement of done.>
45
46**Tests**
471. <How the acceptance is demonstrated.>
48
49## Decisions
50
51## Definition of done
52`
53}
54
55// What Claude reads every turn while toc is active (SPEC §7.4): behaviour
56// only; the grammar itself lives in the toc:dev-plan skill.
57export function rulesBlock(planPath: string): string {
58  return `The toc sidebar is showing ${planPath}, a dev plan whose milestones (### M<n> — Title), steps (checkboxes under **Deliverables**: - [ ] S<n>.<k> Short title — description) and **Acceptance** checkboxes it reads. To write or restructure the plan, use the toc:dev-plan skill.
59Rules: step IDs are stable (never renumber or reuse). At most one step is [~]: mark a step [~] before you start it (your edits and commits are attributed to it), [x] when its deliverable exists, [-] with the reason if dropped. Tick an Acceptance box only after you have seen it pass. Short titles <= ${MAX_TITLE} chars. toc adds the Plan-Step trailer to your git commits; don't add it yourself.`
60}
61
62export const REPAIR_SYSTEM = `You repair a project's DEV_PLAN.md so the claude-toc sidebar can parse it again.
63The grammar:
64${GRAMMAR}
65Rules for the repair:
66- Make the smallest edits that fix the listed issues. The file is mostly prose for people: never rewrite prose, titles, descriptions or notes otherwise.
67- Never change a step ID listed as "used by commits": commits point at those IDs.
68- Duplicate ID: the first occurrence keeps the ID (commits point at it); give the later one the next unused ID in its milestone.
69- More than one [~]: keep [~] on the step the recent conversation shows is being worked on; set the others to [ ] (or [x] if the conversation shows they were finished).
70- Unknown marker: map it to the closest of [ ] [~] [x] [-].
71- Deliverable without an ID: give it the next unused ID in its milestone. Checkboxes under **Acceptance** never have IDs: if one does, remove the ID and leave the line where it is.
72- Step outside a milestone: move it under the milestone it belongs to, or the last one.
73Use the Edit tool on the file. Finish with one line: "Repaired: <what changed>".`
74
75export function repairTask(path: string, issues: Issue[], usedIds: string[], excerpt: string): string {
76  const list = issues.map((i) => `- line ${i.line}: ${i.message} [${i.code}]`).join('\n')
77  return `Repair ${path}.
78
79Issues the parser found:
80${list}
81
82Step IDs used by commits (never change these): ${usedIds.length ? usedIds.join(', ') : 'none'}
83
84Recent conversation (newest last), to judge which step is really in progress:
85${excerpt || '(none)'}`
86}
87
88const EXCERPT_CHARS = 6000
89
90export function transcriptExcerpt(messages: { role: string; text: string }[]): string {
91  const lines: string[] = []
92  let used = 0
93  for (let i = messages.length - 1; i >= 0 && used < EXCERPT_CHARS; i--) {
94    const m = messages[i]
95    const text = (m.text ?? '').replace(/\s+/g, ' ').trim()
96    if (!text) continue
97    const line = `${m.role}: ${text.slice(0, 600)}`
98    lines.unshift(line)
99    used += line.length
100  }
101  return lines.join('\n')
102}
103
104// ── keybindings ─────────────────────────────────────────────────────────
105
106export const KEYBINDINGS: { context: string; key: string; action: string }[] = [
107  // Global, not Chat: Chat bindings are inactive while the pane has focus.
108  { context: 'Global', key: 'ctrl+x t', action: ACTIONS.toggle },
109  { context: 'Global', key: 'meta+j', action: ACTIONS.pagedown },
110  { context: 'Global', key: 'meta+k', action: ACTIONS.pageup },
111  { context: 'Global', key: 'meta+h', action: ACTIONS.prevsec },
112  { context: 'Global', key: 'meta+l', action: ACTIONS.nextsec },
113]
114
115type Block = { context: string; bindings: Record<string, string | null> }
116
117// Merge toc's bindings into keybindings.json text. Never overrides a key the
118// user bound to something else; reports it as a conflict instead.
119export function mergeKeybindings(text: string | null): { text: string; added: string[]; conflicts: string[] } {
120  let doc: { bindings?: Block[]; [k: string]: unknown }
121  try {
122    doc = text && text.trim() ? JSON.parse(text) : {}
123  } catch {
124    throw new Error('keybindings.json is not valid JSON; fix it first')
125  }
126  const blocks: Block[] = Array.isArray(doc.bindings) ? doc.bindings : []
127  const added: string[] = []
128  const conflicts: string[] = []
129  for (const b of KEYBINDINGS) {
130    let block = blocks.find((x) => x.context === b.context)
131    if (!block) {
132      block = { context: b.context, bindings: {} }
133      blocks.push(block)
134    }
135    const have = block.bindings[b.key]
136    if (have === b.action) continue
137    if (have !== undefined) {
138      conflicts.push(`${b.context} ${b.key} is bound to ${have}`)
139      continue
140    }
141    block.bindings[b.key] = b.action
142    added.push(`${b.context} ${b.key}`)
143  }
144  const out = {
145    $schema: 'https://www.schemastore.org/claude-code-keybindings.json',
146    $docs: 'https://code.claude.com/docs/en/keybindings',
147    ...doc,
148    bindings: blocks,
149  }
150  return { text: JSON.stringify(out, null, 2) + '\n', added, conflicts }
151}
152
hooks/lib/view.ts 323 lines
1// The sidebar drawing: state + data → element tree. Elements come from
2// $.ui.resolve(e) in register.ts and are passed in as `el`.
3
4import { diffPage, diffRows, fitCount, pageStarts, sourcePage } from './diff.ts'
5import type { Segment } from './diff.ts'
6import {
7  ART, CTX, KEYS, PLAN, PREV, SECTIONS, isFolded, isShown, planRows, previewCosts, previewTarget, rowsOf, selectedStep, targetTitle,
8} from './model.ts'
9import type { Data, Preview, Repair, Row, UI } from './model.ts'
10import { errorsOf, isActive, isComplete, progress } from './plan.ts'
11import type { Roles } from './palette.ts'
12
13type El = { Box: Function; Text: Function; Button: Function; Code: Function }
14type Act = { row: (key: string) => () => void; key: (k: string) => () => void }
15
16export type ViewInput = {
17  el: El
18  ui: UI
19  data: Data
20  repair: Repair
21  preview: Preview | null
22  alloc: number[]
23  width: number
24  roles: Roles
25  act: Act
26  focused: boolean
27}
28
29// Keys a mod can't get as hotkeys arrive through these spare engine actions
30// (KNOWLEDGE.md: Alt bindings in the Global context press the Button). They
31// must have no engine handler: app:toggleReplTab (v0.1) is Claude's "switch
32// tab" as soon as a second pane such as /diff is open (KNOWLEDGE.md).
33export const ACTIONS = {
34  toggle: 'strip:jump1',
35  pagedown: 'strip:jump2',
36  pageup: 'strip:jump3',
37  prevsec: 'confirm:previousField',
38  nextsec: 'permission:toggleDebug',
39} as const
40
41const STATUS_GLYPH = { todo: '○', doing: '◐', done: '✓', cancelled: '✗' } as const
42
43function fit(s: string, n: number): string {
44  if (n <= 0) return ''
45  const chars = Array.from(s)
46  return chars.length > n ? chars.slice(0, Math.max(0, n - 1)).join('') + '…' : s + ' '.repeat(n - chars.length)
47}
48
49function age(now: number, mtimeMs: number): string {
50  const s = Math.max(0, Math.round((now - mtimeMs) / 1000))
51  if (s < 60) return s + 's'
52  if (s < 3600) return Math.round(s / 60) + 'm'
53  if (s < 86400) return Math.round(s / 3600) + 'h'
54  if (s < 86400 * 14) return Math.round(s / 86400) + 'd'
55  return Math.round(s / (86400 * 7)) + 'w'
56}
57
58function len(s: string): number {
59  return Array.from(s).length
60}
61
62export function render(v: ViewInput) {
63  const { el, ui, roles } = v
64  const children: unknown[] = []
65  for (let sec = 0; sec < SECTIONS.length; sec++) {
66    children.push(header(v, sec))
67    if (sec === KEYS) children.push(legend(v, isShown(ui, KEYS)))
68    if (!isShown(ui, sec) || sec === KEYS) continue
69    if (sec === PREV) children.push(...previewBody(v))
70    else {
71      const rows = rowsOf(sec, ui, v.data, v.repair)
72      const off = ui.offset[sec]
73      rows.slice(off, off + v.alloc[sec]).forEach((r) => children.push(rowView(v, sec, r)))
74    }
75  }
76  return el.Box({ flexDirection: 'column', children })
77}
78
79// One row: colored prefix, the label as a plain Button (the click target and
80// the focus ring, as /diff's file rows do — a Button's label can't be
81// colored, only dimmed), then a colored tail.
82function line(v: ViewInput, key: string, isCursor: boolean, prefix: unknown[], label: string, dim: boolean, tail: unknown[]) {
83  const { el, roles } = v
84  const button = el.Button(dim
85    ? { key: 'r:' + key, label: label || ' ', plain: true, dimColor: true, onPress: v.act.row(key) }
86    : { key: 'r:' + key, label: label || ' ', plain: true, onPress: v.act.row(key) })
87  const children = [...prefix.map((p) => (typeof p === 'string' ? el.Text({ children: [p] }) : p)), button, ...tail]
88  const props: Record<string, unknown> = { key: 'row:' + key, flexDirection: 'row', children }
89  if (isCursor && roles.sel) props.backgroundColor = roles.sel
90  return el.Box(props)
91}
92
93// Plain informational line (preview placeholders): not focusable.
94function note(v: ViewInput, key: string, parts: unknown[]) {
95  return v.el.Box({ key: 'row:' + key, flexDirection: 'row', children: [v.el.Text({ wrap: 'truncate-end', children: parts })] })
96}
97
98function t(v: ViewInput, text: string, style: Record<string, unknown> = {}) {
99  return v.el.Text({ ...style, children: [text] })
100}
101
102function header(v: ViewInput, sec: number) {
103  const { ui, roles, data } = v
104  const title =
105    sec === ART ? 'Artifacts · ' + (selectedStep(ui, data) === '' ? 'unattributed' : selectedStep(ui, data) ?? '—')
106      : sec === PREV ? 'Preview · ' + targetTitle(previewTarget(ui, data))
107        : SECTIONS[sec]
108  const right = summary(v, sec)
109  const shown = isShown(ui, sec)
110  const focusSec = ui.sec === sec
111  const lead = (shown ? (focusSec ? '━┥ ' : '─┤ ') : '▸┤ ')
112  // digit + lead + title + ' ├' + fill + ' ' + summary must fit the width exactly.
113  const fill = Math.max(2, v.width - 1 - len(lead) - len(title) - 2 - 1 - len(right.text) - 1)
114  const isCursor = focusSec && (sec === PREV || ui.cursor[sec] === null)
115  return line(v, 'h:' + sec, isCursor, [t(v, (sec + 1) + lead, { color: focusSec ? roles.accent : roles.faint, bold: focusSec })], title, false, [
116    t(v, ' ├' + '─'.repeat(fill) + ' ', { color: roles.faint }),
117    t(v, right.text, { color: right.color }),
118  ])
119}
120
121function summary(v: ViewInput, sec: number): { text: string; color: string } {
122  const { data, roles, ui } = v
123  if (sec === CTX) {
124    const have = data.context.filter((c) => c.exists).length
125    return { text: have + '/' + data.context.length, color: roles.dim }
126  }
127  if (sec === PLAN) {
128    const plan = data.plan
129    if (!plan) return { text: 'missing', color: roles.err }
130    const errors = errorsOf(plan).length
131    if (errors) return { text: '✗ ' + errors + ' issue' + (errors > 1 ? 's' : ''), color: roles.err }
132    const { done, live } = progress(plan.milestones.flatMap((m) => m.steps))
133    const filled = live ? Math.round((done / live) * 6) : 0
134    return { text: done + '/' + live + ' ' + '▰'.repeat(filled) + '▱'.repeat(6 - filled), color: roles.dim }
135  }
136  if (sec === ART) {
137    const id = selectedStep(ui, data)
138    if (id === null) return { text: '', color: roles.dim }
139    const c = (data.commits.get(id) ?? []).length
140    const f = (data.files.get(id) ?? []).length
141    return { text: '⇡' + c + ' +' + f, color: roles.accent }
142  }
143  if (sec === KEYS) return { text: isShown(ui, KEYS) ? '' : 'l: show', color: roles.faint }
144  const total = previewTotal(v)
145  if (!total) return { text: '', color: roles.dim }
146  if (!isShown(ui, PREV)) return { text: total + ' rows', color: roles.dim }
147  const starts = pageStarts(previewCosts(v.preview, v.width), Math.max(1, v.alloc[PREV]))
148  const at = starts.filter((s) => s <= ui.previewOffset).length
149  return { text: Math.max(1, at) + '/' + starts.length, color: roles.dim }
150}
151
152function rowView(v: ViewInput, sec: number, r: Row) {
153  const { roles, ui, data } = v
154  const w = v.width
155  const isCursor = ui.sec === sec && ui.cursor[sec] === r.key
156  // leftStyle only decides dim-or-not now: the label is a Button.
157  const right = (left: string, rightText: string, leftStyle: Record<string, unknown>, rightStyle: Record<string, unknown>, prefix: unknown[] = [], prefixLen = 0) => {
158    const room = w - prefixLen - len(rightText) - (rightText ? 1 : 0)
159    const dim = !!leftStyle.dim
160    return line(v, r.key, isCursor, prefix, fit(left, room), dim, rightText ? [t(v, ' ' + rightText, rightStyle)] : [])
161  }
162  switch (r.kind) {
163    case 'ctx': {
164      const f = r.file
165      return right(f.name, f.exists ? age(data.now, f.mtimeMs) : 'missing', { dim: !f.exists }, { color: roles.dim },
166        [t(v, '▤ ', { color: f.exists ? roles.accent : roles.faint })], 2)
167    }
168    case 'ms': {
169      const plan = data.plan!
170      const m = plan.milestones.find((x) => x.id === r.id)!
171      const { done, live } = progress(m.steps)
172      const complete = isComplete(m)
173      const active = isActive(m)
174      const glyphStyle = complete ? { color: roles.doneDim } : active ? { color: roles.doing, bold: true } : { color: roles.strong, bold: true }
175      const glyph = isFolded(ui, plan, m.id) ? '▸ ' : '▾ '
176      const acc = m.acceptance.total && !complete ? ' ☐' + m.acceptance.done + '/' + m.acceptance.total : ''
177      return right(m.id + ' ' + m.title, done + '/' + live + acc + (complete ? ' ✓' : '  '), { dim: complete }, complete ? { color: roles.doneDim } : active ? { color: roles.doing, bold: true } : { color: roles.dim },
178        [t(v, glyph, glyphStyle)], 2)
179    }
180    case 'step': {
181      const s = r.step
182      const c = (data.commits.get(s.id) ?? []).length
183      const f = (data.files.get(s.id) ?? []).length
184      const counters = (c ? '⇡' + c : '') + (f ? (c ? ' ' : '') + '+' + f : '')
185      const glyphColor = { todo: roles.text, doing: roles.doing, done: roles.done, cancelled: roles.cancelled }[s.status]
186      const titleStyle = { dim: s.status === 'done' || s.status === 'cancelled' }
187      const selected = selectedStep(ui, data) === s.id
188      const mark = selected && ui.sec !== PLAN ? '›' : ' '
189      return right(s.id + ' ' + s.title, counters, titleStyle, { color: roles.accent },
190        [t(v, mark, { color: roles.accent }), t(v, ' ' + STATUS_GLYPH[s.status] + ' ', { color: glyphColor, bold: s.status === 'doing' })], 4)
191    }
192    case 'unattr':
193      return right('unattributed edits', '+' + r.count, { dim: true }, { color: roles.accent }, [t(v, '  · ', { color: roles.faint })], 4)
194    case 'issue':
195      return right(r.text, '', { dim: r.severity === 'warn' }, {}, [t(v, '  ! ', { color: roles.err, bold: true })], 4)
196    case 'repair':
197      return r.phase === 'running'
198        ? right('haiku is repairing DEV_PLAN.md…', '', {}, {}, [t(v, '◐ ', { color: roles.doing, bold: true })], 2)
199        : right('DEV_PLAN.md is broken. Repair?', 'y/n', {}, { color: roles.doing, bold: true }, [t(v, '✗ ', { color: roles.err, bold: true })], 2)
200    case 'note':
201      return right(r.text, '', { dim: true }, {}, ['  '], 2)
202    case 'commit': {
203      const c = r.commit
204      return right(c.subject, '+' + c.add + '-' + c.del, {}, { color: roles.dim },
205        [t(v, r.isOpen ? '▾ ' : '▸ ', { color: roles.faint }), t(v, c.short.slice(0, 7) + ' ', { color: roles.sha })], 2 + Math.min(7, c.short.length) + 1)
206    }
207    case 'cfile':
208      return right(r.path, '+' + r.add + ' -' + r.del, {}, { color: roles.dim }, [t(v, '    ▪ ', { color: roles.file })], 6)
209    case 'file': {
210      const st = r.file.state
211      const color = st === 'A' ? roles.add : st === 'M' ? roles.doing : st === 'D' ? roles.del : roles.dim
212      return right(r.file.rel, '', { dim: st === '✓' }, {},
213        [t(v, ' ' + st + ' ', { color, bold: st !== '✓' })], 3)
214    }
215    case 'empty':
216      return right(r.text, '', { dim: true }, {}, ['  '], 2)
217  }
218}
219
220function previewTotal(v: ViewInput): number {
221  const p = v.preview
222  if (!p) return 0
223  if (p.files) return diffRows(p.files).length
224  if (p.source !== null) return p.source.split('\n').length
225  return 0
226}
227
228function previewBody(v: ViewInput): unknown[] {
229  const { el, roles, ui } = v
230  const p = v.preview
231  const n = v.alloc[PREV]
232  if (!p) return [note(v, 'pv:none', [t(v, '  nothing to preview', { color: roles.faint, italic: true })])]
233  if (p.error) return [note(v, 'pv:err', [t(v, '  ' + p.error, { color: roles.err })])]
234  let segs: Segment[] = []
235  // n is screen rows; Code wraps, so take the lines that fit when wrapped.
236  const count = fitCount(previewCosts(p, v.width), ui.previewOffset, n)
237  if (p.files) segs = diffPage(p.files, diffRows(p.files), ui.previewOffset, count)
238  else if (p.source !== null) segs = sourcePage(p.path, p.source, ui.previewOffset, count, p.firstLine)
239  const out: unknown[] = []
240  segs.forEach((s, i) => {
241    if (s.kind === 'file') {
242      const f = s.file
243      out.push(el.Box({ key: 'pv:f:' + i, flexDirection: 'row', children: [el.Text({ wrap: 'truncate-end', children: [
244        t(v, '▌', { color: roles.file }),
245        t(v, fit(' ' + f.path, v.width - 2 - len('+' + f.add + ' -' + f.del) - 1), { color: roles.file, bold: true }),
246        t(v, ' +' + f.add, { color: roles.add }),
247        t(v, ' -' + f.del, { color: roles.del }),
248        f.binary ? t(v, ' bin', { color: roles.dim }) : '',
249      ] })] }))
250    } else if (s.kind === 'diff') {
251      out.push(el.Code({ key: 'pv:d:' + i, source: s.source, format: 'diff', path: s.path }))
252    } else {
253      out.push(el.Code({ key: 'pv:s:' + i, source: s.source, path: s.path, startLine: s.startLine }))
254    }
255  })
256  if (!out.length) out.push(note(v, 'pv:empty', [t(v, '  (empty)', { color: roles.faint, italic: true })]))
257  return out
258}
259
260type LegendItem = { hotkey?: string; action?: keyof typeof ACTIONS; label: string }
261
262// The legend's items in groups (moves, actions, sections), each group
263// packed into as few rows as the width allows. Shared with the layout, so
264// the Keys section is given exactly the rows it draws.
265export function legendRows(width: number, repairAsk: boolean): LegendItem[][] {
266  const h = (hotkey: string, label: string): LegendItem => ({ hotkey, label })
267  const a = (action: keyof typeof ACTIONS, label: string): LegendItem => ({ action, label })
268  const groups: LegendItem[][] = [
269    [h('j', '↓'), h('k', '↑'), h('f', '⇟'), h('b', '⇞'), h('h', '◂'), h('l', '▸'), h('g', '⤒'), h('e', '⤓')],
270    [...(repairAsk ? [h('y', 'yes'), h('n', 'no')] : []), h('o', 'open'), h('d', 'diff'), h('v', 'git'), h('p', 'plan'), h('z', 'zoom'), h('q', 'close')],
271    // Alt keys only reach us in terminals that send them (iTerm2: Option as Esc+).
272    [h('1', 'ctx'), h('2', 'plan'), h('3', 'art'), h('4', 'prev'), h('5', 'keys'), a('pagedown', '⌥j'), a('pageup', '⌥k'), a('prevsec', '⌥h'), a('nextsec', '⌥l')],
273  ]
274  const cells = (it: LegendItem) => (it.hotkey ? 3 : 0) + len(it.label)
275  const rows: LegendItem[][] = []
276  for (const g of groups) {
277    let row: LegendItem[] = []
278    let used = 0
279    for (const it of g) {
280      const need = (row.length ? 1 : 0) + cells(it)
281      if (row.length && used + need > width) {
282        rows.push(row)
283        row = []
284        used = 0
285      }
286      used += row.length ? 1 + cells(it) : cells(it)
287      row.push(it)
288    }
289    if (row.length) rows.push(row)
290  }
291  return rows
292}
293
294export function isRepairAsk(repair: Repair, data: Data): boolean {
295  return !!(repair && repair.phase === 'ask' && errorsOf(data.plan).length > 0)
296}
297
298// The key buttons are drawn even while Keys is folded: a mod's hotkeys are
299// its drawn Buttons, so folded means drawn into zero rows, not left out.
300function legend(v: ViewInput, open: boolean) {
301  const { el } = v
302  const button = (it: LegendItem) => it.hotkey
303    ? el.Button({ key: 'key:' + it.hotkey, label: it.label, hotkey: it.hotkey, plain: true, dimColor: true, onPress: v.act.key(it.hotkey) })
304    : el.Button({ key: 'act:' + it.action, label: it.label, plain: true, dimColor: true, action: ACTIONS[it.action!], onPress: v.act.key(it.action!) })
305  const rows = legendRows(v.width - 1, isRepairAsk(v.repair, v.data))
306    .map((r) => el.Box({ flexDirection: 'row', columnGap: 1, children: r.map(button) }))
307  return el.Box(open
308    ? { key: 'legend', flexDirection: 'column', children: rows }
309    : { key: 'legend', flexDirection: 'column', height: 0, overflow: 'hidden', children: rows })
310}
311
312// Order of focusable row buttons as drawn, for edge-scroll decisions.
313export function drawnOrder(ui: UI, data: Data, repair: Repair, alloc: number[]): string[] {
314  const out: string[] = []
315  for (let sec = 0; sec < SECTIONS.length; sec++) {
316    out.push('h:' + sec)
317    if (!isShown(ui, sec) || sec === PREV) continue
318    const rows = rowsOf(sec, ui, data, repair)
319    rows.slice(ui.offset[sec], ui.offset[sec] + alloc[sec]).forEach((r) => out.push(r.key))
320  }
321  return out
322}
323