pi-extension-utils 0.3.1 → 0.3.3

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.
Files changed (61) hide show
  1. package/README.md +91 -45
  2. package/dist/index.js +9 -140
  3. package/dist/src/client/index.d.ts +7 -0
  4. package/dist/src/client/index.js +25 -0
  5. package/dist/src/client/types.d.ts +17 -0
  6. package/dist/src/client/types.js +1 -0
  7. package/dist/src/config/index.d.ts +28 -0
  8. package/dist/src/config/index.js +222 -0
  9. package/dist/src/index.d.ts +10 -7
  10. package/dist/src/index.js +10 -7
  11. package/dist/src/logger/config.d.ts +11 -0
  12. package/dist/src/logger/config.js +27 -0
  13. package/dist/src/logger/index.d.ts +20 -0
  14. package/dist/src/logger/index.js +76 -0
  15. package/dist/src/{pane-overlay.d.ts → pane/overlay.d.ts} +1 -1
  16. package/dist/src/{pane-overlay.js → pane/overlay.js} +2 -2
  17. package/dist/src/reminders/client.d.ts +10 -0
  18. package/dist/src/reminders/client.js +49 -0
  19. package/dist/src/reminders/config.d.ts +5 -6
  20. package/dist/src/reminders/config.js +9 -23
  21. package/dist/src/reminders/debug.js +2 -2
  22. package/dist/src/reminders/host.d.ts +5 -0
  23. package/dist/src/reminders/host.js +298 -0
  24. package/dist/src/reminders/index.d.ts +3 -5
  25. package/dist/src/reminders/index.js +3 -308
  26. package/dist/src/reminders/types.d.ts +3 -0
  27. package/dist/src/reminders/types.js +3 -0
  28. package/dist/src/ui/client.d.ts +22 -0
  29. package/dist/src/ui/client.js +17 -0
  30. package/dist/src/utils-config.d.ts +24 -0
  31. package/dist/src/utils-config.js +38 -0
  32. package/dist/src/widgets/client.d.ts +27 -0
  33. package/dist/src/widgets/client.js +130 -0
  34. package/dist/src/widgets/host.d.ts +2 -0
  35. package/dist/src/widgets/host.js +143 -0
  36. package/dist/src/{protocol.d.ts → widgets/protocol.d.ts} +2 -6
  37. package/docs/README.md +11 -0
  38. package/docs/client.md +91 -0
  39. package/docs/config.md +86 -0
  40. package/docs/pane-overlay.md +88 -0
  41. package/docs/reference/reminders-spec.md +351 -0
  42. package/docs/reminders.md +68 -0
  43. package/docs/widgets.md +57 -0
  44. package/examples/README.md +23 -0
  45. package/examples/config.ts +31 -0
  46. package/examples/logger.ts +20 -0
  47. package/examples/pane-overlay.ts +47 -0
  48. package/examples/reminders.ts +25 -0
  49. package/examples/widget-coordinator.ts +18 -0
  50. package/package.json +12 -1
  51. package/dist/src/client.d.ts +0 -47
  52. package/dist/src/client.js +0 -194
  53. package/dist/src/logger.d.ts +0 -12
  54. package/dist/src/logger.js +0 -45
  55. /package/dist/src/{tui-chrome.d.ts → pane/chrome.d.ts} +0 -0
  56. /package/dist/src/{tui-chrome.js → pane/chrome.js} +0 -0
  57. /package/dist/src/{key-dispatch.d.ts → pane/key-dispatch.d.ts} +0 -0
  58. /package/dist/src/{key-dispatch.js → pane/key-dispatch.js} +0 -0
  59. /package/dist/src/{pane-state.d.ts → pane/state.d.ts} +0 -0
  60. /package/dist/src/{pane-state.js → pane/state.js} +0 -0
  61. /package/dist/src/{protocol.js → widgets/protocol.js} +0 -0
package/README.md CHANGED
@@ -1,63 +1,109 @@
1
1
  # pi-extension-utils
2
2
 
3
- Shared utilities for Pi extensions. This package has two faces:
3
+ Shared utilities for Pi extensions that need coordinated UI, fullscreen overlays, reminders, and logging.
4
4
 
5
- - `index.ts` is the Pi host extension registered through `package.json` `pi.extensions`.
6
- - `src/index.ts` is the client library surface that other extensions can vendor and import.
5
+ Two faces:
7
6
 
8
- ## Architecture
7
+ - **Host extension** — `package.json` loads `./dist/index.js` once per Pi session.
8
+ - **Client library** — other extensions import from `pi-extension-utils`.
9
9
 
10
- The host and clients communicate over Pi's shared in-process `pi.events` bus. Protocol events are namespaced as `pi-extension-utils:*` and every payload carries `protocolVersion` and `clientId`. Payloads may carry live function references because they never leave the process.
10
+ ## Quick Start
11
11
 
12
- Clients emit `hello` and listen for `ready`. Before a host is ready, widget calls fall back to the consumer extension's own `ctx.ui.setWidget`. If a host arrives later, fallback widgets are cleared and re-registered through the coordinator.
12
+ ```bash
13
+ npm install pi-extension-utils
14
+ ```
15
+
16
+ Add the package to Pi settings so the host extension loads:
13
17
 
14
- The host owns one real widget per placement and composes registered sub-widgets sorted by `(order, insertion)`. Fullscreen leases blank coordinated widgets until all leases are released or the owning client disposes.
18
+ ```json
19
+ {
20
+ "packages": ["npm:pi-extension-utils"]
21
+ }
22
+ ```
15
23
 
16
- ## API sketch
24
+ Use the client from another extension:
17
25
 
18
26
  ```ts
19
- import { connect, createLogger, paneOverlay } from "pi-extension-utils";
27
+ import { connect } from "pi-extension-utils";
20
28
 
21
29
  const client = connect(pi, { ctx, clientId: "my-extension" });
22
- client.widgets.set("belowEditor", "status", (tui, theme) => component, { order: 10 });
23
- client.widgets.remove("belowEditor", "status");
24
-
25
- // Run a custom fullscreen UI: widgets are blanked before mount and
26
- // restored afterwards, even if the component throws.
27
- const result = await client.ui.fullscreen<string | null>(
28
- (tui, theme, keybindings, done) => new MyOverlayComponent(tui, theme, done),
29
- );
30
-
31
- // Opinionated master/detail pane overlay helper with built-in key handling.
32
- const result = await client.ui.fullscreen(
33
- paneOverlay<string, string>({
34
- primary: {
35
- mode: "cursor",
36
- rows: ["one", "two", "three"],
37
- renderRow: (row) => row,
38
- onSelectionChange: (row) => console.log("selected", row),
39
- },
40
- detail: {
41
- title: "Details",
42
- rows: (ctx) => [`Selected: ${ctx.selectedRow}`],
43
- },
44
- legendPlacement: "footer",
45
- }),
46
- );
30
+ ```
31
+
32
+ ## What you get
47
33
 
48
- // Lower-level lease, for manual control over the blank/restore window.
49
- const lease = client.fullscreen.acquire();
50
- lease.release();
34
+ | Feature | Use when |
35
+ |---|---|
36
+ | Widget coordinator | Multiple extensions need ordered widgets above/below the editor |
37
+ | `ui.fullscreen()` | A command opens a custom full-screen UI and should hide widgets while open |
38
+ | `paneOverlay()` | You want a master/detail fullscreen dashboard with built-in keys and legend |
39
+ | Reminders | An extension needs durable model-visible guidance without mutating prompts |
40
+ | Logger | You need namespaced rotating logs |
51
41
 
52
- // Reminders (host absorbed from pi-reminders): fire-and-forget emits;
53
- // list() resolves { reminders: [], count: 0 } when no host is present.
54
- client.reminders.upsert({ id: "my-reminder", text: "...", source: "my-extension" });
55
- client.reminders.remove("my-reminder");
56
- client.reminders.clearSource("my-extension");
57
- const { reminders } = await client.reminders.list();
42
+ ## Small Example
58
43
 
59
- client.dispose();
44
+ ```ts
45
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
46
+ import { connect, createLogger, paneOverlay } from "pi-extension-utils";
60
47
 
61
48
  const log = createLogger("my-extension");
62
- log.info("started");
49
+
50
+ export default function (pi: ExtensionAPI) {
51
+ pi.on("session_start", (_event, ctx) => {
52
+ const client = connect(pi, { ctx, clientId: "my-extension" });
53
+
54
+ client.widgets.set("belowEditor", "status", () => ({
55
+ render: () => ["my-extension: ready"],
56
+ invalidate: () => {},
57
+ }), { order: 10 });
58
+
59
+ client.reminders.upsert({
60
+ source: "my-extension",
61
+ id: "state",
62
+ text: "Remember the current extension state.",
63
+ });
64
+ });
65
+
66
+ pi.registerCommand("my-dashboard", {
67
+ description: "Open my extension dashboard",
68
+ handler: async (_args, ctx) => {
69
+ const client = connect(pi, { ctx, clientId: "my-extension" });
70
+ await client.ui.fullscreen(paneOverlay({
71
+ primary: { title: "Items", mode: "cursor", rows: ["alpha", "beta"] },
72
+ detail: { title: "Details", rows: (o) => [`selected: ${o.selectedRow}`] },
73
+ }));
74
+ },
75
+ });
76
+
77
+ log.info("registered");
78
+ }
79
+ ```
80
+
81
+ ## Docs
82
+
83
+ | Topic | Link |
84
+ |---|---|
85
+ | Client APIs | [docs/client.md](docs/client.md) |
86
+ | Config files | [docs/config.md](docs/config.md) |
87
+ | Widget coordinator | [docs/widgets.md](docs/widgets.md) |
88
+ | Pane overlays | [docs/pane-overlay.md](docs/pane-overlay.md) |
89
+ | Reminders | [docs/reminders.md](docs/reminders.md) |
90
+
91
+ ## Examples
92
+
93
+ See [examples/](examples/).
94
+
95
+ | Example | Shows |
96
+ |---|---|
97
+ | [`widget-coordinator.ts`](examples/widget-coordinator.ts) | Ordered widgets through the coordinator |
98
+ | [`config.ts`](examples/config.ts) | Extension-owned JSON/JSONC config |
99
+ | [`pane-overlay.ts`](examples/pane-overlay.ts) | Fullscreen master/detail dashboard |
100
+ | [`reminders.ts`](examples/reminders.ts) | Upsert/list/clear reminders |
101
+ | [`logger.ts`](examples/logger.ts) | Namespaced logger |
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ npm run typecheck
107
+ npm test
108
+ npm run build
63
109
  ```
package/dist/index.js CHANGED
@@ -1,145 +1,14 @@
1
- import { registerReminderHost } from "./src/reminders/index.js";
2
- import { basePayload, EVENTS, PROTOCOL_VERSION, } from "./src/protocol.js";
3
- const HOST_CLIENT_ID = "pi-extension-utils-host";
4
- const HOST_WIDGET_PREFIX = "pi-extension-utils";
5
- const PLACEMENTS = ["aboveEditor", "belowEditor"];
1
+ import { registerReminderHost } from "./src/reminders/host.js";
2
+ import { registerWidgetHost } from "./src/widgets/host.js";
6
3
  export default function (pi) {
7
- registerReminderHost(pi);
8
- const widgets = new Map();
9
- const fullscreenStack = [];
10
- let seq = 0;
11
- let currentCtx;
12
- for (const placement of PLACEMENTS)
13
- widgets.set(placement, []);
14
- function setHostWidget(placement) {
15
- if (!currentCtx)
16
- return;
17
- const records = sortedWidgets(placement);
18
- const hidden = fullscreenStack.length > 0;
19
- if (records.length === 0 && !hidden) {
20
- currentCtx.ui.setWidget(hostKey(placement), undefined, { placement });
21
- return;
22
- }
23
- currentCtx.ui.setWidget(hostKey(placement), (tui, theme) => createHostComponent(tui, theme, records, hidden), { placement });
24
- }
25
- function rerenderAll() {
26
- for (const placement of PLACEMENTS)
27
- setHostWidget(placement);
28
- }
29
- pi.events.on(EVENTS.hello, (data) => {
30
- const payload = readPayload(data, "hello");
31
- if (!payload)
32
- return;
33
- pi.events.emit(EVENTS.ready, basePayload(HOST_CLIENT_ID));
34
- });
35
- pi.events.on(EVENTS.registerWidget, (data) => {
36
- const payload = readPayload(data, "register-widget");
37
- if (!payload || !isPlacement(payload.placement) || typeof payload.key !== "string" || typeof payload.order !== "number" || typeof payload.factory !== "function") {
38
- warnDrop("register-widget", data);
39
- return;
40
- }
41
- const records = widgets.get(payload.placement);
42
- const existing = records.find((record) => record.clientId === payload.clientId && record.key === payload.key);
43
- if (existing) {
44
- existing.order = payload.order;
45
- existing.factory = payload.factory;
46
- }
47
- else {
48
- records.push({ clientId: payload.clientId, key: payload.key, order: payload.order, factory: payload.factory, seq: seq++ });
49
- }
50
- setHostWidget(payload.placement);
51
- });
52
- pi.events.on(EVENTS.unregisterWidget, (data) => {
53
- const payload = readPayload(data, "unregister-widget");
54
- if (!payload)
55
- return;
56
- if (payload.all) {
57
- removeClient(payload.clientId);
58
- rerenderAll();
59
- return;
60
- }
61
- if (!isPlacement(payload.placement) || typeof payload.key !== "string") {
62
- warnDrop("unregister-widget", data);
63
- return;
64
- }
65
- const records = widgets.get(payload.placement);
66
- widgets.set(payload.placement, records.filter((record) => record.clientId !== payload.clientId || record.key !== payload.key));
67
- setHostWidget(payload.placement);
68
- });
69
- pi.events.on(EVENTS.fullscreenAcquire, (data) => {
70
- const payload = readPayload(data, "fullscreen-acquire");
71
- if (!payload || typeof payload.token !== "string") {
72
- warnDrop("fullscreen-acquire", data);
73
- return;
74
- }
75
- if (!fullscreenStack.some((lease) => lease.token === payload.token)) {
76
- fullscreenStack.push({ clientId: payload.clientId, token: payload.token });
77
- rerenderAll();
78
- }
79
- });
80
- pi.events.on(EVENTS.fullscreenRelease, (data) => {
81
- const payload = readPayload(data, "fullscreen-release");
82
- if (!payload || typeof payload.token !== "string") {
83
- warnDrop("fullscreen-release", data);
84
- return;
85
- }
86
- const before = fullscreenStack.length;
87
- for (let index = fullscreenStack.length - 1; index >= 0; index--) {
88
- if (fullscreenStack[index].token === payload.token)
89
- fullscreenStack.splice(index, 1);
90
- }
91
- if (fullscreenStack.length !== before)
92
- rerenderAll();
93
- });
94
- pi.on("session_start", (_event, ctx) => {
95
- currentCtx = ctx;
96
- rerenderAll();
97
- pi.events.emit(EVENTS.ready, basePayload(HOST_CLIENT_ID));
98
- });
99
- pi.events.emit(EVENTS.ready, basePayload(HOST_CLIENT_ID));
100
- function removeClient(clientId) {
101
- for (const placement of PLACEMENTS) {
102
- widgets.set(placement, widgets.get(placement).filter((record) => record.clientId !== clientId));
103
- }
104
- for (let index = fullscreenStack.length - 1; index >= 0; index--) {
105
- if (fullscreenStack[index].clientId === clientId)
106
- fullscreenStack.splice(index, 1);
107
- }
108
- }
109
- function sortedWidgets(placement) {
110
- return [...widgets.get(placement)].sort((a, b) => a.order - b.order || a.seq - b.seq);
111
- }
4
+ registerHost("reminders", () => registerReminderHost(pi));
5
+ registerHost("widgets", () => registerWidgetHost(pi));
112
6
  }
113
- function createHostComponent(tui, theme, records, hidden) {
114
- const components = hidden ? [] : records.map((record) => record.factory(tui, theme));
115
- return {
116
- render(width) {
117
- return components.flatMap((component) => component.render(width));
118
- },
119
- invalidate() {
120
- for (const component of components)
121
- component.invalidate();
122
- },
123
- };
124
- }
125
- function hostKey(placement) {
126
- return `${HOST_WIDGET_PREFIX}-${placement}`;
127
- }
128
- function readPayload(data, eventName) {
129
- if (!data || typeof data !== "object") {
130
- warnDrop(eventName, data);
131
- return undefined;
7
+ function registerHost(name, register) {
8
+ try {
9
+ register();
132
10
  }
133
- const payload = data;
134
- if (typeof payload.protocolVersion !== "number" || payload.protocolVersion > PROTOCOL_VERSION || typeof payload.clientId !== "string") {
135
- warnDrop(eventName, data);
136
- return undefined;
11
+ catch (error) {
12
+ console.warn(`pi-extension-utils: failed to register ${name} host`, error);
137
13
  }
138
- return payload;
139
- }
140
- function isPlacement(value) {
141
- return value === "aboveEditor" || value === "belowEditor";
142
- }
143
- function warnDrop(eventName, data) {
144
- console.warn(`pi-extension-utils: dropping invalid ${eventName} payload`, data);
145
14
  }
@@ -0,0 +1,7 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { UtilsClient, UtilsClientOptions } from "./types.ts";
3
+ export type { UtilsClient, UtilsClientOptions } from "./types.ts";
4
+ export type { RemindersClient } from "../reminders/client.ts";
5
+ export type { FullscreenComponentFactory, UiClient } from "../ui/client.ts";
6
+ export type { FullscreenClient, FullscreenLease, WidgetsClient, WidgetSetOptions } from "../widgets/client.ts";
7
+ export declare function connect(pi: ExtensionAPI, opts: UtilsClientOptions): UtilsClient;
@@ -0,0 +1,25 @@
1
+ import { createRemindersClient } from "../reminders/client.js";
2
+ import { createUiClient } from "../ui/client.js";
3
+ import { connectWidgetCoordinator } from "../widgets/client.js";
4
+ let nextClientId = 1;
5
+ export function connect(pi, opts) {
6
+ const clientId = opts.clientId ?? `client-${process.pid}-${Date.now()}-${nextClientId++}`;
7
+ let disposed = false;
8
+ const coordinator = connectWidgetCoordinator(pi, { ctx: opts.ctx, clientId });
9
+ return {
10
+ clientId,
11
+ get mode() {
12
+ return coordinator.mode;
13
+ },
14
+ widgets: coordinator.widgets,
15
+ fullscreen: coordinator.fullscreen,
16
+ ui: createUiClient(coordinator.fullscreen, opts.ctx),
17
+ reminders: createRemindersClient(pi, () => disposed),
18
+ dispose() {
19
+ if (disposed)
20
+ return;
21
+ disposed = true;
22
+ coordinator.dispose();
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,17 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { RemindersClient } from "../reminders/client.ts";
3
+ import type { UiClient } from "../ui/client.ts";
4
+ import type { FullscreenClient, WidgetsClient } from "../widgets/client.ts";
5
+ export interface UtilsClientOptions {
6
+ ctx: ExtensionContext;
7
+ clientId?: string;
8
+ }
9
+ export interface UtilsClient {
10
+ readonly clientId: string;
11
+ readonly mode: "fallback" | "coordinated";
12
+ widgets: WidgetsClient;
13
+ fullscreen: FullscreenClient;
14
+ ui: UiClient;
15
+ reminders: RemindersClient;
16
+ dispose(): void;
17
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import type { Static, TSchema } from "typebox";
2
+ export type ConfigFormat = "json" | "jsonc";
3
+ export type DeepPartial<T> = T extends readonly unknown[] ? T : T extends object ? {
4
+ [K in keyof T]?: DeepPartial<T[K]>;
5
+ } : T;
6
+ export interface DefineConfigOptions<Schema extends TSchema> {
7
+ /** File basename, without .json/.jsonc. */
8
+ name: string;
9
+ /** Directory containing <name>.json or <name>.jsonc. Defaults to getAgentDir()/config. */
10
+ dir?: string;
11
+ /** TypeBox schema. Field defaults and descriptions are read from this schema. */
12
+ schema: Schema;
13
+ }
14
+ export interface DefinedConfig<Schema extends TSchema> {
15
+ readonly name: string;
16
+ readonly schema: Schema;
17
+ /** Return the resolved config path. If missing, returns the default .jsonc path. */
18
+ path(): string;
19
+ /** Ensure the config file exists, creating .jsonc with schema defaults/comments when missing. */
20
+ ensure(): string;
21
+ /** Ensure, load, apply schema defaults, validate, cache, and return config. */
22
+ get(): Static<Schema>;
23
+ /** Force a disk reread, replacing the cached value. */
24
+ reload(): Static<Schema>;
25
+ /** Patch config or mutate a draft; writes to disk and updates cache. */
26
+ update(patch: DeepPartial<Static<Schema>> | ((draft: Static<Schema>) => void)): Static<Schema>;
27
+ }
28
+ export declare function defineConfig<Schema extends TSchema>(options: DefineConfigOptions<Schema>): DefinedConfig<Schema>;
@@ -0,0 +1,222 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { applyEdits, modify, parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
5
+ import { Value } from "typebox/value";
6
+ const FORMAT_OPTIONS = { insertSpaces: false, tabSize: 1, eol: "\n", insertFinalNewline: true };
7
+ export function defineConfig(options) {
8
+ validateName(options.name);
9
+ let cachedPath;
10
+ let cachedValue;
11
+ const config = {
12
+ name: options.name,
13
+ schema: options.schema,
14
+ path() {
15
+ return resolvePath().path;
16
+ },
17
+ ensure() {
18
+ const resolved = resolvePath();
19
+ if (!resolved.exists) {
20
+ mkdirSync(configDir(), { recursive: true });
21
+ writeFileSync(resolved.path, createInitialContent(options.schema, resolved.format));
22
+ cachedPath = { ...resolved, exists: true };
23
+ }
24
+ return resolved.path;
25
+ },
26
+ get() {
27
+ if (cachedValue !== undefined)
28
+ return cloneJson(cachedValue);
29
+ return load(true);
30
+ },
31
+ reload() {
32
+ cachedValue = undefined;
33
+ cachedPath = undefined;
34
+ return load(true);
35
+ },
36
+ update(patch) {
37
+ const filePath = config.ensure();
38
+ const resolved = resolvePath();
39
+ const before = load(false);
40
+ const draft = cloneJson(before);
41
+ if (typeof patch === "function") {
42
+ patch(draft);
43
+ }
44
+ else {
45
+ mergeDeep(draft, patch);
46
+ }
47
+ const after = applyDefaultsAndValidate(options.schema, draft, resolved.path);
48
+ writeUpdatedConfig(filePath, resolved.format, before, after);
49
+ cachedValue = cloneJson(after);
50
+ return cloneJson(after);
51
+ },
52
+ };
53
+ function configDir() {
54
+ return options.dir ?? join(getAgentDir(), "config");
55
+ }
56
+ function resolvePath() {
57
+ if (cachedPath?.exists)
58
+ return cachedPath;
59
+ const dir = configDir();
60
+ const jsoncPath = join(dir, `${options.name}.jsonc`);
61
+ const jsonPath = join(dir, `${options.name}.json`);
62
+ const hasJsonc = existsSync(jsoncPath);
63
+ const hasJson = existsSync(jsonPath);
64
+ if (hasJsonc && hasJson) {
65
+ throw new Error(`Config '${options.name}' is ambiguous: both ${jsoncPath} and ${jsonPath} exist`);
66
+ }
67
+ cachedPath = hasJsonc
68
+ ? { path: jsoncPath, format: "jsonc", exists: true }
69
+ : hasJson
70
+ ? { path: jsonPath, format: "json", exists: true }
71
+ : { path: jsoncPath, format: "jsonc", exists: false };
72
+ return cachedPath;
73
+ }
74
+ function load(ensure) {
75
+ if (ensure)
76
+ config.ensure();
77
+ const resolved = resolvePath();
78
+ let parsed = {};
79
+ if (resolved.exists || existsSync(resolved.path)) {
80
+ parsed = readConfigFile(resolved.path, resolved.format);
81
+ }
82
+ const value = applyDefaultsAndValidate(options.schema, parsed, resolved.path);
83
+ cachedValue = cloneJson(value);
84
+ return cloneJson(value);
85
+ }
86
+ return config;
87
+ }
88
+ function validateName(name) {
89
+ if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
90
+ throw new Error(`Config name must be a filename base using letters, numbers, '.', '_' or '-': ${name}`);
91
+ }
92
+ }
93
+ function readConfigFile(path, format) {
94
+ const text = readFileSync(path, "utf8");
95
+ if (!text.trim())
96
+ return {};
97
+ if (format === "json")
98
+ return JSON.parse(text);
99
+ const errors = [];
100
+ const parsed = parseJsonc(text, errors, { allowTrailingComma: true });
101
+ if (errors.length > 0) {
102
+ const first = errors[0];
103
+ throw new Error(`Invalid JSONC in ${path} at offset ${first.offset}: ${printParseErrorCode(first.error)}`);
104
+ }
105
+ return parsed ?? {};
106
+ }
107
+ function applyDefaultsAndValidate(schema, value, path) {
108
+ const defaults = defaultValue(schema);
109
+ const merged = mergeDefaults(defaults.hasDefault ? defaults.value : {}, isPlainObject(value) ? value : value ?? {});
110
+ Value.Default(schema, merged);
111
+ if (!Value.Check(schema, merged)) {
112
+ const details = [...Value.Errors(schema, merged)]
113
+ .slice(0, 5)
114
+ .map((error) => `${"path" in error && typeof error.path === "string" ? error.path : "/"} ${error.message}`)
115
+ .join("; ");
116
+ throw new Error(`Invalid config ${path}: ${details}`);
117
+ }
118
+ return merged;
119
+ }
120
+ function writeUpdatedConfig(path, format, before, after) {
121
+ if (format === "json") {
122
+ writeFileSync(path, `${JSON.stringify(after, null, "\t")}\n`);
123
+ return;
124
+ }
125
+ let text = readFileSync(path, "utf8");
126
+ for (const diff of diffLeaves(before, after)) {
127
+ const edits = modify(text, diff.path, diff.value, { formattingOptions: FORMAT_OPTIONS });
128
+ text = applyEdits(text, edits);
129
+ }
130
+ writeFileSync(path, text.endsWith("\n") ? text : `${text}\n`);
131
+ }
132
+ function createInitialContent(schema, format) {
133
+ const typedSchema = schema;
134
+ const defaults = defaultValue(typedSchema);
135
+ const value = defaults.hasDefault ? defaults.value : typedSchema.type === "object" ? {} : null;
136
+ if (format === "json")
137
+ return `${JSON.stringify(value, null, "\t")}\n`;
138
+ return `${stringifyJsoncWithComments(value, typedSchema, 0)}\n`;
139
+ }
140
+ function stringifyJsoncWithComments(value, schema, level) {
141
+ if (isPlainObject(value)) {
142
+ const properties = isPlainObject(schema.properties) ? schema.properties : {};
143
+ const keys = orderedKeys(value, properties);
144
+ if (keys.length === 0)
145
+ return "{}";
146
+ const lines = ["{"];
147
+ keys.forEach((key, index) => {
148
+ const childSchema = properties[key] ?? {};
149
+ const indent = "\t".repeat(level + 1);
150
+ const description = typeof childSchema.description === "string" ? childSchema.description.trim() : "";
151
+ if (description) {
152
+ for (const line of description.split(/\r?\n/)) {
153
+ lines.push(`${indent}// ${line.trim()}`);
154
+ }
155
+ }
156
+ const rendered = stringifyJsoncWithComments(value[key], childSchema, level + 1);
157
+ lines.push(`${indent}${JSON.stringify(key)}: ${rendered}${index === keys.length - 1 ? "" : ","}`);
158
+ });
159
+ lines.push(`${"\t".repeat(level)}}`);
160
+ return lines.join("\n");
161
+ }
162
+ return JSON.stringify(value, null, "\t");
163
+ }
164
+ function orderedKeys(value, properties) {
165
+ const object = value;
166
+ const keys = new Set([...Object.keys(properties), ...Object.keys(object)]);
167
+ return [...keys].filter((key) => Object.prototype.hasOwnProperty.call(object, key));
168
+ }
169
+ function defaultValue(schema) {
170
+ if (Object.prototype.hasOwnProperty.call(schema, "default")) {
171
+ return { hasDefault: true, value: cloneJson(schema.default) };
172
+ }
173
+ if (schema.type === "object" && isPlainObject(schema.properties)) {
174
+ const value = {};
175
+ for (const [key, child] of Object.entries(schema.properties)) {
176
+ const childDefault = defaultValue(child);
177
+ if (childDefault.hasDefault)
178
+ value[key] = childDefault.value;
179
+ }
180
+ return Object.keys(value).length > 0 ? { hasDefault: true, value } : { hasDefault: false, value: undefined };
181
+ }
182
+ return { hasDefault: false, value: undefined };
183
+ }
184
+ function mergeDefaults(defaults, value) {
185
+ if (isPlainObject(defaults) && isPlainObject(value)) {
186
+ const merged = { ...cloneJson(defaults) };
187
+ for (const [key, childValue] of Object.entries(value)) {
188
+ merged[key] = Object.prototype.hasOwnProperty.call(merged, key)
189
+ ? mergeDefaults(merged[key], childValue)
190
+ : childValue;
191
+ }
192
+ return merged;
193
+ }
194
+ return value === undefined ? cloneJson(defaults) : value;
195
+ }
196
+ function mergeDeep(target, patch) {
197
+ if (!isPlainObject(target) || !isPlainObject(patch))
198
+ return;
199
+ for (const [key, value] of Object.entries(patch)) {
200
+ if (isPlainObject(value) && isPlainObject(target[key])) {
201
+ mergeDeep(target[key], value);
202
+ }
203
+ else {
204
+ target[key] = cloneJson(value);
205
+ }
206
+ }
207
+ }
208
+ function diffLeaves(before, after, path = []) {
209
+ if (isPlainObject(before) && isPlainObject(after)) {
210
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
211
+ return [...keys].flatMap((key) => diffLeaves(before[key], after[key], [...path, key]));
212
+ }
213
+ if (JSON.stringify(before) === JSON.stringify(after))
214
+ return [];
215
+ return [{ path, value: after }];
216
+ }
217
+ function isPlainObject(value) {
218
+ return typeof value === "object" && value !== null && !Array.isArray(value);
219
+ }
220
+ function cloneJson(value) {
221
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
222
+ }
@@ -1,8 +1,11 @@
1
- export { connect, type FullscreenLease, type RemindersClient, type UtilsClient, type UtilsClientOptions, type WidgetSetOptions } from "./client.ts";
2
- export { createLogger, type Logger, type LoggerOptions } from "./logger.ts";
3
- export * from "./key-dispatch.ts";
4
- export * from "./pane-state.ts";
5
- export * from "./protocol.ts";
1
+ export { connect, type FullscreenComponentFactory, type FullscreenLease, type RemindersClient, type UtilsClient, type UtilsClientOptions, type WidgetSetOptions } from "./client/index.ts";
2
+ export { defineConfig, type DeepPartial, type DefinedConfig, type DefineConfigOptions } from "./config/index.ts";
3
+ export { createLogger, LOG_LEVELS, loggerLevelSchema, loggingConfigSchema, type Logger, type LoggerLevel, type LoggerOptions, type LoggingConfig, type LogLevel } from "./logger/index.ts";
4
+ export { utilsConfigSchema, type UtilsConfig } from "./utils-config.ts";
5
+ export * from "./pane/key-dispatch.ts";
6
+ export * from "./pane/state.ts";
7
+ export * from "./widgets/protocol.ts";
8
+ export { remindersConfigSchema, type RemindersConfig } from "./reminders/config.ts";
6
9
  export * from "./reminders/types.ts";
7
- export * from "./tui-chrome.ts";
8
- export * from "./pane-overlay.ts";
10
+ export * from "./pane/chrome.ts";
11
+ export * from "./pane/overlay.ts";
package/dist/src/index.js CHANGED
@@ -1,8 +1,11 @@
1
- export { connect } from "./client.js";
2
- export { createLogger } from "./logger.js";
3
- export * from "./key-dispatch.js";
4
- export * from "./pane-state.js";
5
- export * from "./protocol.js";
1
+ export { connect } from "./client/index.js";
2
+ export { defineConfig } from "./config/index.js";
3
+ export { createLogger, LOG_LEVELS, loggerLevelSchema, loggingConfigSchema } from "./logger/index.js";
4
+ export { utilsConfigSchema } from "./utils-config.js";
5
+ export * from "./pane/key-dispatch.js";
6
+ export * from "./pane/state.js";
7
+ export * from "./widgets/protocol.js";
8
+ export { remindersConfigSchema } from "./reminders/config.js";
6
9
  export * from "./reminders/types.js";
7
- export * from "./tui-chrome.js";
8
- export * from "./pane-overlay.js";
10
+ export * from "./pane/chrome.js";
11
+ export * from "./pane/overlay.js";
@@ -0,0 +1,11 @@
1
+ import { Type, type Static } from "typebox";
2
+ export declare const LOG_LEVELS: readonly ["debug", "info", "warn", "error"];
3
+ export type LogLevel = typeof LOG_LEVELS[number];
4
+ export type LoggerLevel = LogLevel | "silent";
5
+ export declare const loggerLevelSchema: Type.TUnion<[Type.TLiteral<"debug">, Type.TLiteral<"info">, Type.TLiteral<"warn">, Type.TLiteral<"error">, Type.TLiteral<"silent">]>;
6
+ export declare const loggingConfigSchema: Type.TObject<{
7
+ level: Type.TUnion<[Type.TLiteral<"debug">, Type.TLiteral<"info">, Type.TLiteral<"warn">, Type.TLiteral<"error">, Type.TLiteral<"silent">]>;
8
+ maxFiles: Type.TNumber;
9
+ maxBytes: Type.TNumber;
10
+ }>;
11
+ export type LoggingConfig = Static<typeof loggingConfigSchema>;