@henryqw/pi-footer 4.1.2 → 4.2.0
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 +43 -1
- package/extensions/footer-config.ts +103 -0
- package/extensions/footer.ts +37 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,12 +96,54 @@ Statuses from other extensions, including Ponytail and `pi-rewind`, share the le
|
|
|
96
96
|
|
|
97
97
|
## Config
|
|
98
98
|
|
|
99
|
-
Package-owned: `~/.pi/agent/config/pi-open-in/config.json` (under `getAgentDir()` when overridden). The retained `pi-open-in` home belongs to the open commands now shipped here.
|
|
99
|
+
Package-owned: `~/.pi/agent/config/pi-footer/config.json` for footer colors and `~/.pi/agent/config/pi-open-in/config.json` for the open command (both under `getAgentDir()` when overridden). The retained `pi-open-in` home belongs to the open commands now shipped here.
|
|
100
100
|
|
|
101
101
|
| Name | Description | Values | Default |
|
|
102
102
|
| --- | --- | --- | --- |
|
|
103
|
+
| `colors` | Colors for footer-owned text by role. | Object that maps color roles to theme tokens, `#RGB`, `#RRGGBB`, `oklch(...)`, `okhsl(...)`, or an ANSI index from `0` to `255`. | Built-in colors. |
|
|
103
104
|
| `command` | Command that `/open` runs. Required when the file exists. | Non-empty string. | `"code"` |
|
|
104
105
|
|
|
106
|
+
The footer reads `colors` once per session start; run `/reload` after editing it. The file is never rewritten. A missing file or a missing role keeps the built-in color. At each session start, an invalid file shows a notification with the path and the reason, and the whole color config falls back to the built-in colors.
|
|
107
|
+
|
|
108
|
+
### Footer color roles
|
|
109
|
+
|
|
110
|
+
Every role is optional:
|
|
111
|
+
|
|
112
|
+
| Role | Surface |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `repository` | Repository name on the first line. |
|
|
115
|
+
| `branch` | Branch or checkout name, still hyperlink-wrapped when the terminal supports it. |
|
|
116
|
+
| `gitStatus` | Git badges such as `[+2 ~3 ↑1]`. |
|
|
117
|
+
| `input`, `output`, `cache`, `throughput`, `cost`, `context` | The six usage metrics on the second line. |
|
|
118
|
+
| `model` | Active model and routed physical model. |
|
|
119
|
+
| `runtime` | Cumulative agent-work time on the third line. |
|
|
120
|
+
| `codegraphActive` | The `● CG` badge during a direct `codegraph_explore` call. |
|
|
121
|
+
| `separator` | Middle dots, bullets, arrows, and the truncation ellipsis. |
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"colors": {
|
|
126
|
+
"repository": "muted",
|
|
127
|
+
"branch": "accent",
|
|
128
|
+
"gitStatus": "warning",
|
|
129
|
+
"input": "#4ec9b0",
|
|
130
|
+
"output": "#9cdcfe",
|
|
131
|
+
"cache": "#c586c0",
|
|
132
|
+
"throughput": "#dcdcaa",
|
|
133
|
+
"cost": "#ce9178",
|
|
134
|
+
"context": "#89d185",
|
|
135
|
+
"model": "text",
|
|
136
|
+
"runtime": "muted",
|
|
137
|
+
"codegraphActive": "accent",
|
|
138
|
+
"separator": "dim"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Theme tokens are `accent`, `dim`, `muted`, `text`, `success`, `warning`, `error`, and `thinkingText`. Thinking levels keep their built-in gradient, and statuses from other extensions keep their producer colors; the `colors` object controls neither.
|
|
144
|
+
|
|
145
|
+
### Open command
|
|
146
|
+
|
|
105
147
|
A missing file silently uses the default command, `code`. An invalid existing file makes `/open` fail visibly and stays unchanged. See Limits and recovery. `/open` reads the config on every run. Only `/set-open-in` writes the file, atomically. Reads do not create or write the config home.
|
|
106
148
|
|
|
107
149
|
## API
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { extensionConfigPath, createConfigStore } from "@henryqw/pi-config-store";
|
|
2
|
+
import { parseColor, type Color } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
/** Color roles for footer-owned content; external extension statuses and the thinking gradient are not covered. */
|
|
6
|
+
export const FOOTER_COLOR_ROLES = [
|
|
7
|
+
"repository",
|
|
8
|
+
"branch",
|
|
9
|
+
"gitStatus",
|
|
10
|
+
"input",
|
|
11
|
+
"output",
|
|
12
|
+
"cache",
|
|
13
|
+
"throughput",
|
|
14
|
+
"cost",
|
|
15
|
+
"context",
|
|
16
|
+
"model",
|
|
17
|
+
"runtime",
|
|
18
|
+
"codegraphActive",
|
|
19
|
+
"separator",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type FooterColorRole = (typeof FOOTER_COLOR_ROLES)[number];
|
|
23
|
+
|
|
24
|
+
/** Theme foreground tokens accepted as config values. */
|
|
25
|
+
const THEME_TOKENS: ReadonlySet<ThemeColor> = new Set<ThemeColor>([
|
|
26
|
+
"accent",
|
|
27
|
+
"dim",
|
|
28
|
+
"muted",
|
|
29
|
+
"text",
|
|
30
|
+
"success",
|
|
31
|
+
"warning",
|
|
32
|
+
"error",
|
|
33
|
+
"thinkingText",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export type FooterColorValue = { kind: "token"; token: ThemeColor } | { kind: "color"; color: Color };
|
|
37
|
+
|
|
38
|
+
export type FooterColorConfig = Partial<Record<FooterColorRole, FooterColorValue>>;
|
|
39
|
+
|
|
40
|
+
function parseColorValue(role: string, raw: unknown): FooterColorValue {
|
|
41
|
+
if (typeof raw === "number") {
|
|
42
|
+
if (!Number.isInteger(raw) || raw < 0 || raw > 255) {
|
|
43
|
+
throw new Error(`colors.${role}: ANSI index must be an integer from 0 to 255`);
|
|
44
|
+
}
|
|
45
|
+
return { kind: "color", color: parseColor(raw) };
|
|
46
|
+
}
|
|
47
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
48
|
+
throw new Error(`colors.${role}: expected a theme token or a color value`);
|
|
49
|
+
}
|
|
50
|
+
if (THEME_TOKENS.has(raw as ThemeColor)) return { kind: "token", token: raw as ThemeColor };
|
|
51
|
+
try {
|
|
52
|
+
return { kind: "color", color: parseColor(raw) };
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error(`colors.${role}: invalid color "${raw}"`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Validates and converts the config file; throws on a present-but-invalid file so the caller decides the fallback. */
|
|
59
|
+
export function parseFooterColorConfig(value: unknown): { colors: FooterColorConfig } {
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
61
|
+
throw new Error("config must be a JSON object");
|
|
62
|
+
}
|
|
63
|
+
const root = value as Record<string, unknown>;
|
|
64
|
+
const unknown = Object.keys(root).filter((key) => key !== "colors");
|
|
65
|
+
if (unknown.length > 0) throw new Error(`unknown field(s): ${unknown.join(", ")}`);
|
|
66
|
+
if (!("colors" in root)) throw new Error('missing "colors" field');
|
|
67
|
+
if (!root.colors || typeof root.colors !== "object" || Array.isArray(root.colors)) {
|
|
68
|
+
throw new Error('"colors" must be an object');
|
|
69
|
+
}
|
|
70
|
+
const colors: FooterColorConfig = {};
|
|
71
|
+
for (const [role, raw] of Object.entries(root.colors as Record<string, unknown>)) {
|
|
72
|
+
if (!FOOTER_COLOR_ROLES.includes(role as FooterColorRole)) {
|
|
73
|
+
throw new Error(`unknown color role "${role}"`);
|
|
74
|
+
}
|
|
75
|
+
colors[role as FooterColorRole] = parseColorValue(role, raw);
|
|
76
|
+
}
|
|
77
|
+
return { colors };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function createFooterConfigStore(agentDir?: string) {
|
|
81
|
+
return createConfigStore<{ colors: FooterColorConfig }>({
|
|
82
|
+
extensionId: "pi-footer",
|
|
83
|
+
agentDir,
|
|
84
|
+
defaults: () => ({ colors: {} }),
|
|
85
|
+
parse: parseFooterColorConfig,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Reads the color config; a missing file yields empty colors (built-in colors), an invalid file throws. */
|
|
90
|
+
export function loadFooterConfig(agentDir?: string): FooterColorConfig {
|
|
91
|
+
return createFooterConfigStore(agentDir).loadSync().value.colors;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Config file path, used in error notifications. */
|
|
95
|
+
export function footerConfigPath(agentDir?: string): string {
|
|
96
|
+
return extensionConfigPath("pi-footer", agentDir);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** An explicit config wins; otherwise fall back to the caller's semantic token. */
|
|
100
|
+
export function paintFooter(theme: Theme, value: FooterColorValue | undefined, fallback: ThemeColor, text: string): string {
|
|
101
|
+
if (value === undefined) return theme.fg(fallback, text);
|
|
102
|
+
return value.kind === "token" ? theme.fg(value.token, text) : theme.style(text, { fg: value.color });
|
|
103
|
+
}
|
package/extensions/footer.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { basename, dirname, join } from "node:path";
|
|
|
3
3
|
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { getCapabilities, hyperlink, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
5
|
import { configuredOpenUri } from "./open.ts";
|
|
6
|
+
import { type FooterColorConfig, footerConfigPath, loadFooterConfig, paintFooter } from "./footer-config.ts";
|
|
6
7
|
|
|
7
8
|
const THINKING_COLORS = {
|
|
8
9
|
minimal: 46,
|
|
@@ -15,6 +16,8 @@ const THINKING_COLORS = {
|
|
|
15
16
|
const QUOTA_STATUS_KEY = "pi-multi-codex";
|
|
16
17
|
const AGENT_TIME_ENTRY = "pi-footer:agent-work";
|
|
17
18
|
const SUBAGENT_BACKGROUND_RESULT = "subagent-background-result";
|
|
19
|
+
// Shorter windows are bursts (non-streamed fallbacks, one large chunk) or too coarse to time.
|
|
20
|
+
const MIN_TPS_WINDOW_MS = 250;
|
|
18
21
|
/** Pi's VIRTUAL_MODEL_API: catalog entries that route each request to a physical model. */
|
|
19
22
|
const VIRTUAL_MODEL_API = "pi-virtual";
|
|
20
23
|
|
|
@@ -284,9 +287,9 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
284
287
|
pi.on("message_end", (event) => {
|
|
285
288
|
if (event.message.role !== "assistant") return;
|
|
286
289
|
const output = event.message.usage?.output ?? 0;
|
|
287
|
-
const
|
|
290
|
+
const milliseconds = assistantStartedAt === undefined ? 0 : assistantLastUpdateAt! - assistantStartedAt;
|
|
288
291
|
assistantStartedAt = assistantLastUpdateAt = undefined;
|
|
289
|
-
tps =
|
|
292
|
+
tps = milliseconds >= MIN_TPS_WINDOW_MS ? output / (milliseconds / 1000) : undefined;
|
|
290
293
|
});
|
|
291
294
|
|
|
292
295
|
// Session entries are append-only; the leaf also changes on tree navigation.
|
|
@@ -324,6 +327,14 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
324
327
|
}
|
|
325
328
|
};
|
|
326
329
|
|
|
330
|
+
// Colors are read once per session start; /reload applies edits
|
|
331
|
+
let footerColors: FooterColorConfig = {};
|
|
332
|
+
try {
|
|
333
|
+
footerColors = loadFooterConfig();
|
|
334
|
+
} catch (error) {
|
|
335
|
+
ctx.ui.notify(`Footer colors ignored (${footerConfigPath()}): ${(error as Error).message}`, "error");
|
|
336
|
+
}
|
|
337
|
+
|
|
327
338
|
ctx.ui.setFooter((tui, theme, data) => {
|
|
328
339
|
requestRuntimeRender = () => tui.requestRender();
|
|
329
340
|
const unsubscribe = data.onBranchChange(() => {
|
|
@@ -353,7 +364,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
353
364
|
const extensionStatuses = data.getExtensionStatuses();
|
|
354
365
|
const prStatus = sanitizeStatus(extensionStatuses.get("pi-pr") ?? "");
|
|
355
366
|
const codegraph = activeCodegraphCalls.size > 0
|
|
356
|
-
? `${theme.
|
|
367
|
+
? `${paintFooter(theme, footerColors.codegraphActive, "accent", "●")} CG`
|
|
357
368
|
: sanitizeStatus(extensionStatuses.get("pi-codegraph") ?? "");
|
|
358
369
|
const quotaStatus = sanitizeStatus(extensionStatuses.get(QUOTA_STATUS_KEY) ?? "");
|
|
359
370
|
const externalStatuses: string[] = [];
|
|
@@ -362,38 +373,42 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
362
373
|
const text = sanitizeStatus(value);
|
|
363
374
|
if (text) externalStatuses.push(text);
|
|
364
375
|
}
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
376
|
+
const sep = (text: string) => paintFooter(theme, footerColors.separator, "dim", text);
|
|
377
|
+
// Previously uncolored joins only take the separator color when it is configured, keeping the default look
|
|
378
|
+
const plainSep = (text: string) =>
|
|
379
|
+
footerColors.separator === undefined ? text : paintFooter(theme, footerColors.separator, "dim", text);
|
|
380
|
+
const ellipsis = sep("…");
|
|
381
|
+
const usage = [
|
|
382
|
+
paintFooter(theme, footerColors.input, "dim", `↑ ${formatTokens(input)}`),
|
|
383
|
+
paintFooter(theme, footerColors.output, "dim", `↓ ${formatTokens(output)}`),
|
|
384
|
+
paintFooter(theme, footerColors.cache, "dim", `↺ ${cacheRate === undefined ? "—" : `${cacheRate.toFixed(1)}%`}`),
|
|
385
|
+
paintFooter(theme, footerColors.throughput, "dim", `⚡ ${tps === undefined ? "—" : `${tps.toFixed(1)} t/s`}`),
|
|
386
|
+
paintFooter(theme, footerColors.cost, "dim", `$ ${cost.toFixed(3)}`),
|
|
387
|
+
paintFooter(theme, footerColors.context, "dim", `◔ ${context == null ? "—" : `${context.toFixed(1)}%`}`),
|
|
388
|
+
].join(sep(" · "));
|
|
374
389
|
const thinkingText = (level: string) => {
|
|
375
390
|
const thinkingColor = THINKING_COLORS[level as keyof typeof THINKING_COLORS];
|
|
376
391
|
return level === "ultra" ? rainbow(level) : thinkingColor === undefined ? theme.fg("dim", level) : color(level, thinkingColor);
|
|
377
392
|
};
|
|
378
|
-
const selected = theme.
|
|
393
|
+
const selected = paintFooter(theme, footerColors.model, "dim", `${ctx.model?.id ?? "no-model"}`) + sep(" • ") + thinkingText(String(ctx.thinkingLevel ?? "off"));
|
|
379
394
|
// Under a virtual selection, show where the latest request went, as Pi's own footer does.
|
|
380
395
|
const model = ctx.model?.api === VIRTUAL_MODEL_API && routed
|
|
381
|
-
? selected + theme.
|
|
396
|
+
? selected + sep(" → ") + paintFooter(theme, footerColors.model, "dim", routed.model) + (routed.thinkingLevel ? sep(" • ") + thinkingText(routed.thinkingLevel) : "")
|
|
382
397
|
: selected;
|
|
383
398
|
const elapsed = activeMilliseconds + (activeStartedAt === undefined ? 0 : performance.now() - activeStartedAt);
|
|
384
|
-
const runtime = theme.
|
|
385
|
-
const identity = branch ? theme.
|
|
399
|
+
const runtime = paintFooter(theme, footerColors.runtime, "dim", `◷ ${formatDuration(elapsed)}`);
|
|
400
|
+
const identity = branch ? paintFooter(theme, footerColors.repository, "dim", repo) + sep(" · ") : "";
|
|
386
401
|
const checkout = branch ?? repo;
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const checkoutStatus = gitSummary.badges ? `${checkoutLink} ${theme.
|
|
391
|
-
const identityLine = prStatus ? `${identity}${checkoutStatus} · ${prStatus}` : `${identity}${checkoutStatus}`;
|
|
402
|
+
const linkUri = getCapabilities().hyperlinks ? openUri : undefined;
|
|
403
|
+
const checkoutText = paintFooter(theme, footerColors.branch, linkUri ? "accent" : "dim", checkout);
|
|
404
|
+
const checkoutLink = linkUri ? hyperlink(checkoutText, linkUri) : checkoutText;
|
|
405
|
+
const checkoutStatus = gitSummary.badges ? `${checkoutLink} ${paintFooter(theme, footerColors.gitStatus, "dim", gitSummary.badges)}` : checkoutLink;
|
|
406
|
+
const identityLine = prStatus ? `${identity}${checkoutStatus}${plainSep(" · ")}${prStatus}` : `${identity}${checkoutStatus}`;
|
|
392
407
|
const firstLine = quotaStatus ? align(identityLine, quotaStatus, width, ellipsis) : identityLine;
|
|
393
408
|
const lines = [
|
|
394
409
|
firstLine,
|
|
395
410
|
align(usage, model, width, ellipsis),
|
|
396
|
-
alignRightReserved([codegraph, ...externalStatuses].filter(Boolean).join(" · "), runtime, width, ellipsis),
|
|
411
|
+
alignRightReserved([codegraph, ...externalStatuses].filter(Boolean).join(plainSep(" · ")), runtime, width, ellipsis),
|
|
397
412
|
];
|
|
398
413
|
return lines.map((line) => truncateToWidth(line, width, ellipsis));
|
|
399
414
|
},
|