a pane with one card per production pipeline, read from small status files

a pane beside the chat with one card per production pipeline: what it is doing now, what waits for you, the last thing it made, and what comes next.
the mod only reads. each pipeline writes one small json file into a folder you pick, and every file there becomes a card. a new pipeline needs no change to the mod: it only starts writing its file. the format is in FORMAT.md.
┌ music ─────────────────────── running · 1m ago · studio-mac ┐
│ 05_autumn · 42/70 usable │
│ [the puppy at work] ■■■■■■□■■■■■■■■□■■■… │
│ waiting for you │
│ · hear 3 songs in 05_autumn: #7, #12, #31 │
│ next: making songs │
└─────────────────────────────────────────────────────────────┘
waiting, error or quiet, or a waiting card gets a new item, a short notice shows in the corner (once, not every minute). turn it off with notices in /config.stale_after_sec) and went quiet gets a "quiet" mark, so a sleeping computer doesn't look like it is still working.in a claude code session in your terminal:
/plugin install puppy-console --marketplace nonbiri1004/puppy-mods
answer y to add the marketplace, then pick a scope. set status folder to the folder your pipelines write into (~ is your home folder). you can change it later in /config.
| command | what it does |
|---|---|
/console | opens the pane. it reads the folder again every minute; r reads it now |
claude plugin validate puppy-console
claude plugin test puppy-console
MIT
hooks/register.tsx 158 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Problem, Snapshot, Status } from '../types'
5import { detailLines, picture, W } from './draw'
6import { ago, alerts, expandHome, isStale, parseStatus, sortCards, textBar, titleOf } from './format'
7
8const PANE = 'puppy-console'
9const REFRESH_MS = 60_000
10
11const snapshot = atom({ plugin: 'puppy-console', key: 'snapshot' } as const, null)
12
13const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
14
15// Every *.json in the folder is one card. A file that does not parse becomes a problem line,
16// never a missing card without a word: the writer may be halfway through, or on an old format.
17export async function loadFolder($: EngineInterface, dir: string): Promise<Snapshot> {
18 const loadedAt = await $.clock.now()
19 if (!dir) return { dir, cards: [], problems: [], dirError: 'no status folder set', loadedAt }
20 let entries
21 try {
22 entries = await $.fs.list(dir)
23 } catch (err) {
24 return { dir, cards: [], problems: [], dirError: errorText(err), loadedAt }
25 }
26 const cards: Status[] = []
27 const problems: Problem[] = []
28 for (const f of entries) {
29 if (f.kind !== 'file' || !f.name.endsWith('.json') || f.name.startsWith('.')) continue
30 try {
31 const s = parseStatus(await $.fs.read(`${dir}/${f.name}`))
32 if (`${s.pipeline}.json` !== f.name) throw new Error(`"pipeline" is ${s.pipeline}, so the file should be ${s.pipeline}.json`)
33 cards.push(s)
34 } catch (err) {
35 problems.push({ file: f.name, reason: errorText(err) })
36 }
37 }
38 return { dir, cards: sortCards(cards), problems, dirError: null, loadedAt }
39}
40
41// One refresh at a time: the timer and the button share the running one,
42// so an older read can never finish last and overwrite a newer one.
43let running: Promise<void> | null = null
44function refresh($: EngineInterface, setting: string, isNotifying: boolean): Promise<void> {
45 running ??= (async () => {
46 const dir = expandHome(setting.trim(), await $.env.get('HOME'))
47 const prev = await read($, snapshot)
48 const next = await loadFolder($, dir)
49 await update($, snapshot, () => next)
50 // A toast shows in this conversation only, for ten seconds; each open conversation tells its own.
51 if (isNotifying) for (const line of alerts(prev, next)) $.ui.toast(line, { timeoutMs: 10_000 })
52 })().finally(() => { running = null })
53 return running
54}
55
56// A detail of an unexpected shape loses its picture, not the whole pane.
57function safePicture(s: Status, stale: boolean) {
58 try {
59 return picture(s, stale)
60 } catch {
61 return null
62 }
63}
64
65function safeLines(s: Status): string[] {
66 try {
67 return detailLines(s)
68 } catch {
69 return []
70 }
71}
72
73export const register: Register = (on, options) => {
74 const setting = String(options.status_dir ?? '')
75 const isNotifying = options.notify !== 'off'
76
77 on('session.start', async ($, e, next) => {
78 await $.command.register({ name: 'console', description: 'Open the pipeline cards' })
79 void refresh($, setting, isNotifying)
80 // Like setInterval in a component's mount: each tick reads the folder again and the pane redraws.
81 $.clock.every(REFRESH_MS, () => refresh($, setting, isNotifying))
82 return next(e)
83 })
84
85 on('command.run', { command: 'console' }, async $ => {
86 await refresh($, setting, isNotifying)
87 await $.ui.open({ id: PANE, title: 'console' })
88 return { text: 'pipeline cards opened.' }
89 })
90
91 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
92 const ui = $.ui.resolve(e)
93 const { Box, Text, Button } = ui
94 const snap = await read($, snapshot)
95 const now = await $.clock.now()
96 if (!snap) return <Text dimColor>loading…</Text>
97
98 const hasSvg = e.surface !== 'terminal' && 'Svg' in ui
99
100 const card = (s: Status) => {
101 const stale = isStale(s, now)
102 const pic = hasSvg ? safePicture(s, stale) : null
103 const stateColor = s.state === 'error' ? 'error' : s.state === 'waiting' ? 'warning' : s.state === 'running' ? 'success' : undefined
104 return (
105 <Box key={s.pipeline} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} gap="xs">
106 <Box flexDirection="row" justifyContent="space-between">
107 <Text weight="semibold">{titleOf(s)}</Text>
108 <Text dimColor>
109 <Text color={stateColor}>{stale ? 'quiet' : s.state}</Text>
110 {' · '}{ago(now - Date.parse(s.updated))}{s.host ? ` · ${s.host}` : ''}
111 </Text>
112 </Box>
113 <Text>{s.headline}</Text>
114 {stale && <Text color="warning">no update for {ago(now - Date.parse(s.updated)).replace(' ago', '')}; is {s.host ?? 'the computer'} asleep?</Text>}
115 {pic && 'Svg' in ui
116 ? <ui.Svg source={pic.source} alt={pic.alt} isInteractive />
117 : s.progress && <Text mono dimColor>{textBar(s.progress)}</Text>}
118 {/* no picture (the terminal): the same details as text */}
119 {!pic && safeLines(s).map(line => <Text dimColor>{line}</Text>)}
120 {(s.waiting ?? []).length > 0 && (
121 <Box flexDirection="column">
122 <Text color="warning">waiting for you</Text>
123 {(s.waiting ?? []).slice(0, 5).map(w => <Text key={w.id}>· {w.text}</Text>)}
124 {(s.waiting ?? []).length > 5 && <Text dimColor>· and {(s.waiting ?? []).length - 5} more</Text>}
125 </Box>
126 )}
127 {s.last_output && (
128 <Text dimColor>last: {s.last_output.label}{s.last_output.at ? ` · ${ago(now - Date.parse(s.last_output.at))}` : ''}</Text>
129 )}
130 {s.next && !(s.waiting ?? []).some(w => w.text === s.next) && <Text dimColor>next: {s.next}</Text>}
131 </Box>
132 )
133 }
134
135 return (
136 <Box flexDirection="column" gap={1}>
137 <Box flexDirection="row" justifyContent="space-between">
138 <Text dimColor>checked {ago(now - snap.loadedAt)}</Text>
139 <Button key="refresh" label="refresh" hotkey="r" onPress={() => refresh($, setting, isNotifying)} />
140 </Box>
141 {snap.dirError && (
142 <Box flexDirection="column">
143 <Text color="error">can't read the status folder: {snap.dirError}</Text>
144 <Text dimColor>set it in /config → puppy-console → status folder{snap.dir ? ` (now ${snap.dir})` : ''}</Text>
145 </Box>
146 )}
147 {!snap.dirError && snap.cards.length === 0 && snap.problems.length === 0 && (
148 <Text dimColor>no status files in {snap.dir} yet</Text>
149 )}
150 {snap.cards.map(card)}
151 {snap.problems.map(p => (
152 <Text key={`p:${p.file}`} dimColor>skipped {p.file}: {p.reason}</Text>
153 ))}
154 </Box>
155 )
156 })
157}
158hooks/draw.ts 198 lines1// The picture under each card, as an SVG string: a progress bar for any pipeline, and a richer
2// picture for the details this mod knows (music, ai-video). Warm greys, light by day and dark by
3// night, low contrast; the only orange is the puppy's headphones.
4import type { MusicDetail, Stage, Status, VideoDetail, VideoJob } from '../types'
5import { sit, sitBlink, sleep0, sleep1 } from './puppy'
6
7export const W = 640
8
9const esc = (s: string) =>
10 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
11
12// Cut a label to fit; SVG text does not wrap. n is in Latin letters; a CJK character counts as two.
13const wide = (c: string) => /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(c)
14export function cut(s: string, n: number): string {
15 let used = 0
16 const chars = [...s]
17 for (let i = 0; i < chars.length; i++) {
18 used += wide(chars[i]!) ? 2 : 1
19 if (used > n) return `${chars.slice(0, i).join('').slice(0, Math.max(0, i - 1))}…`
20 }
21 return s
22}
23
24const STYLE = `<style>
25 svg{--card:#f8f7f4;--line:#dcdad4;--text:#2a2926;--muted:#6d6a64;--ok:#7f9a72;--listen:#c2a768;--reject:#b98276}
26 @media (prefers-color-scheme: dark){svg{--card:#232321;--line:#353431;--text:#e7e5e0;--muted:#9b9891;
27 --ok:#86a078;--listen:#bfa66a;--reject:#b07e73}}
28 .t{fill:var(--text);font-family:ui-sans-serif,system-ui,sans-serif}
29 .m{fill:var(--muted);font-family:ui-sans-serif,system-ui,sans-serif}
30 .px{font-family:ui-monospace,Menlo,monospace;letter-spacing:.5px}
31</style>`
32
33// The width and height in the markup let the pane fit the picture to its own width (never wider
34// than W) and keep the shape; guessing the pane's pixel width from its columns left empty space.
35const svg = (h: number, body: string) =>
36 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${h}" viewBox="0 0 ${W} ${h}">${STYLE}${body}</svg>`
37
38function bar(x: number, y: number, w: number, part: number, fill = 'var(--ok)') {
39 const f = Math.max(0, Math.min(1, part)) * w
40 return `<rect x="${x}" y="${y}" width="${w}" height="4" rx="2" fill="var(--line)"/>` +
41 `<rect x="${x}" y="${y}" width="${f.toFixed(1)}" height="4" rx="2" fill="${fill}"/>`
42}
43
44export type Picture = { source: string; height: number; alt: string }
45
46function progressPicture(s: Status): Picture | null {
47 const p = s.progress
48 if (!p || !p.total) return null
49 const label = `${p.done}/${p.total}${p.unit ? ` ${p.unit}` : ''}`
50 return {
51 height: 24,
52 alt: label,
53 source: svg(24, bar(0, 10, W - 150, p.done / p.total) +
54 `<text class="m px" x="${W}" y="16" font-size="11" text-anchor="end">${esc(cut(label, 22))}</text>`),
55 }
56}
57
58// Two frames swapped by SMIL: the puppy blinks while it works and breathes while it sleeps.
59function puppy(x: number, y: number, scale: number, isWorking: boolean) {
60 const [a, b, dur, keyTimes] = isWorking ? [sit, sitBlink, '4s', '0;0.94'] : [sleep0, sleep1, '3s', '0;0.5']
61 const frame = (rects: string, values: string) =>
62 `<g visibility="visible">${rects}<set attributeName="visibility" to="visible"/>` +
63 `<animate attributeName="visibility" values="${values}" keyTimes="${keyTimes}" dur="${dur}" repeatCount="indefinite" calcMode="discrete"/></g>`
64 return `<g transform="translate(${x} ${y}) scale(${scale})" shape-rendering="crispEdges">` +
65 frame(a, 'visible;hidden') + frame(b, 'hidden;visible') + '</g>'
66}
67
68function notes(x: number, y: number) {
69 return [0, 1.3, 2.6].map((delay, i) =>
70 `<text class="m" x="${x + i * 10}" y="${y}" font-size="13" opacity="0">♪` +
71 `<animate attributeName="y" values="${y};${y - 30}" dur="3.9s" begin="${delay}s" repeatCount="indefinite"/>` +
72 `<animate attributeName="opacity" values="0;.8;0" dur="3.9s" begin="${delay}s" repeatCount="indefinite"/></text>`,
73 ).join('')
74}
75
76export const MAX_SQUARES = 300
77
78const STRIP_COLOR: Record<string, string> = {
79 o: 'var(--ok)', l: 'var(--listen)', r: 'var(--reject)', x: 'var(--line)', '?': 'var(--muted)',
80}
81
82// The puppy at work (or asleep), then one square per song made.
83function musicPicture(s: Status, isStale: boolean): Picture | null {
84 const d = (s.detail ?? {}) as MusicDetail
85 const b = d.batches?.[0]
86 if (!b) return null
87 const H = 120
88 const isWorking = s.state === 'running' && !isStale
89 let body = puppy(0, H - 26 * 2.6 - 18, 2.6, isWorking)
90 if (isWorking) body += notes(70, 40)
91 // the host is already in the card's header; the picture keeps to the studio itself
92 const comfy = d.comfy ? (d.comfy.up ? 'comfy up' : 'comfy down') : ''
93
94 // 30 squares per row; they shrink so a batch of 220 songs still fits. Past MAX_SQUARES only the
95 // newest are drawn, so the squares keep a size and the SVG stays under the element's size limit.
96 const all = typeof b.strip === 'string' ? b.strip : ''
97 const strip = all.slice(-MAX_SQUARES)
98 const [x0, y0, per] = [120, 6, 30]
99 const rows = Math.max(1, Math.ceil(strip.length / per))
100 const step = Math.min(16, 86 / rows)
101 const size = step - 2
102 ;[...strip].forEach((c, i) => {
103 const [x, y] = [x0 + (i % per) * step, y0 + Math.floor(i / per) * step]
104 body += c === 'e'
105 ? `<rect x="${(x + 0.5).toFixed(1)}" y="${(y + 0.5).toFixed(1)}" width="${(size - 1).toFixed(1)}" height="${(size - 1).toFixed(1)}" rx="1" fill="none" stroke="var(--reject)"/>`
106 : `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${size.toFixed(1)}" height="${size.toFixed(1)}" rx="1" fill="${STRIP_COLOR[c] ?? 'var(--line)'}"/>`
107 })
108 if (!strip) body += `<text class="m" x="${x0}" y="40" font-size="11">no songs yet</text>`
109 if (all.length > strip.length) body += `<text class="m" x="${W}" y="${H - 18}" font-size="9" text-anchor="end">newest ${strip.length} of ${all.length}</text>`
110 const legend: [string, string][] = [['ok', 'var(--ok)'], ['to hear', 'var(--listen)'], ['rejected', 'var(--reject)']]
111 legend.forEach(([name, fill], i) => {
112 body += `<rect x="${x0 + i * 78}" y="${H - 12}" width="8" height="8" rx="1" fill="${fill}"/>` +
113 `<text class="m" x="${x0 + 12 + i * 78}" y="${H - 4}" font-size="12">${name}</text>`
114 })
115 const pace = b.avg_gen_sec ? `${Math.round(b.avg_gen_sec / 60)} min / song` : ''
116 const right = [comfy, pace].filter(Boolean).join(' · ')
117 if (right) body += `<text class="m px" x="${W}" y="${H - 4}" font-size="12" text-anchor="end">${esc(right)}</text>`
118 return {
119 height: H,
120 alt: `${b.name}: ${b.ok ?? 0} ok, ${b.needs_listen ?? 0} to hear, ${b.rejected ?? 0} rejected`,
121 source: svg(H, body),
122 }
123}
124
125export const STAGES: readonly Stage[] = ['plan', 'clips', 'voice', 'compose', 'final', 'done']
126// The two review gates only you can pass.
127const GATE: ReadonlySet<Stage> = new Set(['plan', 'final'])
128
129export function byStage(jobs: VideoJob[]): Record<Stage, VideoJob[]> {
130 const out = Object.fromEntries(STAGES.map(st => [st, [] as VideoJob[]])) as Record<Stage, VideoJob[]>
131 for (const j of jobs) (out[j.stage] ?? out.plan).push(j)
132 return out
133}
134
135// One column per stage, a chip per job; the review gates have a dashed outline.
136function videoPicture(s: Status): Picture | null {
137 const d = (s.detail ?? {}) as VideoDetail
138 const jobs = d.jobs ?? []
139 if (!jobs.length) return null
140 const lanes = byStage(jobs)
141 const colW = W / STAGES.length
142 const shown = 4
143 const H = 36 + shown * 20 + 30
144 let body = ''
145 STAGES.forEach((st, i) => {
146 const x = i * colW
147 body += `<rect x="${x + 2}" y="0.5" width="${colW - 4}" height="${H - 31}" rx="6" fill="var(--card)" stroke="var(--line)"${GATE.has(st) ? ' stroke-dasharray="4 3"' : ''}/>`
148 body += `<text class="m px" x="${x + 10}" y="16" font-size="11">${st.toUpperCase()}${GATE.has(st) ? ' · YOU' : ''}</text>`
149 const list = lanes[st]
150 list.slice(0, shown).forEach((j, k) => {
151 const name = j.job.split('/').pop() ?? j.job
152 body += `<text class="${st === 'done' ? 'm' : 't'}" x="${x + 10}" y="${38 + k * 20}" font-size="14">${esc(cut(name, 12))}</text>`
153 })
154 if (list.length > shown) body += `<text class="m" x="${x + 10}" y="${38 + shown * 20}" font-size="12">+${list.length - shown} more</text>`
155 })
156 const spend = d.spend_usd ?? 0
157 const cap = d.cap_usd ?? 0
158 if (cap > 0) {
159 body += bar(0, H - 14, W - 190, spend / cap, spend / cap > 0.8 ? 'var(--reject)' : 'var(--ok)')
160 body += `<text class="m px" x="${W}" y="${H - 8}" font-size="13" text-anchor="end">spent $${spend.toFixed(2)} of $${cap.toFixed(2)}</text>`
161 }
162 const waiting = jobs.filter(j => GATE.has(j.stage)).length
163 return {
164 height: H,
165 alt: `${jobs.length} jobs, ${waiting} at a review gate; spent $${spend.toFixed(2)} of $${cap.toFixed(2)}`,
166 source: svg(H, body),
167 }
168}
169
170// The same details as text, for surfaces without Svg (the terminal).
171export function detailLines(s: Status): string[] {
172 if (s.pipeline === 'music') {
173 const d = (s.detail ?? {}) as MusicDetail
174 const b = d.batches?.[0]
175 if (!b) return []
176 const failed = b.errors ? ` · failed ${b.errors}` : ''
177 const comfy = d.comfy ? ` · comfy ${d.comfy.up ? 'up' : 'down'}` : ''
178 return [`${b.name}: made ${b.generated ?? 0} · ok ${b.ok ?? 0} · to hear ${b.needs_listen ?? 0} · rejected ${b.rejected ?? 0}${failed}${comfy}`]
179 }
180 if (s.pipeline === 'ai-video') {
181 const d = (s.detail ?? {}) as VideoDetail
182 const jobs = d.jobs ?? []
183 if (!jobs.length) return []
184 const lanes = byStage(jobs)
185 const lines = STAGES.filter(st => lanes[st].length).map(st =>
186 `${st}${GATE.has(st) ? ' (you)' : ''}: ${lanes[st].map(j => j.job.split('/').pop() ?? j.job).join(', ')}`)
187 if (d.cap_usd) lines.push(`spent $${(d.spend_usd ?? 0).toFixed(2)} of $${d.cap_usd.toFixed(2)}`)
188 return lines
189 }
190 return []
191}
192
193export function picture(s: Status, isStale: boolean): Picture | null {
194 if (s.pipeline === 'music') return musicPicture(s, isStale) ?? progressPicture(s)
195 if (s.pipeline === 'ai-video') return videoPicture(s) ?? progressPicture(s)
196 return progressPicture(s)
197}
198hooks/format.ts 112 lines1// Reading one status file (FORMAT.md). Pure functions, so the tests need no engine.
2import type { Snapshot, State, Status } from '../types'
3
4const STATES: readonly State[] = ['running', 'idle', 'waiting', 'error']
5
6const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
7
8// Only what the card needs is checked; a pipeline can add any field it likes.
9export function parseStatus(text: string): Status {
10 let v: unknown
11 try {
12 v = JSON.parse(text)
13 } catch {
14 throw new Error('not json (maybe being written; next refresh reads it again)')
15 }
16 if (!isObject(v) || v.format !== 'pipeline-status') throw new Error('no "format": "pipeline-status" (an older status file?)')
17 if (v.version !== 1) throw new Error(`format version ${String(v.version)}; this mod reads version 1`)
18 if (typeof v.pipeline !== 'string' || !v.pipeline) throw new Error('no "pipeline"')
19 if (typeof v.updated !== 'string' || Number.isNaN(Date.parse(v.updated))) throw new Error('"updated" is not a time')
20 if (!STATES.includes(v.state as State)) throw new Error(`"state" must be one of ${STATES.join(', ')}`)
21 if (typeof v.headline !== 'string') throw new Error('no "headline"')
22 if (v.waiting != null && !Array.isArray(v.waiting)) throw new Error('"waiting" is not a list')
23 // Optional fields of the wrong type are dropped, so one odd field can't break the sorting or the drawing.
24 const str = (x: unknown) => (typeof x === 'string' ? x : undefined)
25 const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : undefined)
26 const p = isObject(v.progress) ? v.progress : null
27 const last = isObject(v.last_output) && typeof v.last_output.label === 'string' ? v.last_output : null
28 return {
29 ...(v as unknown as Status),
30 title: str(v.title),
31 host: str(v.host),
32 stale_after_sec: num(v.stale_after_sec),
33 next: str(v.next) ?? null,
34 progress: p && num(p.done) != null
35 ? { done: num(p.done)!, total: num(p.total) ?? null, ...(str(p.unit) ? { unit: str(p.unit) } : {}) }
36 : null,
37 last_output: last
38 ? { label: last.label as string, ...(str(last.at) ? { at: str(last.at) } : {}), ...(str(last.path) ? { path: str(last.path) } : {}) }
39 : null,
40 waiting: ((v.waiting as unknown[] | undefined) ?? []).filter(isObject).map(w => ({
41 id: String(w.id ?? ''),
42 text: String(w.text ?? ''),
43 ...(typeof w.since === 'string' ? { since: w.since } : {}),
44 })),
45 }
46}
47
48// Quiet for longer than the writer promised. A file without stale_after_sec is never stale.
49export function isStale(s: Status, now: number): boolean {
50 return s.stale_after_sec != null && now - Date.parse(s.updated) > s.stale_after_sec * 1000
51}
52
53// Cards that need you come first: waiting, then error, then running, then idle; ties by title.
54const ORDER: Record<State, number> = { waiting: 0, error: 1, running: 2, idle: 3 }
55export function sortCards(cards: Status[]): Status[] {
56 return [...cards].sort((a, b) => ORDER[a.state] - ORDER[b.state] || titleOf(a).localeCompare(titleOf(b)))
57}
58
59export const titleOf = (s: Status) => s.title || s.pipeline
60
61export function ago(ms: number): string {
62 const min = Math.round(ms / 60_000)
63 if (min < 1) return 'just now'
64 if (min < 60) return `${min}m ago`
65 const h = Math.floor(min / 60)
66 if (h < 48) return `${h}h ${min % 60}m ago`
67 return `${Math.floor(h / 24)}d ago`
68}
69
70// A text progress bar for surfaces without Svg: ▓▓▓▓░░░░░░ 42/70 usable songs
71export function textBar(p: NonNullable<Status['progress']>, width = 10): string {
72 const unit = p.unit ? ` ${p.unit}` : ''
73 if (!p.total) return `${p.done}${unit}`
74 const filled = Math.round(Math.max(0, Math.min(1, p.done / p.total)) * width)
75 return `${'▓'.repeat(filled)}${'░'.repeat(width - filled)} ${p.done}/${p.total}${unit}`
76}
77
78// "~/x" → "<home>/x"; the folder setting is typed by hand.
79export function expandHome(path: string, home: string | undefined): string {
80 return home && (path === '~' || path.startsWith('~/')) ? home + path.slice(1) : path
81}
82
83// What a card needs from you right now: its state, or "quiet" when the writer went silent.
84const markOf = (s: Status, now: number) => (isStale(s, now) ? 'quiet' : s.state)
85const ALERT = new Set(['waiting', 'error', 'quiet'])
86
87// The lines to tell the person, comparing the last read with this one: a card that turned
88// waiting, error or quiet, or new waiting items on a card that was already waiting.
89// No previous read (the pane just started) is a baseline, not news.
90export function alerts(prev: Snapshot | null, next: Snapshot): string[] {
91 if (!prev) return []
92 const before = new Map(prev.cards.map(s => [s.pipeline, s]))
93 const out: string[] = []
94 for (const s of next.cards) {
95 const was = before.get(s.pipeline)
96 const mark = markOf(s, next.loadedAt)
97 const wasMark = was ? markOf(was, prev.loadedAt) : undefined
98 const items = s.waiting ?? []
99 if (ALERT.has(mark) && mark !== wasMark) {
100 const why = mark === 'quiet'
101 ? `no update for ${ago(next.loadedAt - Date.parse(s.updated)).replace(' ago', '')}; is ${s.host ?? 'the computer'} asleep?`
102 : mark === 'waiting' && items[0] ? items[0].text : s.headline
103 out.push(`${titleOf(s)} · ${mark}: ${why}`)
104 } else if (mark === 'waiting') {
105 const seen = new Set((was?.waiting ?? []).map(w => w.id))
106 const fresh = items.filter(w => !seen.has(w.id))
107 if (fresh.length) out.push(`${titleOf(s)} · new: ${fresh[0]!.text}${fresh.length > 1 ? ` (+${fresh.length - 1} more)` : ''}`)
108 }
109 }
110 return out
111}
112hooks/puppy.ts 7 lines1// Generated from nonbiri1004/baibai-brand src/sprite.ts by `npm run export`. Do not edit; change baibai-brand and sync.
2// 34×26 frames (the 32×24 grid plus its outline), one SVG rect per horizontal run.
3export const sit = '<rect x="17" y="0" width="5" height="1" fill="#2b2230"/><rect x="15" y="1" width="2" height="1" fill="#2b2230"/><rect x="17" y="1" width="5" height="1" fill="#a8501c"/><rect x="22" y="1" width="2" height="1" fill="#2b2230"/><rect x="14" y="2" width="1" height="1" fill="#2b2230"/><rect x="15" y="2" width="1" height="1" fill="#f6ecdc"/><rect x="16" y="2" width="1" height="1" fill="#a8501c"/><rect x="17" y="2" width="2" height="1" fill="#2b2230"/><rect x="19" y="2" width="3" height="1" fill="#f6ecdc"/><rect x="22" y="2" width="1" height="1" fill="#a8501c"/><rect x="23" y="2" width="1" height="1" fill="#f6ecdc"/><rect x="24" y="2" width="1" height="1" fill="#2b2230"/><rect x="14" y="3" width="1" height="1" fill="#2b2230"/><rect x="15" y="3" width="1" height="1" fill="#a8501c"/><rect x="16" y="3" width="9" height="1" fill="#f6ecdc"/><rect x="25" y="3" width="1" height="1" fill="#2b2230"/><rect x="14" y="4" width="1" height="1" fill="#2b2230"/><rect x="15" y="4" width="1" height="1" fill="#a8501c"/><rect x="16" y="4" width="10" height="1" fill="#f6ecdc"/><rect x="26" y="4" width="1" height="1" fill="#2b2230"/><rect x="13" y="5" width="1" height="1" fill="#2b2230"/><rect x="14" y="5" width="4" height="1" fill="#f08a3c"/><rect x="18" y="5" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="5" width="1" height="1" fill="#2b2230"/><rect x="13" y="6" width="1" height="1" fill="#2b2230"/><rect x="14" y="6" width="1" height="1" fill="#a8501c"/><rect x="15" y="6" width="3" height="1" fill="#f08a3c"/><rect x="18" y="6" width="4" height="1" fill="#f6ecdc"/><rect x="22" y="6" width="1" height="1" fill="#1c1614"/><rect x="23" y="6" width="4" height="1" fill="#f6ecdc"/><rect x="27" y="6" width="1" height="1" fill="#2b2230"/><rect x="13" y="7" width="1" height="1" fill="#2b2230"/><rect x="14" y="7" width="1" height="1" fill="#a8501c"/><rect x="15" y="7" width="3" height="1" fill="#f08a3c"/><rect x="18" y="7" width="4" height="1" fill="#f6ecdc"/><rect x="22" y="7" width="1" height="1" fill="#1c1614"/><rect x="23" y="7" width="4" height="1" fill="#f6ecdc"/><rect x="27" y="7" width="1" height="1" fill="#2b2230"/><rect x="13" y="8" width="1" height="1" fill="#2b2230"/><rect x="14" y="8" width="1" height="1" fill="#a8501c"/><rect x="15" y="8" width="3" height="1" fill="#f08a3c"/><rect x="18" y="8" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="8" width="2" height="1" fill="#2b2230"/><rect x="13" y="9" width="1" height="1" fill="#2b2230"/><rect x="14" y="9" width="4" height="1" fill="#f08a3c"/><rect x="18" y="9" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="9" width="2" height="1" fill="#1c1614"/><rect x="30" y="9" width="1" height="1" fill="#2b2230"/><rect x="9" y="10" width="5" height="1" fill="#2b2230"/><rect x="14" y="10" width="5" height="1" fill="#dba77a"/><rect x="19" y="10" width="10" height="1" fill="#f6ecdc"/><rect x="29" y="10" width="1" height="1" fill="#2b2230"/><rect x="7" y="11" width="2" height="1" fill="#2b2230"/><rect x="9" y="11" width="5" height="1" fill="#f6ecdc"/><rect x="14" y="11" width="5" height="1" fill="#dba77a"/><rect x="19" y="11" width="9" height="1" fill="#f6ecdc"/><rect x="28" y="11" width="1" height="1" fill="#2b2230"/><rect x="6" y="12" width="1" height="1" fill="#2b2230"/><rect x="7" y="12" width="7" height="1" fill="#f6ecdc"/><rect x="14" y="12" width="2" height="1" fill="#c08a5c"/><rect x="16" y="12" width="2" height="1" fill="#dba77a"/><rect x="18" y="12" width="7" height="1" fill="#f6ecdc"/><rect x="25" y="12" width="1" height="1" fill="#e8877a"/><rect x="26" y="12" width="2" height="1" fill="#2b2230"/><rect x="5" y="13" width="1" height="1" fill="#2b2230"/><rect x="6" y="13" width="18" height="1" fill="#f6ecdc"/><rect x="24" y="13" width="2" height="1" fill="#2b2230"/><rect x="5" y="14" width="1" height="1" fill="#2b2230"/><rect x="6" y="14" width="13" height="1" fill="#f6ecdc"/><rect x="19" y="14" width="1" height="1" fill="#2b2230"/><rect x="20" y="14" width="3" height="1" fill="#f6ecdc"/><rect x="23" y="14" width="1" height="1" fill="#2b2230"/><rect x="4" y="15" width="1" height="1" fill="#2b2230"/><rect x="5" y="15" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="15" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="15" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="15" width="1" height="1" fill="#2b2230"/><rect x="20" y="15" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="15" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="15" width="1" height="1" fill="#2b2230"/><rect x="4" y="16" width="1" height="1" fill="#2b2230"/><rect x="5" y="16" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="16" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="16" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="16" width="1" height="1" fill="#2b2230"/><rect x="20" y="16" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="16" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="16" width="1" height="1" fill="#2b2230"/><rect x="3" y="17" width="2" height="1" fill="#2b2230"/><rect x="5" y="17" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="17" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="17" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="17" width="1" height="1" fill="#2b2230"/><rect x="20" y="17" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="17" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="17" width="1" height="1" fill="#2b2230"/><rect x="2" y="18" width="1" height="1" fill="#2b2230"/><rect x="3" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="5" y="18" width="1" height="1" fill="#2b2230"/><rect x="6" y="18" width="10" height="1" fill="#f6ecdc"/><rect x="16" y="18" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="18" width="1" height="1" fill="#2b2230"/><rect x="20" y="18" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="18" width="1" height="1" fill="#2b2230"/><rect x="1" y="19" width="1" height="1" fill="#2b2230"/><rect x="2" y="19" width="6" height="1" fill="#f6ecdc"/><rect x="8" y="19" width="3" height="1" fill="#dcc7a8"/><rect x="11" y="19" width="5" height="1" fill="#f6ecdc"/><rect x="16" y="19" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="19" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="19" width="1" height="1" fill="#2b2230"/><rect x="20" y="19" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="19" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="19" width="1" height="1" fill="#2b2230"/><rect x="1" y="20" width="1" height="1" fill="#2b2230"/><rect x="2" y="20" width="15" height="1" fill="#dcc7a8"/><rect x="17" y="20" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="20" width="1" height="1" fill="#2b2230"/><rect x="20" y="20" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="20" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="20" width="1" height="1" fill="#2b2230"/><rect x="2" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="21" width="2" height="1" fill="#dcc7a8"/><rect x="5" y="21" width="1" height="1" fill="#2b2230"/><rect x="6" y="21" width="13" height="1" fill="#dcc7a8"/><rect x="19" y="21" width="1" height="1" fill="#2b2230"/><rect x="20" y="21" width="3" height="1" fill="#dcc7a8"/><rect x="23" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="22" width="2" height="1" fill="#2b2230"/><rect x="6" y="22" width="2" height="1" fill="#2b2230"/><rect x="8" y="22" width="6" height="1" fill="#dcc7a8"/><rect x="14" y="22" width="5" height="1" fill="#2b2230"/><rect x="20" y="22" width="3" height="1" fill="#2b2230"/><rect x="8" y="23" width="6" height="1" fill="#2b2230"/>'
4export const sitBlink = '<rect x="17" y="0" width="5" height="1" fill="#2b2230"/><rect x="15" y="1" width="2" height="1" fill="#2b2230"/><rect x="17" y="1" width="5" height="1" fill="#a8501c"/><rect x="22" y="1" width="2" height="1" fill="#2b2230"/><rect x="14" y="2" width="1" height="1" fill="#2b2230"/><rect x="15" y="2" width="1" height="1" fill="#f6ecdc"/><rect x="16" y="2" width="1" height="1" fill="#a8501c"/><rect x="17" y="2" width="2" height="1" fill="#2b2230"/><rect x="19" y="2" width="3" height="1" fill="#f6ecdc"/><rect x="22" y="2" width="1" height="1" fill="#a8501c"/><rect x="23" y="2" width="1" height="1" fill="#f6ecdc"/><rect x="24" y="2" width="1" height="1" fill="#2b2230"/><rect x="14" y="3" width="1" height="1" fill="#2b2230"/><rect x="15" y="3" width="1" height="1" fill="#a8501c"/><rect x="16" y="3" width="9" height="1" fill="#f6ecdc"/><rect x="25" y="3" width="1" height="1" fill="#2b2230"/><rect x="14" y="4" width="1" height="1" fill="#2b2230"/><rect x="15" y="4" width="1" height="1" fill="#a8501c"/><rect x="16" y="4" width="10" height="1" fill="#f6ecdc"/><rect x="26" y="4" width="1" height="1" fill="#2b2230"/><rect x="13" y="5" width="1" height="1" fill="#2b2230"/><rect x="14" y="5" width="4" height="1" fill="#f08a3c"/><rect x="18" y="5" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="5" width="1" height="1" fill="#2b2230"/><rect x="13" y="6" width="1" height="1" fill="#2b2230"/><rect x="14" y="6" width="1" height="1" fill="#a8501c"/><rect x="15" y="6" width="3" height="1" fill="#f08a3c"/><rect x="18" y="6" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="6" width="1" height="1" fill="#2b2230"/><rect x="13" y="7" width="1" height="1" fill="#2b2230"/><rect x="14" y="7" width="1" height="1" fill="#a8501c"/><rect x="15" y="7" width="3" height="1" fill="#f08a3c"/><rect x="18" y="7" width="3" height="1" fill="#f6ecdc"/><rect x="21" y="7" width="2" height="1" fill="#1c1614"/><rect x="23" y="7" width="4" height="1" fill="#f6ecdc"/><rect x="27" y="7" width="1" height="1" fill="#2b2230"/><rect x="13" y="8" width="1" height="1" fill="#2b2230"/><rect x="14" y="8" width="1" height="1" fill="#a8501c"/><rect x="15" y="8" width="3" height="1" fill="#f08a3c"/><rect x="18" y="8" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="8" width="2" height="1" fill="#2b2230"/><rect x="13" y="9" width="1" height="1" fill="#2b2230"/><rect x="14" y="9" width="4" height="1" fill="#f08a3c"/><rect x="18" y="9" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="9" width="2" height="1" fill="#1c1614"/><rect x="30" y="9" width="1" height="1" fill="#2b2230"/><rect x="9" y="10" width="5" height="1" fill="#2b2230"/><rect x="14" y="10" width="5" height="1" fill="#dba77a"/><rect x="19" y="10" width="10" height="1" fill="#f6ecdc"/><rect x="29" y="10" width="1" height="1" fill="#2b2230"/><rect x="7" y="11" width="2" height="1" fill="#2b2230"/><rect x="9" y="11" width="5" height="1" fill="#f6ecdc"/><rect x="14" y="11" width="5" height="1" fill="#dba77a"/><rect x="19" y="11" width="9" height="1" fill="#f6ecdc"/><rect x="28" y="11" width="1" height="1" fill="#2b2230"/><rect x="6" y="12" width="1" height="1" fill="#2b2230"/><rect x="7" y="12" width="7" height="1" fill="#f6ecdc"/><rect x="14" y="12" width="2" height="1" fill="#c08a5c"/><rect x="16" y="12" width="2" height="1" fill="#dba77a"/><rect x="18" y="12" width="7" height="1" fill="#f6ecdc"/><rect x="25" y="12" width="3" height="1" fill="#2b2230"/><rect x="5" y="13" width="1" height="1" fill="#2b2230"/><rect x="6" y="13" width="18" height="1" fill="#f6ecdc"/><rect x="24" y="13" width="1" height="1" fill="#2b2230"/><rect x="5" y="14" width="1" height="1" fill="#2b2230"/><rect x="6" y="14" width="13" height="1" fill="#f6ecdc"/><rect x="19" y="14" width="1" height="1" fill="#2b2230"/><rect x="20" y="14" width="3" height="1" fill="#f6ecdc"/><rect x="23" y="14" width="1" height="1" fill="#2b2230"/><rect x="4" y="15" width="1" height="1" fill="#2b2230"/><rect x="5" y="15" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="15" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="15" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="15" width="1" height="1" fill="#2b2230"/><rect x="20" y="15" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="15" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="15" width="1" height="1" fill="#2b2230"/><rect x="4" y="16" width="1" height="1" fill="#2b2230"/><rect x="5" y="16" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="16" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="16" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="16" width="1" height="1" fill="#2b2230"/><rect x="20" y="16" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="16" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="16" width="1" height="1" fill="#2b2230"/><rect x="3" y="17" width="2" height="1" fill="#2b2230"/><rect x="5" y="17" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="17" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="17" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="17" width="1" height="1" fill="#2b2230"/><rect x="20" y="17" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="17" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="17" width="1" height="1" fill="#2b2230"/><rect x="2" y="18" width="1" height="1" fill="#2b2230"/><rect x="3" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="5" y="18" width="1" height="1" fill="#2b2230"/><rect x="6" y="18" width="10" height="1" fill="#f6ecdc"/><rect x="16" y="18" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="18" width="1" height="1" fill="#2b2230"/><rect x="20" y="18" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="18" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="18" width="1" height="1" fill="#2b2230"/><rect x="1" y="19" width="1" height="1" fill="#2b2230"/><rect x="2" y="19" width="6" height="1" fill="#f6ecdc"/><rect x="8" y="19" width="3" height="1" fill="#dcc7a8"/><rect x="11" y="19" width="5" height="1" fill="#f6ecdc"/><rect x="16" y="19" width="1" height="1" fill="#dcc7a8"/><rect x="17" y="19" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="19" width="1" height="1" fill="#2b2230"/><rect x="20" y="19" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="19" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="19" width="1" height="1" fill="#2b2230"/><rect x="1" y="20" width="1" height="1" fill="#2b2230"/><rect x="2" y="20" width="15" height="1" fill="#dcc7a8"/><rect x="17" y="20" width="2" height="1" fill="#f6ecdc"/><rect x="19" y="20" width="1" height="1" fill="#2b2230"/><rect x="20" y="20" width="1" height="1" fill="#dcc7a8"/><rect x="21" y="20" width="2" height="1" fill="#f6ecdc"/><rect x="23" y="20" width="1" height="1" fill="#2b2230"/><rect x="2" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="21" width="2" height="1" fill="#dcc7a8"/><rect x="5" y="21" width="1" height="1" fill="#2b2230"/><rect x="6" y="21" width="13" height="1" fill="#dcc7a8"/><rect x="19" y="21" width="1" height="1" fill="#2b2230"/><rect x="20" y="21" width="3" height="1" fill="#dcc7a8"/><rect x="23" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="22" width="2" height="1" fill="#2b2230"/><rect x="6" y="22" width="2" height="1" fill="#2b2230"/><rect x="8" y="22" width="6" height="1" fill="#dcc7a8"/><rect x="14" y="22" width="5" height="1" fill="#2b2230"/><rect x="20" y="22" width="3" height="1" fill="#2b2230"/><rect x="8" y="23" width="6" height="1" fill="#2b2230"/>'
5export const sleep0 = '<rect x="19" y="7" width="5" height="1" fill="#2b2230"/><rect x="17" y="8" width="2" height="1" fill="#2b2230"/><rect x="19" y="8" width="5" height="1" fill="#a8501c"/><rect x="24" y="8" width="1" height="1" fill="#2b2230"/><rect x="16" y="9" width="1" height="1" fill="#2b2230"/><rect x="17" y="9" width="1" height="1" fill="#f6ecdc"/><rect x="18" y="9" width="1" height="1" fill="#a8501c"/><rect x="19" y="9" width="2" height="1" fill="#2b2230"/><rect x="21" y="9" width="3" height="1" fill="#f6ecdc"/><rect x="24" y="9" width="1" height="1" fill="#a8501c"/><rect x="25" y="9" width="2" height="1" fill="#2b2230"/><rect x="16" y="10" width="1" height="1" fill="#2b2230"/><rect x="17" y="10" width="1" height="1" fill="#a8501c"/><rect x="18" y="10" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="10" width="1" height="1" fill="#2b2230"/><rect x="16" y="11" width="1" height="1" fill="#2b2230"/><rect x="17" y="11" width="1" height="1" fill="#a8501c"/><rect x="18" y="11" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="11" width="1" height="1" fill="#2b2230"/><rect x="15" y="12" width="1" height="1" fill="#2b2230"/><rect x="16" y="12" width="1" height="1" fill="#f6ecdc"/><rect x="17" y="12" width="2" height="1" fill="#f08a3c"/><rect x="19" y="12" width="9" height="1" fill="#f6ecdc"/><rect x="28" y="12" width="1" height="1" fill="#2b2230"/><rect x="15" y="13" width="1" height="1" fill="#2b2230"/><rect x="16" y="13" width="1" height="1" fill="#a8501c"/><rect x="17" y="13" width="3" height="1" fill="#f08a3c"/><rect x="20" y="13" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="13" width="1" height="1" fill="#2b2230"/><rect x="8" y="14" width="8" height="1" fill="#2b2230"/><rect x="16" y="14" width="1" height="1" fill="#a8501c"/><rect x="17" y="14" width="3" height="1" fill="#f08a3c"/><rect x="20" y="14" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="14" width="1" height="1" fill="#2b2230"/><rect x="6" y="15" width="2" height="1" fill="#2b2230"/><rect x="8" y="15" width="8" height="1" fill="#f6ecdc"/><rect x="16" y="15" width="1" height="1" fill="#a8501c"/><rect x="17" y="15" width="3" height="1" fill="#f08a3c"/><rect x="20" y="15" width="3" height="1" fill="#f6ecdc"/><rect x="23" y="15" width="2" height="1" fill="#1c1614"/><rect x="25" y="15" width="4" height="1" fill="#f6ecdc"/><rect x="29" y="15" width="3" height="1" fill="#2b2230"/><rect x="5" y="16" width="1" height="1" fill="#2b2230"/><rect x="6" y="16" width="10" height="1" fill="#f6ecdc"/><rect x="16" y="16" width="4" height="1" fill="#f08a3c"/><rect x="20" y="16" width="10" height="1" fill="#f6ecdc"/><rect x="30" y="16" width="2" height="1" fill="#1c1614"/><rect x="32" y="16" width="1" height="1" fill="#2b2230"/><rect x="4" y="17" width="1" height="1" fill="#2b2230"/><rect x="5" y="17" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="17" width="1" height="1" fill="#dba77a"/><rect x="17" y="17" width="2" height="1" fill="#f08a3c"/><rect x="19" y="17" width="2" height="1" fill="#dba77a"/><rect x="21" y="17" width="10" height="1" fill="#f6ecdc"/><rect x="31" y="17" width="1" height="1" fill="#2b2230"/><rect x="3" y="18" width="1" height="1" fill="#2b2230"/><rect x="4" y="18" width="12" height="1" fill="#f6ecdc"/><rect x="16" y="18" width="5" height="1" fill="#dba77a"/><rect x="21" y="18" width="9" height="1" fill="#f6ecdc"/><rect x="30" y="18" width="1" height="1" fill="#2b2230"/><rect x="3" y="19" width="1" height="1" fill="#2b2230"/><rect x="4" y="19" width="12" height="1" fill="#f6ecdc"/><rect x="16" y="19" width="2" height="1" fill="#c08a5c"/><rect x="18" y="19" width="2" height="1" fill="#dba77a"/><rect x="20" y="19" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="19" width="1" height="1" fill="#2b2230"/><rect x="2" y="20" width="1" height="1" fill="#2b2230"/><rect x="3" y="20" width="14" height="1" fill="#f6ecdc"/><rect x="17" y="20" width="2" height="1" fill="#dba77a"/><rect x="19" y="20" width="8" height="1" fill="#f6ecdc"/><rect x="27" y="20" width="3" height="1" fill="#2b2230"/><rect x="2" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="21" width="17" height="1" fill="#dcc7a8"/><rect x="20" y="21" width="1" height="1" fill="#2b2230"/><rect x="21" y="21" width="3" height="1" fill="#f6ecdc"/><rect x="24" y="21" width="6" height="1" fill="#dcc7a8"/><rect x="30" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="22" width="7" height="1" fill="#2b2230"/><rect x="10" y="22" width="7" height="1" fill="#dcc7a8"/><rect x="17" y="22" width="4" height="1" fill="#2b2230"/><rect x="21" y="22" width="9" height="1" fill="#dcc7a8"/><rect x="30" y="22" width="1" height="1" fill="#2b2230"/><rect x="10" y="23" width="7" height="1" fill="#2b2230"/><rect x="21" y="23" width="9" height="1" fill="#2b2230"/>'
6export const sleep1 = '<rect x="19" y="7" width="5" height="1" fill="#2b2230"/><rect x="17" y="8" width="2" height="1" fill="#2b2230"/><rect x="19" y="8" width="5" height="1" fill="#a8501c"/><rect x="24" y="8" width="1" height="1" fill="#2b2230"/><rect x="16" y="9" width="1" height="1" fill="#2b2230"/><rect x="17" y="9" width="1" height="1" fill="#f6ecdc"/><rect x="18" y="9" width="1" height="1" fill="#a8501c"/><rect x="19" y="9" width="2" height="1" fill="#2b2230"/><rect x="21" y="9" width="3" height="1" fill="#f6ecdc"/><rect x="24" y="9" width="1" height="1" fill="#a8501c"/><rect x="25" y="9" width="2" height="1" fill="#2b2230"/><rect x="16" y="10" width="1" height="1" fill="#2b2230"/><rect x="17" y="10" width="1" height="1" fill="#a8501c"/><rect x="18" y="10" width="9" height="1" fill="#f6ecdc"/><rect x="27" y="10" width="1" height="1" fill="#2b2230"/><rect x="16" y="11" width="1" height="1" fill="#2b2230"/><rect x="17" y="11" width="1" height="1" fill="#a8501c"/><rect x="18" y="11" width="10" height="1" fill="#f6ecdc"/><rect x="28" y="11" width="1" height="1" fill="#2b2230"/><rect x="15" y="12" width="1" height="1" fill="#2b2230"/><rect x="16" y="12" width="1" height="1" fill="#f6ecdc"/><rect x="17" y="12" width="2" height="1" fill="#f08a3c"/><rect x="19" y="12" width="9" height="1" fill="#f6ecdc"/><rect x="28" y="12" width="1" height="1" fill="#2b2230"/><rect x="8" y="13" width="8" height="1" fill="#2b2230"/><rect x="16" y="13" width="1" height="1" fill="#a8501c"/><rect x="17" y="13" width="3" height="1" fill="#f08a3c"/><rect x="20" y="13" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="13" width="1" height="1" fill="#2b2230"/><rect x="6" y="14" width="2" height="1" fill="#2b2230"/><rect x="8" y="14" width="8" height="1" fill="#f6ecdc"/><rect x="16" y="14" width="1" height="1" fill="#a8501c"/><rect x="17" y="14" width="3" height="1" fill="#f08a3c"/><rect x="20" y="14" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="14" width="1" height="1" fill="#2b2230"/><rect x="5" y="15" width="1" height="1" fill="#2b2230"/><rect x="6" y="15" width="10" height="1" fill="#f6ecdc"/><rect x="16" y="15" width="1" height="1" fill="#a8501c"/><rect x="17" y="15" width="3" height="1" fill="#f08a3c"/><rect x="20" y="15" width="3" height="1" fill="#f6ecdc"/><rect x="23" y="15" width="2" height="1" fill="#1c1614"/><rect x="25" y="15" width="4" height="1" fill="#f6ecdc"/><rect x="29" y="15" width="3" height="1" fill="#2b2230"/><rect x="4" y="16" width="1" height="1" fill="#2b2230"/><rect x="5" y="16" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="16" width="4" height="1" fill="#f08a3c"/><rect x="20" y="16" width="10" height="1" fill="#f6ecdc"/><rect x="30" y="16" width="2" height="1" fill="#1c1614"/><rect x="32" y="16" width="1" height="1" fill="#2b2230"/><rect x="3" y="17" width="1" height="1" fill="#2b2230"/><rect x="4" y="17" width="12" height="1" fill="#f6ecdc"/><rect x="16" y="17" width="1" height="1" fill="#dba77a"/><rect x="17" y="17" width="2" height="1" fill="#f08a3c"/><rect x="19" y="17" width="2" height="1" fill="#dba77a"/><rect x="21" y="17" width="10" height="1" fill="#f6ecdc"/><rect x="31" y="17" width="1" height="1" fill="#2b2230"/><rect x="3" y="18" width="1" height="1" fill="#2b2230"/><rect x="4" y="18" width="12" height="1" fill="#f6ecdc"/><rect x="16" y="18" width="5" height="1" fill="#dba77a"/><rect x="21" y="18" width="9" height="1" fill="#f6ecdc"/><rect x="30" y="18" width="1" height="1" fill="#2b2230"/><rect x="3" y="19" width="2" height="1" fill="#2b2230"/><rect x="5" y="19" width="11" height="1" fill="#f6ecdc"/><rect x="16" y="19" width="2" height="1" fill="#c08a5c"/><rect x="18" y="19" width="2" height="1" fill="#dba77a"/><rect x="20" y="19" width="9" height="1" fill="#f6ecdc"/><rect x="29" y="19" width="1" height="1" fill="#2b2230"/><rect x="2" y="20" width="1" height="1" fill="#2b2230"/><rect x="3" y="20" width="14" height="1" fill="#f6ecdc"/><rect x="17" y="20" width="2" height="1" fill="#dba77a"/><rect x="19" y="20" width="8" height="1" fill="#f6ecdc"/><rect x="27" y="20" width="3" height="1" fill="#2b2230"/><rect x="2" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="21" width="17" height="1" fill="#dcc7a8"/><rect x="20" y="21" width="1" height="1" fill="#2b2230"/><rect x="21" y="21" width="3" height="1" fill="#f6ecdc"/><rect x="24" y="21" width="6" height="1" fill="#dcc7a8"/><rect x="30" y="21" width="1" height="1" fill="#2b2230"/><rect x="3" y="22" width="7" height="1" fill="#2b2230"/><rect x="10" y="22" width="7" height="1" fill="#dcc7a8"/><rect x="17" y="22" width="4" height="1" fill="#2b2230"/><rect x="21" y="22" width="9" height="1" fill="#dcc7a8"/><rect x="30" y="22" width="1" height="1" fill="#2b2230"/><rect x="10" y="23" width="7" height="1" fill="#2b2230"/><rect x="21" y="23" width="9" height="1" fill="#2b2230"/>'
7types/index.d.ts 68 lines1// Any JSON value (what a status file can hold).
2export type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
3
4// One status file, as FORMAT.md describes it (format "pipeline-status", version 1).
5export type State = 'running' | 'idle' | 'waiting' | 'error'
6
7export type WaitingItem = { id: string; text: string; since?: string }
8
9export type Status = {
10 format: 'pipeline-status'
11 version: 1
12 pipeline: string
13 title?: string
14 host?: string
15 updated: string
16 stale_after_sec?: number
17 state: State
18 headline: string
19 progress?: { done: number; total: number | null; unit?: string } | null
20 waiting?: WaitingItem[]
21 last_output?: { label: string; at?: string; path?: string } | null
22 next?: string | null
23 detail?: { [key: string]: Json } | null
24}
25
26// detail of pipeline "music" (baibai-music tools/status_export.py)
27export type MusicBatch = {
28 name: string
29 strip?: string
30 est_usable?: number | null
31 target_usable?: number | null
32 generated?: number | null
33 ok?: number | null
34 needs_listen?: number | null
35 rejected?: number | null
36 errors?: number | null
37 avg_gen_sec?: number | null
38 status_tail?: string[]
39}
40export type MusicDetail = {
41 comfy?: { up: boolean; running: number | null; pending: number | null }
42 generating?: boolean | null
43 batches?: MusicBatch[]
44}
45
46// detail of pipeline "ai-video" (ai-video-pipeline)
47export type Stage = 'plan' | 'clips' | 'voice' | 'compose' | 'final' | 'done'
48export type VideoJob = { job: string; stage: Stage; spend_usd: number; cap_usd: number; updated?: string; next?: string }
49export type VideoDetail = { spend_usd?: number; cap_usd?: number; jobs?: VideoJob[] }
50
51// A file in the folder that is not a card, and why.
52export type Problem = { file: string; reason: string }
53
54export type Snapshot = {
55 dir: string
56 cards: Status[]
57 problems: Problem[]
58 // the folder itself could not be read (missing, not set)
59 dirError: string | null
60 loadedAt: number
61}
62
63declare module 'claude-code' {
64 interface PluginState {
65 'puppy-console': { snapshot: Snapshot | null }
66 }
67}
68