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

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

| Plugin | What it draws |
|---|---|
| limit-bars | Four 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-fx | Animated 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-highlight | Sets 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-pane | A 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-fx | Restyles 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.
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.
--.fable, or any other model-specific weekly window.○ ◔ ◑ ◕ ●) instead.| Command | Does |
|---|---|
/progress | Show or hide the bars |
/progress-demo | Run a sample plan |
/progress-sounds | Play the decision, error and done sounds |
/progress-clear | Remove all bars |
/progress-debug | Show the widths the bars were laid out by (the terminal's, and what the prompt line was told) |
| Command | Does | |
|---|---|---|
/fx | List 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 debug | Show 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.
The bars only move when Claude reports progress, so this plugin changes what Claude does, not just what the terminal draws:
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.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.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
MIT. plan-progress-fx is a fork of plan-progress by Kirill Serditov (MIT); see its LICENSE.
hooks/register.tsx 556 lines1import { 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}
556types/index.d.ts 24 lines1export 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