paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
198 lines (178 loc) • 7.21 kB
text/typescript
import { IDLE_LIVE, type LiveState } from './live';
import type { PaqadHudSnapshot } from './types';
export type ShipLabel = 'Edit' | 'Commit' | 'Push' | 'PR' | 'CI';
export type ShipStepState = 'done' | 'current' | 'todo' | 'failed' | 'closed' | 'unknown';
export interface ShipRailStep {
label: ShipLabel;
state: ShipStepState;
}
export interface ShipRail {
steps: ShipRailStep[];
status: string | null;
inherited: number;
ci: { done: number; total: number } | null;
allGreen: boolean;
prNumber: number | null;
merged: boolean;
}
const EDIT_STAGE_IDS = ['development', 'checks', 'documentation_sync'];
function editState(snapshot: PaqadHudSnapshot | null, live: LiveState): ShipStepState {
const stateOf = (id: string) => snapshot?.stages.find((stage) => stage.stage === id)?.state;
const open = EDIT_STAGE_IDS.some((id) => stateOf(id) === 'running');
if (open || live.editing !== null) return 'current';
if (EDIT_STAGE_IDS.some((id) => stateOf(id) === 'complete' || stateOf(id) === 'redone')) {
return 'done';
}
return 'todo';
}
function prStep(hasPr: boolean, prState: string | null): ShipStepState {
if (!hasPr) return 'todo';
if (prState === 'closed') return 'closed';
return prState === null ? 'unknown' : 'done';
}
function prLabel(number: number | null): string {
return number === null ? 'PR' : `PR #${number}`;
}
function ciState(hasPr: boolean, closed: boolean, state: string | null): ShipStepState {
if (!hasPr || closed || state === null) return 'todo';
if (state === 'pass') return 'done';
if (state === 'fail') return 'failed';
return state === 'pending' ? 'current' : 'unknown';
}
export function shipRail(snapshot: PaqadHudSnapshot | null, live: LiveState = IDLE_LIVE): ShipRail {
const active = snapshot?.active === true;
const ship = active ? (snapshot?.ship ?? null) : null;
const commits = ship?.commits ?? 0;
const prState = ship?.pr_state ?? null;
const hasPr = ship !== null && ship.pr_number !== null && ship.pr !== null;
const ci = ship?.ci_detail ?? null;
const ciCount =
ci && ci.total !== null && ci.total > 0 ? { done: ci.done ?? 0, total: ci.total } : null;
const merged = ship?.merged === true;
const allGreen = ship?.all_green === true && !merged;
const steps: ShipRailStep[] = [
{ label: 'Edit', state: active ? editState(snapshot, live) : 'todo' },
{ label: 'Commit', state: commits > 0 ? 'done' : 'todo' },
{
label: 'Push',
state: ship?.push === 'pushed' ? 'done' : ship?.push === 'failed' ? 'failed' : 'todo',
},
{ label: 'PR', state: prStep(hasPr, prState) },
{ label: 'CI', state: ciState(hasPr, prState === 'closed', ci?.state ?? null) },
];
const number = ship?.pr_number ?? null;
return {
steps,
status: railStatus(steps, commits, number, ciCount),
inherited: ship ? Math.max(ship.inherited_count, 0) : 0,
ci: ciCount,
allGreen,
prNumber: number,
merged,
};
}
function railStatus(
steps: readonly ShipRailStep[],
commits: number,
number: number | null,
ci: { done: number; total: number } | null,
): string | null {
const state = (label: ShipLabel) => steps.find((step) => step.label === label)!.state;
if (state('PR') === 'closed') return 'PR closed';
if (state('PR') === 'unknown') return 'PR state unknown';
if (state('CI') === 'failed') return 'CI failed';
if (state('CI') === 'current' || state('CI') === 'done') {
return ci ? `CI · ${ci.done} of ${ci.total} green` : 'CI running';
}
if (state('PR') === 'done') return prLabel(number);
if (state('Push') === 'failed') return 'push failed';
if (state('Push') === 'done') return 'pushed';
if (commits > 0) return `${commits} of your commit${commits === 1 ? '' : 's'}`;
if (state('Edit') === 'current') return 'editing';
if (state('Edit') === 'done') return 'edited';
return null;
}
export const STEP_WORDS: Record<ShipStepState, string> = {
done: 'done',
current: 'running',
todo: 'not yet',
failed: 'failed',
closed: 'closed',
unknown: 'unknown',
};
export function railAlt(rail: ShipRail): string {
const words = rail.steps.map((step) => {
if (step.label === 'CI' && rail.ci && step.state !== 'todo') {
return `CI ${rail.ci.done} of ${rail.ci.total} green`;
}
return `${step.label} ${STEP_WORDS[step.state]}`;
});
return `Ship: ${words.join(', ')}.`;
}
const COLOR = {
done: '#2e9d5b',
current: '#3b7dd8',
todo: '#9a9a9a',
bad: '#d0573f',
face: 'white',
label: 'gray',
} as const;
function stepColor(state: ShipStepState): string {
if (state === 'done') return COLOR.done;
if (state === 'current') return COLOR.current;
if (state === 'failed' || state === 'closed') return COLOR.bad;
return COLOR.todo;
}
const ICONS: Record<ShipLabel, string> = {
Edit: 'M-6 6 L4 -4 L6 -2 L-4 8 Z M-6 6 L-7 9 L-4 8',
Commit: 'M-9 0 H-4 M4 0 H9 M0 -4 A4 4 0 1 0 0.01 -4',
Push: 'M0 8 V-7 M-5 -2 L0 -7 L5 -2',
PR: 'M-5 -7 V7 M5 -3 V7 M5 -3 C5 -8 0 -8 -2 -8 M-4 -10 L-2 -8 L-4 -6',
CI: 'M-6 0 L-2 5 L7 -6',
};
const escapeXml = (text: string): string =>
text.replace(/[<>&"']/g, (char) => `&#${char.charCodeAt(0)};`);
export const RAIL_WIDTH = 360;
export const RAIL_HEIGHT = 64;
export function shipRailSvg(rail: ShipRail): { source: string; alt: string } {
const step = RAIL_WIDTH / rail.steps.length;
const y = 26;
const x = (index: number) => step / 2 + index * step;
const lastDone = rail.steps.reduce(
(last, entry, index) => (entry.state === 'done' ? index : last),
-1,
);
const parts: string[] = [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${RAIL_WIDTH} ${RAIL_HEIGHT}" width="${RAIL_WIDTH}" height="${RAIL_HEIGHT}">`,
`<line x1="${x(0)}" y1="${y}" x2="${x(rail.steps.length - 1)}" y2="${y}" stroke="${COLOR.todo}" stroke-width="2"/>`,
];
if (lastDone > 0) {
parts.push(
`<line x1="${x(0)}" y1="${y}" x2="${x(lastDone)}" y2="${y}" stroke="${COLOR.done}" stroke-width="3"/>`,
);
}
rail.steps.forEach((entry, index) => {
const color = stepColor(entry.state);
const cx = x(index);
parts.push(
`<g transform="translate(${cx} ${y})"><title>${escapeXml(`${entry.label} ${STEP_WORDS[entry.state]}`)}</title>`,
`<circle r="13" fill="${COLOR.face}" stroke="${color}" stroke-width="2"/>`,
`<path d="${ICONS[entry.label]}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
);
if (entry.label === 'CI' && rail.ci) {
const share = Math.min(1, Math.max(0, rail.ci.done / rail.ci.total));
const radius = 17;
const length = 2 * Math.PI * radius;
parts.push(
`<circle r="${radius}" fill="none" stroke="${COLOR.todo}" stroke-width="3"/>`,
`<circle r="${radius}" fill="none" stroke="${COLOR.done}" stroke-width="3" stroke-dasharray="${(length * share).toFixed(2)} ${length.toFixed(2)}" transform="rotate(-90)"/>`,
);
}
parts.push(
'</g>',
`<text x="${cx}" y="${RAIL_HEIGHT - 6}" text-anchor="middle" font-size="11" fill="${COLOR.label}">${entry.label}</text>`,
);
});
parts.push('</svg>');
return { source: parts.join(''), alt: railAlt(rail) };
}