@henryqw/pi-footer 4.2.0 → 5.0.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
@@ -101,9 +101,9 @@ Package-owned: `~/.pi/agent/config/pi-footer/config.json` for footer colors and
101
101
  | Name | Description | Values | Default |
102
102
  | --- | --- | --- | --- |
103
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. |
104
- | `command` | Command that `/open` runs. Required when the file exists. | Non-empty string. | `"code"` |
104
+ | `command` | Command that `/open` runs. | Non-empty string. | `"code"` |
105
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.
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 unknown key, an unknown role, or an invalid color shows a warning with the path and the reason. Only the invalid role uses its built-in color. The other roles keep their configured colors. A malformed file uses the built-in colors for all roles.
107
107
 
108
108
  ### Footer color roles
109
109
 
@@ -144,7 +144,7 @@ Theme tokens are `accent`, `dim`, `muted`, `text`, `success`, `warning`, `error`
144
144
 
145
145
  ### Open command
146
146
 
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.
147
+ A missing file silently uses the default command, `code`. An invalid existing file shows a warning and uses `code`. The file 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.
148
148
 
149
149
  ## API
150
150
 
@@ -153,7 +153,8 @@ Consumers: use the owner API. Do not read the config file directly. Replace impo
153
153
  ```ts
154
154
  import { loadOpenInConfig } from "@henryqw/pi-footer/open-uri";
155
155
 
156
- const { source, value } = loadOpenInConfig();
156
+ const loaded = loadOpenInConfig();
157
+ if (loaded.status === "ok") console.log(loaded.value.command, loaded.issues);
157
158
  ```
158
159
 
159
160
  These exports are for package consumers. The commands in Use are the Pi interfaces.
@@ -165,15 +166,15 @@ These exports are for package consumers. The commands in Use are the Pi interfac
165
166
  | `loadOpenInConfig(agentDir?)` | function | Reads and validates the owner config. |
166
167
  | `configuredOpenUri(path)` | function | Returns a safe VS Code URI for a supported command. |
167
168
 
168
- `loadOpenInConfig` returns `source` as `"missing"` or `"file"`. `value.command` is validated. Pass an agent directory when needed.
169
+ `loadOpenInConfig` returns a `@henryqw/pi-config-store` `ConfigLoad` result: `status`, `source` (`"missing"` or `"file"`), `issues`, and, when `status` is `"ok"`, `value`. `value.command` is validated. An invalid `command` gives `"code"` and an issue. The function does not show issues. Pass an agent directory when needed.
169
170
 
170
- `configuredOpenUri(path)` works when the executable is `code`. With `code -n` or `code --new-window`, it adds `windowId=_blank` so the link opens in a new window. It returns `undefined` for other commands or invalid config. `@henryqw/pi-config-store` owns the config home and storage.
171
+ `configuredOpenUri(path)` works when the executable is `code`. With `code -n` or `code --new-window`, it adds `windowId=_blank` so the link opens in a new window. It returns `undefined` for other commands. An invalid config uses `code`. The function never shows a notification, because the footer calls it on each render. `@henryqw/pi-config-store` owns the config home and storage.
171
172
 
172
173
  ## Limits and recovery
173
174
 
174
175
  The open command splits on whitespace into an executable and arguments. Tokens cannot contain spaces, and quoting is unsupported. Use a wrapper script for executables in paths that contain spaces.
175
176
 
176
- An existing config file must be a JSON object with exactly one non-empty string `command` property. Otherwise `/open` fails with a visible error, offers no open URI, and leaves the malformed file unchanged. If the configured command exits with an error, Pi reports its error output or exit code. Fix the command and run `/open` again.
177
+ An existing config file must be a JSON object with one non-empty string `command` property. An unknown key, an invalid `command`, or a malformed file shows a warning at session start, or at the next `/open`. The warning also shows when only the footer is active, because the footer link uses this config. Each issue shows one time in each session, also when the footer and `/open` are both active. The footer render never shows a warning. Then `/open` and the footer link use `code`, and the file stays unchanged. Fix the file or run `/set-open-in <command>` to replace it. `/set-open-in` first shows each new problem in the file that it replaces. If the configured command exits with an error, Pi reports its error output or exit code. Fix the command and run `/open` again.
177
178
 
178
179
  When the configured executable is `code` and Pi reports hyperlink support, the accent-colored checkout name links to the current path. The link opens a new window for `code -n` or `code --new-window`. A missing config silently uses `code`. Other executables and terminals with hyperlinks disabled render plain text.
179
180
 
@@ -1,4 +1,4 @@
1
- import { extensionConfigPath, createConfigStore } from "@henryqw/pi-config-store";
1
+ import { createConfigStore, field, type FieldParser, parsers } from "@henryqw/pi-config-store";
2
2
  import { parseColor, type Color } from "@earendil-works/pi-tui";
3
3
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
4
4
 
@@ -37,65 +37,32 @@ export type FooterColorValue = { kind: "token"; token: ThemeColor } | { kind: "c
37
37
 
38
38
  export type FooterColorConfig = Partial<Record<FooterColorRole, FooterColorValue>>;
39
39
 
40
- function parseColorValue(role: string, raw: unknown): FooterColorValue {
40
+ /** Parses one role's color; a throw drops only that role. */
41
+ export const parseFooterColor: FieldParser<FooterColorValue> = (raw) => {
41
42
  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
- }
43
+ if (!Number.isInteger(raw) || raw < 0 || raw > 255) throw new Error("ANSI index must be an integer from 0 to 255");
45
44
  return { kind: "color", color: parseColor(raw) };
46
45
  }
47
- if (typeof raw !== "string" || !raw.trim()) {
48
- throw new Error(`colors.${role}: expected a theme token or a color value`);
49
- }
46
+ if (typeof raw !== "string" || !raw.trim()) throw new Error("expected a theme token or a color value");
50
47
  if (THEME_TOKENS.has(raw as ThemeColor)) return { kind: "token", token: raw as ThemeColor };
51
48
  try {
52
49
  return { kind: "color", color: parseColor(raw) };
53
50
  } catch {
54
- throw new Error(`colors.${role}: invalid color "${raw}"`);
51
+ throw new Error(`invalid color "${raw}"`);
55
52
  }
56
- }
53
+ };
57
54
 
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 }>({
55
+ /** A missing file, role, or invalid role keeps the built-in color for that role. */
56
+ export function createFooterConfigStore(agentDir?: string) {
57
+ return createConfigStore({
82
58
  extensionId: "pi-footer",
83
59
  agentDir,
84
- defaults: () => ({ colors: {} }),
85
- parse: parseFooterColorConfig,
60
+ fields: {
61
+ colors: field(parsers.record(parseFooterColor, { key: parsers.oneOf(FOOTER_COLOR_ROLES) }), {}),
62
+ },
86
63
  });
87
64
  }
88
65
 
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
66
  /** An explicit config wins; otherwise fall back to the caller's semantic token. */
100
67
  export function paintFooter(theme: Theme, value: FooterColorValue | undefined, fallback: ThemeColor, text: string): string {
101
68
  if (value === undefined) return theme.fg(fallback, text);
@@ -2,8 +2,10 @@ import { readFile } from "node:fs/promises";
2
2
  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
+ import { registerConfigReporting } from "@henryqw/pi-config-store";
6
+ import { createOpenInConfigStore } from "./open-in-config.ts";
5
7
  import { configuredOpenUri } from "./open.ts";
6
- import { type FooterColorConfig, footerConfigPath, loadFooterConfig, paintFooter } from "./footer-config.ts";
8
+ import { createFooterConfigStore, paintFooter } from "./footer-config.ts";
7
9
 
8
10
  const THINKING_COLORS = {
9
11
  minimal: 46,
@@ -179,6 +181,10 @@ function latestResponse(ctx: ExtensionContext): { model: string; thinkingLevel?:
179
181
  }
180
182
 
181
183
  export default function footerExtension(pi: ExtensionAPI): void {
184
+ const colorStore = createFooterConfigStore();
185
+ // Launch reporting shows color config issues; the session-start read below sees the same file. Renders read
186
+ // the open-in config for the checkout link but never notify, so its issues show here too, shared with /open.
187
+ registerConfigReporting(pi, [colorStore, createOpenInConfigStore()]);
182
188
  let activeMilliseconds = 0;
183
189
  let activeStartedAt: number | undefined;
184
190
  let promptPaused = false;
@@ -328,12 +334,8 @@ export default function footerExtension(pi: ExtensionAPI): void {
328
334
  };
329
335
 
330
336
  // 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
+ const loadedColors = colorStore.loadSync();
338
+ const footerColors = loadedColors.status === "ok" ? loadedColors.value.colors : {};
337
339
 
338
340
  ctx.ui.setFooter((tui, theme, data) => {
339
341
  requestRuntimeRender = () => tui.requestRender();
@@ -0,0 +1,12 @@
1
+ import { createConfigStore, field, parsers } from "@henryqw/pi-config-store";
2
+
3
+ const DEFAULT_COMMAND = "code";
4
+
5
+ /** The open-in config store, shared by the footer link and `/open`. */
6
+ export function createOpenInConfigStore(agentDir?: string) {
7
+ return createConfigStore({
8
+ extensionId: "pi-open-in",
9
+ agentDir,
10
+ fields: { command: field(parsers.string({ trim: true }), DEFAULT_COMMAND) },
11
+ });
12
+ }
@@ -1,48 +1,20 @@
1
1
  import { pathToFileURL } from "node:url";
2
2
  import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { createConfigStore } from "@henryqw/pi-config-store";
4
-
5
- const DEFAULT_COMMAND = "code";
3
+ import { type ConfigLoad, registerConfigReporting } from "@henryqw/pi-config-store";
4
+ import { createOpenInConfigStore } from "./open-in-config.ts";
6
5
 
7
6
  export type OpenInConfig = { command: string };
8
7
 
9
- function parseOpenInConfig(value: unknown): OpenInConfig {
10
- const config = value as Record<string, unknown>;
11
- if (
12
- !config ||
13
- typeof config !== "object" ||
14
- Array.isArray(config) ||
15
- Object.keys(config).length !== 1 ||
16
- typeof config.command !== "string" ||
17
- !config.command.trim()
18
- ) {
19
- throw new Error('Invalid open-in config: expected exactly one non-empty string "command" property');
20
- }
21
- return { command: config.command.trim() };
22
- }
23
-
24
- function createOpenInConfigStore(agentDir?: string) {
25
- return createConfigStore<OpenInConfig>({
26
- extensionId: "pi-open-in",
27
- agentDir,
28
- defaults: () => ({ command: DEFAULT_COMMAND }),
29
- parse: parseOpenInConfig,
30
- });
31
- }
32
-
33
- export function loadOpenInConfig(agentDir?: string): { source: "file" | "missing"; value: OpenInConfig } {
8
+ /** An invalid or unknown key is an issue; an invalid `command` falls back to `code`. */
9
+ export function loadOpenInConfig(agentDir?: string): ConfigLoad<OpenInConfig> {
34
10
  return createOpenInConfigStore(agentDir).loadSync();
35
11
  }
36
12
 
13
+ /** Runs on every footer render, so it never notifies; the footer and open extensions report issues at session start. */
37
14
  export function configuredOpenUri(path: string): string | undefined {
38
- let command: string;
39
- try {
40
- command = loadOpenInConfig().value.command;
41
- } catch {
42
- // Invalid config must not break footer render; just omit the URI.
43
- return undefined;
44
- }
45
- const [executable, ...args] = command.split(/\s+/);
15
+ const loaded = loadOpenInConfig();
16
+ if (loaded.status === "disabled") return undefined;
17
+ const [executable, ...args] = loaded.value.command.split(/\s+/);
46
18
  if (executable !== "code") return undefined;
47
19
  const query = args.includes("-n") || args.includes("--new-window") ? "?windowId=_blank" : "";
48
20
  if (path.startsWith("\\\\")) {
@@ -56,12 +28,16 @@ export function configuredOpenUri(path: string): string | undefined {
56
28
 
57
29
  export default function openInExtension(pi: ExtensionAPI): void {
58
30
  const configStore = createOpenInConfigStore();
31
+ const reporter = registerConfigReporting(pi, [configStore]);
59
32
 
60
33
  // Reload config on each call; split the command on whitespace (no quoted tokens).
61
34
  pi.registerCommand("open", {
62
35
  description: "Open the current path with the configured command",
63
36
  handler: async (_args, ctx) => {
64
- const [executable, ...args] = configStore.loadSync().value.command.split(/\s+/);
37
+ const loaded = configStore.loadSync();
38
+ reporter.report(ctx, configStore, loaded);
39
+ if (loaded.status === "disabled") return;
40
+ const [executable, ...args] = loaded.value.command.split(/\s+/);
65
41
  const result = await pi.exec(executable, [...args, ctx.cwd]);
66
42
  if (result.code !== 0) {
67
43
  throw new Error(`Open command failed: ${result.stderr.trim() || `exit code ${result.code}`}`);
@@ -74,6 +50,8 @@ export default function openInExtension(pi: ExtensionAPI): void {
74
50
  handler: async (args, ctx) => {
75
51
  const command = args.trim();
76
52
  if (!command) throw new Error("Usage: /set-open-in <command>");
53
+ // The new command replaces the whole file, so first show any problem in the file that it removes.
54
+ reporter.report(ctx, configStore, configStore.loadSync());
77
55
  await configStore.save({ command });
78
56
  ctx.ui.notify(`Saved open-in command: ${command}`, "info");
79
57
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-footer",
3
- "version": "4.2.0",
3
+ "version": "5.0.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",
@@ -50,7 +50,7 @@
50
50
  "image": "https://raw.githubusercontent.com/HenryQW/pi-harness/main/extensions/pi-footer/example.png"
51
51
  },
52
52
  "dependencies": {
53
- "@henryqw/pi-config-store": "^3.0.0"
53
+ "@henryqw/pi-config-store": "^4.0.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@earendil-works/pi-coding-agent": "1.1.0",