@henryqw/pi-footer 4.1.3 → 4.2.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 +43 -1
- package/extensions/footer-config.ts +103 -0
- package/extensions/footer.ts +33 -20
- 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 warning 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,
|
|
@@ -326,6 +327,14 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
326
327
|
}
|
|
327
328
|
};
|
|
328
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}`, "warning");
|
|
336
|
+
}
|
|
337
|
+
|
|
329
338
|
ctx.ui.setFooter((tui, theme, data) => {
|
|
330
339
|
requestRuntimeRender = () => tui.requestRender();
|
|
331
340
|
const unsubscribe = data.onBranchChange(() => {
|
|
@@ -355,7 +364,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
355
364
|
const extensionStatuses = data.getExtensionStatuses();
|
|
356
365
|
const prStatus = sanitizeStatus(extensionStatuses.get("pi-pr") ?? "");
|
|
357
366
|
const codegraph = activeCodegraphCalls.size > 0
|
|
358
|
-
? `${theme.
|
|
367
|
+
? `${paintFooter(theme, footerColors.codegraphActive, "accent", "●")} CG`
|
|
359
368
|
: sanitizeStatus(extensionStatuses.get("pi-codegraph") ?? "");
|
|
360
369
|
const quotaStatus = sanitizeStatus(extensionStatuses.get(QUOTA_STATUS_KEY) ?? "");
|
|
361
370
|
const externalStatuses: string[] = [];
|
|
@@ -364,38 +373,42 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
364
373
|
const text = sanitizeStatus(value);
|
|
365
374
|
if (text) externalStatuses.push(text);
|
|
366
375
|
}
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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(" · "));
|
|
376
389
|
const thinkingText = (level: string) => {
|
|
377
390
|
const thinkingColor = THINKING_COLORS[level as keyof typeof THINKING_COLORS];
|
|
378
391
|
return level === "ultra" ? rainbow(level) : thinkingColor === undefined ? theme.fg("dim", level) : color(level, thinkingColor);
|
|
379
392
|
};
|
|
380
|
-
const selected = theme.
|
|
393
|
+
const selected = paintFooter(theme, footerColors.model, "dim", `${ctx.model?.id ?? "no-model"}`) + sep(" • ") + thinkingText(String(ctx.thinkingLevel ?? "off"));
|
|
381
394
|
// Under a virtual selection, show where the latest request went, as Pi's own footer does.
|
|
382
395
|
const model = ctx.model?.api === VIRTUAL_MODEL_API && routed
|
|
383
|
-
? selected + theme.
|
|
396
|
+
? selected + sep(" → ") + paintFooter(theme, footerColors.model, "dim", routed.model) + (routed.thinkingLevel ? sep(" • ") + thinkingText(routed.thinkingLevel) : "")
|
|
384
397
|
: selected;
|
|
385
398
|
const elapsed = activeMilliseconds + (activeStartedAt === undefined ? 0 : performance.now() - activeStartedAt);
|
|
386
|
-
const runtime = theme.
|
|
387
|
-
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(" · ") : "";
|
|
388
401
|
const checkout = branch ?? repo;
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const checkoutStatus = gitSummary.badges ? `${checkoutLink} ${theme.
|
|
393
|
-
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}`;
|
|
394
407
|
const firstLine = quotaStatus ? align(identityLine, quotaStatus, width, ellipsis) : identityLine;
|
|
395
408
|
const lines = [
|
|
396
409
|
firstLine,
|
|
397
410
|
align(usage, model, width, ellipsis),
|
|
398
|
-
alignRightReserved([codegraph, ...externalStatuses].filter(Boolean).join(" · "), runtime, width, ellipsis),
|
|
411
|
+
alignRightReserved([codegraph, ...externalStatuses].filter(Boolean).join(plainSep(" · ")), runtime, width, ellipsis),
|
|
399
412
|
];
|
|
400
413
|
return lines.map((line) => truncateToWidth(line, width, ellipsis));
|
|
401
414
|
},
|