An on-demand production board and unit detail pane.

Personal slash commands, hooks and scripts for Claude Code and Codex.
commands/ — slash commands and skillsscripts/ — the hooks and tools they rundocs/ — design notes and as-built recordshooks/register.tsx 287 lines1import type { BoardData } from '../types/board';
2import type { EngineInterface, Register, Timer } from 'claude-code';
3import type { AccountsPaneState, BoardSnapshot, PaneState } from '../types';
4import { loadAccounts, loadBoard, refreshIfChanged, resolveUserZone, snapshotPaths } from './data';
5import type { AccountsLoader, BoardLoader, RefreshPaths, UserZoneSettings } from './data';
6import { BoardView } from './board';
7import { UnitView } from './unit';
8import { AccountsView } from './accounts';
9import type { StoredAccountsStatus } from '../types/accounts';
10
11const PANE = 'unit-board';
12const ACCOUNTS = 'accounts-board';
13const ACCOUNT_PANES = { plugin: 'unit-board', key: 'accountsPanes' } as const;
14type OpenAccountsPane = { timer: Timer; busy: boolean; paths: RefreshPaths[]; zone: string };
15const PANES = { plugin: 'unit-board', key: 'panes' } as const;
16
17type Selection = { kind: 'board' } | { kind: 'unit'; production: string; unit: string };
18type OpenPane = { timer: Timer; cwd: string; session: string; busy: boolean; paths: RefreshPaths[]; selection: Selection };
19
20function boardCalls($: EngineInterface): BoardLoader {
21 return {
22 home: () => $.env.get('HOME'),
23 registry: () => $.env.get('NOTIFIER_STATE_DIR'),
24 now: () => $.clock.now(),
25 run: argv => $.process.run(argv),
26 read: path => $.fs.read(path),
27 };
28}
29
30function viewState(data: BoardSnapshot<BoardData>, selection: Selection, now: number): PaneState<BoardData> {
31 if (selection.kind === 'unit' && data.boards.find(board => board.production === selection.production)?.units.some(unit => unit.key === selection.unit)) {
32 return { ...selection, data, now };
33 }
34 return { kind: 'board', data, now };
35}
36
37function commandSelection(data: BoardSnapshot<BoardData>, wanted: string): Selection {
38 if (!wanted) return { kind: 'board' };
39 const board = data.boards.find(board => board.units.some(unit => unit.key === wanted));
40 return board ? { kind: 'unit', production: board.production, unit: wanted } : { kind: 'board' };
41}
42
43function retainWatch(active: OpenPane, path: RefreshPaths) {
44 if (path.kind === 'board') { active.paths.push(path); return; }
45 const previous = active.paths.filter(path => path.kind === 'discovery');
46 active.paths = [...active.paths.filter(path => path.kind === 'board'), {
47 kind: 'discovery', sources: [...new Set([...previous.flatMap(path => path.sources), ...path.sources])],
48 loadedAt: Math.min(path.loadedAt, ...previous.map(path => path.loadedAt)),
49 }];
50}
51
52// Only put writes these entries; the self-contained engine contract cannot import BoardData.
53const readPane = async ($: EngineInterface, id: string) =>
54 (await $.state.get(PANES)).value?.[id] as PaneState<BoardData> | undefined;
55const put = async ($: EngineInterface, id: string, state: PaneState<BoardData>, isOpen: () => boolean) => {
56 if (!isOpen()) return;
57 const old = (await $.state.get(PANES)).value ?? {};
58 if (isOpen()) await $.state.set(PANES, { ...old, [id]: state });
59};
60const stop = async ($: EngineInterface, opened: Map<string, OpenPane>, id: string) => {
61 opened.get(id)?.timer.cancel();
62 opened.delete(id);
63 const panes = { ...(await $.state.get(PANES)).value };
64 delete panes[id];
65 await $.state.set(PANES, panes);
66};
67const tick = async ($: EngineInterface, opened: Map<string, OpenPane>, id: string) => {
68 const active = opened.get(id);
69 if (!active || active.busy) return;
70 const isOpen = () => opened.get(id) === active;
71 active.busy = true;
72 try {
73 const state = await readPane($, id);
74 if (!state || !isOpen()) return;
75 const regenerated = await refreshIfChanged({ stat: path => $.fs.stat(path) }, active.paths,
76 () => loadBoard(boardCalls($), active.cwd, active.session, isOpen, path => retainWatch(active, path)), async data => {
77 const now = await $.clock.now() / 1000;
78 if (!isOpen()) return;
79 active.paths = snapshotPaths(data);
80 const fresh = viewState(data, active.selection, now);
81 active.selection = fresh.kind === 'unit' ? { kind: 'unit', production: fresh.production, unit: fresh.unit } : { kind: 'board' };
82 await put($, id, fresh, isOpen);
83 }, isOpen);
84 if (regenerated || !isOpen()) return;
85 if (state.kind === 'board' || state.kind === 'unit') {
86 await put($, id, { ...state, now: await $.clock.now() / 1000 }, isOpen);
87 }
88 } catch (error) {
89 await put($, id, { kind: 'failed', reason: String(error) }, isOpen);
90 } finally { active.busy = false; }
91};
92const startTimer = ($: EngineInterface, opened: Map<string, OpenPane>, id: string, cwd: string, session: string) => {
93 const previous = opened.get(id);
94 previous?.timer.cancel();
95 opened.set(id, { cwd, session, busy: false, paths: previous?.paths ?? [], selection: previous?.selection ?? { kind: 'board' },
96 timer: $.clock.every(10_000, () => { void tick($, opened, id); }) });
97};
98const select = async ($: EngineInterface, opened: Map<string, OpenPane>, id: string, selection: Selection) => {
99 const active = opened.get(id);
100 if (!active) return;
101 const current = await readPane($, id);
102 if (current?.kind !== 'board' && current?.kind !== 'unit') return;
103 active.selection = selection;
104 await put($, id, viewState(current.data, selection, current.now), () => opened.get(id) === active);
105};
106
107const putAccounts = async ($: EngineInterface, state: AccountsPaneState<StoredAccountsStatus>, isOpen: () => boolean) => {
108 if (!isOpen()) return;
109 const previous = (await $.state.get(ACCOUNT_PANES)).value ?? {};
110 if (isOpen()) await $.state.set(ACCOUNT_PANES, { ...previous, [ACCOUNTS]: state });
111};
112function accountsCalls($: EngineInterface): AccountsLoader {
113 return {
114 home: () => $.env.get('HOME'), now: () => $.clock.now(),
115 stat: path => $.fs.stat(path), run: argv => $.process.run(argv),
116 };
117}
118function zoneSettings($: EngineInterface): UserZoneSettings {
119 return {
120 override: () => $.env.get('USER_ZONE'), config: () => $.env.get('USER_ZONE_CONF'),
121 home: () => $.env.get('HOME'), read: path => $.fs.read(path),
122 };
123}
124const readAccounts = async ($: EngineInterface, opened: Map<string, OpenAccountsPane>, active: OpenAccountsPane) => {
125 const isOpen = () => opened.get(ACCOUNTS) === active;
126 try {
127 const data = await loadAccounts(accountsCalls($), isOpen, paths => { if (isOpen()) active.paths = paths; });
128 await putAccounts($, { kind: 'accounts', data, zone: active.zone, now: await $.clock.now() / 1000 }, isOpen);
129 } catch {
130 // Never include process output or a login in an error or command result.
131 await putAccounts($, { kind: 'failed', reason: 'Stored accounts status could not be read.' }, isOpen);
132 }
133};
134const tickAccounts = async ($: EngineInterface, opened: Map<string, OpenAccountsPane>) => {
135 const active = opened.get(ACCOUNTS);
136 if (!active || active.busy) return;
137 const isOpen = () => opened.get(ACCOUNTS) === active;
138 active.busy = true;
139 try {
140 const regenerated = await refreshIfChanged({ stat: path => $.fs.stat(path) }, active.paths,
141 () => loadAccounts(accountsCalls($), isOpen, paths => { if (isOpen()) active.paths = paths; }),
142 async data => putAccounts($, { kind: 'accounts', data, zone: active.zone, now: await $.clock.now() / 1000 }, isOpen), isOpen);
143 if (regenerated || !isOpen()) return;
144 const state = (await $.state.get(ACCOUNT_PANES)).value?.[ACCOUNTS] as AccountsPaneState<StoredAccountsStatus> | undefined;
145 if (state?.kind === 'accounts') {
146 await putAccounts($, { ...state, now: await $.clock.now() / 1000 }, isOpen);
147 }
148 } catch {
149 await putAccounts($, { kind: 'failed', reason: 'Stored accounts status could not be read.' }, isOpen);
150 } finally { active.busy = false; }
151};
152const startAccounts = ($: EngineInterface, opened: Map<string, OpenAccountsPane>, zone: string) => {
153 const previous = opened.get(ACCOUNTS);
154 previous?.timer.cancel();
155 const active: OpenAccountsPane = { busy: false, paths: previous?.paths ?? [], zone,
156 timer: $.clock.every(10_000, () => { void tickAccounts($, opened); }) };
157 opened.set(ACCOUNTS, active);
158 return active;
159};
160const stopAccounts = async ($: EngineInterface, opened: Map<string, OpenAccountsPane>) => {
161 opened.get(ACCOUNTS)?.timer.cancel();
162 opened.delete(ACCOUNTS);
163 const panes = { ...(await $.state.get(ACCOUNT_PANES)).value };
164 delete panes[ACCOUNTS];
165 await $.state.set(ACCOUNT_PANES, panes);
166};
167
168export const register: Register = on => {
169 const opened = new Map<string, OpenPane>();
170 const accounts = new Map<string, OpenAccountsPane>();
171 let accountsLifecycle = 0;
172
173 on('session.start', async ($, e, next) => {
174 const lifecycle = ++accountsLifecycle;
175 await $.command.register({ name: ACCOUNTS, description: 'Show the stored accounts pane' });
176 const panes = await $.ui.panes();
177 if (panes.some(pane => pane.id === PANE)) {
178 startTimer($, opened, PANE, e.cwd, await $.session.id());
179 const active = opened.get(PANE)!;
180 const state = await readPane($, PANE);
181 if (state?.kind === 'board' || state?.kind === 'unit') {
182 active.paths = snapshotPaths(state.data);
183 active.selection = state.kind === 'unit' ? { kind: 'unit', production: state.production, unit: state.unit } : { kind: 'board' };
184 }
185 await tick($, opened, PANE);
186 }
187 if (panes.some(pane => pane.id === ACCOUNTS)) {
188 const zone = await resolveUserZone(zoneSettings($), () => accountsLifecycle === lifecycle);
189 if (zone.kind === 'resolved' && accountsLifecycle === lifecycle) {
190 const active = startAccounts($, accounts, zone.name);
191 if (active.paths.length) await tickAccounts($, accounts);
192 else {
193 active.busy = true;
194 try { await readAccounts($, accounts, active); } finally { active.busy = false; }
195 }
196 }
197 }
198 return next(e);
199 });
200 on('command.run', { command: 'showrunner:unit_board' }, async ($, e) => {
201 const wanted = e.args.trim();
202 if (wanted === 'close') {
203 await $.ui.close({ id: PANE });
204 return { text: 'Unit board closed.' };
205 }
206 const cwd = await $.session.cwd();
207 const session = await $.session.id();
208 opened.get(PANE)?.timer.cancel();
209 opened.delete(PANE);
210 await $.ui.open({ id: PANE, title: 'Units', closeOnEscape: true });
211 startTimer($, opened, PANE, cwd, session);
212 const active = opened.get(PANE)!;
213 const isOpen = () => opened.get(PANE) === active;
214 active.busy = true;
215 await put($, PANE, { kind: 'loading' }, isOpen);
216 try {
217 const data = await loadBoard(boardCalls($), cwd, session, isOpen, path => retainWatch(active, path));
218 if (isOpen()) {
219 active.paths = snapshotPaths(data);
220 active.selection = commandSelection(data, wanted);
221 const now = await $.clock.now() / 1000;
222 await put($, PANE, viewState(data, active.selection, now), isOpen);
223 }
224 } catch (error) {
225 await put($, PANE, { kind: 'failed', reason: String(error) }, isOpen);
226 } finally { active.busy = false; }
227 return { text: 'Unit board opened.' };
228 });
229 on('command.run', { command: ACCOUNTS }, async ($, e) => {
230 if (e.args.trim() === 'close') {
231 await $.ui.close({ id: ACCOUNTS });
232 return { text: 'Accounts closed.' };
233 }
234 const lifecycle = ++accountsLifecycle;
235 accounts.get(ACCOUNTS)?.timer.cancel();
236 accounts.delete(ACCOUNTS);
237 const zone = await resolveUserZone(zoneSettings($), () => accountsLifecycle === lifecycle);
238 if (zone.kind === 'cancelled' || accountsLifecycle !== lifecycle) return { text: 'Accounts closed.' };
239 await $.ui.open({ id: ACCOUNTS, title: 'Accounts', closeOnEscape: true });
240 if (accountsLifecycle !== lifecycle) return { text: 'Accounts closed.' };
241 const active = startAccounts($, accounts, zone.name);
242 const isOpen = () => accounts.get(ACCOUNTS) === active;
243 active.busy = true;
244 try {
245 await putAccounts($, { kind: 'loading' }, isOpen);
246 await readAccounts($, accounts, active);
247 } finally { active.busy = false; }
248 return { text: 'Accounts opened.' };
249 });
250 on('turn.complete', async ($, e, next) => {
251 if (opened.size === 0) return next(e);
252 for (const id of opened.keys()) await tick($, opened, id);
253 return next(e);
254 });
255 on('ui.close', async ($, e, next) => {
256 if (e.id === PANE) await stop($, opened, e.id);
257 if (e.id === ACCOUNTS) {
258 accountsLifecycle++;
259 await stopAccounts($, accounts);
260 }
261 return next(e);
262 });
263 on('ui.render', { component: 'Pane', requestId: ACCOUNTS }, async ($, e) => {
264 const state = (await $.state.get(ACCOUNT_PANES)).value?.[ACCOUNTS] as AccountsPaneState<StoredAccountsStatus> | undefined;
265 const elements = $.ui.resolve(e);
266 const { Text } = elements;
267 if (!state || state.kind === 'loading') return <Text color="inactive">Loading accounts…</Text>;
268 if (state.kind === 'failed') return <Text color="error">{state.reason}</Text>;
269 return AccountsView(elements, e.props.bodyColumns, state.data, state.zone, state.now);
270 });
271 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
272 const state = await readPane($, PANE);
273 const elements = $.ui.resolve(e);
274 const { Text } = elements;
275 if (!state || state.kind === 'loading') return <Text color="inactive">Loading unit board…</Text>;
276 if (state.kind === 'failed') return <Text color="error">{state.reason}</Text>;
277 if (state.kind === 'unit') {
278 const data = state.data.boards.find(board => board.production === state.production);
279 const unit = data?.units.find(unit => unit.key === state.unit);
280 if (data && unit) return UnitView(elements, e.props.bodyColumns, e.props.scroll.bodyRows, data, unit, state.now,
281 () => { void select($, opened, PANE, { kind: 'board' }); });
282 }
283 return BoardView(elements, e.props.bodyColumns, e.props.scroll.bodyRows, state.data, state.now,
284 (production, unit) => { void select($, opened, PANE, { kind: 'unit', production, unit }); });
285 });
286};
287hooks/data.ts 162 lines1import type { ProcessRunResult } from 'claude-code';
2import type { StoredAccountsStatus } from '../types/accounts';
3import type { BoardData, BoardResult } from '../types/board';
4import type { BoardSnapshot } from '../types';
5
6export type RefreshPaths =
7 | { kind: 'board'; board: string; sources: string[] }
8 | { kind: 'discovery'; sources: string[]; loadedAt: number };
9export interface RefreshFiles { stat(path: string): Promise<{ mtimeMs: number }> }
10export interface BoardLoader {
11 home(): Promise<string | undefined>;
12 registry(): Promise<string | undefined>;
13 now(): Promise<number>;
14 run(argv: readonly string[]): Promise<ProcessRunResult>;
15 read(path: string): Promise<string>;
16}
17
18/** Shared by panes; it owns neither a timer nor any pane's state. */
19export async function refreshIfChanged<T>(
20 files: RefreshFiles,
21 paths: readonly RefreshPaths[],
22 generator: () => Promise<T>,
23 set: (fresh: T) => Promise<void>,
24 isOpen: () => boolean,
25): Promise<boolean> {
26 let changed = false;
27 for (const path of paths) {
28 if (!isOpen()) return false;
29 let written: number;
30 if (path.kind === 'discovery') written = path.loadedAt;
31 else {
32 try { written = (await files.stat(path.board)).mtimeMs; }
33 catch { changed = true; continue; }
34 }
35 for (const source of path.sources) {
36 if (!isOpen()) return false;
37 try { if ((await files.stat(source)).mtimeMs > written) changed = true; }
38 catch { changed = true; }
39 }
40 }
41 if (!changed || !isOpen()) return false;
42 const fresh = await generator();
43 if (!isOpen()) return false;
44 await set(fresh);
45 return true;
46}
47
48export function snapshotPaths(snapshot: BoardSnapshot<BoardData>): RefreshPaths[] {
49 return [
50 ...snapshot.files.map((board, index): RefreshPaths => ({ kind: 'board', board, sources: snapshot.boards[index]?.sources ?? [] })),
51 { kind: 'discovery', sources: snapshot.watch, loadedAt: snapshot.loadedAt },
52 ];
53}
54
55export async function loadBoard(
56 calls: BoardLoader, cwd: string, session: string, isOpen: () => boolean,
57 watch: (paths: RefreshPaths) => void,
58): Promise<BoardSnapshot<BoardData>> {
59 const ensureOpen = () => { if (!isOpen()) throw new Error('Pane closed.'); };
60 ensureOpen();
61 const home = await calls.home();
62 if (!home) throw new Error('Home directory is unavailable.');
63 const registry = await calls.registry();
64 const loadedAt = await calls.now();
65 ensureOpen();
66 watch({ kind: 'discovery', sources: [registry || `${home}/.local/state/notifier`], loadedAt });
67 const result = await calls.run([
68 `${home}/.claude/scripts/lib/py`, `${home}/.claude/scripts/direct/board_json.py`, '--cwd', cwd, '--session', session,
69 ]);
70 ensureOpen();
71 if (result.exitCode !== 0) throw new Error(result.stderr.trim() || 'The board could not be refreshed.');
72 const response = JSON.parse(result.stdout) as BoardResult;
73 for (const paths of [response.files, response.watch]) {
74 if (!Array.isArray(paths) || !paths.every(path => typeof path === 'string')) {
75 throw new Error('The board generator returned an invalid path list.');
76 }
77 }
78 watch({ kind: 'discovery', sources: response.watch, loadedAt });
79 const boards: BoardData[] = [];
80 for (const path of response.files) {
81 ensureOpen();
82 boards.push(JSON.parse(await calls.read(path)) as BoardData);
83 }
84 ensureOpen();
85 return { files: response.files, boards, watch: response.watch, loadedAt };
86}
87
88export interface AccountsLoader {
89 home(): Promise<string | undefined>;
90 now(): Promise<number>;
91 run(argv: readonly string[]): Promise<ProcessRunResult>;
92 stat(path: string): Promise<{ mtimeMs: number }>;
93}
94
95export interface UserZoneSettings {
96 override(): Promise<string | undefined>;
97 config(): Promise<string | undefined>;
98 home(): Promise<string | undefined>;
99 read(path: string): Promise<string>;
100}
101export type ZoneLookup = { kind: 'resolved'; name: string } | { kind: 'cancelled' };
102
103/** Matches scripts/lib/user_zone.py, including its configurable settings path. */
104export async function resolveUserZone(calls: UserZoneSettings, isActive: () => boolean): Promise<ZoneLookup> {
105 if (!isActive()) return { kind: 'cancelled' };
106 let name = await calls.override();
107 if (!isActive()) return { kind: 'cancelled' };
108 if (!name) {
109 const configured = await calls.config();
110 if (!isActive()) return { kind: 'cancelled' };
111 const home = configured ? '' : await calls.home();
112 if (!isActive()) return { kind: 'cancelled' };
113 let contents = '';
114 try { contents = await calls.read(configured || `${home}/.claude/config/user_zone.conf`); }
115 catch { /* An absent setting uses the repository's default. */ }
116 if (!isActive()) return { kind: 'cancelled' };
117 name = contents.split(/\r?\n/).find(line => line.startsWith('zone='))?.split('=').slice(1).join('=').trim();
118 }
119 name ||= 'America/New_York';
120 // Validate before storing the zone, as the Python resolver does.
121 new Intl.DateTimeFormat('en-US', { timeZone: name });
122 return { kind: 'resolved', name };
123}
124
125/** Only stored-status inputs and their existing discovery directories are inspected. */
126export async function loadAccounts(
127 calls: AccountsLoader, isOpen: () => boolean, watch: (paths: RefreshPaths[]) => void,
128): Promise<StoredAccountsStatus> {
129 const ensureOpen = () => { if (!isOpen()) throw new Error('Pane closed.'); };
130 ensureOpen();
131 const home = await calls.home();
132 if (!home) throw new Error('Home directory is unavailable.');
133 const loadedAt = await calls.now();
134 const sources = new Set<string>();
135 const inputs = [`${home}/.local/state/agent-notes/usage.json`, `${home}/.local/state/accounts/groups.json`,
136 `${home}/.claude/config/accounts.json`, `${home}/.local/state/accounts/role_map_cache.json`];
137 for (const input of inputs) {
138 let path = input;
139 while (path) {
140 ensureOpen();
141 try {
142 await calls.stat(path);
143 sources.add(path);
144 break;
145 } catch { /* A missing input is discovered through its closest existing parent. */ }
146 path = path.slice(0, path.lastIndexOf('/')) || (path === '/' ? '' : '/');
147 }
148 watch([{ kind: 'discovery', sources: [...sources], loadedAt }]);
149 }
150 ensureOpen();
151 const result = await calls.run([`${home}/.claude/scripts/lib/py`, `${home}/.claude/scripts/accounts/accounts.py`,
152 'status', '--json', '--stored']);
153 ensureOpen();
154 // Failure details never leave the pane loader: the pane shows a fixed reason, never process output.
155 if (result.exitCode !== 0) throw new Error('Stored accounts status could not be read.');
156 try {
157 const status = JSON.parse(result.stdout) as StoredAccountsStatus;
158 if (!Array.isArray(status.accounts) || !Array.isArray(status.problems)) throw new Error();
159 return status;
160 } catch { throw new Error('Stored accounts status is invalid.'); }
161}
162hooks/board.tsx 226 lines1import type { Elements } from 'claude-code';
2
3export type PaneElements = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>;
4import type { BoardData, BoardUnit } from '../types/board';
5import type { BoardSnapshot } from '../types';
6import type { Tone } from './geometry';
7import { cellWidth, clockText, doneText, elapsedText, overdueText, rulerCells, runsOf, shortTime, signedMinutes, stripCells, wrappedLines } from './geometry';
8
9/** The detail view retains the complete ETA history. */
10export function etaText(unit: BoardUnit, now = 0): string {
11 const eta = unit.eta;
12 if (!eta?.at) return '';
13 const history = eta.moved > 0
14 ? `first ${eta.first?.text ?? '—'} · moved ${eta.moved}× · ${signedMinutes(eta.delta_minutes ?? 0)} ${(eta.delta_minutes ?? 0) < 0 ? 'early' : 'late'}`
15 : 'on its first ETA';
16 const overdue = unit.phase ? overdueText(eta.at.at, now) : '';
17 return `ETA ${eta.at.text}${overdue} ${history} · fix rounds ${eta.fix_done}${eta.fix_running ? ' + 1 running' : ''}`;
18}
19
20export function gateText(unit: BoardUnit): string {
21 const phase = unit.phases.find(row => row.status === 'gated');
22 if (!phase || phase.gate.kind === 'none') return '';
23 const gate = phase.gate;
24 return gate.kind === 'timed'
25 ? `Phase ${phase.id} gated until ${gate.until.text} — ${gate.gate}: ${gate.waits_on}`
26 : `Phase ${phase.id} gated by ${gate.gate}: ${gate.waits_on}`;
27}
28
29export function finishText(unit: BoardUnit, now = 0): string {
30 const finish = unit.finish;
31 switch (finish.kind) {
32 case 'predicted': return `done by ${finish.at.text}${overdueText(finish.at.at, now)}`;
33 case 'finished': return `finished ${finish.at.text}`;
34 case 'after_gate': return `after ${finish.gate}`;
35 case 'unknown': return '';
36 }
37}
38
39function boardGateText(unit: BoardUnit, now: number, utcOffsetMinutes: number): string {
40 const phase = unit.phases.find(row => row.status === 'gated');
41 if (!phase || phase.gate.kind === 'none') return '';
42 const gate = phase.gate;
43 return `Phase ${phase.id} waits on ${gate.gate}${gate.kind === 'timed' ? ` until ${shortTime(gate.until, now, utcOffsetMinutes)}` : ''}: ${gate.waits_on}`;
44}
45
46function runFinishText(unit: BoardUnit, now: number, utcOffsetMinutes: number): string {
47 const finish = unit.finish;
48 switch (finish.kind) {
49 case 'predicted': return `run ends ${shortTime(finish.at, now, utcOffsetMinutes)}${finish.at.at < now ? ` · late ${elapsedText(now - finish.at.at)}` : ''}`;
50 case 'finished': return `run ended ${shortTime(finish.at, now, utcOffsetMinutes)}`;
51 case 'after_gate': return `run ends after ${finish.gate}`;
52 case 'unknown': return '';
53 }
54}
55
56/** The board has no ruler; the detail view uses this shared renderer. */
57export function RulerView(elements: PaneElements, unit: BoardUnit, data: BoardData, now: number, width: number) {
58 const { Box, Text } = elements;
59 if (!unit.phase) return <Box />;
60 const ruler = rulerCells(unit, now, width, data.utc_offset_minutes);
61 const paint = (cells: typeof ruler.track) => runsOf(cells).map(run => run.tone === 'default'
62 ? <Text>{run.text}</Text> : <Text color={run.tone}>{run.text}</Text>);
63 return <Box flexDirection="column">
64 <Text>{paint(ruler.track)}</Text>
65 <Text>{paint(ruler.labelCells)}</Text>
66 </Box>;
67}
68
69function workerCounts(unit: BoardUnit): [string, number][] {
70 const counts = new Map<string, number>();
71 for (const worker of unit.workers) {
72 if (worker.state !== 'ended') counts.set(worker.state, (counts.get(worker.state) ?? 0) + 1);
73 }
74 const priority = (state: string) => ['running', 'idle', 'paused'].indexOf(state);
75 return [...counts].sort(([left], [right]) => {
76 const a = priority(left), b = priority(right);
77 return (a < 0 ? 3 : a) - (b < 0 ? 3 : b) || left.localeCompare(right, undefined, { sensitivity: 'base' });
78 });
79}
80
81export function workersText(unit: BoardUnit): string {
82 return workerCounts(unit).map(([state, count]) => `${count} ${state}`).join(' · ');
83}
84
85export function healthTone(unit: BoardUnit, now: number): Tone {
86 if ((unit.eta?.at && unit.eta.at.at < now) || (unit.finish.kind === 'predicted' && unit.finish.at.at < now)) return 'error';
87 if (unit.phases.some(phase => phase.status === 'gated') || (unit.phase && !unit.eta?.at)) return 'warning';
88 if (unit.state === 'running') return 'suggestion';
89 if (unit.state === 'run-finished') return 'success';
90 return 'inactive';
91}
92
93/** Styling stays attached to text even when a fact wraps onto another row. */
94export interface BoardSpan { text: string; tone: Tone; kind: 'fact' | 'name' | 'bold' }
95export type BoardLine = BoardSpan[];
96interface UnitBlock { unit: BoardUnit; lines: BoardLine[]; workers: BoardLine[] }
97export const span = (text: string, tone: Tone = 'default', kind: BoardSpan['kind'] = 'fact'): BoardSpan => ({ text, tone, kind });
98const lineText = (parts: BoardLine) => parts.map(part => part.text).join('');
99
100export function wrapLine(parts: BoardLine, width: number, indent = 0): BoardLine[] {
101 indent = Math.max(0, Math.min(width - 1, indent));
102 const text = lineText(parts);
103 const room = Math.max(1, width - indent);
104 const wrapped = wrappedLines(text, room, indent ? 0 : 2);
105 let cursor = 0;
106 return wrapped.map(line => {
107 const content = line.trimStart();
108 const start = text.indexOf(content, cursor);
109 const end = start + content.length;
110 const row: BoardLine = [span(' '.repeat(indent) + line.slice(0, line.length - content.length))];
111 let offset = 0;
112 for (const part of parts) {
113 const from = Math.max(start, offset);
114 const to = Math.min(end, offset + part.text.length);
115 if (from < to) row.push({ ...part, text: part.text.slice(from - offset, to - offset) });
116 offset += part.text.length;
117 }
118 cursor = end;
119 return row;
120 });
121}
122
123/** A right-hand fact moves intact to its next row before either side wraps. */
124export function pairedLines(left: BoardLine, right: BoardLine, width: number, indent = 2, rightIndent = indent): BoardLine[] {
125 if (!lineText(left)) return wrapLine(right, width, rightIndent);
126 if (!lineText(right)) return wrapLine(left, width, indent);
127 const spacing = width - indent - cellWidth(lineText(left)) - cellWidth(lineText(right));
128 if (spacing >= 2) {
129 return wrapLine([...left, span(' '.repeat(spacing)), ...right], width, indent);
130 }
131 return [...wrapLine(left, width, indent), ...wrapLine(right, width, rightIndent)];
132}
133
134function unitBlock(unit: BoardUnit, data: BoardData, now: number, width: number): UnitBlock {
135 const done = unit.phases.filter(phase => phase.status === 'done').length;
136 const progress = `${done}/${unit.phases.length}`;
137 const finish = runFinishText(unit, now, data.utc_offset_minutes);
138 const finishTone = unit.finish.kind === 'predicted' && unit.finish.at.at < now ? 'error' : 'default';
139 const heading = [span('● ', healthTone(unit, now)), span(unit.key, 'default', 'name')];
140 if (!unit.phase) {
141 heading.push(span(` ${unit.stage}${unit.phases.length ? ` · ${progress}` : ''}`));
142 if (finish) heading.push(span(` · ${finish}`, finishTone));
143 return { unit, lines: wrapLine(heading, width), workers: [] };
144 }
145 const phase = unit.phase;
146 heading.push(span(phase.kind === 'ticket' ? ` Ticket ${phase.id} · ${phase.merged} merged ${phase.title}` : ` Phase ${phase.id} ${phase.title}`));
147 const left: BoardLine = runsOf(stripCells(unit.phases)).map(run => span(run.text, run.tone));
148 if (left.length) left.push(span(' '));
149 left.push(span(progress));
150 if (unit.stage !== `Phase ${phase.id} running` && unit.stage !== `Ticket ${phase.id} running` && unit.stage) left.push(span(` ${unit.stage}`));
151 const fixes = unit.eta?.fix_done ?? unit.phases.find(row => row.id === phase.id)?.fix_done ?? 0;
152 const fixing = unit.eta?.fix_running ?? unit.phases.find(row => row.id === phase.id)?.fix_running ?? 0;
153 if (fixes > 0) left.push(span(` · fix rounds ${fixes}${fixing ? ' + 1 running' : ''}`, fixes >= 3 ? 'warning' : 'default'));
154 const eta = unit.eta?.at;
155 const right = eta ? [span(`ETA ${shortTime(eta, now, data.utc_offset_minutes)} · ${eta.at < now ? 'late' : 'in'} ${elapsedText(eta.at - now)}`, eta.at < now ? 'error' : 'default')]
156 : [span('no ETA', 'warning')];
157 const lines = [...wrapLine(heading, width), ...pairedLines(left, right, width)];
158 const gate = boardGateText(unit, now, data.utc_offset_minutes);
159 if (gate) lines.push(...wrapLine([span(gate, 'warning')], width, 2));
160 const workers: BoardLine = [];
161 const counts = workerCounts(unit);
162 if (counts.length) {
163 workers.push(span('workers ', 'inactive'));
164 counts.forEach(([state, count], index) => {
165 if (index) workers.push(span(' · ', 'inactive'));
166 workers.push(span(`${count} ${state}`, state === 'running' ? 'suggestion' : state === 'paused' ? 'warning' : 'default'));
167 });
168 }
169 return { unit, lines, workers: pairedLines(workers, finish ? [span(finish, finishTone)] : [], width) };
170}
171
172export function BoardView(elements: PaneElements, columns: number, bodyRows: number, snapshot: BoardSnapshot<BoardData>, now: number, select: (production: string, unit: string) => void) {
173 const { Box, Text, Button } = elements;
174 const width = Math.max(1, Math.floor(columns));
175 const limit = Math.max(0, Math.floor(bodyRows));
176 const boards = snapshot.boards.map(data => {
177 const late = data.units.filter(unit => healthTone(unit, now) === 'error').length;
178 const counts = [span(`${data.units.length} units`, 'inactive')];
179 if (late) counts.push(span(' · ', 'inactive'), span(`${late} late`, 'error'));
180 counts.push(span(` · as of ${clockText(data.as_of.at, data.utc_offset_minutes)}`, 'inactive'));
181 const name = [span(data.production, 'default', 'bold')];
182 const spacing = width - cellWidth(data.production) - cellWidth(lineText(counts));
183 const header = spacing >= 2 ? wrapLine([...name, span(' '.repeat(spacing)), ...counts], width)
184 : pairedLines(name, counts, width, 0);
185 return { data, header, blocks: [...data.units].sort((a, b) => a.key.localeCompare(b.key, undefined, { sensitivity: 'base' }))
186 .map(unit => unitBlock(unit, data, now, width)), hidden: [] as BoardUnit[], showOverflow: true };
187 });
188 let gap = 1;
189 const height = () => boards.reduce((total, board) => total + board.header.length
190 + board.blocks.reduce((count, block) => count + block.lines.length + block.workers.length, 0)
191 + board.blocks.length * gap + Number(board.showOverflow && board.hidden.length > 0), 0);
192 // Line 3 goes first, from the bottom, including all of its continuation rows.
193 for (const block of boards.flatMap(board => board.blocks).reverse()) {
194 if (height() <= limit) break;
195 block.workers = [];
196 }
197 if (height() > limit) gap = 0;
198 for (const board of [...boards].reverse()) {
199 while (board.blocks.length && height() > limit) board.hidden.unshift(board.blocks.pop()!.unit);
200 }
201 // Tiny panes can retain the hidden-unit notice after the production header.
202 for (const board of [...boards].reverse()) {
203 if (height() > limit) board.header = [];
204 }
205 for (const board of [...boards].reverse()) {
206 if (height() > limit) board.showOverflow = false;
207 }
208 function drawLine(parts: BoardLine, production: string, key: string, index: number) {
209 return <Box flexDirection="row" flexShrink={0}>{parts.filter(part => part.text).map((part, partIndex) => {
210 if (part.kind === 'name') return <Button key={index === 0 ? `open-${production}-${key}` : `open-${production}-${key}-${index}-${partIndex}`} plain onPress={() => select(production, key)}><Text bold>{part.text}</Text></Button>;
211 if (part.tone === 'default') return <Text bold={part.kind === 'bold'}>{part.text}</Text>;
212 return <Text color={part.tone} bold={part.kind === 'bold'}>{part.text}</Text>;
213 })}</Box>;
214 }
215 return <Box flexDirection="column">
216 {boards.length === 0 && limit > 0 && wrappedLines('Run /showrunner:unit_board in a showrunner session.', width).slice(0, limit).map(line => <Text>{line}</Text>)}
217 {boards.map(board => <Box flexDirection="column" flexShrink={0}>
218 {board.header.map((line, index) => drawLine(line, board.data.production, '', index))}
219 {board.blocks.map(block => <Box flexDirection="column" flexShrink={0} marginTop={gap}>
220 {[...block.lines, ...block.workers].map((line, index) => drawLine(line, board.data.production, block.unit.key, index))}
221 </Box>)}
222 {board.showOverflow && board.hidden.length > 0 && <Text>{doneText(board.hidden.map(unit => unit.key), width, `+${board.hidden.length} more`)}</Text>}
223 </Box>)}
224 </Box>;
225}
226hooks/unit.tsx 145 lines1import type { RenderElement } from 'claude-code';
2import type { PaneElements } from './board';
3import type { Tone } from './geometry';
4import type { BoardData, BoardPhase, BoardUnit } from '../types/board';
5import { cellWidth, duration, fitText, runsOf, signedMinutes, stripCells, wrappedLines } from './geometry';
6import { etaText, finishText, gateText, RulerView } from './board';
7
8function recordText(phase: BoardPhase): string {
9 const eta = phase.eta;
10 if (eta.delta_minutes !== null) {
11 return `${signedMinutes(eta.delta_minutes)} ${eta.delta_minutes > 0 ? 'late' : 'early'}, said ${eta.trail.at(-1)?.[0].text ?? '—'}`;
12 }
13 return eta.trail.length ? `ETA ${eta.trail.at(-1)?.[0].text}` : 'none stated';
14}
15
16/** Rows are removed in this order only after history and steps have folded. */
17type UnitRowRetention = 'context' | 'worker' | 'step' | 'fold' | 'phase' | 'heading' | 'open-phase' | 'navigation';
18interface UnitRow { view: RenderElement; height: number; retention: UnitRowRetention }
19interface UnitSection { rows: UnitRow[]; gap: number }
20
21function sectionHeight(sections: readonly UnitSection[]): number {
22 return sections.reduce((total, section) => total + (section.rows.length
23 ? section.gap + section.rows.reduce((height, row) => height + row.height, 0) : 0), 0);
24}
25
26function earlierIds(phases: readonly BoardPhase[]): string {
27 const ids = phases.map(phase => phase.id);
28 const consecutive = ids.every((id, index) => id === String(Number(ids[0]) + index));
29 return ids.length > 1 && consecutive ? `${ids[0]}–${ids.at(-1)}` : ids.join(', ');
30}
31
32function padCells(text: string, width: number): string {
33 return text + ' '.repeat(Math.max(0, width - cellWidth(text)));
34}
35
36export function UnitView(elements: PaneElements, columns: number, bodyRows: number, data: BoardData, unit: BoardUnit, now: number, back: () => void) {
37 const { Box, Text, Button } = elements;
38 const limit = Math.max(0, Math.floor(bodyRows));
39 const fact = (text: string, tone: Tone = 'default') => tone === 'default'
40 ? <Text>{text}</Text> : <Text color={tone}>{text}</Text>;
41 const eta = etaText(unit, now);
42 const gate = gateText(unit);
43 const finish = finishText(unit, now);
44 const heading = !unit.phase ? unit.stage : unit.phase.kind === 'ticket'
45 ? `Ticket ${unit.phase.id} · ${unit.phase.merged} merged · ${unit.stage}`
46 : `Phase ${unit.phase.id} of ${unit.phase.of} · ${unit.stage}`;
47 const trail = unit.eta?.trail.map(([eta, stated]) => `${eta.text} said ${stated.text}`).join(' → ') ?? '';
48 const strip = stripCells(unit.phases);
49 const slotWidth = Math.max(0, ...unit.workers.map(worker => cellWidth(worker.slot))) + 1;
50 const roleWidth = Math.max(0, ...unit.workers.map(worker => cellWidth(worker.role || worker.slot))) + 1;
51 const stateWidth = Math.max(0, ...unit.workers.map(worker => cellWidth(worker.state))) + 1;
52 const idWidth = Math.max(3, ...unit.phases.map(phase => cellWidth(phase.id)));
53 const fixWidth = Math.max(0, ...unit.phases.map(phase => phase.fix_done + (phase.fix_running ? 1 : 0))) + 1;
54 const timingWidth = Math.max(7, ...unit.phases.map(phase => (phase.took === null ? phase.status : duration(phase.took)).length + 1));
55 const recordWidth = Math.max(0, ...unit.phases.map(phase => cellWidth(recordText(phase))));
56 const stepTimes = unit.steps.map(step => `${step[2].text} · ${duration(step[3])}`);
57 const markWidth = Math.max(cellWidth('✓ '), cellWidth('▶ '));
58 const timeWidth = Math.min(Math.max(0, columns - markWidth - 2), Math.max(0, ...stepTimes.map(cellWidth)));
59 const labelWidth = Math.max(0, columns - timeWidth - markWidth - 2);
60 const done = unit.phases.filter(phase => phase.status === 'done');
61 const future = unit.phases.filter(phase => phase.status === 'todo' || phase.status === 'gated');
62 const hiddenDone = new Set<BoardPhase>();
63 const hiddenFuture = new Set<BoardPhase>();
64 let hiddenSteps = 0;
65 const row = (view: RenderElement, retention: UnitRowRetention = 'context', height = 1): UnitRow => ({ view, height, retention });
66
67 function sections(): UnitSection[] {
68 const overview = [row(<Box justifyContent="space-between">
69 <Text wrap="truncate-end"><Text bold>{unit.key}</Text> {heading}</Text>
70 <Button key="back" label="board" plain onPress={back} />
71 </Box>, 'navigation')];
72 if (unit.phase?.title) overview.push(row(<Text bold wrap="truncate-end">{unit.phase.title}</Text>));
73 if (unit.plan) overview.push(row(unit.phase
74 ? <Text wrap="truncate-end">{unit.plan}</Text>
75 : <Text bold wrap="truncate-end">{unit.plan}</Text>));
76 overview.push(...wrappedLines(unit.summary, columns).map(line => row(<Text wrap="truncate-end">{line}</Text>)));
77 overview.push(row(<Text wrap="truncate-end"><Text color="inactive">as of </Text>{data.as_of.text}</Text>));
78 const timing: UnitRow[] = [];
79 timing.push(...wrappedLines(eta, columns).map(line => row(<Text wrap="truncate-end">{line}</Text>)));
80 timing.push(...wrappedLines(gate, columns).map(line => row(<Text color="warning" wrap="truncate-end">{line}</Text>)));
81 timing.push(...wrappedLines(finish, columns).map(line => row(<Text wrap="truncate-end">{line}</Text>)));
82 if (unit.phase) timing.push(row(RulerView(elements, unit, data, now, Math.max(1, columns - 2)), 'context', 2));
83 timing.push(...wrappedLines(trail, columns).map(line => row(<Text wrap="truncate-end">{line}</Text>)));
84 const steps: UnitRow[] = [];
85 if (unit.steps.length) {
86 steps.push(row(<Text bold>This phase</Text>, 'heading'));
87 if (hiddenSteps) steps.push(row(<Text color="inactive" wrap="truncate-end">+{hiddenSteps} earlier steps</Text>, 'fold'));
88 unit.steps.slice(hiddenSteps).forEach(([state, what], offset) => {
89 const label = fitText(what, labelWidth);
90 steps.push(row(<Text wrap="truncate-end"><Text color={state === 'running' ? 'suggestion' : 'success'}>{padCells(state === 'running' ? '▶ ' : '✓ ', markWidth)}</Text>{padCells(label, labelWidth)} <Text>{fitText(stepTimes[hiddenSteps + offset]!, timeWidth)}</Text></Text>, 'step'));
91 });
92 }
93 const workers: UnitRow[] = unit.workers.length ? [row(<Text bold>Workers</Text>, 'heading'), ...unit.workers.map(worker => row(
94 <Text wrap="truncate-end"><Text>{padCells(worker.slot, slotWidth)}</Text><Text bold>{padCells(worker.role || worker.slot, roleWidth)}</Text>{fact(padCells(worker.state, stateWidth), worker.state === 'running' ? 'suggestion' : worker.state === 'paused' ? 'warning' : 'default')}{worker.doing}</Text>, 'worker'))] : [];
95 const record = [row(<Text bold>Phase record</Text>, 'heading')];
96 if (!unit.phases.length) record.push(row(<Text color="inactive">no phases recorded</Text>, 'phase'));
97 if (hiddenFuture.size) record.push(row(<Text color="inactive" wrap="truncate-end">+{hiddenFuture.size} later</Text>, 'fold'));
98 [...unit.phases].reverse().filter(phase => !hiddenDone.has(phase) && !hiddenFuture.has(phase)).forEach(phase => {
99 const id = ' '.repeat(Math.max(0, idWidth - cellWidth(phase.id))) + phase.id;
100 record.push(row(<Text wrap="truncate-end">
101 <Text>{id} </Text>{(phase.took === null ? phase.status : duration(phase.took)).padEnd(timingWidth)}
102 <Text color="warning">{('●'.repeat(phase.fix_done) + (phase.fix_running ? '○' : '')).padEnd(fixWidth)}</Text>
103 {fact(padCells(recordText(phase), recordWidth), phase.eta.delta_minutes !== null && phase.eta.delta_minutes > 0 ? 'error' : 'default')} {phase.title}
104 </Text>, phase.status === 'open' ? 'open-phase' : 'phase'));
105 });
106 if (hiddenDone.size) record.push(row(<Text color="inactive" wrap="truncate-end">{fitText(`+${hiddenDone.size} earlier: ${earlierIds(done.filter(phase => hiddenDone.has(phase)))}`, columns)}</Text>, 'fold'));
107 return [{ rows: overview, gap: 0 }, { rows: timing, gap: 1 },
108 { rows: strip.length ? [row(<Text wrap="truncate-end">{runsOf(strip).map(run => run.tone === 'default' ? <Text>{run.text}</Text> : <Text color={run.tone}>{run.text}</Text>)}</Text>)] : [], gap: 1 },
109 { rows: steps, gap: 1 }, { rows: workers, gap: 1 }, { rows: record, gap: 1 }];
110 }
111
112 let content = sections();
113 if (sectionHeight(content) > limit || future.length > 3) {
114 // A useful fold replaces at least two old rows with one notice.
115 future.slice(3).forEach(phase => hiddenFuture.add(phase));
116 if (done.length > 2) done.slice(0, 2).forEach(phase => hiddenDone.add(phase));
117 if (unit.steps.length > 2) hiddenSteps = 2;
118 content = sections();
119 for (const phase of done.slice(hiddenDone.size, -1)) {
120 if (sectionHeight(content) <= limit) break;
121 hiddenDone.add(phase);
122 content = sections();
123 }
124 while (sectionHeight(content) > limit && unit.steps.length - hiddenSteps > 1) {
125 hiddenSteps++;
126 content = sections();
127 }
128 }
129 if (sectionHeight(content) > limit) content.forEach(section => { section.gap = 0; });
130 // Extremely short panes retain navigation and the open record row longest.
131 for (const retention of ['context', 'worker', 'step', 'fold', 'phase', 'open-phase', 'navigation'] as const) {
132 for (const section of content) {
133 for (let index = section.rows.length - 1; index >= 0 && sectionHeight(content) > limit; index--) {
134 if (section.rows[index]!.retention === retention) section.rows.splice(index, 1);
135 if (section.rows.every(row => row.retention === 'heading')) {
136 section.rows = [];
137 break;
138 }
139 }
140 }
141 }
142 return <Box flexDirection="column">{content.filter(section => section.rows.length).map(section =>
143 <Box flexDirection="column" flexShrink={0} marginTop={section.gap}>{section.rows.map(row => row.view)}</Box>)}</Box>;
144}
145hooks/accounts.tsx 222 lines1import { span, wrapLine } from './board';
2import type { BoardLine, PaneElements } from './board';
3import type { StoredAccount, StoredAccountsStatus } from '../types/accounts';
4import { cellWidth } from './geometry';
5import type { Tone } from './geometry';
6
7export type AccountReading<Value> = { kind: 'available'; value: Value } | { kind: 'unavailable' };
8export type CreditBalance = { kind: 'finite'; amount: number } | { kind: 'unlimited' } | { kind: 'unavailable' };
9export interface AccountDisplay {
10 label: string;
11 roles: string[];
12 weekly: AccountReading<number>;
13 resets: AccountReading<string>;
14 credits: CreditBalance;
15 useCredits: boolean;
16 readOk: boolean;
17 creditsInUse: boolean;
18 read: AccountReading<string>;
19}
20
21function leftTone(weekly: AccountReading<number>): Tone {
22 if (weekly.kind === 'unavailable') return 'default';
23 return weekly.value < 10 ? 'error' : weekly.value < 25 ? 'warning' : 'default';
24}
25
26function reading<Value>(value: Value | null | undefined): AccountReading<Value> {
27 return value === null || value === undefined ? { kind: 'unavailable' } : { kind: 'available', value };
28}
29
30export function accountDisplay(account: StoredAccount): AccountDisplay {
31 return {
32 label: account.label, roles: account.roles,
33 weekly: reading(account.weekly_left_percent), resets: reading(account.resets_at),
34 credits: account.credits === null ? { kind: 'unavailable' }
35 : account.credits === 'unlimited' ? { kind: 'unlimited' } : { kind: 'finite', amount: account.credits },
36 useCredits: account.use_credits, readOk: account.read_ok === true,
37 creditsInUse: account.credits_in_use === true, read: reading(account.read_at),
38 };
39}
40
41/** Compare calendar days in the resolved zone, including across DST changes. */
42function localDay(date: Date, zone: string): number {
43 const parts = new Intl.DateTimeFormat('en-US', {
44 timeZone: zone, year: 'numeric', month: 'numeric', day: 'numeric',
45 }).formatToParts(date);
46 const part = (type: string) => Number(parts.find(part => part.type === type)!.value);
47 return Date.UTC(part('year'), part('month') - 1, part('day')) / 86400000;
48}
49
50function dateText(value: AccountReading<string>, zone: string, now: number): string {
51 if (value.kind === 'unavailable') return '';
52 const date = new Date(value.value);
53 if (!Number.isFinite(date.getTime())) return '';
54 const days = Math.abs(localDay(date, zone) - localDay(new Date(now * 1000), zone));
55 if (days > 6) return new Intl.DateTimeFormat('en-GB', {
56 timeZone: zone, day: 'numeric', month: 'short',
57 }).format(date);
58 const clock = new Intl.DateTimeFormat('en-GB', {
59 timeZone: zone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
60 }).format(date);
61 return days === 0 ? clock : `${new Intl.DateTimeFormat('en-US', { timeZone: zone, weekday: 'short' }).format(date)} ${clock}`;
62}
63
64function readAge(value: AccountReading<string>, now: number): string {
65 if (value.kind === 'unavailable') return '';
66 const readAt = new Date(value.value).getTime() / 1000;
67 if (!Number.isFinite(readAt)) return '';
68 const age = Math.max(0, now - readAt);
69 if (age < 60) return `${Math.floor(age)}s`;
70 if (age < 3600) return `${Math.floor(age / 60)}m`;
71 if (age < 86400) return `${Math.floor(age / 3600)}h`;
72 return `${Math.floor(age / 86400)}d`;
73}
74
75type AccountColumn = 'dot' | 'label' | 'roles' | 'left' | 'resets' | 'credits' | 'enabled' | 'inUse' | 'read';
76interface AccountRow {
77 cells: Record<AccountColumn, BoardLine>;
78 facts: BoardLine[];
79}
80interface TableColumn {
81 key: AccountColumn;
82 heading: string;
83 align: 'left' | 'right';
84 width: number;
85}
86
87function accountRow(account: AccountDisplay, zone: string, now: number): AccountRow {
88 const tone = leftTone(account.weekly);
89 const dot = account.readOk ? 'success' : 'error';
90 const left = account.weekly.kind === 'available' ? `${Math.round(account.weekly.value)}%` : '—';
91 const reset = dateText(account.resets, zone, now);
92 const read = readAge(account.read, now);
93 const balance = account.credits.kind === 'unavailable' ? '' : account.credits.kind === 'unlimited'
94 ? 'unlimited' : new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(account.credits.amount);
95 const credits = [span(balance)];
96 const facts = [[span(`${left} left`, tone)]];
97 if (reset) facts.push([span('resets', 'inactive'), span(` ${reset}`)]);
98 if (balance) facts.push([span('credits', 'inactive'), span(' '), ...credits]);
99 if (account.useCredits) facts.push([span('enabled')]);
100 if (account.creditsInUse) facts.push([span('in use', 'warning')]);
101 if (read) facts.push([span('read', 'inactive'), span(` ${read} ago`)]);
102 return { cells: {
103 dot: [span('●', dot)], label: [span(account.label, 'default', 'bold')],
104 roles: [span(account.roles.join(', '))], left: [span(left, tone)],
105 resets: [span(reset)], credits, enabled: [span(account.useCredits ? 'yes' : '')],
106 inUse: [span(account.creditsInUse ? 'yes' : '', 'warning')], read: [span(read)],
107 }, facts };
108}
109
110const lineWidth = (line: BoardLine) => line.reduce((width, part) => width + cellWidth(part.text), 0);
111const joinFacts = (facts: BoardLine[]): BoardLine => facts.flatMap((fact, index) =>
112 index ? [span(' · ', 'inactive'), ...fact] : fact);
113
114/** Keep facts intact, dropping only the separator where a new row starts. */
115function wrapFacts(facts: BoardLine[], width: number): BoardLine[] {
116 const indent = Math.max(0, Math.min(2, width - 1));
117 const room = width - indent;
118 const lines: BoardLine[] = [];
119 let current: BoardLine[] = [];
120 let used = 0;
121 const flush = () => {
122 if (current.length) lines.push([span(' '.repeat(indent)), ...joinFacts(current)]);
123 current = [];
124 used = 0;
125 };
126 for (const fact of facts) {
127 const size = lineWidth(fact);
128 if (size > room) {
129 flush();
130 lines.push(...wrapLine(fact, width, indent));
131 continue;
132 }
133 const separator = current.length ? cellWidth(' · ') : 0;
134 if (used + separator + size > room) flush();
135 used += (current.length ? cellWidth(' · ') : 0) + size;
136 current.push(fact);
137 }
138 flush();
139 return lines;
140}
141
142function tableColumns(accounts: AccountRow[]): TableColumn[] {
143 const columns: TableColumn[] = [
144 { key: 'dot', heading: '', align: 'left', width: 0 },
145 { key: 'label', heading: 'account', align: 'left', width: 0 },
146 { key: 'roles', heading: 'role', align: 'left', width: 0 },
147 { key: 'left', heading: 'left', align: 'right', width: 0 },
148 { key: 'resets', heading: 'resets', align: 'right', width: 0 },
149 { key: 'credits', heading: 'credits', align: 'right', width: 0 },
150 { key: 'enabled', heading: 'enabled', align: 'left', width: 0 },
151 { key: 'inUse', heading: 'in use', align: 'left', width: 0 },
152 { key: 'read', heading: 'read', align: 'right', width: 0 },
153 ];
154 return columns.map(column => ({ ...column, width: accounts.reduce(
155 (width, row) => Math.max(width, lineWidth(row.cells[column.key])), cellWidth(column.heading)) }));
156}
157
158function tableLine(columns: TableColumn[], cells: Record<AccountColumn, BoardLine>, indent = 0): BoardLine {
159 const line = [span(' '.repeat(indent)), ...columns.flatMap((column, index) => {
160 const cell = cells[column.key];
161 const padding = span(' '.repeat(column.width - lineWidth(cell)));
162 return [...(index ? [span(' ')] : []),
163 ...(column.align === 'right' ? [padding, ...cell]
164 : index === columns.length - 1 ? cell : [...cell, padding])];
165 })];
166 // Missing final cells leave no padding at the row's edge.
167 while (line.length) {
168 const last = line[line.length - 1]!;
169 const text = last.text.trimEnd();
170 if (text) {
171 line[line.length - 1] = { ...last, text };
172 break;
173 }
174 line.pop();
175 }
176 return line;
177}
178
179function accountLines(accounts: AccountRow[], width: number): BoardLine[] {
180 if (!accounts.length) return [];
181 const columns = tableColumns(accounts);
182 const header = Object.fromEntries(columns.map(column =>
183 [column.key, [span(column.heading, 'default', 'bold')]])) as Record<AccountColumn, BoardLine>;
184 const tableWidth = (columns: TableColumn[], indent = 0) =>
185 indent + columns.reduce((width, column) => width + column.width, 0) + (columns.length - 1) * 2;
186 if (tableWidth(columns) <= width) {
187 return [tableLine(columns, header), ...accounts.map(row => tableLine(columns, row.cells))];
188 }
189 const first = columns.slice(0, 4);
190 const second = columns.slice(4);
191 if (tableWidth(first) <= width && tableWidth(second, 2) <= width) {
192 return [tableLine(first, header), tableLine(second, header, 2),
193 ...accounts.flatMap(row => [tableLine(first, row.cells), tableLine(second, row.cells, 2)])];
194 }
195 return accounts.flatMap(row => {
196 const heading = [...row.cells.dot, span(' '), ...row.cells.label];
197 if (lineWidth(row.cells.roles)) heading.push(span(' '), ...row.cells.roles);
198 return [...wrapLine(heading, width), ...wrapFacts(row.facts, width)];
199 });
200}
201
202export function AccountsView(elements: PaneElements, columns: number, status: StoredAccountsStatus, zone: string, now: number) {
203 const { Box, Text } = elements;
204 const width = Math.max(1, Math.floor(columns));
205 const accounts = status.accounts.map(accountDisplay)
206 .sort((left, right) => left.label.localeCompare(right.label, undefined, { sensitivity: 'base' }))
207 .map(account => accountRow(account, zone, now));
208 const rows = accountLines(accounts, width);
209 const drawLine = (parts: BoardLine) => <Box flexDirection="row" flexShrink={0}>
210 {(parts.some(part => part.text) ? parts.filter(part => part.text) : [span('')]).map(part => part.tone === 'default'
211 ? <Text bold={part.kind === 'bold'}>{part.text}</Text>
212 : <Text color={part.tone} bold={part.kind === 'bold'}>{part.text}</Text>)}
213 </Box>;
214 return <Box flexDirection="column">
215 {rows.map(drawLine)}
216 {status.problems.length > 0 && <Box flexDirection="column" marginTop={1}>
217 {wrapLine([span('Problems', 'default', 'bold')], width).map(drawLine)}
218 {status.problems.flatMap(problem => wrapLine([span(`• ${problem}`, 'warning')], width)).map(drawLine)}
219 </Box>}
220 </Box>;
221}
222hooks/geometry.ts 205 lines1import type { BoardPhase, BoardTime, BoardUnit } from '../types/board';
2
3export type Tone = 'success' | 'error' | 'warning' | 'suggestion' | 'inactive' | 'default';
4export interface Cell { ch: string; tone: Tone }
5export interface Run { text: string; tone: Tone }
6export interface Ruler { track: Cell[]; labels: string; labelCells: Cell[]; from: number; to: number }
7
8/** Terminal cells occupied by ordinary, combining, wide and emoji characters. */
9export function cellWidth(text: string): number {
10 let width = 0;
11 for (const ch of text) {
12 const code = ch.codePointAt(0)!;
13 if (/[\p{Mark}\p{Cf}]/u.test(ch) || code < 0x20 || (code >= 0x7f && code < 0xa0)) continue;
14 const wide = code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a
15 || (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f)
16 || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff)
17 || (code >= 0xfe10 && code <= 0xfe19) || (code >= 0xfe30 && code <= 0xfe6f)
18 || (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)
19 || (code >= 0x1b000 && code <= 0x1b2ff) || (code >= 0x20000 && code <= 0x3fffd));
20 width += wide || /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/u.test(ch) ? 2 : 1;
21 }
22 return width;
23}
24
25/** Keep the following column's space even when a title fills its entire cell. */
26export function fitText(text: string, width: number): string {
27 text = text.replace(/[ \t]*[\r\n\t][\s]*/gu, ' ');
28 width = Math.max(0, Math.floor(width));
29 if (cellWidth(text) <= width) return text;
30 if (!width) return '';
31 let prefix = '';
32 let used = 0;
33 for (const ch of text) {
34 const size = cellWidth(ch);
35 if (used + size > width - 1) break;
36 prefix += ch;
37 used += size;
38 }
39 return prefix + '…';
40}
41
42export function doneText(keys: readonly string[], width: number, label = 'Done'): string {
43 for (let count = keys.length; count >= 0; count--) {
44 const names = keys.slice(0, count).join(', ');
45 const more = count < keys.length ? `${names ? ', ' : ''}+${keys.length - count} more` : '';
46 const line = `${label}: ${names}${more}`;
47 if (cellWidth(line) <= width) return line;
48 }
49 return wrappedLines(`${label}: +${keys.length} more`, width)[0] ?? '';
50}
51
52export function overdueText(at: number, now: number): string {
53 if (now <= at) return '';
54 return ` · overdue ${elapsedText(now - at)}`;
55}
56
57export function runsOf(cells: readonly Cell[]): Run[] {
58 const runs: Run[] = [];
59 for (const cell of cells) {
60 const last = runs[runs.length - 1];
61 if (last?.tone === cell.tone) last.text += cell.ch;
62 else runs.push({ text: cell.ch, tone: cell.tone });
63 }
64 return runs;
65}
66
67/** Every cell summarizes a contiguous slice, with unfinished work taking priority. */
68export function stripCells(phases: readonly BoardPhase[]): Cell[] {
69 const styles: Record<BoardPhase['status'], Cell> = {
70 done: { ch: '█', tone: 'success' }, open: { ch: '▓', tone: 'suggestion' },
71 todo: { ch: '░', tone: 'inactive' }, gated: { ch: '▒', tone: 'warning' },
72 };
73 const count = Math.min(20, phases.length);
74 return Array.from({ length: count }, (_, index) => {
75 const slice = phases.slice(Math.floor(index * phases.length / count), Math.floor((index + 1) * phases.length / count));
76 const status = (['open', 'gated', 'todo', 'done'] as const).find(state => slice.some(phase => phase.status === state))!;
77 return { ...styles[status] };
78 });
79}
80
81/** Explicit wrapping makes each rendered row's height known before folding. */
82export function wrappedLines(text: string, columns: number, continuationIndent = 0): string[] {
83 const width = Math.max(1, Math.floor(columns));
84 const indent = ' '.repeat(Math.max(0, Math.min(width - 1, continuationIndent)));
85 return text.replace(/\r\n?/gu, '\n').split('\n').flatMap(paragraph => {
86 const lines: string[] = [];
87 let rest = paragraph;
88 while (rest.length) {
89 const prefix = lines.length ? indent : '';
90 const room = width - cellWidth(prefix);
91 if (cellWidth(rest) <= room) {
92 lines.push(prefix + rest);
93 break;
94 }
95 let end = 0;
96 let used = 0;
97 for (const ch of rest) {
98 if (used + cellWidth(ch) > room) break;
99 end += ch.length;
100 used += cellWidth(ch);
101 }
102 // A wide glyph cannot occupy a single remaining cell. Give it a full
103 // row when possible, rather than replacing it with an ellipsis.
104 if (!end) {
105 const ch = Array.from(rest)[0]!;
106 if (cellWidth(ch) <= width) lines.push(ch);
107 rest = rest.slice(ch.length);
108 continue;
109 }
110 const space = rest.slice(0, end).lastIndexOf(' ');
111 if (space > 0) end = space;
112 lines.push(prefix + rest.slice(0, end).trimEnd());
113 rest = rest.slice(end).trimStart();
114 }
115 return lines;
116 });
117}
118
119/** Calendar comparisons use the production offset, including across midnight. */
120export function shortTime(time: BoardTime, now: number, utcOffsetMinutes = 0): string {
121 const local = new Date((time.at + utcOffsetMinutes * 60) * 1000);
122 const day = Math.floor((time.at + utcOffsetMinutes * 60) / 86400);
123 const today = Math.floor((now + utcOffsetMinutes * 60) / 86400);
124 const clock = clockText(time.at, utcOffsetMinutes);
125 if (day === today) return clock;
126 if (Math.abs(day - today) <= 6) return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][local.getUTCDay()]} ${clock}`;
127 return `${local.getUTCDate()} ${['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][local.getUTCMonth()]}`;
128}
129
130export function elapsedText(seconds: number): string {
131 const minutes = Math.floor(Math.abs(seconds) / 60);
132 const hours = Math.floor(minutes / 60);
133 return hours >= 24 ? `${Math.floor(hours / 24)}d ${hours % 24}h`
134 : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`;
135}
136
137export function clockText(epoch: number, offset: number): string {
138 const local = new Date((epoch + offset * 60) * 1000);
139 return `${String(local.getUTCHours()).padStart(2, '0')}:${String(local.getUTCMinutes()).padStart(2, '0')}`;
140}
141
142interface RulerMark { column: number; text: string; tone: Tone }
143
144/** Later marks displace entire earlier labels, never pieces of them. */
145function rulerLabels(marks: readonly RulerMark[], width: number): Cell[] {
146 const placed: { start: number; mark: RulerMark }[] = [];
147 for (const mark of marks) {
148 const size = cellWidth(mark.text);
149 if (size > width) continue;
150 const start = Math.max(0, Math.min(width - size, mark.column));
151 for (let index = placed.length - 1; index >= 0; index--) {
152 const old = placed[index]!;
153 if (start <= old.start + cellWidth(old.mark.text) && old.start <= start + size) placed.splice(index, 1);
154 }
155 placed.push({ start, mark });
156 }
157 const labels: Cell[] = [];
158 let column = 0;
159 for (const { start, mark } of placed.sort((a, b) => a.start - b.start)) {
160 while (column < start) { labels.push({ ch: ' ', tone: 'default' }); column++; }
161 for (const ch of mark.text) { labels.push({ ch, tone: mark.tone }); column += cellWidth(ch); }
162 }
163 return labels;
164}
165
166export function labelRuns(marks: readonly { column: number; text: string }[], width: number): string {
167 return rulerLabels(marks.map(mark => ({ ...mark, tone: 'default' })), width).map(cell => cell.ch).join('').trimEnd();
168}
169
170export function rulerCells(unit: BoardUnit, now: number, width: number, utcOffsetMinutes = 0): Ruler {
171 width = Math.max(1, Math.floor(width));
172 const phase = unit.phases.find(row => row.id === unit.phase?.id && row.status === 'open');
173 const from = phase?.start?.at ?? now;
174 const promise = unit.eta?.at;
175 const end = Math.max(promise?.at ?? now, now, from + 1);
176 const to = end + (end - from) * 0.1;
177 const column = (at: number) => Math.max(0, Math.min(width - 1, Math.round((at - from) / (to - from) * (width - 1))));
178 const track: Cell[] = Array.from({ length: width }, (_, index) => ({
179 ch: index <= column(now) ? '━' : '─', tone: index <= column(now) ? 'suggestion' : 'inactive',
180 }));
181 const marks: RulerMark[] = [{ column: 0, text: phase?.start?.text ?? clockText(from, utcOffsetMinutes), tone: 'default' }];
182 for (const [target] of unit.eta?.trail ?? []) {
183 track[column(target.at)] = { ch: '○', tone: 'inactive' };
184 marks.push({ column: column(target.at), text: target.text, tone: 'default' });
185 }
186 if (promise) {
187 track[column(promise.at)] = { ch: '●', tone: promise.at < now ? 'error' : 'success' };
188 marks.push({ column: column(promise.at), text: promise.text, tone: promise.at < now ? 'error' : 'success' });
189 }
190 track[column(now)] = { ch: '│', tone: 'default' };
191 marks.push({ column: column(now), text: clockText(now, utcOffsetMinutes), tone: 'default' });
192 const labelCells = rulerLabels(marks, width);
193 return { track, labels: labelCells.map(cell => cell.ch).join('').trimEnd(), labelCells, from, to };
194}
195
196export function signedMinutes(minutes: number): string {
197 const size = Math.round(Math.abs(minutes));
198 return `${minutes < 0 ? '-' : '+'}${Math.floor(size / 60)}:${String(size % 60).padStart(2, '0')}`;
199}
200
201export function duration(seconds: number): string {
202 const minutes = Math.floor(seconds / 60);
203 return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;
204}
205types/index.d.ts 27 lines1/** Files and complete snapshots from one generator invocation. */
2export interface BoardSnapshot<Production> {
3 files: string[];
4 watch: string[];
5 loadedAt: number;
6 boards: Production[];
7}
8
9export type PaneState<Production> =
10 | { kind: 'loading' }
11 | { kind: 'board'; data: BoardSnapshot<Production>; now: number }
12 | { kind: 'unit'; data: BoardSnapshot<Production>; production: string; unit: string; now: number }
13 | { kind: 'failed'; reason: string };
14
15export type AccountsPaneState<Status> =
16 | { kind: 'loading' }
17 | { kind: 'accounts'; data: Status; zone: string; now: number }
18 | { kind: 'failed'; reason: string };
19
20/** The engine contract keeps snapshots opaque; hooks bind Production to BoardData. */
21/** Missing keys are closed panes. Timers belong to the module, never persisted. */
22declare module 'claude-code' {
23 interface PluginState {
24 'unit-board': { panes: Record<string, PaneState<unknown>>; accountsPanes: Record<string, AccountsPaneState<unknown>> };
25 }
26}
27