SLOPSHOPPER

agenda

Pane of open questions, items Claude left undone, and a cross-session scratch pad; answer, dismiss or fire off each one.

newpanecommandtoaststatusprompt
v0.4.1no licenseupdated 2026-10-09djbrandl/claude-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agenda
│ ┃ Agenda ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ CONTEXT 97k / 200k · API- │ agenda │ │ ┃ ███████████████████████░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ Agenda: starter theme written to │ │ ┃ ⎿ Read 6 lines │ /Users/dev/.claude/agenda.theme.json; edit │ │ ┃ [ handoff + clear ] [ drop pending handoff ⏺ Update(src/auth.ts) │ it and run /agenda to reload │ │ ┃ handoff loaded, consumed on the next repl ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ │ ┃ AGENTS 0 ⎿ 3 pass, 1 fail │ agenda │ │ ┃ no subagents │ Agenda: starter theme written to │ │ ┃ ● Done. refresh now re│ /Users/dev/.claude/agenda.theme.json; edit │ │ ┃ QUESTIONS 0 │ it and run /agenda to reload │ │ ┃ none pending ✻ Worked for 42s · don╰────────────────────────────────────────────╯ │ ┃ │ ┃ UNDONE 0 › /agenda │ ┃ nothing left undone ⎿ agenda: Agenda pane opened. │ ┃ │ ┃ NOTES 0 │ ┃ add a project note, kept across sessions ⏎ │ ┃ │ ┃ ✎ answer · ▶ do · ✕ dismiss · ▸ expand │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agenda
CONTEXT 97k / 200k · API-equiv $0.42 ███████████████████████░░░░░░░░░░░░░░░░░░░░░░░ 49% [ handoff + clear ] [ drop pending handoff ] handoff loaded, consumed on the next reply AGENTS 0 no subagents QUESTIONS 0 none pending UNDONE 0 nothing left undone NOTES 0 add a project note, kept across sessions ⏎ add ✎ answer · ▶ do · ✕ dismiss · ▸ expand
README

agenda

A Claude Code mod: one pane with a context gauge and handoff-and-clear, a live list of subagents, each with the macro steps its assignment asks for (planned once at spawn, ticked off as it works; running rows open, finished rows fold to a progress line), open questions pulled from each reply (this session only; a question clears itself once you answer it, through ✎ or in your own words), items left undone (with "do it" buttons), and a scratch pad of notes. Undone items and notes belong to the project directory and are kept across sessions and injected into the system prompt. The status line shows the counts only while the pane is off screen.

Install

/plugin install agenda --marketplace djbrandl/claude-mods

Answer y to add the marketplace, pick the user scope. Then /agenda opens the pane, /note <text> adds a note, /handoff writes a handoff and clears.

Scrolling

When the pane holds more than fits, a scrollbar runs down its right edge. Scroll with the mouse wheel over the pane, or give it the keyboard (ctrl+x tab, or click it) and use the arrows, Page Up/Down, Home and End.

The one setting

The installer asks for a Summary model. The pane runs a small background Claude call after each reply to pull out open questions and unfinished work, and to write the one-line subagent summaries. This setting picks which Claude does that chore. haiku (the default) is cheap, fast and enough; it has no effect on which model answers you in the main chat. Change it with /plugin configure agenda only if the one-liners read badly.

Styling

The first run writes ~/.claude/agenda.theme.json with the defaults. Edit it (colours as hex or terminal colour names, any text for glyphs and labels, the two context-gauge thresholds), then run /agenda to reload. Plugin updates replace the plugin, never that file, so your styling survives every update. Leave a key out to keep its default; delete the file to start over. The path is a plugin setting (themeFile) if you want it elsewhere.

Develop

claude --plugin-dir <this folder>
claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 953 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgendaItem, AgentRow, ContextGauge, Handoff, PlanStep, Theme } from '../types'
5import { DEFAULT_THEME, THEME_DOC, legend, mergeTheme } from './theme'
6
7const PANE = 'agenda'
8const TITLE = 'Agenda'
9const LEGACY_STORE_KEY = 'agenda:v1'
10const STORE_PREFIX = 'agenda:v2:'
11const MAX_PER_LIST = 60
12const MAX_DONE_AGENTS = 4
13const MAX_PLAN_STEPS = 8
14const SUMMARIZE_AFTER_TOOLS = 4
15
16// Looks come from the theme file (see hooks/theme.ts); T mirrors the state atom for the helpers.
17let T: Theme = DEFAULT_THEME
18
19const questions = atom({ plugin: 'agenda', key: 'questions' } as const, [])
20const undone = atom({ plugin: 'agenda', key: 'undone' } as const, [])
21const notes = atom({ plugin: 'agenda', key: 'notes' } as const, [])
22const agents = atom({ plugin: 'agenda', key: 'agents' } as const, [])
23// Agents whose row the person flipped from its default: running rows open, finished rows folded.
24const toggledAgents = atom({ plugin: 'agenda', key: 'toggledAgents' } as const, [])
25const context = atom({ plugin: 'agenda', key: 'context' } as const, null)
26const handoff = atom({ plugin: 'agenda', key: 'handoff' } as const, null)
27const scanning = atom({ plugin: 'agenda', key: 'scanning' } as const, false)
28const handingOff = atom({ plugin: 'agenda', key: 'handingOff' } as const, false)
29const theme = atom({ plugin: 'agenda', key: 'theme' } as const, DEFAULT_THEME)
30
31// Questions are the session's alone and live in $.state only. Undone, notes and the pending handoff
32// belong to the project directory and are stored under its path.
33type Stored = { undone: AgendaItem[]; notes: AgendaItem[]; handoff: Handoff | null }
34type ListName = 'questions' | 'undone' | 'notes'
35
36const EXTRACT_SYSTEM = `You read one reply from a coding assistant to its user, together with the user's request it answers, the open questions already recorded, and the list of unfinished items already recorded. You answer with four lists.
37
38"questions": every NEW question or decision the assistant is asking the USER to answer or decide, that the user still needs to respond to. Include requests for a choice, confirmation, missing information or a go/no-go. Exclude rhetorical questions, questions the assistant answered itself, questions directed at nobody, and anything already in the recorded open questions.
39
40"answered": the ids from the recorded open questions that the user's request answers or decides, or that this reply shows are settled, withdrawn, or no longer relevant. A user who tells the assistant what to do about a question has answered it even without using the word.
41
42"undone": ONLY work the user asked for that the assistant explicitly did not finish. A part of the request it dropped or skipped, a step it said it would do next but did not, a failing check it left broken. Most replies have ZERO undone items. Exclude all of these: caveats about what it could not verify from where it sits ("not verified live", "needs a real run", "tell me what you see"); optional suggestions, ideas, offers or follow-ups; work that waits on the user's decision (that is a question); explanations of how something works; anything that is already in the recorded list.
43
44"resolved": the ids from the recorded list that this reply shows are now finished, superseded, or no longer apply.
45
46Write each new item as one short self-contained sentence (max 160 characters) that makes sense without the reply. Do not invent items. Reply with JSON only, no prose, no code fence:
47{"questions":["..."],"answered":["id"],"undone":["..."],"resolved":["id"]}
48Empty lists are fine and usual.`
49
50const PLAN_SYSTEM = `You are given the assignment a coding subagent was just handed. List the macro steps it will have to carry out to finish: the outcomes a manager would track, not individual file reads or commands. Between 2 and 6 steps, in the order they will likely happen, each a short phrase of at most 60 characters in plain language, no tool names or paths unless they are the point. Reply with JSON only, no prose, no code fence:
51{"steps":["..."]}`
52
53const PROGRESS_SYSTEM = `You watch a coding subagent work against a numbered plan. You are given its assignment, the plan (number, state, step), and its latest activity (what it said and which tools it used, with the key argument of each). Decide what the activity shows: which steps are now finished, and which single step it is working on now. Add a new step ONLY if the agent is clearly doing substantial work that no planned step covers; phrase it like the others, max 60 characters. Reply with JSON only, no prose, no code fence:
54{"done":[1],"doing":2,"added":["..."]}
55"doing" is a step number or null; a step number may be from the plan or count on from the end for an added step.`
56
57const HANDOFF_PROMPT = `Write a handoff for a fresh session that will continue this work with none of this conversation. Plain Markdown, under 60 lines, no preamble. Sections, in this order:
58# Handoff
59## Goal
60What the user is trying to achieve, in their words where possible.
61## State
62What is done and verified, what is built but unverified, what is broken.
63## Decisions
64Choices the user made or confirmed, each with the reason, so they are not re-litigated.
65## Open questions
66Questions the user has not answered yet.
67## Left undone
68Work named but not finished, one line each.
69## Next step
70The single next action to take.
71## Files
72Paths touched or that the next session must read first.`
73
74function newId(): string {
75  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
76}
77
78function norm(text: string): string {
79  return text.toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim()
80}
81
82function merge(list: AgendaItem[], texts: string[], at: string): AgendaItem[] {
83  const seen = new Set(list.map(one => norm(one.text)))
84  const added: AgendaItem[] = []
85  for (const text of texts) {
86    const clean = text.trim()
87    if (!clean || seen.has(norm(clean))) continue
88    seen.add(norm(clean))
89    added.push({ id: newId(), text: clean, at })
90  }
91  return [...list, ...added].slice(-MAX_PER_LIST)
92}
93
94type Extracted = { questions: string[]; answered: string[]; undone: string[]; resolved: string[] }
95
96function parseExtract(text: string): Extracted {
97  const none: Extracted = { questions: [], answered: [], undone: [], resolved: [] }
98  const start = text.indexOf('{')
99  const end = text.lastIndexOf('}')
100  if (start < 0 || end <= start) return none
101  try {
102    const parsed = JSON.parse(text.slice(start, end + 1)) as Partial<Record<keyof Extracted, unknown>>
103    const strings = (value: unknown): string[] =>
104      Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : []
105    return {
106      questions: strings(parsed.questions),
107      answered: strings(parsed.answered),
108      undone: strings(parsed.undone),
109      resolved: strings(parsed.resolved),
110    }
111  } catch {
112    return none
113  }
114}
115
116function asList(value: unknown): AgendaItem[] {
117  return Array.isArray(value)
118    ? value.filter(
119        (one): one is AgendaItem =>
120          typeof one === 'object' && one !== null && typeof (one as AgendaItem).text === 'string',
121      )
122    : []
123}
124
125let projectKey = ''
126
127async function projectStoreKey($: EngineInterface): Promise<string> {
128  if (!projectKey) {
129    const cwd = (await $.session.cwd()).replace(/[\/]+$/, '').toLowerCase()
130    projectKey = STORE_PREFIX + cwd
131  }
132  return projectKey
133}
134
135async function load($: EngineInterface): Promise<void> {
136  const key = await projectStoreKey($)
137  let raw = (await $.store.get(key)) as Partial<Stored> | undefined
138  if (!raw) {
139    // One-time migration of the pre-project store: its items were made in this project.
140    const legacy = (await $.store.get(LEGACY_STORE_KEY)) as Partial<Stored> | undefined
141    if (legacy) {
142      raw = legacy
143      await $.store.set(key, { undone: asList(legacy.undone), notes: asList(legacy.notes), handoff: legacy.handoff ?? null })
144      await $.store.delete(LEGACY_STORE_KEY)
145    }
146  }
147  await update($, questions, () => [])
148  await update($, undone, () => asList(raw?.undone))
149  await update($, notes, () => asList(raw?.notes))
150  const h = raw?.handoff
151  await update($, handoff, () => (h && typeof h.text === 'string' ? { text: h.text, at: String(h.at ?? '') } : null))
152}
153
154async function persist($: EngineInterface): Promise<void> {
155  const stored: Stored = {
156    undone: await read($, undone),
157    notes: await read($, notes),
158    handoff: await read($, handoff),
159  }
160  await $.store.set(await projectStoreKey($), stored)
161  await refreshStatus($)
162}
163
164// The status line carries the counts only while the pane is not on screen.
165async function refreshStatus($: EngineInterface): Promise<void> {
166  const panes = await $.ui.panes()
167  const mine = panes.find(one => one.id === PANE)
168  if (mine && mine.isPlaced && mine.isShown) {
169    $.ui.status(undefined)
170    return
171  }
172  const q = (await read($, questions)).length
173  const u = (await read($, undone)).length
174  $.ui.status(q + u === 0 ? undefined : `agenda: ${q} open question${q === 1 ? '' : 's'} · ${u} undone`)
175}
176
177async function setList($: EngineInterface, which: ListName, fn: (list: AgendaItem[]) => AgendaItem[]): Promise<void> {
178  if (which === 'questions') await update($, questions, fn)
179  else if (which === 'undone') await update($, undone, fn)
180  else await update($, notes, fn)
181  await persist($)
182}
183
184async function refreshContext($: EngineInterface): Promise<void> {
185  const usage = await $.session.usage()
186  const tokens = usage.context.tokens ?? 0
187  const window = usage.context.window
188  const gauge: ContextGauge = {
189    tokens,
190    window,
191    percent: usage.context.percent ?? (window ? Math.round((tokens / window) * 100) : 0),
192    usd: usage.cost?.usd ?? 0,
193  }
194  await update($, context, () => gauge)
195}
196
197async function refreshAgents($: EngineInterface): Promise<void> {
198  const live = await $.agent.list()
199  const now = Date.now()
200  await update($, agents, list => {
201    const byId = new Map(list.map(one => [one.id, one]))
202    for (const info of live) {
203      const prev = byId.get(info.id)
204      const isOver = info.status === 'completed' || info.status === 'failed' || info.status === 'killed'
205      byId.set(info.id, {
206        id: info.id,
207        description: info.description || prev?.description || info.type,
208        type: info.type,
209        status: info.status,
210        startedAt: prev?.startedAt ?? now,
211        endedAt: isOver ? (prev?.endedAt ?? now) : undefined,
212        plan: isOver && prev ? finishPlan(prev.plan, info.status === 'completed') : (prev?.plan ?? []),
213      })
214    }
215    const rows = [...byId.values()]
216    const active = rows.filter(one => one.endedAt === undefined)
217    const done = rows
218      .filter(one => one.endedAt !== undefined)
219      .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
220      .slice(0, MAX_DONE_AGENTS)
221    return [...active, ...done]
222  })
223}
224
225let lastPrompt = ''
226
227// The question the ✎ button put into the prompt box; cleared from the list when that prompt is sent.
228const ANSWER_PREFIX = 'Answering your earlier question '
229let pendingAnswer: AgendaItem | null = null
230
231// The pane's own estimate of its tree's height at the last draw, and how far the engine's count differed.
232let lastEstimate = 0
233let rowCorrection = 0
234
235async function scanAnswer($: EngineInterface, answer: string, model: string): Promise<void> {
236  await update($, scanning, () => true)
237  try {
238    const recorded = await read($, undone)
239    const open = await read($, questions)
240    const prompt = [
241      `User's request:\n${lastPrompt.trim().slice(0, 4000) || '(not captured)'}`,
242      open.length
243        ? `Recorded open questions (id: text):\n${open.map(one => `${one.id}: ${one.text}`).join('\n')}`
244        : 'Recorded open questions: none',
245      recorded.length
246        ? `Recorded unfinished items (id: text):\n${recorded.map(one => `${one.id}: ${one.text}`).join('\n')}`
247        : 'Recorded unfinished items: none',
248      `Assistant's reply:\n${answer.slice(0, 20000)}`,
249    ].join('\n\n')
250    const reply = await $.model.complete({ model, system: EXTRACT_SYSTEM, prompt, effort: 'low', maxTokens: 800, timeoutMs: 30000 })
251    if (!reply.isAnswered) return
252    const found = parseExtract(reply.text)
253    const answered = new Set(found.answered.filter(id => open.some(one => one.id === id)))
254    const resolved = new Set(found.resolved.filter(id => recorded.some(one => one.id === id)))
255    if (found.questions.length === 0 && found.undone.length === 0 && answered.size === 0 && resolved.size === 0) return
256    const at = new Date().toISOString()
257    await update($, questions, list => merge(list.filter(one => !answered.has(one.id)), found.questions, at))
258    await update($, undone, list => merge(list.filter(one => !resolved.has(one.id)), found.undone, at))
259    await persist($)
260    const parts: string[] = []
261    if (found.questions.length) parts.push(`${found.questions.length} open question${found.questions.length === 1 ? '' : 's'}`)
262    if (answered.size) parts.push(`${answered.size} answered`)
263    if (found.undone.length) parts.push(`${found.undone.length} undone`)
264    if (resolved.size) parts.push(`${resolved.size} resolved`)
265    $.ui.toast(`Agenda: ${parts.join(', ')}`)
266  } finally {
267    await update($, scanning, () => false)
268  }
269}
270
271type Activity = { lines: string[]; tools: number; inFlight: Promise<void> | null }
272const activity = new Map<string, Activity>()
273
274function keyArg(input: unknown): string {
275  if (typeof input !== 'object' || input === null) return ''
276  const o = input as Record<string, unknown>
277  for (const k of ['description', 'file_path', 'path', 'command', 'pattern', 'query', 'url', 'prompt', 'skill']) {
278    const v = o[k]
279    if (typeof v === 'string' && v.trim()) return v.replace(/\s+/g, ' ').slice(0, 120)
280  }
281  return ''
282}
283
284function noteActivity(agentId: string, blocks: readonly { type: string; [field: string]: unknown }[]): { isDue: boolean } {
285  const buf = activity.get(agentId) ?? { lines: [], tools: 0, inFlight: null }
286  activity.set(agentId, buf)
287  let hasNarration = false
288  for (const block of blocks) {
289    if (block.type === 'text' && typeof block.text === 'string') {
290      const text = block.text.replace(/\s+/g, ' ').trim()
291      if (text.length >= 20) {
292        buf.lines.push(`said: ${text.slice(0, 300)}`)
293        if (text.length >= 60) hasNarration = true
294      }
295    } else if (block.type === 'tool_use' && typeof block.name === 'string') {
296      const arg = keyArg(block.input)
297      buf.lines.push(arg ? `${block.name}: ${arg}` : block.name)
298      buf.tools += 1
299    }
300  }
301  return { isDue: hasNarration || buf.tools >= SUMMARIZE_AFTER_TOOLS }
302}
303
304function parseJson(text: string): Record<string, unknown> | null {
305  const start = text.indexOf('{')
306  const end = text.lastIndexOf('}')
307  if (start < 0 || end <= start) return null
308  try {
309    const parsed: unknown = JSON.parse(text.slice(start, end + 1))
310    return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null
311  } catch {
312    return null
313  }
314}
315
316function stepTexts(value: unknown): string[] {
317  return Array.isArray(value)
318    ? value
319        .filter((one): one is string => typeof one === 'string')
320        .map(one => one.replace(/\s+/g, ' ').trim().replace(/^[-*\d.)\s]+/, ''))
321        .filter(one => one.length > 0)
322        .map(one => one.slice(0, 80))
323    : []
324}
325
326// The agent is over: whatever it was on is done when it completed, otherwise left where it stood.
327function finishPlan(plan: PlanStep[], isCompleted: boolean): PlanStep[] {
328  if (!isCompleted) return plan
329  return plan.map(step => (step.state === 'doing' ? { ...step, state: 'done' } : step))
330}
331
332// Called once per spawn with the assignment the subagent was handed.
333async function planAgent($: EngineInterface, agentId: string, description: string, assignment: string, model: string): Promise<void> {
334  const reply = await $.model.complete({
335    model,
336    system: PLAN_SYSTEM,
337    prompt: `Description: ${description}\n\nAssignment:\n${assignment.slice(0, 12000)}`,
338    effort: 'low',
339    maxTokens: 300,
340    timeoutMs: 20000,
341  })
342  if (!reply.isAnswered) return
343  const steps = stepTexts(parseJson(reply.text)?.steps).slice(0, MAX_PLAN_STEPS)
344  if (steps.length === 0) return
345  await update($, agents, list =>
346    list.map(one => {
347      if (one.id !== agentId) return one
348      // Progress may already have added steps; the plan goes in front of them.
349      const known = new Set(steps.map(norm))
350      const extra = one.plan.filter(step => !known.has(norm(step.text)))
351      const planned: PlanStep[] = steps.map(text => ({ text, state: one.plan.find(step => norm(step.text) === norm(text))?.state ?? 'todo' }))
352      return { ...one, plan: [...planned, ...extra].slice(0, MAX_PLAN_STEPS) }
353    }),
354  )
355}
356
357async function summarizeActivity($: EngineInterface, agentId: string, model: string): Promise<void> {
358  const buf = activity.get(agentId)
359  if (!buf || buf.lines.length === 0 || buf.inFlight) return
360  const lines = buf.lines.splice(0, buf.lines.length)
361  buf.tools = 0
362  const run = (async () => {
363    const row = (await read($, agents)).find(one => one.id === agentId)
364    const plan = row?.plan ?? []
365    const prompt = [
366      `Assignment: ${row?.description ?? agentId}`,
367      plan.length ? `Plan:\n${plan.map((step, i) => `${i + 1}. [${step.state}] ${step.text}`).join('\n')}` : 'Plan: none yet',
368      `Latest activity:\n${lines.map(one => `- ${one}`).join('\n')}`,
369    ].join('\n\n')
370    const reply = await $.model.complete({ model, system: PROGRESS_SYSTEM, prompt: prompt.slice(0, 12000), effort: 'low', maxTokens: 200, timeoutMs: 20000 })
371    if (!reply.isAnswered) return
372    const parsed = parseJson(reply.text)
373    if (!parsed) return
374    const done = new Set(Array.isArray(parsed.done) ? parsed.done.filter((one): one is number => Number.isInteger(one)) : [])
375    const doing = Number.isInteger(parsed.doing) ? (parsed.doing as number) : null
376    const added = stepTexts(parsed.added)
377    await update($, agents, list =>
378      list.map(one => {
379        if (one.id !== agentId) return one
380        const have = new Set(one.plan.map(step => norm(step.text)))
381        const extra: PlanStep[] = added.filter(text => !have.has(norm(text))).map(text => ({ text, state: 'todo' }))
382        const next = [...one.plan, ...extra].slice(0, MAX_PLAN_STEPS).map((step, i) => {
383          const n = i + 1
384          if (done.has(n)) return { ...step, state: 'done' as const }
385          if (n === doing) return { ...step, state: 'doing' as const }
386          // Moving on to a later step finishes the one it was on.
387          if (step.state === 'doing' && doing !== null && doing > n) return { ...step, state: 'done' as const }
388          return step
389        })
390        return { ...one, plan: next }
391      }),
392    )
393  })()
394  buf.inFlight = run
395  try {
396    await run
397  } finally {
398    buf.inFlight = null
399  }
400}
401
402async function writeHandoff($: EngineInterface): Promise<Handoff | null> {
403  await update($, handingOff, () => true)
404  try {
405    const qs = await read($, questions)
406    const us = await read($, undone)
407    const extra = [
408      qs.length ? `The agenda pane also holds these unanswered questions; include them under Open questions:\n${qs.map(one => `- ${one.text}`).join('\n')}` : '',
409      us.length ? `And these undone items; include them under Left undone:\n${us.map(one => `- ${one.text}`).join('\n')}` : '',
410    ]
411      .filter(Boolean)
412      .join('\n\n')
413    const reply = await $.model.fork({ prompt: extra ? `${HANDOFF_PROMPT}\n\n${extra}` : HANDOFF_PROMPT })
414    if (!reply.isAnswered) {
415      $.ui.toast(`Agenda: handoff failed (${reply.reason})`)
416      return null
417    }
418    const written: Handoff = { text: reply.text.trim(), at: new Date().toISOString() }
419    await update($, handoff, () => written)
420    await persist($)
421    const cwd = await $.session.cwd()
422    const path = `${cwd.replace(/[\\/]+$/, '')}/.claude/handoff.md`
423    try {
424      await $.fs.write(path, `${written.text}\n`)
425    } catch {
426      // The store copy is what the next session reads; the file is a courtesy.
427    }
428    return written
429  } finally {
430    await update($, handingOff, () => false)
431  }
432}
433
434function bar(percent: number, width: number): string {
435  const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
436  return T.gauge.filled.repeat(filled) + T.gauge.empty.repeat(Math.max(0, width - filled))
437}
438
439function gaugeColor(percent: number): string {
440  if (percent >= T.gauge.dangerAt) return T.gauge.danger
441  if (percent >= T.gauge.warnAt) return T.gauge.warn
442  return T.gauge.ok
443}
444
445/** Rows a word-wrapped Text takes at `width` cells, as the terminal wraps it. */
446function wrapRows(text: string, width: number): number {
447  const w = Math.max(1, width)
448  let rows = 0
449  for (const line of text.split('\n')) {
450    let used = 0
451    let lineRows = 1
452    for (const word of line.split(' ')) {
453      const len = [...word].length
454      if (used === 0) {
455        lineRows += Math.max(0, Math.ceil(len / w) - 1)
456        used = len % w || (len ? w : 0)
457      } else if (used + 1 + len <= w) {
458        used += 1 + len
459      } else {
460        lineRows += Math.ceil(Math.max(1, len) / w)
461        used = len % w || w
462      }
463    }
464    rows += lineRows
465  }
466  return rows
467}
468
469/** The thumb's first row and length on a track of `rows` rows over a tree of `content` rows. */
470function thumb(offset: number, rows: number, content: number): { start: number; size: number } {
471  const size = Math.max(1, Math.min(rows, Math.round((rows * rows) / content)))
472  const travel = Math.max(1, content - rows)
473  const start = Math.round((Math.min(Math.max(0, offset), travel) / travel) * (rows - size))
474  return { start, size }
475}
476
477function kTokens(n: number): string {
478  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
479}
480
481function elapsed(row: AgentRow, now: number): string {
482  const s = Math.max(0, Math.round(((row.endedAt ?? now) - row.startedAt) / 1000))
483  return s >= 60 ? `${Math.floor(s / 60)}m${s % 60}s` : `${s}s`
484}
485
486function agentGlyph(status: AgentRow['status']): { glyph: string; color: string } {
487  return T.status[status]
488}
489
490async function expandHome($: EngineInterface, path: string): Promise<string> {
491  if (!path.startsWith('~')) return path
492  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
493  return home.replace(/[\/]+$/, '') + path.slice(1)
494}
495
496/**
497 * Reads the theme file. A missing file is written with the defaults so there is something to edit;
498 * an unreadable or malformed one yields the defaults and a note of why.
499 */
500async function loadTheme($: EngineInterface, themeFile: string): Promise<{ theme: Theme; path: string; problem?: string; isNew?: boolean }> {
501  const path = await expandHome($, themeFile)
502  let exists = false
503  try {
504    exists = await $.fs.exists(path)
505  } catch {
506    return { theme: DEFAULT_THEME, path, problem: 'could not check the theme file' }
507  }
508  if (!exists) {
509    try {
510      await $.fs.write(path, JSON.stringify({ $doc: THEME_DOC, ...DEFAULT_THEME }, null, 2) + '\n')
511      return { theme: DEFAULT_THEME, path, isNew: true }
512    } catch {
513      return { theme: DEFAULT_THEME, path, problem: 'could not write the starter theme file' }
514    }
515  }
516  try {
517    const text = await $.fs.read(path)
518    return { theme: mergeTheme(JSON.parse(text)), path }
519  } catch {
520    return { theme: DEFAULT_THEME, path, problem: 'theme file is not valid JSON; using defaults' }
521  }
522}
523
524async function applyTheme($: EngineInterface, themeFile: string, isReload: boolean): Promise<void> {
525  const loaded = await loadTheme($, themeFile)
526  T = loaded.theme
527  await update($, theme, () => loaded.theme)
528  if (loaded.isNew) $.ui.toast(`Agenda: starter theme written to ${loaded.path}; edit it and run /agenda to reload`)
529  else if (loaded.problem) $.ui.toast(`Agenda: ${loaded.problem} (${loaded.path})`)
530  else if (isReload) $.ui.toast('Agenda: theme reloaded')
531}
532
533export const register: Register = (on, options) => {
534  const extractorModel = String(options.extractorModel ?? 'haiku')
535  const themeFile = String(options.themeFile ?? '~/.claude/agenda.theme.json')
536
537  let hasOpenedOnPrompt = false
538
539  on('session.start', async ($, e, next) => {
540    await applyTheme($, themeFile, false)
541    await load($)
542    await persist($)
543    await $.command.register({ name: 'agenda', description: 'Open the Agenda pane (agents, context, questions, undone, notes)' })
544    await $.command.register({ name: 'note', description: 'Add a note to the Agenda scratch pad: /note <text>' })
545    await $.command.register({ name: 'handoff', description: 'Write a handoff for the next session, then /clear' })
546    // Unasked, the engine seats a pane only on a terminal 144 columns or wider; narrower, it waits
547    // undrawn, and the first prompt.submit below opens it as the person's own ask, at any width.
548    void $.ui.open({ id: PANE, title: TITLE })
549    try {
550      await refreshContext($)
551    } catch {
552      // No usage yet; the gauge reads "no turn yet" until the first reply.
553    }
554    $.clock.every(3000, async () => {
555      const rows = await read($, agents)
556      if (rows.some(one => one.endedAt === undefined)) await refreshAgents($)
557    })
558    return next(e)
559  })
560
561  on('prompt.submit', async ($, e, next) => {
562    if (e.origin?.kind !== 'plugin') {
563      lastPrompt = e.text
564      const answering = pendingAnswer
565      if (answering && e.text.startsWith(ANSWER_PREFIX)) {
566        pendingAnswer = null
567        await setList($, 'questions', list => list.filter(one => one.id !== answering.id))
568      }
569    }
570    if (!hasOpenedOnPrompt) {
571      hasOpenedOnPrompt = true
572      const panes = await $.ui.panes()
573      const mine = panes.find(one => one.id === PANE)
574      if (!mine || !mine.isPlaced) void $.ui.open({ id: PANE, title: TITLE })
575    }
576    return next(e)
577  }).catch(($, e, next) => next(e))
578
579  on('session.end', async ($, e, next) => {
580    await update($, questions, () => [])
581    return next(e)
582  })
583
584  on('ui.open', { id: PANE }, async ($, e, next) => {
585    const result = await next(e)
586    void refreshStatus($)
587    return result
588  }).catch(($, e, next) => next(e))
589
590  on('ui.close', { id: PANE }, async ($, e, next) => {
591    const result = await next(e)
592    void refreshStatus($)
593    return result
594  }).catch(($, e, next) => next(e))
595
596  on('command.run', { command: 'agenda' }, async $ => {
597    await applyTheme($, themeFile, true)
598    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
599    return { text: opened.isPlaced ? 'Agenda pane opened.' : 'Agenda pane could not be placed; widen the terminal.' }
600  })
601
602  on('command.run', { command: 'note' }, async ($, e) => {
603    const text = (e.args ?? '').trim()
604    if (!text) return { text: 'Usage: /note <text>' }
605    await setList($, 'notes', list => merge(list, [text], new Date().toISOString()))
606    return { text: `Noted: ${text}` }
607  })
608
609  on('command.run', { command: 'handoff' }, async $ => {
610    const written = await writeHandoff($)
611    if (!written) return { text: 'Handoff not written; nothing to hand off yet or the model did not answer.' }
612    void $.command.run({ command: 'clear' })
613    return { text: 'Handoff written to .claude/handoff.md and kept for the next session. Clearing.' }
614  })
615
616  on('agent.spawn', async ($, e, next) => {
617    const result = await next(e)
618    await refreshAgents($)
619    if (!result.deny && result.agentId) {
620      void planAgent($, result.agentId, e.description || e.subagentType, e.prompt, extractorModel)
621    }
622    return result
623  }).catch(($, e, next) => next(e))
624
625  on('session.append', { door: 'response' }, ($, e, next) => {
626    if (e.agentId && e.message.role === 'assistant') {
627      const { isDue } = noteActivity(e.agentId, e.message.content)
628      if (isDue) void summarizeActivity($, e.agentId, extractorModel)
629    }
630    return next(e)
631  }).catch(($, e, next) => next(e))
632
633  on('turn.complete', async ($, e, next) => {
634    const result = await next(e)
635    void refreshAgents($)
636    if (e.agentId) {
637      void summarizeActivity($, e.agentId, extractorModel)
638      return result
639    }
640    void refreshContext($)
641    // The first main-loop answer after a handoff was loaded has consumed it.
642    const pending = await read($, handoff)
643    if (pending && e.reason === 'answer') {
644      await update($, handoff, () => null)
645      await persist($)
646    }
647    if (e.reason === 'answer' && e.answer.trim().length >= 20) void scanAnswer($, e.answer, extractorModel)
648    return result
649  })
650
651  on('prompt.compose', async ($, e, next) => {
652    const composed = await next(e)
653    const sections = [...composed.sections]
654    const list = await read($, notes)
655    if (list.length > 0) {
656      sections.push({
657        id: 'agenda:notes',
658        scope: 'session',
659        text: [
660          '# Agenda scratch pad',
661          'Standing notes the user keeps for this project directory, across sessions, through the agenda pane. Treat them as context that must not be lost; honour any to-do or constraint listed here when it is relevant to the current work.',
662          ...list.map(one => `- ${one.text}`),
663        ].join('\n'),
664      })
665    }
666    const pending = await read($, handoff)
667    if (pending) {
668      sections.push({
669        id: 'agenda:handoff',
670        scope: 'session',
671        text: [
672          '# Handoff from the previous session',
673          `Written ${pending.at}. The previous session ended on purpose and this one continues it. Read this before acting, then pick up at "Next step". Do not re-ask the questions it lists as decided.`,
674          '',
675          pending.text,
676        ].join('\n'),
677      })
678    }
679    return { sections }
680  })
681
682  // The engine scrolls the pane body but draws no scrollbar, and the render hook is not told the tree's
683  // height. The pane estimates it; each scroll reports the true height, which calibrates the estimate.
684  on('ui.scroll', { component: 'Pane', requestId: PANE }, ($, e, next) => {
685    if (lastEstimate > 0) rowCorrection = e.contentRows - lastEstimate
686    return next(e)
687  }).catch(($, e, next) => next(e))
688
689  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
690    const { Box, Text, Button } = $.ui.resolve(e)
691    const Input = e.surface === 'mobile' ? null : $.ui.resolve(e).Input
692    const qs = await read($, questions)
693    const us = await read($, undone)
694    const ns = await read($, notes)
695    const rows = await read($, agents)
696    const toggled = await read($, toggledAgents)
697    const gauge = await read($, context)
698    const pending = await read($, handoff)
699    const busy = await read($, scanning)
700    const isHandingOff = await read($, handingOff)
701    const t = await read($, theme)
702    T = t
703    const width = Math.max(24, e.props.bodyColumns)
704    const inner = width - 2
705    const now = Date.now()
706
707    const askAnswer = (item: AgendaItem) => async () => {
708      pendingAnswer = item
709      await $.prompt.fill({ text: `${ANSWER_PREFIX}"${item.text}": `, mode: 'replace' })
710    }
711    const dismiss = (which: ListName, item: AgendaItem) => () =>
712      void setList($, which, list => list.filter(one => one.id !== item.id))
713    const doUndone = (item: AgendaItem) => async () => {
714      await setList($, 'undone', list => list.filter(one => one.id !== item.id))
715      void $.prompt.submit({ text: `Do this item you left undone earlier, then verify it: ${item.text}` })
716    }
717    const doAllUndone = async () => {
718      const items = us.map(one => `- ${one.text}`)
719      await setList($, 'undone', () => [])
720      void $.prompt.submit({ text: `Do every item you left undone earlier, then verify each:\n${items.join('\n')}` })
721    }
722    const addNote = (value: string) => {
723      const text = value.trim()
724      if (!text) return
725      void setList($, 'notes', list => merge(list, [text], new Date().toISOString()))
726    }
727    const handoffAndClear = async () => {
728      const written = await writeHandoff($)
729      if (!written) return
730      $.ui.toast('Agenda: handoff written, clearing session')
731      void $.command.run({ command: 'clear' })
732    }
733    const toggleAgent = (id: string) => () =>
734      void update($, toggledAgents, list => (list.includes(id) ? list.filter(one => one !== id) : [...list, id]))
735    const dropHandoff = () => {
736      void (async () => {
737        await update($, handoff, () => null)
738        await persist($)
739      })()
740    }
741
742    const heading = (label: string, color: string, count: number, trailing?: JSX.Element) => (
743      <Box justifyContent="space-between" marginTop={1}>
744        <Box gap={1}>
745          <Text color={color} bold>
746            {label}
747          </Text>
748          <Text color={t.colors.dim}>{count}</Text>
749        </Box>
750        {trailing}
751      </Box>
752    )
753
754    const item = (one: AgendaItem, color: string, buttons: JSX.Element) => (
755      <Box key={one.id} paddingLeft={1} gap={1}>
756        <Text color={color}>{t.glyphs.bullet}</Text>
757        <Box flexGrow={1}>
758          <Text color={t.colors.text} wrap="wrap">
759            {one.text}
760          </Text>
761        </Box>
762        <Box gap={1} flexShrink={0}>
763          {buttons}
764        </Box>
765      </Box>
766    )
767
768    const empty = (text: string) => (
769      <Box paddingLeft={2}>
770        <Text color={t.colors.dim} italic>
771          {text}
772        </Text>
773      </Box>
774    )
775
776    const barWidth = Math.max(8, inner - 8)
777
778    // Height estimate mirroring the layout below, row for row; see the ui.scroll hook.
779    const cells = (text: string) => [...text].length
780    const itemRows = (one: AgendaItem, buttonCells: number) => wrapRows(one.text, inner - 4 - cells(t.glyphs.bullet) - buttonCells)
781    let estimate = 4 + (pending ? 1 : 0)
782    estimate += 2 + (rows.length === 0 ? 1 : 0)
783    for (const row of rows) {
784      const isDone = row.endedAt !== undefined
785      const isOpen = toggled.includes(row.id) ? isDone : !isDone
786      estimate += isOpen ? wrapRows(row.description, inner - 12) : 1
787      if (!isOpen) estimate += row.plan.length > 0 && (isDone || row.plan.some(step => step.state !== 'done')) ? 1 : 0
788      else if (row.plan.length === 0) estimate += 1
789      else for (const step of row.plan) estimate += wrapRows(step.text, inner - 6)
790    }
791    const pairCells = (a: string, b: string) => cells(a) + 1 + cells(b)
792    estimate += 2 + (qs.length === 0 ? 1 : 0) + qs.reduce((n, one) => n + itemRows(one, pairCells(t.glyphs.answer, t.glyphs.dismiss)), 0)
793    estimate += 2 + (us.length === 0 ? 1 : 0) + us.reduce((n, one) => n + itemRows(one, pairCells(t.glyphs.do, t.glyphs.dismiss)), 0)
794    estimate += 2 + ns.reduce((n, one) => n + itemRows(one, cells(t.glyphs.dismiss)), 0)
795    estimate += 1 + 1 + wrapRows(legend(t), inner)
796    lastEstimate = estimate
797    const contentRows = Math.max(1, estimate + rowCorrection)
798    const view = e.props.scroll
799    const overflows = contentRows > view.bodyRows && view.bodyRows > 1
800    const track = overflows ? thumb(view.offset, view.bodyRows, contentRows) : null
801
802    return (
803      <Box flexDirection="column" width={width} backgroundColor={t.colors.background} paddingX={1}>
804        <Box justifyContent="space-between">
805          <Text color={t.sections.context} bold>
806            {t.labels.context}
807          </Text>
808          {gauge && (
809            <Text color={t.colors.muted}>
810              {kTokens(gauge.tokens)} / {kTokens(gauge.window)} · API-equiv ${gauge.usd.toFixed(2)}
811            </Text>
812          )}
813        </Box>
814        {gauge ? (
815          <Box gap={1}>
816            <Text color={gaugeColor(gauge.percent)}>{bar(gauge.percent, barWidth)}</Text>
817            <Text color={gaugeColor(gauge.percent)} bold>
818              {gauge.percent}%
819            </Text>
820          </Box>
821        ) : (
822          empty('no turn yet')
823        )}
824        <Box gap={1} marginTop={1}>
825          <Button
826            key="handoff"
827            label={isHandingOff ? 'writing handoff…' : t.labels.handoff}
828            variant="primary"
829            hotkey="h"
830            onPress={handoffAndClear}
831          />
832          {pending && <Button key="drop-handoff" label={t.labels.dropHandoff} onPress={dropHandoff} />}
833        </Box>
834        {pending && (
835          <Box paddingLeft={2}>
836            <Text color={t.colors.success}>handoff loaded, consumed on the next reply</Text>
837          </Box>
838        )}
839
840        {heading(t.labels.agents, t.sections.agents, rows.filter(one => one.endedAt === undefined).length)}
841        {rows.length === 0 && empty('no subagents')}
842        {rows.map(row => {
843          const { glyph, color } = agentGlyph(row.status)
844          const isDone = row.endedAt !== undefined
845          // Running rows open by default so the plan is in view; finished rows fold to one line.
846          const isOpen = toggled.includes(row.id) ? isDone : !isDone
847          const doneCount = row.plan.filter(step => step.state === 'done').length
848          const current = row.plan.find(step => step.state === 'doing') ?? row.plan.find(step => step.state === 'todo')
849          const folded = row.plan.length === 0 ? '' : isDone ? `${doneCount}/${row.plan.length} steps` : current ? `${doneCount}/${row.plan.length} · ${current.text}` : ''
850          return (
851            <Box key={row.id} flexDirection="column" paddingLeft={1}>
852              <Box gap={1}>
853                <Text color={color}>{glyph}</Text>
854                <Button key={`ag-${row.id}`} plain label={isOpen ? t.glyphs.collapse : t.glyphs.expand} onPress={toggleAgent(row.id)} />
855                <Box width={inner - 12}>
856                  <Text color={isDone ? t.colors.dim : t.colors.text} bold={!isDone} wrap={isOpen ? 'wrap' : 'truncate-end'}>
857                    {row.description}
858                  </Text>
859                </Box>
860                <Text color={t.colors.dim}>{elapsed(row, now)}</Text>
861              </Box>
862              {!isOpen && folded && (
863                <Box paddingLeft={4} width={inner - 4}>
864                  <Text color={t.colors.muted} wrap="truncate-end">
865                    {folded}
866                  </Text>
867                </Box>
868              )}
869              {isOpen && row.plan.length === 0 && (
870                <Box paddingLeft={4}>
871                  <Text color={t.colors.dim} italic>
872                    {isDone ? 'no plan recorded' : 'reading its assignment…'}
873                  </Text>
874                </Box>
875              )}
876              {isOpen &&
877                row.plan.map((step, i) => (
878                  <Box key={`${row.id}-${i}`} paddingLeft={4} width={inner - 4} gap={1}>
879                    <Text color={step.state === 'done' ? t.colors.success : step.state === 'doing' ? t.colors.text : t.colors.dim}>
880                      {step.state === 'done' ? t.glyphs.stepDone : step.state === 'doing' ? t.glyphs.stepDoing : t.glyphs.stepTodo}
881                    </Text>
882                    <Box width={inner - 6}>
883                      <Text color={step.state === 'doing' ? t.colors.text : step.state === 'done' ? t.colors.muted : t.colors.dim} bold={step.state === 'doing'} wrap="wrap">
884                        {step.text}
885                      </Text>
886                    </Box>
887                  </Box>
888                ))}
889            </Box>
890          )
891        })}
892
893        {heading(t.labels.questions, t.sections.questions, qs.length, busy ? <Text color={t.colors.dim}>scanning…</Text> : undefined)}
894        {qs.length === 0 && empty('none pending')}
895        {qs.map(one =>
896          item(
897            one,
898            t.sections.questions,
899            <>
900              <Button key={`qa-${one.id}`} plain label={t.glyphs.answer} variant="primary" onPress={askAnswer(one)} />
901              <Button key={`qx-${one.id}`} plain label={t.glyphs.dismiss} dimColor onPress={dismiss('questions', one)} />
902            </>,
903          ),
904        )}
905
906        {heading(t.labels.undone, t.sections.undone, us.length, us.length > 1 ? (
907            <Box gap={1}>
908              <Button key="do-all" plain label={t.glyphs.doAll} onPress={doAllUndone} />
909              <Button key="dismiss-all" plain label={t.glyphs.dismissAll} dimColor onPress={() => void setList($, 'undone', () => [])} />
910            </Box>
911          ) : undefined)}
912        {us.length === 0 && empty('nothing left undone')}
913        {us.map(one =>
914          item(
915            one,
916            t.sections.undone,
917            <>
918              <Button key={`ud-${one.id}`} plain label={t.glyphs.do} variant="primary" onPress={doUndone(one)} />
919              <Button key={`ux-${one.id}`} plain label={t.glyphs.dismiss} dimColor onPress={dismiss('undone', one)} />
920            </>,
921          ),
922        )}
923
924        {heading(t.labels.notes, t.sections.notes, ns.length)}
925        {ns.map(one => item(one, t.sections.notes, <Button key={`nx-${one.id}`} plain label={t.glyphs.dismiss} dimColor onPress={dismiss('notes', one)} />))}
926        <Box paddingLeft={1}>
927          {Input ? (
928            <Input key="new-note" placeholder="add a project note, kept across sessions" submitLabel="add" onSubmit={addNote} />
929          ) : (
930            <Text color={t.colors.dim}>add notes with /note from a terminal or desktop</Text>
931          )}
932        </Box>
933        <Box marginTop={1}>
934          <Text color={t.colors.dim}>{legend(t)}</Text>
935        </Box>
936        {track && (
937          // Painted over the right padding column, pinned to the rows the window shows.
938          <Box key="scrollbar" position="absolute" top={Math.max(0, Math.min(view.offset, contentRows - view.bodyRows))} right={0} width={1} flexDirection="column">
939            {Array.from({ length: view.bodyRows }, (_, i) => {
940              const isThumb = i >= track.start && i < track.start + track.size
941              return (
942                <Text key={`sb-${i}`} color={isThumb ? t.scrollbar.thumbColor : t.scrollbar.trackColor}>
943                  {isThumb ? t.scrollbar.thumb : t.scrollbar.track}
944                </Text>
945              )
946            })}
947          </Box>
948        )}
949      </Box>
950    )
951  })
952}
953
hooks/theme.ts 74 lines
1import type { Theme } from '../types'
2
3export const THEME_DOC =
4  'Styling for the Claude Code agenda pane. Edit any value, then run /agenda to reload. Colours are hex or terminal names; glyphs are any text. Plugin updates never touch this file. Delete it to get the defaults back.'
5
6// Deep Space palette (Tokyo Night Moon derived), matching the author's WezTerm background.
7export const DEFAULT_THEME: Theme = {
8  colors: { background: '#0a0d1a', text: '#c8d3f5', dim: '#545c7e', muted: '#828bb8', success: '#c3e88d' },
9  sections: { context: '#c099ff', agents: '#82aaff', questions: '#ffc777', undone: '#ff757f', notes: '#86e1fc' },
10  gauge: { ok: '#c099ff', warn: '#ffc777', danger: '#ff757f', warnAt: 50, dangerAt: 75, filled: '█', empty: '░' },
11  status: {
12    pending: { glyph: '○', color: '#828bb8' },
13    running: { glyph: '●', color: '#82aaff' },
14    waiting: { glyph: '◐', color: '#ffc777' },
15    idle: { glyph: '◐', color: '#ffc777' },
16    completed: { glyph: '✓', color: '#c3e88d' },
17    failed: { glyph: '✗', color: '#ff757f' },
18    killed: { glyph: '✗', color: '#ff757f' },
19  },
20  glyphs: {
21    answer: '✎',
22    do: '▶',
23    doAll: '▶▶ all',
24    dismiss: '✕',
25    dismissAll: '✕ all',
26    expand: '▸',
27    collapse: '▾',
28    bullet: '▎',
29    stepTodo: '○',
30    stepDoing: '◉',
31    stepDone: '✓',
32  },
33  labels: {
34    context: 'CONTEXT',
35    agents: 'AGENTS',
36    questions: 'QUESTIONS',
37    undone: 'UNDONE',
38    notes: 'NOTES',
39    handoff: 'handoff + clear',
40    dropHandoff: 'drop pending handoff',
41  },
42  scrollbar: { thumb: '┃', track: '│', thumbColor: '#828bb8', trackColor: '#2f334d' },
43}
44
45function isRecord(value: unknown): value is Record<string, unknown> {
46  return typeof value === 'object' && value !== null && !Array.isArray(value)
47}
48
49/** Fills a partial theme in over the defaults, keeping only values of the right type. */
50function fill<T>(base: T, raw: unknown): T {
51  if (!isRecord(raw) || !isRecord(base)) return base
52  const out: Record<string, unknown> = { ...base }
53  for (const key of Object.keys(base)) {
54    const want = (base as Record<string, unknown>)[key]
55    const got = raw[key]
56    if (got === undefined) continue
57    if (isRecord(want)) out[key] = fill(want, got)
58    else if (typeof want === typeof got && (typeof got !== 'string' || got.length > 0)) out[key] = got
59  }
60  return out as T
61}
62
63export function mergeTheme(raw: unknown): Theme {
64  const theme = fill(DEFAULT_THEME, raw)
65  theme.gauge.warnAt = Math.max(0, Math.min(100, theme.gauge.warnAt))
66  theme.gauge.dangerAt = Math.max(theme.gauge.warnAt, Math.min(100, theme.gauge.dangerAt))
67  return theme
68}
69
70export function legend(theme: Theme): string {
71  const g = theme.glyphs
72  return `${g.answer} answer · ${g.do} do · ${g.dismiss} dismiss · ${g.expand} expand`
73}
74
types/index.d.ts 88 lines
1export type AgendaItem = {
2  id: string
3  text: string
4  /** ISO date the item was captured. */
5  at: string
6}
7
8export type AgentRow = {
9  id: string
10  /** The Agent call's own few-word description of the task. */
11  description: string
12  type: string
13  status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
14  startedAt: number
15  endedAt?: number
16  /** The macro steps its assignment asks for, in order, each ticked off as its activity shows progress. */
17  plan: PlanStep[]
18}
19
20export type PlanStep = {
21  text: string
22  state: 'todo' | 'doing' | 'done'
23}
24
25export type ContextGauge = {
26  tokens: number
27  window: number
28  percent: number
29  /** API list-price equivalent of the session so far; the user is on a subscription, shown for comparison. */
30  usd: number
31}
32
33export type Handoff = {
34  text: string
35  /** ISO date the handoff was written. */
36  at: string
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    agenda: {
42      questions: AgendaItem[]
43      undone: AgendaItem[]
44      notes: AgendaItem[]
45      agents: AgentRow[]
46      toggledAgents: string[]
47      context: ContextGauge | null
48      handoff: Handoff | null
49      scanning: boolean
50      handingOff: boolean
51      theme: Theme
52    }
53  }
54}
55
56export type StatusLook = { glyph: string; color: string }
57
58/** Everything a person may restyle; read from the theme file, defaults filled in. */
59export type Theme = {
60  colors: { background: string; text: string; dim: string; muted: string; success: string }
61  sections: { context: string; agents: string; questions: string; undone: string; notes: string }
62  gauge: { ok: string; warn: string; danger: string; warnAt: number; dangerAt: number; filled: string; empty: string }
63  status: {
64    pending: StatusLook
65    running: StatusLook
66    waiting: StatusLook
67    idle: StatusLook
68    completed: StatusLook
69    failed: StatusLook
70    killed: StatusLook
71  }
72  glyphs: {
73    answer: string
74    do: string
75    doAll: string
76    dismiss: string
77    dismissAll: string
78    expand: string
79    collapse: string
80    bullet: string
81    stepTodo: string
82    stepDoing: string
83    stepDone: string
84  }
85  labels: { context: string; agents: string; questions: string; undone: string; notes: string; handoff: string; dropHandoff: string }
86  scrollbar: { thumb: string; track: string; thumbColor: string; trackColor: string }
87}
88