@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 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
@@ -0,0 +1,3 @@
1
+ {
2
+ "timeoutMs": 2000
3
+ }
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
+ }
@@ -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
+ }