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
@@ -0,0 +1,143 @@
1
+ import { basePayload, EVENTS, PROTOCOL_VERSION, } from "./protocol.js";
2
+ const HOST_CLIENT_ID = "pi-extension-utils-host";
3
+ const HOST_WIDGET_PREFIX = "pi-extension-utils";
4
+ const PLACEMENTS = ["aboveEditor", "belowEditor"];
5
+ export function registerWidgetHost(pi) {
6
+ const widgets = new Map();
7
+ const fullscreenStack = [];
8
+ let seq = 0;
9
+ let currentCtx;
10
+ for (const placement of PLACEMENTS)
11
+ widgets.set(placement, []);
12
+ function setHostWidget(placement) {
13
+ if (!currentCtx)
14
+ return;
15
+ const records = sortedWidgets(placement);
16
+ const hidden = fullscreenStack.length > 0;
17
+ if (records.length === 0 && !hidden) {
18
+ currentCtx.ui.setWidget(hostKey(placement), undefined, { placement });
19
+ return;
20
+ }
21
+ currentCtx.ui.setWidget(hostKey(placement), (tui, theme) => createHostComponent(tui, theme, records, hidden), { placement });
22
+ }
23
+ function rerenderAll() {
24
+ for (const placement of PLACEMENTS)
25
+ setHostWidget(placement);
26
+ }
27
+ pi.events.on(EVENTS.hello, (data) => {
28
+ const payload = readPayload(data, "hello");
29
+ if (!payload)
30
+ return;
31
+ pi.events.emit(EVENTS.ready, basePayload(HOST_CLIENT_ID));
32
+ });
33
+ pi.events.on(EVENTS.registerWidget, (data) => {
34
+ const payload = readPayload(data, "register-widget");
35
+ if (!payload || !isPlacement(payload.placement) || typeof payload.key !== "string" || typeof payload.order !== "number" || typeof payload.factory !== "function") {
36
+ warnDrop("register-widget", data);
37
+ return;
38
+ }
39
+ const records = widgets.get(payload.placement);
40
+ const existing = records.find((record) => record.clientId === payload.clientId && record.key === payload.key);
41
+ if (existing) {
42
+ existing.order = payload.order;
43
+ existing.factory = payload.factory;
44
+ }
45
+ else {
46
+ records.push({ clientId: payload.clientId, key: payload.key, order: payload.order, factory: payload.factory, seq: seq++ });
47
+ }
48
+ setHostWidget(payload.placement);
49
+ });
50
+ pi.events.on(EVENTS.unregisterWidget, (data) => {
51
+ const payload = readPayload(data, "unregister-widget");
52
+ if (!payload)
53
+ return;
54
+ if (payload.all) {
55
+ removeClient(payload.clientId);
56
+ rerenderAll();
57
+ return;
58
+ }
59
+ if (!isPlacement(payload.placement) || typeof payload.key !== "string") {
60
+ warnDrop("unregister-widget", data);
61
+ return;
62
+ }
63
+ const records = widgets.get(payload.placement);
64
+ widgets.set(payload.placement, records.filter((record) => record.clientId !== payload.clientId || record.key !== payload.key));
65
+ setHostWidget(payload.placement);
66
+ });
67
+ pi.events.on(EVENTS.fullscreenAcquire, (data) => {
68
+ const payload = readPayload(data, "fullscreen-acquire");
69
+ if (!payload || typeof payload.token !== "string") {
70
+ warnDrop("fullscreen-acquire", data);
71
+ return;
72
+ }
73
+ if (!fullscreenStack.some((lease) => lease.token === payload.token)) {
74
+ fullscreenStack.push({ clientId: payload.clientId, token: payload.token });
75
+ rerenderAll();
76
+ }
77
+ });
78
+ pi.events.on(EVENTS.fullscreenRelease, (data) => {
79
+ const payload = readPayload(data, "fullscreen-release");
80
+ if (!payload || typeof payload.token !== "string") {
81
+ warnDrop("fullscreen-release", data);
82
+ return;
83
+ }
84
+ const before = fullscreenStack.length;
85
+ for (let index = fullscreenStack.length - 1; index >= 0; index--) {
86
+ if (fullscreenStack[index].token === payload.token)
87
+ fullscreenStack.splice(index, 1);
88
+ }
89
+ if (fullscreenStack.length !== before)
90
+ rerenderAll();
91
+ });
92
+ pi.on("session_start", (_event, ctx) => {
93
+ currentCtx = ctx;
94
+ rerenderAll();
95
+ pi.events.emit(EVENTS.ready, basePayload(HOST_CLIENT_ID));
96
+ });
97
+ pi.events.emit(EVENTS.ready, basePayload(HOST_CLIENT_ID));
98
+ function removeClient(clientId) {
99
+ for (const placement of PLACEMENTS) {
100
+ widgets.set(placement, widgets.get(placement).filter((record) => record.clientId !== clientId));
101
+ }
102
+ for (let index = fullscreenStack.length - 1; index >= 0; index--) {
103
+ if (fullscreenStack[index].clientId === clientId)
104
+ fullscreenStack.splice(index, 1);
105
+ }
106
+ }
107
+ function sortedWidgets(placement) {
108
+ return [...widgets.get(placement)].sort((a, b) => a.order - b.order || a.seq - b.seq);
109
+ }
110
+ }
111
+ function createHostComponent(tui, theme, records, hidden) {
112
+ const components = hidden ? [] : records.map((record) => record.factory(tui, theme));
113
+ return {
114
+ render(width) {
115
+ return components.flatMap((component) => component.render(width));
116
+ },
117
+ invalidate() {
118
+ for (const component of components)
119
+ component.invalidate();
120
+ },
121
+ };
122
+ }
123
+ function hostKey(placement) {
124
+ return `${HOST_WIDGET_PREFIX}-${placement}`;
125
+ }
126
+ function readPayload(data, eventName) {
127
+ if (!data || typeof data !== "object") {
128
+ warnDrop(eventName, data);
129
+ return undefined;
130
+ }
131
+ const payload = data;
132
+ if (typeof payload.protocolVersion !== "number" || payload.protocolVersion > PROTOCOL_VERSION || typeof payload.clientId !== "string") {
133
+ warnDrop(eventName, data);
134
+ return undefined;
135
+ }
136
+ return payload;
137
+ }
138
+ function isPlacement(value) {
139
+ return value === "aboveEditor" || value === "belowEditor";
140
+ }
141
+ function warnDrop(eventName, data) {
142
+ console.warn(`pi-extension-utils: dropping invalid ${eventName} payload`, data);
143
+ }
@@ -1,3 +1,4 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
2
  export declare const PROTOCOL_VERSION = 1;
2
3
  export declare const EVENT_PREFIX = "pi-extension-utils";
3
4
  export declare const HELLO_EVENT = "pi-extension-utils:hello";
@@ -15,12 +16,7 @@ export declare const EVENTS: {
15
16
  readonly fullscreenRelease: "pi-extension-utils:fullscreen-release";
16
17
  };
17
18
  export type WidgetPlacement = "aboveEditor" | "belowEditor";
18
- export interface WidgetComponent {
19
- render(width: number): string[];
20
- invalidate(): void;
21
- dispose?(): void;
22
- }
23
- export type WidgetFactory = (tui: unknown, theme: unknown) => WidgetComponent;
19
+ export type WidgetFactory = NonNullable<Parameters<ExtensionContext["ui"]["setWidget"]>[1]>;
24
20
  export interface ProtocolPayload {
25
21
  protocolVersion: number;
26
22
  clientId: string;
package/docs/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # Docs
2
+
3
+ | Doc | Covers |
4
+ |---|---|
5
+ | [client.md](client.md) | `connect()`, fullscreen, reminders, logger |
6
+ | [config.md](config.md) | Extension-owned JSON/JSONC config files |
7
+ | [widgets.md](widgets.md) | Ordered above/below editor widgets |
8
+ | [pane-overlay.md](pane-overlay.md) | Master/detail fullscreen overlays |
9
+ | [reminders.md](reminders.md) | Reminder producer API |
10
+
11
+ Reference artifacts live under [reference/](reference/).
package/docs/client.md ADDED
@@ -0,0 +1,91 @@
1
+ # Client APIs
2
+
3
+ Import from the package root:
4
+
5
+ ```ts
6
+ import { connect, createLogger, paneOverlay } from "pi-extension-utils";
7
+ ```
8
+
9
+ ## Connect
10
+
11
+ ```ts
12
+ const client = connect(pi, { ctx, clientId: "my-extension" });
13
+ ```
14
+
15
+ | Option | Notes |
16
+ |---|---|
17
+ | `pi` | Extension API from your extension factory |
18
+ | `ctx` | Current command/session context |
19
+ | `clientId` | Stable ID used on the shared event bus |
20
+
21
+ Call `client.dispose()` when a long-lived extension instance is shutting down.
22
+
23
+ ## Widgets
24
+
25
+ ```ts
26
+ client.widgets.set("aboveEditor", "status", (tui, theme) => component, { order: 10 });
27
+ client.widgets.remove("aboveEditor", "status");
28
+ ```
29
+
30
+ | Placement | Meaning |
31
+ |---|---|
32
+ | `aboveEditor` | Widget above the editor |
33
+ | `belowEditor` | Widget below the editor |
34
+
35
+ The host composes widgets by `(order, insertion)`.
36
+
37
+ ## Fullscreen
38
+
39
+ ```ts
40
+ await client.ui.fullscreen((tui, theme, keybindings, done) => new MyComponent(tui, theme, done));
41
+ ```
42
+
43
+ `ui.fullscreen()`:
44
+
45
+ - acquires a fullscreen lease
46
+ - blanks coordinated widgets
47
+ - calls `ctx.ui.custom()`
48
+ - releases the lease in `finally`
49
+
50
+ Use `client.fullscreen.acquire()` only when you need manual lease control.
51
+
52
+ ## Pane overlay
53
+
54
+ ```ts
55
+ await client.ui.fullscreen(paneOverlay({ primary, detail }));
56
+ ```
57
+
58
+ See [pane-overlay.md](pane-overlay.md).
59
+
60
+ ## Reminders
61
+
62
+ ```ts
63
+ client.reminders.upsert({ source: "my-extension", id: "state", text: "Remember this." });
64
+ client.reminders.remove("my-extension", "state");
65
+ client.reminders.clearSource("my-extension");
66
+ const snapshot = await client.reminders.list("my-extension");
67
+ ```
68
+
69
+ See [reminders.md](reminders.md).
70
+
71
+ ## Logger
72
+
73
+ ```ts
74
+ const log = createLogger("my-extension", {
75
+ level: "info",
76
+ maxFiles: 5,
77
+ });
78
+
79
+ log.debug("hidden at info level");
80
+ log.info("started");
81
+ log.warn("slow path");
82
+ log.error("failed");
83
+ ```
84
+
85
+ `createLogger(name)` writes to `getAgentDir()/log/<name>.log` by default. `level` is typed as `"debug" | "info" | "warn" | "error" | "silent"`; `maxFiles` controls retained rotations (`.1`, `.2`, ...). If `level`, `maxFiles`, or `maxBytes` are omitted, the value comes from `getAgentDir()/config/utils.jsonc` `logging` defaults. Explicit options always win.
86
+
87
+ Override when needed:
88
+
89
+ ```ts
90
+ createLogger("my-extension", { dir: "/tmp/my-extension-logs", maxBytes: 256 * 1024 });
91
+ ```
package/docs/config.md ADDED
@@ -0,0 +1,86 @@
1
+ # Config
2
+
3
+ `defineConfig()` manages extension-owned config files without touching Pi `settings.json`.
4
+
5
+ ## Basic use
6
+
7
+ ```ts
8
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
+ import { Type, type Static } from "typebox";
10
+ import { defineConfig } from "pi-extension-utils";
11
+
12
+ const schema = Type.Object({
13
+ asyncByDefault: Type.Boolean({
14
+ default: false,
15
+ description: "Run jobs asynchronously unless the caller opts out.",
16
+ }),
17
+ });
18
+
19
+ type MyConfig = Static<typeof schema>;
20
+
21
+ const config = defineConfig({
22
+ name: "my-extension",
23
+ schema,
24
+ });
25
+
26
+ const cfg: MyConfig = config.get();
27
+ ```
28
+
29
+ Default path:
30
+
31
+ ```txt
32
+ getAgentDir()/config/<name>.jsonc
33
+ ```
34
+
35
+ Use `dir` when an extension owns a different config directory:
36
+
37
+ ```ts
38
+ const config = defineConfig({
39
+ name: "subagent",
40
+ dir: getAgentDir(),
41
+ schema,
42
+ });
43
+ ```
44
+
45
+ That resolves either:
46
+
47
+ ```txt
48
+ <dir>/subagent.jsonc
49
+ <dir>/subagent.json
50
+ ```
51
+
52
+ ## JSON and JSONC
53
+
54
+ Resolution is automatic:
55
+
56
+ - if only `.jsonc` exists, JSONC comments and trailing commas are allowed
57
+ - if only `.json` exists, strict JSON is used
58
+ - if neither exists, `.jsonc` is created
59
+ - if both exist, loading throws an ambiguity error
60
+
61
+ ## Defaults and comments
62
+
63
+ Put defaults and comments on the TypeBox schema:
64
+
65
+ ```ts
66
+ Type.Boolean({
67
+ default: false,
68
+ description: "Show hidden reminders in the transcript UI for debugging.",
69
+ })
70
+ ```
71
+
72
+ `get()` creates the file when missing, applies schema defaults, validates, caches, and returns a typed value.
73
+
74
+ Generated JSONC uses `description` as comments. `update()` patches existing files through `jsonc-parser`, so existing comments and formatting are preserved where possible.
75
+
76
+ ## Reload and update
77
+
78
+ ```ts
79
+ config.reload();
80
+
81
+ config.update((cfg) => {
82
+ cfg.asyncByDefault = true;
83
+ });
84
+ ```
85
+
86
+ `get()` returns cached clones. Use `reload()` after external edits, such as on extension reload/session start.
@@ -0,0 +1,88 @@
1
+ # Pane Overlay
2
+
3
+ `paneOverlay()` builds an opinionated fullscreen master/detail UI.
4
+
5
+ Use it when you want:
6
+
7
+ - left/right panes
8
+ - built-in focus, resize, cursor, scroll keys
9
+ - derived key legend
10
+ - custom actions that appear in the legend
11
+
12
+ ## Quick Start
13
+
14
+ ```ts
15
+ await client.ui.fullscreen(
16
+ paneOverlay<void, Run>({
17
+ primary: {
18
+ title: "Runs",
19
+ mode: "cursor",
20
+ rows: () => runs,
21
+ selectionKey: (run) => run.id,
22
+ renderRow: (run) => run.label,
23
+ },
24
+ detail: {
25
+ title: (ctx) => ctx.selectedRow?.label ?? "Details",
26
+ rows: (ctx) => renderDetails(ctx.selectedRow),
27
+ },
28
+ customActions: [
29
+ {
30
+ keys: ["enter", "o"],
31
+ label: "collapse",
32
+ run: (ctx) => toggleCollapse(ctx.selectedKey),
33
+ },
34
+ ],
35
+ }),
36
+ );
37
+ ```
38
+
39
+ ## Standard keys
40
+
41
+ | Keys | Action |
42
+ |---|---|
43
+ | `esc`, `ctrl+c`, `q` | Close (`q` is configurable) |
44
+ | `tab`, `←`, `→` | Switch focus |
45
+ | `j/k`, arrows | Move cursor or scroll focused pane |
46
+ | `u/d` | Half-page up/down |
47
+ | `g/G`, `home/end` | Top/bottom |
48
+ | `[/]` | Resize split |
49
+
50
+ `PageUp/PageDown` are intentionally not part of this helper. Use `u/d`.
51
+
52
+ ## Options
53
+
54
+ | Option | Purpose |
55
+ |---|---|
56
+ | `primary` | Left pane: list or scroll content |
57
+ | `detail` | Right pane: content derived from selected primary row |
58
+ | `split` | Widths, min/max, resize step |
59
+ | `legendPlacement` | `footer` or `primary` |
60
+ | `customActions` | Extra keys with labels and handlers |
61
+ | `closeKeys` | Override close keys, e.g. omit `q` |
62
+ | `collapse` | Optional primary/sidebar collapse key |
63
+ | `perSelectionScroll` | Keep separate detail scroll per selected key |
64
+ | `stickyBottom` | Detail starts/follows at bottom until user scrolls |
65
+
66
+ ## Custom actions
67
+
68
+ ```ts
69
+ customActions: [
70
+ {
71
+ keys: "a",
72
+ label: "all sessions",
73
+ run: (ctx) => toggleAllSessions(),
74
+ },
75
+ {
76
+ keys: "y",
77
+ label: "copy id",
78
+ when: (ctx) => ctx.detailFocus,
79
+ run: (ctx) => copy(ctx.selectedKey),
80
+ },
81
+ ]
82
+ ```
83
+
84
+ Custom actions run before standard movement keys and are included in the legend by default.
85
+
86
+ ## Examples
87
+
88
+ - [examples/pane-overlay.ts](../examples/pane-overlay.ts)