UNPKG

paqad-ai

Version:

Spec-driven development framework — AI agents that think before they type

243 lines (211 loc) • 6.7 kB
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, }; }