@gonrocca/zero-pi 0.1.63 → 0.1.65
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 +2 -0
- package/extensions/zero-hud.ts +453 -0
- package/extensions/zero-models-picker.ts +43 -0
- package/extensions/zero-models.ts +10 -15
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -113,6 +113,7 @@ into `/forge` for you.
|
|
|
113
113
|
| **Run memory** | Every run recalls and saves traces to Cortex, so runs learn from each other. |
|
|
114
114
|
| **Provider guard** | Warns when the `anthropic` provider runs on a metered API key instead of your subscription. |
|
|
115
115
|
| **Startup banner** | The sunset ANSI-Shadow `ZERO` wordmark, drawn once at pi startup — `ZERO_HEADER=off` to disable. |
|
|
116
|
+
| **ZERO HUD** | OMP-inspired segmented footer for SDD work: phase, model, tokens, cache, cost, diff, context %, and branch. Switch presets with `/zero-hud compact\|minimal\|full\|ascii\|off` or `ZERO_HUD_PRESET`. |
|
|
116
117
|
| **Working-phrase ticker** | Swaps pi's `Working...` for a context-aware Spanish phrase + spinner. |
|
|
117
118
|
| **Conversation resume** | Writes `.pi/zero-resume.md` on exit — the restore command + a conversation tail. |
|
|
118
119
|
| **Windows tree-kill** | Aborting a turn kills the whole process tree — no orphaned `claude`. |
|
|
@@ -131,6 +132,7 @@ into `/forge` for you.
|
|
|
131
132
|
| `/zero-archive <slug>` | Merge an approved run into `.sdd/specs/`, move it to `.sdd/archive/YYYY-MM-DD-<slug>/`, and persist `archivePath`. |
|
|
132
133
|
| `/zero-validate <slug>` | Validate proposal/spec/design/tasks artifacts, including task schema and per-domain specs. |
|
|
133
134
|
| `/zero-status` | Show each `.sdd/` run's artifact, sync, latest-verdict, and GitHub-link status. |
|
|
135
|
+
| `/zero-hud [compact\|minimal\|full\|ascii\|off\|on\|preview]` | Preview or switch the segmented ZERO footer for this pi session. Default preset: `compact`. |
|
|
134
136
|
| `/zero-cost [<slug>]` | Report tokens (in/out/cache), USD cost, duration, and tool-count per SDD phase for a run — reads native pi session metas and project-local `.pi-subagents/artifacts`; `<slug>` for a specific run, no argument for the most recent. |
|
|
135
137
|
| `/zero-checkpoint [<slug>] [--json]` | Save `diff.patch`, `status.txt`, `head.txt`, `meta.json`, and a review-before-running `restore.sh` under `.sdd/<slug>/checkpoints/<id>/`. |
|
|
136
138
|
| `/zero-branch <slug>` | Create/reuse the configured SDD Git branch and persist `branch`/`baseBranch`. |
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// zero-pi — ZERO HUD statusline.
|
|
2
|
+
//
|
|
3
|
+
// A segmented footer inspired by omp's status-line presets, adapted to ZERO SDD:
|
|
4
|
+
// phase-aware, cost-aware, compact by default, and fully defensive. This
|
|
5
|
+
// replaces the older one-piece `zero-statusline` package extension while keeping
|
|
6
|
+
// that file around for backwards-compatible imports/tests.
|
|
7
|
+
//
|
|
8
|
+
// Runtime controls:
|
|
9
|
+
// ZERO_HUD_PRESET=compact|minimal|full|ascii|off
|
|
10
|
+
// /zero-hud compact|minimal|full|ascii|off|on|preview
|
|
11
|
+
|
|
12
|
+
import { exec } from "node:child_process";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
|
|
15
|
+
const execAsync = promisify(exec);
|
|
16
|
+
|
|
17
|
+
type RGB = [number, number, number];
|
|
18
|
+
type HudPreset = "minimal" | "compact" | "full" | "ascii" | "off";
|
|
19
|
+
type Phase = "clarify" | "explore" | "plan" | "analyze" | "build" | "veredicto" | "forge";
|
|
20
|
+
|
|
21
|
+
const CORAL: RGB = [255, 124, 92];
|
|
22
|
+
const GOLD: RGB = [255, 214, 130];
|
|
23
|
+
const PEACH: RGB = [255, 168, 99];
|
|
24
|
+
const ROSE: RGB = [255, 106, 122];
|
|
25
|
+
const ORCHID: RGB = [176, 106, 179];
|
|
26
|
+
const MINT: RGB = [79, 221, 171];
|
|
27
|
+
const AMBER: RGB = [238, 190, 92];
|
|
28
|
+
const SKY: RGB = [109, 184, 230];
|
|
29
|
+
const STEEL: RGB = [176, 152, 142];
|
|
30
|
+
const DIM: RGB = [120, 104, 98];
|
|
31
|
+
|
|
32
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
33
|
+
const STATUS_ID = "zero-hud";
|
|
34
|
+
const BRAND = "ZERO";
|
|
35
|
+
|
|
36
|
+
function fg([r, g, b]: RGB, text: string): string {
|
|
37
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function plain(text: string): string {
|
|
41
|
+
return text.replace(ANSI_RE, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dim(text: string): string {
|
|
45
|
+
return fg(DIM, text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function phaseColor(phase: Phase | undefined): RGB {
|
|
49
|
+
switch (phase) {
|
|
50
|
+
case "clarify": return GOLD;
|
|
51
|
+
case "explore": return SKY;
|
|
52
|
+
case "plan": return PEACH;
|
|
53
|
+
case "analyze": return ORCHID;
|
|
54
|
+
case "build": return CORAL;
|
|
55
|
+
case "veredicto": return MINT;
|
|
56
|
+
case "forge": return GOLD;
|
|
57
|
+
default: return DIM;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function normalizePreset(value: string | undefined): HudPreset | null {
|
|
62
|
+
const raw = (value ?? "").trim().toLowerCase();
|
|
63
|
+
if (raw === "minimal" || raw === "compact" || raw === "full" || raw === "ascii" || raw === "off") return raw;
|
|
64
|
+
if (raw === "on") return "compact";
|
|
65
|
+
return raw === "" ? null : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatTokenCount(n: number): string {
|
|
69
|
+
if (!Number.isFinite(n) || n < 0) return "0";
|
|
70
|
+
if (n < 1000) return `${Math.floor(n)}`;
|
|
71
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
|
72
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatUsd(n: number | undefined): string | undefined {
|
|
76
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n <= 0) return undefined;
|
|
77
|
+
if (n < 0.01) return `$${n.toFixed(4)}`;
|
|
78
|
+
if (n < 1) return `$${n.toFixed(3)}`;
|
|
79
|
+
return `$${n.toFixed(2)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ctxColor(percent: number): RGB {
|
|
83
|
+
if (!Number.isFinite(percent) || percent < 50) return MINT;
|
|
84
|
+
if (percent < 80) return AMBER;
|
|
85
|
+
return ROSE;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function shortModel(id: string | undefined): string | undefined {
|
|
89
|
+
if (!id) return undefined;
|
|
90
|
+
const slash = id.lastIndexOf("/");
|
|
91
|
+
const bare = slash >= 0 ? id.slice(slash + 1) : id;
|
|
92
|
+
return bare || undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function shortBranch(value: string | undefined): string | undefined {
|
|
96
|
+
if (!value) return undefined;
|
|
97
|
+
return value.length <= 24 ? value : `…${value.slice(-23)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SessionUsage {
|
|
101
|
+
input: number;
|
|
102
|
+
output: number;
|
|
103
|
+
cacheRead: number;
|
|
104
|
+
cacheWrite: number;
|
|
105
|
+
costUsd: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface PiSessionEntry {
|
|
109
|
+
type?: string;
|
|
110
|
+
message?: {
|
|
111
|
+
role?: string;
|
|
112
|
+
usage?: {
|
|
113
|
+
input?: number;
|
|
114
|
+
output?: number;
|
|
115
|
+
cacheRead?: number;
|
|
116
|
+
cacheWrite?: number;
|
|
117
|
+
cost?: number | { total?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
interface PiSessionManager {
|
|
122
|
+
getEntries?(): PiSessionEntry[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function positive(n: unknown): number {
|
|
126
|
+
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function usageCost(usage: PiSessionEntry["message"] extends infer M ? M extends { usage?: infer U } ? U : never : never): number {
|
|
130
|
+
if (!usage || typeof usage !== "object") return 0;
|
|
131
|
+
const cost = (usage as { cost?: unknown }).cost;
|
|
132
|
+
if (typeof cost === "number") return positive(cost);
|
|
133
|
+
if (cost && typeof cost === "object") {
|
|
134
|
+
const c = cost as { total?: unknown; input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown };
|
|
135
|
+
return positive(c.total) || positive(c.input) + positive(c.output) + positive(c.cacheRead) + positive(c.cacheWrite);
|
|
136
|
+
}
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function computeSessionUsage(sessionManager: PiSessionManager | undefined): SessionUsage {
|
|
141
|
+
const totals: SessionUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
|
|
142
|
+
if (!sessionManager || typeof sessionManager.getEntries !== "function") return totals;
|
|
143
|
+
try {
|
|
144
|
+
for (const entry of sessionManager.getEntries()) {
|
|
145
|
+
if (entry?.type !== "message") continue;
|
|
146
|
+
if (entry.message?.role !== "assistant") continue;
|
|
147
|
+
const u = entry.message.usage;
|
|
148
|
+
if (!u) continue;
|
|
149
|
+
totals.input += positive(u.input);
|
|
150
|
+
totals.output += positive(u.output);
|
|
151
|
+
totals.cacheRead += positive(u.cacheRead);
|
|
152
|
+
totals.cacheWrite += positive(u.cacheWrite);
|
|
153
|
+
totals.costUsd += usageCost(u);
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Return what we collected so far.
|
|
157
|
+
}
|
|
158
|
+
totals.costUsd = Math.round((totals.costUsd + Number.EPSILON) * 1_000_000) / 1_000_000;
|
|
159
|
+
return totals;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const SDD_AGENT_TO_PHASE: Record<string, Phase> = {
|
|
163
|
+
"zero-clarify": "clarify",
|
|
164
|
+
"zero-explore": "explore",
|
|
165
|
+
"zero-plan": "plan",
|
|
166
|
+
"zero-analyze": "analyze",
|
|
167
|
+
"zero-build": "build",
|
|
168
|
+
"zero-veredicto": "veredicto",
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export function phaseFromSubagentArgs(args: unknown): Phase | undefined {
|
|
172
|
+
const names = new Set<string>();
|
|
173
|
+
const collect = (value: unknown): void => {
|
|
174
|
+
if (typeof value === "string" && value.trim()) names.add(value.trim());
|
|
175
|
+
};
|
|
176
|
+
const walk = (value: unknown): void => {
|
|
177
|
+
if (!value || typeof value !== "object") return;
|
|
178
|
+
const record = value as Record<string, unknown>;
|
|
179
|
+
collect(record.agent);
|
|
180
|
+
for (const key of ["chain", "tasks", "parallel"] as const) {
|
|
181
|
+
const list = record[key];
|
|
182
|
+
if (Array.isArray(list)) for (const item of list) walk(item);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
walk(args);
|
|
186
|
+
for (const name of names) {
|
|
187
|
+
const key = name.toLowerCase().split(".").pop() ?? name.toLowerCase();
|
|
188
|
+
if (SDD_AGENT_TO_PHASE[key]) return SDD_AGENT_TO_PHASE[key];
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function phaseFromInput(text: string): Phase | undefined {
|
|
194
|
+
return /\/forge\b|\bforge\b|zero[\s-]?sdd|\bsdd\b/i.test(text ?? "") ? "forge" : undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface HudParts {
|
|
198
|
+
phase?: Phase;
|
|
199
|
+
model?: string;
|
|
200
|
+
tokensIn?: number;
|
|
201
|
+
tokensOut?: number;
|
|
202
|
+
cacheRead?: number;
|
|
203
|
+
costUsd?: number;
|
|
204
|
+
diffAdded?: number;
|
|
205
|
+
diffRemoved?: number;
|
|
206
|
+
ctxPercent?: number;
|
|
207
|
+
branch?: string;
|
|
208
|
+
preset?: HudPreset;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function separator(preset: HudPreset): string {
|
|
212
|
+
if (preset === "ascii") return dim(" | ");
|
|
213
|
+
if (preset === "minimal") return dim(" / ");
|
|
214
|
+
return dim(" ▸ ");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function segment(label: string, value: string, color: RGB, preset: HudPreset): string {
|
|
218
|
+
if (preset === "ascii") return `${label}:${plain(value)}`;
|
|
219
|
+
return `${dim(label)} ${fg(color, value)}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function composeHud(parts: HudParts): string {
|
|
223
|
+
const preset = parts.preset ?? "compact";
|
|
224
|
+
if (preset === "off") return "";
|
|
225
|
+
|
|
226
|
+
const out: string[] = [];
|
|
227
|
+
const sep = separator(preset);
|
|
228
|
+
|
|
229
|
+
if (preset !== "minimal") {
|
|
230
|
+
out.push(preset === "ascii" ? BRAND : fg(GOLD, BRAND));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (parts.phase) {
|
|
234
|
+
const value = preset === "ascii" ? parts.phase : `◆ ${parts.phase}`;
|
|
235
|
+
out.push(segment("phase", value, phaseColor(parts.phase), preset));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (parts.model && preset !== "minimal") {
|
|
239
|
+
out.push(fg(CORAL, parts.model));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (parts.tokensIn != null || parts.tokensOut != null) {
|
|
243
|
+
const tokenText = `↑${formatTokenCount(parts.tokensIn ?? 0)} ↓${formatTokenCount(parts.tokensOut ?? 0)}`;
|
|
244
|
+
out.push(segment("tok", tokenText, PEACH, preset));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (preset === "full" && parts.cacheRead && parts.cacheRead > 0) {
|
|
248
|
+
out.push(segment("cache", `↺${formatTokenCount(parts.cacheRead)}`, SKY, preset));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const cost = formatUsd(parts.costUsd);
|
|
252
|
+
if (cost && preset !== "minimal") {
|
|
253
|
+
out.push(segment("cost", cost, ORCHID, preset));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (parts.diffAdded != null || parts.diffRemoved != null) {
|
|
257
|
+
const diff = `+${parts.diffAdded ?? 0}/-${parts.diffRemoved ?? 0}`;
|
|
258
|
+
out.push(segment("diff", diff, (parts.diffAdded ?? 0) || (parts.diffRemoved ?? 0) ? MINT : DIM, preset));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (parts.ctxPercent != null && Number.isFinite(parts.ctxPercent)) {
|
|
262
|
+
out.push(segment("ctx", `${Math.round(parts.ctxPercent)}%`, ctxColor(parts.ctxPercent), preset));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const branch = shortBranch(parts.branch);
|
|
266
|
+
if (branch && preset !== "minimal") {
|
|
267
|
+
out.push(segment("git", branch, STEEL, preset));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return out.join(sep);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface PiUI {
|
|
274
|
+
setStatus(id: string, text: string | undefined): void;
|
|
275
|
+
notify?(message: string, level?: "info" | "warning" | "error"): void;
|
|
276
|
+
}
|
|
277
|
+
interface PiModel {
|
|
278
|
+
id?: string;
|
|
279
|
+
name?: string;
|
|
280
|
+
contextWindow?: number;
|
|
281
|
+
}
|
|
282
|
+
interface PiCtx {
|
|
283
|
+
ui?: PiUI;
|
|
284
|
+
model?: PiModel;
|
|
285
|
+
cwd?: string;
|
|
286
|
+
sessionManager?: PiSessionManager;
|
|
287
|
+
getContextUsage?(): { tokens?: number } | null | undefined;
|
|
288
|
+
}
|
|
289
|
+
interface PiAPI {
|
|
290
|
+
on(event: string, handler: (event: unknown, ctx: PiCtx) => void): void;
|
|
291
|
+
registerCommand?(name: string, options: { description: string; handler: (args: string, ctx: PiCtx) => unknown }): void;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let branch: string | undefined;
|
|
295
|
+
let added = 0;
|
|
296
|
+
let removed = 0;
|
|
297
|
+
let lastCtx: PiCtx | undefined;
|
|
298
|
+
let gitInFlight = false;
|
|
299
|
+
let preset: HudPreset = normalizePreset(process.env.ZERO_HUD_PRESET) ?? "compact";
|
|
300
|
+
let activePhase: Phase | undefined;
|
|
301
|
+
|
|
302
|
+
function resetSessionState(): void {
|
|
303
|
+
branch = undefined;
|
|
304
|
+
added = 0;
|
|
305
|
+
removed = 0;
|
|
306
|
+
activePhase = undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function readGit(cwdHint: string | undefined): Promise<void> {
|
|
310
|
+
const cwd = cwdHint || process.cwd();
|
|
311
|
+
if (!cwd || gitInFlight) return;
|
|
312
|
+
gitInFlight = true;
|
|
313
|
+
try {
|
|
314
|
+
try {
|
|
315
|
+
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd, timeout: 1500, windowsHide: true });
|
|
316
|
+
branch = stdout.trim() || undefined;
|
|
317
|
+
} catch {
|
|
318
|
+
branch = undefined;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const { stdout } = await execAsync("git diff --shortstat", { cwd, timeout: 1500, windowsHide: true });
|
|
322
|
+
const ins = stdout.match(/(\d+)\s+insertions?\(\+\)/);
|
|
323
|
+
const del = stdout.match(/(\d+)\s+deletions?\(-\)/);
|
|
324
|
+
added = ins ? parseInt(ins[1], 10) : 0;
|
|
325
|
+
removed = del ? parseInt(del[1], 10) : 0;
|
|
326
|
+
} catch {
|
|
327
|
+
added = 0;
|
|
328
|
+
removed = 0;
|
|
329
|
+
}
|
|
330
|
+
} finally {
|
|
331
|
+
gitInFlight = false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function render(ctx: PiCtx): void {
|
|
336
|
+
try {
|
|
337
|
+
if (!ctx?.ui || typeof ctx.ui.setStatus !== "function") return;
|
|
338
|
+
if (preset === "off") {
|
|
339
|
+
ctx.ui.setStatus(STATUS_ID, undefined);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const window = ctx.model?.contextWindow && ctx.model.contextWindow > 0 ? ctx.model.contextWindow : 200_000;
|
|
343
|
+
const used = ctx.getContextUsage?.()?.tokens;
|
|
344
|
+
const ctxPercent = typeof used === "number" && used >= 0 ? Math.min(100, (used / window) * 100) : undefined;
|
|
345
|
+
const usage = computeSessionUsage(ctx.sessionManager);
|
|
346
|
+
const text = composeHud({
|
|
347
|
+
preset,
|
|
348
|
+
phase: activePhase,
|
|
349
|
+
model: shortModel(ctx.model?.id ?? ctx.model?.name),
|
|
350
|
+
tokensIn: usage.input,
|
|
351
|
+
tokensOut: usage.output,
|
|
352
|
+
cacheRead: usage.cacheRead,
|
|
353
|
+
costUsd: usage.costUsd,
|
|
354
|
+
diffAdded: added,
|
|
355
|
+
diffRemoved: removed,
|
|
356
|
+
ctxPercent,
|
|
357
|
+
branch,
|
|
358
|
+
});
|
|
359
|
+
ctx.ui.setStatus(STATUS_ID, text || undefined);
|
|
360
|
+
} catch {
|
|
361
|
+
// HUD failures must never break a pi session.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function preview(nextPreset: HudPreset): string {
|
|
366
|
+
return plain(composeHud({
|
|
367
|
+
preset: nextPreset,
|
|
368
|
+
phase: "build",
|
|
369
|
+
model: "claude-opus-4-7",
|
|
370
|
+
tokensIn: 128_400,
|
|
371
|
+
tokensOut: 8_120,
|
|
372
|
+
cacheRead: 512_000,
|
|
373
|
+
costUsd: 0.042,
|
|
374
|
+
diffAdded: 120,
|
|
375
|
+
diffRemoved: 8,
|
|
376
|
+
ctxPercent: 42,
|
|
377
|
+
branch: "sdd/zero-hud",
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export default function register(pi?: unknown): void {
|
|
382
|
+
try {
|
|
383
|
+
if (!pi || typeof (pi as PiAPI).on !== "function") return;
|
|
384
|
+
const api = pi as PiAPI;
|
|
385
|
+
|
|
386
|
+
api.registerCommand?.("zero-hud", {
|
|
387
|
+
description: "Preview or switch the ZERO HUD footer (minimal|compact|full|ascii|off|on|preview).",
|
|
388
|
+
handler: (args, ctx) => {
|
|
389
|
+
const raw = (args ?? "").trim().toLowerCase();
|
|
390
|
+
const next = raw === "preview" || raw === "" ? preset : normalizePreset(raw);
|
|
391
|
+
if (!next) {
|
|
392
|
+
ctx.ui?.notify?.("Uso: /zero-hud minimal|compact|full|ascii|off|on|preview", "warning");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (raw !== "preview" && raw !== "") preset = next;
|
|
396
|
+
render(ctx);
|
|
397
|
+
ctx.ui?.notify?.(`ZERO HUD ${raw === "preview" || raw === "" ? "preview" : "preset"}: ${next}\n${preview(next)}`, "info");
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
api.on("session_start", (_event, ctx) => {
|
|
402
|
+
lastCtx = ctx;
|
|
403
|
+
resetSessionState();
|
|
404
|
+
render(ctx);
|
|
405
|
+
void readGit(ctx?.cwd).then(() => render(ctx));
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
api.on("input", (event, ctx) => {
|
|
409
|
+
lastCtx = ctx;
|
|
410
|
+
const phase = phaseFromInput((event as { text?: string })?.text ?? "");
|
|
411
|
+
if (phase) activePhase = phase;
|
|
412
|
+
render(ctx);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
api.on("model_select", (_event, ctx) => {
|
|
416
|
+
lastCtx = ctx;
|
|
417
|
+
render(ctx);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
api.on("message_update", (_event, ctx) => {
|
|
421
|
+
lastCtx = ctx;
|
|
422
|
+
render(ctx);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
api.on("tool_execution_start", (event, ctx) => {
|
|
426
|
+
lastCtx = ctx;
|
|
427
|
+
const e = event as { toolName?: string; args?: unknown };
|
|
428
|
+
const phase = /subagent|task/i.test(e.toolName ?? "") ? phaseFromSubagentArgs(e.args) : undefined;
|
|
429
|
+
if (phase) activePhase = phase;
|
|
430
|
+
render(ctx);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
api.on("tool_execution_end", (_event, ctx) => {
|
|
434
|
+
lastCtx = ctx;
|
|
435
|
+
void readGit(ctx?.cwd).then(() => render(ctx));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
api.on("agent_end", (_event, ctx) => {
|
|
439
|
+
lastCtx = ctx;
|
|
440
|
+
render(ctx);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
api.on("session_shutdown", () => {
|
|
444
|
+
try {
|
|
445
|
+
lastCtx?.ui?.setStatus?.(STATUS_ID, undefined);
|
|
446
|
+
} catch {
|
|
447
|
+
// ignore
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
} catch {
|
|
451
|
+
// Registration itself must never break a pi session.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
@@ -346,6 +346,49 @@ export function navigate(state: PickerState, dir: -1 | 1): PickerState {
|
|
|
346
346
|
return state;
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// decodeKey — keystroke decoding
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
/** The picker's five meaningful keys, as decoded from a raw stdin sequence. */
|
|
354
|
+
export type PickerKey = "up" | "down" | "enter" | "esc" | "backspace";
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Kitty-keyboard-protocol matcher for one functional key: `CSI <code>` +
|
|
358
|
+
* optional `;1` (no modifiers) + optional `:1` (press) / `:2` (repeat) +
|
|
359
|
+
* `<final>`. A `:3` (release) or any real modifier does NOT match — releases
|
|
360
|
+
* must be ignored and modified keys are not picker keys.
|
|
361
|
+
*/
|
|
362
|
+
function kittyPressOrRepeat(code: string, final: string): RegExp {
|
|
363
|
+
return new RegExp(`^\\x1b\\[${code}(?:;1(?::[12])?)?${final}$`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const KITTY_UP = kittyPressOrRepeat("1", "A");
|
|
367
|
+
const KITTY_DOWN = kittyPressOrRepeat("1", "B");
|
|
368
|
+
const KITTY_ENTER = kittyPressOrRepeat("13", "u");
|
|
369
|
+
const KITTY_ESC = kittyPressOrRepeat("27", "u");
|
|
370
|
+
const KITTY_BACKSPACE = kittyPressOrRepeat("127", "u");
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Decode one raw stdin sequence into a picker key, or `null` when it is not
|
|
374
|
+
* one (printable characters, releases, modified keys, unknown sequences).
|
|
375
|
+
*
|
|
376
|
+
* pi-tui negotiates kitty keyboard protocol flags 7 with the terminal, and a
|
|
377
|
+
* granting terminal (Ghostty, kitty, …) then encodes arrows as
|
|
378
|
+
* `CSI 1;1:1 A/B` and Esc as `CSI 27 u` — never the legacy `\x1b[A` / bare
|
|
379
|
+
* `\x1b` forms. A non-granting terminal keeps the legacy (or SS3
|
|
380
|
+
* application-cursor-mode) forms. Both worlds are accepted here; kitty
|
|
381
|
+
* repeats (`:2`) navigate too so holding an arrow scrolls the list.
|
|
382
|
+
*/
|
|
383
|
+
export function decodeKey(data: string): PickerKey | null {
|
|
384
|
+
if (data === "\x1b[A" || data === "\x1bOA" || KITTY_UP.test(data)) return "up";
|
|
385
|
+
if (data === "\x1b[B" || data === "\x1bOB" || KITTY_DOWN.test(data)) return "down";
|
|
386
|
+
if (data === "\r" || data === "\n" || data === "\r\n" || KITTY_ENTER.test(data)) return "enter";
|
|
387
|
+
if (data === "\x1b" || KITTY_ESC.test(data)) return "esc";
|
|
388
|
+
if (data === "\x7f" || data === "\x08" || KITTY_BACKSPACE.test(data)) return "backspace";
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
349
392
|
// ---------------------------------------------------------------------------
|
|
350
393
|
// enter / back — Enter/Esc dispatch
|
|
351
394
|
// ---------------------------------------------------------------------------
|
|
@@ -44,6 +44,7 @@ import type { AutotunePending } from "./autotune-extension.ts";
|
|
|
44
44
|
import {
|
|
45
45
|
back,
|
|
46
46
|
createPickerState,
|
|
47
|
+
decodeKey,
|
|
47
48
|
enter,
|
|
48
49
|
navigate,
|
|
49
50
|
submitText,
|
|
@@ -521,14 +522,6 @@ const PICKER_TITLE = "zero · modelos SDD";
|
|
|
521
522
|
/** The dim help line shown at the foot of the boxed panel. */
|
|
522
523
|
const PICKER_HELP = "↑↓ navegar · enter elegir · esc volver";
|
|
523
524
|
|
|
524
|
-
/** ANSI arrow-key escape sequences. */
|
|
525
|
-
const KEY_UP = "\x1b[A";
|
|
526
|
-
const KEY_DOWN = "\x1b[B";
|
|
527
|
-
const KEY_ESC = "\x1b";
|
|
528
|
-
/** Enter — CR or LF, depending on the terminal. */
|
|
529
|
-
const KEY_ENTER = new Set(["\r", "\n", "\r\n"]);
|
|
530
|
-
/** Backspace — DEL or BS. */
|
|
531
|
-
const KEY_BACKSPACE = new Set(["\x7f", "\x08"]);
|
|
532
525
|
|
|
533
526
|
/**
|
|
534
527
|
* Clamp a rendered line to `width` columns so it never overflows the box
|
|
@@ -678,7 +671,8 @@ function createPickerComponent(
|
|
|
678
671
|
|
|
679
672
|
/** Route a keystroke while the inline text buffer is open. */
|
|
680
673
|
function handleTextInput(data: string): void {
|
|
681
|
-
|
|
674
|
+
const key = decodeKey(data);
|
|
675
|
+
if (key === "esc") {
|
|
682
676
|
// Esc abandons the typed value and returns to the current list screen
|
|
683
677
|
// unchanged. `submitText` with an empty string is exactly that no-op:
|
|
684
678
|
// it clears `textPrompt` and rebuilds the list without committing.
|
|
@@ -687,13 +681,13 @@ function createPickerComponent(
|
|
|
687
681
|
tui.requestRender();
|
|
688
682
|
return;
|
|
689
683
|
}
|
|
690
|
-
if (
|
|
684
|
+
if (key === "enter") {
|
|
691
685
|
state = submitText(state, buffer ?? "");
|
|
692
686
|
buffer = null;
|
|
693
687
|
tui.requestRender();
|
|
694
688
|
return;
|
|
695
689
|
}
|
|
696
|
-
if (
|
|
690
|
+
if (key === "backspace") {
|
|
697
691
|
buffer = (buffer ?? "").slice(0, -1);
|
|
698
692
|
tui.requestRender();
|
|
699
693
|
return;
|
|
@@ -714,24 +708,25 @@ function createPickerComponent(
|
|
|
714
708
|
return;
|
|
715
709
|
}
|
|
716
710
|
|
|
717
|
-
|
|
711
|
+
const key = decodeKey(data);
|
|
712
|
+
if (key === "up") {
|
|
718
713
|
state = navigate(state, -1);
|
|
719
714
|
tui.requestRender();
|
|
720
715
|
return;
|
|
721
716
|
}
|
|
722
|
-
if (
|
|
717
|
+
if (key === "down") {
|
|
723
718
|
state = navigate(state, 1);
|
|
724
719
|
tui.requestRender();
|
|
725
720
|
return;
|
|
726
721
|
}
|
|
727
|
-
if (
|
|
722
|
+
if (key === "enter") {
|
|
728
723
|
const result = enter(state);
|
|
729
724
|
// `enter` on a custom-* row opens `textPrompt`; arm the buffer.
|
|
730
725
|
if (result.type === "state" && result.state.textPrompt) buffer = "";
|
|
731
726
|
applyResult(result);
|
|
732
727
|
return;
|
|
733
728
|
}
|
|
734
|
-
if (
|
|
729
|
+
if (key === "esc") {
|
|
735
730
|
applyResult(back(state));
|
|
736
731
|
return;
|
|
737
732
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/zero-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.65",
|
|
4
4
|
"description": "zero-pi — an installable layer for pi (pi.dev): the zero spec-driven development workflow (clarify → explore → plan → analyze → build → veredicto) with automatic clarify/analyze gates, per-phase model autotune, and token-efficient batched builds. Adds capability to pi without modifying pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"image": "https://raw.githubusercontent.com/gonzalonicolasr/zero-pi/main/assets/preview.png",
|
|
26
26
|
"extensions": [
|
|
27
27
|
"./extensions/zero-banner.ts",
|
|
28
|
-
"./extensions/zero-
|
|
28
|
+
"./extensions/zero-hud.ts",
|
|
29
29
|
"./extensions/working-phrases.ts",
|
|
30
30
|
"./extensions/win-tree-kill.ts",
|
|
31
31
|
"./extensions/sdd-agents.ts",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"themes",
|
|
55
55
|
"assets/preview.png",
|
|
56
56
|
"extensions/zero-banner.ts",
|
|
57
|
+
"extensions/zero-hud.ts",
|
|
57
58
|
"extensions/zero-statusline.ts",
|
|
58
59
|
"extensions/working-phrases.ts",
|
|
59
60
|
"extensions/win-tree-kill.ts",
|