SLOPSHOPPER

backlog-pane

A side pane with the project's git status and its Backlog.md tasks: start, finish and create tasks from the pane

newpanecommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · backlog-pane
│ ┃ Workspace ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ⎇ ? ↑0 ↓0 │ ⏺ Read(src/auth.ts) │ ┃ │ ✓ clean │ ⎿ Read 6 lines │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No backlog here yet. Run `backlog init` in ⏺ Bash(bun test) │ ┃ the project; it appears on its own. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /backlog │ ⎿ backlog-pane: Workspace pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workspace
╭──────────────────────────────────────────────────────────╮ │ ⎇ ? ↑0 ↓0 │ │ ✓ clean │ ╰──────────────────────────────────────────────────────────╯ No backlog here yet. Run `backlog init` in the project; it appears on its own.
README

claude-code-mods

Stylish HUD mods for the Claude Code terminal, drawn under the prompt.

a highlighted reply with rainbow headings and bold text, and the limit rings and progress bars under the Claude Code prompt

PluginWhat it draws
limit-barsFour braille rings, filled clockwise with the percentage inside: context (green → amber → red), session 5-hour limit (cyan → violet), weekly limit (pink → orange) and Fable weekly limit (mint → blue). A light orbits each filled arc while Claude works (the rings hold still while it is idle, so typing stays quick); a ring pulses red past 80% (context) or 90% (limits). Beside them: model with its effort level (italic, shading violet → pink from low to max), workspace folder, tokens used and when the session limit resets (↻ 4:30 PM).
plan-progress-fxAnimated rainbow progress bars for multi-step tasks, with gradient titles and percentages, placed to the right of the rings. On a narrow terminal they stay collapsed until you open them with ◉ Progress, and then take the rings' place. It also changes how Claude works so the bars stay current; see below.
reply-highlightSets Claude's replies apart in the transcript: a rainbow edge down the left (red at the top of each block to violet at the bottom) and a violet tint behind them. Text keeps the terminal's colour; what Claude bolds, and headings, run through a rainbow letter by letter. Code blocks and tables keep Claude Code's own drawing inside the panel.
backlog-paneA side pane for the project: its git status on top (branch, ahead/behind, changed files coloured by kind), shown in any git repository, then its Backlog.md tasks when it has a backlog/ folder (in its root, or a parent inside the same repository). Tasks in progress carry a rainbow bar of checked acceptance criteria and a ✓ done button; the backlog list has ▸ start buttons; priority, type and first label sit in front of each title; a box at the bottom creates a task. Buttons run the backlog CLI, so files stay as Backlog.md writes them. Each section sits in a rounded card in its colour, titled on its first row with its count on the right. Rereads every few seconds; /backlog reopens it.
transcript-fxRestyles the rest of the transcript to match. Tool calls are gold rows (an icon per kind, a short summary, ✓ / ✗ / ◐ / ⊘) with their results in a gold-edged panel, and folded runs are one gold count line (Read ×3 · Grep ×2). Your prompts sit in a padded teal box with a rainbow frame running clockwise around it. A small rounded square sits beside the spinner with a rainbow comet circling its border; the spinner keeps its time and tokens. Each turn closes with ✦ baked in 2m 50s · 3:42 PM, and a rainbow rule sits above the prompt when nothing else uses that band. /fx turns each part on or off.

Each works alone; limit-bars and plan-progress-fx share the line under the prompt when both are installed.

Install

Requires a Claude Code build with plugin hooks (ui.render).

claude plugin marketplace add LegendSilvia/claude-code-mods
claude plugin install limit-bars@claude-code-mods
claude plugin install plan-progress-fx@claude-code-mods
claude plugin install reply-highlight@claude-code-mods
claude plugin install backlog-pane@claude-code-mods
claude plugin install transcript-fx@claude-code-mods

Restart Claude Code. If you use a statusLine command in settings.json that shows the model, folder or context, you can remove it: limit-bars shows the same.

Notes

  • The rate-limit rings fill in after the first reply of a session (Claude Code learns the limits from API responses) and only on a subscription account. A limit your account doesn't report shows --.
  • The Fable ring reads the weekly window whose name contains fable, or any other model-specific weekly window.
  • The rings and animation need the terminal; the desktop app gets pie glyphs (○ ◔ ◑ ◕ ●) instead.

plan-progress-fx commands

CommandDoes
/progressShow or hide the bars
/progress-demoRun a sample plan
/progress-soundsPlay the decision, error and done sounds
/progress-clearRemove all bars
/progress-debugShow the widths the bars were laid out by (the terminal's, and what the prompt line was told)

transcript-fx commands

CommandDoes
/fxList the parts and whether each is on
`/fx <part> on\off`Turn one part on or off: tools, spinner, prompts, footer, rule (remembered across sessions)
/fx debugShow what the footer clock went by: the last turn's end and each recent footer's first drawing and decision

The footer's clock time is taken when the turn ends; footers already on screen when the plugin reloads show only their duration.

How plan-progress-fx steers Claude

The bars only move when Claude reports progress, so this plugin changes what Claude does, not just what the terminal draws:

  • Rules in the system prompt. Every session it adds a short section telling Claude to create a bar (through the plugin's plan_progress tool) for any task needing more than about three edits or commands, to update it as steps finish, and to mark it "needs input" before asking you to decide.
  • One refused call per turn. While no bar is open, the 4th file-changing Edit, Write, MultiEdit, NotebookEdit, Bash or PowerShell call of a turn is refused once, with a message telling Claude to create a bar first. Read-only shell calls (ls, git status, grep) don't count, and subagents are never refused.
  • Sent back once at the end of a turn. If the turn did work and a bar is still open when Claude stops, Claude is sent back once to update it. A reply that ends in a question marks the bar as waiting on you instead.
  • Reminders. With a bar open, every six changing calls without an update add a one-line reminder to the tool result, and each prompt you send carries one line naming the open bars.

Develop

claude plugin validate plugins/limit-bars
claude plugin test plugins/limit-bars
claude plugin test plugins/plan-progress-fx
claude plugin test plugins/reply-highlight
claude plugin test plugins/backlog-pane
claude plugin test plugins/transcript-fx

License

MIT. plan-progress-fx is a fork of plan-progress by Kirill Serditov (MIT); see its LICENSE.

Source 2 files
hooks/register.tsx 556 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Git, GitFile, Task } from '../types'
5
6const PANE = 'backlog'
7const POLL_MS = 4000
8const board = atom({ plugin: 'backlog-pane', key: 'board' } as const, null as Board | null)
9const git = atom({ plugin: 'backlog-pane', key: 'git' } as const, null as Git | null)
10// the last action's outcome, shown in the footer: "TASK-3 started", or why a command failed
11const flash = atom({ plugin: 'backlog-pane', key: 'flash' } as const, null as string | null)
12
13const DEFAULT_STATUSES = ['To Do', 'In Progress', 'Done']
14const DONE = /^(done|completed?|closed)$/i
15const ACTIVE = /progress|doing|review/i
16const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
17
18const unquote = (v: string) => {
19  const s = v.trim()
20  if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1).replace(/''/g, "'")
21  if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1).replace(/\\"/g, '"')
22  return s
23}
24
25const frontLines = (text: string): string[] => {
26  const lines = text.replace(/\r\n/g, '\n').split('\n')
27  if (lines[0]?.trim() !== '---') return []
28  const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
29  return lines.slice(1, end < 0 ? lines.length : end)
30}
31
32// the scalar keys of a task file's front matter; folded (>-) and literal (|) blocks joined into one line
33export function frontMatter(text: string): Record<string, string> {
34  const lines = frontLines(text)
35  const out: Record<string, string> = {}
36  for (let i = 0; i < lines.length; i++) {
37    const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]!)
38    if (!m) continue
39    const [, key, raw] = m as unknown as [string, string, string]
40    if (/^[>|][-+]?$/.test(raw.trim())) {
41      const parts: string[] = []
42      while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]!)) parts.push(lines[++i]!.trim())
43      out[key] = parts.join(' ')
44    } else if (raw.trim() !== '' && !raw.trim().startsWith('[')) {
45      out[key] = unquote(raw)
46    }
47  }
48  return out
49}
50
51// the list keys of the front matter, inline ([a, b]) or as "- item" lines beneath the key
52export function frontLists(text: string): Record<string, string[]> {
53  const lines = frontLines(text)
54  const out: Record<string, string[]> = {}
55  for (let i = 0; i < lines.length; i++) {
56    const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]!)
57    if (!m) continue
58    const [, key, raw] = m as unknown as [string, string, string]
59    const inline = /^\[(.*)\]$/.exec(raw.trim())
60    if (inline) {
61      out[key] = inline[1]!.split(',').map(unquote).filter(Boolean)
62    } else if (raw.trim() === '') {
63      const items: string[] = []
64      while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1]!)) items.push(unquote(lines[++i]!.replace(/^\s*-\s+/, '')))
65      if (items.length > 0) out[key] = items
66    }
67  }
68  return out
69}
70
71// checked and total acceptance criteria: "- [x] #1 ..." lines, inside the AC markers when present
72export function acceptance(text: string): { done: number; total: number } {
73  const body = text.replace(/\r\n/g, '\n')
74  const marked = /<!-- AC:BEGIN -->([\s\S]*?)<!-- AC:END -->/.exec(body)
75  const scope = marked ? marked[1]! : (/## Acceptance Criteria\n([\s\S]*?)(\n## |$)/.exec(body)?.[1] ?? '')
76  const boxes = scope.match(/^\s*- \[[ xX]\]/gm) ?? []
77  return { done: boxes.filter(b => /\[[xX]\]/.test(b)).length, total: boxes.length }
78}
79
80export function toTask(text: string, fallbackId: string): Task | null {
81  const f = frontMatter(text)
82  if (!f.title || !f.status) return null
83  const l = frontLists(text)
84  return {
85    id: f.id ?? fallbackId,
86    title: f.title,
87    status: f.status,
88    priority: f.priority,
89    type: f.type,
90    labels: l.labels ?? [],
91    assignee: (l.assignee ?? (f.assignee ? [f.assignee] : [])).map(a => a.replace(/^@/, '')),
92    ac: acceptance(text),
93    parent: f.parent_task_id ?? f.parent,
94    updated: f.updated_date ?? f.created_date,
95    created: f.created_date,
96    ordinal: f.ordinal !== undefined && !Number.isNaN(Number(f.ordinal)) ? Number(f.ordinal) : undefined,
97  }
98}
99
100export function statusesOf(config: string): string[] {
101  const m = /^statuses:\s*\[(.*)\]\s*$/m.exec(config.replace(/\r\n/g, '\n'))
102  if (!m) return DEFAULT_STATUSES
103  const list = m[1]!.split(',').map(unquote).filter(Boolean)
104  return list.length > 0 ? list : DEFAULT_STATUSES
105}
106
107// the statuses the buttons move a task to: the board's first in-progress kind, and its last done kind
108export const activeStatus = (statuses: string[]) => statuses.find(s => ACTIVE.test(s)) ?? 'In Progress'
109export const doneStatus = (statuses: string[]) => [...statuses].reverse().find(s => DONE.test(s)) ?? 'Done'
110
111// "2026-09-17 13:19" → "1:19 PM" on the same day as now, "Sep 17" otherwise
112export function when(stamp: string | undefined, now: number): string {
113  const m = stamp && /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/.exec(stamp)
114  if (!m) return ''
115  const today = new Date(now)
116  const isToday = Number(m[1]) === today.getFullYear() && Number(m[2]) === today.getMonth() + 1 && Number(m[3]) === today.getDate()
117  if (isToday && m[4] !== undefined) {
118    const h = Number(m[4])
119    return `${h % 12 === 0 ? 12 : h % 12}:${m[5]} ${h < 12 ? 'AM' : 'PM'}`
120  }
121  return `${MONTHS[Number(m[2]) - 1]} ${Number(m[3])}`
122}
123
124const idNumber = (id: string) => Number(/(\d+(?:\.\d+)*)$/.exec(id)?.[1]?.split('.')[0] ?? 0)
125export const byOrder = (a: Task, b: Task) =>
126  (a.ordinal ?? Infinity) - (b.ordinal ?? Infinity) || idNumber(a.id) - idNumber(b.id) || a.id.localeCompare(b.id)
127
128const clock = (now: number) => {
129  const d = new Date(now)
130  const h = d.getHours()
131  return `${h % 12 === 0 ? 12 : h % 12}:${String(d.getMinutes()).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`
132}
133
134const sepOf = (path: string) => (path.includes('\\') ? '\\' : '/')
135const parentOf = (path: string) => path.slice(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')))
136
137// the folders a workspace's backlog may sit in: the project root, and above it only as far as
138// the root's own git repository goes, so a folder of unrelated projects never lends its backlog
139export function searchDirs(root: string, repoTop: string | null): string[] {
140  const norm = (p: string) => p.replace(/[\\/]+$/, '').replace(/\\/g, '/').toLowerCase()
141  const dirs = [root.replace(/[\\/]+$/, '')]
142  if (repoTop === null) return dirs
143  const top = norm(repoTop)
144  let dir = dirs[0]!
145  while (norm(dir) !== top && norm(dir).startsWith(`${top}/`)) {
146    dir = parentOf(dir)
147    dirs.push(dir)
148  }
149  return dirs
150}
151
152async function findRepoTop($: EngineInterface, root: string): Promise<string | null> {
153  const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root, timeoutMs: 10000 }).catch(() => null)
154  return r && r.exitCode === 0 ? r.stdout.trim() : null
155}
156
157// the workspace's backlog folder: backlog/ or .backlog/ holding tasks/, in one of the searchDirs
158async function findBacklog($: EngineInterface, root: string, top: string | null): Promise<string | null> {
159  const sep = sepOf(root)
160  for (const dir of searchDirs(root, top)) {
161    for (const name of ['backlog', '.backlog']) {
162      const candidate = `${dir}${sep}${name}`
163      if (await $.fs.exists(`${candidate}${sep}tasks`).catch(() => false)) return candidate
164    }
165  }
166  return null
167}
168
169// what each task file last parsed to, by name and modified time, so a poll rereads only what changed
170const cache = new Map<string, { mtimeMs: number; task: Task | null }>()
171let signature = ''
172
173async function scan($: EngineInterface, dir: string): Promise<void> {
174  const sep = sepOf(dir)
175  const files = (await $.fs.list(`${dir}${sep}tasks`).catch(() => [])).filter(f => f.kind === 'file' && f.name.endsWith('.md'))
176  // config.yml counts too: an edited status list shows without waiting on a task file
177  const conf = await $.fs.stat(`${dir}${sep}config.yml`).catch(() => null)
178  const sig = [...files.map(f => `${f.name}:${f.mtimeMs}`).sort(), `config.yml:${conf?.mtimeMs ?? 0}`].join('|')
179  if (sig === signature) return
180  signature = sig
181  const tasks: Task[] = []
182  for (const f of files) {
183    let hit = cache.get(f.name)
184    if (!hit || hit.mtimeMs !== f.mtimeMs) {
185      const text = await $.fs.read(`${dir}${sep}tasks${sep}${f.name}`).catch(() => '')
186      hit = { mtimeMs: f.mtimeMs, task: toTask(String(text), f.name.replace(/\.md$/, '')) }
187      cache.set(f.name, hit)
188    }
189    if (hit.task) tasks.push(hit.task)
190  }
191  const config = await $.fs.read(`${dir}${sep}config.yml`).catch(() => '')
192  const readAt = await $.clock.now()
193  await update($, board, () => ({ dir, statuses: statusesOf(String(config)), tasks: tasks.sort(byOrder), readAt }))
194}
195
196// `git status --porcelain=v1 -b`: the branch line, then one line per changed path
197export function parseGitStatus(out: string): Git {
198  const lines = out.replace(/\r\n/g, '\n').split('\n').filter(Boolean)
199  const head = lines[0]?.startsWith('## ') ? lines.shift()!.slice(3) : ''
200  const branch =
201    /^No commits yet on (.+)$/.exec(head)?.[1] ??
202    (/^HEAD \(no branch\)/.test(head) ? 'detached' : head.split('...')[0]!.split(' ')[0] || '?')
203  const ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
204  const behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
205  const files: GitFile[] = lines.map(l => {
206    const xy = l.slice(0, 2)
207    const path = l.slice(3).split(' -> ').pop()!.replace(/^"(.*)"$/, '$1')
208    const code = xy === '??' ? '?' : xy[0] !== ' ' ? xy[0]! : xy[1]!
209    return { code, path }
210  })
211  return { branch, ahead, behind, files }
212}
213
214// the workspace's git state, or null where it is not a repository (or git is missing); a poll
215// never takes index.lock from the person's own git, and non-ASCII names come out as written
216async function scanGit($: EngineInterface, cwd: string): Promise<void> {
217  const argv = ['git', '--no-optional-locks', '-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-b']
218  const r = await $.process.run(argv, { cwd, timeoutMs: 10000 }).catch(() => null)
219  const next = r && r.exitCode === 0 ? parseGitStatus(r.stdout) : null
220  const prev = await read($, git)
221  if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, git, () => next)
222}
223
224// what is watched: the project root, its repo top (null outside git), and its backlog once found
225let watched: { root: string; top: string | null; dir: string | null } | null = null
226
227async function tick($: EngineInterface): Promise<void> {
228  const w = watched
229  if (!w) return
230  // a backlog made later (backlog init) is found on the next tick
231  if (!w.dir) w.dir = await findBacklog($, w.root, w.top)
232  if (w.dir) await scan($, w.dir)
233  if (w.top) await scanGit($, w.root)
234}
235
236// the timer's tick; skipped while the last one still runs, so a slow git never stacks up, and
237// while the pane is closed, since nobody sees it (/backlog scans again as it reopens)
238let isPolling = false
239async function poll($: EngineInterface): Promise<void> {
240  if (isPolling) return
241  isPolling = true
242  try {
243    if ((await $.ui.panes()).some(p => p.id === PANE)) await tick($)
244  } finally {
245    isPolling = false
246  }
247}
248
249// starts watching once, however many callers race to it (the session's start and an early
250// /backlog); true when there is something to show (a backlog, or a git repository)
251let starting: Promise<void> | null = null
252async function watch($: EngineInterface): Promise<boolean> {
253  if (!watched) {
254    starting ??= (async () => {
255      const root = await $.session.root()
256      const top = await findRepoTop($, root)
257      watched = { root, top, dir: await findBacklog($, root, top) }
258      $.clock.every(POLL_MS, () => void poll($).catch(() => undefined))
259    })().finally(() => (starting = null))
260    await starting
261  }
262  await tick($)
263  return watched !== null && (watched.dir !== null || watched.top !== null)
264}
265
266// a Windows host: the paths the engine hands out start with a drive letter there
267const isWindowsPath = (path: string) => /^[A-Za-z]:[\\/]/.test(path)
268
269// the backlog command as an argument vector: the binary on PATH, or on Windows the npm shim's
270// node script, since a .cmd shim cannot run without a shell and a shell would reparse titles;
271// a bare `backlog` on Windows resolves to that shim, so there only a full .exe path runs as is
272let backlogArgv: string[] | null | undefined
273async function backlogCommand($: EngineInterface, cwd: string): Promise<string[] | null> {
274  if (backlogArgv !== undefined) return backlogArgv
275  if (!isWindowsPath(cwd)) {
276    const direct = await $.process.run(['backlog', '--version'], { cwd, timeoutMs: 15000 }).catch(() => null)
277    return (backlogArgv = direct && direct.exitCode === 0 ? ['backlog'] : null)
278  }
279  const where = await $.process.run(['where', 'backlog'], { cwd, timeoutMs: 10000 }).catch(() => null)
280  // the first hit Windows can start; npm's extensionless sh shim sits beside the .cmd and is skipped
281  const hit = where && where.exitCode === 0 ? where.stdout.split(/\r?\n/).map(l => l.trim()).find(l => /\.(exe|cmd|bat)$/i.test(l)) : undefined
282  if (hit && /\.exe$/i.test(hit)) return (backlogArgv = [hit])
283  if (hit) {
284    const script = `${parentOf(hit)}\\node_modules\\backlog.md\\cli.js`
285    if (await $.fs.exists(script).catch(() => false)) return (backlogArgv = ['node', script])
286  }
287  return (backlogArgv = null)
288}
289
290// runs one backlog command in the project that holds the backlog, then rereads the board
291async function runBacklog($: EngineInterface, args: string[], ok: string): Promise<void> {
292  const dir = watched?.dir
293  if (!dir) return
294  const cwd = parentOf(dir)
295  const argv = await backlogCommand($, cwd)
296  if (!argv) {
297    await update($, flash, () => 'backlog CLI not found on PATH')
298    return
299  }
300  const r = await $.process.run([...argv, ...args], { cwd, timeoutMs: 30000 }).catch((err: unknown) => ({
301    exitCode: -1,
302    stdout: '',
303    stderr: String(err),
304  }))
305  const why = (r.stderr || r.stdout).trim().split(/\r?\n/).pop() ?? ''
306  await update($, flash, () => (r.exitCode === 0 ? ok : `failed: ${why || `exit ${r.exitCode}`}`))
307  signature = ''
308  await scan($, dir)
309}
310
311async function setStatus($: EngineInterface, id: string, to: 'start' | 'done'): Promise<void> {
312  const b = await read($, board)
313  if (!b) return
314  // ids come from the repo's files; one that is not a plain name never reaches a command line
315  if (!/^[\w.-]+$/.test(id)) {
316    await update($, flash, () => `failed: refused odd task id ${JSON.stringify(id)}`)
317    return
318  }
319  const status = to === 'start' ? activeStatus(b.statuses) : doneStatus(b.statuses)
320  await runBacklog($, ['task', 'edit', id, '-s', status], `${id} ${to === 'start' ? 'started' : 'done'}`)
321}
322
323// the titles being created now: Enter pressed again before the CLI answers makes no second task
324const creating = new Set<string>()
325async function createTask($: EngineInterface, title: string): Promise<void> {
326  const t = title.trim()
327  if (!t || creating.has(t)) return
328  creating.add(t)
329  try {
330    await runBacklog($, ['task', 'create', t], `created “${t}”`)
331  } finally {
332    creating.delete(t)
333  }
334}
335
336const GIT_CODE_COLOR: Record<string, string> = { M: '#ffcb6b', A: '#69f0ae', D: '#ff6b8b', R: '#b388ff', C: '#b388ff', U: '#ff6b8b', '?': '#69f0ae' }
337const GIT_ROWS = 8
338const TYPE_COLOR: Record<string, string> = { bug: '#ff6b8b', feature: '#69f0ae', chore: '#4dd0e1', docs: '#82aaff', refactor: '#c792ea' }
339const PRIORITY = { high: { glyph: '▲', color: '#ff6b8b' }, medium: { glyph: '◆', color: '#ffcb6b' }, low: { glyph: '▽', color: '#7a7a85' } } as const
340
341const STYLE = {
342  active: { glyph: '◐', color: '#ffcb6b' },
343  todo: { glyph: '○', color: '#b388ff' },
344  other: { glyph: '◇', color: '#4dd0e1' },
345}
346
347// a hue in degrees to #rrggbb, bright enough for a dark pane
348function hue(h: number): string {
349  const s = 0.85
350  const l = 0.62
351  const k = (n: number) => (n + h / 30) % 12
352  const a = s * Math.min(l, 1 - l)
353  const c = (n: number) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))
354  return `#${[0, 8, 4].map(n => c(n).toString(16).padStart(2, '0')).join('')}`
355}
356
357// the acceptance criteria as a bar: the checked share drawn in a rainbow, the rest a dim track
358export function acBar(done: number, total: number, width: number): { filled: number; colors: string[] } {
359  const filled = total > 0 ? Math.round((Math.min(done, total) / total) * width) : 0
360  return { filled, colors: Array.from({ length: filled }, (_, i) => hue((i / Math.max(1, width - 1)) * 300)) }
361}
362
363export const register: Register = on => {
364  on('session.start', async ($, e, next) => {
365    const ran = await next(e)
366    await $.command.register({ name: 'backlog', description: "Show the project's git status and Backlog.md tasks in a side pane" })
367    // the first scan runs on its own, so the first prompt never waits on git
368    void watch($)
369      .then(found => (found ? $.ui.open({ id: PANE, title: 'Workspace' }) : undefined))
370      .catch(() => undefined)
371    return ran
372  })
373
374  on('command.run', { command: 'backlog' }, async $ => {
375    if (!(await watch($))) return { text: 'Nothing to show: this project has no backlog/ folder and is not a git repository.' }
376    await $.ui.open({ id: PANE, title: 'Workspace' })
377    return { text: 'Workspace pane opened.' }
378  })
379
380  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
381    const table = $.ui.resolve(e)
382    const { Box, Button, Text } = table
383    // the mobile surface has no Input; the new-task box is left out there
384    const Input = 'Input' in table ? table.Input : null
385    const b = await read($, board)
386    const g = await read($, git)
387    const note = await read($, flash)
388    const now = await $.clock.now()
389    const cols = Math.max(20, e.props.bodyColumns ?? 40)
390    // a rounded card in one colour, its title and count on the first row inside: the border
391    // draws over anything laid on it, so a title set into the edge never shows
392    const card = (id: string, color: string, title: unknown, count: unknown, body: unknown) => (
393      <Box key={`card-${id}`} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} marginBottom={1}>
394        {title ? (
395          <Box key={`head-${id}`} flexDirection="row" justifyContent="space-between" gap={1}>
396            <Box flexShrink={1}>{title}</Box>
397            {count ? <Box flexShrink={0}>{count}</Box> : null}
398          </Box>
399        ) : null}
400        {body}
401      </Box>
402    )
403
404    const gitRows = g ? Math.min(g.files.length, GIT_ROWS) + (g.files.length > GIT_ROWS ? 1 : 0) : 0
405    const gitSection = g
406      ? card(
407          'git',
408          '#69f0ae',
409          <Text bold color="#69f0ae">
410            ⎇ {g.branch}
411          </Text>,
412          <Text dimColor>
413            ↑{g.ahead} ↓{g.behind}
414          </Text>,
415          <>
416        {g.files.length === 0 ? <Text dimColor>✓ clean</Text> : null}
417        {g.files.slice(0, GIT_ROWS).map((f, i) => (
418          <Text key={`gf-${i}`} wrap="truncate-start">
419            <Text bold color={GIT_CODE_COLOR[f.code] ?? '#4dd0e1'}>
420              {f.code}
421            </Text>{' '}
422            {f.path}
423          </Text>
424        ))}
425        {g.files.length > GIT_ROWS ? <Text dimColor>+{g.files.length - GIT_ROWS} more</Text> : null}
426          </>,
427        )
428      : null
429
430    if (!b) {
431      return (
432        <Box flexDirection="column">
433          {gitSection}
434          <Text dimColor>No backlog here yet. Run `backlog init` in the project; it appears on its own.</Text>
435        </Box>
436      )
437    }
438
439    // priority, type and first label, in front of a title
440    const chips = (t: Task) => {
441      const p = t.priority ? PRIORITY[t.priority as keyof typeof PRIORITY] : undefined
442      const out = []
443      if (p) out.push(<Text key={`p-${t.id}`} color={p.color}>{`${p.glyph} `}</Text>)
444      if (t.type) out.push(<Text key={`ty-${t.id}`} bold color={TYPE_COLOR[t.type] ?? '#4dd0e1'}>{`${t.type} `}</Text>)
445      if (t.labels[0]) out.push(<Text key={`lb-${t.id}`} color="#8f7fd6">{`#${t.labels[0]} `}</Text>)
446      return out
447    }
448
449    const open = b.statuses.filter(s => !DONE.test(s))
450    // in-progress kinds first, then the rest in the board's own order
451    const order = [...open.filter(s => ACTIVE.test(s)), ...open.filter(s => !ACTIVE.test(s))]
452    const done = b.tasks.filter(t => DONE.test(t.status)).length
453    let room = Math.max(6, (e.viewport?.rows ?? 30) - 9 - (g ? gitRows + 4 : 0))
454
455    const sections = order.map(status => {
456      const list = b.tasks.filter(t => t.status === status)
457      const kind = ACTIVE.test(status) ? 'active' : /to ?do|backlog|todo/i.test(status) ? 'todo' : 'other'
458      const style = STYLE[kind]
459      const label = kind === 'todo' ? 'Backlog' : status
460      const perTask = kind === 'active' ? 4 : 1
461      const fits = Math.max(1, Math.floor((room - 4) / perTask))
462      const shown = list.slice(0, fits)
463      room -= 4 + shown.length * perTask
464      return card(
465        status,
466        style.color,
467        <Text bold color={style.color}>
468          {style.glyph} {label}
469        </Text>,
470        <Text dimColor>{String(list.length)}</Text>,
471        <>
472          {list.length === 0 ? <Text dimColor>nothing here</Text> : null}
473          {shown.map(t => {
474            if (kind === 'active') {
475              const bar = acBar(t.ac.done, t.ac.total, cols - 6)
476              return (
477                <Box key={t.id} flexDirection="column" marginBottom={1}>
478                  <Box flexDirection="row" justifyContent="space-between" gap={1}>
479                    <Box flexShrink={1}>
480                      <Text>
481                        <Text color={style.color}>▸ </Text>
482                        {chips(t)}
483                        <Text bold>{t.title}</Text>
484                      </Text>
485                    </Box>
486                    <Box flexShrink={0}>
487                      <Button key={`done-${t.id}`} label="✓ done" onPress={() => void setStatus($, t.id, 'done')} />
488                    </Box>
489                  </Box>
490                  {t.ac.total > 0 ? (
491                    <Text>
492                      {'  '}
493                      {bar.colors.map((c, i) => (
494                        <Text key={`ab-${t.id}-${i}`} color={c}>
495                          ━
496                        </Text>
497                      ))}
498                      <Text color="#3a3a42">{'─'.repeat(Math.max(0, cols - 6 - bar.filled))}</Text>
499                    </Text>
500                  ) : null}
501                  <Text dimColor>
502                    {'  '}
503                    {[
504                      t.id,
505                      t.updated ? `started ${when(t.updated, now)}` : '',
506                      t.ac.total > 0 ? `${t.ac.done}/${t.ac.total} criteria` : '',
507                      t.assignee.join(', '),
508                    ]
509                      .filter(Boolean)
510                      .join(' · ')}
511                  </Text>
512                </Box>
513              )
514            }
515            return (
516              <Box key={t.id} flexDirection="row" justifyContent="space-between" gap={1}>
517                <Box flexShrink={1}>
518                  <Text>
519                    <Text color={style.color}>{t.parent ? '  ↳ ' : '· '}</Text>
520                    {chips(t)}
521                    {t.title}
522                  </Text>
523                </Box>
524                <Box flexShrink={0}>
525                  <Button key={`start-${t.id}`} plain dimColor label="▸ start" onPress={() => void setStatus($, t.id, 'start')} />
526                </Box>
527              </Box>
528            )
529          })}
530          {list.length > shown.length ? <Text dimColor>+{list.length - shown.length} more</Text> : null}
531        </>,
532      )
533    })
534
535    return (
536      <Box flexDirection="column">
537        {gitSection}
538        {sections}
539        {Input
540          ? card(
541              'new',
542              '#4dd0e1',
543              null,
544              null,
545              <Input key="new-task" placeholder="new task title…" submitLabel="add" onSubmit={(value: string) => void createTask($, value)} />,
546            )
547          : null}
548        <Text dimColor>
549          <Text color="#69f0ae">✓ {done} done</Text> · backlog.md synced {clock(b.readAt)}
550        </Text>
551        {note ? <Text color={note.startsWith('failed') ? '#ff6b8b' : '#69f0ae'}>{note}</Text> : null}
552      </Box>
553    )
554  })
555}
556
types/index.d.ts 24 lines
1export type Task = {
2  id: string
3  title: string
4  status: string
5  priority?: string
6  type?: string
7  labels: string[]
8  assignee: string[]
9  ac: { done: number; total: number }
10  parent?: string
11  updated?: string
12  created?: string
13  ordinal?: number
14}
15export type Board = { dir: string; statuses: string[]; tasks: Task[]; readAt: number }
16export type GitFile = { code: string; path: string }
17export type Git = { branch: string; ahead: number; behind: number; files: GitFile[] }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'backlog-pane': { board: Board | null; git: Git | null; flash: string | null }
22  }
23}
24