pi-libactions 0.3.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luan Santos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # pi-libactions
2
+
3
+ `pi-libactions` is a UI-free action registry and keybinding loader
4
+ for Pi extensions. It is a library, not a Pi extension: importing it registers
5
+ no commands, shortcuts, tools, or UI, and it is not installed with `pi install`
6
+ on its own. It reaches users as a dependency of the extensions that use it.
7
+
8
+ The registry lets an extension publish a named action without knowing which
9
+ host will expose it. A shortcut host (currently `pi-xsettings`) reads
10
+ the user's `keybindings.json` and binds each configured key to the matching
11
+ action. Modal features such as `pi-copy-mode` read the same snapshot
12
+ without registering their keys as global editor shortcuts.
13
+
14
+ ## Preview
15
+
16
+ Actions registered by Side Panel and Side Chat, bound by Xsettings, and listed in Pi's `/hotkeys`. The library itself registers no UI.
17
+
18
+ ![pi-libactions in Bootty](https://github.com/luan/agents/releases/download/v0.3.2/pi-libactions.png)
19
+
20
+ [Watch the demo](https://github.com/luan/agents/releases/download/v0.3.2/pi-libactions.mp4).
21
+
22
+ ## For users: `keybindings.json`
23
+
24
+ Actions registered through this library have no default keys. You choose every
25
+ binding in one file in Pi's agent directory:
26
+
27
+ ```text
28
+ <Pi agent directory>/keybindings.json
29
+ ```
30
+
31
+ For a normal local install this is `~/.pi/agent/keybindings.json` (the path is
32
+ `join(getAgentDir(), "keybindings.json")`, so it follows Pi's agent directory).
33
+
34
+ The file is a JSON object. Each property name is an action ID; each value is a
35
+ single key ID string or an array of key ID strings:
36
+
37
+ ```json
38
+ {
39
+ "example.open": "ctrl+o",
40
+ "xsettings.effort.increase": ["alt+."],
41
+ "codex.context.cycle": "ctrl+shift+w"
42
+ }
43
+ ```
44
+
45
+ A key ID is a base key optionally preceded by modifiers, joined with `+`, for
46
+ example `ctrl+shift+p`. Modifiers are `ctrl`, `shift`, `alt`, and `super`, and
47
+ each may appear once. Accepted base keys are:
48
+
49
+ - lowercase letters `a`-`z` and digits `0`-`9`
50
+ - punctuation: `` ` `` `-` `=` `[` `]` `\` `;` `'` `,` `.` `/` `!` `@` `#` `$`
51
+ `%` `^` `&` `*` `(` `)` `_` `+` `|` `~` `{` `}` `:` `<` `>` `?`
52
+ - `escape`, `esc`, `enter`, `return`, `tab`, `space`, `backspace`, `delete`,
53
+ `insert`, `clear`, `home`, `end`, `pageUp`, `pageDown`
54
+ - `up`, `down`, `left`, `right`
55
+ - `f1` through `f12`
56
+
57
+ Rules the loader applies:
58
+
59
+ - Base keys are case-sensitive (`pageUp`, not `pageup`). Array order is kept.
60
+ - Invalid key IDs inside a value are dropped; the remaining keys are kept. A
61
+ value that is not a string or array leaves the action with no keys.
62
+ - Collisions between two action IDs bound to the same key are not resolved
63
+ here; the host decides.
64
+ - Malformed JSON, an unreadable or missing file, or a top-level value that is
65
+ not an object yields an empty map, so nothing is bound.
66
+ - The file is read on load, not watched. Reload extensions after editing.
67
+
68
+ Which action IDs exist depends on the extensions you have installed; each
69
+ extension's README lists its IDs. Bindings only take effect when a shortcut
70
+ host is installed: `pi install npm:pi-xsettings` provides one that
71
+ calls `pi.registerShortcut()` for every configured key. Without a host, the
72
+ registry still works but nothing binds global keys.
73
+
74
+ ## For extension authors
75
+
76
+ ### Install and import
77
+
78
+ Add the library to your package's `dependencies` and `bundledDependencies` so it
79
+ ships inside your published package:
80
+
81
+ ```json
82
+ {
83
+ "dependencies": {
84
+ "pi-libactions": "^0.1.0"
85
+ },
86
+ "bundledDependencies": ["pi-libactions"]
87
+ }
88
+ ```
89
+
90
+ Then import the public SDK:
91
+
92
+ ```ts
93
+ import { registerAction } from "pi-libactions/sdk";
94
+ ```
95
+
96
+ The package has no runtime dependencies; `@earendil-works/pi-coding-agent` and
97
+ `@earendil-works/pi-tui` are peer dependencies that Pi provides. The root
98
+ export and `/sdk` expose the same symbols: `ACTIONS_PROTOCOL`,
99
+ `ACTIONS_REGISTRY_KEY`, `ensureActionsRegistry`, `registerAction`,
100
+ `loadActionKeybindings`, `isActionKeyId`, and the types `ActionRegistration`,
101
+ `ActionsRegistry`, `ActionKeybindings`.
102
+
103
+ ### Action registry
104
+
105
+ The registry protocol is `pi-libactions/registry/v1`, stored on `globalThis`
106
+ under `Symbol.for("pi-libactions/registry/v1")`.
107
+
108
+ An action has this shape:
109
+
110
+ ```ts
111
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
112
+
113
+ type ActionRegistration = {
114
+ id: string;
115
+ description: string;
116
+ run(ctx: ExtensionContext): void | Promise<void>;
117
+ };
118
+ ```
119
+
120
+ Use a stable, namespaced ID (for example `myext.panel.open`). Register during
121
+ extension setup and keep the disposer for reload and shutdown:
122
+
123
+ ```ts
124
+ import { registerAction } from "pi-libactions/sdk";
125
+
126
+ const unregister = registerAction({
127
+ id: "example.open",
128
+ description: "Open the example panel",
129
+ async run(ctx) {
130
+ await openExamplePanel(ctx);
131
+ },
132
+ });
133
+
134
+ // Call when the extension is disposed or reloaded.
135
+ unregister();
136
+ ```
137
+
138
+ `registerAction()` uses the process-wide registry returned by
139
+ `ensureActionsRegistry()`. A host that needs to inspect or listen to the
140
+ registry uses the full API:
141
+
142
+ ```ts
143
+ import { ensureActionsRegistry } from "pi-libactions/sdk";
144
+
145
+ const actions = ensureActionsRegistry();
146
+ const stopListening = actions.onRegister((action) => {
147
+ console.log(action.id, action.description);
148
+ });
149
+ const action = actions.find("example.open");
150
+ ```
151
+
152
+ Behaviour, as implemented in `src/protocol/actions.ts`:
153
+
154
+ - The registry exposes `protocol` and `version` (`1`). `register()` and
155
+ `onRegister()` each return a disposer.
156
+ - `onRegister()` receives registrations made after the listener is attached;
157
+ it does not replay existing actions. The registry never calls `run()` itself.
158
+ - Registering an existing ID replaces its action. A disposer only removes the
159
+ exact action instance it registered, so disposing an older registration
160
+ cannot remove a newer replacement.
161
+ - Listener errors are swallowed so an optional host cannot break registration.
162
+ - `registerAction()` returns a no-op disposer if the global capability cannot
163
+ be created.
164
+ - Action fields are not validated at runtime; callers must provide the
165
+ documented shape.
166
+ - Separate copies of this package in the same JavaScript realm find the same
167
+ registry through `Symbol.for`. `ensureActionsRegistry(scope)` accepts a
168
+ custom global-like object when an isolated scope is required.
169
+
170
+ ### Reading keybindings from an extension
171
+
172
+ ```ts
173
+ import { loadActionKeybindings, isActionKeyId } from "pi-libactions/sdk";
174
+
175
+ const bindings = loadActionKeybindings(); // defaults to <agent dir>/keybindings.json
176
+ const keys = bindings["example.open"] ?? []; // readonly KeyId[], frozen
177
+ ```
178
+
179
+ `loadActionKeybindings(path?)` returns `ActionKeybindings`, a frozen
180
+ `Readonly<Record<string, readonly KeyId[]>>` with frozen inner arrays.
181
+ `isActionKeyId(value)` validates one key ID without touching the filesystem.
182
+
183
+ ## Layout
184
+
185
+ | Responsibility | File |
186
+ | --- | --- |
187
+ | Public SDK re-exports | `src/sdk.ts` (root `src/index.ts` re-exports it) |
188
+ | Action registry protocol and `Symbol.for` capability | `src/protocol/actions.ts` |
189
+ | `keybindings.json` loader and key ID validation | `src/keybindings.ts` |
190
+
191
+ ## Develop
192
+
193
+ Source: https://github.com/luan/agents, directory
194
+ harnesses/pi/agent/packages/pi-libactions. Run `bun run typecheck` and
195
+ `bun test test` in that directory.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "pi-libactions",
3
+ "version": "0.3.2",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/luan/agents.git",
8
+ "directory": "harnesses/pi/agent/packages/pi-libactions"
9
+ },
10
+ "description": "UI-free action registry capability for Pi extensions",
11
+ "type": "module",
12
+ "keywords": [
13
+ "pi-package"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./sdk": "./src/sdk.ts"
18
+ },
19
+ "pi": {
20
+ "extensions": [],
21
+ "image": "https://github.com/luan/agents/releases/download/v0.3.2/pi-libactions.png",
22
+ "video": "https://github.com/luan/agents/releases/download/v0.3.2/pi-libactions.mp4"
23
+ },
24
+ "dependencies": {},
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-coding-agent": "*",
27
+ "@earendil-works/pi-tui": "*"
28
+ },
29
+ "devDependencies": {
30
+ "@earendil-works/pi-coding-agent": "0.84.2",
31
+ "@earendil-works/pi-tui": "0.84.2",
32
+ "@types/bun": "^1.3.0",
33
+ "typescript": "^6.0.3"
34
+ },
35
+ "scripts": {
36
+ "typecheck": "tsc --build",
37
+ "test": "bun test --only-failures test"
38
+ },
39
+ "bundledDependencies": []
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./sdk.ts";
@@ -0,0 +1,94 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import type { KeyId } from "@earendil-works/pi-tui";
5
+
6
+ /** Immutable user keybindings indexed by action ID. */
7
+ export type ActionKeybindings = Readonly<Record<string, readonly KeyId[]>>;
8
+
9
+ type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
10
+
11
+ const BASE_KEYS = new Set([
12
+ ..."abcdefghijklmnopqrstuvwxyz0123456789".split(""),
13
+ "`",
14
+ "-",
15
+ "=",
16
+ "[",
17
+ "]",
18
+ "\\",
19
+ ";",
20
+ "'",
21
+ ",",
22
+ ".",
23
+ "/",
24
+ "!",
25
+ "@",
26
+ "#",
27
+ "$",
28
+ "%",
29
+ "^",
30
+ "&",
31
+ "*",
32
+ "(",
33
+ ")",
34
+ "_",
35
+ "+",
36
+ "|",
37
+ "~",
38
+ "{",
39
+ "}",
40
+ ":",
41
+ "<",
42
+ ">",
43
+ "?",
44
+ "escape",
45
+ "esc",
46
+ "enter",
47
+ "return",
48
+ "tab",
49
+ "space",
50
+ "backspace",
51
+ "delete",
52
+ "insert",
53
+ "clear",
54
+ "home",
55
+ "end",
56
+ "pageUp",
57
+ "pageDown",
58
+ "up",
59
+ "down",
60
+ "left",
61
+ "right",
62
+ ...Array.from({ length: 12 }, (_unused, index) => `f${index + 1}`),
63
+ ]);
64
+ const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
65
+
66
+ /** Validate one pi-tui key identifier without registering it. */
67
+ export function isActionKeyId(value: string): value is KeyId {
68
+ const parts = value.split("+");
69
+ const base = parts.pop();
70
+ if (!base || !BASE_KEYS.has(base)) return false;
71
+ const seen = new Set<string>();
72
+ return parts.every((part) => MODIFIERS.has(part) && !seen.has(part) && Boolean(seen.add(part)));
73
+ }
74
+
75
+ /**
76
+ * Read the user-owned action bindings used by extension action hosts and modal features.
77
+ * Invalid documents, action values, and key identifiers are omitted.
78
+ * @param path Optional keybindings file path; defaults to Pi's active agent directory.
79
+ * @returns A deeply immutable action-to-key snapshot refreshed when extensions reload.
80
+ */
81
+ export function loadActionKeybindings(path = join(getAgentDir(), "keybindings.json")): ActionKeybindings {
82
+ try {
83
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonValue;
84
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return Object.freeze({});
85
+ const result: Record<string, readonly KeyId[]> = {};
86
+ for (const [action, value] of Object.entries(parsed)) {
87
+ const keys = typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
88
+ result[action] = Object.freeze(keys.filter((key): key is KeyId => typeof key === "string" && isActionKeyId(key)));
89
+ }
90
+ return Object.freeze(result);
91
+ } catch {
92
+ return Object.freeze({});
93
+ }
94
+ }
@@ -0,0 +1,123 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const ACTIONS_REGISTRY_KEY = Symbol.for("pi-libactions/registry/v1");
4
+ export const ACTIONS_PROTOCOL = "pi-libactions/registry/v1" as const;
5
+
6
+ export interface ActionRegistration {
7
+ id: string;
8
+ description: string;
9
+ run(ctx: ExtensionContext): void | Promise<void>;
10
+ }
11
+
12
+ export interface ActionsRegistry {
13
+ readonly protocol: typeof ACTIONS_PROTOCOL;
14
+ readonly version: 1;
15
+ register(action: ActionRegistration): () => void;
16
+ onRegister(listener: (action: ActionRegistration) => void): () => void;
17
+ find(id: string): ActionRegistration | undefined;
18
+ }
19
+
20
+ type RegistryState = {
21
+ actions: Map<string, ActionRegistration>;
22
+ listeners: Set<(action: ActionRegistration) => void>;
23
+ };
24
+
25
+ const STATE_KEY = Symbol.for("pi-libactions/registry-state/v1");
26
+ const states = new WeakMap<ActionsRegistry, RegistryState>();
27
+
28
+ // type-boundary: Symbol.for capabilities can be populated by another extension realm; this validator narrows the public methods.
29
+ type UntrustedRegistryValue = unknown;
30
+
31
+ function isRegistry(value: UntrustedRegistryValue): value is ActionsRegistry {
32
+ if (!value || typeof value !== "object") return false;
33
+ const candidate = value as Partial<ActionsRegistry>;
34
+ return (
35
+ candidate.protocol === ACTIONS_PROTOCOL &&
36
+ candidate.version === 1 &&
37
+ typeof candidate.register === "function" &&
38
+ typeof candidate.onRegister === "function" &&
39
+ typeof candidate.find === "function"
40
+ );
41
+ }
42
+
43
+ function stateFor(registry: ActionsRegistry): RegistryState {
44
+ const existing = states.get(registry);
45
+ if (existing) return existing;
46
+ const shared = Reflect.get(registry as object, STATE_KEY) as UntrustedRegistryValue;
47
+ if (isState(shared)) {
48
+ states.set(registry, shared);
49
+ return shared;
50
+ }
51
+ const state: RegistryState = { actions: new Map(), listeners: new Set() };
52
+ Object.defineProperty(registry, STATE_KEY, { value: state, enumerable: false, configurable: false });
53
+ states.set(registry, state);
54
+ return state;
55
+ }
56
+
57
+ function isState(value: UntrustedRegistryValue): value is RegistryState {
58
+ if (!value || typeof value !== "object") return false;
59
+ const candidate = value as Partial<RegistryState>;
60
+ return isMap(candidate.actions) && isSet(candidate.listeners);
61
+ }
62
+
63
+ function isMap(value: UntrustedRegistryValue): value is Map<never, never> {
64
+ if (!value || typeof value !== "object") return false;
65
+ const candidate = value as {
66
+ get?: UntrustedRegistryValue;
67
+ set?: UntrustedRegistryValue;
68
+ delete?: UntrustedRegistryValue;
69
+ };
70
+ return (
71
+ typeof candidate.get === "function" && typeof candidate.set === "function" && typeof candidate.delete === "function"
72
+ );
73
+ }
74
+
75
+ function isSet(value: UntrustedRegistryValue): value is Set<never> {
76
+ if (!value || typeof value !== "object") return false;
77
+ const candidate = value as { add?: UntrustedRegistryValue; delete?: UntrustedRegistryValue };
78
+ return typeof candidate.add === "function" && typeof candidate.delete === "function";
79
+ }
80
+
81
+ export function ensureActionsRegistry(scope: typeof globalThis = globalThis): ActionsRegistry {
82
+ const slots = scope as Record<PropertyKey, UntrustedRegistryValue>;
83
+ const existing = slots[ACTIONS_REGISTRY_KEY];
84
+ if (isRegistry(existing)) return existing;
85
+
86
+ const registry: ActionsRegistry = {
87
+ protocol: ACTIONS_PROTOCOL,
88
+ version: 1,
89
+ register(action) {
90
+ const state = stateFor(registry);
91
+ state.actions.set(action.id, action);
92
+ for (const listener of [...state.listeners]) {
93
+ try {
94
+ listener(action);
95
+ } catch {
96
+ // Optional action consumers must not affect registration.
97
+ }
98
+ }
99
+ return () => {
100
+ if (state.actions.get(action.id) === action) state.actions.delete(action.id);
101
+ };
102
+ },
103
+ onRegister(listener) {
104
+ const state = stateFor(registry);
105
+ state.listeners.add(listener);
106
+ return () => state.listeners.delete(listener);
107
+ },
108
+ find(id) {
109
+ return stateFor(registry).actions.get(id);
110
+ },
111
+ };
112
+ stateFor(registry);
113
+ slots[ACTIONS_REGISTRY_KEY] = registry;
114
+ return registry;
115
+ }
116
+
117
+ export function registerAction(action: ActionRegistration): () => void {
118
+ try {
119
+ return ensureActionsRegistry().register(action);
120
+ } catch {
121
+ return () => {};
122
+ }
123
+ }
package/src/sdk.ts ADDED
@@ -0,0 +1,9 @@
1
+ export {
2
+ ensureActionsRegistry,
3
+ registerAction,
4
+ ACTIONS_PROTOCOL,
5
+ ACTIONS_REGISTRY_KEY,
6
+ type ActionRegistration,
7
+ type ActionsRegistry,
8
+ } from "./protocol/actions.ts";
9
+ export { isActionKeyId, loadActionKeybindings, type ActionKeybindings } from "./keybindings.ts";
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { ensureActionsRegistry, loadActionKeybindings } from "../src/sdk.ts";
6
+
7
+ describe("pi-libactions registry", () => {
8
+ test("supports consumer-first registration and identity-safe disposal", () => {
9
+ const scope = Object.create(null) as typeof globalThis;
10
+ const registry = ensureActionsRegistry(scope);
11
+ const action = { id: "test.action", description: "Test", run: () => {} };
12
+ const remove = registry.register(action);
13
+ expect(registry.find(action.id)).toBe(action);
14
+ remove();
15
+ expect(registry.find(action.id)).toBeUndefined();
16
+ });
17
+
18
+ test("isolates listener failures", () => {
19
+ const scope = Object.create(null) as typeof globalThis;
20
+ const registry = ensureActionsRegistry(scope);
21
+ registry.onRegister(() => {
22
+ throw new Error("listener");
23
+ });
24
+ expect(() => registry.register({ id: "safe", description: "Safe", run: () => {} })).not.toThrow();
25
+ });
26
+
27
+ test("loads one strict immutable user keybinding snapshot", () => {
28
+ const directory = mkdtempSync(join(tmpdir(), "pi-libactions-"));
29
+ const path = join(directory, "keybindings.json");
30
+ writeFileSync(path, JSON.stringify({ action: ["ctrl+c", "bad+key"], single: "space", invalid: 42 }));
31
+ const bindings = loadActionKeybindings(path);
32
+ expect(bindings).toEqual({ action: ["ctrl+c"], single: ["space"], invalid: [] });
33
+ expect(Object.isFrozen(bindings)).toBe(true);
34
+ expect(Object.isFrozen(bindings.action)).toBe(true);
35
+ });
36
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "incremental": true,
9
+ "tsBuildInfoFile": "../../../../../target/tsbuildinfo/pi-libactions.tsbuildinfo",
10
+ "allowImportingTsExtensions": true,
11
+ "skipLibCheck": true,
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["src", "test"]
15
+ }