// The perk surfaces module. The one module that owns perk's UI // vocabulary per the TUI charter (`docs/design/tui-charter.md` §3–§5): the single-value `perk` // status slot key, footer identity marks, the §5 glyph + theming vocabulary, the §4 height // bounds, the `createPerkStatus` status handle, and the pure format helpers the standing // surfaces render with. The notify seam itself stays in `report.ts` (re-exported here so "the // surfaces module" is surfaces.ts + report.ts for the surfaces guard). The host-SDK bridge // captures the host pi-tui namespace through this module (`hostPiTui`), so pi-tui stays confined // here. // // Perk status (charter §6 D2): perk presents ONE footer status under the single // `perk` slot — the objective segment composed with the ref-counted activity facet // (` · `, either half optional). The per-feature status slots and widgets // are retired (D8 sanctioned). The perk-owned footer (`perkFooter`/`installPerkFooter` below) // renders the value directly; the `perk` status slot keeps publishing — it is the RPC-visible // surface (setFooter is an RPC no-op). import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { ReportDetailSink } from "./report.ts"; // The whole host pi-tui namespace, for the host-SDK bridge's facade (substrate/hostSdk.ts). export * as hostPiTui from "@earendil-works/pi-tui"; // `Key` is keybinding vocabulary (`pi.registerShortcut(Key.ctrlAlt("p"), …)`), not rich UI — // re-exported so pi-tui imports stay structurally confined to the surfaces module (the // surfacesGuard pi-tui import rule) without allowlisting the shortcut-registering modules. export { Key } from "@earendil-works/pi-tui"; // Re-exports: the notify seam stays in report.ts; surfaces.ts is the one import for UI vocabulary. export { attachReportDetailSink, type ReportDetailSink, type ReportTarget, report, type Severity, } from "./report.ts"; // --- standing-surface slot keys (charter §2) --- // The ONE perk status slot (D2): perk's single status value renders under this key. export const STATUS_SLOT_PERK = "perk"; // --- footer identity marks (charter §5 / D3: emoji are footer-only identity, 2 cells wide) --- export const MARK_OBJECTIVE = "🎯"; // --- activity vocabulary (the optional second half of the composed `perk` status) --- // Plain text, no emoji (D3 keeps emoji as identity marks): a Perk-owned wait the operator // cannot otherwise see. export const ACTIVITY_BROWSER_REVIEW = "waiting on browser review"; /** The provider-retry wait labels of the foreground writers (both conflict modes share one). */ export const ACTIVITY_LABEL_CONFLICT_RESOLVER = "conflict resolver"; export const ACTIVITY_LABEL_LIBRARIAN = "librarian"; /** * Structural slices of the substrate's provider-diagnostics vocabulary * (`substrate/providerDiagnostics.ts`): surfaces.ts takes structural params rather than importing * feature or mechanism homes, and `surfaces.test.ts` pins the substrate shape assignable to them. */ export type DelegationErrorKindLike = "rate_limited" | "transient_other" | "unknown"; export interface DelegationRetryLike { phase: "backoff" | "in_flight"; attempt: number; maxAttempts: number; delayMs: number; } /** The slice an activity owner takes: begin a wait with its text, receive the matching `end`. */ export type ActivitySink = (text: string) => () => void; /** * The least-capable slice a wiring layer threads toward an activity owner: begin a wait against * the live target, nothing else. The full `PerkStatusHandle` stays at the composition root, the * objective publisher and the footer. */ export type ActivityHandle = Pick; // --- glyph vocabulary (charter §5 / D3) — charter-law data, pinned by tests --- export type GlyphKind = "done" | "current" | "pending" | "warning" | "failure"; export const GLYPHS: Record = { done: { glyph: "✓", themeColor: "success" }, current: { glyph: "▸", themeColor: "accent" }, pending: { glyph: "○", themeColor: "dim" }, warning: { glyph: "⚠", themeColor: "warning" }, failure: { glyph: "✗", themeColor: "error" }, }; // --- height bounds (charter §4 / D1/D8) — enforced by the report() notify budget and the footer builder --- export const NOTIFY_MAX_LINES = 1; export const FOOTER_MAX_LINES = 1; // --- the standing-surface setter ----------------------------------------------------------------- /** * The minimal theme surface perk's themed renderers need (`theme.fg(color, text)`). Pi's real * `Theme` satisfies it structurally; tests fake it with a tagging `fg`. Keeping the structural * type here keeps surfaces.ts dependency-light (no `Theme` import). */ export interface ThemeLike { fg(color: string, text: string): string; } /** * The minimal headless-aware surface the status handle needs — the `perk` status slot is the one * standing surface it serves. `ExtensionContext` satisfies it; tests fake it (the same * minimal-structural-interface recipe as report.ts's `ReportTarget` — see * `docs/learned/pi/extension-seams.md`). */ export interface StandingTarget { hasUI: boolean; ui: { setStatus(slot: string, value: string | undefined): void; }; } // --- the working-message seam (vendored `whimsical`; charter §6 permitted text-only) ------------ /** * The minimal headless-aware surface the `setWorkingMessage` seam needs. `ExtensionContext` * satisfies it; tests fake it (the `StandingTarget`/`ReportTarget` minimal-structural recipe). * `setWorkingMessage` sets only a plain text label on pi's existing default working indicator * (`undefined` restores pi's default per the SDK contract) — distinct from the declined * `setWorkingIndicator` (D5). */ export interface WorkingMessageTarget { hasUI: boolean; ui: { setWorkingMessage(message?: string): void }; } /** * Set (or, with `undefined`, restore pi's default) the working-message label; no-op headless. * The one sanctioned `ctx.ui.setWorkingMessage` call site (guard allowlist) — `whimsical` routes * its per-turn phrase through here. Text-only and headless-no-op, so it never touches rich UI in a * cold/headless/RPC session. */ export function setWorkingMessage(target: WorkingMessageTarget, message?: string): void { if (!target.hasUI) return; target.ui.setWorkingMessage(message); } // --- the perk status (charter D2) -------------------------------------------- /** * The composed status handle: the objective publisher sets its half through `set`, activity * owners hold ref-counted waits through `beginActivity`, and the handle republishes the single * `perk` status slot with the composed value. The footer reads it back via `get` and repaints via * `subscribe` — the slot's `setStatus` dual-publish is deliberate (RPC clients see the slot; * setFooter is an RPC no-op). */ export interface PerkStatusHandle { /** Set (or clear, with undefined) the objective half; publishes the slot. No-op headless. */ set(target: StandingTarget, text: string | undefined): void; /** * Begin one activity wait: the newest live wait's text shows while any begun wait is unended; * returns this wait's `end` (idempotent; inert after a reset — no publish). No-op headless * (returns a no-op `end`, records nothing). */ beginActivity(target: StandingTarget, text: string): () => void; /** Reset every wait (session shutdown); a late `end` of a reset wait is inert. No-op headless. */ clearActivity(target: StandingTarget): void; /** The composed value (` · `, either half optional; undefined when neither). */ get(): string | undefined; /** * Subscribe to publishes: the listener fires after every headful `set`, begin, end and clear * (headless calls are full no-ops, so nothing fires). Returns an unsubscribe. */ subscribe(listener: () => void): () => void; } /** * Create the composed `perk` status handle (one per extension instance — created in pi/activation.ts * and passed to the objective publisher and the activity owners; no hidden module state). * Headless calls are full no-ops (never record text, so headless-era text can't resurrect in a * later headful render). The activity is a SET of live wait tokens, not a set/clear: waits can * overlap (the browser-operation owner retires a replaced review before the next begins, and the * set stays the backstop), so an older wait's settle must not blank a newer one — * the text clears only when the LAST wait ends (the newest live wait's text shows meanwhile, so * ending one restores the remainder). An ended or reset token is simply absent, so its late `end` * finds nothing to remove and publishes nothing — a wait begun after a reset can never be * consumed by a stale `end`. No width handling: pi's footer truncates. */ export function createPerkStatus(): PerkStatusHandle { let objective: string | undefined; const live = new Set<{ text: string }>(); // insertion-ordered: the last entry is the newest const listeners = new Set<() => void>(); const compose = (): string | undefined => { const activity = [...live].at(-1)?.text; const halves = [objective, activity].filter((half) => half !== undefined); return halves.length === 0 ? undefined : halves.join(" · "); }; const publish = (target: StandingTarget): void => { target.ui.setStatus(STATUS_SLOT_PERK, compose()); for (const listener of listeners) listener(); }; return { set(target, text) { if (!target.hasUI) return; objective = text; publish(target); }, beginActivity(target, text) { if (!target.hasUI) return () => {}; const token = { text }; live.add(token); try { publish(target); } catch (error) { // A wait whose first publish threw is not live, so no ghost text survives a rendering // failure; the throw still reaches the caller. The slot may already show the wait (a // subscriber can throw after `setStatus` succeeded) and the caller holds no `end` for it, // so repaint the restored composition best-effort before rethrowing the original error. live.delete(token); try { publish(target); } catch { /* The original failure is the one reported; the rollback repaint is best-effort. */ } throw error; } return () => { // Already ended, or reset by `clearActivity`: nothing to remove, nothing to publish. if (!live.delete(token)) return; publish(target); }; }, clearActivity(target) { if (!target.hasUI) return; live.clear(); publish(target); }, get() { return compose(); }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, }; } // --- the foreground writers' provider-retry wait ----------------------------------------------- const DELEGATION_ERROR_WORDS: Record = { rate_limited: "rate limited", transient_other: "transient error", unknown: "request failed", }; /** * The activity text of a provider retry: `