@sagmans/pi-prefix 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/SECURITY.md +21 -0
- package/defaults.json +3 -0
- package/index.ts +197 -0
- package/package.json +52 -0
- package/schema.json +56 -0
- package/skills/pi-prefix/SKILL.md +46 -0
- package/src/config.ts +373 -0
- package/src/conflicts.ts +71 -0
- package/src/prefix-editor.ts +260 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sagmans
|
|
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,150 @@
|
|
|
1
|
+
# @sagmans/pi-prefix
|
|
2
|
+
|
|
3
|
+
A prefix key for [Pi](https://github.com/earendil-works/pi): press
|
|
4
|
+
one configurable prefix key, then a second key, to trigger a Pi app action, a
|
|
5
|
+
slash command, or an extension event.
|
|
6
|
+
|
|
7
|
+
- **Zero-keymap by default.** No prefix and no bindings ship with the package.
|
|
8
|
+
Without configuration the extension is completely inert.
|
|
9
|
+
- **Your keybindings stay authoritative.** A prefix that collides with an
|
|
10
|
+
active main-editor binding disables pi-prefix and reports the exact
|
|
11
|
+
keybinding ids. pi-prefix never edits `keybindings.json`.
|
|
12
|
+
- **No arbitrary code.** Targets are app actions, slash commands, and events.
|
|
13
|
+
There is no executable target.
|
|
14
|
+
|
|
15
|
+
## Security warning
|
|
16
|
+
|
|
17
|
+
Pi packages execute with your user privileges. Review this repository before
|
|
18
|
+
installing. Report vulnerabilities privately (see SECURITY.md).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
After publication:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install npm:@sagmans/pi-prefix@0.1.0
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then restart Pi (or run `/reload`).
|
|
29
|
+
|
|
30
|
+
## Ask your agent
|
|
31
|
+
|
|
32
|
+
Ask your coding agent to configure pi-prefix for you, for example:
|
|
33
|
+
|
|
34
|
+
> Set up @sagmans/pi-prefix for me: prefix ctrl+x, with m for model picker,
|
|
35
|
+
> h for /hotkeys submitted, and a binding for the open event of the extension
|
|
36
|
+
> I use.
|
|
37
|
+
|
|
38
|
+
The package ships a skill that guides agents through the same steps.
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
Two layers, merged in order **defaults → global → project**:
|
|
43
|
+
|
|
44
|
+
| Layer | Path | When loaded |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| Global | `~/.pi/agent/pi-prefix.json` | always |
|
|
47
|
+
| Project | `<project>/.pi/pi-prefix.json` | only when the project is trusted |
|
|
48
|
+
|
|
49
|
+
Project scalar values (`prefix`, `timeoutMs`) replace global values. Project
|
|
50
|
+
bindings add or replace global bindings by normalized key; `null` removes an
|
|
51
|
+
inherited binding. Activation needs an effective `prefix` and at least one
|
|
52
|
+
effective binding. Any parse or validation error fails the whole effective
|
|
53
|
+
config with the source path and reason.
|
|
54
|
+
|
|
55
|
+
`~/.pi/agent/pi-prefix.json` example:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"$schema": "https://unpkg.com/@sagmans/pi-prefix/schema.json",
|
|
60
|
+
"prefix": "ctrl+x",
|
|
61
|
+
"timeoutMs": 2000,
|
|
62
|
+
"bindings": {
|
|
63
|
+
"m": { "action": "app.model.select" },
|
|
64
|
+
"h": { "command": "/hotkeys", "submit": true },
|
|
65
|
+
"a": { "command": "/third-party-command", "submit": false },
|
|
66
|
+
"o": { "event": "example-extension:open", "payload": { "source": "pi-prefix" } }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Trusted project overlay example (`<project>/.pi/pi-prefix.json`):
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"bindings": {
|
|
76
|
+
"o": null,
|
|
77
|
+
"w": { "command": "/project-command", "submit": true }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Schema reference
|
|
83
|
+
|
|
84
|
+
All properties are optional at layer level.
|
|
85
|
+
|
|
86
|
+
- `prefix` — one key id, e.g. `ctrl+x`, `f9`, `alt+shift+p`. See Pi's
|
|
87
|
+
keybinding format (`modifier+key`, modifiers `ctrl`, `shift`, `alt`,
|
|
88
|
+
`super`; aliases `esc`/`return` are normalized to `escape`/`enter`).
|
|
89
|
+
- `timeoutMs` — positive integer; prefix mode clears after this idle time.
|
|
90
|
+
Default `2000`.
|
|
91
|
+
- `bindings` — map of second key id to exactly one target, or `null` to
|
|
92
|
+
remove an inherited binding:
|
|
93
|
+
- `{ "action": "app.model.select" }` — a Pi app action. Unknown or
|
|
94
|
+
context-only actions notify when pressed instead of throwing.
|
|
95
|
+
- `{ "command": "/hotkeys", "submit": true }` — a slash command.
|
|
96
|
+
`submit: true` runs the command immediately; extension commands run
|
|
97
|
+
without touching the editor draft, anything else replaces the draft and
|
|
98
|
+
submits. `submit: false` types the command and stops.
|
|
99
|
+
- `{ "event": "example-extension:open", "payload": {} }` — emits the event
|
|
100
|
+
name and exact JSON payload through `pi.events`. The extension that owns
|
|
101
|
+
the event defines valid names and payloads.
|
|
102
|
+
|
|
103
|
+
Escape is reserved for canceling prefix mode; a binding on `escape` loads
|
|
104
|
+
with a warning and can never dispatch. Enter, tab, and arrows are valid second
|
|
105
|
+
keys.
|
|
106
|
+
|
|
107
|
+
### Command behavior
|
|
108
|
+
|
|
109
|
+
With `submit: true`, **extension commands** (those listed by `/commands` with
|
|
110
|
+
the `extension` source, e.g. a mode toggle installed from another package) are
|
|
111
|
+
dispatched directly through Pi's command pipeline and the current editor draft
|
|
112
|
+
is preserved untouched. Any other command — built-ins like `/model` or
|
|
113
|
+
third-party commands Pi reports without the `extension` source — replaces the
|
|
114
|
+
draft and submits it through the editor,
|
|
115
|
+
matching manual typing. With `submit: false` the command text sits in the
|
|
116
|
+
editor until you press Enter, which is the right form for commands that take an
|
|
117
|
+
argument.
|
|
118
|
+
|
|
119
|
+
## Conflicts and /pi-prefix
|
|
120
|
+
|
|
121
|
+
On startup pi-prefix scans Pi's main-editor bindings. A colliding prefix
|
|
122
|
+
disables pi-prefix with an error naming the exact keybinding ids. Existing
|
|
123
|
+
bindings are never changed. Fix by choosing another prefix or rebinding the
|
|
124
|
+
conflicting action yourself in `~/.pi/agent/keybindings.json`, then `/reload`.
|
|
125
|
+
|
|
126
|
+
Third-party extension shortcuts cannot be inventoried publicly: Pi reports
|
|
127
|
+
those collisions under **[Extension issues]** and resolves them by load order.
|
|
128
|
+
That limitation is Pi's, not pi-prefix's.
|
|
129
|
+
|
|
130
|
+
`/pi-prefix` prints active/inactive state, loaded config paths, trust state,
|
|
131
|
+
effective prefix, timeout, a binding summary, config errors and warnings, and
|
|
132
|
+
any Pi keybinding conflicts.
|
|
133
|
+
|
|
134
|
+
## What pi-prefix does not do
|
|
135
|
+
|
|
136
|
+
- No shipped prefix, keymap, or bindings.
|
|
137
|
+
- No self-registration protocol for other extensions.
|
|
138
|
+
- No arbitrary-code targets and no private Pi APIs.
|
|
139
|
+
- No automatic conflict edits; reports only.
|
|
140
|
+
- No raw global terminal input interception; activation goes through Pi's
|
|
141
|
+
native shortcut registration.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm install
|
|
147
|
+
npm run typecheck
|
|
148
|
+
npm test
|
|
149
|
+
npm pack --dry-run # package-content gate
|
|
150
|
+
```
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Only the latest released line of `@sagmans/pi-prefix` is supported with
|
|
6
|
+
security fixes.
|
|
7
|
+
|
|
8
|
+
## Pi packages run with your privileges
|
|
9
|
+
|
|
10
|
+
A Pi package is code that runs on your machine with your user privileges when
|
|
11
|
+
Pi loads it. Review any package before installing it, including this one.
|
|
12
|
+
|
|
13
|
+
## Reporting a vulnerability
|
|
14
|
+
|
|
15
|
+
Report vulnerabilities through GitHub private vulnerability reporting
|
|
16
|
+
(GitHub Security Advisories) for `sagmans/pi-prefix`. Do not open public
|
|
17
|
+
issues for unpatched vulnerabilities; public reports about an unpatched flaw
|
|
18
|
+
put every user at risk.
|
|
19
|
+
|
|
20
|
+
Include the package version, the config layers involved, and reproduction
|
|
21
|
+
steps where possible.
|
package/defaults.json
ADDED
package/index.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { CustomEditor, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
KeybindingsManager,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { EditorComponent, EditorTheme, KeyId, TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
import { loadConfig } from "./src/config.ts";
|
|
9
|
+
import type { LoadConfigResult } from "./src/config.ts";
|
|
10
|
+
import { findPrefixConflicts } from "./src/conflicts.ts";
|
|
11
|
+
import type { PrefixConflict } from "./src/conflicts.ts";
|
|
12
|
+
import { PrefixEditor } from "./src/prefix-editor.ts";
|
|
13
|
+
|
|
14
|
+
const STATUS_KEY = "pi-prefix";
|
|
15
|
+
const STATUS_COMMAND = "pi-prefix";
|
|
16
|
+
const SHORTCUT_DESCRIPTION = "Activate the pi-prefix prefix key";
|
|
17
|
+
// WHY: third-party shortcut collisions cannot be inventoried publicly; Pi
|
|
18
|
+
// resolves them by load order and reports them under Extension issues.
|
|
19
|
+
const THIRD_PARTY_LIMIT_NOTE =
|
|
20
|
+
"Third-party extension shortcut collisions are reported by Pi under [Extension issues] and resolved by load order.";
|
|
21
|
+
// WHY: only extension-owned commands can execute through sendUserMessage;
|
|
22
|
+
// built-in interactive commands and templates need the editor-submit path.
|
|
23
|
+
const EXTENSION_COMMAND_SOURCE = "extension";
|
|
24
|
+
const COMMAND_NAME_SEPARATOR = " ";
|
|
25
|
+
const COMMAND_PREFIX = "/";
|
|
26
|
+
|
|
27
|
+
type SessionState = {
|
|
28
|
+
result: LoadConfigResult;
|
|
29
|
+
projectTrusted: boolean;
|
|
30
|
+
wrapper: PrefixEditor | undefined;
|
|
31
|
+
conflicts: PrefixConflict[];
|
|
32
|
+
shortcutRegistered: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function formatDiagnostics(result: LoadConfigResult, level: "error" | "warning"): string[] {
|
|
36
|
+
return result.diagnostics
|
|
37
|
+
.filter((diagnostic) => diagnostic.level === level)
|
|
38
|
+
.map((diagnostic) => `${diagnostic.path}: ${diagnostic.message}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extensionCommandName(command: string): string {
|
|
42
|
+
const withoutPrefix = command.startsWith(COMMAND_PREFIX) ? command.slice(COMMAND_PREFIX.length) : command;
|
|
43
|
+
const separatorIndex = withoutPrefix.indexOf(COMMAND_NAME_SEPARATOR);
|
|
44
|
+
return separatorIndex === -1 ? withoutPrefix : withoutPrefix.slice(0, separatorIndex);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function bindingSummary(state: SessionState): string {
|
|
48
|
+
const bindings = state.result.config?.bindings;
|
|
49
|
+
if (bindings === undefined) return "none";
|
|
50
|
+
return [...bindings.entries()]
|
|
51
|
+
.map(([key, target]) => {
|
|
52
|
+
const description =
|
|
53
|
+
"action" in target
|
|
54
|
+
? `action ${target.action}`
|
|
55
|
+
: "command" in target
|
|
56
|
+
? `command ${target.command}${target.submit ? " (submit)" : ""}`
|
|
57
|
+
: `event ${target.event}`;
|
|
58
|
+
return `${key} -> ${description}`;
|
|
59
|
+
})
|
|
60
|
+
.join(", ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function statusMessage(state: SessionState | undefined, agentDir: string): string {
|
|
64
|
+
if (state === undefined || state.result.config === undefined) {
|
|
65
|
+
return [
|
|
66
|
+
"pi-prefix: inactive",
|
|
67
|
+
`global config: ${agentDir}/pi-prefix.json`,
|
|
68
|
+
"No effective prefix and bindings. See the package README for configuration.",
|
|
69
|
+
].join(" | ");
|
|
70
|
+
}
|
|
71
|
+
const { config } = state.result;
|
|
72
|
+
const lines = [
|
|
73
|
+
state.conflicts.length > 0 ? "pi-prefix: disabled (conflicts)" : "pi-prefix: active",
|
|
74
|
+
`prefix: ${config.prefix}`,
|
|
75
|
+
`timeout: ${config.timeoutMs}ms`,
|
|
76
|
+
`bindings: ${bindingSummary(state)}`,
|
|
77
|
+
`project trusted: ${state.projectTrusted ? "yes" : "no"}`,
|
|
78
|
+
`global: ${state.result.globalPath}`,
|
|
79
|
+
`project: ${state.result.projectPath}${state.result.projectLoaded ? " (loaded)" : ""}`,
|
|
80
|
+
];
|
|
81
|
+
for (const error of formatDiagnostics(state.result, "error")) {
|
|
82
|
+
lines.push(`error: ${error}`);
|
|
83
|
+
}
|
|
84
|
+
for (const warning of formatDiagnostics(state.result, "warning")) {
|
|
85
|
+
lines.push(`warning: ${warning}`);
|
|
86
|
+
}
|
|
87
|
+
for (const conflict of state.conflicts) {
|
|
88
|
+
lines.push(`conflict: ${conflict.key} is bound to ${conflict.keybinding}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push(THIRD_PARTY_LIMIT_NOTE);
|
|
91
|
+
return lines.join(" | ");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function setupPiPrefix(
|
|
95
|
+
pi: ExtensionAPI,
|
|
96
|
+
options: { agentDir: string },
|
|
97
|
+
): void {
|
|
98
|
+
let state: SessionState | undefined;
|
|
99
|
+
|
|
100
|
+
pi.registerCommand(STATUS_COMMAND, {
|
|
101
|
+
description: "Show pi-prefix status, loaded config layers, and conflicts",
|
|
102
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
103
|
+
ctx.ui.notify(statusMessage(state, options.agentDir), "info");
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pi.on("session_start", (_event, ctx) => {
|
|
108
|
+
if (!ctx.hasUI) return;
|
|
109
|
+
const result = loadConfig({
|
|
110
|
+
agentDir: options.agentDir,
|
|
111
|
+
cwd: ctx.cwd,
|
|
112
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
113
|
+
});
|
|
114
|
+
for (const error of formatDiagnostics(result, "error")) {
|
|
115
|
+
ctx.ui.notify(`pi-prefix: ${error}`, "error");
|
|
116
|
+
}
|
|
117
|
+
for (const warning of formatDiagnostics(result, "warning")) {
|
|
118
|
+
ctx.ui.notify(`pi-prefix: ${warning}`, "warning");
|
|
119
|
+
}
|
|
120
|
+
if (result.config === undefined) {
|
|
121
|
+
// WHY: absence of a full map is ordinary; the extension stays inert
|
|
122
|
+
// without touching the editor chain.
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
state = {
|
|
126
|
+
result,
|
|
127
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
128
|
+
wrapper: undefined,
|
|
129
|
+
conflicts: [],
|
|
130
|
+
shortcutRegistered: false,
|
|
131
|
+
};
|
|
132
|
+
const sessionState: SessionState = state;
|
|
133
|
+
const previousFactory = ctx.ui.getEditorComponent();
|
|
134
|
+
ctx.ui.setEditorComponent(
|
|
135
|
+
(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
|
|
136
|
+
const inner: EditorComponent = previousFactory
|
|
137
|
+
? previousFactory(tui, theme, keybindings)
|
|
138
|
+
: new CustomEditor(tui, theme, keybindings);
|
|
139
|
+
const config = sessionState.result.config;
|
|
140
|
+
if (config === undefined) {
|
|
141
|
+
return inner;
|
|
142
|
+
}
|
|
143
|
+
const wrapper = new PrefixEditor(tui, theme, keybindings, inner, {
|
|
144
|
+
config,
|
|
145
|
+
emitEvent: (event, payload) => pi.events.emit(event, payload),
|
|
146
|
+
setStatus: (text) => ctx.ui.setStatus(STATUS_KEY, text),
|
|
147
|
+
notify: (message, type) => ctx.ui.notify(message, type),
|
|
148
|
+
dispatchExtensionCommand: (command) => {
|
|
149
|
+
const name = extensionCommandName(command);
|
|
150
|
+
const isExtensionCommand = pi.getCommands().some(
|
|
151
|
+
(available) => available.name === name && available.source === EXTENSION_COMMAND_SOURCE,
|
|
152
|
+
);
|
|
153
|
+
if (!isExtensionCommand) return false;
|
|
154
|
+
pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
155
|
+
return true;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
if (!sessionState.shortcutRegistered) {
|
|
159
|
+
// WHY: the editor factory is the only place Pi hands extensions the
|
|
160
|
+
// authoritative keybinding manager, so conflict scanning and native
|
|
161
|
+
// registration both happen here, once per session.
|
|
162
|
+
const conflicts = findPrefixConflicts(config.prefix, keybindings);
|
|
163
|
+
if (conflicts.length > 0) {
|
|
164
|
+
sessionState.conflicts = conflicts;
|
|
165
|
+
const ids = conflicts.map((conflict) => conflict.keybinding).join(", ");
|
|
166
|
+
ctx.ui.notify(
|
|
167
|
+
`pi-prefix disabled: prefix ${config.prefix} conflicts with ${ids}`,
|
|
168
|
+
"error",
|
|
169
|
+
);
|
|
170
|
+
wrapper.dispose();
|
|
171
|
+
sessionState.wrapper = wrapper;
|
|
172
|
+
return wrapper;
|
|
173
|
+
}
|
|
174
|
+
pi.registerShortcut(config.prefix as KeyId, {
|
|
175
|
+
description: SHORTCUT_DESCRIPTION,
|
|
176
|
+
handler: () => {
|
|
177
|
+
sessionState.wrapper?.activate();
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
sessionState.shortcutRegistered = true;
|
|
181
|
+
}
|
|
182
|
+
sessionState.wrapper = wrapper;
|
|
183
|
+
return wrapper;
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
189
|
+
state?.wrapper?.dispose();
|
|
190
|
+
state = undefined;
|
|
191
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default function (pi: ExtensionAPI): void {
|
|
196
|
+
setupPiPrefix(pi, { agentDir: getAgentDir() });
|
|
197
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sagmans/pi-prefix",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Configurable prefix key that maps a second key to Pi app actions, slash commands, or extension events",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/sagmans/pi-prefix.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"pi-package",
|
|
16
|
+
"pi",
|
|
17
|
+
"pi-extension",
|
|
18
|
+
"keybindings",
|
|
19
|
+
"prefix"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22.19.0"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.ts",
|
|
26
|
+
"src/",
|
|
27
|
+
"defaults.json",
|
|
28
|
+
"schema.json",
|
|
29
|
+
"skills/",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"SECURITY.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --experimental-transform-types --test test/*.test.ts",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": ">=0.84.4 <0.85.0",
|
|
40
|
+
"@earendil-works/pi-tui": ">=0.84.4 <0.85.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": ">=0.84.4 <0.85.0",
|
|
44
|
+
"@earendil-works/pi-tui": ">=0.84.4 <0.85.0",
|
|
45
|
+
"@types/node": "^24.0.0",
|
|
46
|
+
"typescript": "^5.5.0"
|
|
47
|
+
},
|
|
48
|
+
"pi": {
|
|
49
|
+
"extensions": ["./index.ts"],
|
|
50
|
+
"skills": ["./skills"]
|
|
51
|
+
}
|
|
52
|
+
}
|
package/schema.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://unpkg.com/@sagmans/pi-prefix/schema.json",
|
|
4
|
+
"title": "pi-prefix configuration layer",
|
|
5
|
+
"description": "One configuration layer for @sagmans/pi-prefix. All properties are optional so project layers can be partial overlays.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "type": "string" },
|
|
10
|
+
"prefix": { "$ref": "#/definitions/keyId" },
|
|
11
|
+
"timeoutMs": { "type": "integer", "exclusiveMinimum": 0 },
|
|
12
|
+
"bindings": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": {
|
|
15
|
+
"oneOf": [
|
|
16
|
+
{ "type": "null" },
|
|
17
|
+
{ "$ref": "#/definitions/actionTarget" },
|
|
18
|
+
{ "$ref": "#/definitions/commandTarget" },
|
|
19
|
+
{ "$ref": "#/definitions/eventTarget" }
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"definitions": {
|
|
25
|
+
"keyId": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"pattern": "^(ctrl\+)?(shift\+)?(alt\+)?(super\+)?([a-z]|[0-9]|escape|enter|tab|space|backspace|delete|insert|clear|home|end|pageUp|pageDown|up|down|left|right|f1|f2|f3|f4|f5|f6|f7|f8|f9|f10|f11|f12|`|-|=|\[|\]|\\|;|'|,|\.|/|!|@|#|\$|%|\^|&|\*|\(|\)|_|\+|\||~|\{|\}|:|<|>|\?)$"
|
|
28
|
+
},
|
|
29
|
+
"actionTarget": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"additionalProperties": false,
|
|
32
|
+
"required": ["action"],
|
|
33
|
+
"properties": {
|
|
34
|
+
"action": { "type": "string", "minLength": 1 }
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"commandTarget": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"required": ["command", "submit"],
|
|
41
|
+
"properties": {
|
|
42
|
+
"command": { "type": "string", "pattern": "^/" },
|
|
43
|
+
"submit": { "type": "boolean" }
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"eventTarget": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"additionalProperties": false,
|
|
49
|
+
"required": ["event"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"event": { "type": "string", "minLength": 1 },
|
|
52
|
+
"payload": {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-prefix
|
|
3
|
+
description: Configure or troubleshoot @sagmans/pi-prefix (Pi prefix-key package). Use when the user asks to set up, change, or fix pi-prefix prefix keys, bindings, conflicts, or config layers.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pi-prefix configuration
|
|
7
|
+
|
|
8
|
+
Configure @sagmans/pi-prefix. Two layers: global `~/.pi/agent/pi-prefix.json`,
|
|
9
|
+
trusted project `<project>/.pi/pi-prefix.json`. Edit the smallest layer that
|
|
10
|
+
fits: global for user-wide behavior, project for project-only additions,
|
|
11
|
+
replacements, or removals (`null` removes an inherited binding).
|
|
12
|
+
|
|
13
|
+
## Before editing
|
|
14
|
+
|
|
15
|
+
1. Read both existing config layers if present.
|
|
16
|
+
2. Read Pi's `~/.pi/agent/keybindings.json` for active bindings.
|
|
17
|
+
3. Ask the user for the prefix and each target's intent when missing. Never
|
|
18
|
+
invent third-party event names, payloads, or command names; ask for the
|
|
19
|
+
exact values or check the target extension's documentation with the user.
|
|
20
|
+
|
|
21
|
+
## Target selection order
|
|
22
|
+
|
|
23
|
+
Prefer a built-in app action, then a slash command, then a documented
|
|
24
|
+
extension event.
|
|
25
|
+
|
|
26
|
+
## Writing the config
|
|
27
|
+
|
|
28
|
+
- Bindings take exactly one of `action`, `command` (+ required `submit`),
|
|
29
|
+
or `event` (+ optional `payload`).
|
|
30
|
+
- `submit: true` runs the command immediately — commands whose `/commands`
|
|
31
|
+
source is `extension` dispatch directly and keep the editor draft intact,
|
|
32
|
+
anything else replaces the draft; `submit: false` types it for
|
|
33
|
+
argument-taking commands.
|
|
34
|
+
- Commands must start with `/`. Timeouts must be positive integers.
|
|
35
|
+
- Validate the JSON against schema.json before saving.
|
|
36
|
+
|
|
37
|
+
## Conflicts
|
|
38
|
+
|
|
39
|
+
If the prefix collides with an active main-editor binding, pi-prefix reports
|
|
40
|
+
the exact keybinding ids and stays disabled. Never silently unbind or reassign
|
|
41
|
+
a conflict. Report it and ask the user which existing binding should move.
|
|
42
|
+
|
|
43
|
+
## After editing
|
|
44
|
+
|
|
45
|
+
Tell the user to run `/reload`, then `/pi-prefix` to verify the effective
|
|
46
|
+
prefix, timeout, bindings, and warnings.
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
// WHY: rebranded Pi distributions may rename the project config directory;
|
|
5
|
+
// the exported constant is the only rebrand-safe source.
|
|
6
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
export type PrefixTarget =
|
|
10
|
+
| { action: string }
|
|
11
|
+
| { command: string; submit: boolean }
|
|
12
|
+
| { event: string; payload?: unknown };
|
|
13
|
+
|
|
14
|
+
export type PrefixConfigLayer = {
|
|
15
|
+
$schema?: string;
|
|
16
|
+
prefix?: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
bindings?: Record<string, PrefixTarget | null>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type EffectivePrefixConfig = {
|
|
22
|
+
prefix: KeyId;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
bindings: ReadonlyMap<KeyId, PrefixTarget>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ConfigDiagnostic = {
|
|
28
|
+
level: "error" | "warning";
|
|
29
|
+
path: string;
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type LoadConfigOptions = {
|
|
34
|
+
agentDir: string;
|
|
35
|
+
cwd: string;
|
|
36
|
+
projectTrusted: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type LoadConfigResult = {
|
|
40
|
+
config?: EffectivePrefixConfig;
|
|
41
|
+
diagnostics: ConfigDiagnostic[];
|
|
42
|
+
globalPath: string;
|
|
43
|
+
projectPath: string;
|
|
44
|
+
projectLoaded: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// WHY: pi-tui defines modifier order as ctrl, shift, alt, super; canonical
|
|
48
|
+
// ordering keeps equivalent spellings from becoming distinct map entries.
|
|
49
|
+
const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const;
|
|
50
|
+
|
|
51
|
+
const MODIFIER_SET = new Set<string>(MODIFIER_ORDER);
|
|
52
|
+
|
|
53
|
+
// WHY: pi-tui accepts legacy aliases; canonicalizing them makes conflict
|
|
54
|
+
// comparison and binding lookup unambiguous.
|
|
55
|
+
const KEY_ALIASES: Record<string, string> = {
|
|
56
|
+
esc: "escape",
|
|
57
|
+
return: "enter",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const SPECIAL_KEYS = new Set([
|
|
61
|
+
"escape",
|
|
62
|
+
"enter",
|
|
63
|
+
"tab",
|
|
64
|
+
"space",
|
|
65
|
+
"backspace",
|
|
66
|
+
"delete",
|
|
67
|
+
"insert",
|
|
68
|
+
"clear",
|
|
69
|
+
"home",
|
|
70
|
+
"end",
|
|
71
|
+
"pageUp",
|
|
72
|
+
"pageDown",
|
|
73
|
+
"up",
|
|
74
|
+
"down",
|
|
75
|
+
"left",
|
|
76
|
+
"right",
|
|
77
|
+
...Array.from({ length: 12 }, (_, index) => `f${index + 1}`),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const SYMBOL_KEYS = new Set(
|
|
81
|
+
"`-=[]\\;',./!@#$%^&*()_+|~{}:<>?".split(""),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const LAYER_TOP_LEVEL_KEYS = new Set(["$schema", "prefix", "timeoutMs", "bindings"]);
|
|
85
|
+
const TARGET_KEYS = ["action", "command", "event"] as const;
|
|
86
|
+
// WHY: submit belongs to command targets and payload to event targets; both
|
|
87
|
+
// must pass the unknown-property guard before kind-specific validation.
|
|
88
|
+
const TARGET_PROPERTIES = [...TARGET_KEYS, "submit", "payload"] as const;
|
|
89
|
+
const ESCAPE_KEY = "escape";
|
|
90
|
+
const DEFAULT_TIMEOUT_MS = 2000;
|
|
91
|
+
const CONFIG_FILE_NAME = "pi-prefix.json";
|
|
92
|
+
|
|
93
|
+
const DEFAULTS_PATH = join(
|
|
94
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
95
|
+
"..",
|
|
96
|
+
"defaults.json",
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
function isLetter(value: string): boolean {
|
|
100
|
+
return value.length === 1 && value >= "a" && value <= "z";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isDigit(value: string): boolean {
|
|
104
|
+
return value.length === 1 && value >= "0" && value <= "9";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isUpperLetter(value: string): boolean {
|
|
108
|
+
return value.length === 1 && value >= "A" && value <= "Z";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function normalizeKeyId(value: string): KeyId | undefined {
|
|
112
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
if (value === "+") {
|
|
116
|
+
// WHY: "+" is a valid symbol key whose spelling collides with the
|
|
117
|
+
// modifier separator; handle it before splitting.
|
|
118
|
+
return "+" as KeyId;
|
|
119
|
+
}
|
|
120
|
+
const parts = value.split("+");
|
|
121
|
+
// WHY: empty segments mean malformed ids like "ctrl++x".
|
|
122
|
+
if (parts.some((part) => part.length === 0)) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const rawKey = parts[parts.length - 1];
|
|
126
|
+
const rawModifiers = parts.slice(0, -1);
|
|
127
|
+
if (rawModifiers.some((modifier) => !MODIFIER_SET.has(modifier))) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
const modifierSet = new Set(rawModifiers);
|
|
131
|
+
// WHY: duplicate modifiers like "ctrl+ctrl+x" must not silently collapse.
|
|
132
|
+
if (modifierSet.size !== rawModifiers.length) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
let key: string | undefined;
|
|
136
|
+
if (isUpperLetter(rawKey)) {
|
|
137
|
+
key = rawKey.toLowerCase();
|
|
138
|
+
modifierSet.add("shift");
|
|
139
|
+
} else if (isLetter(rawKey) || isDigit(rawKey)) {
|
|
140
|
+
key = rawKey;
|
|
141
|
+
} else if (KEY_ALIASES[rawKey] !== undefined) {
|
|
142
|
+
key = KEY_ALIASES[rawKey];
|
|
143
|
+
} else if (SPECIAL_KEYS.has(rawKey)) {
|
|
144
|
+
key = rawKey;
|
|
145
|
+
} else if (SYMBOL_KEYS.has(rawKey)) {
|
|
146
|
+
key = rawKey;
|
|
147
|
+
}
|
|
148
|
+
if (key === undefined) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const ordered = MODIFIER_ORDER.filter((modifier) => modifierSet.has(modifier));
|
|
152
|
+
return (ordered.length > 0 ? `${ordered.join("+")}+${key}` : key) as KeyId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type ParsedTarget = PrefixTarget | null;
|
|
156
|
+
|
|
157
|
+
function validateTarget(
|
|
158
|
+
value: unknown,
|
|
159
|
+
keyLabel: string,
|
|
160
|
+
path: string,
|
|
161
|
+
diagnostics: ConfigDiagnostic[],
|
|
162
|
+
): ParsedTarget | undefined {
|
|
163
|
+
if (value === null) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
167
|
+
diagnostics.push({ level: "error", path, message: `binding "${keyLabel}" must be an object or null` });
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const record = value as Record<string, unknown>;
|
|
171
|
+
for (const property of Object.keys(record)) {
|
|
172
|
+
if (!(TARGET_PROPERTIES as readonly string[]).includes(property)) {
|
|
173
|
+
diagnostics.push({ level: "error", path, message: `binding "${keyLabel}" has unknown property "${property}"` });
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const present = TARGET_KEYS.filter((kind) => record[kind] !== undefined);
|
|
178
|
+
if (present.length !== 1) {
|
|
179
|
+
diagnostics.push({
|
|
180
|
+
level: "error",
|
|
181
|
+
path,
|
|
182
|
+
message: `binding "${keyLabel}" must contain exactly one of action, command, or event`,
|
|
183
|
+
});
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
const kind = present[0];
|
|
187
|
+
if (record.payload !== undefined && kind !== "event") {
|
|
188
|
+
// WHY: payload is only meaningful for event targets; elsewhere it hides
|
|
189
|
+
// configuration mistakes.
|
|
190
|
+
diagnostics.push({
|
|
191
|
+
level: "error",
|
|
192
|
+
path,
|
|
193
|
+
message: `binding "${keyLabel}" payload is only allowed with event targets`,
|
|
194
|
+
});
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if (kind === "action") {
|
|
198
|
+
if (typeof record.action !== "string" || record.action.length === 0) {
|
|
199
|
+
diagnostics.push({ level: "error", path, message: `binding "${keyLabel}" action must be a non-empty string` });
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
return { action: record.action };
|
|
203
|
+
}
|
|
204
|
+
if (kind === "command") {
|
|
205
|
+
if (typeof record.command !== "string" || !record.command.startsWith("/")) {
|
|
206
|
+
diagnostics.push({ level: "error", path, message: `binding "${keyLabel}" command must start with "/"` });
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
if (typeof record.submit !== "boolean") {
|
|
210
|
+
diagnostics.push({ level: "error", path, message: `binding "${keyLabel}" submit must be a boolean` });
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
return { command: record.command, submit: record.submit };
|
|
214
|
+
}
|
|
215
|
+
if (typeof record.event !== "string" || record.event.length === 0) {
|
|
216
|
+
diagnostics.push({ level: "error", path, message: `binding "${keyLabel}" event must be a non-empty string` });
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
if (record.payload !== undefined && typeof record.payload !== "object") {
|
|
220
|
+
diagnostics.push({ level: "error", path, message: `binding "${keyLabel}" payload must be an object` });
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return record.payload !== undefined
|
|
224
|
+
? { event: record.event, payload: record.payload }
|
|
225
|
+
: { event: record.event };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
type ParsedLayer = {
|
|
229
|
+
prefix?: KeyId;
|
|
230
|
+
timeoutMs?: number;
|
|
231
|
+
bindings?: Map<KeyId, PrefixTarget | null>;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
function parseLayer(
|
|
235
|
+
raw: string,
|
|
236
|
+
path: string,
|
|
237
|
+
isDefaults: boolean,
|
|
238
|
+
diagnostics: ConfigDiagnostic[],
|
|
239
|
+
): ParsedLayer | undefined {
|
|
240
|
+
let parsed: unknown;
|
|
241
|
+
try {
|
|
242
|
+
parsed = JSON.parse(raw);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
diagnostics.push({ level: "error", path, message: `invalid JSON: ${(error as Error).message}` });
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
248
|
+
diagnostics.push({ level: "error", path, message: "configuration must be a JSON object" });
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
const record = parsed as Record<string, unknown>;
|
|
252
|
+
for (const property of Object.keys(record)) {
|
|
253
|
+
if (!LAYER_TOP_LEVEL_KEYS.has(property)) {
|
|
254
|
+
diagnostics.push({ level: "error", path, message: `unknown property "${property}"` });
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const layer: ParsedLayer = {};
|
|
259
|
+
if (record.prefix !== undefined) {
|
|
260
|
+
const normalized = normalizeKeyId(record.prefix as string);
|
|
261
|
+
if (normalized === undefined) {
|
|
262
|
+
diagnostics.push({ level: "error", path, message: `prefix "${String(record.prefix)}" is not a valid key id` });
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
layer.prefix = normalized;
|
|
266
|
+
}
|
|
267
|
+
if (record.timeoutMs !== undefined) {
|
|
268
|
+
const timeout = record.timeoutMs;
|
|
269
|
+
// WHY: any positive integer is accepted by design; the guard only rejects
|
|
270
|
+
// values a timer cannot schedule meaningfully.
|
|
271
|
+
if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout <= 0) {
|
|
272
|
+
diagnostics.push({ level: "error", path, message: `timeoutMs must be a positive integer, got ${String(timeout)}` });
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
layer.timeoutMs = timeout;
|
|
276
|
+
}
|
|
277
|
+
if (record.bindings !== undefined) {
|
|
278
|
+
if (typeof record.bindings !== "object" || record.bindings === null || Array.isArray(record.bindings)) {
|
|
279
|
+
diagnostics.push({ level: "error", path, message: "bindings must be an object" });
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
const bindings = new Map<KeyId, PrefixTarget | null>();
|
|
283
|
+
for (const [rawKey, rawTarget] of Object.entries(record.bindings)) {
|
|
284
|
+
const normalizedKey = normalizeKeyId(rawKey);
|
|
285
|
+
if (normalizedKey === undefined) {
|
|
286
|
+
diagnostics.push({ level: "error", path, message: `binding key "${rawKey}" is not a valid key id` });
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
if (normalizedKey === ESCAPE_KEY) {
|
|
290
|
+
// WHY: escape is reserved for canceling prefix mode; warn instead of
|
|
291
|
+
// failing so a single bad binding never disables the whole map.
|
|
292
|
+
diagnostics.push({
|
|
293
|
+
level: "warning",
|
|
294
|
+
path,
|
|
295
|
+
message: 'binding "escape" can never dispatch; escape cancels prefix mode',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const target = validateTarget(rawTarget, rawKey, path, diagnostics);
|
|
299
|
+
if (target === undefined) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
bindings.set(normalizedKey, target);
|
|
303
|
+
}
|
|
304
|
+
layer.bindings = bindings;
|
|
305
|
+
}
|
|
306
|
+
if (isDefaults && (layer.prefix !== undefined || layer.bindings !== undefined)) {
|
|
307
|
+
// WHY: shipped defaults must stay neutral; a packaged default keymap would
|
|
308
|
+
// surprise users on first install.
|
|
309
|
+
diagnostics.push({ level: "error", path, message: "defaults must not ship a prefix or bindings" });
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
return layer;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function readLayer(
|
|
316
|
+
path: string,
|
|
317
|
+
isDefaults: boolean,
|
|
318
|
+
diagnostics: ConfigDiagnostic[],
|
|
319
|
+
): ParsedLayer | undefined {
|
|
320
|
+
let raw: string;
|
|
321
|
+
try {
|
|
322
|
+
raw = readFileSync(path, "utf8");
|
|
323
|
+
} catch {
|
|
324
|
+
// WHY: absence is ordinary; only malformed content is a diagnostic.
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
return parseLayer(raw, path, isDefaults, diagnostics);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function loadConfig(options: LoadConfigOptions): LoadConfigResult {
|
|
331
|
+
const diagnostics: ConfigDiagnostic[] = [];
|
|
332
|
+
const globalPath = join(options.agentDir, CONFIG_FILE_NAME);
|
|
333
|
+
const projectPath = join(options.cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME);
|
|
334
|
+
|
|
335
|
+
const defaultsLayer = readLayer(DEFAULTS_PATH, true, diagnostics);
|
|
336
|
+
const globalLayer = readLayer(globalPath, false, diagnostics);
|
|
337
|
+
let projectLayer: ParsedLayer | undefined;
|
|
338
|
+
let projectLoaded = false;
|
|
339
|
+
if (options.projectTrusted) {
|
|
340
|
+
projectLayer = readLayer(projectPath, false, diagnostics);
|
|
341
|
+
projectLoaded = projectLayer !== undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const result: LoadConfigResult = { diagnostics, globalPath, projectPath, projectLoaded };
|
|
345
|
+
// WHY: any error in any loaded layer fails the whole effective config so a
|
|
346
|
+
// partially merged map can never activate.
|
|
347
|
+
if (diagnostics.some((diagnostic) => diagnostic.level === "error")) {
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
const prefix = projectLayer?.prefix ?? globalLayer?.prefix;
|
|
351
|
+
const timeoutMs =
|
|
352
|
+
projectLayer?.timeoutMs ?? globalLayer?.timeoutMs ?? defaultsLayer?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
353
|
+
const bindings = new Map<KeyId, PrefixTarget>();
|
|
354
|
+
const overlays = [globalLayer, projectLayer];
|
|
355
|
+
for (const overlay of overlays) {
|
|
356
|
+
if (overlay?.bindings === undefined) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
for (const [key, target] of overlay.bindings) {
|
|
360
|
+
if (target === null) {
|
|
361
|
+
bindings.delete(key);
|
|
362
|
+
} else {
|
|
363
|
+
bindings.set(key, target);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (prefix === undefined || bindings.size === 0) {
|
|
368
|
+
// WHY: inert is the safe default; absence of a full map is not an error.
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
result.config = { prefix, timeoutMs, bindings };
|
|
372
|
+
return result;
|
|
373
|
+
}
|
package/src/conflicts.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { KeyId, KeybindingsManager } from "@earendil-works/pi-tui";
|
|
2
|
+
import { normalizeKeyId } from "./config.ts";
|
|
3
|
+
|
|
4
|
+
export type PrefixConflict = {
|
|
5
|
+
key: KeyId;
|
|
6
|
+
keybinding: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// WHY: only these app actions are wired into Pi's main editor; picker- and
|
|
10
|
+
// dialog-only actions (tui.select.*, app.tree.*, app.models.*) never fire
|
|
11
|
+
// while the editor has focus, so they are not conflicts.
|
|
12
|
+
const MAIN_EDITOR_APP_ACTIONS = [
|
|
13
|
+
"app.interrupt",
|
|
14
|
+
"app.clear",
|
|
15
|
+
"app.exit",
|
|
16
|
+
"app.suspend",
|
|
17
|
+
"app.thinking.cycle",
|
|
18
|
+
"app.model.cycleForward",
|
|
19
|
+
"app.model.cycleBackward",
|
|
20
|
+
"app.model.select",
|
|
21
|
+
"app.tools.expand",
|
|
22
|
+
"app.thinking.toggle",
|
|
23
|
+
"app.editor.external",
|
|
24
|
+
"app.message.copy",
|
|
25
|
+
"app.message.followUp",
|
|
26
|
+
"app.message.dequeue",
|
|
27
|
+
"app.clipboard.pasteImage",
|
|
28
|
+
"app.session.new",
|
|
29
|
+
"app.session.tree",
|
|
30
|
+
"app.session.fork",
|
|
31
|
+
"app.session.resume",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
const MAIN_EDITOR_PREFIXES = ["tui.editor.", "tui.input."];
|
|
35
|
+
const MAIN_EDITOR_IDS = new Set<string>(MAIN_EDITOR_APP_ACTIONS);
|
|
36
|
+
|
|
37
|
+
function isMainEditorBinding(id: string): boolean {
|
|
38
|
+
return (
|
|
39
|
+
MAIN_EDITOR_IDS.has(id) ||
|
|
40
|
+
MAIN_EDITOR_PREFIXES.some((prefix) => id.startsWith(prefix))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function findPrefixConflicts(
|
|
45
|
+
prefix: KeyId,
|
|
46
|
+
keybindings: KeybindingsManager,
|
|
47
|
+
): PrefixConflict[] {
|
|
48
|
+
const normalizedPrefix = normalizeKeyId(prefix);
|
|
49
|
+
if (normalizedPrefix === undefined) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const conflicts: PrefixConflict[] = [];
|
|
53
|
+
for (const [id, keys] of Object.entries(keybindings.getResolvedBindings())) {
|
|
54
|
+
if (!isMainEditorBinding(id)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const keyList = Array.isArray(keys) ? keys : [keys];
|
|
58
|
+
for (const rawKey of keyList) {
|
|
59
|
+
if (rawKey === undefined) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const normalized = normalizeKeyId(rawKey);
|
|
63
|
+
if (normalized === normalizedPrefix) {
|
|
64
|
+
conflicts.push({ key: normalized, keybinding: id });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// WHY: stable output keeps /pi-prefix diagnostics and tests deterministic.
|
|
69
|
+
conflicts.sort((a, b) => a.keybinding.localeCompare(b.keybinding));
|
|
70
|
+
return conflicts;
|
|
71
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AppKeybinding, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
4
|
+
import type {
|
|
5
|
+
AutocompleteProvider,
|
|
6
|
+
EditorComponent,
|
|
7
|
+
EditorTheme,
|
|
8
|
+
KeyId,
|
|
9
|
+
TUI,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import type { EffectivePrefixConfig, PrefixTarget } from "./config.ts";
|
|
12
|
+
|
|
13
|
+
export type PrefixEditorOptions = {
|
|
14
|
+
config: EffectivePrefixConfig;
|
|
15
|
+
emitEvent: (event: string, payload?: unknown) => void;
|
|
16
|
+
setStatus: (text: string | undefined) => void;
|
|
17
|
+
notify: (message: string, type?: "info" | "warning" | "error") => void;
|
|
18
|
+
// WHY: editor-independent commands must not sacrifice the user's draft, so
|
|
19
|
+
// the host decides whether direct dispatch applies before any setText.
|
|
20
|
+
dispatchExtensionCommand: (command: string) => boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// WHY: Pi wires these three app actions through replaceable callbacks rather
|
|
24
|
+
// than the ordinary actionHandlers map; dispatch must honor the same paths.
|
|
25
|
+
const INTERRUPT_ACTION: AppKeybinding = "app.interrupt";
|
|
26
|
+
const EXIT_ACTION: AppKeybinding = "app.exit";
|
|
27
|
+
const PASTE_IMAGE_ACTION: AppKeybinding = "app.clipboard.pasteImage";
|
|
28
|
+
const CANCEL_KEY: KeyId = "escape";
|
|
29
|
+
const STATUS_KEY = "pi-prefix";
|
|
30
|
+
const ENTER_INPUT = "\r";
|
|
31
|
+
|
|
32
|
+
// Layer prefix behavior over whichever editor another extension installed.
|
|
33
|
+
type WrappedEditor = EditorComponent & {
|
|
34
|
+
actionHandlers?: Map<AppKeybinding, () => void>;
|
|
35
|
+
onSubmit?: (text: string) => void;
|
|
36
|
+
onChange?: (text: string) => void;
|
|
37
|
+
disableSubmit?: boolean;
|
|
38
|
+
focused?: boolean;
|
|
39
|
+
onEscape?: () => void;
|
|
40
|
+
onCtrlD?: () => void;
|
|
41
|
+
onPasteImage?: () => void;
|
|
42
|
+
onExtensionShortcut?: (data: string) => boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function modifierMask(parts: string[]): number {
|
|
46
|
+
let modifier = 0;
|
|
47
|
+
if (parts.includes("shift")) modifier |= 1;
|
|
48
|
+
if (parts.includes("alt")) modifier |= 2;
|
|
49
|
+
if (parts.includes("ctrl")) modifier |= 4;
|
|
50
|
+
if (parts.includes("super")) modifier |= 8;
|
|
51
|
+
return modifier;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function inputForKey(keyId: string): string | undefined {
|
|
55
|
+
const parts = keyId.toLowerCase().split("+");
|
|
56
|
+
const key = parts.pop();
|
|
57
|
+
if (!key) return undefined;
|
|
58
|
+
if (key === "enter" || key === "return") {
|
|
59
|
+
const modifier = modifierMask(parts);
|
|
60
|
+
return modifier === 0 ? ENTER_INPUT : `\x1b[13;${modifier + 1}u`;
|
|
61
|
+
}
|
|
62
|
+
if (parts.length === 1 && parts[0] === "ctrl" && key.length === 1) {
|
|
63
|
+
const code = key.charCodeAt(0);
|
|
64
|
+
if (code >= 97 && code <= 122) return String.fromCharCode(code - 96);
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class PrefixEditor extends CustomEditor {
|
|
70
|
+
private prefixActive = false;
|
|
71
|
+
private prefixTimer: ReturnType<typeof setTimeout> | undefined;
|
|
72
|
+
// WHY: a stale wrapper inside a rebuilt editor chain must never intercept
|
|
73
|
+
// keystrokes against a disposed dispatch surface.
|
|
74
|
+
private disposed = false;
|
|
75
|
+
|
|
76
|
+
private readonly appTui: TUI;
|
|
77
|
+
private readonly keybindingsManager: KeybindingsManager;
|
|
78
|
+
private readonly inner: WrappedEditor;
|
|
79
|
+
private readonly options: PrefixEditorOptions;
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
appTui: TUI,
|
|
83
|
+
theme: EditorTheme,
|
|
84
|
+
keybindingsManager: KeybindingsManager,
|
|
85
|
+
inner: WrappedEditor,
|
|
86
|
+
options: PrefixEditorOptions,
|
|
87
|
+
) {
|
|
88
|
+
super(appTui, theme, keybindingsManager);
|
|
89
|
+
this.appTui = appTui;
|
|
90
|
+
this.keybindingsManager = keybindingsManager;
|
|
91
|
+
this.inner = inner;
|
|
92
|
+
this.options = options;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private syncAppHandlers(): void {
|
|
96
|
+
if (this.inner.actionHandlers instanceof Map) {
|
|
97
|
+
this.inner.actionHandlers.clear();
|
|
98
|
+
for (const [action, handler] of this.actionHandlers) {
|
|
99
|
+
this.inner.actionHandlers.set(action, handler);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// WHY: the TUI sets focused only on the outermost component; wrappers
|
|
103
|
+
// must forward it or inner editors silently change focus behavior.
|
|
104
|
+
this.inner.focused = this.focused;
|
|
105
|
+
this.inner.onSubmit = this.onSubmit;
|
|
106
|
+
this.inner.onChange = this.onChange;
|
|
107
|
+
this.inner.disableSubmit = this.disableSubmit;
|
|
108
|
+
this.inner.onEscape = this.onEscape;
|
|
109
|
+
this.inner.onCtrlD = this.onCtrlD;
|
|
110
|
+
this.inner.onPasteImage = this.onPasteImage;
|
|
111
|
+
this.inner.onExtensionShortcut = this.onExtensionShortcut;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
activate(): void {
|
|
115
|
+
if (this.disposed) return;
|
|
116
|
+
this.prefixActive = true;
|
|
117
|
+
if (this.prefixTimer) clearTimeout(this.prefixTimer);
|
|
118
|
+
this.prefixTimer = setTimeout(() => this.clearPrefix(), this.options.config.timeoutMs);
|
|
119
|
+
this.options.setStatus(`prefix ${this.options.config.prefix}`);
|
|
120
|
+
this.appTui.requestRender();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private clearPrefix(): void {
|
|
124
|
+
const wasActive = this.prefixActive;
|
|
125
|
+
this.prefixActive = false;
|
|
126
|
+
if (this.prefixTimer) {
|
|
127
|
+
clearTimeout(this.prefixTimer);
|
|
128
|
+
this.prefixTimer = undefined;
|
|
129
|
+
}
|
|
130
|
+
this.options.setStatus(undefined);
|
|
131
|
+
if (wasActive) this.appTui.requestRender();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private submitCurrentDraft(): void {
|
|
135
|
+
if (this.disableSubmit) return;
|
|
136
|
+
for (const key of this.keybindingsManager.getKeys("tui.input.submit")) {
|
|
137
|
+
const input = inputForKey(String(key));
|
|
138
|
+
if (input) {
|
|
139
|
+
this.syncAppHandlers();
|
|
140
|
+
this.inner.handleInput(input);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private dispatch(target: PrefixTarget): void {
|
|
147
|
+
if ("action" in target) {
|
|
148
|
+
const action = target.action as AppKeybinding;
|
|
149
|
+
const handler =
|
|
150
|
+
action === INTERRUPT_ACTION
|
|
151
|
+
? (this.onEscape ?? this.actionHandlers.get(action))
|
|
152
|
+
: action === EXIT_ACTION
|
|
153
|
+
? (this.onCtrlD ?? this.actionHandlers.get(action))
|
|
154
|
+
: action === PASTE_IMAGE_ACTION
|
|
155
|
+
? (this.onPasteImage ?? this.actionHandlers.get(action))
|
|
156
|
+
: this.actionHandlers.get(action);
|
|
157
|
+
if (handler) {
|
|
158
|
+
this.syncAppHandlers();
|
|
159
|
+
handler();
|
|
160
|
+
} else {
|
|
161
|
+
this.options.notify(`pi-prefix: action "${target.action}" is unavailable`, "warning");
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if ("command" in target) {
|
|
166
|
+
if (target.submit && this.options.dispatchExtensionCommand(target.command)) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.setText(target.command);
|
|
170
|
+
if (target.submit) {
|
|
171
|
+
this.submitCurrentDraft();
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if ("event" in target) {
|
|
176
|
+
this.options.emitEvent(target.event, target.payload);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// WHY: exhaustiveness guard so a future target kind fails compilation.
|
|
180
|
+
const exhaustive: never = target;
|
|
181
|
+
return exhaustive;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
handleInput(data: string): void {
|
|
185
|
+
// WHY: Pi delivers registered shortcuts through this callback; honoring
|
|
186
|
+
// it first is what lets native registration activate prefix mode.
|
|
187
|
+
if (this.onExtensionShortcut?.(data)) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (this.disposed) {
|
|
191
|
+
this.syncAppHandlers();
|
|
192
|
+
this.inner.handleInput(data);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (this.prefixActive) {
|
|
196
|
+
if (matchesKey(data, CANCEL_KEY)) {
|
|
197
|
+
this.clearPrefix();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const [keyId, target] of this.options.config.bindings) {
|
|
201
|
+
if (matchesKey(data, keyId)) {
|
|
202
|
+
this.clearPrefix();
|
|
203
|
+
this.dispatch(target);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
this.clearPrefix();
|
|
208
|
+
}
|
|
209
|
+
this.syncAppHandlers();
|
|
210
|
+
this.inner.handleInput(data);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Mark transparent and release the prefix timer; called on session_shutdown. */
|
|
214
|
+
dispose(): void {
|
|
215
|
+
this.disposed = true;
|
|
216
|
+
this.clearPrefix();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
render(width: number): string[] {
|
|
220
|
+
this.inner.borderColor = this.borderColor;
|
|
221
|
+
this.inner.focused = this.focused;
|
|
222
|
+
return this.inner.render(width);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
invalidate(): void {
|
|
226
|
+
this.inner.invalidate();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
getText(): string {
|
|
230
|
+
return this.inner.getText();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setText(text: string): void {
|
|
234
|
+
this.inner.setText(text);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
addToHistory(text: string): void {
|
|
238
|
+
this.inner.addToHistory?.(text);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
insertTextAtCursor(text: string): void {
|
|
242
|
+
this.inner.insertTextAtCursor?.(text);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
getExpandedText(): string {
|
|
246
|
+
return this.inner.getExpandedText?.() ?? this.inner.getText();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
250
|
+
this.inner.setAutocompleteProvider?.(provider);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
setPaddingX(padding: number): void {
|
|
254
|
+
this.inner.setPaddingX?.(padding);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
setAutocompleteMaxVisible(maxVisible: number): void {
|
|
258
|
+
this.inner.setAutocompleteMaxVisible?.(maxVisible);
|
|
259
|
+
}
|
|
260
|
+
}
|