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…

<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.
┌─┤ 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.
<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>
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.
"tui": "fullscreen") for the docked sidebar; narrower windows get it above the prompt. Built and tested on v2.1.296.less works without delta).y)./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)
└─────────────────────────────────────────────────────────────────
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.
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
hooks/register.ts 838 lines1// 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}
838hooks/lib/diff.ts 167 lines1// 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}
167hooks/lib/editor.ts 100 lines1// 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}
100hooks/lib/git.ts 148 lines1// 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}
148hooks/lib/ledger.ts 52 lines1// 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}
52hooks/lib/model.ts 561 lines1// 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}
561hooks/lib/palette.ts 53 lines1// 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}
53hooks/lib/plan.ts 301 lines1// 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}
301hooks/lib/text.ts 152 lines1// 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}
152hooks/lib/view.ts 323 lines1// 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