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 +21 -0
- package/README.md +195 -0
- package/package.json +40 -0
- package/src/index.ts +1 -0
- package/src/keybindings.ts +94 -0
- package/src/protocol/actions.ts +123 -0
- package/src/sdk.ts +9 -0
- package/test/registry.test.ts +36 -0
- package/tsconfig.json +15 -0
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
|
+

|
|
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
|
+
}
|