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

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.
/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.
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 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.
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.
claude --plugin-dir <this folder>
claude plugin validate .
claude plugin test .hooks/register.tsx 953 lines1import { 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}
953hooks/theme.ts 74 lines1import 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}
74types/index.d.ts 88 lines1export 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