@henryqw/pi-footer 4.1.3 → 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 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
+ }
@@ -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}`, "error");
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.fg("accent", "●")} CG`
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 ellipsis = theme.fg("dim", "…");
368
- const usage = theme.fg("dim", [
369
- `↑ ${formatTokens(input)}`,
370
- `↓ ${formatTokens(output)}`,
371
- `↺ ${cacheRate === undefined ? "—" : `${cacheRate.toFixed(1)}%`}`,
372
- `⚡ ${tps === undefined ? "—" : `${tps.toFixed(1)} t/s`}`,
373
- `$ ${cost.toFixed(3)}`,
374
- `◔ ${context == null ? "—" : `${context.toFixed(1)}%`}`,
375
- ].join(" · "));
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.fg("dim", `${ctx.model?.id ?? "no-model"} • `) + thinkingText(String(ctx.thinkingLevel ?? "off"));
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.fg("dim", ` → ${routed.model}`) + (routed.thinkingLevel ? theme.fg("dim", " • ") + thinkingText(routed.thinkingLevel) : "")
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.fg("dim", `◷ ${formatDuration(elapsed)}`);
387
- const identity = branch ? theme.fg("dim", `${repo} · `) : "";
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 checkoutLink = openUri && getCapabilities().hyperlinks
390
- ? hyperlink(theme.fg("accent", checkout), openUri)
391
- : theme.fg("dim", checkout);
392
- const checkoutStatus = gitSummary.badges ? `${checkoutLink} ${theme.fg("dim", gitSummary.badges)}` : checkoutLink;
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-footer",
3
- "version": "4.1.3",
3
+ "version": "4.2.0",
4
4
  "description": "Show repository and usage details in the Pi footer and open the current working directory.",
5
5
  "keywords": [
6
6
  "pi-package",