@henryqw/pi-footer 4.2.1 → 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 +8 -7
- package/extensions/footer-config.ts +13 -46
- package/extensions/footer.ts +9 -7
- package/extensions/open-in-config.ts +12 -0
- package/extensions/open.ts +15 -37
- package/package.json +2 -2
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.
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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(`
|
|
51
|
+
throw new Error(`invalid color "${raw}"`);
|
|
55
52
|
}
|
|
56
|
-
}
|
|
53
|
+
};
|
|
57
54
|
|
|
58
|
-
/**
|
|
59
|
-
export function
|
|
60
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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);
|
package/extensions/footer.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
footerColors = loadFooterConfig();
|
|
334
|
-
} catch (error) {
|
|
335
|
-
ctx.ui.notify(`Footer colors ignored (${footerConfigPath()}): ${(error as Error).message}`, "warning");
|
|
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
|
+
}
|
package/extensions/open.ts
CHANGED
|
@@ -1,48 +1,20 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
2
|
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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": "
|
|
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": "^
|
|
53
|
+
"@henryqw/pi-config-store": "^4.0.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@earendil-works/pi-coding-agent": "1.1.0",
|