A pane into the work: plan progress from thoughts/ and the session's pinned ★ views

Claude Code mods by Team Brilliant. A mod is a plugin that hooks into Claude Code's events and UI: it can draw a pane, rewrite how a message renders, or react to tool calls. The mods API is early access, and mods run in Claude Code only.
/plugin install thoughts@teambrilliant-marketplace
Run /plugin marketplace add teambrilliant/marketplace first if you haven't added the marketplace.
| Mod | What it does |
|---|---|
thoughts | Side pane with the active thoughts/plans/ plan as a progress card, each tick checked against the commands that actually ran, plus the session's ★ views pinned until /clear. /thoughts opens it. Works with dev-skills. |
CONTRACTS.md lists what skills must emit for mods to read.
~/.local/bin/claude --plugin-dir mods/<name>
~/.local/bin/claude plugin test mods/<name>
The full loop is in CLAUDE.md.
hooks/register.tsx 241 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { recordRun } from './evidence'
5import { parsePlan, ticksOf } from './plan'
6import { PinnedPane, PlanPane, bandText, isOpen } from './ui'
7import type { Evidence } from './ui'
8import { extractViews, hashText, pinViews, shrinkPinned } from './views'
9import type { View } from './views'
10
11const PANE = 'thoughts'
12const PLAN_PATH = /(^|\/)thoughts\/plans\/[^/]+\.md$/u
13const OUTSIDE_THOUGHTS = (path: string) => !/(^|\/)thoughts\//u.test(path)
14
15const pins = atom({ plugin: 'thoughts', key: 'pins' }, [])
16const isPaneShown = atom({ plugin: 'thoughts', key: 'isPaneShown' }, false)
17const isSeeded = atom({ plugin: 'thoughts', key: 'isSeeded' }, false)
18const activePlan = atom({ plugin: 'thoughts', key: 'activePlan' }, null)
19const planText = atom({ plugin: 'thoughts', key: 'planText' }, null)
20const baselineTicks = atom({ plugin: 'thoughts', key: 'baselineTicks' }, [])
21const ledger = atom({ plugin: 'thoughts', key: 'ledger' }, [])
22const editSeq = atom({ plugin: 'thoughts', key: 'editSeq' }, 0)
23
24async function readOrNull($: EngineInterface, path: string): Promise<string | null> {
25 try {
26 return await $.fs.read(path)
27 } catch {
28 return null
29 }
30}
31
32async function hashesOf($: EngineInterface, views: readonly View[]): Promise<Map<string, string>> {
33 const hashes = new Map<string, string>()
34 for (const { target } of views) {
35 if (target === null || hashes.has(target)) continue
36 const text = await readOrNull($, target)
37 if (text !== null) hashes.set(target, hashText(text))
38 }
39 return hashes
40}
41
42async function pinAll($: EngineInterface, views: readonly View[]) {
43 if (views.length === 0) return
44 const hashes = await hashesOf($, views)
45 const at = await $.clock.now()
46 const isFirstPin = (await read($, pins)).length === 0
47 await update($, pins, current => pinViews(current, views, at, target => hashes.get(target) ?? null))
48 if (isFirstPin) await openPane($)
49}
50
51/** Opened unasked (first pin) it may wait undrawn on a narrow terminal; shrinking follows what's actually on screen. */
52async function openPane($: EngineInterface) {
53 await $.ui.open({ id: PANE, title: 'thoughts' })
54 await refreshPaneShown($)
55}
56
57async function refreshPaneShown($: EngineInterface) {
58 const isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)
59 await update($, isPaneShown, () => isShown)
60}
61
62async function loadPlan($: EngineInterface, path: string) {
63 const text = await readOrNull($, path)
64 const isNewPlan = (await read($, activePlan)) !== path
65 await update($, activePlan, () => path)
66 await update($, planText, () => text)
67 if (isNewPlan) {
68 const plan = text === null ? undefined : parsePlan(text)
69 await update($, baselineTicks, () => (plan === undefined ? [] : ticksOf(plan)))
70 }
71}
72
73async function refreshTargets($: EngineInterface) {
74 const current = await read($, pins)
75 const changed = await Promise.all(
76 current.map(async pin => {
77 if (pin.target === null || pin.targetHash === null) return pin
78 const text = await readOrNull($, pin.target)
79 return { ...pin, isTargetChanged: text !== null && hashText(text) !== pin.targetHash }
80 }),
81 )
82 await update($, pins, () => changed)
83}
84
85async function evidenceOf($: EngineInterface): Promise<Evidence> {
86 return {
87 ledger: await read($, ledger),
88 editSeq: await read($, editSeq),
89 baselineTicks: await read($, baselineTicks),
90 now: await $.clock.now(),
91 }
92}
93
94const textOf = (blocks: readonly { type: string; text?: string }[]) =>
95 blocks.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])).join('\n')
96
97export const register: Register = on => {
98 on('session.start', async ($, e, next) => {
99 await $.command.register({
100 name: 'thoughts',
101 description: 'Open the thoughts pane · /thoughts plan <path> picks the plan',
102 })
103 if (!(await read($, isSeeded))) {
104 const messages = await $.session.messages()
105 await pinAll($, messages.filter(message => message.role === 'assistant').flatMap(message => extractViews(message.text)))
106 await update($, isSeeded, () => true)
107 }
108 return next(e)
109 })
110
111 on('session.append', { door: 'response' }, async ($, e, next) => {
112 const stored = await next(e)
113 if (e.agentId === undefined && e.message.type === 'assistant') {
114 const { content } = e.message
115 await pinAll($, extractViews(typeof content === 'string' ? content : textOf(content)))
116 }
117 return stored
118 }).catch(($, e, next) => next(e))
119
120 on('session.end', async ($, e, next) => {
121 if (e.reason === 'clear') await update($, pins, () => [])
122 return next(e)
123 })
124
125 on('command.run', { command: 'thoughts' }, async ($, e) => {
126 const planArg = /^plan\s+(\S+)/u.exec(e.args.trim())?.[1]
127 if (planArg !== undefined) {
128 await loadPlan($, planArg)
129 const plan = await read($, planText)
130 return { text: plan === null ? `can't read ${planArg}` : `tracking ${planArg}` }
131 }
132 await openPane($)
133 return { text: 'pane open.' }
134 })
135
136 on('ui.open', async ($, e, next) => {
137 const opened = await next(e)
138 await refreshPaneShown($)
139 return opened
140 }).catch(($, e, next) => next(e))
141
142 on('ui.close', async ($, e, next) => {
143 const closed = await next(e)
144 await refreshPaneShown($)
145 return closed
146 }).catch(($, e, next) => next(e))
147
148 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
149 const ran = await next(e)
150 if (ran.deny === undefined) {
151 const run = { command: e.command, isOk: ran.isError !== true, at: await $.clock.now(), editSeq: await read($, editSeq) }
152 await update($, ledger, current => recordRun(current, run))
153 }
154 return ran
155 }).catch(($, e, next) => next(e))
156
157 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
158 const ran = await next(e)
159 if (PLAN_PATH.test(e.file_path)) await loadPlan($, e.file_path)
160 return ran
161 }).catch(($, e, next) => next(e))
162
163 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
164 const ran = await next(e)
165 if (ran.deny === undefined && ran.isError !== true) {
166 if (PLAN_PATH.test(e.file_path)) await loadPlan($, e.file_path)
167 if (OUTSIDE_THOUGHTS(e.file_path)) await update($, editSeq, seq => seq + 1)
168 await refreshTargets($)
169 }
170 return ran
171 }).catch(($, e, next) => next(e))
172
173 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
174 const ran = await next(e)
175 if (ran.deny === undefined && ran.isError !== true) {
176 if (PLAN_PATH.test(e.file_path)) await loadPlan($, e.file_path)
177 if (OUTSIDE_THOUGHTS(e.file_path)) await update($, editSeq, seq => seq + 1)
178 await refreshTargets($)
179 }
180 return ran
181 }).catch(($, e, next) => next(e))
182
183 on('turn.complete', async ($, e, next) => {
184 const answer = await next(e)
185 const path = await read($, activePlan)
186 if (path !== null) await loadPlan($, path)
187 await refreshTargets($)
188 await refreshPaneShown($)
189 return answer
190 })
191
192 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
193 if (!(await read($, isPaneShown))) return next(e)
194 const pinnedTexts = (await read($, pins)).map(pin => pin.text)
195 const text = shrinkPinned(e.props.text, pinnedTexts)
196 return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
197 })
198
199 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
200 const kit = $.ui.resolve(e)
201 const { Box } = kit
202 const path = await read($, activePlan)
203 const root = `${await $.session.root()}/`
204 const shownPath = path !== null && path.startsWith(root) ? path.slice(root.length) : path
205 const text = await read($, planText)
206 const plan = text === null ? undefined : parsePlan(text)
207 const columns = e.props.bodyColumns
208 return (
209 <Box flexDirection="column" gap={1}>
210 {shownPath !== null && plan !== undefined && PlanPane({ kit, plan, path: shownPath, columns, evidence: await evidenceOf($) })}
211 {PinnedPane({
212 kit,
213 pins: await read($, pins),
214 columns,
215 actions: {
216 discard: kind => void update($, pins, current => current.filter(pin => pin.kind !== kind)),
217 reExplain: target => void $.prompt.fill({ text: `/dev-skills:explain ${target}` }),
218 toggle: kind =>
219 void update($, pins, current =>
220 current.map((pin, at) => (pin.kind === kind ? { ...pin, fold: isOpen(pin, at === current.length - 1) ? 'closed' : 'open' } : pin)),
221 ),
222 },
223 })}
224 </Box>
225 )
226 })
227
228 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
229 const text = await read($, planText)
230 const bandPins = { kinds: (await read($, pins)).map(pin => pin.kind), isPaneShown: await read($, isPaneShown) }
231 const line = bandText(text === null ? undefined : parsePlan(text), bandPins, await evidenceOf($), e.props.bodyColumns)
232 if (line === null) return next(e)
233 const { Text } = $.ui.resolve(e)
234 return (
235 <Text dimColor wrap="truncate-end">
236 {line}
237 </Text>
238 )
239 })
240}
241hooks/evidence.ts 111 lines1import type { ThoughtsRun } from '../types'
2import type { Plan, PlanItem } from './plan'
3import { progressOf } from './plan'
4
5export type Glyph = '○' | '●' | '✓' | '◌' | '✗'
6
7const LEDGER_LIMIT = 200
8
9const normalized = (command: string) => command.replace(/\s+/gu, ' ').trim()
10
11export function recordRun(ledger: readonly ThoughtsRun[], run: ThoughtsRun): ThoughtsRun[] {
12 return [...ledger, { ...run, command: normalized(run.command) }].slice(-LEDGER_LIMIT)
13}
14
15/** The latest run whose command contains the check's command. */
16export function lastRunFor(item: PlanItem, ledger: readonly ThoughtsRun[]): ThoughtsRun | undefined {
17 const { command } = item
18 if (command === null) return undefined
19 const wanted = normalized(command)
20 return ledger.findLast(run => run.command.includes(wanted))
21}
22
23/**
24 * ○ open · ● ticked, passing run since the last edit · ✓ ticked before the mod started watching
25 * (or a prose item: nothing to verify) · ◌ ticked without fresh evidence · ✗ last run failed.
26 */
27export function glyphFor(item: PlanItem, ledger: readonly ThoughtsRun[], editSeq: number, baselineTicks: readonly string[]): Glyph {
28 const last = lastRunFor(item, ledger)
29 if (last !== undefined && !last.isOk) return '✗'
30 if (!item.isChecked) return '○'
31 if (last === undefined) return item.command === null || baselineTicks.includes(item.text) ? '✓' : '◌'
32 return last.editSeq === editSeq ? '●' : '◌'
33}
34
35export type EvidenceSummary = { verified: number; earlier: number; selfReported: number; stale: number; failing: number }
36
37/** What backs the ticks: runs seen this session, ticks from before it, prose ticks with nothing to run, stale or failing runs. */
38export function evidenceSummary(items: readonly PlanItem[], ledger: readonly ThoughtsRun[], editSeq: number, baselineTicks: readonly string[]): EvidenceSummary {
39 const summary: EvidenceSummary = { verified: 0, earlier: 0, selfReported: 0, stale: 0, failing: 0 }
40 for (const item of items) {
41 const glyph = glyphFor(item, ledger, editSeq, baselineTicks)
42 if (glyph === '●') summary.verified += 1
43 if (glyph === '◌') summary.stale += 1
44 if (glyph === '✗') summary.failing += 1
45 if (glyph === '✓') {
46 if (baselineTicks.includes(item.text)) summary.earlier += 1
47 else summary.selfReported += 1
48 }
49 }
50 return summary
51}
52
53export type StripCell = Glyph | 'gap'
54
55const GAP_CELLS = 2
56const WORST_FIRST: readonly Glyph[] = ['✗', '◌', '○', '✓', '●']
57
58const widthOf = (strip: readonly StripCell[]) => strip.reduce((cells, cell) => cells + (cell === 'gap' ? GAP_CELLS : 1), 0)
59
60const withGaps = (groups: readonly Glyph[][]): StripCell[] => groups.flatMap((group, at): StripCell[] => (at === 0 ? group : ['gap', ...group]))
61
62/**
63 * One cell per check with a gap (two cells wide) between phases, empty phases skipped. Wider than
64 * maxCells: one cell per phase at its worst glyph (✗ > ◌ > ○ > ✓ > ●). Still wider: nothing.
65 */
66export function stripOf(plan: Plan, glyphOf: (item: PlanItem) => Glyph, maxCells: number): StripCell[] {
67 const groups = plan.phases.filter(phase => phase.items.length > 0).map(phase => phase.items.map(glyphOf))
68 const perCheck = withGaps(groups)
69 if (widthOf(perCheck) <= maxCells) return perCheck
70 const perPhase = withGaps(groups.map(glyphs => [WORST_FIRST.find(glyph => glyphs.includes(glyph)) ?? '●']))
71 return widthOf(perPhase) <= maxCells ? perPhase : []
72}
73
74/** Failed runs in a row for the item's check, counted back from its newest run. */
75export function failStreakOf(item: PlanItem, ledger: readonly ThoughtsRun[]): number {
76 const { command } = item
77 if (command === null) return 0
78 const wanted = normalized(command)
79 const runs = ledger.filter(run => run.command.includes(wanted))
80 const lastPass = runs.findLastIndex(run => run.isOk)
81 return runs.length - 1 - lastPass
82}
83
84/**
85 * Every box ticked, and the plan's own done-gate ran green since the last edit: each Final
86 * Verification check with a command is ●. Without such checks, every check with a command is ●.
87 */
88export function isProvenDone(plan: Plan, glyphOf: (item: PlanItem) => Glyph): boolean {
89 const progress = progressOf(plan)
90 if (progress.total === 0 || progress.done < progress.total) return false
91 const runnable = (items: readonly PlanItem[]) => items.filter(item => item.command !== null)
92 const gate = runnable(plan.phases.find(phase => phase.name === 'Final Verification')?.items ?? [])
93 const proof = gate.length > 0 ? gate : runnable(plan.phases.flatMap(phase => phase.items))
94 return proof.length > 0 && proof.every(item => glyphOf(item) === '●')
95}
96
97export function progressGlyph(done: number, total: number): '○' | '◔' | '◑' | '◕' | '●' {
98 const ratio = total === 0 ? 0 : done / total
99 if (ratio === 0) return '○'
100 if (ratio >= 1) return '●'
101 if (ratio < 0.375) return '◔'
102 return ratio < 0.625 ? '◑' : '◕'
103}
104
105export function ageOf(ms: number): string {
106 const seconds = Math.max(0, Math.round(ms / 1000))
107 if (seconds < 60) return `${seconds}s`
108 const minutes = Math.round(seconds / 60)
109 return minutes < 60 ? `${minutes}m` : `${Math.round(minutes / 60)}h`
110}
111hooks/plan.ts 70 lines1export type PlanItem = { text: string; command: string | null; isChecked: boolean }
2export type PlanPhase = { name: string; layer: string | null; items: PlanItem[] }
3export type Plan = { title: string; phases: PlanPhase[] }
4
5const TITLE = /^# (.+?)(?:\s+-\s+Implementation Plan)?\s*$/u
6const PHASE = /^## (Phase \d+: .+?)\s*$/u
7const FINAL = /^## Final Verification\s*$/u
8const SECTION = /^## /u
9const LAYER = /^\*\*Layer:\*\*\s*(.+?)(?:\s+·\s+.*)?$/u
10const ITEM = /^- \[( |x)\] (.+)$/u
11const LEADING_COMMAND = /^`([^`]+)`/u
12
13function itemOf(mark: string, text: string): PlanItem {
14 const command = LEADING_COMMAND.exec(text)?.[1]?.replace(/\\$/u, '') ?? null
15 return { text, command, isChecked: mark === 'x' }
16}
17
18/** The phases and their checks; sections other than phases and Final Verification are ignored. */
19export function parsePlan(markdown: string): Plan | undefined {
20 let title: string | null = null
21 const phases: PlanPhase[] = []
22 let current: PlanPhase | null = null
23 for (const line of markdown.split('\n')) {
24 title ??= TITLE.exec(line)?.[1] ?? null
25 const phase = PHASE.exec(line)?.[1]
26 if (phase !== undefined || FINAL.test(line)) {
27 current = { name: phase ?? 'Final Verification', layer: null, items: [] }
28 phases.push(current)
29 continue
30 }
31 if (SECTION.test(line)) {
32 current = null
33 continue
34 }
35 if (current === null) continue
36 current.layer ??= LAYER.exec(line)?.[1]?.trim() ?? null
37 const item = ITEM.exec(line)
38 if (item?.[1] !== undefined && item[2] !== undefined) current.items.push(itemOf(item[1], item[2]))
39 }
40 return title === null || phases.length === 0 ? undefined : { title, phases }
41}
42
43export type Progress = { done: number; total: number; current: PlanPhase | null; resume: PlanItem | null }
44
45/** The current phase is the first with an unticked item; a phase is done when all its boxes are ticked. */
46export function progressOf(plan: Plan): Progress {
47 const items = plan.phases.flatMap(phase => phase.items)
48 const current = plan.phases.find(phase => phase.items.some(item => !item.isChecked)) ?? null
49 return {
50 done: items.filter(item => item.isChecked).length,
51 total: items.length,
52 current,
53 resume: current?.items.find(item => !item.isChecked) ?? null,
54 }
55}
56
57export function isPhaseDone(phase: PlanPhase): boolean {
58 return phase.items.length > 0 && phase.items.every(item => item.isChecked)
59}
60
61export function ticksOf(plan: Plan): string[] {
62 return plan.phases.flatMap(phase => phase.items.filter(item => item.isChecked).map(item => item.text))
63}
64
65/** `P2` for "Phase 2: …", `Final` for Final Verification. */
66export function shortName(phase: PlanPhase): string {
67 const number = /^Phase (\d+):/u.exec(phase.name)?.[1]
68 return number === undefined ? 'Final' : `P${number}`
69}
70hooks/ui.tsx 279 lines1import type { BoxProps, ButtonProps, ElementConstructor, MarkdownProps, TextProps } from 'claude-code'
2
3import type { ThoughtsPin, ThoughtsRun } from '../types'
4import { ageOf, evidenceSummary, failStreakOf, glyphFor, isProvenDone, progressGlyph, stripOf } from './evidence'
5import type { EvidenceSummary, Glyph, StripCell } from './evidence'
6import type { Plan, PlanItem, PlanPhase } from './plan'
7import { isPhaseDone, progressOf, shortName } from './plan'
8
9export type Kit = {
10 Box: ElementConstructor<BoxProps>
11 Text: ElementConstructor<TextProps>
12 Button: ElementConstructor<ButtonProps>
13 Markdown: ElementConstructor<MarkdownProps>
14}
15
16export type Evidence = { ledger: readonly ThoughtsRun[]; editSeq: number; baselineTicks: readonly string[]; now: number }
17
18const clip = (text: string, columns: number) => (text.length <= columns ? text : `${text.slice(0, Math.max(1, columns - 1))}…`)
19
20/** Box-drawing lines are pictures: wrapping breaks them, so they truncate. Prose wraps. */
21export const wrapFor = (line: string) => (/[│├└┌┐┘┬┴┼►▼◄▲]|──/u.test(line) ? 'truncate-end' : 'wrap')
22
23const bodyOf = (pin: ThoughtsPin) => pin.text.split('\n').slice(1, -1)
24
25const headingOf = (pin: ThoughtsPin) => (pin.target === null ? `★ ${pin.kind}` : `★ ${pin.kind} · ${pin.target}`)
26
27export type PinnedActions = { discard: (kind: string) => void; reExplain: (target: string) => void; toggle: (kind: string) => void }
28
29const FENCE = /^\s*```/u
30
31/**
32 * A pin's body as markdown: bullets become a list, backticks become code. Runs of
33 * box-drawing lines get a code fence so markdown keeps them line for line; a body
34 * that already carries its own fences is passed through untouched.
35 */
36export function markdownOf(lines: readonly string[]): string {
37 if (lines.some(line => FENCE.test(line))) return lines.join('\n')
38 const out: string[] = []
39 let isInDrawing = false
40 for (const line of lines) {
41 const isDrawing = wrapFor(line) === 'truncate-end'
42 if (isDrawing !== isInDrawing) out.push('```')
43 out.push(line)
44 isInDrawing = isDrawing
45 }
46 if (isInDrawing) out.push('```')
47 return out.join('\n').trim()
48}
49
50/** The first line of prose in a pin's body: fenced blocks and drawing lines skipped, a leading bullet dropped. */
51export function previewOf(pin: ThoughtsPin): string {
52 let isInFence = false
53 for (const line of bodyOf(pin)) {
54 if (FENCE.test(line)) {
55 isInFence = !isInFence
56 continue
57 }
58 if (isInFence || line.trim() === '' || wrapFor(line) !== 'wrap') continue
59 return line.trim().replace(/^[-•]\s*/u, '')
60 }
61 return ''
62}
63
64/** A hand-set fold wins; otherwise the newest pin is open and older ones fold. */
65export function isOpen(pin: ThoughtsPin, isNewest: boolean): boolean {
66 return pin.fold === 'open' || (pin.fold === undefined && isNewest)
67}
68
69/** Newest first; each pin open as a card or folded to one dim line, toggled with ▾/▸. */
70export function PinnedPane({ kit, pins, columns, actions }: { kit: Kit; pins: readonly ThoughtsPin[]; columns: number; actions: PinnedActions }) {
71 const { Box, Text, Button, Markdown } = kit
72 if (pins.length === 0) return <Text dimColor> ★ views pin here as they appear</Text>
73 const newestFirst = [...pins].reverse()
74 const controls = (pin: ThoughtsPin) => (
75 <Box>
76 {pin.isTargetChanged && pin.target !== null && <Text color="yellow">⚠ changed </Text>}
77 {pin.isTargetChanged && pin.target !== null && (
78 <Button key={`r-${pin.kind}`} label="r" plain onPress={() => actions.reExplain(pin.target ?? '')} />
79 )}
80 <Text> </Text>
81 <Button key={`x-${pin.kind}`} label="x" plain dimColor onPress={() => actions.discard(pin.kind)} />
82 </Box>
83 )
84 const foldButton = (pin: ThoughtsPin, isPinOpen: boolean) => (
85 <Button key={`f-${pin.kind}`} label={isPinOpen ? '▾' : '▸'} plain dimColor={!isPinOpen} onPress={() => actions.toggle(pin.kind)} />
86 )
87 return (
88 <Box flexDirection="column" gap={1}>
89 {newestFirst.map((pin, at) =>
90 isOpen(pin, at === 0) ? (
91 <Box key={pin.kind} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
92 <Box justifyContent="space-between">
93 <Box>
94 {foldButton(pin, true)}
95 <Text bold> {clip(headingOf(pin), Math.max(8, columns - 16))}</Text>
96 </Box>
97 {controls(pin)}
98 </Box>
99 <Box marginTop={1}>
100 <Markdown key={`body-${pin.kind}`} text={markdownOf(bodyOf(pin))} />
101 </Box>
102 </Box>
103 ) : (
104 <Box key={pin.kind} justifyContent="space-between" paddingX={2}>
105 <Box>
106 {foldButton(pin, false)}
107 <Text dimColor wrap="truncate-end">
108 {' '}
109 {clip(`${headingOf(pin)} · ${previewOf(pin)}`, Math.max(8, columns - 12))}
110 </Text>
111 </Box>
112 {controls(pin)}
113 </Box>
114 ),
115 )}
116 </Box>
117 )
118}
119
120const GLYPH_COLOR: Partial<Record<Glyph, 'red' | 'yellow'>> = { '✗': 'red', '◌': 'yellow' }
121
122/** Shape carries the meaning, no color: tall = verified now, short = ticked without a fresh run, baseline = open. */
123const STRIP_CHAR: Record<StripCell, string> = { '●': '▌', '✓': '▖', '◌': '▖', '✗': '✗', '○': '▁', gap: ' ' }
124
125const stripText = (cells: readonly StripCell[]) => cells.map(cell => STRIP_CHAR[cell]).join('')
126
127/** `4 of 4 verified` when everything is backed by runs; otherwise each non-zero kind of backing. */
128function summaryParts(summary: EvidenceSummary, done: number, total: number): { text: string; isAlarm: boolean }[] {
129 if (done === 0) return [{ text: 'not started', isAlarm: false }]
130 if (done === total && summary.verified === total) return [{ text: `${total} of ${total} verified`, isAlarm: false }]
131 return [
132 { count: summary.verified, label: 'verified', isAlarm: false },
133 { count: summary.earlier, label: 'earlier', isAlarm: false },
134 { count: summary.selfReported, label: 'self-reported', isAlarm: false },
135 { count: summary.stale, label: 'stale', isAlarm: false },
136 { count: summary.failing, label: 'failing', isAlarm: true },
137 ].flatMap(({ count, label, isAlarm }) => (count === 0 ? [] : [{ text: `${count} ${label}`, isAlarm }]))
138}
139
140/** Things-style card: done phases fold to one line with an evidence dot per check, the current phase opens, upcoming phases stay quiet. */
141export function PlanPane({ kit, plan, path, columns, evidence }: { kit: Kit; plan: Plan; path: string; columns: number; evidence: Evidence }) {
142 const { Box, Text } = kit
143 const progress = progressOf(plan)
144 const lastRun = evidence.ledger.at(-1)
145 const glyphOf = (item: PlanItem) => glyphFor(item, evidence.ledger, evidence.editSeq, evidence.baselineTicks)
146 const isComplete = progress.done === progress.total && progress.total > 0
147 const backing = evidenceSummary(plan.phases.flatMap(phase => phase.items), evidence.ledger, evidence.editSeq, evidence.baselineTicks)
148 const summary = summaryParts(backing, progress.done, progress.total)
149 const nameOf = (phase: PlanPhase) => phase.name.replace(/^Phase \d+: /u, '')
150 const strip = stripText(stripOf(plan, glyphOf, Math.max(8, columns - 6)))
151 const borderStyle = backing.failing > 0 ? 'bold' : isProvenDone(plan, glyphOf) ? 'double' : 'round'
152 return (
153 <Box key="plan-card" flexDirection="column" borderStyle={borderStyle} borderDimColor={borderStyle === 'round'} paddingX={1}>
154 <Box justifyContent="space-between">
155 <Text bold>
156 {progressGlyph(progress.done, progress.total)} {clip(plan.title, Math.max(8, columns - 14))}
157 </Text>
158 <Text dimColor>
159 {progress.done}/{progress.total}
160 </Text>
161 </Box>
162 {strip !== '' && (
163 <Text wrap="truncate-end">
164 {' '}
165 {strip}
166 </Text>
167 )}
168 <Text dimColor wrap="truncate-start">
169 {' '}
170 {path}
171 </Text>
172 <Box flexDirection="column" marginTop={1} marginBottom={1}>
173 {plan.phases.map(phase => {
174 if (isPhaseDone(phase)) {
175 return (
176 <Box key={phase.name} justifyContent="space-between" paddingLeft={2}>
177 <Text dimColor wrap="truncate-end">
178 ✓ {nameOf(phase)}
179 </Text>
180 <Box gap={1}>
181 {phase.items.map(item => (
182 <Text key={item.text} color={GLYPH_COLOR[glyphOf(item)]} dimColor={GLYPH_COLOR[glyphOf(item)] === undefined}>
183 {glyphOf(item)}
184 </Text>
185 ))}
186 </Box>
187 </Box>
188 )
189 }
190 if (phase !== progress.current) {
191 return (
192 <Box key={phase.name} justifyContent="space-between" paddingLeft={2}>
193 <Text dimColor wrap="truncate-end">
194 ○ {nameOf(phase)}
195 </Text>
196 <Text dimColor>{phase.layer ?? ''}</Text>
197 </Box>
198 )
199 }
200 return (
201 <Box key={phase.name} flexDirection="column" paddingLeft={2}>
202 <Box justifyContent="space-between">
203 <Text bold wrap="truncate-end">
204 ▾ {nameOf(phase)}
205 </Text>
206 <Text dimColor>{phase.layer ?? ''}</Text>
207 </Box>
208 {phase.items.map(item => {
209 const glyph = glyphOf(item)
210 const isResume = item === progress.resume
211 const failStreak = isResume ? failStreakOf(item, evidence.ledger) : 0
212 return (
213 <Box key={item.text} paddingLeft={4}>
214 <Text color={isResume ? 'cyan' : GLYPH_COLOR[glyph]} dimColor={glyph === '✓'} wrap="wrap">
215 {glyph} {item.command ?? item.text}
216 {isResume ? ' ← now' : ''}
217 {failStreak >= 2 ? ` · failed ${failStreak}×` : ''}
218 </Text>
219 </Box>
220 )
221 })}
222 </Box>
223 )
224 })}
225 </Box>
226 <Box paddingLeft={2}>
227 {isComplete && <Text dimColor>Complete · </Text>}
228 {summary.map((part, at) => (
229 <Text key={part.text} color={part.isAlarm ? 'red' : undefined} dimColor={!part.isAlarm}>
230 {at === 0 ? '' : ' · '}
231 {part.text}
232 </Text>
233 ))}
234 {lastRun !== undefined && (
235 <Text dimColor>
236 {' · '}last run {ageOf(evidence.now - lastRun.at)} ago
237 </Text>
238 )}
239 </Box>
240 </Box>
241 )
242}
243
244export type BandPins = { kinds: readonly string[]; isPaneShown: boolean }
245
246/** `★ 2 pinned: Strategic, Product · /thoughts to view` while the pane is hidden; just the count while it shows them. */
247function pinsText({ kinds, isPaneShown }: BandPins): string {
248 const count = `★ ${kinds.length} pinned`
249 if (isPaneShown) return count
250 return `${count}: ${kinds.map(kind => kind.replace(/ View$/u, '')).join(', ')} · /thoughts to view`
251}
252
253const SEPARATOR = ' · '
254
255/** The strip takes whatever room the band's other parts leave; it collapses per phase, then drops out, before anything else is cut. */
256export function bandText(plan: Plan | undefined, pins: BandPins, evidence: Evidence, columns: number): string | null {
257 const parts: string[] = []
258 let stripAt = -1
259 if (plan !== undefined) {
260 const progress = progressOf(plan)
261 const lastRun = evidence.ledger.at(-1)
262 parts.push(`${progressGlyph(progress.done, progress.total)} ${plan.title}`)
263 if (progress.current !== null) {
264 parts.push(progress.current.layer === null ? shortName(progress.current) : `${shortName(progress.current)} ${progress.current.layer}`)
265 }
266 parts.push(`${progress.done}/${progress.total}`)
267 stripAt = parts.length
268 if (lastRun !== undefined) parts.push(`${ageOf(evidence.now - lastRun.at)} ago`)
269 }
270 if (pins.kinds.length > 0) parts.push(pinsText(pins))
271 if (parts.length === 0) return null
272 if (plan !== undefined) {
273 const glyphOf = (item: PlanItem) => glyphFor(item, evidence.ledger, evidence.editSeq, evidence.baselineTicks)
274 const strip = stripText(stripOf(plan, glyphOf, columns - parts.join(SEPARATOR).length - SEPARATOR.length))
275 if (strip !== '') parts.splice(stripAt, 0, strip)
276 }
277 return parts.join(SEPARATOR)
278}
279hooks/views.ts 86 lines1import type { ThoughtsPin } from '../types'
2
3export type View = { kind: string; target: string | null; text: string }
4
5type Span = View & { first: number; last: number }
6
7const OPENER = /^`?★ (.+?) ─{3,}`?\s*$/u
8const CLOSER = /─{3,} ★`?\s*$/u
9const FENCE = /^\s*```/u
10const TARGET_SEPARATOR = ' · '
11
12function labelOf(heading: string): { kind: string; target: string | null } {
13 const at = heading.indexOf(TARGET_SEPARATOR)
14 return at === -1
15 ? { kind: heading.trim(), target: null }
16 : { kind: heading.slice(0, at).trim(), target: heading.slice(at + TARGET_SEPARATOR.length).trim() }
17}
18
19function findSpans(lines: readonly string[]): Span[] {
20 const spans: Span[] = []
21 for (let open = 0; open < lines.length; open++) {
22 const heading = OPENER.exec(lines[open] ?? '')?.[1]
23 if (heading === undefined) continue
24 const nextOpen = lines.findIndex((line, at) => at > open && OPENER.test(line))
25 const close = lines.findIndex((line, at) => at > open && CLOSER.test(line))
26 if (close === -1 || (nextOpen !== -1 && nextOpen < close)) continue
27 const isFenced = FENCE.test(lines[open - 1] ?? '') && FENCE.test(lines[close + 1] ?? '')
28 spans.push({
29 ...labelOf(heading),
30 text: lines.slice(open, close + 1).join('\n'),
31 first: isFenced ? open - 1 : open,
32 last: isFenced ? close + 1 : close,
33 })
34 open = close
35 }
36 return spans
37}
38
39/** Every closed ★ block in `text`, in order. An opener without a closer is not a view. */
40export function extractViews(text: string): View[] {
41 return findSpans(text.split('\n')).map(({ kind, target, text: block }) => ({ kind, target, text: block }))
42}
43
44/** Draws each block whose text is pinned as one line; everything else stays byte-identical. */
45export function shrinkPinned(text: string, pinnedTexts: readonly string[]): string {
46 const lines = text.split('\n')
47 const shrunk = findSpans(lines).filter(span => pinnedTexts.includes(span.text))
48 if (shrunk.length === 0) return text
49 const out: string[] = []
50 let at = 0
51 for (const span of shrunk) {
52 out.push(...lines.slice(at, span.first), `★ ${span.kind} · pinned`)
53 at = span.last + 1
54 }
55 out.push(...lines.slice(at))
56 return out.join('\n')
57}
58
59/** One slot per kind: a newer view replaces the pin of its kind, other kinds keep their order. */
60export function pinViews(pins: readonly ThoughtsPin[], views: readonly View[], at: number, hashOf: (target: string) => string | null): ThoughtsPin[] {
61 return views.reduce<ThoughtsPin[]>(
62 (next, view) => [
63 ...next.filter(pin => pin.kind !== view.kind),
64 {
65 kind: view.kind,
66 text: view.text,
67 target: view.target,
68 targetHash: view.target === null ? null : hashOf(view.target),
69 isTargetChanged: false,
70 pinnedAt: at,
71 },
72 ],
73 [...pins],
74 )
75}
76
77/** FNV-1a: cheap, synchronous, good enough to notice that a file changed. */
78export function hashText(text: string): string {
79 let hash = 0x811c9dc5
80 for (let at = 0; at < text.length; at++) {
81 hash ^= text.charCodeAt(at)
82 hash = Math.imul(hash, 0x01000193) >>> 0
83 }
84 return hash.toString(16)
85}
86types/index.d.ts 28 lines1export type ThoughtsPin = {
2 kind: string
3 text: string
4 target: string | null
5 targetHash: string | null
6 isTargetChanged: boolean
7 pinnedAt: number
8 /** Set by hand with the fold toggle; absent, the newest pin is open and older ones fold. */
9 fold?: 'open' | 'closed'
10}
11
12export type ThoughtsRun = { command: string; isOk: boolean; at: number; editSeq: number }
13
14declare module 'claude-code' {
15 interface PluginState {
16 thoughts: {
17 pins: ThoughtsPin[]
18 isPaneShown: boolean
19 isSeeded: boolean
20 activePlan: string | null
21 planText: string | null
22 baselineTicks: string[]
23 ledger: ThoughtsRun[]
24 editSeq: number
25 }
26 }
27}
28