SLOPSHOPPER

claudeopticon

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…

newpaneguardcommandstatusprompt
v0.4.4MITupdated 2026-10-16Marceux-Croix/Claudeopticon
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudeopticon
│ ┃ Claudeopticon ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Orchestrator Idle ⏺ Read(src/auth.ts) │ ┃ · 08:53:20 Show environment file ⎿ Read 6 lines │ ┃ · 08:53:20 Clean build output and push ⏺ Update(src/auth.ts) │ ┃ · 08:53:20 Show working tree status ⎿ Added 2 lines, removed 1 line │ ┃ · 08:53:20 Run the test suite ⏺ Bash(bun test) │ ┃ · 08:53:20 Write: /work/app/src/cache.ts ⎿ 3 pass, 1 fail │ ┃ │ ┃ Background tasks ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Agents ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Todo › /claudeopticon │ ⎿ claudeopticon: Claudeopticon pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claudeopticon
Orchestrator Idle · 08:53:20 Show environment file · 08:53:20 Clean build output and push · 08:53:20 Show working tree status · 08:53:20 Run the test suite · 08:53:20 Write: /work/app/src/cache.ts Background tasks Agents Todo
README

Claudeopticon: see everything Claude Code is doing, in one live pane

Claudeopticon

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.

The whole Claudeopticon pane: the top card, then Orchestrator, Background tasks, Agents and the Todo board, top to bottom

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.

Contents

Requirements

  • Claude Code 2.1.275 or later.
  • The desktop app or VS Code for the card view. A terminal shows the same data as text.

Install

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.

Quick start

  1. Make a folder .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
  1. Open the pane. The items show as tickets in the Open column.
  2. Click ⋙ on a ticket to send it to Claude. Claude marks it [~], and the ticket moves to In progress.
  3. When Claude starts an agent for a ticket, the agent's card links to it and shows its progress.

The pane

Top card

The top card: todo done, live agents, and session tokens

  • Todo: how much of your todo is done.
  • Agents: how many agents run, and how far along they are together.
  • Tokens: how much text Claude has processed this session (a token is the unit of Claude's usage). The line below counts since the last compaction, when Claude Code shrinks a long conversation to free up room.

Orchestrator: what Claude is doing

The Orchestrator card

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.

Background tasks: what runs in the background

The Background tasks card

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.

Agents: what Claude's helpers are doing

The agents timeline and two agent 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.

Todo: the ticket board

The ticket board

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:

ButtonOnWhat it does
⋙ Send to ClaudeOpen ticketsSends the item to Claude as a prompt, to build it.
? Ask Claude for a status updateTickets in progress, or already sentAsks Claude for a short report in the chat. It starts no new work.
◐ Mark in progressOpen ticketsMoves the ticket by hand.
✓ Mark doneTickets in progressMoves the ticket by hand.
↩ Move back to openTickets in progressMoves 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:

  • Show as list shows each file as a list. Arrange by category groups the list.
  • The group buttons show or hide each group. Haiku makes the groups: it reads your open tickets, names up to five groups, and puts each ticket in one. A ticket keeps its group, and new tickets are sorted as they appear.
  • Show completed adds finished tickets and archived todo files.
  • The search box finds tickets with every word you type.

The todo format

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
  • Give each item a short ID, #T<n>, unique in the project.
  • Prose, tables and headings may surround the items. Only checkbox lines count.
  • When an item is done, check it off and leave it in the file. Checked items are your record of finished work.
  • When every item in a file is done, move the file to .claude_todo/archive/.
  • The pane shows the tickets of every .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.
  • Files of the same name in different folders are two files, each with its own header row. Each change goes back to the file the ticket came from. Two folders may use the same #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.

Rules for Claude

At the start of each conversation, the mod gives Claude short instructions. Claude:

  • keeps .claude_todo/ in the format above and updates each box as status changes;
  • puts the item's ID in the description of each agent it starts for that item;
  • finds or adds an item before it starts any work;
  • stops work on an item you moved to open or done;
  • never changes the box of an item you moved. If it thinks your move was wrong, it calls the move_ticket tool. You get a dialog, and the box changes only if you say yes.

Agents and progress

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.

Options

OptionDefaultWhat it does
rulesOnGives 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.

Cost and privacy

The mod uses Haiku for three small jobs:

  • Sorting tickets into groups: once per new ticket, up to 60 per request. Sorting a large todo the first time costs a few thousand tokens.
  • Matching a new agent to its tickets: one request per agent.
  • Naming a background task: one very short request per task.

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.

Limits

  • The token count starts the first time the mod sees a session.
  • The Orchestrator history is kept in memory, so a reload clears it.
  • Agent data is saved per session, so /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.
  • An agent's progress bar comes from the agent's own reports, not from a measurement. It works well for work with clear steps, such as editing a set of files or running tests. For open-ended work, such as research, debugging or review, the agent cannot know its steps in advance, so the total may grow as it goes (step 3 of 5 can become step 4 of 9), or the reports may stop. Some agents skip the reports, and a custom agent without report_progress in its tool list shows no bar at all.
  • Ticket buttons sit over the card, so their position is approximate.
  • The pane finds the .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).
  • Consolidate todos and Delete duplicate todos are off in a repo whose root folder is the home .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/.
  • The files in the known folders stay live: new items, edits, checkbox changes, new files and archived files show within a few seconds, with no restart.

How it works explains the hooks, the linking and the drawing.

Development

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.

License

MIT

Source 3 files
hooks/register.tsx 3275 lines
1import 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 lines
1/**
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
843
types/index.d.ts 58 lines
1export 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