SLOPSHOPPER

thoughts

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

newpanebandrowsguardcommand
v0.2.1no licenseupdated 2026-10-09teambrilliant/claude-code-mods/mods/thoughts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thoughts
│ ┃ thoughts ✕ › fix the failing auth test and add an audit log call │ ┃ ★ views pin here as they appear │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /thoughts │ ⎿ thoughts: pane open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · thoughts
★ views pin here as they appear
README

claude-code-mods

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.

Install

/plugin install thoughts@teambrilliant-marketplace

Run /plugin marketplace add teambrilliant/marketplace first if you haven't added the marketplace.

Mods

ModWhat it does
thoughtsSide 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.

Develop

~/.local/bin/claude --plugin-dir mods/<name>
~/.local/bin/claude plugin test mods/<name>

The full loop is in CLAUDE.md.

Source 6 files
hooks/register.tsx 241 lines
1import { 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}
241
hooks/evidence.ts 111 lines
1import 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}
111
hooks/plan.ts 70 lines
1export 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}
70
hooks/ui.tsx 279 lines
1import 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}
279
hooks/views.ts 86 lines
1import 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}
86
types/index.d.ts 28 lines
1export 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