paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
243 lines (211 loc) • 6.7 kB
text/typescript
import type { LiveState, PaqadHudSnapshot, PaqadPaneTab, PaqadTone } from './types';
export type BandAction = 'minimise' | 'restore' | 'close' | 'show' | 'reopen';
export interface BandSegment {
text: string;
tone?: PaqadTone;
dim?: boolean;
opens?: PaqadPaneTab;
action?: BandAction;
hotkey?: string;
key?: string;
}
export type BandRow = BandSegment[];
export interface BandCell {
key: string;
top: BandSegment;
bottom: BandSegment;
}
export interface BandSvg {
source: string;
alt: string;
width: number;
height: number;
}
export interface BandBlock {
key: string;
title: BandRow;
cells: BandCell[];
svg: BandSvg | null;
note: BandRow | null;
}
export interface BandSplitRow {
left: BandRow;
right: BandRow;
}
export interface BandBody {
blocks: BandBlock[];
footer: BandSplitRow | null;
}
export interface BodyContext {
now: number;
surface: 'terminal' | 'desktop';
turnStartedAt?: number | null;
unavailable?: boolean;
live?: LiveState;
}
export const UNAVAILABLE = 'HUD data unavailable';
export type BodyRenderer = (snapshot: PaqadHudSnapshot, context: BodyContext) => BandBody;
export const BAND_BODIES: Record<string, BodyRenderer> = {};
export function registerBandBody(workflow: string, renderer: BodyRenderer): void {
BAND_BODIES[workflow] = renderer;
}
export interface ShellHeader {
unavailable?: boolean;
tone: PaqadTone;
chip: string | null;
headline: string;
note?: string | null;
gap?: string | null;
activity?: readonly string[];
verdict: { word: string; tone: PaqadTone } | null;
flags: number;
lane: string | null;
tokens: string | null;
}
export type BandLayout =
| { kind: 'none' }
| { kind: 'line'; row: BandRow }
| {
kind: 'card';
tone: PaqadTone;
header: BandSplitRow;
direction: 'row' | 'column';
blocks: BandBlock[];
footer: BandSplitRow | null;
activity: BandRow[];
};
export const ROW_LAYOUT_COLUMNS = 110;
export const CARD_BORDER_ROWS = 2;
export const SVG_ROWS = 2;
export const BRAND = 'paqad';
export const PROOF = 'Proof';
export const MINIMISE = '– Minimise';
export const RESTORE = '+ Restore';
export const CLOSE = '× Close';
export const CONTROL_HOTKEYS: Readonly<Record<BandAction, string>> = {
minimise: 'm',
restore: 'r',
close: 'x',
show: 's',
reopen: 'o',
};
export function brandSegment(tone: PaqadTone): BandSegment {
return { text: BRAND, tone, opens: 'trust', key: 'paqad-brand' };
}
export function controlSegment(action: 'minimise' | 'restore' | 'close'): BandSegment {
const text = action === 'minimise' ? MINIMISE : action === 'restore' ? RESTORE : CLOSE;
return { text, action, hotkey: CONTROL_HOTKEYS[action], key: `paqad-${action}`, dim: true };
}
export function flagChip(flags: number): BandSegment | null {
if (flags <= 0) return null;
return {
text: `${flags} flag${flags === 1 ? '' : 's'}`,
tone: 'warn',
opens: 'trust',
key: 'paqad-flags',
};
}
export const ELLIPSIS = '…';
export function fitLine(text: string, max: number): string {
const chars = [...text];
const room = Math.max(1, Math.floor(max));
if (chars.length <= room) return text;
return `${chars
.slice(0, room - 1)
.join('')
.trimEnd()}${ELLIPSIS}`;
}
const CARD_INSET_COLUMNS = 4;
const MIN_HEADLINE_COLUMNS = 12;
function rowWidth(row: BandRow): number {
return row.reduce((sum, segment) => sum + [...segment.text].length, 0);
}
export function headerRow(header: ShellHeader, idle: boolean, columns?: number): BandSplitRow {
const left: BandRow = [brandSegment(header.tone)];
if (header.chip) left.push({ text: ` ${header.chip}`, dim: true });
left.push({ text: ` ${header.headline}`, tone: header.tone });
if (header.note) left.push({ text: ` ${header.note}`, dim: true });
if (header.gap) left.push({ text: ` ${header.gap}`, dim: true });
if (header.unavailable) left.push({ text: ` ${UNAVAILABLE}`, dim: true });
const right: BandRow = [];
if (!idle) {
if (header.verdict) right.push({ text: header.verdict.word, tone: header.verdict.tone }, gap());
const flags = flagChip(header.flags);
if (flags) right.push(flags, gap());
if (header.lane) right.push({ text: `${header.lane} lane`, dim: true }, gap());
if (header.tokens) {
right.push({ text: header.tokens, dim: true, opens: 'cost', key: 'paqad-tokens' }, gap());
}
right.push({ text: PROOF, opens: 'trust', key: 'paqad-proof' }, gap());
}
right.push(controlSegment('minimise'), gap(), controlSegment('close'));
if (columns !== undefined) {
const lead = rowWidth(left.slice(0, header.chip ? 2 : 1)) + 2;
const room = Math.max(
MIN_HEADLINE_COLUMNS,
columns - CARD_INSET_COLUMNS - rowWidth(right) - lead,
);
const at = header.chip ? 2 : 1;
left[at] = { ...left[at]!, text: ` ${fitLine(header.headline, room)}` };
}
return { left, right };
}
function gap(): BandSegment {
return { text: ' ' };
}
export function blockRows(block: BandBlock): number {
return 1 + (block.svg ? SVG_ROWS : block.cells.length > 0 ? 2 : 0) + (block.note ? 1 : 0);
}
export function cardRows(
blocks: readonly BandBlock[],
direction: 'row' | 'column',
footer: boolean,
): number {
const body =
direction === 'row'
? Math.max(0, ...blocks.map(blockRows))
: blocks.reduce((sum, block) => sum + blockRows(block), 0);
return CARD_BORDER_ROWS + 1 + body + (footer ? 1 : 0);
}
export interface ShellSize {
columns: number;
maxRows: number;
}
export function layoutShell(
header: ShellHeader,
body: BandBody | null,
size: ShellSize,
minimisedLine: BandRow,
): BandLayout {
const activity: BandRow[] = (header.activity ?? []).map((line) => [
{ text: ` ${line}`, dim: true },
]);
const maxRows = Math.max(1, Math.floor(size.maxRows)) - activity.length;
if (body === null) {
if (maxRows < CARD_BORDER_ROWS + 1) return { kind: 'line', row: minimisedLine };
return {
kind: 'card',
tone: header.tone,
header: headerRow(header, true, size.columns),
direction: 'column',
blocks: [],
footer: null,
activity,
};
}
const direction = size.columns >= ROW_LAYOUT_COLUMNS ? 'row' : 'column';
let footer = direction === 'row' ? body.footer : null;
if (footer && cardRows(body.blocks, direction, true) > maxRows) footer = null;
if (cardRows(body.blocks, direction, footer !== null) > maxRows) {
return { kind: 'line', row: minimisedLine };
}
return {
kind: 'card',
tone: header.tone,
header: headerRow(header, false, size.columns),
direction,
blocks: body.blocks,
footer,
activity,
};
}