A ticket system for Claude Code: your todo as a live ticket board you can hand to Claude, with each step Claude takes and each helper agent linked to the…

![]()
A ticket system for Claude Code. Plan the work, hand it to Claude, and watch it get done.
Claudeopticon is a mod for Claude Code. It adds a work pane next to your conversation which shows your todo list as a ticket board, what Claude is doing, what its helpers (Claude Code calls them agents) are doing, and what runs in the background. You can send a ticket to Claude, ask how it is going, or move it yourself.
At its core is a ticket system that lives in your repo. Each line in .claude_todo/ is a ticket with an ID such as #T12. From the board you can send a ticket to Claude, ask for a status update, or move it between Open, In progress and Done. Claude keeps each ticket's state up to date as it works, and every helper agent is linked to the tickets it works on.
Run this line in Claude Code:
/plugin install claudeopticon --marketplace Marceux-Croix/Claudeopticon
The pane opens at the start of each session. To open it again, run /claudeopticon. In a session that is already running, run /reload-plugins first.
.claude_todo/ at the root of your repo, and a file in it, for example .claude_todo/TODO.md: - [ ] #T1 Write the migration for the v2 config format
- [ ] #T2 Document the plugin API
⋙ on a ticket to send it to Claude. Claude marks it [~], and the ticket moves to In progress.This card lists what Claude does itself, not its agents. Each line is the short description Claude already writes for an action, with its start time. The newest is on top. A pulsing dot means the action still runs. Older and Newer page through the history.
Claude can start work that keeps running: a build, a server, a monitor (a watch on a log file), or a workflow of several agents. Each task gets a line with a short name, its start time, and a dot: pulsing while it runs, gray when it ended ("(failed)" or "(stopped)" if not clean). Haiku, Claude's small model, makes the name once, at the start. A task that a helper agent started ends with that agent.
Finished tasks are hidden. Show finished tasks brings them back. Agents that run in the background have their own cards.
Each agent gets a card, labeled A1, A2, and so on, in start order. Each agent has its own color. A card shows what the agent was asked to do, its tickets, its progress ("Step 3 of 7" with a bar), its run time, tool calls and tokens, and what it does now. A timeline above the cards shows one bar per agent.
A running agent has a Stop button (⊖) left of its status. Stop ends the agent at once. It gets no chance to finish its step or write a summary. Use it when an agent has gone wrong.
An agent that finished its work but was never stopped shows Idle, with the time it finished and its last command marked "Last:". It does not count as live, and it keeps its Stop button so you can close it.
If an agent vanishes from Claude Code's agent list without a report of how it ended, its card shows Ended after 15 seconds.
Finished agents are hidden. Show finished agents brings them back, grayed out. You can name an agent by its label in chat ("how is A2 doing?"). The mod tells Claude which agent that is.
The pane draws one board for all your todo files, with the columns In progress, Open and Done. Above it, one header card has a row for each file: its name, its progress bar, and its own "done of total" count. If two files have the same name, the row also shows the folder in dim text. A ticket shows its number, its group, its status, and the tags of the agents working on it. The tags blink in the agent's color. When an agent starts on a ticket, the ticket moves to In progress by itself.
Tickets that are not done have buttons on the bottom edge:
| Button | On | What it does |
|---|---|---|
⋙ Send to Claude | Open tickets | Sends the item to Claude as a prompt, to build it. |
? Ask Claude for a status update | Tickets in progress, or already sent | Asks Claude for a short report in the chat. It starts no new work. |
◐ Mark in progress | Open tickets | Moves the ticket by hand. |
✓ Mark done | Tickets in progress | Moves the ticket by hand. |
↩ Move back to open | Tickets in progress | Moves the ticket by hand. |
The move buttons are for work you do yourself, such as a human review. They change the checkbox in your todo file and nothing else. Claude is told at its next action, so it does not work on a ticket you closed. It is not asked to do anything.
The Todo heading says how many todo files the board pulls from ("Pulling todo from 3 files"; archives are not counted). With more than one .claude_todo/ folder, Consolidate todos copies whole files (tickets and notes, every non-blank line) from the other folders into the central one at the repo root, under a ## From <folder> heading. It folds tickets whose text is letter-for-letter identical into one, and copies everything else. A folder that later gains a line the central one lacks comes back on the board. An item whose #T ID the central folder already uses gets the next free ID, references to it in the same file follow, and Claude is told the new IDs once. The old folders are not changed, but they leave the board. Then Delete duplicate todos appears, and stays after a reload while an old folder exists. It removes an old file only after checking that every line of it is in the central file and that the file did not change meanwhile. It keeps any file that fails, naming it, and it merges an old categories.json into the central one before removing it. Both buttons are off in a git worktree and when the repo root is ~/.claude.
Other controls:
The mod reads checkbox lines in the Markdown files at the top level of .claude_todo/, and in .claude_todo/archive/.
- [ ] #T4 Open item
- [~] #T5 Item in progress
- [x] #T6 Done item
#T<n>, unique in the project..claude_todo/archive/..claude_todo/ folder in the repo together, as if they were one todo. It finds the folders with git (tracked, untracked and ignored ones; at most 20) and adds the main checkout's folders from a git worktree. It never reads the global ~/.claude/.claude_todo.#T ID: they stay two tickets.categories.json lives in the folder nearest to the session folder. With no such folder, items are not sorted into categories.At the start of each conversation, the mod gives Claude short instructions. Claude:
.claude_todo/ in the format above and updates each box as status changes;move_ticket tool. You get a dialog, and the box changes only if you say yes.The mod asks each agent to report progress after each step, with a small tool it adds, report_progress. Most agents can use it with no setup. The exception is a custom agent whose definition file lists its tools in a tools: line. Add this name to that list to get its progress bar:
mcp__claudeopticon__report_progress
Without it, the agent shows no bar but still links to its tickets.
An agent links to a ticket when its description or prompt names the ticket's #T ID, when it names the ticket in a progress report, when its prompt quotes the ticket's text, or when Haiku, reading the agent's task once at its start, picks the ticket.
| Option | Default | What it does |
|---|---|---|
rules | On | Gives Claude the instructions for keeping the todo. Turn it off if you wrote your own in CLAUDE.md. |
Set options in the /config menu, under the plugin.
The mod uses Haiku for three small jobs:
Everything else is free or nearly free: the Orchestrator card makes no requests, the instructions to Claude are about 400 tokens per conversation, a progress report is about 40 tokens, and card token counts come from usage Claude Code already reports.
All requests go through your own Claude Code session. The mod sends nothing anywhere else. On disk, it reads .claude_todo/ and writes three things: the checkbox of a ticket you move, the checkbox of a ticket you allow Claude to move, and .claude_todo/categories.json, which remembers each ticket's group.
/reload-plugins keeps it. An agent that started while the mod was off shows its model after its next request, and its run time counts from the load.report_progress in its tool list shows no bar at all..claude_todo/ folders once, when it starts. A new .claude_todo/ in the session's folder or a folder above it shows at once. One made elsewhere needs /reload-plugins (or a new session)..claude folder (~/.claude), because there the repo's root .claude_todo/ is Claude Code's global todo folder, which the mod never writes to. They are also off in a git worktree. In every other repo the central todo is the repo's own root .claude_todo/.How it works explains the hooks, the linking and the drawing.
Load the repo from its folder, run the checks, and see CONTRIBUTING.md.
claude --plugin-dir .
claude plugin validate .
claude plugin test .
The cards follow the Stage UI design system.
hooks/register.tsx 3275 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import type { AgentRow, AgentRowStatus, Progress, TodoFile, TodoItem, TodoStatus } from '../types'
4import { ACCENT, DANGER, GREEN, clock, tokens, orchestratorSvg, plural, agentColor, agentLabel, agentSvg, stopInsetPx, cardWidth, elapsed, boardHead, fileCards, heroSvg, longItems, ticketSvg, setWidth, timelineSvg } from './cards'
5import type { AgentView, FileView, ItemStatus, ItemView, TaskView, Usage, View } from './cards'
6
7const PANE = 'claudeopticon'
8const TITLE = 'Claudeopticon'
9/** Whether this load of the mod has reopened a pane that carried an older title. */
10let isRetitled = false
11const DIR = '.claude_todo'
12const TICK_MS = 1000
13const SCAN_EVERY = 3
14/** Ticks between full resyncs: every file reread whatever its mtime, the categories reloaded, the agent list asked again. */
15const RESYNC_EVERY = 60
16const CLOCK_MS = 5000
17const ITEMS_SHOWN = 12
18/** The corner button's offsets from the card's lower right corner, in cells: about even in px. */
19const ACTION_RIGHT = 2
20const ACTION_BOTTOM = 1
21/** Rows from a live agent card's top edge to its Stop button, on the title row left of the badge. */
22const STOP_TOP = 1
23/** The Stop button's label: a circle with a bar, the stop sign. */
24const STOP_GLYPH = '\u2296'
25/** The cells one corner button and its gap take, to set the tip left of them all. */
26const ACTION_CELLS = 5
27/** The narrowest a ticket may be drawn side by side with others, in px; narrower, columns stack. */
28const MIN_TICKET_PX = 300
29/** CSS pixels per pane cell on the desktop: a slight overestimate, since a card wider than the pane scales down to fit. */
30const CELL_PX = 8.5
31const MAX_UP = 8
32const LINK_MIN_CHARS = 12
33
34const PROGRESS_TOOL = 'mcp__claudeopticon__report_progress'
35const MOVE_TOOL = 'mcp__claudeopticon__move_ticket'
36
37/** Appended to each agent's prompt: about 100 tokens, and each report rides along with a tool call. */
38export const PROGRESS_NOTE = `
39
40Progress: if you have the ${PROGRESS_TOOL} tool, call it once you have a plan, with done (steps finished), total (steps planned) and step (a few words on the current step). If your task covers items in the project's .claude_todo/ files, also pass items: each one quoted from its line, or its #T ID. Call it again after each step, in the same message as your next tool call. Raise total if the plan grows; pass items again only if they change.`
41
42/** An agent's report, held to sense: whole steps, at least one planned, never more done than planned. */
43export function readProgress(input: Record<string, unknown>): Progress | undefined {
44 const total = Math.max(1, Math.round(Number(input.total)))
45 const done = Math.min(total, Math.max(0, Math.round(Number(input.done))))
46 if (!Number.isFinite(total) || !Number.isFinite(done)) {
47 return undefined
48 }
49 const step = typeof input.step === 'string' && input.step.trim() !== '' ? input.step.trim().slice(0, 120) : undefined
50
51 return step === undefined ? { done, total } : { done, total, step }
52}
53
54/** The items an agent claims in a report: up to 20 quoted lines or IDs; undefined when it named none. */
55export function readClaims(input: Record<string, unknown>): string[] | undefined {
56 if (!Array.isArray(input.items)) {
57 return undefined
58 }
59 const claims = input.items
60 .filter((one): one is string => typeof one === 'string' && one.trim() !== '')
61 .map(one => one.trim().slice(0, 300))
62 .slice(0, 20)
63
64 return claims.length === 0 ? undefined : claims
65}
66
67/** How long a claim stands before the next progress reply asks the agent to confirm it. */
68export const CLAIM_CHECK_MS = 10 * 60_000
69
70/**
71 * The reply to a progress report. Every 10 minutes it asks the agent to confirm the items it
72 * claimed, or, when it claimed none, to name them if its task covers any: about 40 tokens, carried
73 * by a call the agent makes anyway.
74 */
75export function progressReply(claims: readonly string[] | undefined, isDue: boolean): string {
76 if (!isDue) {
77 return 'Noted.'
78 }
79 if (claims === undefined || claims.length === 0) {
80 return 'Noted. If your task covers items in .claude_todo/, pass them as items in your next report.'
81 }
82
83 return `Noted. You said you handle: ${claims.map(claim => `"${claim.slice(0, 80)}"`).join('; ')}. If that changed, pass the current items in your next report.`
84}
85
86/** When each agent's claim was last set or confirmed, by agent ID. */
87const claimCheckedAt = new Map<string, number>()
88
89const CLAIM_MIN_CHARS = 12
90const CLAIM_MIN_WORDS = 3
91const CLAIM_MIN_SHARE = 0.7
92
93function words(text: string): Set<string> {
94 return new Set(normalize(text).split(/[^a-z0-9]+/).filter(word => word.length >= 3))
95}
96
97/**
98 * How well a claim names an item, from 0 to 1: 1 when it gives the item's ID or holds the item's
99 * text (or the item holds the claim); else the share of the shorter one's words the two have in
100 * common, counted only from 3 shared words and a 70% share, so a loose paraphrase still lands.
101 */
102export function claimScore(claim: string, item: TodoItem): number {
103 const c = normalize(claim)
104 if (item.id !== undefined && new RegExp(`#${item.id.toLowerCase()}(?![0-9a-z])`).test(c)) {
105 return 1
106 }
107 const t = normalize(item.text)
108 if (Math.min(c.length, t.length) >= CLAIM_MIN_CHARS && (t.includes(c) || c.includes(t))) {
109 return 1
110 }
111 const a = words(claim)
112 const b = words(item.text)
113 const shared = [...a].filter(word => b.has(word)).length
114 const share = shared / Math.max(1, Math.min(a.size, b.size))
115
116 return shared >= CLAIM_MIN_WORDS && share >= CLAIM_MIN_SHARE ? share : 0
117}
118
119/**
120 * What the pane draws, held by this session's own process. Each session runs its own copy of the
121 * mod, so this can hold nothing but this session's project and agents. A hot reload starts it
122 * over; the next scan and agent list fill it again within a few seconds.
123 */
124let todoFiles: TodoFile[] = []
125let agentRows: AgentRow[] = []
126let clockMs = 0
127const expandedFiles = new Set<string>()
128/** Files whose long items show whole, past 8 lines, by the pane's file ID. */
129const openText = new Set<string>()
130let isTicketView = true
131let usage: Usage | undefined
132
133/**
134 * What the main conversation (the orchestrator) did itself this session: the one-line summary of
135 * each of its tool calls (the call's own `description`, as the transcript shows it), with its start
136 * time, newest last, the last 1000 kept. Agent calls stay out: they are the agents' work.
137 */
138export type OrchestratorEntry = { id: number; line: string; at: number; isRunning: boolean }
139let orchestrator: { isActive: boolean; history: OrchestratorEntry[] } = { isActive: false, history: [] }
140let callCount = 0
141/** Which page of the history the card shows: 0 the newest. */
142let orchestratorPage = 0
143/** Categories the person hid, by name; their items leave the list and the board (search still finds them). */
144const hiddenCategories = new Set<string>()
145/** Tickets already sent to Claude this session, by file and item key. */
146const sentTickets = new Set<string>()
147let isFinishedShown = false
148let isCompletedShown = false
149let sortBy: 'file' | 'category' = 'file'
150let query = ''
151function redraw($: EngineInterface): void {
152 $.ui.invalidate('ui.render')
153 saveAgents($)
154}
155
156// ---------------------------------------------------------------- agents kept across a reload
157
158/** The agent rows kept in the store per session, so a plugin reload keeps each agent's data. */
159const KEPT_AGENTS = 200
160let isAgentsLoaded = false
161let savedAgents = ''
162
163async function agentsKey($: EngineInterface): Promise<string> {
164 return `agents:${await $.session.id()}`
165}
166
167/** Reads a stored list back into rows, keeping only entries with the fields every row has. */
168export function readKeptAgents(stored: unknown): AgentRow[] {
169 if (!Array.isArray(stored)) {
170 return []
171 }
172
173 return stored.filter((row): row is AgentRow =>
174 typeof row === 'object' && row !== null && typeof row.id === 'string' && typeof row.description === 'string'
175 && typeof row.type === 'string' && typeof row.status === 'string' && typeof row.startedAt === 'number'
176 && typeof row.toolCalls === 'number' && typeof row.brief === 'string')
177}
178
179/**
180 * Loads the rows this session kept, once per load of the mod, under the rows already seen: a row
181 * seen since the load wins. An agent whose ticket match already ran is not matched again.
182 */
183async function loadAgents($: EngineInterface): Promise<void> {
184 if (isAgentsLoaded) {
185 return
186 }
187 isAgentsLoaded = true
188 const kept = readKeptAgents(await $.store.get(await agentsKey($)).catch(() => undefined))
189 const seen = new Set(agentRows.map(row => row.id))
190 agentRows = [...kept.filter(row => !seen.has(row.id)), ...agentRows]
191 for (const row of kept) {
192 if (row.autoClaims !== undefined) {
193 matchedAgents.add(row.id)
194 }
195 }
196 // The first store after a load rewrites the list, without entries that did not read back.
197 savedAgents = ''
198}
199
200/** Stores the rows when they changed since the last store, after the kept rows are loaded. */
201function saveAgents($: EngineInterface): void {
202 if (!isAgentsLoaded) {
203 return
204 }
205 const rows = agentRows.slice(-KEPT_AGENTS)
206 const text = JSON.stringify(rows)
207 if (text === savedAgents) {
208 return
209 }
210 savedAgents = text
211 void agentsKey($).then(key => $.store.set(key, rows)).catch(() => undefined)
212}
213
214const ENDED: readonly AgentRowStatus[] = ['completed', 'failed', 'killed', 'ended']
215
216// ---------------------------------------------------------------- todo
217
218const CHECKBOX = /^\s*[-*+] \[([ xX~>])\]\s+(.*)$/
219const ITEM_ID = /#([A-Za-z]+\d+)\b/
220
221/** An item's text as the pane shows it: Markdown bold and strike markers taken out, spaces trimmed. */
222export function plainText(markdown: string): string {
223 return markdown.replace(/\*\*|__|~~/g, '').trim()
224}
225
226/** Reads the checkbox items of one todo file, in the format the RULES block gives Claude. */
227export function parseTodo(markdown: string): TodoItem[] {
228 const items: TodoItem[] = []
229 for (const line of markdown.split('\n')) {
230 const match = CHECKBOX.exec(line)
231 if (match === null) {
232 continue
233 }
234 const box = match[1] ?? ' '
235 const status: TodoStatus =
236 box === 'x' || box === 'X' ? 'completed' : box === '~' || box === '>' ? 'in_progress' : 'pending'
237 const text = plainText(match[2] ?? '')
238 const id = ITEM_ID.exec(text)?.[1]
239 items.push(id === undefined ? { text, status } : { text, status, id })
240 }
241
242 return items
243}
244
245export function count(items: readonly TodoItem[]): { done: number; active: number; total: number } {
246 return {
247 done: items.filter(item => item.status === 'completed').length,
248 active: items.filter(item => item.status === 'in_progress').length,
249 total: items.length,
250 }
251}
252
253/**
254 * Finds the project's own todo folder: `.claude_todo/` at the root of the repo the session works
255 * in, or of its main checkout from a git worktree (whose `.git` file names it; `.claude_todo/` may
256 * be gitignored, so a worktree may have none). Outside a repo, only the session folder's own. Never
257 * the `.claude_todo/` in the home `.claude` folder, which belongs to no project.
258 */
259export async function findTodoDir($: EngineInterface): Promise<string | undefined> {
260 return findTodoDirWith({
261 exists: path => $.fs.exists(path),
262 kind: path => $.fs.stat(path).then(stat => stat.kind).catch(() => undefined),
263 realPath: path => $.fs.stat(path, { resolve: true }).then(stat => stat.realPath).catch(() => undefined),
264 read: path => $.fs.read(path).then(text => (typeof text === 'string' ? text : '')).catch(() => ''),
265 })
266}
267
268/** The file system calls the todo lookup makes, apart from `$` so a test can lay out folders. */
269export type TodoFs = {
270 exists: (path: string) => Promise<boolean>
271 kind: (path: string) => Promise<string | undefined>
272 realPath: (path: string) => Promise<string | undefined>
273 read: (path: string) => Promise<string>
274}
275
276export async function findTodoDirWith(fs: TodoFs): Promise<string | undefined> {
277 const found = await repoTodoDir(fs)
278 if (found === undefined) {
279 return undefined
280 }
281 const real = await fs.realPath(found)
282
283 return real !== undefined && isGlobalTodo(real) ? undefined : found
284}
285
286/** True for the global todo folder, `<home>/.claude/.claude_todo`, which the mod never reads. A project's own `<repo>/.claude/.claude_todo` is not it. */
287export function isGlobalTodo(realPath: string): boolean {
288 return /^\/(Users|home)\/[^/]+\/\.claude\/\.claude_todo\/?$|^\/root\/\.claude\/\.claude_todo\/?$/.test(realPath)
289}
290
291/**
292 * The nearest `.claude_todo/`, from the session folder up to the repo root: a subfolder with a todo
293 * of its own keeps it. With none up to the root, a worktree uses its main checkout's. Outside a
294 * repo, only the session folder's own counts.
295 */
296async function repoTodoDir(fs: TodoFs): Promise<string | undefined> {
297 for (let depth = 0; depth <= MAX_UP; depth += 1) {
298 const prefix = '../'.repeat(depth)
299 if (await fs.exists(`${prefix}${DIR}`)) {
300 // Taken only inside a repo: the walk must reach a .git at or above this folder.
301 for (let up = depth; up <= MAX_UP; up += 1) {
302 if (await fs.exists(`${'../'.repeat(up)}.git`)) {
303 return `${prefix}${DIR}`
304 }
305 }
306
307 return depth === 0 ? DIR : undefined
308 }
309 const git = `${prefix}.git`
310 if (!(await fs.exists(git))) {
311 continue
312 }
313 if ((await fs.kind(git)) === 'file') {
314 const gitdir = /^gitdir:\s*(.+)$/m.exec(await fs.read(git))?.[1]?.trim()
315 const main = gitdir?.split('/.git/worktrees/')[0]
316
317 return main !== undefined && main !== gitdir && (await fs.exists(`${main}/${DIR}`)) ? `${main}/${DIR}` : undefined
318 }
319
320 return undefined
321 }
322
323 return undefined
324}
325
326// ---------------------------------------------------------------- folders
327
328/** One `.claude_todo/` folder: `path` as the engine's fs takes it, `rel` from its checkout's root. */
329export type TodoFolder = { path: string; rel: string; isOther: boolean; real?: string }
330
331/** Most folders the pane reads: a repo with more is cut, primary folder first, then by path. */
332export const MAX_FOLDERS = 20
333
334/**
335 * The `.claude_todo/` folders in the output of `git ls-files -z`: every entry with a `.claude_todo`
336 * segment is cut to the folder path ending there. An ignored folder lists as a collapsed directory
337 * entry (`x/.claude_todo/`), which cuts the same way. Duplicates drop; the order is by path.
338 */
339export function todoFoldersIn(listing: string, root: string): { path: string; rel: string }[] {
340 const rels = new Set<string>()
341 for (const entry of listing.split('\0')) {
342 const parts = entry.split('/').filter(part => part !== '')
343 const at = parts.indexOf(DIR)
344 if (at >= 0) {
345 rels.add(parts.slice(0, at + 1).join('/'))
346 }
347 }
348
349 return [...rels].sort().map(rel => ({ path: `${root}/${rel}`, rel }))
350}
351
352/**
353 * The repo root and, from a git worktree, its main checkout, from the output of
354 * `git rev-parse --path-format=absolute --show-toplevel --git-common-dir`.
355 */
356export function repoRoots(stdout: string): { root: string; main?: string } | undefined {
357 const [top, common] = stdout.split('\n').map(line => line.trim())
358 if (top === undefined || top === '' || !top.startsWith('/')) {
359 return undefined
360 }
361 const main = common !== undefined && common !== `${top}/.git` && common.endsWith('/.git') ? common.slice(0, -'/.git'.length) : undefined
362
363 return main === undefined || main === '' ? { root: top } : { root: top, main }
364}
365
366/**
367 * The folders the pane reads from those found: the global ones dropped, a place found twice kept
368 * once (the primary's entry when it is one of them, else the first), the primary folder first and
369 * the rest by path, cut to MAX_FOLDERS. `primary` is the `path` of the one folder the nearest-up
370 * rule picks.
371 */
372export function pickFolders(found: readonly { path: string; rel: string; real?: string }[], primary: string | undefined, globals: ReadonlySet<string>): TodoFolder[] {
373 const byRel = new Map<string, { path: string; rel: string; real?: string }>()
374 for (const one of found) {
375 if (globals.has(one.path)) {
376 continue
377 }
378 const held = byRel.get(one.rel)
379 if (held === undefined || (one.path === primary && held.path !== primary)) {
380 byRel.set(one.rel, one)
381 }
382 }
383 const kept = [...byRel.values()]
384 const first = kept.filter(one => one.path === primary)
385 const rest = kept.filter(one => one.path !== primary).sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))
386
387 return [...first.map(one => ({ ...one, isOther: false })), ...rest.map(one => ({ ...one, isOther: true }))].slice(0, MAX_FOLDERS)
388}
389
390/** The `.claude_todo/` folders of a checkout, tracked, untracked and ignored, through git. */
391async function listFolders($: EngineInterface, root: string): Promise<{ path: string; rel: string }[]> {
392 const run = async (argv: string[]) => {
393 const done = await $.process.run(argv, { cwd: root })
394
395 return done.exitCode === 0 ? done.stdout : ''
396 }
397 const tracked = await run(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'])
398 const ignored = await run(['git', 'ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'])
399
400 return todoFoldersIn(`${tracked}\0${ignored}`, root)
401}
402
403/** The real path of a folder, or undefined when it cannot be resolved. */
404function realOf($: EngineInterface, path: string): Promise<string | undefined> {
405 return $.fs.stat(path, { resolve: true }).then(stat => stat.realPath).catch(() => undefined)
406}
407
408/** The repo root of the session, kept to name a folder found later by its place in the repo. */
409let repoRoot: string | undefined
410/** Where the central todo lives: the root of the main checkout (the repo root outside a worktree). */
411let centralRoot: string | undefined
412
413/**
414 * Finds every `.claude_todo/` folder of the repo (and of the main checkout, from a worktree) with
415 * git. Outside a repo, or when git fails, only the nearest one by the old rule counts. A path git
416 * lists but the disk no longer has (a tracked file deleted) is dropped.
417 */
418async function discoverFolders($: EngineInterface): Promise<TodoFolder[]> {
419 const nearest = await findTodoDir($).catch(() => undefined)
420 const nearestReal = nearest === undefined ? undefined : await realOf($, nearest)
421 const alone: TodoFolder[] = nearest === undefined ? [] : [{ path: nearest, rel: nearest, isOther: false, ...(nearestReal === undefined ? {} : { real: nearestReal }) }]
422 try {
423 const top = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'])
424 const roots = top.exitCode === 0 ? repoRoots(top.stdout) : undefined
425 if (roots === undefined) {
426 return alone
427 }
428 repoRoot = roots.root
429 centralRoot = roots.main ?? roots.root
430 // Consolidate and Delete are off in a linked worktree and when the main folder is the global one.
431 const centralPath = `${centralRoot}/${DIR}`
432 centralBlocked = roots.main !== undefined || isGlobalTodo(centralPath) || isGlobalTodo((await realOf($, centralPath)) ?? centralPath)
433 const listed = [...(await listFolders($, roots.root)), ...(roots.main === undefined ? [] : await listFolders($, roots.main))]
434 const found: { path: string; rel: string; real?: string }[] = []
435 const globals = new Set<string>()
436 let primary: string | undefined
437 for (const one of listed) {
438 if (!(await $.fs.exists(one.path).catch(() => false))) {
439 continue
440 }
441 const real = await realOf($, one.path)
442 found.push(real === undefined ? one : { ...one, real })
443 if (isGlobalTodo(real ?? one.path)) {
444 globals.add(one.path)
445 }
446 if (real !== undefined && real === nearestReal && primary === undefined) {
447 primary = one.path
448 }
449 }
450 if (nearest !== undefined && primary === undefined) {
451 // The nearest folder git did not list: keep it, named by its place in the repo when that shows.
452 found.unshift({ path: nearest, rel: relIn(nearestReal, roots.root) ?? nearest, ...(nearestReal === undefined ? {} : { real: nearestReal }) })
453 primary = nearest
454 }
455
456 return pickFolders(found, primary, globals)
457 } catch {
458 return alone
459 }
460}
461
462/** A real path from the repo root, or undefined when it lies outside the root. */
463function relIn(real: string | undefined, root: string | undefined): string | undefined {
464 return real !== undefined && root !== undefined && real.startsWith(`${root}/`) ? real.slice(root.length + 1) : undefined
465}
466
467/** The folders found when the mod started; undefined until then. Git runs once per load; a reload does it again. */
468let todoFolders: TodoFolder[] | undefined
469let folderSearch: Promise<TodoFolder[]> | undefined
470/** The nearest folder the last scan saw, to look it up among the known ones only when it changes. */
471let nearestSeen: string | undefined
472
473function ensureFolders($: EngineInterface): Promise<TodoFolder[]> {
474 if (todoFolders !== undefined) {
475 return Promise.resolve(todoFolders)
476 }
477 folderSearch ??= discoverFolders($).then(async found => {
478 await loadDuplicates($, found).catch(() => undefined)
479 todoFolders = found
480
481 return found
482 })
483
484 return folderSearch
485}
486
487// ---------------------------------------------------------------- consolidating
488
489/**
490 * Folders whose todo was copied into the central one: they stay off the board, and the Delete
491 * duplicate todos button stays while any of them exists. Kept in the store by repo root, so a reload
492 * or a new session knows them. `wasPrimary`: the folder was the primary one when it was copied.
493 */
494let dupFolders: { path: string; real?: string; wasPrimary?: boolean }[] = []
495/** By source file (absolute path): the IDs renumbered when it was copied, old (lowercase) to new. */
496let renameMap: Record<string, Record<string, string>> = {}
497/** By source file (absolute path): the source lines (trailing spaces cut) folded into an identical central ticket, not copied. */
498let foldMap: Record<string, string[]> = {}
499/** What the last coverage check looked at, so the board's scan checks again only after a file changed. */
500let lastCoverageSig: string | undefined
501/** True when Consolidate and Delete are off: a linked worktree, or a main folder that would be the global one. */
502let centralBlocked = false
503/** The renumbered IDs Claude has not been told of yet. */
504let pendingRenames: string[] = []
505
506const dupKey = (): string | undefined => (centralRoot === undefined ? undefined : `duplicates:${centralRoot}`)
507const metaKey = (): string | undefined => (centralRoot === undefined ? undefined : `duplicates-meta:${centralRoot}`)
508
509function isDuplicate(folder: { path: string; real?: string }): boolean {
510 return dupFolders.some(one => one.path === folder.path || (one.real !== undefined && one.real === folder.real))
511}
512
513/** The central folder: `<root of the main checkout>/.claude_todo`. */
514function centralFolder(): string | undefined {
515 return centralRoot === undefined ? undefined : `${centralRoot}/${DIR}`
516}
517
518/** The folders on the board: the known ones, less the duplicates. When the primary one was a duplicate, the central one takes its place. */
519function visibleFolders(): TodoFolder[] {
520 const all = todoFolders ?? []
521 const shown = all.filter(one => !isDuplicate(one))
522 const lostPrimary = all.some(one => !one.isOther && isDuplicate(one)) && !shown.some(one => !one.isOther)
523
524 return lostPrimary ? shown.map(one => (one.path === centralFolder() ? { ...one, isOther: false } : one)) : shown
525}
526
527/** True when the Consolidate todos button shows: a repo's main checkout (not a worktree, not the global folder), and more than one folder on the board. */
528function canConsolidate(): boolean {
529 return !centralBlocked && centralRoot !== undefined && visibleFolders().length > 1
530}
531
532/** True when the Delete duplicate todos button shows: the same gate, and a recorded duplicate folder. */
533function canDelete(): boolean {
534 return !centralBlocked && centralRoot !== undefined && dupFolders.length > 0
535}
536
537/** Writes the renumbering and fold records to the store. */
538async function saveMeta($: EngineInterface): Promise<void> {
539 const meta = metaKey()
540 if (meta !== undefined) {
541 await $.store.set(meta, { primary: dupFolders.filter(one => one.wasPrimary === true).map(one => one.path), renames: renameMap, folds: foldMap }).catch(() => undefined)
542 }
543}
544
545/** Writes the duplicate record and the renumbering record to the store. */
546async function saveRecord($: EngineInterface): Promise<void> {
547 const key = dupKey()
548 if (key === undefined) {
549 return
550 }
551 await $.store.set(key, dupFolders.map(one => one.path)).catch(() => undefined)
552 await saveMeta($)
553}
554
555/** Keeps the recorded duplicates that still exist on disk, and rewrites the record when some are gone. */
556async function loadDuplicates($: EngineInterface, found: readonly TodoFolder[] = todoFolders ?? []): Promise<void> {
557 const key = dupKey()
558 const meta = metaKey()
559 if (key === undefined || meta === undefined) {
560 return
561 }
562 const stored = await $.store.get(key).catch(() => undefined)
563 const kept = (await $.store.get(meta).catch(() => undefined)) as { primary?: unknown; renames?: unknown; folds?: unknown } | undefined
564 const primary = new Set(Array.isArray(kept?.primary) ? kept.primary.filter((one): one is string => typeof one === 'string') : [])
565 if (typeof kept?.renames === 'object' && kept.renames !== null) {
566 renameMap = { ...(kept.renames as typeof renameMap), ...renameMap }
567 }
568 // Only arrays of strings read back as folds: a malformed entry means no folds, so its lines must be copied.
569 if (typeof kept?.folds === 'object' && kept.folds !== null) {
570 const clean = Object.fromEntries(Object.entries(kept.folds as Record<string, unknown>).map(([file, list]) => [file, Array.isArray(list) ? list.filter((one): one is string => typeof one === 'string') : []]))
571 foldMap = { ...clean, ...foldMap }
572 }
573 // The record and what this load already knows: a store that fails must not unhide a folder.
574 const paths = [...new Set([...(Array.isArray(stored) ? stored.filter((one): one is string => typeof one === 'string') : []), ...dupFolders.map(one => one.path)])]
575 const alive: typeof dupFolders = []
576 for (const path of paths) {
577 if (!path.startsWith('/') || !(await $.fs.exists(path).catch(() => false))) {
578 continue
579 }
580 const real = found.find(one => one.path === path)?.real ?? (await realOf($, path))
581 const was = primary.has(path) || dupFolders.some(one => one.path === path && one.wasPrimary === true)
582 alive.push({ path, ...(real === undefined ? {} : { real }), ...(was ? { wasPrimary: true } : {}) })
583 }
584 dupFolders = alive
585 if (alive.length !== paths.length) {
586 // An empty list is the cleared record.
587 await saveRecord($)
588 }
589 await dropUncovered($, true)
590}
591
592/** Every checkbox line of a todo file's text, as written. */
593export function itemLines(markdown: string): string[] {
594 return markdown.split('\n').filter(line => CHECKBOX.test(line))
595}
596
597/** Every line of the text with something on it, trailing spaces cut. */
598export function nonBlankLines(markdown: string): string[] {
599 return markdown.split('\n').map(line => line.trimEnd()).filter(line => line.trim() !== '')
600}
601
602/** An item line as compared between files: its box and text, with only its own leading `#T` ID taken out. */
603export function lineSignature(line: string): string {
604 const match = CHECKBOX.exec(line)
605 const box = (match?.[1] ?? ' ').replace(/[xX]/, 'x').replace('>', '~')
606
607 return `${box} ${plainText(match?.[2] ?? '').replace(/^#[A-Za-z]+\d+\s*/, '').replace(/\s+/g, ' ').trim().toLowerCase()}`
608}
609
610/** What a line must match in the central file to count as copied: the exact line (after the renumbering), item lines and prose alike. */
611export function coverKey(line: string): string {
612 return `t:${line.trimEnd()}`
613}
614
615/** The line with every `#ID` in the map replaced by its new one (one pass, so a new ID is never renamed again). */
616export function applyRenames(line: string, renames: Readonly<Record<string, string>>): string {
617 return line.replace(/#([A-Za-z]+\d+)\b/g, (whole, id: string) => {
618 const fresh = renames[id.toLowerCase()]
619
620 return fresh === undefined ? whole : `#${fresh}`
621 })
622}
623
624/**
625 * The lines of one source file with every item ID that `used` has already replaced by the next free
626 * ID of its letters (`#T3` when `#T1` and `#T2` are taken), and the references to it in the other
627 * lines of the file rewritten the same way. `used` (lowercase IDs) gains each ID the file ends up with.
628 * `renames` maps old (lowercase) to new; `pairs` lists them as written, for the toast.
629 */
630export function renumberLines(lines: readonly string[], used: Set<string>): { lines: string[]; renames: Record<string, string>; pairs: { from: string; to: string }[] } {
631 const renames: Record<string, string> = {}
632 const pairs: { from: string; to: string }[] = []
633 // A new number also skips this source's own IDs, so one clash never pushes a later line out.
634 const own = lines.flatMap(line => {
635 const id = CHECKBOX.test(line) ? ITEM_ID.exec(line)?.[1] : undefined
636
637 return id === undefined ? [] : [id.toLowerCase()]
638 })
639 const nextFree = (letters: string) => {
640 let top = 0
641 for (const id of [...used, ...own]) {
642 const m = /^([a-z]+)(\d+)$/.exec(id)
643 if (m !== null && m[1] === letters.toLowerCase()) {
644 top = Math.max(top, Number(m[2]))
645 }
646 }
647
648 return `${letters}${top + 1}`
649 }
650 for (const line of lines) {
651 const id = CHECKBOX.test(line) ? ITEM_ID.exec(line)?.[1] : undefined
652 if (id === undefined) {
653 continue
654 }
655 if (renames[id.toLowerCase()] !== undefined) {
656 continue
657 }
658 if (!used.has(id.toLowerCase())) {
659 used.add(id.toLowerCase())
660 continue
661 }
662 const fresh = nextFree(/^[A-Za-z]+/.exec(id)?.[0] ?? 'T')
663 used.add(fresh.toLowerCase())
664 renames[id.toLowerCase()] = fresh
665 pairs.push({ from: id, to: fresh })
666 }
667
668 return { lines: lines.map(line => applyRenames(line, renames)), renames, pairs }
669}
670
671/** The text of a target file with new sections added: kept as it was, then each `## From <place>` block. */
672export function appendSections(existing: string, sections: readonly { place: string; lines: readonly string[] }[]): string {
673 let text = existing
674 for (const section of sections) {
675 const gap = text.trim() === '' ? '' : text.endsWith('\n') ? '\n' : '\n\n'
676 text = `${text}${gap}## From ${section.place}\n\n${section.lines.join('\n')}\n`
677 }
678
679 return text
680}
681
682/** The non-blank lines under each `## From <place>` heading of the text, one list per heading, up to the next `## ` heading. */
683export function sectionBlocks(text: string, place: string): string[][] {
684 const blocks: string[][] = []
685 let current: string[] | undefined
686 for (const line of text.split('\n').map(one => one.trimEnd())) {
687 if (line.trim() === `## From ${place}`) {
688 current = []
689 blocks.push(current)
690 } else if (line.startsWith('## ')) {
691 current = undefined
692 } else if (current !== undefined && line.trim() !== '') {
693 current.push(line)
694 }
695 }
696
697 return blocks
698}
699
700/** True when the text already holds the `## From <place>` section of that source. */
701export function hasSection(text: string, place: string): boolean {
702 return text.split('\n').some(line => line.trim() === `## From ${place}`)
703}
704
705/** The one-time note for Claude after IDs were renumbered. */
706export function renameNote(lines: readonly string[]): string | undefined {
707 return lines.length === 0
708 ? undefined
709 : [
710 'The Claudeopticon pane (the claudeopticon mod) consolidated several .claude_todo/ folders into the repo\'s main one. These item IDs collided and were renumbered in the copies. Use the new IDs from now on:',
711 ...lines,
712 ].join('\n')
713}
714
715/** Where a folder sits in its checkout, for the heading in the central file: its path without `.claude_todo`. */
716function placeOf(rel: string): string {
717 const place = rel.slice(0, Math.max(0, rel.length - DIR.length)).replace(/\/$/, '')
718
719 return place === '' ? 'the root of another checkout' : place
720}
721
722/** A folder's place in its checkout, for telling same-named files apart: its path without `.claude_todo`; empty at the repo root. */
723export function folderPlace(rel: string): string {
724 return rel.slice(0, Math.max(0, rel.length - DIR.length)).replace(/\/$/, '')
725}
726
727/** The `.md` files of a folder, top level and archive, as names inside it. */
728async function mdFiles($: EngineInterface, folder: string, isStrict = false): Promise<string[]> {
729 const names: string[] = []
730 for (const [dir, prefix] of [[folder, ''], [`${folder}/archive`, 'archive/']] as const) {
731 // Strict, a folder that will not answer or list is an error, not an empty or absent folder.
732 if (!(isStrict ? await $.fs.exists(dir) : await $.fs.exists(dir).catch(() => false))) {
733 continue
734 }
735 for (const entry of isStrict ? await $.fs.list(dir) : await $.fs.list(dir).catch(() => [])) {
736 if (entry.kind === 'file' && entry.name.endsWith('.md')) {
737 names.push(`${prefix}${entry.name}`)
738 }
739 }
740 }
741
742 return names
743}
744
745/** The text of a file that must read: a failed read is an error, not an empty file. */
746async function readStrict($: EngineInterface, path: string): Promise<string> {
747 const text = await $.fs.read(path)
748 if (typeof text !== 'string') {
749 throw new Error(`could not read ${path}`)
750 }
751
752 return text
753}
754
755async function readText($: EngineInterface, path: string): Promise<string> {
756 const text = await $.fs.read(path).catch(() => '')
757
758 return typeof text === 'string' ? text : ''
759}
760
761/** Writes a file, making its folder first through `mkdir -p` (no shell) when the plain write fails. */
762async function writeFile($: EngineInterface, path: string, text: string): Promise<void> {
763 try {
764 await $.fs.write(path, text)
765 } catch (error) {
766 const dir = path.slice(0, path.lastIndexOf('/'))
767 const made = await $.process.run(['mkdir', '-p', dir]).catch(() => undefined)
768 if (made?.exitCode !== 0) {
769 throw error
770 }
771 await $.fs.write(path, text)
772 }
773}
774
775let isWorking = false
776
777/**
778 * The parts of a ticket line that a fold compares: everything but the item's own leading `#T<n>` ID,
779 * kept letter for letter (bullet, box, spacing, text), and that ID. Undefined for any other line.
780 */
781export function foldParts(line: string): { key: string; id: string } | undefined {
782 const m = /^(\s*[-*+] \[[ xX~>]\]\s+)#([A-Za-z]+\d+)(?=\s|$)(.*)$/.exec(line.trimEnd())
783
784 return m === null ? undefined : { key: `${m[1]}#${m[3]}`, id: m[2]! }
785}
786
787/**
788 * Moves the work to one central todo, `<root of the main checkout>/.claude_todo/`: the non-blank
789 * lines of every other folder's files go into the central file of the same name (archive files into
790 * the central `archive/`), in order, under a `## From <place>` heading. A line the central file
791 * already holds (exactly, after the file's recorded renames) is not copied again. A ticket line
792 * identical, apart from its own ID, to a ticket line of the central file or one queued for it in
793 * this press is folded: not copied, its ID mapped to the existing ticket's (references follow, and
794 * the fold is recorded so Delete knows). Any other ticket whose ID the central folder uses gets the
795 * next free ID. Each target file is written once, if it did not change since it was read; the sources
796 * are never touched. Then the folders are found again (the one discovery outside startup), the
797 * sources join the duplicates, and a folder not fully covered leaves them again. Off in a linked
798 * worktree and when the main folder would be the global one.
799 */
800async function consolidate($: EngineInterface): Promise<void> {
801 const central = centralFolder()
802 if (isWorking || central === undefined || !canConsolidate()) {
803 return
804 }
805 isWorking = true
806 try {
807 await dropUncovered($, true)
808 if (!canConsolidate()) {
809 return
810 }
811 const realCentral = await realOf($, central)
812 const sources: { path: string; real: string; rel: string; wasPrimary: boolean }[] = []
813 for (const one of visibleFolders()) {
814 if (one.path === central || (realCentral !== undefined && one.real === realCentral) || !one.path.startsWith('/')) {
815 continue
816 }
817 const real = one.real ?? (await realOf($, one.path))
818 if (real !== undefined) {
819 sources.push({ path: one.path, real, rel: one.rel, wasPrimary: !one.isOther })
820 }
821 }
822 if (sources.length === 0) {
823 return
824 }
825 const used = new Set<string>()
826 const texts = new Map<string, string>()
827 const centralText = async (name: string) => {
828 const held = texts.get(name)
829 if (held !== undefined) {
830 return held
831 }
832 const path = `${central}/${name}`
833 // An existing central file that cannot be read aborts the press: it is never written over.
834 const text = (await $.fs.exists(path)) ? await readStrict($, path) : ''
835 texts.set(name, text)
836
837 return text
838 }
839 for (const name of await mdFiles($, central, true)) {
840 for (const line of itemLines(await centralText(name))) {
841 const id = ITEM_ID.exec(line)?.[1]
842 if (id !== undefined) {
843 used.add(id.toLowerCase())
844 }
845 }
846 }
847 // Per target: the central lines still free to cover a source line, and the tickets a fold may join.
848 const free = new Map<string, Map<string, number>>()
849 const whole = new Map<string, Map<string, number>>()
850 const tickets = new Map<string, Map<string, string>>()
851 const stateFor = async (name: string) => {
852 if (!free.has(name)) {
853 const held = new Map<string, number>()
854 const ids = new Map<string, string>()
855 for (const line of nonBlankLines(await centralText(name))) {
856 held.set(coverKey(line), (held.get(coverKey(line)) ?? 0) + 1)
857 const parts = foldParts(line)
858 if (parts !== undefined && !ids.has(parts.key)) {
859 ids.set(parts.key, parts.id)
860 }
861 }
862 free.set(name, held)
863 whole.set(name, new Map(held))
864 tickets.set(name, ids)
865 }
866
867 return { held: free.get(name)!, all: whole.get(name)!, ids: tickets.get(name)! }
868 }
869 type Block = { place: string; lines: string[] }
870 type Commit = { target?: string; file: string; renames: Record<string, string>; folds: string[]; told: string[] }
871 const sections = new Map<string, Block[]>()
872 const commits: Commit[] = []
873 // A source that will not list or read stays on the board: nothing of it was copied.
874 const unreadable = new Set<string>()
875 for (const source of sources) {
876 const place = placeOf(source.rel)
877 const names = await mdFiles($, source.path, true).catch(() => undefined)
878 if (names === undefined) {
879 unreadable.add(source.path)
880 continue
881 }
882 for (const name of names) {
883 const path = `${source.path}/${name}`
884 const text = await readStrict($, path).catch(() => undefined)
885 if (text === undefined) {
886 unreadable.add(source.path)
887 continue
888 }
889 const lines = nonBlankLines(text)
890 const { held, all: whole, ids } = await stateFor(name)
891 const old = renameMap[path] ?? {}
892 const oldFolds = new Set(foldMap[path] ?? [])
893 const fold: Record<string, string> = {}
894 // What the lines already in the central file still need of the old record; the rest is stale.
895 const keepOld: Record<string, string> = {}
896 const keepFolds: string[] = []
897 const folded: string[] = []
898 const told: string[] = []
899 const toCopy: string[] = []
900 for (const line of lines) {
901 // Already in the central file: exactly, after this file's renames. A recorded fold may share its line.
902 const key = coverKey(applyRenames(line, old))
903 // A recorded fold may share its central line, used or not; any other line uses it up.
904 if (oldFolds.has(line) ? (whole.get(key) ?? 0) > 0 : (held.get(key) ?? 0) > 0) {
905 if (oldFolds.has(line)) {
906 keepFolds.push(line)
907 } else {
908 held.set(key, (held.get(key) ?? 0) - 1)
909 }
910 for (const [, id] of line.matchAll(/#([A-Za-z]+\d+)\b/g)) {
911 if (id !== undefined && old[id.toLowerCase()] !== undefined) {
912 keepOld[id.toLowerCase()] = old[id.toLowerCase()]!
913 }
914 }
915 continue
916 }
917 const parts = foldParts(line)
918 const target = parts === undefined ? undefined : ids.get(parts.key)
919 if (parts !== undefined && target !== undefined) {
920 if (target.toLowerCase() !== parts.id.toLowerCase()) {
921 fold[parts.id.toLowerCase()] = target
922 }
923 folded.push(line)
924 told.push(`- ${place}/${name}: #${parts.id} folded into #${target}`)
925 continue
926 }
927 toCopy.push(line)
928 }
929 const fresh = renumberLines(toCopy, used)
930 // New copies take no old renaming: a recreated file must not inherit a stale one.
931 const now = { ...fold, ...fresh.renames }
932 const out = toCopy.map(line => applyRenames(line, now))
933 told.push(...fresh.pairs.map(pair => `- ${place}/${name}: #${pair.from} is now #${pair.to}`))
934 for (const line of out) {
935 const parts = foldParts(line)
936 if (parts !== undefined && !ids.has(parts.key)) {
937 ids.set(parts.key, parts.id)
938 }
939 }
940 if (out.length > 0) {
941 sections.set(name, [...(sections.get(name) ?? []), { place, lines: out }])
942 }
943 commits.push({ ...(out.length > 0 ? { target: name } : {}), file: path, renames: { ...keepOld, ...now }, folds: [...keepFolds, ...folded], told })
944 }
945 }
946 let files = 0
947 const told: string[] = []
948 const skipped = new Set<string>()
949 const commit = (one: Commit) => {
950 // Always replaced, an empty one too: a file copied again must not keep an older renumbering.
951 renameMap = { ...renameMap, [one.file]: one.renames }
952 foldMap = { ...foldMap, [one.file]: [...new Set(one.folds)] }
953 told.push(...one.told)
954 }
955 for (const one of commits.filter(c => c.target === undefined)) {
956 commit(one)
957 }
958 const notCopied = new Set<string>()
959 for (const [name, blocks] of sections) {
960 const path = `${central}/${name}`
961 // The target may have changed since the press read it: then it is not written, and its sources stay uncovered.
962 const now = (await $.fs.exists(path)) ? await readStrict($, path) : ''
963 if (now !== (await centralText(name))) {
964 skipped.add(`${DIR}/${name}`)
965 // Its sources were not copied: they stay off the duplicates record, so a failed check later cannot hide them.
966 for (const one of commits.filter(c => c.target === name)) {
967 notCopied.add(one.file.slice(0, one.file.length - name.length - 1))
968 }
969 continue
970 }
971 await writeFile($, path, appendSections(now, blocks))
972 files += 1
973 for (const one of commits.filter(c => c.target === name)) {
974 commit(one)
975 }
976 // The renumbering is kept as each file lands, so a press that fails later still knows it.
977 await saveMeta($)
978 }
979 // The union of the old record and the new sources, in memory at once; the coverage check then
980 // lets go of any folder that is not fully in the central one.
981 const fresh = sources.filter(one => !unreadable.has(one.path) && !notCopied.has(one.path)).map(one => ({ path: one.path, real: one.real, ...(one.wasPrimary ? { wasPrimary: true as const } : {}) }))
982 dupFolders = [...dupFolders.filter(one => !fresh.some(now => now.path === one.path)), ...fresh]
983 await saveRecord($)
984 await dropUncovered($, true)
985 pendingRenames = [...pendingRenames, ...told]
986 // The one discovery outside startup: the git index again, now with the central folder in it.
987 todoFolders = undefined
988 folderSearch = undefined
989 nearestSeen = undefined
990 await ensureFolders($)
991 await scan($, true)
992 if (skipped.size > 0) {
993 $.ui.toast(`Not written, changed meanwhile: ${[...skipped].join(', ')}. Their folders stay on the board. Press Consolidate todos again.`)
994 }
995 $.ui.toast(`Consolidated ${files} ${files === 1 ? 'file' : 'files'} into ${DIR}/ at the repo root.${skipped.size === 0 && unreadable.size === 0 ? ' The old folders are off the board.' : ''}${told.length > 0 ? ` Renumbered: ${told.map(line => line.slice(2)).join('; ')}` : ''}`)
996 } catch (error) {
997 $.ui.toast(`Could not consolidate: ${error instanceof Error ? error.message : String(error)}`)
998 } finally {
999 isWorking = false
1000 redraw($)
1001 }
1002}
1003
1004/** One file of a duplicate folder, as the coverage check judged it. `empty`: no non-blank line. */
1005type FileCover = { name: string; path: string; text: string; state: 'covered' | 'uncovered' | 'empty' }
1006
1007/**
1008 * The one coverage rule, for the board and for Delete: a file of a duplicate folder is covered when
1009 * every non-blank line of it, after its recorded renames, is a line of the central file of the same
1010 * name, and a central line covers only one source line. A line recorded as folded (`foldMap`) may
1011 * share its central line; no other line may. Folders are judged in order, sharing the central lines.
1012 * A file that cannot be read counts as uncovered. A central file that cannot be read throws.
1013 */
1014async function coverageOf($: EngineInterface, central: string, folders: readonly { path: string }[]): Promise<Map<string, FileCover[] | undefined>> {
1015 const have = new Map<string, Map<string, number>>()
1016 // What each central file holds before anything is used: a recorded fold is judged against this.
1017 const whole = new Map<string, Map<string, number>>()
1018 const covers = async (name: string) => {
1019 let held = have.get(name)
1020 if (held === undefined) {
1021 held = new Map()
1022 const path = `${central}/${name}`
1023 const text = (await $.fs.exists(path).catch(() => false)) ? await readStrict($, path) : ''
1024 for (const line of nonBlankLines(text)) {
1025 held.set(coverKey(line), (held.get(coverKey(line)) ?? 0) + 1)
1026 }
1027 have.set(name, held)
1028 whole.set(name, new Map(held))
1029 }
1030
1031 return held
1032 }
1033 const out = new Map<string, FileCover[] | undefined>()
1034 for (const dup of folders) {
1035 const files: FileCover[] = []
1036 // A folder that will not list is not judged: nothing is known of it.
1037 const names = await mdFiles($, dup.path, true).catch(() => undefined)
1038 if (names === undefined) {
1039 out.set(dup.path, undefined)
1040 continue
1041 }
1042 for (const name of names) {
1043 const path = `${dup.path}/${name}`
1044 const text = await readStrict($, path).catch(() => undefined)
1045 if (text === undefined) {
1046 files.push({ name, path, text: '', state: 'uncovered' })
1047 continue
1048 }
1049 const lines = nonBlankLines(text)
1050 if (lines.length === 0) {
1051 files.push({ name, path, text, state: 'empty' })
1052 continue
1053 }
1054 const renames = renameMap[path] ?? {}
1055 const folded = new Set(foldMap[path] ?? [])
1056 const held = await covers(name)
1057 const left = new Map(held)
1058 const isCovered = lines.every(line => {
1059 const key = coverKey(applyRenames(line, renames))
1060 if (folded.has(line)) {
1061 return (whole.get(name)?.get(key) ?? 0) > 0
1062 }
1063 const n = left.get(key) ?? 0
1064 left.set(key, n - 1)
1065
1066 return n > 0
1067 })
1068 if (isCovered) {
1069 // The lines used stay used for the next file.
1070 for (const [key, n] of left) {
1071 held.set(key, n)
1072 }
1073 }
1074 files.push({ name, path, text, state: isCovered ? 'covered' : 'uncovered' })
1075 }
1076 out.set(dup.path, files)
1077 }
1078
1079 return out
1080}
1081
1082/** The names and mtimes of the `.md` files of the central folder and the duplicate folders: a cheap sign that a coverage check is due. */
1083async function coverageSignature($: EngineInterface, central: string): Promise<string> {
1084 const parts: string[] = []
1085 for (const folder of [central, ...dupFolders.map(one => one.path)]) {
1086 for (const [dir, prefix] of [[folder, ''], [`${folder}/archive`, 'archive/']] as const) {
1087 if (await $.fs.exists(dir).catch(() => false)) {
1088 for (const entry of await $.fs.list(dir).catch(() => [])) {
1089 if (entry.kind === 'file' && entry.name.endsWith('.md')) {
1090 parts.push(`${dir}/${prefix}${entry.name}:${entry.mtimeMs}:${entry.size}`)
1091 }
1092 }
1093 }
1094 }
1095 }
1096
1097 return parts.join('|')
1098}
1099
1100/**
1101 * A recorded duplicate counts only while all its non-blank lines are covered by the central folder.
1102 * A folder that is not leaves the record (and the store), shows on the board again, and is a
1103 * Consolidate source again. Checked at load, at each full resync, before Consolidate and Delete, and
1104 * at the board's scan whenever a file of those folders changed. Off where Consolidate and Delete are.
1105 */
1106async function dropUncovered($: EngineInterface, isForced = false): Promise<void> {
1107 const central = centralFolder()
1108 if (central === undefined || centralBlocked || dupFolders.length === 0) {
1109 return
1110 }
1111 try {
1112 const sign = await coverageSignature($, central)
1113 if (!isForced && sign === lastCoverageSig) {
1114 return
1115 }
1116 const cover = await coverageOf($, central, dupFolders)
1117 // Remembered only after a check that ran, so a failed one is tried again on the next scan.
1118 lastCoverageSig = sign
1119 const kept = dupFolders.filter(one => !(cover.get(one.path) ?? []).some(file => file.state === 'uncovered'))
1120 if (kept.length !== dupFolders.length) {
1121 dupFolders = kept
1122 lastCoverageSig = undefined
1123 await saveRecord($)
1124 }
1125 } catch {
1126 // A central file that cannot be read changes nothing: the record stays as it was.
1127 }
1128}
1129
1130/**
1131 * Whether files may be removed from a recorded duplicate folder: an absolute path with no `..`,
1132 * ending in `.claude_todo`, that is its own real path (so no symlink is in it), inside the main
1133 * checkout, and neither the central folder nor the global one.
1134 */
1135async function isTrustedFolder($: EngineInterface, path: string, central: string, realCentral: string | undefined): Promise<boolean> {
1136 const parts = path.split('/')
1137 if (!path.startsWith('/') || parts.includes('..') || parts.filter(part => part !== '').pop() !== DIR || path === central) {
1138 return false
1139 }
1140 const real = await realOf($, path)
1141 const root = centralRoot === undefined ? undefined : await realOf($, centralRoot)
1142 if (real === undefined || real !== path || root === undefined || !real.startsWith(`${root}/`) || isGlobalTodo(real)) {
1143 return false
1144 }
1145
1146 return realCentral === undefined || real !== realCentral
1147}
1148
1149/** True for a regular file that is its own real path: no symlink in the file or the folders above it. */
1150async function isPlainFile($: EngineInterface, path: string): Promise<boolean> {
1151 const stat = await $.fs.stat(path).catch(() => undefined)
1152
1153 return stat !== undefined && stat.kind === 'file' && (await realOf($, path)) === path
1154}
1155
1156/**
1157 * Merges the entries of a duplicate folder's `categories.json` into the central book, keys mapped to
1158 * the central items (a renumbered ID follows its rename). True when the central book now holds them.
1159 */
1160async function mergeBook($: EngineInterface, dup: { path: string }, central: string): Promise<boolean> {
1161 try {
1162 const mine = JSON.parse(await readText($, `${dup.path}/categories.json`)) as Partial<CategoryBook>
1163 // An existing central book that will not read stops the merge: it is never replaced blind.
1164 const hasBook = await $.fs.exists(`${central}/categories.json`).catch(() => false)
1165 const text = hasBook ? await readStrict($, `${central}/categories.json`) : ''
1166 const theirs = text.trim() === '' ? ({} as Partial<CategoryBook>) : (JSON.parse(text) as Partial<CategoryBook>)
1167 const names = Array.isArray(theirs.categories) ? theirs.categories.filter((name): name is string => typeof name === 'string') : []
1168 const items: Record<string, string> = typeof theirs.items === 'object' && theirs.items !== null ? { ...theirs.items } : {}
1169 const renames = Object.assign({}, ...Object.entries(renameMap).filter(([file]) => file.startsWith(`${dup.path}/`)).map(([, map]) => map)) as Record<string, string>
1170 for (const [key, category] of Object.entries(typeof mine.items === 'object' && mine.items !== null ? mine.items : {})) {
1171 if (typeof category !== 'string') {
1172 continue
1173 }
1174 const bare = key.includes('::') ? key.slice(key.indexOf('::') + 2) : key
1175 const fresh = bare.startsWith('#') ? renames[bare.slice(1)] : undefined
1176 const mapped = fresh === undefined ? bare : `#${fresh.toLowerCase()}`
1177 if (!names.includes(category) && names.length < MAX_CATEGORIES) {
1178 names.push(category)
1179 }
1180 // A category the central book has no room for: keep the old book rather than lose it.
1181 if (!names.includes(category)) {
1182 return false
1183 }
1184 if (items[mapped] === undefined) {
1185 items[mapped] = category
1186 }
1187 }
1188 // Changed since the read: skip it, so an edit made meanwhile is not lost.
1189 if ((hasBook ? await readStrict($, `${central}/categories.json`) : '') !== text) {
1190 return false
1191 }
1192 await $.fs.write(`${central}/categories.json`, `${JSON.stringify({ categories: names, items }, null, 2)}\n`)
1193
1194 return true
1195 } catch {
1196 return false
1197 }
1198}
1199
1200/**hooks/cards.ts 843 lines1/**
2 * The desktop look, to the Stage UI style guide (v1.10), with two deliberate
3 * exceptions: the cards follow the system theme (the stage palette when dark, the guide's
4 * light page tokens when light, LIGHT_CSS; STG-1 keeps the stage black in every theme), and each
5 * live agent has a color that its todo items blink (AGENT_COLORS; COL-8 names no category by
6 * color). Violet is progress and live (COL-1); failed is orange (COL-7). Motion is CSS, so reduced
7 * motion stops it (MOT-2). A card's markup holds nothing that changes with the clock alone: a
8 * redraw that changes no data sends the same markup, so the pane does not reload the image.
9 */
10
11export type ItemStatus = 'pending' | 'in_progress' | 'completed'
12
13export type ItemView = {
14 text: string
15 status: ItemStatus
16 owners: number[]
17 /** The item's stable key: its ID (`#t12`) when it has one, else its normalized text; qualified by folder outside the primary folder. */
18 key: string
19 /** The key without the folder: how the line is found in its file. Same as `key` when absent. */
20 localKey?: string
21 /** The source of the item, which the pane never shows: its folder, its file in that folder, and its path from the repo root. */
22 folder?: string
23 file?: string
24 path?: string
25 /** Shown in progress only because a live agent works on it: its box is still `- [ ]`. */
26 isHeld?: true
27}
28
29export type FileView = {
30 /** The file's path inside its folder. */
31 name: string
32 /** The file's ID in the pane: its name, or its folder and name when another file has that name. */
33 id: string
34 /** Where its folder sits in the repo (no `.claude_todo`); empty at the root. Shown only to tell same-named files apart. */
35 place?: string
36 done: number
37 active: number
38 total: number
39 owners: number[]
40 items: ItemView[]
41 mtimeMs: number
42 isArchived?: boolean
43 /** Category headings inside the card: each names the index of its first item. */
44 groups?: { name: string; from: number; count: number }[]
45}
46
47export type AgentView = {
48 index: number
49 description: string
50 type: string
51 model?: string
52 status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed' | 'ended'
53 isEnded: boolean
54 startedAt: number
55 endedAt?: number
56 toolCalls: number
57 /** The tokens of every model request the agent made, every kind summed. */
58 tokens?: number
59 /** Its last turn ended and nothing followed: finished, but never stopped. Not live. */
60 isIdle?: boolean
61 idleSince?: number
62 doing?: string
63 /** The open todo items the agent works on, by text. */
64 links: string[]
65 progress?: { done: number; total: number; step?: string }
66}
67
68export type View = {
69 /** Every todo file, those with open items first; each carries all its items: linked, in progress, open, done. */
70 files: FileView[]
71 agents: AgentView[]
72 /** Counts over the files at the top of `.claude_todo/`; the archive is not in them. */
73 done: number
74 active: number
75 total: number
76 /** Done items in the completed ledger: checked items at the top level and every done item in the archive. */
77 completed: number
78 live: number
79 now: number
80 /** The session's token burn, when the mod has counted any. */
81 usage?: Usage
82}
83
84/**
85 * Tokens the session has used, main conversation and subagents, every kind (input, output, cache
86 * read and cache write) summed from each finished turn; what the total stood at when the
87 * conversation last compacted; and since when the mod has counted.
88 */
89export type Usage = { tokens: number; atCompact?: number; since: number }
90
91/** "950", "84.3k", "1.23M", "17.38M": a token count, to 2 decimals in millions and 1 in thousands. */
92export function tokens(n: number): string {
93 return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(2)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n))
94}
95
96// Stage UI tokens: section 3.3 (fixed) and 3.4 (stage palette).
97export const ACCENT = '#8B5CF6'
98export const PROGRESS_END = '#C084FC'
99export const INDICATOR = '#7C3AED'
100export const PRIMARY = '#22D3EE'
101export const SUGGESTED = '#10303B'
102export const DANGER = '#FB923C'
103export const STAGE_DANGER = '#FF7A33'
104export const GREEN = '#34D399'
105const STAGE_BG = '#07080F'
106const CARD = '#171A26'
107const ACTOR = '#1E2233'
108const STAGE_GRAY = '#9AA0B4'
109
110const LABEL = 'rgba(255,255,255,.88)'
111const SUB = 'rgba(255,255,255,.62)'
112const FAINT = 'rgba(255,255,255,.38)'
113const TRACK = 'rgba(255,255,255,.22)'
114const RULE = 'rgba(255,255,255,.08)'
115
116const PAD = 16
117let W = 384
118let INNER = W - PAD * 2
119
120/** The cards' width in CSS pixels, set once per draw from the pane's width. */
121export function setWidth(px: number): void {
122 W = Math.round(Math.min(960, Math.max(300, px)))
123 INNER = W - PAD * 2
124}
125
126export function agentLabel(index: number): string {
127 return `A${index + 1}`
128}
129
130/**
131 * Agent colors, a deliberate exception to Stage UI COL-8: each live agent
132 * blinks a color, and so does each todo item it works on. Six colors of a brand-neutral categorical
133 * palette, at their dark steps, in fixed order, checked for color-vision deficiency; never cycled, so an
134 * agent past the sixth is gray. The A1, A2 labels stay, so color is never the only cue (COL-9).
135 */
136export const AGENT_COLORS = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#9085e9']
137
138export function agentColor(index: number): string {
139 return AGENT_COLORS[index] ?? STAGE_GRAY_FALLBACK
140}
141
142const STAGE_GRAY_FALLBACK = '#9AA0B4'
143
144/**
145 * The light theme: each dark-palette color as drawn, and its Stage UI light page token (section
146 * 3.2). A CSS rule beats a presentation attribute, so one block per card swaps them all when the
147 * system is light; the desktop app on its default "system" theme follows the same setting. Agent
148 * colors and the violet progress stay as they are.
149 */
150const LIGHT: readonly (readonly [string, string, string])[] = [
151 ['fill', STAGE_BG, '#FFFFFF'],
152 ['stroke', STAGE_BG, '#FFFFFF'],
153 ['fill', 'rgba(255,255,255,.88)', '#10131C'],
154 ['fill', 'rgba(255,255,255,.62)', '#5A6072'],
155 ['fill', 'rgba(255,255,255,.38)', 'rgba(16,19,28,.45)'],
156 ['stroke', 'rgba(255,255,255,.38)', 'rgba(16,19,28,.45)'],
157 ['fill', 'rgba(255,255,255,.22)', 'rgba(16,19,28,.12)'],
158 ['stroke', 'rgba(255,255,255,.22)', 'rgba(16,19,28,.12)'],
159 ['stroke', 'rgba(255,255,255,.08)', 'rgba(16,19,28,.08)'],
160 ['fill', 'rgba(255,255,255,.1)', 'rgba(16,19,28,.06)'],
161 ['stroke', 'rgba(255,255,255,.6)', 'rgba(16,19,28,.35)'],
162 ['fill', '#171A26', '#F1F3F8'],
163 ['fill', '#1E2233', '#E1E4EE'],
164 ['fill', '#10303B', '#DDF6FB'],
165 ['fill', '#9AA0B4', '#6B7186'],
166 ['stroke', '#FFFFFF', '#10131C'],
167 ['stroke', '#9AA0B4', '#6B7186'],
168 ['fill', '#FB923C', '#B83C0B'],
169 ['fill', 'rgba(251,146,60,.16)', '#FFEDE3'],
170 ['fill', '#34D399', '#047857'],
171 ['fill', '#22D3EE', '#0E7490'],
172]
173
174export const LIGHT_CSS = `@media (prefers-color-scheme:light){${LIGHT.map(([prop, dark, light]) => `[${prop}="${dark}"]{${prop}:${light}}`).join('')}rect[fill="${STAGE_BG}"]{stroke:rgba(16,19,28,.12);stroke-width:2}}`
175const BLINK_S = 1.2
176const MAX_LINKS_SHOWN = 3
177
178/**
179 * The blink animations one card uses: a class per set of colors, which blinks each color in turn,
180 * 1.2 s per color. Reduced motion holds the first color solid.
181 */
182class Blinks {
183 private readonly rules = new Map<string, string>()
184
185 cls(colors: readonly string[]): string {
186 const id = colors.join(',')
187 const known = this.rules.get(id)
188 if (known !== undefined) {
189 return known.split('{')[0]?.slice(1) ?? ''
190 }
191 const name = `k${this.rules.size}`
192 const n = colors.length
193 const frames = colors
194 .map((color, k) => `${((k / n) * 100).toFixed(2)}%{fill:${color};opacity:1}${(((k + 0.5) / n) * 100).toFixed(2)}%{fill:${color};opacity:.25}`)
195 .join('')
196 this.rules.set(id, `.${name}{animation:${name} ${(BLINK_S * n).toFixed(1)}s steps(1,end) infinite}@keyframes ${name}{${frames}}`)
197
198 return name
199 }
200
201 css(): string {
202 const names = [...this.rules.values()].map(rule => `.${rule.split('{')[0]?.slice(1)}`)
203
204 return [...this.rules.values()].join('\n') + (names.length === 0 ? '' : `\n@media (prefers-reduced-motion:reduce){${names.join(',')}{animation:none}}`)
205 }
206}
207
208export function elapsed(ms: number): string {
209 const seconds = Math.max(0, Math.round(ms / 1000))
210 const minutes = Math.floor(seconds / 60)
211
212 return minutes === 0 ? `${seconds}s` : `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
213}
214
215/** "1 agent", "3 agents" (TXT-4). */
216export function plural(n: number, noun: string): string {
217 return `${n} ${noun}${n === 1 ? '' : 's'}`
218}
219
220function esc(text: string): string {
221 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
222}
223
224/** Ends a single-line text with an ellipsis when it does not fit (TYP-3). */
225function fit(text: string, px: number, size: number, weight = 400, isMono = false): string {
226 const glyph = size * (isMono ? 0.62 : weight >= 500 ? 0.55 : 0.52)
227 const room = Math.max(1, Math.floor(px / glyph))
228
229 return text.length > room ? `${text.slice(0, Math.max(1, room - 1)).trimEnd()}…` : text
230}
231
232/** A drawn card: its SVG and its size in CSS pixels, so the pane sizes its frame to it. */
233export type Card = { source: string; width: number; height: number; alt: string }
234
235
236function stage(height: number, body: string, alt: string, css = ''): Card {
237 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}" role="img" aria-label="${esc(alt)}">
238<defs><linearGradient id="progress" x1="0" x2="1"><stop offset="0.7" stop-color="${ACCENT}"/><stop offset="1" stop-color="${PROGRESS_END}"/></linearGradient></defs>
239<style>
240text{font-family:"Instrument Sans","Helvetica Neue",Arial,sans-serif;font-variant-numeric:tabular-nums;-webkit-font-smoothing:antialiased}
241.mono{font-family:"Fragment Mono",ui-monospace,Menlo,monospace}
242.blink{animation:blink 1.2s steps(1,end) infinite}
243@keyframes blink{50%{opacity:.25}}
244.pulse{animation:pulse 1.2s cubic-bezier(0,0,.2,1) infinite;transform-box:fill-box;transform-origin:center}
245@keyframes pulse{0%{opacity:.6;transform:scale(1)}100%{opacity:0;transform:scale(2.4)}}
246@media (prefers-reduced-motion:reduce){.blink,.pulse{animation:none}.pulse{opacity:0}}
247${LIGHT_CSS}
248${css}
249</style>
250<rect width="${W}" height="${height}" rx="12" fill="${STAGE_BG}"/>
251${body}</svg>`
252
253 return { source, width: W, height, alt }
254}
255
256/** A wall-clock time in the local time zone, `14:32:05`. */
257export function clock(ms: number): string {
258 const at = new Date(ms)
259 const two = (n: number) => String(n).padStart(2, '0')
260
261 return `${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`
262}
263
264/**
265 * The Orchestrator card: one line per tool call summary, newest first, each with its start time in
266 * small text on the right; a call still running has a live dot, a finished one a quiet one.
267 */
268export function orchestratorSvg(entries: readonly { line: string; at: number; isRunning: boolean }[]): Card {
269 const h = 22 + entries.length * 22
270 const parts = entries.map((entry, k) => {
271 const y = 30 + k * 22
272 const dot = entry.isRunning
273 ? `<circle cx="${PAD + 5}" cy="${y - 4}" r="4" fill="${ACCENT}"/><circle cx="${PAD + 5}" cy="${y - 4}" r="4" fill="${ACCENT}" class="pulse"/>`
274 : `<circle cx="${PAD + 5}" cy="${y - 4}" r="3" fill="${FAINT}"/>`
275
276 return `${dot}<text x="${PAD + 18}" y="${y}" fill="${entry.isRunning ? LABEL : SUB}" font-size="13">${esc(fit(entry.line, INNER - 18 - 64, 13))}</text>
277<text x="${W - PAD}" y="${y}" text-anchor="end" fill="${FAINT}" font-size="10.5" class="mono">${clock(entry.at)}</text>`
278 })
279
280 return stage(h, parts.join('\n'), `Orchestrator: ${entries.map(entry => `${clock(entry.at)} ${entry.line}`).join('; ')}`)
281}
282
283/** A background task as the pane draws it: its short name, what kind it is, and how it stands. */
284export type TaskView = {
285 name: string
286 kind: 'shell' | 'monitor' | 'workflow'
287 /** `ended`: the helper agent that started it ended, and no word came of how the task ended. */
288 status: 'running' | 'completed' | 'failed' | 'killed' | 'ended'
289 startedAt: number
290 endedAt?: number
291}
292
293/** An actor disk: 12 px radius, letter 500; the state dot at its lower right (STG-DRAW-7, CMP-AV-4). */
294function actor(cx: number, cy: number, index: number, state: 'working' | 'failed' | 'none', isMatch: boolean, blinks: Blinks): string {
295 const color = agentColor(index)
296 const ring = isMatch ? `<circle cx="${cx}" cy="${cy}" r="14.5" fill="none" stroke="${color}" stroke-width="1.5"/>` : ''
297 const dot =
298 state === 'none'
299 ? ''
300 : state === 'failed'
301 ? `<circle cx="${cx + 8.5}" cy="${cy + 8.5}" r="4.5" fill="${STAGE_DANGER}" stroke="${STAGE_BG}" stroke-width="2"/>`
302 : `<circle cx="${cx + 8.5}" cy="${cy + 8.5}" r="4.5" fill="${color}" stroke="${STAGE_BG}" stroke-width="2" class="${blinks.cls([color])}"/>`
303
304 return `${ring}<circle cx="${cx}" cy="${cy}" r="12" fill="${ACTOR}" stroke="rgba(255,255,255,.6)" stroke-width="1.2"/>
305<text x="${cx}" y="${cy + 4}" text-anchor="middle" fill="${LABEL}" font-size="${index >= 9 ? 9.5 : 11}" font-weight="500">${agentLabel(index)}</text>${dot}`
306}
307
308/** A 4 px progress strip: white 22% track, `--progress` fill (STG-DRAW-5); the working share in white 38%. */
309function strip(x: number, y: number, w: number, done: number, active: number, total: number): string {
310 const wDone = total === 0 ? 0 : (done / total) * w
311 const wActive = total === 0 ? 0 : (active / total) * w
312
313 return `<rect x="${x}" y="${y}" width="${w}" height="4" rx="2" fill="${TRACK}"/>
314${wActive > 0 ? `<rect x="${x + wDone}" y="${y}" width="${wActive}" height="4" rx="2" fill="${FAINT}"/>` : ''}
315${wDone > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(wDone, 4)}" height="4" rx="2" fill="url(#progress)"/>` : ''}`
316}
317
318function badgeWidth(label: string): number {
319 return Math.round(label.length * 6.4 + 12)
320}
321
322/** A status badge: 22 px high, radius 4, 500 12 px (CMP-BDG-1). */
323function badge(right: number, y: number, label: string, kind: 'live' | 'ok' | 'danger' | 'neutral'): string {
324 const w = badgeWidth(label)
325 const fill = kind === 'live' ? INDICATOR : kind === 'danger' ? 'rgba(251,146,60,.16)' : 'rgba(255,255,255,.1)'
326 const ink = kind === 'live' ? '#FFFFFF' : kind === 'danger' ? DANGER : kind === 'ok' ? GREEN : STAGE_GRAY
327
328 return `<rect x="${right - w}" y="${y - 15}" width="${w}" height="22" rx="4" fill="${fill}"/>
329<text x="${right - w / 2}" y="${y}" text-anchor="middle" fill="${ink}" font-size="12" font-weight="500">${esc(label)}</text>`
330}
331
332// ---------------------------------------------------------------- summary
333
334function ringArc(cx: number, cy: number, r: number, share: number): string {
335 const sweep = Math.min(Math.max(share, 0.0001), 0.9999)
336 const a1 = (sweep - 0.25) * 2 * Math.PI
337
338 return `M ${cx} ${cy - r} A ${r} ${r} 0 ${sweep > 0.5 ? 1 : 0} 1 ${(cx + r * Math.cos(a1)).toFixed(2)} ${(cy + r * Math.sin(a1)).toFixed(2)}`
339}
340
341/** A figure in a gauge's place: the number large, then its title and line, aligned with the rings. */
342function stat(cx: number, cy: number, value: string, title: string, detail: string): string {
343 const r = 34
344 // 32 px, smaller when the number would not fit its third of the card (a bold digit is about 0.6 em).
345 const size = Math.min(32, Math.floor((W / 3 - 16) / (value.length * 0.6)))
346
347 return `<text x="${cx}" y="${cy + 11}" text-anchor="middle" fill="${LABEL}" font-size="${size}" font-weight="700" letter-spacing="-0.5">${esc(value)}</text>
348<text x="${cx}" y="${cy + r + 24}" text-anchor="middle" fill="${LABEL}" font-size="14" font-weight="500">${esc(title)}</text>
349<text x="${cx}" y="${cy + r + 42}" text-anchor="middle" fill="${SUB}" font-size="12">${esc(detail)}</text>`
350}
351
352function gauge(cx: number, cy: number, share: number | undefined, value: string, unit: string, title: string, detail: string, stroke = 'url(#progress)', valueSize = 22): string {
353 const r = 34
354 const arc = share === undefined || share <= 0 ? '' : `<path d="${ringArc(cx, cy, r, share)}" fill="none" stroke="${stroke}" stroke-width="6" stroke-linecap="round"/>`
355
356 return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${TRACK}" stroke-width="6"/>${arc}
357<text x="${cx}" y="${cy + valueSize * 0.32}" text-anchor="middle" fill="${LABEL}" font-size="${valueSize}" font-weight="700">${esc(value)}<tspan fill="${SUB}" font-size="13" font-weight="500">${esc(unit)}</tspan></text>
358<text x="${cx}" y="${cy + r + 24}" text-anchor="middle" fill="${LABEL}" font-size="14" font-weight="500">${esc(title)}</text>
359<text x="${cx}" y="${cy + r + 42}" text-anchor="middle" fill="${SUB}" font-size="12">${esc(detail)}</text>`
360}
361
362/**
363 * The share of the agents' work done: a finished agent counts whole, a running one by the share
364 * of its plan it reports done, and one that has not reported counts as nothing yet.
365 */
366export function agentShare(agents: readonly AgentView[]): number {
367 if (agents.length === 0) {
368 return 0
369 }
370 const sum = agents.reduce((total, agent) => {
371 if (agent.isEnded || agent.isIdle) {
372 return total + 1
373 }
374 const progress = agent.progress
375
376 return total + (progress === undefined ? 0 : progress.done / progress.total)
377 }, 0)
378
379 return sum / agents.length
380}
381
382/** Two gauges: the share of todo items done, and the share of agents finished, with a live dot. */
383export function heroSvg(view: View): Card {
384 const n = view.agents.length
385 const ended = n - view.live
386 const at = [W / 6 + 4, W / 2, (W * 5) / 6 - 4]
387 const todo =
388 view.total === 0
389 ? gauge(at[0]!, 62, undefined, '', '', 'Todo', 'No items')
390 : gauge(at[0]!, 62, view.done / view.total, String(Math.round((view.done / view.total) * 100)), '%', 'Todo', `${view.done} of ${view.total} done`)
391 const agents =
392 n === 0
393 ? gauge(at[1]!, 62, undefined, '', '', 'Agents', 'None yet')
394 : gauge(at[1]!, 62, agentShare(view.agents), String(view.live), ' live', 'Agents', `${ended} of ${plural(n, 'agent')} finished`)
395 // Token burn: a number, not a ring.
396 const usage = view.usage
397 const burn =
398 usage === undefined
399 ? stat(at[2]!, 62, '', 'Tokens', 'None counted yet')
400 : stat(
401 at[2]!,
402 62,
403 tokens(usage.tokens),
404 'Tokens',
405 usage.atCompact === undefined ? 'No compaction yet' : `${tokens(usage.tokens - usage.atCompact)} since compact`,
406 )
407 const live =
408 view.live > 0
409 ? `<circle cx="${W - PAD - 4}" cy="${PAD + 4}" r="4" fill="${ACCENT}" class="pulse"/>
410<circle cx="${W - PAD - 4}" cy="${PAD + 4}" r="4" fill="${ACCENT}"/>
411<text x="${W - PAD - 14}" y="${PAD + 8}" text-anchor="end" fill="${SUB}" font-size="12">Live</text>`
412 : ''
413
414 return stage(166, `${todo}${agents}${burn}${live}`, `Todo ${view.done} of ${view.total} done. ${view.live} of ${plural(n, 'agent')} live.${usage === undefined ? '' : ` ${tokens(usage.tokens)} tokens used${usage.atCompact === undefined ? '' : `, ${tokens(usage.tokens - usage.atCompact)} since compact`}.`}`)
415}
416
417// ---------------------------------------------------------------- todo
418
419function itemMark(x: number, y: number, status: ItemStatus, owners: readonly number[], blinks: Blinks): string {
420 if (status === 'completed') {
421 return `<path d="M ${x - 4} ${y} l 2.8 2.8 l 5.2 -5.6" fill="none" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
422 }
423 if (owners.length > 0) {
424 const colors = owners.map(agentColor)
425
426 return `<circle cx="${x}" cy="${y}" r="5" fill="${colors[0]}" class="${blinks.cls(colors)}"/>`
427 }
428 if (status === 'in_progress') {
429 return `<circle cx="${x}" cy="${y}" r="4" fill="${STAGE_GRAY}"/>`
430 }
431
432 return `<circle cx="${x}" cy="${y}" r="4.5" fill="none" stroke="${FAINT}" stroke-width="1.2"/>`
433}
434
435const ITEMS_PER_CARD = 40
436const LINE_H = 18
437const MAX_LINES = 8
438
439/**
440 * Breaks a text into lines that fit `px` at `size`, at spaces where it can and inside a long word
441 * where it must; past `maxLines` the last line ends with an ellipsis (TYP-3: long free text wraps).
442 */
443export function wrap(text: string, px: number, size: number, weight = 400, maxLines = MAX_LINES): string[] {
444 const room = Math.max(8, Math.floor(px / (size * (weight >= 500 ? 0.55 : 0.52))))
445 const lines: string[] = []
446 let line = ''
447 for (const word of text.split(/\s+/).filter(one => one !== '')) {
448 let rest = word
449 while (rest.length > room) {
450 if (line !== '') {
451 lines.push(line)
452 line = ''
453 }
454 lines.push(rest.slice(0, room))
455 rest = rest.slice(room)
456 }
457 if (line === '') {
458 line = rest
459 } else if (line.length + 1 + rest.length <= room) {
460 line = `${line} ${rest}`
461 } else {
462 lines.push(line)
463 line = rest
464 }
465 }
466 if (line !== '' || lines.length === 0) {
467 lines.push(line)
468 }
469 if (lines.length <= maxLines) {
470 return lines
471 }
472 const kept = lines.slice(0, maxLines)
473 kept[maxLines - 1] = `${(kept[maxLines - 1] ?? '').slice(0, Math.max(1, room - 1)).trimEnd()}…`
474
475 return kept
476}
477
478/**
479 * One todo file as cards: the first carries its name, count and strip; then up to `limit` of its
480 * items in the view's order, 40 to a card, so a file of any length stays inside the SVG limit. An
481 * item longer than 8 lines stops there with an ellipsis, unless `isTextOpen` shows every item whole.
482 */
483/**
484 * One ticket as a Stage UI card, `width` px wide: a 12 px container on the tonal card fill; a top
485 * row with the status mark, an ID chip, a category chip and a status badge; the item's text, up to
486 * 4 lines; and the agents on it as colored chips. In progress draws a violet accent bar (progress,
487 * COL-1); a ticket an agent works on draws its border and bar in the agent's color instead. When
488 * `actions` (a count), a bottom row of its own is kept free for the pane's buttons and their tips.
489 */
490export function ticketSvg(item: ItemView, view: View, width: number, info: { id?: string; category?: string; isSent: boolean; actions?: number }): Card {
491 const w = Math.max(200, Math.round(width))
492 const pad = 14
493 const blinks = new Blinks()
494 const owner = item.owners[0]
495 const accent = owner !== undefined ? agentColor(owner) : item.status === 'in_progress' ? ACCENT : undefined
496 const parts: string[] = []
497 let x = pad
498 const top = 22
499 // Status mark
500 if (item.status === 'completed') {
501 parts.push(`<path d="M ${x} ${top - 1} l 2.8 2.8 l 5.2 -5.6" fill="none" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`)
502 } else if (owner !== undefined) {
503 const colors = item.owners.map(agentColor)
504 parts.push(`<circle cx="${x + 4}" cy="${top - 3}" r="4.5" fill="${colors[0]}" class="${blinks.cls(colors)}"/>`)
505 } else if (item.status === 'in_progress') {
506 parts.push(`<circle cx="${x + 4}" cy="${top - 3}" r="4" fill="${STAGE_GRAY}"/>`)
507 } else {
508 parts.push(`<circle cx="${x + 4}" cy="${top - 3}" r="4.5" fill="none" stroke="${FAINT}" stroke-width="1.2"/>`)
509 }
510 x += 16
511 // Status badge, right
512 const status = info.isSent && item.status !== 'completed' ? { label: 'Sent', kind: 'neutral' as const } : item.status === 'completed' ? { label: 'Done', kind: 'ok' as const } : item.status === 'in_progress' ? { label: 'In progress', kind: 'live' as const } : { label: 'Open', kind: 'neutral' as const }
513 const badgeRight = w - pad
514 parts.push(badge(badgeRight, top + 2, status.label, status.kind))
515 const room = badgeRight - badgeWidth(status.label) - 8
516 // ID chip
517 if (info.id !== undefined && x + 40 < room) {
518 const cw = Math.round(info.id.length * 7 + 14)
519 parts.push(`<rect x="${x}" y="${top - 13}" width="${cw}" height="20" rx="8" fill="rgba(255,255,255,.1)"/>
520<text x="${x + cw / 2}" y="${top + 1}" text-anchor="middle" fill="${LABEL}" font-size="11" class="mono">${esc(info.id)}</text>`)
521 x += cw + 6
522 }
523 // Category chip
524 if (info.category !== undefined && x + 40 < room) {
525 const label = fit(info.category, room - x - 16, 11, 500)
526 const cw = Math.round(label.length * 6 + 16)
527 parts.push(`<rect x="${x + 0.5}" y="${top - 12.5}" width="${cw}" height="19" rx="9.5" fill="none" stroke="rgba(255,255,255,.22)"/>
528<text x="${x + cw / 2}" y="${top + 1}" text-anchor="middle" fill="${SUB}" font-size="11" font-weight="500">${esc(label)}</text>`)
529 }
530 // Body
531 const text = info.id === undefined ? item.text : item.text.replace(info.id, '').replace(/^\s*[:.-]?\s*/, '')
532 const lines = wrap(text, w - pad * 2, 13, 400, 4)
533 let y = top + 26
534 const ink = item.status === 'completed' ? FAINT : LABEL
535 const strike = item.status === 'completed' ? ' text-decoration="line-through"' : ''
536 parts.push(`<text x="${pad}" y="${y}" fill="${ink}" font-size="13"${strike}>${lines.map((line, k) => `<tspan x="${pad}" dy="${k === 0 ? 0 : 18}">${esc(line)}</tspan>`).join('')}</text>`)
537 y += (lines.length - 1) * 18 + 10
538 // Agents
539 if (item.owners.length > 0) {
540 let ax = pad
541 for (const o of item.owners) {
542 const label = agentLabel(o)
543 const cw = Math.round(label.length * 7 + 14)
544 parts.push(`<rect x="${ax}" y="${y}" width="${cw}" height="18" rx="9" fill="${agentColor(o)}"/>
545<text x="${ax + cw / 2}" y="${y + 13}" text-anchor="middle" fill="#FFFFFF" font-size="10.5" font-weight="700">${label}</text>`)
546 ax += cw + 6
547 }
548 const working = view.agents[owner ?? 0]
549 if (working !== undefined) {
550 parts.push(`<text x="${ax + 2}" y="${y + 13}" fill="${SUB}" font-size="11">${esc(fit(working.description, w - ax - pad - 2, 11))}</text>`)
551 }
552 y += 26
553 }
554 if ((info.actions ?? 0) > 0) {
555 y += SEND_ROW_PX
556 }
557 const h = y + 8
558 const frame = `<rect x="0.75" y="0.75" width="${w - 1.5}" height="${h - 1.5}" rx="12" fill="${CARD}" stroke="${owner !== undefined ? agentColor(owner) : RULE}" stroke-width="${owner !== undefined ? 1.5 : 1}"/>`
559 const bar = accent === undefined ? '' : `<rect x="0" y="12" width="3" height="${h - 24}" rx="1.5" fill="${accent}"/>`
560 const body = item.status === 'completed' ? `<g opacity=".6">${parts.join('\n')}</g>` : parts.join('\n')
561 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="${esc(`${status.label}: ${item.text.slice(0, 120)}`)}">
562<style>
563text{font-family:"Instrument Sans","Helvetica Neue",Arial,sans-serif;font-variant-numeric:tabular-nums;-webkit-font-smoothing:antialiased}
564.mono{font-family:"Fragment Mono",ui-monospace,Menlo,monospace}
565${LIGHT_CSS}
566${blinks.css()}
567</style>
568${frame}${bar}
569${body}</svg>`
570
571 return { source, width: w, height: h, alt: `${status.label}: ${item.text.slice(0, 200)}` }
572}
573
574/** The button row's height: a native button, raised one row off the card's bottom edge. */
575export const SEND_ROW_PX = 60
576
577/** The pane's card width, for laying tickets out in columns beside it. */
578export function cardWidth(): number {
579 return W
580}
581
582/** How many of the first `limit` items of a file run past 8 lines, for the Show full text button. */
583export function longItems(file: FileView, limit: number): number {
584 return file.items.slice(0, limit).filter(item => {
585 const room = INNER - 24 - item.owners.length * 32
586
587 return wrap(item.text, room, 13, item.owners.length > 0 ? 500 : 400, Number.POSITIVE_INFINITY).length > MAX_LINES
588 }).length
589}
590
591export function fileCards(file: FileView, view: View, limit: number, isTextOpen = false): Card[] {
592 const shown = file.items.slice(0, limit)
593 const cards: Card[] = []
594 let parts: string[] = []
595 let blinks = new Blinks()
596 let y = 0
597 let count = 0
598 let first = 0
599 const begin = (isFirst: boolean) => {
600 parts = []
601 blinks = new Blinks()
602 y = 8
603 count = 0
604 if (isFirst) {
605 y = 26
606 parts.push(`<text x="${PAD}" y="${y}" fill="${LABEL}" font-size="14" font-weight="500">${esc(fit(file.name, INNER - 70, 14, 500))}</text>
607<text x="${W - PAD}" y="${y}" text-anchor="end" fill="${SUB}" font-size="12">${file.done} of ${file.total}</text>`)
608 parts.push(strip(PAD, y + 9, INNER, file.done, file.active, file.total))
609 y += 22
610 }
611 y += 18
612 }
613 const flush = () => {
614 const body = file.isArchived === true ? `<g opacity=".55">${parts.join('\n')}</g>` : parts.join('\n')
615 const card = stage(y - 6, body, `${file.name}: ${file.done} of ${file.total} done${first > 0 ? `, items ${first + 1} on` : ''}`, blinks.css())
616 cards.push(card)
617 }
618 begin(true)
619 shown.forEach((item, index) => {
620 if (count === ITEMS_PER_CARD) {
621 flush()
622 first = index
623 begin(false)
624 }
625 const group = file.groups?.find(one => one.from === index)
626 if (group !== undefined) {
627 parts.push(`<text x="${PAD}" y="${y + 2}" fill="${SUB}" font-size="12" font-weight="500">${esc(fit(group.name, INNER - 40, 12, 500))}</text>
628<text x="${W - PAD}" y="${y + 2}" text-anchor="end" fill="${FAINT}" font-size="11">${group.count}</text>
629<line x1="${PAD}" y1="${y + 9}" x2="${W - PAD}" y2="${y + 9}" stroke="${RULE}"/>`)
630 y += 30
631 }
632 const isMatch = item.owners.length > 0
633 const weight = isMatch ? 500 : 400
634 const room = INNER - 24 - item.owners.length * 32
635 const lines = wrap(item.text, room, 13, weight, isTextOpen ? Number.POSITIVE_INFINITY : MAX_LINES)
636 const extra = (lines.length - 1) * LINE_H
637 if (isMatch) {
638 parts.push(`<rect x="${PAD - 8}" y="${y - 17}" width="${INNER + 16}" height="${32 + extra}" rx="8" fill="${SUGGESTED}"/>`)
639 }
640 parts.push(itemMark(PAD + 6, y - 1, item.status, item.owners, blinks))
641 const ink = isMatch || item.status === 'in_progress' ? LABEL : item.status === 'completed' ? FAINT : LABEL
642 const strike = item.status === 'completed' ? ' text-decoration="line-through"' : ''
643 const tspans = lines.map((line, k) => `<tspan x="${PAD + 22}" dy="${k === 0 ? 0 : LINE_H}">${esc(line)}</tspan>`).join('')
644 parts.push(`<text x="${PAD + 22}" y="${y + 3}" fill="${ink}" font-size="13" font-weight="${weight}"${strike}>${tspans}</text>`)
645 item.owners.forEach((owner, k) => {
646 const agent = view.agents[owner]
647 const failed = agent?.status === 'failed' || agent?.status === 'killed'
648 parts.push(actor(W - PAD - 6 - k * 32, y - 1, owner, failed ? 'failed' : agent?.isEnded === false ? 'working' : 'none', true, blinks))
649 })
650 y += 34 + extra
651 count += 1
652 })
653 if (count > 0 || cards.length === 0) {
654 flush()
655 }
656
657 return cards
658}
659
660/** The height of one file row in the board header. */
661const HEAD_ROW_PX = 44
662
663/**
664 * The ticket board's header: one row per file, each with its name, its own progress strip and its
665 * own "done of total". With one file it is the same card as the list view's first card. A file's
666 * place (its folder) shows in dim text after the name only when another row has the same name,
667 * since the two rows would look the same. Archived files are grayed.
668 */
669export function boardHead(files: readonly FileView[]): Card {
670 const counts = new Map<string, number>()
671 for (const file of files) {
672 counts.set(file.name, (counts.get(file.name) ?? 0) + 1)
673 }
674 const rows = files.map((file, i) => {
675 const y = 26 + i * HEAD_ROW_PX
676 const place = (counts.get(file.name) ?? 0) > 1 && file.place !== undefined && file.place !== '' ? file.place : undefined
677 const room = INNER - 70
678 const placeText = place === undefined ? undefined : fit(place, Math.floor(room / 2), 12)
679 const nameText = fit(file.name, placeText === undefined ? room : room - placeText.length * 12 * 0.52 - 10, 14, 500)
680 // The place rides in the name's own text, so the gap is the font's, not a width guess.
681 const placeSpan = placeText === undefined ? '' : `<tspan dx="10" fill="${FAINT}" font-size="12" font-weight="400">${esc(placeText)}</tspan>`
682 const body = `<text x="${PAD}" y="${y}" fill="${LABEL}" font-size="14" font-weight="500">${esc(nameText)}${placeSpan}</text>
683<text x="${W - PAD}" y="${y}" text-anchor="end" fill="${SUB}" font-size="12">${file.done} of ${file.total}</text>
684${strip(PAD, y + 9, INNER, file.done, file.active, file.total)}`
685
686 return file.isArchived === true ? `<g opacity=".55">${body}</g>` : body
687 })
688 const height = 26 + Math.max(0, files.length - 1) * HEAD_ROW_PX + 34
689
690 return stage(height, rows.join('\n'), files.map(file => `${file.name}: ${file.done} of ${file.total} done`).join('; '))
691}
692
693// ---------------------------------------------------------------- agents
694
695function agentBadge(agent: AgentView): { label: string; kind: 'live' | 'ok' | 'danger' | 'neutral' } {
696 if (agent.isIdle) {
697 return { label: 'Idle', kind: 'neutral' }
698 }
699 if (agent.status === 'completed') {
700 return { label: 'Done', kind: 'ok' }
701 }
702 if (agent.status === 'failed') {
703 return { label: 'Failed', kind: 'danger' }
704 }
705 if (agent.status === 'killed') {
706 return { label: 'Stopped', kind: 'danger' }
707 }
708 if (agent.status === 'running') {
709 return { label: 'Running', kind: 'live' }
710 }
711
712 return { label: `${agent.status.charAt(0).toUpperCase()}${agent.status.slice(1)}`, kind: 'neutral' }
713}
714
715/** A span of wall-clock time the timeline shows: from `start` to `end`, in ms since the epoch. */
716/**
717 * The agents' timeline, from the first agent's start to now: one strip per agent. The right end
718 * reads "Now" and a running strip pulses at its head.
719 */
720export function timelineSvg(view: View, all: readonly AgentView[]): Card {
721 const t0 = Math.min(...all.map(a => a.startedAt))
722 const t1 = view.now
723 const agents = [...all]
724 const n = agents.length
725 const parts: string[] = []
726 const blinks = new Blinks()
727 const plotX = PAD + 34
728 const plotW = INNER - 34 - 52
729 const span = Math.max(1000, t1 - t0)
730 const x = (t: number) => plotX + ((Math.min(Math.max(t, t0), t1) - t0) / span) * plotW
731 const top = 26
732 const rowH = 30
733 const axisY = top + Math.max(n, 1) * rowH - 8
734 for (const f of [0, 0.5, 1]) {
735 const gx = plotX + f * plotW
736 const t = t0 + f * span
737 const tick = f === 1 ? 'Now' : `${elapsed(view.now - t)} ago`
738 parts.push(`<line x1="${gx}" y1="${top - 16}" x2="${gx}" y2="${axisY}" stroke="${RULE}"/>
739<text x="${gx}" y="${axisY + 16}" text-anchor="${f === 0 ? 'start' : f === 1 ? 'end' : 'middle'}" fill="${FAINT}" font-size="11">${tick}</text>`)
740 }
741 if (n === 0) {
742 parts.push(`<text x="${plotX + plotW / 2}" y="${top + 4}" text-anchor="middle" fill="${SUB}" font-size="12">No agents ran in this window</text>`)
743 }
744 agents.forEach((agent, i) => {
745 const y = top + i * rowH
746 const end = agent.endedAt ?? agent.idleSince ?? view.now
747 const x0 = x(agent.startedAt)
748 const w = Math.max(6, x(end) - x0)
749 const failed = agent.status === 'failed' || agent.status === 'killed'
750 parts.push(agent.isEnded ? '<g opacity=".55">' : '<g>')
751 parts.push(actor(PAD + 12, y - 2, agent.index, failed ? 'failed' : agent.isEnded ? 'none' : 'working', false, blinks))
752 const fill = agent.isEnded ? (failed ? STAGE_DANGER : TRACK) : 'url(#progress)'
753 parts.push(`<rect x="${x0}" y="${y - 4}" width="${w}" height="4" rx="2" fill="${fill}"/>`)
754 if (agent.status === 'completed' && end <= t1) {
755 parts.push(`<path d="M ${x0 + w + 5} ${y - 2} l 2.4 2.4 l 4.6 -5" fill="none" stroke="#FFFFFF" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`)
756 } else if (!agent.isEnded && !agent.isIdle) {
757 parts.push(`<circle cx="${x0 + w}" cy="${y - 2}" r="4" fill="${ACCENT}" class="pulse"/>
758<circle cx="${x0 + w}" cy="${y - 2}" r="3.5" fill="${ACCENT}"/>`)
759 }
760 parts.push(`<text x="${W - PAD}" y="${y + 2}" text-anchor="end" fill="${SUB}" font-size="12">${elapsed(end - agent.startedAt)}</text></g>`)
761 })
762
763 return stage(axisY + 28, parts.join('\n'), `Timeline of ${plural(n, 'agent')}`, blinks.css())
764}
765
766/** One agent: actor, name and status badge; the item it works on; meta; current tool call. */
767/** The room left of a live agent's badge that the card keeps free for the pane's Stop button. */
768export const STOP_PX = 44
769/** The gap between a live agent's Stop button and its badge. */
770const STOP_GAP_PX = 8
771/**
772 * How far an agent card's title row sits below its usual place: the pane places the Stop button
773 * in whole text rows, and one row down puts its center 5 px below a badge at the usual place.
774 */
775const HEAD_DROP_PX = 5
776
777/** How far a live agent's Stop button sits from the card's right padding: past the badge and a gap. */
778export function stopInsetPx(agent: AgentView): number {
779 return badgeWidth(agentBadge(agent).label) + STOP_GAP_PX
780}
781
782export function agentSvg(agent: AgentView, view: View): Card {
783 const parts: string[] = []
784 // A live agent's title ends short of its Stop button, left of the badge.
785 const stop = agent.isEnded ? 0 : STOP_PX
786 const failed = agent.status === 'failed' || agent.status === 'killed'
787 const { label, kind } = agentBadge(agent)
788 const bw = badgeWidth(label)
789 const blinks = new Blinks()
790 const color = agentColor(agent.index)
791 const shownLinks = agent.links.slice(0, MAX_LINKS_SHOWN)
792 const moreLinks = agent.links.length - shownLinks.length
793 const hasLink = shownLinks.length > 0
794 const progress = agent.isEnded ? undefined : agent.progress
795 const lines = 2 + shownLinks.length + (moreLinks > 0 ? 1 : 0) + (!agent.isEnded && agent.doing !== undefined ? 1 : 0) + (progress !== undefined ? 1 : 0)
796 const h = 20 + HEAD_DROP_PX + lines * 20 + (progress !== undefined ? 8 : 0)
797 if (hasLink && !agent.isEnded) {
798 parts.push(`<rect x="1" y="1" width="${W - 2}" height="${h - 2}" rx="11" fill="none" stroke="${color}" stroke-opacity=".55"/>`)
799 }
800 parts.push(actor(PAD + 12, 26 + HEAD_DROP_PX, agent.index, failed ? 'failed' : agent.isEnded ? 'none' : 'working', hasLink && !agent.isEnded, blinks))
801 let ty = 31 + HEAD_DROP_PX
802 const tx = PAD + 36
803 parts.push(`<text x="${tx}" y="${ty}" fill="${agent.isEnded ? SUB : LABEL}" font-size="14" font-weight="500">${esc(fit(agent.description, INNER - 44 - bw - stop, 14, 500))}</text>`)
804 parts.push(badge(W - PAD, ty, label, kind))
805 ty += 20
806 for (const link of shownLinks) {
807 parts.push(`<circle cx="${tx + 4}" cy="${ty - 4}" r="4" fill="${color}" class="${blinks.cls([color])}"/>
808<text x="${tx + 14}" y="${ty}" fill="${SUB}" font-size="12">Working on <tspan fill="${color}" font-weight="500">${esc(fit(link, INNER - 130, 12, 500))}</tspan></text>`)
809 ty += 20
810 }
811 if (moreLinks > 0) {
812 parts.push(`<text x="${tx + 14}" y="${ty}" fill="${SUB}" font-size="12">and ${plural(moreLinks, 'more item')}</text>`)
813 ty += 20
814 }
815 if (progress !== undefined) {
816 const percent = Math.round((progress.done / progress.total) * 100)
817 const label = `Step ${Math.min(progress.done + 1, progress.total)} of ${progress.total}${progress.step ? `: ${progress.step}` : ''}`
818 parts.push(`<text x="${tx}" y="${ty}" fill="${LABEL}" font-size="12" font-weight="500">${esc(fit(label, INNER - 90, 12, 500))}</text>
819<text x="${W - PAD}" y="${ty}" text-anchor="end" fill="${SUB}" font-size="12">${percent}%</text>`)
820 parts.push(strip(tx, ty + 7, W - PAD - tx, progress.done, 0, progress.total))
821 ty += 28
822 }
823 const ran = elapsed((agent.endedAt ?? agent.idleSince ?? view.now) - agent.startedAt)
824 const isIdle = agent.isIdle === true && agent.idleSince !== undefined
825 const when = agent.endedAt !== undefined ? [`ran ${ran}`, `ended ${elapsed(view.now - agent.endedAt)} ago`] : isIdle ? [`ran ${ran}`] : [ran]
826 // An idle agent's note leads, so a narrow card never cuts it off.
827 const meta = [isIdle ? `Finished ${clock(agent.idleSince!)}, not stopped` : undefined, agent.type, agent.model, ...when, plural(agent.toolCalls, 'tool call'), agent.tokens ? `${tokens(agent.tokens)} tokens` : undefined]
828 .filter((part): part is string => part !== undefined && part !== '')
829 .join('\u2002·\u2002')
830 parts.push(`<text x="${tx}" y="${ty}" fill="${SUB}" font-size="12">${esc(fit(meta, INNER - 40, 12))}</text>`)
831 ty += 20
832 if (!agent.isEnded && agent.doing !== undefined) {
833 // An idle agent's call has long returned: it is the last one, not the current one.
834 const doing = agent.isIdle ? `Last: ${agent.doing}` : agent.doing
835 parts.push(`<text x="${tx}" y="${ty}" fill="${SUB}" font-size="12" class="mono">${esc(fit(doing, INNER - 44, 12, 400, true))}</text>`)
836 }
837
838 // A finished agent stays in the ledger, grayed out (STG-DRAW-6: a quiet item draws at reduced opacity).
839 const body = agent.isEnded ? `<g opacity=".55">${parts.join('\n')}</g>` : parts.join('\n')
840
841 return stage(h, body, `${agentLabel(agent.index)}, ${agent.description}: ${label}`, blinks.css())
842}
843types/index.d.ts 58 lines1export type TodoStatus = 'pending' | 'in_progress' | 'completed'
2
3export type TodoItem = {
4 text: string
5 status: TodoStatus
6 /** The item's ID without its `#` (`T12`), when the line carries one. */
7 id?: string
8}
9
10export type TodoFile = {
11 /** The file's path under its `.claude_todo/` folder: `OPEN.md`, or `archive/OLD.md` for an archived one. */
12 name: string
13 /** The path of the `.claude_todo/` folder the file lives in, as the engine's fs takes it. Folder plus name identify a file. */
14 folder: string
15 /** The folder's path from the repo root, ending in `.claude_todo` (`mods/x/.claude_todo`); `folder` when not known. */
16 rel?: string
17 /** True for a folder other than the primary one (the nearest to the session folder): its item keys carry its place, so equal IDs stay apart. The pane never shows the folder. */
18 isOther?: true
19 mtimeMs: number
20 items: TodoItem[]
21 /** True for a file in `.claude_todo/archive/`: finished work, listed in the completed ledger. */
22 isArchived?: boolean
23}
24
25/** `ended`: the agent left the engine's agent list with no word of how it ended. */
26export type AgentRowStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed' | 'ended'
27
28export type AgentRow = {
29 id: string
30 description: string
31 type: string
32 model?: string
33 status: AgentRowStatus
34 startedAt: number
35 endedAt?: number
36 toolCalls: number
37 /** The tokens of every model request the agent made, every kind summed. */
38 tokens?: number
39 doing?: string
40 /** The description and the head of the prompt, lowercased, for linking to todo items. */
41 brief: string
42 /** What SendMessage addresses the agent by, when it has a name. */
43 name?: string
44 /**
45 * When the agent's last turn ended with nothing after it: the work is done, but the agent was
46 * never stopped. Cleared by its next tool call or model request.
47 */
48 idleSince?: number
49 /** The agent's own report through `report_progress`: steps done of its plan, and the current one. */
50 progress?: Progress
51 /** The todo items the agent says it handles, as it quoted them through `report_progress`. */
52 claims?: string[]
53 /** The todo items Haiku matched to the agent's task when it started, by their text. */
54 autoClaims?: string[]
55}
56
57export type Progress = { done: number; total: number; step?: string }
58