@mgiles/perk 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +105 -0
  2. package/extension/adapters/planAdapterPlannotator.ts +269 -0
  3. package/extension/adapters/planAdapterTombell.ts +147 -0
  4. package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
  5. package/extension/checkpoints/checkpoints.ts +542 -0
  6. package/extension/checkpoints/planSteps.ts +108 -0
  7. package/extension/doors/address.ts +360 -0
  8. package/extension/doors/askUser.ts +194 -0
  9. package/extension/doors/ciExecutor.ts +583 -0
  10. package/extension/doors/land.ts +222 -0
  11. package/extension/doors/learn.ts +235 -0
  12. package/extension/doors/learnDocs.ts +99 -0
  13. package/extension/doors/lifecycleGates.ts +171 -0
  14. package/extension/doors/prReview.ts +339 -0
  15. package/extension/doors/ready.ts +86 -0
  16. package/extension/doors/selfcheck.ts +155 -0
  17. package/extension/doors/submit.ts +253 -0
  18. package/extension/factories/objective.ts +240 -0
  19. package/extension/factories/objectiveAuthor.ts +114 -0
  20. package/extension/factories/objectiveDraft.ts +343 -0
  21. package/extension/factories/objectivePlan.ts +838 -0
  22. package/extension/factories/objectiveSave.ts +285 -0
  23. package/extension/factories/planDraft.ts +140 -0
  24. package/extension/factories/planMode.ts +214 -0
  25. package/extension/factories/planReview.ts +644 -0
  26. package/extension/factories/planSave.ts +589 -0
  27. package/extension/factories/planTitle.ts +123 -0
  28. package/extension/index.ts +459 -0
  29. package/extension/substrate/bindingDelivery.ts +199 -0
  30. package/extension/substrate/bindings.ts +180 -0
  31. package/extension/substrate/cache.ts +163 -0
  32. package/extension/substrate/coldDoor.ts +226 -0
  33. package/extension/substrate/config.ts +339 -0
  34. package/extension/substrate/miniYaml.ts +262 -0
  35. package/extension/substrate/prompts.ts +35 -0
  36. package/extension/substrate/providers.ts +177 -0
  37. package/extension/substrate/registry.ts +62 -0
  38. package/extension/substrate/resources.ts +41 -0
  39. package/extension/substrate/result.ts +72 -0
  40. package/extension/substrate/runId.ts +49 -0
  41. package/extension/substrate/sessionData.ts +229 -0
  42. package/extension/substrate/structuredOutput.ts +141 -0
  43. package/extension/substrate/toolGating.ts +400 -0
  44. package/extension/substrate/toolParams.ts +106 -0
  45. package/extension/substrate/workflowState.ts +233 -0
  46. package/extension/surfaces/footerProvider.ts +43 -0
  47. package/extension/surfaces/report.ts +34 -0
  48. package/extension/surfaces/surfaces.ts +460 -0
  49. package/extension/vendor/btw/btw.ts +964 -0
  50. package/extension/vendor/btw/core.ts +153 -0
  51. package/extension/vendor/whimsical/whimsical.ts +485 -0
  52. package/extension/worker/readOnlySession.ts +282 -0
  53. package/extension/worker/worker.ts +765 -0
  54. package/extension/workerMain.ts +150 -0
  55. package/package.json +55 -0
  56. package/prompts/README.md +15 -0
  57. package/prompts/_fixtures/cases.yaml +140 -0
  58. package/prompts/_fixtures/golden/address-action-model.txt +10 -0
  59. package/prompts/_fixtures/golden/address-action.txt +10 -0
  60. package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
  61. package/prompts/_fixtures/golden/address-preview.txt +6 -0
  62. package/prompts/_fixtures/golden/hello.txt +1 -0
  63. package/prompts/_fixtures/golden/implement-github.txt +8 -0
  64. package/prompts/_fixtures/golden/learn-docs.txt +8 -0
  65. package/prompts/_fixtures/golden/learn-github.txt +11 -0
  66. package/prompts/_fixtures/golden/learn-linear.txt +11 -0
  67. package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
  68. package/prompts/_fixtures/golden/learn-other.txt +8 -0
  69. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
  70. package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
  71. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
  72. package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
  73. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
  74. package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
  75. package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
  76. package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
  77. package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
  78. package/prompts/_fixtures/golden/with_include.txt +4 -0
  79. package/prompts/_fixtures/templates/_greeting.md +1 -0
  80. package/prompts/_fixtures/templates/hello.md +1 -0
  81. package/prompts/_fixtures/templates/with_include.md +4 -0
  82. package/prompts/common/objective-read/linear.md +1 -0
  83. package/prompts/common/plan-read/github.md +1 -0
  84. package/prompts/common/plan-read/linear.md +1 -0
  85. package/prompts/common/plan-read/other.md +1 -0
  86. package/prompts/stages/address/action.md +10 -0
  87. package/prompts/stages/address/preview.md +6 -0
  88. package/prompts/stages/implement.md +8 -0
  89. package/prompts/stages/learn-docs.md +8 -0
  90. package/prompts/stages/learn.md +21 -0
  91. package/prompts/stages/objective-plan/guidance.md +12 -0
  92. package/prompts/stages/objective-plan/seed.md +20 -0
  93. package/shared/README.md +29 -0
  94. package/shared/bindings.yaml +64 -0
  95. package/shared/contracts-history.md +403 -0
  96. package/shared/contracts.md +4172 -0
  97. package/shared/providers.yaml +221 -0
  98. package/shared/registry.yaml +199 -0
@@ -0,0 +1,460 @@
1
+ // The perk surfaces module. The one module that owns perk's UI
2
+ // vocabulary per the TUI charter (`docs/design/tui-charter.md` §3–§5): the composed `perk` status
3
+ // slot + widget slot keys, footer identity marks, the §5 glyph + theming vocabulary, the §4 height
4
+ // bounds, the `createPerkStatus` composed-status handle + `setStandingWidget` widget setter, and
5
+ // the pure format helpers the standing surfaces render with. The notify seam itself stays in
6
+ // `report.ts` (re-exported here so "the surfaces module" is surfaces.ts + report.ts for the
7
+ // surfaces guard).
8
+ //
9
+ // Composed status (charter §6 D2): perk presents ONE footer status under the single
10
+ // `perk` slot — the objective + checkpoints segments composed in fixed charter order (objective
11
+ // first), joined with two spaces. The per-feature status slots (`perk-checkpoints`,
12
+ // `perk-objective`) and the `perk-objective` widget are retired (D8 sanctioned). The perk-owned
13
+ // footer (`perkFooter`/`installPerkFooter` below) lifted this composition; the
14
+ // composed `perk` status slot keeps publishing — it is the RPC-visible surface (setFooter is an
15
+ // RPC no-op). A regression guard enforces this composition.
16
+
17
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
+
19
+ // Re-exports: the notify seam stays in report.ts; surfaces.ts is the one import for UI vocabulary.
20
+ export { type ReportTarget, report, type Severity } from "./report.ts";
21
+
22
+ // --- standing-surface slot keys (charter §2) ---
23
+ // The ONE composed status slot (D2): all perk status segments render under this key.
24
+ export const STATUS_SLOT_PERK = "perk";
25
+ // NOTE: same string as providers.ts PERK_CHECKPOINTS_PROVIDER_ID but a different concept
26
+ // (UI slot vs provider id) — deliberately NOT merged.
27
+ export const WIDGET_SLOT_CHECKPOINTS = "perk-checkpoints";
28
+
29
+ // --- footer identity marks (charter §5 / D3: emoji are footer-only identity, 2 cells wide) ---
30
+ export const MARK_CHECKPOINTS = "📋";
31
+ export const MARK_OBJECTIVE = "🎯";
32
+
33
+ // --- glyph vocabulary (charter §5 / D3) — data only; themed rendering binds in nodes 2.2/3.1 ---
34
+ export type GlyphKind = "done" | "current" | "pending" | "warning" | "failure";
35
+ export const GLYPHS: Record<GlyphKind, { glyph: string; themeColor: string }> = {
36
+ done: { glyph: "✓", themeColor: "success" },
37
+ current: { glyph: "▸", themeColor: "accent" },
38
+ pending: { glyph: "○", themeColor: "dim" },
39
+ warning: { glyph: "⚠", themeColor: "warning" },
40
+ failure: { glyph: "✗", themeColor: "error" },
41
+ };
42
+
43
+ // --- height bounds (charter §4 / D1/D8) — enforcement lands in nodes 2.2/2.3/4.1 ---
44
+ export const NOTIFY_MAX_LINES = 1;
45
+ export const FOOTER_MAX_LINES = 1;
46
+ export const CHECKPOINTS_WIDGET_MAX_LINES = 4;
47
+
48
+ // --- the standing-surface setter -----------------------------------------------------------------
49
+
50
+ /**
51
+ * The minimal theme surface perk's themed renderers need (`theme.fg(color, text)`). Pi's real
52
+ * `Theme` satisfies it structurally; tests fake it with a tagging `fg`. Keeping the structural
53
+ * type here keeps surfaces.ts dependency-light (no `Theme` import).
54
+ */
55
+ export interface ThemeLike {
56
+ fg(color: string, text: string): string;
57
+ }
58
+
59
+ /**
60
+ * A standing-widget component factory in pi's `setWidget` factory shape: invoked with the live
61
+ * `(tui, theme)`, returns a component whose `render(width)` computes themed lines **per call**
62
+ * (the D10 stateless-render pattern — never cache themed strings). NOTE: pi's RPC mode drops
63
+ * factory widgets (only string[] forwards) — an accepted trade-off recorded in contracts.md.
64
+ */
65
+ export type StandingWidgetFactory = (
66
+ tui: unknown,
67
+ theme: ThemeLike,
68
+ ) => { render(width: number): string[]; invalidate(): void };
69
+
70
+ /**
71
+ * The minimal headless-aware surface the standing setters need. `ExtensionContext` satisfies it;
72
+ * tests fake it (the same minimal-structural-interface recipe as report.ts's `ReportTarget` — see
73
+ * `docs/learned/pi/extension-seams.md`).
74
+ */
75
+ export interface StandingTarget {
76
+ hasUI: boolean;
77
+ ui: {
78
+ setStatus(slot: string, value: string | undefined): void;
79
+ setWidget(
80
+ slot: string,
81
+ value: string[] | StandingWidgetFactory | undefined,
82
+ options?: { placement?: "aboveEditor" | "belowEditor" },
83
+ ): void;
84
+ };
85
+ }
86
+
87
+ /** Set (or clear, with undefined) a standing widget slot; no-op headless. */
88
+ export function setStandingWidget(
89
+ target: StandingTarget,
90
+ slot: string,
91
+ widget: string[] | StandingWidgetFactory | undefined,
92
+ options?: { placement?: "aboveEditor" | "belowEditor" },
93
+ ): void {
94
+ if (!target.hasUI) return;
95
+ if (options?.placement) {
96
+ target.ui.setWidget(slot, widget, { placement: options.placement });
97
+ } else {
98
+ target.ui.setWidget(slot, widget);
99
+ }
100
+ }
101
+
102
+ // --- the working-message seam (vendored `whimsical`; charter §6 permitted text-only) ------------
103
+
104
+ /**
105
+ * The minimal headless-aware surface the `setWorkingMessage` seam needs. `ExtensionContext`
106
+ * satisfies it; tests fake it (the `StandingTarget`/`ReportTarget` minimal-structural recipe).
107
+ * `setWorkingMessage` sets only a plain text label on pi's existing default working indicator
108
+ * (`undefined` restores pi's default per the SDK contract) — distinct from the declined
109
+ * `setWorkingIndicator` (D5).
110
+ */
111
+ export interface WorkingMessageTarget {
112
+ hasUI: boolean;
113
+ ui: { setWorkingMessage(message?: string): void };
114
+ }
115
+
116
+ /**
117
+ * Set (or, with `undefined`, restore pi's default) the working-message label; no-op headless.
118
+ * The one sanctioned `ctx.ui.setWorkingMessage` call site (guard allowlist) — `whimsical` routes
119
+ * its per-turn phrase through here. Text-only and headless-no-op, so it never touches rich UI in a
120
+ * cold/headless/RPC session.
121
+ */
122
+ export function setWorkingMessage(target: WorkingMessageTarget, message?: string): void {
123
+ if (!target.hasUI) return;
124
+ target.ui.setWorkingMessage(message);
125
+ }
126
+
127
+ // --- the composed perk status (charter D2) --------------------------------------------
128
+
129
+ /** A named segment of the composed `perk` status. Order is fixed by `PERK_SEGMENT_ORDER` (D2). */
130
+ export type PerkSegmentKey = "objective" | "checkpoints";
131
+
132
+ /** The charter D2 segment order: objective first, checkpoints second — never insertion order. */
133
+ export const PERK_SEGMENT_ORDER = ["objective", "checkpoints"] as const;
134
+
135
+ /** The two-space segment separator: `🎯 251 · 12.3k tok · 5m 📋 3/7 · ▸4`. */
136
+ const PERK_SEGMENT_SEPARATOR = " ";
137
+
138
+ /**
139
+ * The composed-status handle: each controller publishes its segment text through `set`, and the
140
+ * handle recomposes + republishes the single `perk` status slot. The footer reads
141
+ * segments back via `get` and repaints via `subscribe` — the slot's `setStatus` dual-publish is
142
+ * deliberate (RPC clients see the slot; setFooter is an RPC no-op).
143
+ */
144
+ export interface PerkStatusHandle {
145
+ /** Set (or clear, with undefined) one segment; recomposes the slot. No-op headless. */
146
+ set(target: StandingTarget, segment: PerkSegmentKey, text: string | undefined): void;
147
+ /** The current text of one segment (undefined when unset). */
148
+ get(segment: PerkSegmentKey): string | undefined;
149
+ /**
150
+ * Subscribe to recompositions: the listener fires after every headful `set` (headless `set`
151
+ * calls are full no-ops, so nothing fires). Returns an unsubscribe.
152
+ */
153
+ subscribe(listener: () => void): () => void;
154
+ }
155
+
156
+ /**
157
+ * Create the composed `perk` status handle (one per extension instance — created in index.ts and
158
+ * passed to the controllers; no hidden module state). Headless calls are full no-ops (never touch
159
+ * the segment map, so headless-era text can't resurrect in a later headful render). The composed
160
+ * line is the ordered present segments joined with two spaces; an empty composition clears the
161
+ * slot. No width handling: pi's footer truncates the joined status line itself.
162
+ */
163
+ export function createPerkStatus(): PerkStatusHandle {
164
+ const segments = new Map<PerkSegmentKey, string>();
165
+ const listeners = new Set<() => void>();
166
+ return {
167
+ set(target, segment, text) {
168
+ if (!target.hasUI) return;
169
+ if (text === undefined) {
170
+ segments.delete(segment);
171
+ } else {
172
+ segments.set(segment, text);
173
+ }
174
+ const composed = PERK_SEGMENT_ORDER.filter((key) => segments.has(key))
175
+ .map((key) => segments.get(key) as string)
176
+ .join(PERK_SEGMENT_SEPARATOR);
177
+ target.ui.setStatus(STATUS_SLOT_PERK, composed === "" ? undefined : composed);
178
+ for (const listener of listeners) listener();
179
+ },
180
+ get(segment) {
181
+ return segments.get(segment);
182
+ },
183
+ subscribe(listener) {
184
+ listeners.add(listener);
185
+ return () => listeners.delete(listener);
186
+ },
187
+ };
188
+ }
189
+
190
+ // --- the perk-owned footer (charter D2) -----------------------------------------------
191
+
192
+ /**
193
+ * The raw material for one composed footer line. Left group (charter order 1–3): `identity`,
194
+ * `objective`, `checkpoints` — the segments render verbatim (they carry their own 🎯/📋 marks).
195
+ * Right group (charter order 4, 5, +context, 6): `branch`, `model`, `context`, `guests` —
196
+ * right-aligned, non-segment system text dim-themed.
197
+ */
198
+ export interface FooterParts {
199
+ /** e.g. `perk v0.0.1` — standing identity (D7), dim. */
200
+ identity: string;
201
+ /** The 🎯 objective segment, verbatim (`handle.get("objective")`). */
202
+ objective?: string;
203
+ /** The 📋 checkpoints segment, verbatim (`handle.get("checkpoints")`). */
204
+ checkpoints?: string;
205
+ /** Git branch (dim); omitted when not in a repo. */
206
+ branch?: string;
207
+ /** Model id (dim); omitted when no model. */
208
+ model?: string;
209
+ /** Context usage — rendered `<pct>%/<window>` (dim; warning >70, error >90; `?` when null). */
210
+ context?: { percent: number | null; contextWindow: number };
211
+ /** Guest extension statuses (dim), pre-sorted by slot key; sanitized here. */
212
+ guests: string[];
213
+ }
214
+
215
+ /** Pi's `sanitizeStatusText` behavior, reimplemented locally (pi does not export it). */
216
+ function sanitizeGuestStatus(text: string): string {
217
+ return text
218
+ .replace(/[\r\n\t]/g, " ")
219
+ .replace(/ +/g, " ")
220
+ .trim();
221
+ }
222
+
223
+ /** The context segment, mirroring pi's default footer: `42.3%/200k`, `?/200k` when unknown. */
224
+ function formatContextSegment(
225
+ context: { percent: number | null; contextWindow: number },
226
+ theme: ThemeLike,
227
+ ): string {
228
+ const pct = context.percent;
229
+ const text = `${pct === null ? "?" : `${pct.toFixed(1)}%`}/${formatTokens(context.contextWindow)}`;
230
+ if (pct !== null && pct > 90) return theme.fg("error", text);
231
+ if (pct !== null && pct > 70) return theme.fg("warning", text);
232
+ return theme.fg("dim", text);
233
+ }
234
+
235
+ /**
236
+ * Compose THE one footer line (FOOTER_MAX_LINES = 1): left group = identity + objective +
237
+ * checkpoints (two-space-joined, charter order); right group = branch + model + context + guests
238
+ * (two-space-joined), right-aligned with ≥2 spaces of padding. When the line exceeds `width`,
239
+ * whole segments drop in the extended D9 order — guests (rightmost-first) → model → branch →
240
+ * context → checkpoints; `identity` and `objective` are NEVER dropped — then `truncateToWidth`
241
+ * as the last resort (ANSI- and 2-cell-emoji-aware).
242
+ */
243
+ export function composeFooterLine(parts: FooterParts, theme: ThemeLike, width: number): string {
244
+ const keep = {
245
+ guests: parts.guests.map((g) => sanitizeGuestStatus(g)),
246
+ model: true,
247
+ branch: true,
248
+ context: true,
249
+ checkpoints: true,
250
+ };
251
+ const compose = (): string => {
252
+ const left = [theme.fg("dim", parts.identity)];
253
+ if (parts.objective !== undefined) left.push(parts.objective);
254
+ if (keep.checkpoints && parts.checkpoints !== undefined) left.push(parts.checkpoints);
255
+ const right: string[] = [];
256
+ if (keep.branch && parts.branch !== undefined) right.push(theme.fg("dim", parts.branch));
257
+ if (keep.model && parts.model !== undefined) right.push(theme.fg("dim", parts.model));
258
+ if (keep.context && parts.context !== undefined) {
259
+ right.push(formatContextSegment(parts.context, theme));
260
+ }
261
+ for (const guest of keep.guests) right.push(theme.fg("dim", guest));
262
+ const leftText = left.join(PERK_SEGMENT_SEPARATOR);
263
+ if (right.length === 0) return leftText;
264
+ const rightText = right.join(PERK_SEGMENT_SEPARATOR);
265
+ const padding = Math.max(2, width - visibleWidth(leftText) - visibleWidth(rightText));
266
+ return `${leftText}${" ".repeat(padding)}${rightText}`;
267
+ };
268
+ let line = compose();
269
+ while (visibleWidth(line) > width) {
270
+ if (keep.guests.length > 0) keep.guests.pop();
271
+ else if (keep.model) keep.model = false;
272
+ else if (keep.branch) keep.branch = false;
273
+ else if (keep.context) keep.context = false;
274
+ else if (keep.checkpoints) keep.checkpoints = false;
275
+ else break; // identity + objective only — nothing left to drop
276
+ line = compose();
277
+ }
278
+ return truncateToWidth(line, width);
279
+ }
280
+
281
+ /**
282
+ * Structural mirror of pi's `ReadonlyFooterDataProvider` (keeps surfaces.ts dependency-light;
283
+ * the real provider satisfies it).
284
+ */
285
+ export interface FooterDataLike {
286
+ getGitBranch(): string | null;
287
+ getExtensionStatuses(): ReadonlyMap<string, string>;
288
+ onBranchChange(callback: () => void): () => void;
289
+ }
290
+
291
+ /** What `perkFooter` needs from the extension: identity, the status handle, and live closures. */
292
+ export interface PerkFooterDeps {
293
+ identity: string;
294
+ status: PerkStatusHandle;
295
+ getModelId(): string | null;
296
+ getContext(): { percent: number | null; contextWindow: number } | null;
297
+ }
298
+
299
+ /**
300
+ * The footer component factory shape `setFooter` receives. Pi's real signature —
301
+ * `(tui: TUI, theme: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?() }`
302
+ * — satisfies this narrower structural type.
303
+ */
304
+ export type PerkFooterFactory = (
305
+ tui: { requestRender(): void },
306
+ theme: ThemeLike,
307
+ footerData: FooterDataLike,
308
+ ) => { render(width: number): string[]; invalidate(): void; dispose(): void };
309
+
310
+ /**
311
+ * The perk-owned footer factory (charter D2): replaces pi's default footer wholesale with one
312
+ * line in the intended split layout. `render` gathers everything live per call (D10 stateless
313
+ * render): segments via the handle, branch/guests via `footerData` (excluding perk's own
314
+ * `STATUS_SLOT_PERK` — the slot keeps publishing for RPC, but the footer renders the segments
315
+ * directly), model/context via the deps closures. Reactivity (the D2 contract): repaints on
316
+ * every handle recompose and on branch change; `dispose` detaches both.
317
+ */
318
+ export function perkFooter(deps: PerkFooterDeps): PerkFooterFactory {
319
+ return (tui, theme, footerData) => {
320
+ const unsubscribeStatus = deps.status.subscribe(() => tui.requestRender());
321
+ const unsubscribeBranch = footerData.onBranchChange(() => tui.requestRender());
322
+ return {
323
+ render(width) {
324
+ const guests = [...footerData.getExtensionStatuses().entries()]
325
+ .filter(([key]) => key !== STATUS_SLOT_PERK)
326
+ .sort(([a], [b]) => a.localeCompare(b))
327
+ .map(([, text]) => text);
328
+ const parts: FooterParts = {
329
+ identity: deps.identity,
330
+ objective: deps.status.get("objective"),
331
+ checkpoints: deps.status.get("checkpoints"),
332
+ branch: footerData.getGitBranch() ?? undefined,
333
+ model: deps.getModelId() ?? undefined,
334
+ context: deps.getContext() ?? undefined,
335
+ guests,
336
+ };
337
+ return [composeFooterLine(parts, theme, width)];
338
+ },
339
+ invalidate() {
340
+ // nothing cached — render() recomputes everything per call (D10)
341
+ },
342
+ dispose() {
343
+ unsubscribeStatus();
344
+ unsubscribeBranch();
345
+ },
346
+ };
347
+ };
348
+ }
349
+
350
+ /** Install the perk-owned footer (sole-owner law, D2); no-op headless. */
351
+ export function installPerkFooter(
352
+ target: { hasUI: boolean; ui: { setFooter(factory: PerkFooterFactory | undefined): void } },
353
+ deps: PerkFooterDeps,
354
+ ): void {
355
+ if (!target.hasUI) return;
356
+ target.ui.setFooter(perkFooter(deps));
357
+ }
358
+
359
+ // --- format helpers (relocated from checkpoints.ts / objective.ts, verbatim) --------------------
360
+ // Structural parameter types (not CheckpointState/CheckpointStep imports) keep surfaces.ts
361
+ // dependency-free and avoid an import cycle with the surface controllers.
362
+
363
+ export interface ProgressStep {
364
+ step: number;
365
+ text: string;
366
+ completed: boolean;
367
+ }
368
+
369
+ export interface ProgressState {
370
+ steps: ProgressStep[];
371
+ /** The in-progress step number, or `null`. */
372
+ current: number | null;
373
+ }
374
+
375
+ /** The `done/total` checkpoint progress summary (with `· ▸n` when a step is current). */
376
+ export function progressLine(state: ProgressState): string {
377
+ const done = state.steps.filter((s) => s.completed).length;
378
+ const base = `${done}/${state.steps.length}`;
379
+ return state.current != null ? `${base} · ▸${state.current}` : base;
380
+ }
381
+
382
+ /** The `GLYPHS` kind for a step: done if completed, current if it IS the current step, else pending. */
383
+ export function stepGlyphKind(state: ProgressState, s: ProgressStep): GlyphKind {
384
+ if (s.completed) return "done";
385
+ if (s.step === state.current) return "current";
386
+ return "pending";
387
+ }
388
+
389
+ /** A windowed progress item: a visible step, or an elision marker for the hidden steps. */
390
+ export type ProgressWindowItem =
391
+ | { kind: "step"; step: ProgressStep }
392
+ | { kind: "elision"; hidden: number; side: "earlier" | "later" };
393
+
394
+ /**
395
+ * The D1 sliding window: at most `cap` step items (elision markers extra). For `n ≤ cap` all
396
+ * steps show with no markers. Otherwise the window anchors on the current step (`current == null`
397
+ * ⟹ all complete ⟹ anchor at the end) sitting second when possible — one earlier step above,
398
+ * the rest below — with `… +N earlier` / `… +N later` markers for the hidden steps.
399
+ */
400
+ export function windowProgress(state: ProgressState, cap: number): ProgressWindowItem[] {
401
+ const n = state.steps.length;
402
+ if (n <= cap) return state.steps.map((step) => ({ kind: "step", step }));
403
+ const anchorIdx =
404
+ state.current != null
405
+ ? Math.max(
406
+ state.steps.findIndex((s) => s.step === state.current),
407
+ 0,
408
+ )
409
+ : n - 1;
410
+ const start = Math.min(Math.max(anchorIdx - 1, 0), n - cap);
411
+ const items: ProgressWindowItem[] = [];
412
+ if (start > 0) items.push({ kind: "elision", hidden: start, side: "earlier" });
413
+ for (const step of state.steps.slice(start, start + cap)) items.push({ kind: "step", step });
414
+ const later = n - start - cap;
415
+ if (later > 0) items.push({ kind: "elision", hidden: later, side: "later" });
416
+ return items;
417
+ }
418
+
419
+ /**
420
+ * The themed checkpoints-widget lines (charter D1/D3/D9/D10): the `windowProgress` window mapped
421
+ * to `✓/▸/○ <n>. <text>` lines colored per the §5 table (completed text muted, elision markers
422
+ * dim), every line width-truncated via pi-tui's `truncateToWidth` (ANSI- and wide-glyph-aware).
423
+ * Pure per call — call it inside a component's `render()` so theming stays live (D10).
424
+ */
425
+ export function renderProgressLines(
426
+ state: ProgressState,
427
+ theme: ThemeLike,
428
+ width: number,
429
+ ): string[] {
430
+ return windowProgress(state, CHECKPOINTS_WIDGET_MAX_LINES).map((item) => {
431
+ if (item.kind === "elision") {
432
+ return truncateToWidth(theme.fg("dim", `… +${item.hidden} ${item.side}`), width);
433
+ }
434
+ const kind = stepGlyphKind(state, item.step);
435
+ const glyph = theme.fg(GLYPHS[kind].themeColor, GLYPHS[kind].glyph);
436
+ const text = `${item.step.step}. ${item.step.text}`;
437
+ const line = `${glyph} ${kind === "done" ? theme.fg("muted", text) : text}`;
438
+ return truncateToWidth(line, width);
439
+ });
440
+ }
441
+
442
+ function formatTokens(tokens: number): string {
443
+ if (tokens < 1000) return `${tokens}`;
444
+ // Whole-k values render bare (`200k`, matching pi's footer); fractional keep one decimal.
445
+ return `${(tokens / 1000).toFixed(1).replace(/\.0$/, "")}k`;
446
+ }
447
+
448
+ function formatElapsed(ms: number): string {
449
+ const totalSec = Math.floor(ms / 1000);
450
+ if (totalSec < 60) return `${totalSec}s`;
451
+ const min = Math.floor(totalSec / 60);
452
+ if (min < 60) return `${min}m`;
453
+ const hr = Math.floor(min / 60);
454
+ return `${hr}h${min % 60}m`;
455
+ }
456
+
457
+ /** A compact one-line budget summary (e.g. `12.3k tok · 5m`). */
458
+ export function formatBudgetLine(args: { tokens: number; elapsedMs: number }): string {
459
+ return `${formatTokens(args.tokens)} tok · ${formatElapsed(args.elapsedMs)}`;
460
+ }