Live band above the prompt for this session and its auto handoff, plus a plan and agents dashboard (/chip-dash)

For you, the owner of this Claude Code setup. Claude Code reads its setup from ~/.claude (the live dir). This repo keeps your own parts of that folder, names every downloaded skill, plugin and framework with where it came from, and keeps the retired wf runner as history in _archive/runner/. An AI working here reads AGENTS.md instead.
Each kind of file has one source of truth. The copy that wins is the one you edit; the other is overwritten.
| Kind | Repo folder | Live path | Source of truth | Who writes it | What moves it |
|---|---|---|---|---|---|
Copied (CLAUDE.md, settings*.json, agents/, rules/, rules-on-demand/, MAP.md, your hooks and scripts) | claude-home/ | ~/.claude/<same path> | live wins | you, in ~/.claude | tools/bin/sync.sh copies live into the repo; tools/bin/install.sh copies back on a new machine |
Linked (skills/, commands/ you wrote) | claude-home/skills/, claude-home/commands/ | ~/.claude/skills/<name> is a symlink (a pointer) into the repo | repo wins | you, in the repo | tools/bin/install.sh makes the links |
| Downloaded (skills from GitHub and packages) | tools/config/manifest.json, your edits in tools/config/patches/ | ~/.claude/skills/<name> | upstream plus your patch | their installer, then you | /claude-config update, after your yes per skill |
Runner (wf, next, retired 2026-10-21) | _archive/runner/ | none: it was copied to ~/.local/share/wf/ until it retired | repo wins | nobody; kept as history | nothing |
| Repo tools | tools/bin/, tools/scripts/ | none (a few are linked into ~/.local/bin) | repo wins | you, in the repo | nothing |
| Frameworks (gstack, jjstack, ECC commands, the JSM skills, plugins) | named in tools/config/manifest.json | their own folders in ~/.claude | their installer wins | their installer | their own installer; never copied here |
| Private (names, work rules, settings fragments, private skills and patches) | none | ~/.claude/private/ | live only | you | nothing; never in any repo |
Claude Code state (projects/, sessions/, history.jsonl, caches) | none | ~/.claude/... | Claude Code only | Claude Code | nothing; never synced |
claude-home/ mirrors ~/.claude: claude-home/rules/x.md installs to ~/.claude/rules/x.md. tools/config/ holds the manifest, patches, the routing table and the MCP config. Guides live in docs/guides/, specs (one per decision) in docs/specs/, the plan in docs/scope/, third party notices in docs/NOTICE.md.
Rules for one place only. Rules for this repo only go in CLAUDE.md, the entry file; AGENTS.md is a symlink to it, so a session in this repo loads the global instructions once and the entry file once. The home chat opens in ~/claude-config; its private rules are in CLAUDE.local.md (git ignored, spec 0067).
claude starts in a folder
|
+-- user memory ....... ~/.claude/CLAUDE.md (+ every @import in it, + ~/.claude/rules/*.md)
+-- project memory .... <folder>/CLAUDE.md or <folder>/.claude/CLAUDE.md, if present
+-- settings .......... ~/.claude/settings.json, then the folder's .claude/settings*.json
+-- hooks ............. scripts settings.json names, run on events
| (SessionStart, PreToolUse, Stop, ...)
+-- skills ............ ~/.claude/skills/<name>/SKILL.md: only the one line description
| loads; the body loads when the skill is invoked (/name or by match)
+-- skill rules ....... when a skill starts, hooks/skill-rules.py adds its files from
~/.claude/rules-on-demand/ (map.json); nothing else loads them
SKILL.md. Yours are linked from claude-home/skills/; the rest come from frameworks and downloads, all listed in docs/guides/SKILLS.md.tools/bin/sync.sh and the commit hooks refuse any of them reaching this public repo.| Guide | Kind | What it covers |
|---|---|---|
| SETUP.md | how to | Install, restore on a new machine, frameworks, downloaded skills, plugins, MCP servers, keeping it current |
| DAILY-USE.md | how to | The workflow chain, /claude-config, picking a skill, and the retired wf commands |
| SKILLS.md | reference | Every skill with its source and license, plus the routing table (generated) |
| HOOKS.md | reference | Each hook script, where it comes from, the settings it relies on |
| DESIGN-PIPELINE.md | how to | Setting up and running the design pipeline |
Also: docs/architecture.md (how the chat workflow, hooks, mods, workflows and the setup sync work) and docs/research/ (skills tried and kept or dropped, reviews).
Each rule below cost something on a real project.
| Rule | Where | Why it exists |
|---|---|---|
| An absent answer rendered as a definite one | claude-home/rules/ecc/common/code-review.md | A count that has not loaded is not zero; a failed fetch is not an empty list. One defaulted count switched off the guard on account deletion |
| Get the RED back when the test came second | claude-home/rules/ecc/common/testing.md | Bug fixes skip watching the test fail, so break the source, see the test go red, restore |
| A test that reimplements the logic passes forever | claude-home/rules/ecc/common/testing.md | A test with a local copy of the behaviour stays green when the real fix is reverted |
| A mock weaker than the real module passes anything | claude-home/rules/ecc/common/testing.md | A mock missing one field hid a control that could never render |
| Shell discipline | claude-home/CLAUDE.md | A stale cd looks like a deleted tree; $(...) does not expand in a quoted heredoc |
| Grep before writing against a symbol | claude-home/CLAUDE.md | A helper that reads plausibly may not exist |
The ECC language rules are not loaded on their own; DAILY-USE.md says how to load one per project.
Languages Markdown (skills, agents, rules), Bash, Python 3 stdlib, one Node ESM script
Key dependencies jq (tools/bin/sync.sh), fontTools (design font scripts), culori (build-ramp.mjs,
node_modules committed on purpose); no package manager at the root
Plugins frontend-design, code-review, feature-dev,
code-simplifier, ralph-loop, clangd-lsp, ponytail, security-guidance
Skill frameworks gstack, then jjstack on top;
ECC commands; the JSM skills; sentry skills; anthropic security review
Downloaded skills about 175, each with its source and license in tools/config/manifest.json
MCP servers context7 (library docs), plus the design MCPs in DESIGN-PIPELINE.md
tools/bin/install.sh --dry-run, then tools/bin/install.sh.~/.claude.json.hooks/register.tsx 365 lines1import type { Register } from 'claude-code'
2import { Band, HandoffRow, liveBuilds, type Facts } from '../band.tsx'
3import { handoffLine } from '../handoff.ts'
4import { parseOwed, parsePrs, prArgv } from '../now.ts'
5import { DEFAULT_VIEW, Dashboard, safeStep } from '../panes.tsx'
6import type { WfDashAsk, WfDashOwed } from '../types/index.d.ts'
7import { color, pane, space, time, width } from '../tokens.ts'
8
9// chip-dash: the band (spec 0059) reads this session and wf-handoff's files; the dashboard (spec 0060) reads plan.py and
10// offers two actions, each behind a confirm; its Now view (spec 0071) reads the catch state file and one gh search.
11// The drawing in band.tsx and panes.tsx is the locked look. Nothing here runs or reads the retired wf runner.
12
13const FACTS = { plugin: 'chip-dash', key: 'facts' } as const
14const EFFORT = { plugin: 'chip-dash', key: 'effort' } as const
15const PLAN = { plugin: 'chip-dash', key: 'plan' } as const
16const VIEW = { plugin: 'chip-dash', key: 'view' } as const
17const HANDOFF = { plugin: 'chip-dash', key: 'handoff' } as const
18const PRS = { plugin: 'chip-dash', key: 'prs' } as const
19const BUILD = { plugin: 'chip-dash', key: 'build' } as const
20const PANE = 'chip-dash'
21// P9: a prompt submitted from inside the confirming hook does not start a turn; from the clock after it returns, it does.
22// The host refuses $.prompt.submit of a text starting with "/" (it would run a command as the user), so the step goes
23// through $.command.run, which runs it as if typed, once the session is idle.
24const SUBMIT_AFTER = 1500
25// an effort level as the settings spell one
26const LEVEL = /^[a-z]{1,20}$/
27
28// shortcut: module values, so a hot reload forgets the dashboard is open until it is opened again; fine for a preview
29let isOpen = false
30let loadPanes = async (_force = false) => {}
31let loadPrs = async (_force = false) => {}
32// spec 0074 AC-12: running workflow agents, agentId -> spawn time, from agent.spawn and turn.complete. shortcut: a
33// module value, so a mod reload starts the count at 0 ($.agent.list() leaves workflow agents out); accepted in the spec
34const building = new Map<string, number>()
35
36async function countBuilds($: any) {
37 const n = liveBuilds(building, await $.clock.now())
38 if ((await $.state.get(BUILD)).value !== n) await $.state.set(BUILD, n)
39}
40
41function clock(ms: number) {
42 const d = new Date(ms)
43 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
44}
45
46// AC-5: no API lists background shells; their ids are only in the Bash result text (probe P8). One stopped by a
47// TaskStop (the model's, or this mod's, which the transcript does not hold) or named by a later task notice is left
48// out; the rest are only "started": one may have ended without the transcript saying so.
49// Security review: only a background Bash call's own result, at its start, so quoted output never adds a row.
50const SHELL_ID = /^Command running in background with ID:\s*([A-Za-z0-9_-]+)\./
51const stoppedShells = new Set<string>()
52// null when the transcript could not be read: the Now view then counts `agents ?`, never 0 shells (spec 0071 AC-6)
53async function shells($: any): Promise<any[] | null> {
54 const msgs = await $.session.messages().catch(() => null)
55 if (!Array.isArray(msgs)) return null
56 const found = new Map<string, any>()
57 for (const m of msgs) {
58 for (const u of m.toolUses ?? []) {
59 const id = u.tool === 'Bash' && u.input?.run_in_background === true ? SHELL_ID.exec(u.text ?? '')?.[1] : null
60 if (id) found.set(id, { id, description: String(u.input?.description || u.input?.command || id), type: 'shell', status: 'started' })
61 if (u.tool === 'TaskStop' && !u.isError) found.delete(String(u.input?.task_id))
62 }
63 if (m.role === 'user') for (const id of [...found.keys()]) if ((m.text ?? '').includes(`<task-id>${id}</task-id>`)) found.delete(id)
64 }
65 return [...found.values()].filter(s => !stoppedShells.has(s.id))
66}
67
68// AC-5: the two confirmed actions. Nothing else is reachable: no push, merge, delete, settings or spec edit.
69async function perform($: any, a: WfDashAsk): Promise<{ text: string; ok: boolean }> {
70 if (a.kind === 'start') {
71 if (!safeStep(a.text)) return { text: `not started, not on the safe list: ${a.text}`, ok: false }
72 const [, command, args = ''] = /^\/(\S+)\s*(.*)$/.exec(a.text)!
73 $.clock.after(SUBMIT_AFTER, () => void $.command.run({ command, args }).catch(() => {}))
74 return { text: `starting ${a.text}`, ok: true }
75 }
76 const r = await $.tool.call({ tool: 'TaskStop', task_id: a.id }).catch((err: any) => ({ deny: String(err?.message ?? err) }))
77 const ok = r && !('deny' in r) && !r.isError
78 if (ok) stoppedShells.add(a.id)
79 return ok ? { text: `stopped ${a.label}`, ok: true } : { text: `could not stop ${a.label}: ${r?.deny ?? r?.text ?? 'no answer'}`, ok: false }
80}
81
82async function openDashboard($: any) {
83 isOpen = true
84 void loadPanes(true)
85 await $.ui.open({ id: PANE, title: 'chip-dash', focus: true, closeOnEscape: true, rows: pane.rows })
86 // reopened on Now: Now opens, so the PR search runs (spec 0071 AC-4); after the open, so the engine lists the pane.
87 // On any other view it does nothing
88 void loadPrs(true)
89 return { text: 'chip-dash open: p n a switch views, esc closes' }
90}
91
92export const register: Register = on => {
93 on('session.start', async ($, e, next) => {
94 const started = await next(e)
95 let lastGood: Facts | null = null
96 let factsRead = false
97 let busy = false
98 const id = await $.session.id().catch(() => '')
99 const home = await $.env.get('HOME')
100 const handoffDir = `${home}/.claude/state/auto-handoff`
101 const catchDir = `${home}/.claude/state/chain-catch`
102 const panelPath = `${handoffDir}/panel.json`
103 let me = id
104 let prev: string | null = null
105
106 // /auto-handoff on or off, and the line: the chain file wf-handoff's save() writes, found the way its own
107 // session.start finds it (a handed off chat points at its chain's root). No file names this chat: null, never on.
108 const readAuto = async () => {
109 const root = (await $.fs.read(`${handoffDir}/${me}.root`).catch(() => '')).trim() || me
110 let c: any = null
111 try { c = JSON.parse(await $.fs.read(`${handoffDir}/${root}.json`)) } catch {}
112 return c && c.session === me ? { off: c.off === true, line: typeof c.line === 'number' ? c.line : null } : null
113 }
114
115 // Spec 0064 AC-2, AC-6: the wf-handoff mod's panel. A /clear keeps this module but changes the id, so the
116 // id before it is the chat this one came from. A panel another chat wrote keeps the last one that was ours.
117 const readPanel = async () => {
118 const now = await $.session.id().catch(() => me)
119 if (now && now !== me) { prev = me; me = now }
120 const raw = await $.fs.read(panelPath).catch(() => null)
121 let p: any = null
122 try { p = raw ? JSON.parse(raw) : null } catch {}
123 const { value: old } = await $.state.get(HANDOFF)
124 const mine = p && typeof p.session === 'string' && Array.isArray(p.steps) && (p.session === me || p.session === prev)
125 await $.state.set(HANDOFF, { panel: mine ? p : old?.panel ?? null, me, prev, dismissed: old?.dismissed ?? 0, frame: old?.frame ?? 0, auto: await readAuto() })
126 }
127
128 // Spec 0071 AC-2, AC-7: what this chat owes, from spec 0070's catch file under this chat's id, else the id it
129 // replaced after a /clear (readPanel keeps both). A check or a read that fails is unknown (`owed ?` and the
130 // error), never "not installed" or "nothing owed": it used to read a rejected exists as false (PR 164 review).
131 const readOwed = async (): Promise<WfDashOwed> => {
132 try {
133 if (!(await $.fs.exists(catchDir))) return { installed: false, lines: null, error: null }
134 for (const sid of [me, prev]) {
135 const path = `${catchDir}/${sid}.json`
136 if (!sid || !(await $.fs.exists(path))) continue
137 return parseOwed(await $.fs.read(path))
138 }
139 return { installed: true, lines: [], error: null }
140 } catch (err: any) {
141 return { installed: true, lines: null, error: String(err?.message ?? err) }
142 }
143 }
144
145 const refresh = async () => {
146 if (busy) return
147 busy = true
148 try {
149 await readPanel()
150 const owed = await readOwed()
151 const cwd = await $.session.cwd()
152 const [r, usage, model, setLevel] = await Promise.all([
153 $.process.run(['python3', `${$.plugin.root}/facts.py`, cwd]).catch(() => null),
154 $.session.usage().catch(() => null),
155 $.session.model().catch(() => null),
156 // the settings' effortLevel stands in until the first reply brings the real one (turn.step). Env vars are the
157 // parent shell's and showed `high` in a `medium` session: never read.
158 $.settings.read({ key: 'effortLevel' }).catch(() => null),
159 ])
160 let parsed: any = null
161 try { parsed = r && r.exitCode === 0 ? JSON.parse(r.stdout || 'null') : null } catch {}
162 // facts.py failing from the start must not hide the band: its fields show `?` until it first answers; only a
163 // failure after that keeps the last good facts, marked stale
164 if (parsed) factsRead = true
165 const raw = parsed ?? (factsRead ? null : {})
166 const limit = (kind: string) => usage?.rateLimits?.find((x: any) => x.kind === kind) ?? null
167 if (raw) {
168 lastGood = {
169 model,
170 effort: typeof setLevel === 'string' && LEVEL.test(setLevel) ? setLevel : null,
171 folder: raw.folder ?? null,
172 git: raw.git ?? null,
173 context: usage?.context?.percent ?? null,
174 contextTokens: usage?.context?.tokens ?? null,
175 contextWindow: usage?.context?.window ?? null,
176 fiveHour: limit('five_hour')?.percentUsed ?? null,
177 fiveHourReset: limit('five_hour')?.resetsAt ?? null,
178 week: limit('seven_day')?.percentUsed ?? null,
179 weekReset: limit('seven_day')?.resetsAt ?? null,
180 via: raw.via ?? null,
181 staleSince: null,
182 owed,
183 }
184 await $.state.set(FACTS, lastGood)
185 } else if (lastGood) {
186 // a failed tick keeps the last good facts, marked stale, never an empty band; the mark keeps its first time.
187 // The owed count is its own read, so it stays current while facts.py fails.
188 lastGood = { ...lastGood, owed, staleSince: lastGood.staleSince ?? clock(await $.clock.now()) }
189 await $.state.set(FACTS, lastGood)
190 }
191 } finally {
192 busy = false
193 }
194 }
195
196 // AC-2: never two plan.py calls at once (a tick is skipped while one runs); a failed or timed out call keeps the
197 // last good plan, marked with the error, never an empty one
198 let planBusy = false
199 loadPanes = async (force = false) => {
200 if (planBusy || !(force || isOpen)) return
201 planBusy = true
202 try {
203 const r = await $.process.run(['python3', `${$.plugin.root}/plan.py`]).catch(() => null)
204 let data = null
205 try {
206 data = r && r.exitCode === 0 ? JSON.parse(r.stdout) : null
207 } catch {}
208 const ok = !!data && Array.isArray(data.repos)
209 const { value: old } = await $.state.get(PLAN)
210 const error = ok ? null : r ? (r.exitCode === 0 ? 'not the expected JSON' : `exit ${r.exitCode}`) : 'did not run or timed out'
211 await $.state.set(PLAN, ok ? { data, at: await $.clock.now(), error } : { data: old?.data ?? null, at: old?.at ?? null, error })
212 } finally {
213 planBusy = false
214 }
215 }
216
217 // Spec 0071 AC-3, AC-4: the one PR search, only while the dashboard is open on Now: when Now opens and on r
218 // (force), and every 2 minutes on the panes' tick. Never two at once; a failed call keeps the last list, marked.
219 let prBusy = false
220 let prStarted = -Infinity
221 loadPrs = async (force = false) => {
222 const { value: view } = await $.state.get(VIEW)
223 // the engine's record of open panes first: the module's isOpen forgets an open pane on a hot reload; isOpen
224 // only when the record cannot be read
225 const open = (await $.ui.panes().catch(() => null))?.some((p: any) => p.id === PANE) ?? isOpen
226 if (prBusy || !open || view?.section !== 'now') return
227 const now = await $.clock.now()
228 if (!force && now - prStarted < time.prTick) return
229 prBusy = true
230 prStarted = now
231 try {
232 const r = await $.process.run(prArgv(), { timeoutMs: time.prTimeout }).catch(() => null)
233 const data = r && r.exitCode === 0 ? parsePrs(r.stdout) : null
234 const said = r ? String(r.stderr ?? '').split('\n').map(l => l.trim()).find(Boolean) : ''
235 const error = data ? null : !r ? 'did not run or timed out' : r.exitCode !== 0 ? said || `exit ${r.exitCode}` : 'not the expected answer'
236 const { value: old } = await $.state.get(PRS)
237 await $.state.set(PRS, data ? { data, at: await $.clock.now(), error } : { data: old?.data ?? null, at: old?.at ?? null, error })
238 } finally {
239 prBusy = false
240 }
241 }
242
243 void refresh()
244 $.clock.every(time.bandTick, () => { void refresh(); void countBuilds($).catch(() => {}) })
245 $.clock.every(time.planTick, () => { void loadPanes(); void loadPrs() })
246 // the spinner turns only while a handoff step runs, and stays on its first frame under reduced motion, as the host's does
247 $.clock.every(time.spin, async () => {
248 const { value: h } = await $.state.get(HANDOFF)
249 if (!(h?.panel && !h.panel.sticky && h.panel.steps.some(s => s.state === 'running'))) return
250 if ((await $.settings.read().catch(() => null) as any)?.prefersReducedMotion) return
251 await $.state.set(HANDOFF, { ...h, frame: h.frame + 1 })
252 })
253
254 for (const name of ['chip', 'wf-dash']) await $.command.register({ name, description: 'chip-dash: the plan, what this chat owes and your PRs, and its agents in one dashboard' })
255 return started
256 })
257
258 for (const command of ['chip', 'wf-dash']) on('command.run', { command }, async $ => openDashboard($))
259
260 on('ui.close', async ($, e, next) => {
261 if (e.id === PANE) isOpen = false
262 return next(e)
263 })
264
265 // Spec 0064 AC-2: a sticky handoff line goes once the owner types.
266 on('prompt.submit', async ($, e, next) => {
267 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
268 // never in the way of the prompt: a failed read or write only keeps the line
269 const h = (await $.state.get(HANDOFF).catch(() => null))?.value
270 if (h?.panel?.sticky) await $.state.set(HANDOFF, { ...h, dismissed: await $.clock.now() }).catch(() => {})
271 }
272 return next(e)
273 })
274
275 // spec 0074 AC-12: probe P1 saw agent.spawn carry e.workflow for each workflow agent and turn.complete its agentId
276 on('agent.spawn', async ($, e, next) => {
277 const r = await next(e)
278 // never in the way of a spawn: a failed count only leaves the band behind
279 try {
280 const id = e.workflow && r && !('deny' in r) ? (r as any).agentId : null
281 if (id) {
282 building.set(id, await $.clock.now())
283 await countBuilds($)
284 }
285 } catch {}
286 return r
287 })
288
289 on('turn.complete', async ($, e, next) => {
290 if (e.agentId && building.delete(e.agentId)) await countBuilds($).catch(() => {})
291 return next(e)
292 })
293
294 // The real effort rides on every main-loop model request; the band shows it from the first reply on.
295 on('turn.step', async function* ($, e, next) {
296 if (!e.agentId && e.effort !== undefined) {
297 const level = String(e.effort)
298 const { value } = await $.state.get(EFFORT)
299 if (value !== level) await $.state.set(EFFORT, level)
300 }
301 return yield* next(e)
302 })
303
304 // The host raises the band on the terminal and the desktop; it used to draw on the terminal only, so a desktop
305 // chat had no band at all. A survey holds the band while it asks.
306 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
307 if ((e.surface !== 'terminal' && e.surface !== 'desktop') || e.props.hasSurvey) return next(e)
308 const { Box, Text, Button } = $.ui.resolve(e)
309 const { value: facts } = await $.state.get(FACTS)
310 const { value: level } = await $.state.get(EFFORT)
311 const { value: handoff } = await $.state.get(HANDOFF)
312 const { value: build } = await $.state.get(BUILD)
313 const line = handoff ? handoffLine(handoff, Date.now()) : null
314 // the latest main loop request's level first, then the settings' one; unknown stays out
315 const value = facts && { ...facts, build: build ?? 0, effort: level || facts.effort, ...(handoff && 'auto' in handoff ? { handoff: handoff.auto ?? null } : {}) }
316 const band = value
317 ? <Band Box={Box} Text={Text} Button={Button} facts={value} columns={e.props.bodyColumns ?? (e.viewport?.columns ?? width.viewport) - space.hostReserve} onOpen={() => void openDashboard($)} />
318 : (
319 <Box paddingX={space.padX}>
320 <Text color={color.dim}>chip-dash: reading status</Text>
321 </Box>
322 )
323 if (!line) return band
324 const dismiss = async () => {
325 const { value: cur } = await $.state.get(HANDOFF)
326 if (cur) await $.state.set(HANDOFF, { ...cur, dismissed: await $.clock.now() })
327 }
328 return (
329 <Box flexDirection="column" width="100%">
330 {band}
331 <HandoffRow Box={Box} Text={Text} Button={Button} line={line} onDismiss={() => void dismiss()} />
332 </Box>
333 )
334 })
335
336 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
337 if ((e as any).requestId !== PANE || e.surface !== 'terminal') return next(e)
338 const el = $.ui.resolve(e)
339 const { value: view = DEFAULT_VIEW } = await $.state.get(VIEW)
340 const { value: plan } = await $.state.get(PLAN)
341 const live = view.section === 'agents' || view.section === 'now'
342 const listed = live ? await $.agent.list().catch(() => null) : []
343 // a shell row never shares an id with an agent, so its stop can never reach one
344 const sh = listed && live ? (await shells($))?.filter(s => !listed.some((a: any) => a.id === s.id)) ?? null : []
345 const agents = listed && view.section === 'agents' ? [...listed, ...(sh ?? [])] : listed
346 // spec 0071 AC-6: the Now view's one line, from the Agents view's own lists
347 const count = listed && sh ? { running: listed.filter((a: any) => a.status === 'running').length, shells: sh.length } : null
348 const cwd = await $.session.cwd().catch(() => null)
349 const go = async (v: Partial<typeof view>) => {
350 const { value: now = DEFAULT_VIEW } = await $.state.get(VIEW)
351 await $.state.set(VIEW, { ...now, ...v })
352 if (v.section === 'now' && now.section !== 'now') void loadPrs(true)
353 }
354 const act = {
355 go: (v: Partial<typeof view>) => void go(v),
356 retry: () => { void loadPanes(true); void loadPrs(true) },
357 run: (a: WfDashAsk) => void perform($, a).then(notice => go({ notice })),
358 }
359 const extras = view.section === 'now'
360 ? { prs: (await $.state.get(PRS)).value, owed: (await $.state.get(FACTS)).value?.owed, handoff: (await $.state.get(HANDOFF)).value, count }
361 : {}
362 return <Dashboard el={el} view={view} act={act} plan={plan} cwd={cwd} agents={agents as any[] | null} focused={e.props.isFocused !== false} rows={e.props.placement === 'dock' ? e.props.scroll?.bodyRows : undefined} columns={e.props.bodyColumns ?? (e.viewport?.columns ?? width.viewport) - pane.frame} {...extras} />
363 })
364}
365band.tsx 193 lines1// The band above the prompt: this session on the left, its auto handoff on the right, always one line.
2// Pure drawing from facts; every color, width and word comes from tokens.ts.
3import type { WfDashFacts } from './types/index.d.ts'
4import type { HandoffLine } from './handoff.ts'
5import { color, effort, glyph, meter, space, width } from './tokens.ts'
6
7export type Facts = WfDashFacts
8
9// One run of text in one color; a segment is a list of runs plus its width in cells.
10type Run = { text: string; color?: string; bg?: string; dim?: boolean; bold?: boolean }
11type Seg = { id: string; runs: Run[]; cells: number }
12
13const seg = (id: string, runs: Run[]): Seg => ({ id, runs, cells: runs.reduce((n, r) => n + r.text.length, 0) })
14const pad = (s: string, n: number) => (s.length >= n ? s : ' '.repeat(n - s.length) + s)
15const pct = (v: number | null) => (v === null ? glyph.unknown : `${Math.round(v)}%`)
16
17// "claude-opus-5-5[1m]" -> "Opus 5.5"; anything else is shown as given.
18export function prettyModel(id: string | null) {
19 if (!id) return glyph.unknown
20 const m = /^claude-([a-z]+)-(\d+)(?:-(\d+))?/.exec(id.replace(/\[.*\]$/, ''))
21 if (!m) return id.replace(/\[.*\]$/, '')
22 return `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}${m[3] ? '.' + m[3] : ''}`
23}
24
25function fillColor(v: number) {
26 return v >= meter.badAt ? meter.fill.bad : v >= meter.warnAt ? meter.fill.warn : meter.fill.ok
27}
28
29// "in 2h10m", "in 3d4h": how long until a usage window resets; '' when unknown or past.
30export function resetIn(iso: string | null, now: number) {
31 const ms = iso ? Date.parse(iso) - now : NaN
32 if (!(ms > 0)) return ''
33 const m = Math.floor(ms / 60000), d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60)
34 return d ? `in ${d}d${h}h` : h ? `in ${h}h${String(m % 60).padStart(2, '0')}m` : `in ${m % 60}m`
35}
36
37// "160k/1M": the context window's fill in tokens, as the host's own header writes sizes.
38export function tokens(n: number) {
39 return n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n)
40}
41
42// Today's status bar meter: the label centred, the used share on a background, the rest dim, then the number.
43function meterSeg(id: string, label: string, value: number | null, reset = ''): Seg {
44 const left = Math.floor((meter.width - label.length) / 2)
45 const cells = (' '.repeat(left) + label).padEnd(meter.width)
46 const filled = value === null ? 0 : Math.min(meter.width, Math.round((value / 100) * meter.width))
47 const runs: Run[] = []
48 if (filled) runs.push({ text: cells.slice(0, filled), bg: fillColor(value as number), color: meter.onFill })
49 runs.push({ text: cells.slice(filled), dim: true })
50 runs.push({ text: ' ' + pad(pct(value), width.pct - 1), color: value === null ? color.dim : value >= meter.warnAt ? fillColor(value) : color.text })
51 if (reset) runs.push({ text: ' ' + reset, color: color.dim })
52 return seg(id, runs)
53}
54
55// spec 0074 AC-12: workflow agents still running, by agentId -> spawn time. One with no turn.complete for 2 hours is
56// dropped (a cancelled run may never send one).
57export const BUILD_STALE_MS = 2 * 60 * 60 * 1000
58export function liveBuilds(running: Map<string, number>, now: number) {
59 for (const [id, at] of running) if (now - at > BUILD_STALE_MS) running.delete(id)
60 return running.size
61}
62
63// `rich` adds the reset times and the context tokens; the band drops them before it drops a segment.
64// `compact` is for a column too narrow for the chip and the full handoff segment (the ~31 cells beside the docked
65// dashboard).
66export function segments(f: Facts, now = Date.now(), rich = true, compact = false, maxCells = Infinity) {
67 const left: Seg[] = [seg('chip', [{ text: ' chip ', bg: color.accent, color: 'inverseText', bold: true }])]
68 const model: Run[] = [{ text: prettyModel(f.model), color: color.text }]
69 if (f.effort) model.push({ text: ' ' + f.effort, color: (effort as Record<string, string>)[f.effort] ?? color.dim })
70 left.push(seg('model', model))
71 left.push(seg('folder', [{ text: f.folder ?? glyph.unknown, color: color.text }]))
72 if (f.git) {
73 const g = f.git
74 const runs: Run[] = [{ text: g.branch, color: color.text }]
75 if (g.changes === 0) runs.push({ text: ' clean', color: color.dim })
76 if (g.staged) runs.push({ text: ` +${g.staged}`, color: color.ok })
77 if (g.modified) runs.push({ text: ` ~${g.modified}`, color: color.warn })
78 if (g.untracked) runs.push({ text: ` ?${g.untracked}`, color: color.dim })
79 left.push(seg('git', runs))
80 }
81 // context is null until the first reply: left out until known, never shown as a guess, like effort
82 if (f.context !== null) {
83 const ctx: Run[] = [{ text: 'ctx ', color: color.dim }, { text: pct(f.context), color: f.context >= meter.warnAt ? fillColor(f.context) : color.text }]
84 if (rich && f.contextTokens !== null && f.contextWindow) ctx.push({ text: ` ${tokens(f.contextTokens)}/${tokens(f.contextWindow)}`, color: color.dim })
85 left.push(seg('ctx', ctx))
86 }
87 if (f.via) left.push(seg('via', [{ text: `via ${f.via}`, color: color.dim }]))
88 else left.push(
89 meterSeg('5h', '5h', f.fiveHour, rich ? resetIn(f.fiveHourReset, now) : ''),
90 meterSeg('week', 'week', f.week, rich ? resetIn(f.weekReset, now) : ''),
91 )
92 // spec 0074 AC-12: running /build-features workflow agents; nothing at 0
93 if ((f.build ?? 0) > 0) left.push(seg('build', [{ text: `build ${f.build}`, color: color.text, bold: true }]))
94 // spec 0071 AC-7: the steps this chat owes, drawn just before the handoff segment; nothing at 0 or when the file
95 // could not be read (never `owed 0`, never `owed ?`). Kept in the left list so fit() counts it, last to drop.
96 const owed = f.owed?.lines?.length ?? 0
97 if (owed > 0) left.push(seg('owed', [{ text: `owed ${owed}`, color: color.warn, bold: true }]))
98
99 // The right end, where the retired runner's counts were: the auto handoff, which the owner turns off in chats that
100 // are not for coding (/auto-handoff). Compact (the ~31 cells beside the docked dashboard) drops the word.
101 const h = f.handoff
102 const right: Run[] = compact ? [] : [{ text: 'handoff ', color: color.dim }]
103 if (h === undefined || h === null) right.push({ text: glyph.unknown, color: color.dim })
104 else if (h.off) right.push({ text: 'off', color: color.warn })
105 else right.push({ text: h.line ? `on ${tokens(h.line)}` : 'on', color: color.text })
106 // the stale mark goes when it does not fit `maxCells` beside the state, never the state itself
107 if (f.staleSince) {
108 const mark = compact ? ' stale' : `${glyph.sep}stale since ${f.staleSince}`
109 if (right.reduce((n, r) => n + r.text.length, 0) + mark.length <= maxCells) right.push({ text: mark, color: color.dim })
110 }
111 return { left, right: seg('handoff', right) }
112}
113
114// Left segments leave in this order when the line is too narrow; the handoff segment and the chip stay. The owed
115// count (drawn on the right) is the last to go: it outlasts the model and stays in the compact band beside the docked
116// dashboard (spec 0071 AC-7), where it is 2 cells too wide beside `on 200k`, so past the model nothing more leaves and
117// the handoff state truncates at its end instead.
118const DROP = ['folder', 'git', 'week', '5h', 'via', 'ctx', 'build', 'model']
119// the segments drawn at the left: every kept one but the owed count
120const leftOf = (kept: Seg[]) => kept.filter(s => s.id !== 'owed')
121
122export function fit(left: Seg[], right: Seg, columns: number) {
123 const used = (l: Seg[]) => l.reduce((n, s) => n + s.cells, 0) + space.gap * Math.max(0, l.length - 1) + space.gap + right.cells + 2 * space.padX
124 let kept = left
125 for (const id of DROP) {
126 if (used(kept) <= columns) break
127 kept = kept.filter(s => s.id !== id)
128 }
129 return kept
130}
131
132function draw(Box: any, Text: any, s: Seg, shrink = 0) {
133 return (
134 <Box flexShrink={shrink}>
135 <Text wrap="truncate-end">
136 {s.runs.map(r => (
137 <Text color={r.color} backgroundColor={r.bg} dimColor={r.dim} bold={r.bold}>{r.text}</Text>
138 ))}
139 </Text>
140 </Box>
141 )
142}
143
144// `columns`: the host's bodyColumns, the band's own column (beside a docked pane too) less the host's `[-]` cells
145export function Band({ Box, Text, Button, facts, columns, onOpen }: { Box: any; Text: any; Button?: any; facts: Facts; columns: number; onOpen?: () => void }) {
146 // the chip as a button draws its hotkey first ("w: "), cells the segment width does not count
147 const room = columns - (Button && onOpen ? space.hotkeyCells : 0)
148 let { left, right } = segments(facts)
149 let kept = fit(left, right, room)
150 if (kept.length < left.length) {
151 ;({ left, right } = segments(facts, Date.now(), false))
152 kept = fit(left, right, room)
153 }
154 if (leftOf(kept).length === 1) {
155 // the owed count stays in the compact band: its cells come out of the stale mark's room
156 const owedCells = kept.filter(s => s.id === 'owed').reduce((n, s) => n + s.cells + space.gap, 0)
157 ;({ left, right } = segments(facts, Date.now(), false, true, room - 2 * space.padX - left[0].cells - space.gap - owedCells))
158 kept = fit(left, right, room)
159 }
160 const owed = kept.find(s => s.id === 'owed')
161 // the chip is pinned; when even the compact state does not fit, the handoff segment truncates at its end instead
162 return (
163 <Box flexDirection="row" paddingX={space.padX} width="100%">
164 <Box flexDirection="row" gap={space.gap} flexGrow={1} flexShrink={leftOf(kept).length === 1 ? 0 : 1}>
165 {leftOf(kept).map(s =>
166 // the chip opens the dashboard: a click, or w once the band holds the keys
167 s.id === 'chip' && Button && onOpen
168 ? <Button key="chip-open" plain hotkey="w" onPress={onOpen}><Text backgroundColor={color.accent} color="inverseText" bold>{' chip '}</Text></Button>
169 : draw(Box, Text, s),
170 )}
171 </Box>
172 {owed && <Box flexShrink={0} marginLeft={space.gap}>{draw(Box, Text, owed)}</Box>}
173 <Box flexShrink={1} marginLeft={space.gap}>
174 {draw(Box, Text, right, 1)}
175 </Box>
176 </Box>
177 )
178}
179
180// Spec 0064 AC-2: the handoff's own line under the band; a sticky one carries its dismiss key.
181export function HandoffRow({ Box, Text, Button, line, onDismiss }: { Box: any; Text: any; Button?: any; line: HandoffLine; onDismiss?: () => void }) {
182 return (
183 <Box flexDirection="row" paddingX={space.padX} gap={space.gap} width="100%">
184 <Box flexShrink={1}>
185 <Text color={line.color} bold={line.kind === 'sticky'} wrap="truncate-end">{line.text}</Text>
186 </Box>
187 {line.kind === 'sticky' && Button && onDismiss
188 ? <Button key="chip-handoff-dismiss" plain hotkey="x" onPress={onDismiss}><Text color={color.dim}>dismiss</Text></Button>
189 : null}
190 </Box>
191 )
192}
193handoff.ts 39 lines1// Spec 0064 AC-2, AC-6: the handoff line under the band, drawn from the wf-handoff mod's panel.json.
2// A file contract, no shared code: wf-handoff writes the file, this only reads it.
3import type { WfDashHandoff, WfDashPanel } from './types/index.d.ts'
4import { color, glyph, time } from './tokens.ts'
5
6export type HandoffLine = { kind: 'running' | 'result' | 'sticky' | 'link'; text: string; color: string; link: string | null }
7
8// The panel is for this chat when it names this chat, or the chat this one replaced in the same
9// terminal: the steps before the clear are written under the old id.
10const ours = (p: WfDashPanel, h: WfDashHandoff) => p.session === h.me || (h.prev !== null && p.session === h.prev)
11
12export function handoffLine(h: WfDashHandoff, now: number): HandoffLine | null {
13 const p = h.panel
14 if (!p || !ours(p, h)) return null
15 // sticky: until the owner types or presses the dismiss key, never on a timer
16 if (p.sticky && p.result && p.at > h.dismissed) {
17 return { kind: 'sticky', text: p.result, color: p.phase === 'paused' ? color.warn : color.bad, link: null }
18 }
19 const running = p.steps.find(s => s.state === 'running')
20 if (running && !p.sticky) {
21 const spin = glyph.spinner[h.frame % glyph.spinner.length]
22 return { kind: 'running', text: `${spin} ${running.label}`, color: color.text, link: null }
23 }
24 const done = p.steps.length > 0 && p.steps.every(s => s.state === 'done')
25 if (done && p.result && now - p.at < time.handoffResult) {
26 return { kind: 'result', text: p.link ? `${p.result} · ${p.link}` : p.result, color: color.ok, link: p.link }
27 }
28 // a chat that came from a handoff keeps its way back to the handover
29 const link = handoverLink(h)
30 if (link) return { kind: 'link', text: `handover: ${link}`, color: color.dim, link }
31 return null
32}
33
34// The handover this chat came from, under the rule the band's link line uses; the Now view shows the same (spec 0071 AC-5).
35export function handoverLink(h: WfDashHandoff): string | null {
36 const p = h.panel
37 return p && p.session === h.me && p.previous && p.link ? p.link : null
38}
39now.ts 42 lines1// Spec 0071: the Now view's two readers, as pure parsing. register.tsx reads the catch file and runs the one gh call.
2import type { WfDashOwed, WfDashPr, WfDashPrs } from './types/index.d.ts'
3
4// AC-3: every open PR you opened, newest update first, with its last commit's check rollup, in one call (probe P1)
5export const PR_QUERY = 'query { search(query: "is:pr is:open author:@me sort:updated-desc", type: ISSUE, first: 20) { issueCount nodes { ... on PullRequest { number title isDraft url repository { name } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } } } } }'
6export const prArgv = () => ['gh', 'api', 'graphql', '-f', `query=${PR_QUERY}`]
7
8// GitHub's check rollup in one word. No rollup is none; a state GitHub does not document is ?, never a guessed word.
9export function checksWord(rollup: unknown): string {
10 if (rollup == null) return 'none'
11 const s = (rollup as { state?: unknown }).state
12 if (s === 'SUCCESS') return 'ok'
13 if (s === 'FAILURE' || s === 'ERROR') return 'failing'
14 if (s === 'PENDING' || s === 'EXPECTED') return 'pending'
15 return '?'
16}
17
18// The search's stdout as PRs and their total; null when it is not the answer's shape (a failed call, never "no PRs")
19export function parsePrs(stdout: string): WfDashPrs | null {
20 let d: any
21 try { d = JSON.parse(stdout) } catch { return null }
22 const s = d?.data?.search
23 if (!s || typeof s.issueCount !== 'number' || !Array.isArray(s.nodes)) return null
24 const prs = s.nodes.filter((n: any) => n && typeof n.number === 'number').map((n: any): WfDashPr => ({
25 repo: String(n.repository?.name ?? '?'),
26 number: n.number,
27 title: String(n.title ?? ''),
28 draft: n.isDraft === true,
29 url: String(n.url ?? ''),
30 checks: checksWord(n.commits?.nodes?.[0]?.commit?.statusCheckRollup),
31 }))
32 return { total: s.issueCount, prs }
33}
34
35// AC-2: the catch file's text. One that does not parse, or has no `owed` list, is unknown (`owed ?`), never nothing owed.
36export function parseOwed(raw: string): WfDashOwed {
37 let d: any
38 try { d = JSON.parse(raw) } catch (err: any) { return { installed: true, lines: null, error: String(err?.message ?? err) } }
39 if (!Array.isArray(d?.owed)) return { installed: true, lines: null, error: 'the file has no owed list' }
40 return { installed: true, lines: d.owed.map(String), error: null }
41}
42panes.tsx 538 lines1// The dashboard: one pane, three sections you move between with a key (p Plan, n Now, a Agents). The runner's Builds
2// and Alerts views went with the runner.
3// Same look as the band: k9s rows, state words that carry their meaning without color, dim chrome, color spent on
4// state. Pure drawing from data; tokens.ts holds every value.
5import type { WfDashAsk, WfDashFeature, WfDashHandoff, WfDashLoaded, WfDashOwed, WfDashPlan, WfDashPr, WfDashPrs } from './types/index.d.ts'
6import { tokens } from './band.tsx'
7import { handoverLink } from './handoff.ts'
8import { agentStatus, color, glyph, pane, shortTab, space, specStatus, state, stateHelp, stateOrder, tinyTab } from './tokens.ts'
9
10type El = { Box: any; Text: any; Button: any }
11export type View = { section: string; tab: string; page: number; sel: number; gsel?: number; nsel?: number; ask?: WfDashAsk | null; notice?: { text: string; ok: boolean } | null }
12// run: the confirmed action (spec 0060 AC-5); only the confirm row's `y` reaches it
13export type Act = { go: (v: Partial<View>) => void; retry: () => void; run: (ask: WfDashAsk) => void }
14type Plan = WfDashLoaded<WfDashPlan> | undefined
15
16export const SECTIONS = [
17 { id: 'plan', key: 'p', label: 'Plan' },
18 { id: 'now', key: 'n', label: 'Now' },
19 { id: 'agents', key: 'a', label: 'Agents' },
20]
21
22// Plan filters, in the order of their digit keys; `states` null means every open feature.
23export const PLAN_TABS: { id: string; label: string; states: string[] | null; done?: boolean; empty: string }[] = [
24 { id: 'needs-you', label: 'Needs you', states: ['waits on you'], empty: 'Nothing waits on you. A step only you can do (a login, a check on your phone) lands here.' },
25 { id: 'ready', label: 'Ready', states: ['ready to build'], empty: 'Nothing is ready to build. A feature lands here once its design is accepted.' },
26 { id: 'design', label: 'Needs design', states: ['needs a design'], empty: 'Nothing needs a design. A feature lands here while a decision is owed (/architect).' },
27 { id: 'progress', label: 'In progress', states: ['in progress'], empty: 'Nothing is in progress. A feature lands here when it is partly done.' },
28 { id: 'built', label: 'Built', states: ['done', 'done (untagged)'], done: true, empty: 'Nothing built yet. A feature lands here once it is built and merged.' },
29 { id: 'open', label: 'All open', states: null, empty: 'Nothing open.' },
30]
31
32export const DEFAULT_VIEW: View = { section: 'plan', tab: 'needs-you', page: 0, sel: 0 }
33
34// -- Actions (spec 0060 AC-5): the only next steps the dashboard may start; anything else is typed by you ------------
35export const SAFE_STEPS = ['/architect', '/develop', '/check verify', '/test', '/sync', '/debug', '/scope']
36// no line breaks or invisible format characters (bidi, zero width): the confirm must show exactly what runs
37export const safeStep = (cmd: string | null) => !!cmd && !/[\r\n\p{Cc}\p{Cf}]/u.test(cmd) && SAFE_STEPS.some(s => cmd === s || cmd.startsWith(`${s} `))
38// a step started in a chat of another repo would build there
39const inRepo = (path: string, cwd: string | null) => !!cwd && (cwd === path || cwd.startsWith(`${path}/`))
40
41// The start offer for a feature: the exact text to submit, or why it is yours to type; null when it has no next step.
42export function startOffer(f: WfDashFeature, path: string, cwd: string | null): { ok: boolean; text: string } | null {
43 if (isDone(f.state) || !f.command || f.state === 'waits on you') return null
44 if (!safeStep(f.command)) return { ok: false, text: `type it yourself: ${f.command}` }
45 if (!inRepo(path, cwd)) return { ok: false, text: `type it yourself in ${path}: ${f.command}` }
46 return { ok: true, text: f.command }
47}
48
49export function confirmText(a: WfDashAsk): string {
50 return a.kind === 'start' ? `Start "${a.text}" in this chat?` : `Stop ${a.label}?`
51}
52
53type Offer = { key: string; label: string; ask: WfDashAsk } | { note: string } | null
54type ActLine = { t: string; tone: string; bold?: boolean }
55type Key = { key: string; label: string; on: boolean; press: () => void }
56
57// The rows a hotkey row wraps to at `cells` wide, so a view can budget for it.
58const keyRows = (keys: Key[], cells: number) =>
59 keys.length ? Math.max(1, Math.ceil((keys.reduce((n, k) => n + k.label.length + space.hotkeyCells, 0) + (keys.length - 1) * space.gap) / Math.max(1, cells))) : 0
60
61// An action's lines and keys: a pending confirm (y runs it, c cancels), else the last result and the view's offer.
62// Each view draws them inside its own row budget: the dashboard never grows past its rows, so the confirm's keys
63// can never fall off the bottom of the pane (design-gate 2026-10-18).
64function actionParts(view: View, act: Act, offer: Offer, cells: number): { lines: ActLine[]; keys: Key[]; rows: number } {
65 const a = view.ask
66 let lines: ActLine[]
67 let keys: Key[]
68 if (a) {
69 keys = [
70 { key: 'y', label: 'yes', on: true, press: () => { act.go({ ask: null }); act.run(a) } },
71 { key: 'c', label: 'cancel', on: true, press: () => act.go({ ask: null }) },
72 ]
73 // wrapped, never cut: the whole text that runs is on screen before yes
74 lines = wrapWords(confirmText(a), cells, Infinity).map(t => ({ t, tone: color.warn, bold: true }))
75 } else {
76 lines = [
77 ...(view.notice ? [{ t: view.notice.text, tone: view.notice.ok ? color.ok : color.bad }] : []),
78 ...(offer && 'note' in offer ? [{ t: offer.note, tone: color.dim }] : []),
79 ]
80 keys = offer && 'ask' in offer ? [{ key: offer.key, label: offer.label, on: true, press: () => act.go({ ask: offer.ask, notice: null }) }] : []
81 }
82 return { lines, keys, rows: lines.length + keyRows(keys, cells) }
83}
84
85function actionRow(el: El, parts: { lines: ActLine[]; keys: Key[] }) {
86 const { Box, Text } = el
87 return (
88 <Box flexDirection="column">
89 {parts.lines.map(l => <Text color={l.tone} bold={l.bold} wrap="truncate-end">{l.t}</Text>)}
90 {parts.keys.length > 0 && keyRow(el, parts.keys)}
91 </Box>
92 )
93}
94
95const hhmm = (ms: number) => {
96 const d = new Date(ms)
97 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
98}
99const stateOf = (s: string) => (state as Record<string, { color: string; word: string }>)[s.replace(' (untagged)', '')] ?? { color: color.text, word: s }
100const rank = (s: string) => {
101 const i = (stateOrder as readonly string[]).indexOf(s.replace(' (untagged)', ''))
102 return i < 0 ? stateOrder.length : i
103}
104// "done (untagged)" is the done group: one state, one group
105const norm = (s: string) => s.replace(' (untagged)', '')
106const isDone = (s: string) => ['done', 'dropped', 'existing'].includes(s.replace(' (untagged)', ''))
107// a box's text without backticks and without the scope's trailing tags (" . waits on you", " . live"): plan markup, not words
108export const untag = (s: string) => s.replace(/`/g, '').replace(/\s+[.·]\s+(waits on you|later boxes go on|live|skipped)\b.*$/, '')
109// the box's command is what the next step runs
110const step = (f: WfDashFeature) => f.command ?? untag(f.next ?? '')
111
112function cell(Box: any, Text: any, text: string, w: number | null, props: Record<string, unknown> = {}) {
113 return (
114 <Box width={w ?? undefined} flexGrow={w ? 0 : 1} flexShrink={w ? 0 : 1} marginRight={space.tight}>
115 <Text wrap="truncate-end" {...props}>{text}</Text>
116 </Box>
117 )
118}
119
120function keyRow({ Box, Button }: El, items: { key: string; label: string; on: boolean; press: () => void; mark?: boolean }[], gap: number = space.gap) {
121 return (
122 <Box flexDirection="row" columnGap={gap} flexWrap="wrap">
123 {items.map(i => <Button key={`k-${i.key}`} plain hotkey={i.key} label={i.on && i.mark ? `[${i.label}]` : i.label} dimColor={!i.on} onPress={i.press} />)}
124 </Box>
125 )
126}
127
128function loading({ Box, Text }: El, what: string, l: WfDashLoaded<unknown> | undefined, act?: Act) {
129 if (l?.error && !l.data) {
130 return (
131 <Box flexDirection="column" marginTop={space.line}>
132 <Text color={color.bad}>{`Could not read ${what} (${l.error}).`}</Text>
133 <Text color={color.dim}>r tries again. If it keeps failing, run python3 plan.py from the chip-dash mod folder in a terminal to see why.</Text>
134 </Box>
135 )
136 }
137 return <Box marginTop={space.line}><Text color={color.dim}>{`Reading ${what}...`}</Text></Box>
138}
139
140// -- Plan ----------------------------------------------------------------------------------------------------------
141
142type Line = { kind: 'group'; name: string; note: string; tone?: string } | { kind: 'feature'; f: WfDashFeature; repo: string; path?: string } | { kind: 'note'; text: string; tone: string }
143
144// Pages of `rows` lines that never orphan a group heading at the foot, and repeat the heading (`cont.`) on top of a
145// page that starts inside a group, so every row on screen sits under its group's name.
146export function paginate(lines: Line[], rows: number): Line[][] {
147 const pages: Line[][] = []
148 let cur: Line[] = []
149 let group: Extract<Line, { kind: 'group' }> | null = null
150 for (const l of lines) {
151 if (l.kind === 'group') group = l
152 if (cur.length + (l.kind === 'group' ? 2 : 1) > rows) {
153 pages.push(cur)
154 cur = l.kind !== 'group' && group ? [{ ...group, note: `${group.note} cont.` }] : []
155 }
156 cur.push(l)
157 }
158 if (cur.length) pages.push(cur)
159 return pages.length ? pages : [[]]
160}
161
162// Grouped by state, Linear style: the group heading carries the state word and its count, so rows never repeat it.
163// Each row names its repo instead. Broken scopes, hub rows and scope warnings follow the state groups.
164export function planLines(repos: WfDashPlan['repos'], tab: (typeof PLAN_TABS)[number]): Line[] {
165 const picked = repos.flatMap(r =>
166 r.error || r.hub ? [] : (r.features ?? []).filter(f => !f.deferred && (tab.states ? tab.states.includes(f.state) : !isDone(f.state))).map(f => ({ f, repo: r.name, path: r.path })))
167 const lines: Line[] = []
168 const groups = [...new Set(picked.map(p => p.f.state.replace(' (untagged)', '')))].sort((a, b) => rank(a) - rank(b))
169 for (const g of groups) {
170 const members = picked.filter(p => p.f.state.replace(' (untagged)', '') === g)
171 // one group on a single-state filter: the filter tab already says the state and the count, so the row goes to a feature
172 if (groups.length > 1 || tab.id === 'open') lines.push({ kind: 'group', name: stateOf(g).word, note: `${members.length}`, tone: stateOf(g).color })
173 members.forEach(p => lines.push({ kind: 'feature', f: p.f, repo: p.repo, path: p.path }))
174 }
175 for (const r of repos) {
176 if (r.error) lines.push({ kind: 'group', name: r.name, note: '' }, { kind: 'note', text: `could not read this scope: ${r.error}`, tone: color.bad })
177 else if (r.hub && tab.id === 'open' && r.rows?.length) {
178 lines.push({ kind: 'group', name: r.name, note: `${r.rows.length} open rows (hub)` })
179 r.rows.forEach(t => lines.push({ kind: 'note', text: t, tone: color.text }))
180 } else if (!tab.done && picked.some(p => p.repo === r.name)) r.warnings?.forEach(w => lines.push({ kind: 'note', text: `${r.name}: ${w}`, tone: color.warn }))
181 }
182 return lines
183}
184
185// Up to `max` lines of `cells` cells, broken at spaces; the last line ends in "..." when text is left over.
186export function wrapWords(text: string, cells: number, max: number): string[] {
187 const out: string[] = []
188 let rest = text.trim()
189 while (rest && out.length < max) {
190 if (rest.length <= cells) { out.push(rest); rest = ''; break }
191 if (out.length === max - 1) { out.push(rest.slice(0, Math.max(0, cells - glyph.cut.length)).trimEnd() + glyph.cut); rest = ''; break }
192 const cut = rest.lastIndexOf(' ', cells)
193 const at = cut > 0 ? cut : cells
194 out.push(rest.slice(0, at).trimEnd())
195 rest = rest.slice(at).trimStart()
196 }
197 return out
198}
199
200function PlanSection({ el, plan, view, act, columns, cwd, extra = 0 }: { el: El; plan: Plan; view: View; act: Act; columns: number; cwd: string | null; extra?: number }) {
201 const { Box, Text } = el
202 if (!plan?.data) return loading(el, 'the plan', plan, act)
203 const repos = plan.data.repos
204 const all = repos.flatMap(r => r.features ?? []).filter(f => !f.deferred)
205 const count = (t: (typeof PLAN_TABS)[number]) => all.filter(f => (t.states ? t.states.includes(f.state) : !isDone(f.state))).length
206 const tab = PLAN_TABS.find(t => t.id === view.tab) ?? PLAN_TABS[0]
207 // plan warnings under All open take their rows out of the list's page, so the pane never grows past its rows; the
208 // list keeps two (a heading and a feature), and warnings past that are counted on the last line, never cut silently
209 const warnAll = tab.id === 'open' ? plan.data.warnings ?? [] : []
210 const warnMax = pane.page + extra - 2
211 const warns = warnAll.length > warnMax ? [...warnAll.slice(0, warnMax - 1), `+${warnAll.length - warnMax + 1} more plan warnings`] : warnAll
212 const room = pane.page + extra - warns.length
213 const pageList = paginate(planLines(repos, tab), room)
214 // the cursor moves over features only; the page is the one that holds the selected feature
215 const feats = pageList.flatMap((p, pi) => p.flatMap(l => (l.kind === 'feature' ? [{ line: l, page: pi }] : [])))
216 const sel = feats.length ? Math.min(view.sel ?? 0, feats.length - 1) : -1
217 const pages = pageList.length
218 const page = sel >= 0 ? feats[sel].page : 0
219 const chosen = sel >= 0 ? feats[sel].line.f : null
220 const wide = columns >= pane.specMin
221 const pageFirst = (p: number) => {
222 const want = ((p % pages) + pages) % pages
223 const i = feats.findIndex(x => x.page >= want)
224 return i < 0 ? 0 : i
225 }
226 // t: the first feature of the next state group, wrapping to the top. It was n until Now took n (spec 0071 AC-1):
227 // two buttons on one hotkey, the later wins, so n would never have opened Now from Plan
228 const nextGroup = () => {
229 if (sel < 0) return 0
230 const i = feats.findIndex((x, k) => k > sel && norm(x.line.f.state) !== norm(feats[sel].line.f.state))
231 return i < 0 ? 0 : i
232 }
233 // the repo column only when the page on screen mixes repos
234 const repoCol = new Set(pageList[page].flatMap(l => (l.kind === 'feature' ? [l.repo] : []))).size > 1
235 const inner = columns - 2 * space.padX
236 // a narrow pane: two deliberate rows of 4 (act now / the rest) with short labels; the act-now row keeps its counts and
237 // falls back to its tiny labels only when the counts would push it past one line
238 const label = (t: (typeof PLAN_TABS)[number], names: Record<string, string>, counted: boolean) =>
239 counted ? `${names[t.id]} ${count(t)}` : names[t.id]
240 // a row's cells: each label with its "N: " hotkey, the gaps, and the selected filter's two brackets
241 const brackets = 2
242 const rowCells = (labels: string[], gap: number) => labels.reduce((n, l) => n + l.length + space.hotkeyCells, 0) + (labels.length - 1) * gap + brackets
243 const actNow = PLAN_TABS.slice(0, 4)
244 const rest = PLAN_TABS.slice(4)
245 const full = Object.fromEntries(PLAN_TABS.map(x => [x.id, x.label]))
246 const actNames = rowCells(actNow.map(t => label(t, shortTab, true)), space.tight) <= inner ? shortTab : { ...shortTab, ...tinyTab }
247 // wide: one row when all eight fit, else the same deliberate 4 + 4 split as narrow, with full labels and counts
248 const tabRows = wide
249 ? rowCells(PLAN_TABS.map(t => label(t, full, true)), space.gap) <= inner
250 ? [PLAN_TABS.map(t => ({ t, text: label(t, full, true) }))]
251 : [actNow, rest].map(r => r.map(t => ({ t, text: label(t, full, true) })))
252 : [actNow.map(t => ({ t, text: label(t, actNames, true) })), rest.map(t => ({ t, text: label(t, shortTab, t.id === tab.id) }))]
253 const cells = Math.max(pane.minWrap, inner - space.indent)
254 // the start action lives in the detail block's rows; a confirm too long to show whole beside its keys is not offered
255 const o = chosen ? startOffer(chosen, feats[sel].line.path ?? '', cwd) : null
256 const fits = (text: string) => wrapWords(confirmText({ kind: 'start', text }), cells, Infinity).length < pane.detail
257 const parts = actionParts(view, act, !o ? null : !o.ok ? { note: o.text } : fits(o.text)
258 ? { key: 's', label: 'start it', ask: { kind: 'start', text: o.text } }
259 : { note: `type it yourself: ${o.text}` }, cells)
260 // the detail block: next step first, wrapped to two lines when it runs long, then what the state means, then the ids;
261 // never the state word again (the group heading says it)
262 const detail = (() => {
263 if (!chosen) return []
264 const help = stateHelp[chosen.state.replace(' (untagged)', '')]
265 // position first, so a narrow pane never cuts it; the repo only when the rows on screen do not already show it
266 const meta = [
267 `${sel + 1} of ${feats.length}`,
268 chosen.spec ? (isDone(chosen.state) ? `spec ${chosen.spec}` : `spec ${chosen.spec}: ${specStatus[chosen.specStatus ?? 'unknown'] ?? chosen.specStatus}`) : 'no spec',
269 !repoCol && new Set(feats.map(x => x.line.repo)).size > 1 ? feats[sel].line.repo : '',
270 ].filter(Boolean).join(' ')
271 // the name cell's width in the list row; when the row cut the name (or its repo), the detail shows them in full first
272 const nameCells = inner - pane.col.cursor - 2 * space.tight - (repoCol ? pane.col.repo + space.tight : 0)
273 - (wide && !isDone(chosen.state) ? pane.col.next + space.tight : 0) - (wide ? pane.col.spec + space.tight : 0)
274 const heading = chosen.heading.replace(/`/g, '')
275 const repo = feats[sel].line.repo
276 const cut = heading.length > nameCells || (repoCol && repo.length > pane.col.repo - space.tight)
277 // the action's rows come out of the block, never added below it
278 const rows = pane.detail - parts.rows
279 const out = cut && rows > 1 ? [{ t: repoCol ? `${heading} ${repo}` : heading, tone: color.text }] : []
280 const room = rows - 1 - out.length
281 const next = isDone(chosen.state) || room < 1 ? [] : wrapWords(`next: ${untag(chosen.next ?? chosen.command ?? glyph.unknown)}`, cells, room)
282 out.push(...next.map(t => ({ t, tone: color.text })))
283 if (out.length < rows - 1 && help) out.push({ t: help, tone: color.dim })
284 // a two-line confirm and its keys fill the block: no row is left for the ids
285 if (out.length < rows) out.push({ t: meta, tone: color.dim })
286 return out
287 })()
288 return (
289 <Box flexDirection="column">
290 {wide && (
291 <Text color={color.dim} wrap="truncate-end">
292 {tab.id === 'needs-you' ? 'Steps only you can do: a login, a check on your phone.' : 'Every plan, grouped by what each feature needs next.'}
293 </Text>
294 )}
295 {tabRows.map(row =>
296 keyRow(el, row.map(({ t, text }) => ({ key: String(PLAN_TABS.indexOf(t) + 1), label: text, on: t.id === tab.id, mark: true, press: () => act.go({ tab: t.id, sel: 0 }) })), wide ? space.gap : space.tight))}
297 {/* a fixed height, padded with blank rows, so switching filters never moves the layout under the fingers */}
298 <Box flexDirection="column" height={extra ? Math.max(pane.page - warns.length, Math.min(room, pageList[page].length)) : room}>
299 {feats.length === 0 && pageList[0].length === 0 && <Text color={color.dim}>{tab.empty}</Text>}
300 {pageList[page].map(l => {
301 if (l.kind === 'group') {
302 return (
303 <Box flexDirection="row" gap={space.tight}>
304 <Text color={l.tone ?? color.text} bold>{l.name}</Text>
305 <Text color={color.dim}>{l.note}</Text>
306 </Box>
307 )
308 }
309 if (l.kind === 'note') return <Box paddingLeft={space.indent}><Text color={l.tone} wrap="truncate-end">{l.text}</Text></Box>
310 const on = l.f === chosen
311 return (
312 <Box flexDirection="row">
313 {cell(Box, Text, on ? '>' : ' ', pane.col.cursor, { color: color.accent, bold: true })}
314 {cell(Box, Text, l.f.heading.replace(/`/g, ''), null, { color: isDone(l.f.state) ? color.dim : color.text, bold: on })}
315 {repoCol && cell(Box, Text, l.repo, pane.col.repo, { color: color.dim })}
316 {wide && !isDone(l.f.state) && cell(Box, Text, step(l.f), pane.col.next, { color: color.dim })}
317 {wide && cell(Box, Text, l.f.spec ? `${l.f.spec} ${l.f.specStatus ?? 'unknown'}` : '', pane.col.spec, { color: color.dim })}
318 </Box>
319 )
320 })}
321 </Box>
322 {/* the selected feature in full, three rows always (blank when nothing is selected) so the height holds;
323 indented, not led by ">", so the cursor stays the only selection mark */}
324 <Box flexDirection="column" height={pane.detail}>
325 {detail.map(d => <Box paddingLeft={space.indent}><Text color={d.tone} wrap="truncate-end">{d.t}</Text></Box>)}
326 {chosen && <Box paddingLeft={space.indent}>{actionRow(el, parts)}</Box>}
327 </Box>
328 {/* every key always bound, dim when it would do nothing: an unbound key falls through to the prompt and takes focus */}
329 {keyRow(el, [
330 { key: 'j', label: 'down', on: feats.length > 1, press: () => act.go({ sel: Math.max(0, Math.min(feats.length - 1, sel + 1)) }) },
331 { key: 'k', label: 'up', on: feats.length > 1, press: () => act.go({ sel: Math.max(0, sel - 1) }) },
332 { key: 'd', label: `${wide ? 'page down' : 'pg'} ${page + 1}/${pages}`, on: page < pages - 1, press: () => act.go({ sel: page < pages - 1 ? pageFirst(page + 1) : sel }) },
333 { key: 'u', label: wide ? 'page up' : 'back', on: page > 0, press: () => act.go({ sel: page > 0 ? pageFirst(page - 1) : sel }) },
334 { key: 't', label: wide ? 'next group' : 'group', on: new Set(feats.map(x => norm(x.line.f.state))).size > 1, press: () => act.go({ sel: nextGroup() }) },
335 { key: 'g', label: 'top', on: sel > 0, press: () => act.go({ sel: 0 }) },
336 ], wide ? space.gap : space.tight)}
337 {warns.map(w => <Text color={color.warn} wrap="truncate-end">{w}</Text>)}
338 {plan.error && <Text color={color.warn}>{`stale since ${hhmm(plan.at ?? Date.now())}: the last refresh failed (${plan.error}); r tries again`}</Text>}
339 </Box>
340 )
341}
342
343// -- Agents --------------------------------------------------------------------------------------------------------
344
345const SHELL_NOTE = "Shells come from this chat's Bash results: a started one may have ended unseen."
346
347function AgentsSection({ el, agents, view, act, columns, extra = 0 }: { el: El; agents: any[] | null; view: View; act: Act; columns: number; extra?: number }) {
348 const { Box, Text } = el
349 if (agents === null) return <Box marginTop={space.line}><Text color={color.bad}>Could not list this session's agents.</Text></Box>
350 const sel = agents.length ? Math.min(view.gsel ?? 0, agents.length - 1) : -1
351 const chosen = sel >= 0 ? agents[sel] : null
352 const cells = columns - 2 * space.padX
353 const parts = actionParts(view, act, chosen && (chosen.status === 'running' || chosen.type === 'shell')
354 ? { key: 'x', label: 'stop it', ask: { kind: 'stop', id: chosen.id, label: `${chosen.type === 'shell' ? 'shell' : 'agent'} ${chosen.description || chosen.id}` } }
355 : null, cells)
356 // shells pile up over a long chat: show the rows that fit beside the note, the j/k row and the action, and follow
357 // the cursor, so the confirm's keys never fall off the bottom (design-gate 2026-10-18 pass 4)
358 const fit = Math.max(1, pane.listRows + extra - parts.rows - Math.ceil(SHELL_NOTE.length / Math.max(1, cells)) - 1)
359 const start = Math.max(0, sel - fit + 1)
360 const below = Math.max(0, agents.length - start - fit)
361 return (
362 <Box flexDirection="column">
363 {columns >= pane.specMin && <Text color={color.dim}>The agents this chat started, and what each is doing.</Text>}
364 <Box flexDirection="column">
365 {/* an empty list from the mod API is not proof of no agents: the host's own count may still show some */}
366 {agents.length === 0 && <Text color={color.dim}>No agents listed. The host's own agent count below the prompt may still show some.</Text>}
367 {agents.slice(start, start + fit).map(a => (
368 <Box flexDirection="row">
369 {cell(Box, Text, a === chosen ? '>' : ' ', pane.col.cursor, { color: color.accent, bold: true })}
370 {cell(Box, Text, a.status, pane.col.state, { color: (agentStatus as Record<string, string>)[a.status] ?? color.text, bold: a.status === 'running' })}
371 {cell(Box, Text, a.description || a.name || a.id, null, { color: color.text })}
372 {cell(Box, Text, a.type, pane.col.next, { color: color.dim })}
373 </Box>
374 ))}
375 </Box>
376 {(start > 0 || below > 0) && (
377 <Text color={color.dim}>{[start > 0 ? `${start} above` : '', below > 0 ? `+${below} more` : ''].filter(Boolean).join(', ')}</Text>
378 )}
379 <Text color={color.dim}>{SHELL_NOTE}</Text>
380 {keyRow(el, [
381 { key: 'j', label: 'down', on: sel < agents.length - 1, press: () => act.go({ gsel: Math.min(agents.length - 1, sel + 1) }) },
382 { key: 'k', label: 'up', on: sel > 0, press: () => act.go({ gsel: Math.max(0, sel - 1) }) },
383 ])}
384 {actionRow(el, parts)}
385 </Box>
386 )
387}
388
389// -- Now (spec 0071) -----------------------------------------------------------------------------------------------
390// This chat right now: what it owes, your open PRs, its handoff, its agents. Four blocks of fixed height (AC-9), so a
391// refresh never moves a row; each block draws `?` for what it could not read, never a 0.
392
393type NowLine = { t: string; tone: string; bold?: boolean; indent?: boolean }
394// agents with status running and shells not stopped or finished; null when either list could not be read
395export type AgentCount = { running: number; shells: number } | null
396
397// AC-2: up to `rows` rows, the heading counted; lines past them end in `and k more`
398export function owedLines(o: WfDashOwed | undefined, rows: number = pane.now.owed): NowLine[] {
399 if (!o) return [{ t: 'owed ?', tone: color.warn, bold: true }]
400 if (!o.installed) return [{ t: 'catch not installed (row 92)', tone: color.dim }]
401 if (o.lines === null) return [{ t: 'owed ?', tone: color.warn, bold: true }, { t: o.error ?? '', tone: color.dim, indent: true }]
402 if (!o.lines.length) return [{ t: 'nothing owed', tone: color.dim }]
403 const room = rows - 1
404 const shown = o.lines.length > room ? o.lines.slice(0, room - 1) : o.lines
405 return [
406 { t: `owed ${o.lines.length}`, tone: color.warn, bold: true },
407 ...shown.map(t => ({ t, tone: color.warn, indent: true })),
408 ...(shown.length < o.lines.length ? [{ t: `and ${o.lines.length - shown.length} more`, tone: color.dim, indent: true }] : []),
409 ]
410}
411
412const checksTone: Record<string, string> = { ok: color.ok, failing: color.bad, pending: color.warn, none: color.dim }
413
414// AC-3, AC-9: the PR block in `rows` rows (heading counted): the rows that fit beside a stale line and a "more" line,
415// following the cursor
416export function prBlock(l: WfDashLoaded<WfDashPrs> | undefined, nsel: number, rows: number) {
417 const data = l?.data ?? null
418 const head: NowLine = { t: `your PRs ${data ? data.total : glyph.unknown}`, tone: color.text, bold: true }
419 const none = { head, prs: [] as WfDashPr[], start: 0, sel: -1, notes: [] as NowLine[] }
420 if (!data) {
421 // before the first call ends, or a first call that failed: no list yet, so nothing to keep
422 const t = l?.error ? `could not read GitHub (${l.error}); r tries again` : 'reading GitHub'
423 return { ...none, notes: [{ t, tone: l?.error ? color.bad : color.dim, indent: true }] }
424 }
425 const stale: NowLine[] = l?.error ? [{ t: `stale since ${hhmm(l.at ?? Date.now())}: ${l.error}`, tone: color.warn, indent: true }] : []
426 if (!data.prs.length) return { ...none, notes: [...stale, { t: 'no open PRs', tone: color.dim, indent: true }] }
427 const onGitHub = Math.max(0, data.total - data.prs.length)
428 let fit = rows - 1 - stale.length
429 if (data.prs.length > fit || onGitHub > 0) fit -= 1
430 fit = Math.max(1, fit)
431 const sel = Math.max(0, Math.min(nsel, data.prs.length - 1))
432 const start = Math.max(0, sel - fit + 1)
433 const below = Math.max(0, data.prs.length - start - fit)
434 const more = [start > 0 ? `${start} above` : '', below > 0 ? `+${below} more` : '', onGitHub > 0 ? `+${onGitHub} more on GitHub` : ''].filter(Boolean).join(', ')
435 return { head, prs: data.prs.slice(start, start + fit), start, sel, notes: [...stale, ...(more ? [{ t: more, tone: color.dim, indent: true }] : [])] }
436}
437
438// AC-5: the band's own words for the handoff, then the handover this chat came from under the band's link rule
439export function handoffLines(h: WfDashHandoff | undefined): { runs: NowLine[]; link: string | null } {
440 const a = h?.auto
441 const word = a == null ? { t: glyph.unknown, tone: color.dim } : a.off ? { t: 'off', tone: color.warn } : { t: a.line ? `on ${tokens(a.line)}` : 'on', tone: color.text }
442 return { runs: [{ t: 'handoff ', tone: color.dim }, word], link: h ? handoverLink(h) : null }
443}
444
445export const agentsLine = (c: AgentCount) => (c ? `agents ${c.running} running, ${c.shells} shells` : `agents ${glyph.unknown}`)
446
447function NowSection({ el, view, act, prs, owed, handoff, count, columns, extra = 0 }: {
448 el: El; view: View; act: Act; prs: WfDashLoaded<WfDashPrs> | undefined; owed: WfDashOwed | undefined; handoff: WfDashHandoff | undefined; count: AgentCount; columns: number; extra?: number
449}) {
450 const { Box, Text } = el
451 const line = (l: NowLine) => (
452 <Box paddingLeft={l.indent ? space.indent : 0}><Text color={l.tone} bold={l.bold} wrap="truncate-end">{l.t}</Text></Box>
453 )
454 const block = prBlock(prs, view.nsel ?? 0, pane.now.prs + extra)
455 const total = prs?.data?.prs.length ?? 0
456 // the repo#number column takes the widest on screen, up to its cap, so the words line up; a narrow pane caps it at
457 // the plan's repo cells, so the title keeps some room (the row's words stay as AC-3 spells them)
458 const idW = Math.min(columns >= pane.specMin ? pane.col.prId : pane.col.repo, Math.max(0, ...block.prs.map(p => `${p.repo}#${p.number}`.length)))
459 const ho = handoffLines(handoff)
460 return (
461 <Box flexDirection="column">
462 <Box flexDirection="column" height={pane.now.owed}>{owedLines(owed).map(line)}</Box>
463 <Box flexDirection="column" height={pane.now.prs + extra}>
464 {line(block.head)}
465 {block.prs.map((p, i) => {
466 const on = block.start + i === block.sel
467 const id = `${p.repo}#${p.number}`
468 return (
469 <Box flexDirection="row">
470 {cell(Box, Text, on ? '>' : ' ', pane.col.cursor, { color: color.accent, bold: true })}
471 <Text wrap="truncate-end">
472 <Text color={color.text} bold={on}>{(id.length > idW ? id.slice(0, Math.max(0, idW - glyph.cut.length)) + glyph.cut : id).padEnd(idW)}</Text>
473 <Text color={p.draft ? color.dim : color.text}>{` ${p.draft ? 'draft' : 'ready'}`}</Text>
474 <Text color={color.dim}>{' checks '}</Text>
475 <Text color={checksTone[p.checks] ?? color.dim}>{p.checks.padEnd('failing'.length)}</Text>
476 <Text color={color.text}>{` ${p.title}`}</Text>
477 </Text>
478 </Box>
479 )
480 })}
481 {block.notes.map(line)}
482 </Box>
483 <Box flexDirection="column" height={pane.now.handoff}>
484 <Text wrap="truncate-end">{ho.runs.map(r => <Text color={r.tone}>{r.t}</Text>)}</Text>
485 {ho.link && line({ t: `last handover: ${ho.link}`, tone: color.dim, indent: true })}
486 </Box>
487 <Box flexDirection="row" height={pane.now.agents} columnGap={space.gap}>
488 {/* the count takes the row, so `a: details` holds its cell when a count grows a digit */}
489 <Box flexGrow={1} flexShrink={1}><Text color={count ? color.text : color.dim} wrap="truncate-end">{agentsLine(count)}</Text></Box>
490 <Text color={color.dim}>a: details</Text>
491 </Box>
492 {/* AC-8: the view's whole footer on one row, so the four blocks keep the pane's 14 rows */}
493 <Box flexDirection="row" columnGap={space.gap} flexWrap="wrap">
494 {keyRow(el, [
495 { key: 'j', label: 'down', on: block.sel >= 0 && block.sel < total - 1, press: () => act.go({ nsel: Math.min(total - 1, block.sel + 1) }) },
496 { key: 'k', label: 'up', on: block.sel > 0, press: () => act.go({ nsel: Math.max(0, block.sel - 1) }) },
497 { key: 'r', label: 'refresh', on: true, press: act.retry },
498 ])}
499 <Text color={color.dim}>esc closes</Text>
500 </Box>
501 </Box>
502 )
503}
504
505// -- The dashboard -------------------------------------------------------------------------------------------------
506
507export function Dashboard({ el, view, act, plan, agents, columns, cwd = null, focused = true, rows, prs, owed, handoff, count = null }: {
508 el: El; view: View; act: Act; plan: Plan; agents: any[] | null; columns: number; cwd?: string | null; focused?: boolean; rows?: number
509 prs?: WfDashLoaded<WfDashPrs>; owed?: WfDashOwed; handoff?: WfDashHandoff; count?: AgentCount
510}) {
511 const { Box, Text } = el
512 const section = SECTIONS.find(s => s.id === view.section) ?? SECTIONS[0]
513 // a docked pane's rows past the inline budget go to the lists, so the room is used, not left blank
514 const extra = rows ? Math.max(0, rows - pane.baseRows) : 0
515 return (
516 <Box flexDirection="column" paddingX={space.padX}>
517 <Box flexDirection="row" columnGap={space.gap}>
518 <Text backgroundColor={color.accent} color="inverseText" bold>{' chip '}</Text>
519 {keyRow(el, SECTIONS.map(s => ({ key: s.key, label: s.label, on: s.id === section.id, mark: true, press: () => act.go({ section: s.id, ask: null, notice: null }) })), columns >= pane.specMin ? space.gap : space.tight)}
520 </Box>
521 {/* a stray key hands the keyboard to the prompt; say so, and how to come back, instead of failing silently.
522 This used to name the host's focus key, which did not give the keys back in a real terminal; a click did (spec 0062 P11) */}
523 {!focused && <Text color={color.warn} wrap="truncate-end">Keys go to the prompt now: click the dashboard to return</Text>}
524 <Box flexDirection="column">
525 {section.id === 'plan' && <PlanSection el={el} plan={plan} view={view} act={act} columns={columns} cwd={cwd} extra={extra} />}
526 {section.id === 'now' && <NowSection el={el} view={view} act={act} prs={prs} owed={owed} handoff={handoff} count={count} columns={columns} extra={extra} />}
527 {section.id === 'agents' && <AgentsSection el={el} agents={agents} view={view} act={act} columns={columns} extra={extra} />}
528 </Box>
529 {section.id !== 'now' && (
530 <Box flexDirection="row" columnGap={space.gap} flexWrap="wrap">
531 {keyRow(el, [{ key: 'r', label: 'refresh', on: true, press: act.retry }])}
532 <Text color={color.dim}>esc closes</Text>
533 </Box>
534 )}
535 </Box>
536 )
537}
538types/index.d.ts 97 lines1export type WfDashFacts = {
2 model: string | null
3 effort: string | null
4 folder: string | null
5 // counts are null when the repo defines its own git filter: facts.py then skips `git status`
6 git: { branch: string; changes: number | null; staged: number | null; modified: number | null; untracked: number | null } | null
7 context: number | null
8 contextTokens: number | null
9 contextWindow: number | null
10 fiveHour: number | null
11 fiveHourReset: string | null
12 week: number | null
13 weekReset: string | null
14 via: string | null
15 staleSince: string | null
16 // the auto handoff segment on the right: undefined before the first read; null when no chain file names this chat
17 // ("handoff ?", never a guessed on or off)
18 handoff?: WfDashAuto | null
19 // spec 0071 AC-7: this chat's owed steps from the catch state file; undefined before the first read
20 owed?: WfDashOwed
21 // spec 0074 AC-12: running /build-features workflow agents
22 build?: number
23}
24
25// spec 0071 AC-2: ~/.claude/state/chain-catch/<session>.json (spec 0070) as the mod reads it. `installed` false: no
26// chain-catch folder; `lines` null with `error`: the file did not parse; `lines` [] : nothing owed
27export type WfDashOwed = { installed: boolean; lines: string[] | null; error: string | null }
28
29// spec 0071 AC-3: one open PR you opened, from the one gh api graphql search
30export type WfDashPr = { repo: string; number: number; title: string; draft: boolean; url: string; checks: string }
31// the search's answer: the PRs on its first page and `total`, its issueCount
32export type WfDashPrs = { total: number; prs: WfDashPr[] }
33
34export type WfDashFeature = {
35 heading: string
36 state: string
37 next: string | null
38 command: string | null
39 spec: string | null
40 specStatus: string | null
41 deferred: boolean
42}
43
44export type WfDashRepo = {
45 name: string
46 path: string
47 hub?: boolean
48 error?: string
49 warnings?: string[]
50 rows?: string[]
51 features?: WfDashFeature[]
52}
53
54// plan.py's answer (spec 0060 AC-1, the runner's parts gone)
55export type WfDashPlan = { repos: WfDashRepo[]; warnings: string[] }
56
57// an action waiting for its confirm (AC-5)
58export type WfDashAsk =
59 | { kind: 'start'; text: string }
60 | { kind: 'stop'; id: string; label: string }
61
62// spec 0064 AC-1: ~/.claude/state/auto-handoff/panel.json as the wf-handoff mod writes it
63export type WfDashPanel = {
64 session: string
65 previous: string | null
66 phase: string
67 steps: { label: string; state: 'pending' | 'running' | 'done' | 'failed' }[]
68 result: string | null
69 sticky: boolean
70 link: string | null
71 at: number
72}
73// this chat's auto handoff from its chain file in ~/.claude/state/auto-handoff/ (wf-handoff writes it): off, and the
74// line in tokens; null until the file names this chat
75export type WfDashAuto = { off: boolean; line: number | null }
76// the last panel that was this chat's, this chat's id and the one it replaced, when the owner last dismissed
77export type WfDashHandoff = { panel: WfDashPanel | null; me: string; prev: string | null; dismissed: number; frame: number; auto?: WfDashAuto | null }
78
79export type WfDashLoaded<T> = { data: T | null; at: number | null; error: string | null }
80
81declare module 'claude-code' {
82 interface PluginState {
83 'chip-dash': {
84 facts: WfDashFacts
85 effort: string | null
86 plan: WfDashLoaded<WfDashPlan>
87 view: {
88 section: string; tab: string; page: number; sel: number; gsel?: number; nsel?: number
89 ask?: WfDashAsk | null; notice?: { text: string; ok: boolean } | null
90 }
91 handoff: WfDashHandoff
92 prs: WfDashLoaded<WfDashPrs>
93 build: number
94 }
95 }
96}
97tokens.ts 173 lines1// chip-dash design tokens. Every color, glyph, width and timing in the mod comes from here.
2// Colors are Claude Code ThemeKeys, never hex, so the mod follows the user's terminal theme.
3
4export const color = {
5 text: "text",
6 dim: "inactive",
7 accent: "claude",
8 border: "inactive",
9 ok: "success",
10 warn: "warning",
11 bad: "error",
12 review: "suggestion",
13 design: "permission",
14} as const;
15
16// One word per state: color is never the only signal.
17export const state = {
18 "waits on you": { color: color.warn, word: "waits on you" },
19 "ready to build": { color: color.ok, word: "ready" },
20 "needs a design": { color: color.design, word: "needs design" },
21 "in progress": { color: color.text, word: "in progress" },
22 // "built", the filter's word, so one state has one name
23 "done": { color: color.dim, word: "built" },
24 "dropped": { color: color.dim, word: "dropped" },
25 "existing": { color: color.dim, word: "existing" },
26} as const;
27
28// Effort levels in jjstack's colors, as theme keys: low green, medium yellow, high magenta, xhigh cyan.
29export const effort = {
30 low: color.ok,
31 medium: color.warn,
32 high: color.design,
33 xhigh: color.review,
34 max: color.accent,
35 auto: color.dim,
36} as const;
37
38// Each plan state in plain words, for the detail line under the list.
39export const stateHelp: Record<string, string> = {
40 "waits on you": "a step only you can do",
41 "ready to build": "designed, ready to build",
42 "needs a design": "a decision is owed first (/architect)",
43 "in progress": "partly done, more steps to go",
44 "done": "built and merged",
45 "dropped": "dropped from the plan",
46 "existing": "was already there before the plan",
47};
48
49// Short filter labels for a narrow pane, where only the selected filter shows its count.
50export const shortTab: Record<string, string> = {
51 "needs-you": "Needs you", "ready": "Ready",
52 "design": "Design", "progress": "Progress", "built": "Built", "open": "All",
53};
54
55// The act-now row's labels when its counts would not fit one line at the pane's width (big counts, a 56-cell pane).
56export const tinyTab: Record<string, string> = {
57 "needs-you": "You", "ready": "Ready", "design": "Design", "progress": "Prog",
58};
59
60// A spec's status in plain words, for the plan's detail line.
61export const specStatus: Record<string, string> = {
62 "Proposed": "written, not accepted yet",
63 "Accepted": "accepted, ready to build",
64 "In Progress": "being built",
65 "Superseded": "replaced by a newer spec",
66 "unknown": "no spec file found",
67};
68
69// Agent status words and their colors.
70export const agentStatus = {
71 pending: color.dim,
72 running: color.text,
73 waiting: color.warn,
74 idle: color.dim,
75 completed: color.ok,
76 failed: color.bad,
77 killed: color.bad,
78} as const;
79
80// Panes: rows per page and the column widths, in cells.
81export const pane = {
82 page: 5,
83 // rows the selected feature's detail block always takes
84 detail: 3,
85 // rows asked for when the pane opens above the prompt: a page plus header, tabs and pager
86 rows: 21,
87 // rows the Agents list may use in a 14-row pane: the top row, the intro, the "+N more" line and the footer take 4
88 listRows: 10,
89 // cells the host's pane border takes from the viewport width (one each side)
90 frame: 2,
91 // body rows the layout above is budgeted for (the ~14-row inline pane); a docked pane's rows past this go to the lists
92 baseRows: 14,
93 // the narrowest text column the detail block wraps into, so a tiny pane still shows words, not single letters
94 minWrap: 10,
95 // repo: the plan's repo name, truncated past this; cursor: the ">" mark (its cell plus the cell's gap make the
96 // 2-cell indent)
97 // prId: the Now view's `repo#number` column at most, as wide as the plan's next step column
98 col: { state: 13, next: 24, spec: 15, repo: 14, cursor: 1, prId: 24 },
99 // under this many body columns (the host's bodyColumns, inside the frame) the plan drops its spec and next columns
100 // and the views their intro line; the panel docked beside a 118-column session is about 84 inside (critique round 7)
101 specMin: 80,
102 // spec 0071 AC-9: the Now view's blocks in the 14-row pane, each with its heading row; a docked pane's rows past
103 // 14 go to the PRs
104 now: { owed: 3, prs: 6, handoff: 2, agents: 1 },
105} as const;
106
107export const stateOrder = ["waits on you", "ready to build", "needs a design", "in progress", "done", "dropped", "existing"] as const;
108
109// Usage meters as today's status bar draws them (spec 0058): the label centred in `width`
110// cells, the filled share on a background color, the rest dim, then the number.
111// Green under 70, yellow 70-89, red 90 and up.
112export const meter = {
113 width: 5,
114 warnAt: 70,
115 badAt: 90,
116 fill: { ok: color.ok, warn: color.warn, bad: color.bad },
117 onFill: "inverseText",
118} as const;
119
120// ASCII only, so the band is copy-paste safe and needs no patched font.
121export const glyph = {
122 sep: " ",
123 unknown: "?",
124 dirty: "*",
125 // ends a line wrapWords had to cut
126 cut: "...",
127 // the handoff's running step (spec 0064 AC-2 names a braille spinner, the one non-ASCII glyph here)
128 spinner: ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"],
129} as const;
130
131// Spacing in terminal cells.
132export const space = {
133 gap: 2,
134 // the plan's 6 filters in a narrow pane: one cell between keys so the row fits one line
135 tight: 1,
136 // one blank row above an error or a view's closing note
137 line: 1,
138 padX: 1,
139 padY: 0,
140 indent: 2,
141 // cells the host keeps at the band's right end for its own `[-]` toggle (its bodyColumns already leaves them out)
142 hostReserve: 5,
143 // a plain hotkey button draws "w: " before its label
144 hotkeyCells: 3,
145} as const;
146
147// Fixed column widths so a tick never shifts the line.
148export const width = {
149 pct: 4,
150 state: 12,
151 spec: 8,
152 elapsed: 6,
153 // the terminal width assumed when the host sends neither bodyColumns nor a viewport
154 viewport: 80,
155} as const;
156
157export const border = {
158 style: "single",
159 dim: true,
160} as const;
161
162// Timings in ms. Ticks come from the specs (band 5 s, plan pane 10 s).
163export const time = {
164 bandTick: 5000,
165 planTick: 10000,
166 // spec 0064 AC-2: the handoff result shows this long; the spinner turns at `spin` while a step runs
167 handoffResult: 10000,
168 spin: 120,
169 // spec 0071 AC-4: the PR search runs every 2 minutes while Now is shown, and gives up after 10 s
170 prTick: 120000,
171 prTimeout: 10000,
172} as const;
173