@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.
- package/README.md +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- 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
|
+
}
|