@leo-alvarenga/pi-qol 0.1.1
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 +62 -0
- package/extensions/features/external-editor-cwd/index.ts +225 -0
- package/extensions/index.ts +25 -0
- package/extensions/lib/config.ts +29 -0
- package/extensions/lib/editor.ts +42 -0
- package/extensions/lib/json.ts +21 -0
- package/extensions/lib/keybindings.ts +85 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/types.ts +8 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonardo A. Alvarenga
|
|
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,62 @@
|
|
|
1
|
+
# pi-qol
|
|
2
|
+
|
|
3
|
+
Small quality-of-life features for [pi](https://github.com/earendil-works/pi-coding-agent), individually toggleable via a single config file.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:@leo-alvarenga/pi-qol
|
|
9
|
+
# or from this repo:
|
|
10
|
+
pi -e ./packages/pi-qol
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Config
|
|
14
|
+
|
|
15
|
+
All features are enabled by default. Disable any feature in `~/.pi/agent/pi-qol.json` (file is optional):
|
|
16
|
+
|
|
17
|
+
```jsonc
|
|
18
|
+
{
|
|
19
|
+
"external-editor-cwd": { "disabled": true },
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run `/reload` after editing any config.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Feature: `external-editor-cwd`
|
|
28
|
+
|
|
29
|
+
Edit the current prompt in your external editor. The file is written to the current working directory so your editor can pick up project context (`.editorconfig`, LSP, etc.).
|
|
30
|
+
|
|
31
|
+
**Default key:** `ctrl+shift+e` (Can be set to override the default "Open in Editor" keybinding)
|
|
32
|
+
|
|
33
|
+
### Choosing the key
|
|
34
|
+
|
|
35
|
+
```jsonc
|
|
36
|
+
{ "external-editor-cwd": { "keybinding": "native" } } // take over pi's ctrl+g
|
|
37
|
+
{ "external-editor-cwd": { "keybinding": "ctrl+shift+e" } } // default
|
|
38
|
+
{ "external-editor-cwd": { "keybinding": "alt+e" } } // any valid key id
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Any invalid value is reported at session start and the extension falls back to `ctrl+shift+e`.
|
|
42
|
+
|
|
43
|
+
#### `"native"` mode
|
|
44
|
+
|
|
45
|
+
pi's built-in editor shortcut (`ctrl+g`) is in a reserved list that prevents extensions from claiming it while it is active. Setting `keybinding: "native"` writes `"app.editor.external": []` into `~/.pi/agent/keybindings.json` to release that binding, then prompts you to run `/reload` once. After that single reload, `ctrl+g` opens this extension's editor instead.
|
|
46
|
+
|
|
47
|
+
- The file reformat is intentional: pi rejects comments in `keybindings.json` anyway
|
|
48
|
+
- Reverting: remove the `[]` entry and switch `keybinding` away from `"native"`
|
|
49
|
+
|
|
50
|
+
### Editor selection
|
|
51
|
+
|
|
52
|
+
Follows pi's own order: `settings.json:externalEditor` → `$VISUAL` → `$EDITOR` → `nano` (`notepad` on Windows).
|
|
53
|
+
|
|
54
|
+
### Behaviour
|
|
55
|
+
|
|
56
|
+
1. The current prompt is written to `./prompt.md` (or `./prompt-<id>.md` if the file already exists) in the working directory
|
|
57
|
+
2. The TUI suspends and hands the terminal to the editor
|
|
58
|
+
3. **Exit 0:** the file is read back into the prompt editor and deleted
|
|
59
|
+
4. **Non-zero exit or spawn failure:** the file is kept and its path is shown in a warning; your text is safe
|
|
60
|
+
5. **Unwritable cwd:** the file is written to the OS temp dir instead
|
|
61
|
+
6. `super+` bindings require a terminal with [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) support
|
|
62
|
+
7. The shortcut requires the prompt editor to have focus. Extensions that replace the editor component must forward `onExtensionShortcut` for this to work
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
import type { FeatureConfig } from "../../lib/config";
|
|
12
|
+
import { resolveEditorCommand, spawnEditor } from "../../lib/editor";
|
|
13
|
+
import {
|
|
14
|
+
getBoundKeys,
|
|
15
|
+
unbindAction,
|
|
16
|
+
type UnbindResult,
|
|
17
|
+
} from "../../lib/keybindings";
|
|
18
|
+
import { normalizeKeyId } from "../../lib/keys";
|
|
19
|
+
import type { QolFeature } from "../../lib/types";
|
|
20
|
+
|
|
21
|
+
type Deps = {
|
|
22
|
+
unbindAction: (actionIds: string[]) => UnbindResult;
|
|
23
|
+
getBoundKeys: (actionIds: string[]) => (string[] | undefined)[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type PromptFile = { path: string; inCwd: boolean };
|
|
27
|
+
|
|
28
|
+
const DEFAULT_KEY = "ctrl+shift+e";
|
|
29
|
+
|
|
30
|
+
// app.editor.external is in RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS with default ctrl+g
|
|
31
|
+
const BUILTIN_ACTIONS = ["app.editor.external", "tui.altScreen.searchNext"];
|
|
32
|
+
const BUILTIN_EDITOR_KEY = "ctrl+g";
|
|
33
|
+
|
|
34
|
+
let running = false;
|
|
35
|
+
let file: PromptFile | null = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve which key to register (or null to skip) based on the user's config.
|
|
39
|
+
* Exported for testing with injected deps
|
|
40
|
+
*/
|
|
41
|
+
export function resolveKeyBinding(
|
|
42
|
+
options: FeatureConfig,
|
|
43
|
+
notices: string[],
|
|
44
|
+
deps: Deps = { getBoundKeys, unbindAction },
|
|
45
|
+
): string | null {
|
|
46
|
+
const raw = options["keybinding"];
|
|
47
|
+
|
|
48
|
+
if (Array.isArray(raw)) return null;
|
|
49
|
+
if (raw === undefined) return DEFAULT_KEY;
|
|
50
|
+
|
|
51
|
+
if (typeof raw !== "string") {
|
|
52
|
+
notices.push(
|
|
53
|
+
`invalid keybinding value (expected string), using ${DEFAULT_KEY}`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return DEFAULT_KEY;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (raw === "native") {
|
|
60
|
+
const bound = deps.getBoundKeys(BUILTIN_ACTIONS);
|
|
61
|
+
|
|
62
|
+
// undefined entry = not in keybindings.json (pi default ctrl+g active); [] = explicitly unbound
|
|
63
|
+
const ctrlGBound = bound.some((b) => b === undefined || b.length > 0);
|
|
64
|
+
|
|
65
|
+
if (!ctrlGBound) return BUILTIN_EDITOR_KEY;
|
|
66
|
+
const result = deps.unbindAction(BUILTIN_ACTIONS);
|
|
67
|
+
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
notices.push(result.reason);
|
|
70
|
+
|
|
71
|
+
return DEFAULT_KEY;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
notices.push(
|
|
75
|
+
`unbound pi's built-in editor key; run \`/reload\` to activate \`${BUILTIN_EDITOR_KEY}\``,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return DEFAULT_KEY;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const canonical = normalizeKeyId(raw);
|
|
82
|
+
if (!canonical) {
|
|
83
|
+
notices.push(`invalid keybinding "${raw}", using ${DEFAULT_KEY}`);
|
|
84
|
+
|
|
85
|
+
return DEFAULT_KEY;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (canonical === BUILTIN_EDITOR_KEY) {
|
|
89
|
+
notices.push(
|
|
90
|
+
`\`${BUILTIN_EDITOR_KEY}\` is reserved by pi's built-in editor; set \`keybinding: "native"\` to take it over`,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return DEFAULT_KEY;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return canonical;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createPromptFile(cwd: string, text: string): PromptFile {
|
|
100
|
+
const getName = (random?: boolean) =>
|
|
101
|
+
`prompt${random ? `-${randomBytes(6).toString("base64url")}` : ""}.md`;
|
|
102
|
+
|
|
103
|
+
const names = [getName(), getName(true)];
|
|
104
|
+
|
|
105
|
+
for (const name of names) {
|
|
106
|
+
const filePath = join(cwd, name);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(filePath, text, { flag: "wx", encoding: "utf8" });
|
|
110
|
+
|
|
111
|
+
return { path: filePath, inCwd: true };
|
|
112
|
+
} catch (err: unknown) {
|
|
113
|
+
const code = (err as { code?: string }).code;
|
|
114
|
+
|
|
115
|
+
if (code === "EEXIST") continue;
|
|
116
|
+
if (code === "EACCES" || code === "EROFS" || code === "EPERM") break;
|
|
117
|
+
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const filePath = join(tmpdir(), getName(true));
|
|
123
|
+
|
|
124
|
+
writeFileSync(filePath, text, "utf8");
|
|
125
|
+
return { path: filePath, inCwd: false };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function rmPromptFile(file: PromptFile): void {
|
|
129
|
+
try {
|
|
130
|
+
rmSync(file.path, { force: true });
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function openEditor(ctx: ExtensionContext): Promise<void> {
|
|
137
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
138
|
+
ctx.ui.notify(
|
|
139
|
+
"pi-qol: external editor needs the interactive TUI",
|
|
140
|
+
"warning",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (running) return;
|
|
147
|
+
running = true;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
if (file) rmPromptFile(file);
|
|
151
|
+
|
|
152
|
+
file = createPromptFile(ctx.cwd, ctx.ui.getEditorText());
|
|
153
|
+
|
|
154
|
+
if (!file.inCwd) {
|
|
155
|
+
ctx.ui.notify(
|
|
156
|
+
`pi-qol: cwd not writable, using temp file: ${file.path}`,
|
|
157
|
+
"warning",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const command = resolveEditorCommand();
|
|
162
|
+
|
|
163
|
+
let exit: number | null = null;
|
|
164
|
+
await ctx.ui.custom<null>(async (tui, _theme, _kb, done) => {
|
|
165
|
+
if (file) {
|
|
166
|
+
tui.stop();
|
|
167
|
+
try {
|
|
168
|
+
exit = await spawnEditor(command, file.path);
|
|
169
|
+
} finally {
|
|
170
|
+
tui.start();
|
|
171
|
+
tui.requestRender(true);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
done(null);
|
|
176
|
+
return { render: () => [], invalidate() {} };
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (exit === 0) {
|
|
180
|
+
ctx.ui.setEditorText(readFileSync(file.path, "utf8"));
|
|
181
|
+
|
|
182
|
+
rmPromptFile(file);
|
|
183
|
+
} else {
|
|
184
|
+
ctx.ui.notify(
|
|
185
|
+
exit === null
|
|
186
|
+
? `pi-qol: could not launch "${command}". Prompt kept at ${file.path}`
|
|
187
|
+
: `pi-qol: editor exited with code ${exit}. Prompt kept at ${file.path}`,
|
|
188
|
+
"warning",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
running = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function register(pi: ExtensionAPI, options: FeatureConfig): void {
|
|
197
|
+
const notices: string[] = [];
|
|
198
|
+
const key = resolveKeyBinding(options, notices);
|
|
199
|
+
|
|
200
|
+
if (key) {
|
|
201
|
+
pi.registerShortcut(key as KeyId, {
|
|
202
|
+
handler: openEditor,
|
|
203
|
+
description: "Edit prompt in external editor (cwd file)",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
pi.on("session_start", (_event, ctx) => {
|
|
208
|
+
if (!ctx.hasUI) return;
|
|
209
|
+
|
|
210
|
+
for (const notice of notices.splice(0)) {
|
|
211
|
+
ctx.ui.notify(`pi-qol: ${notice}`, "warning");
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
pi.on("session_shutdown", () => {
|
|
216
|
+
if (file) rmPromptFile(file);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const feature: QolFeature = {
|
|
221
|
+
register,
|
|
222
|
+
name: "external-editor-cwd",
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export default feature;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { featureOptions, isFeatureEnabled, readQolConfig } from "./lib/config";
|
|
4
|
+
import type { QolFeature } from "./lib/types";
|
|
5
|
+
|
|
6
|
+
import externalEditorCwd from "./features/external-editor-cwd";
|
|
7
|
+
|
|
8
|
+
const FEATURES: QolFeature[] = [externalEditorCwd];
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI) {
|
|
11
|
+
const config = readQolConfig();
|
|
12
|
+
for (const feature of FEATURES) {
|
|
13
|
+
if (!isFeatureEnabled(config, feature.name)) continue;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
feature.register(pi, featureOptions(config, feature.name));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
// One broken feature must not take the others down with it
|
|
19
|
+
console.warn(
|
|
20
|
+
`pi-qol: feature "${feature.name}" failed to register:`,
|
|
21
|
+
error,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readJsonObject } from "./json";
|
|
4
|
+
|
|
5
|
+
export type QolConfig = Record<string, FeatureConfig | undefined>;
|
|
6
|
+
export type FeatureConfig = { disabled?: boolean; [option: string]: unknown };
|
|
7
|
+
|
|
8
|
+
export const QOL_CONFIG_FILE = "pi-qol.json";
|
|
9
|
+
|
|
10
|
+
export function getQolConfigPath(): string {
|
|
11
|
+
return join(getAgentDir(), QOL_CONFIG_FILE);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readQolConfig(): QolConfig {
|
|
15
|
+
return readJsonObject(getQolConfigPath()) as QolConfig;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** All features are enabled unless explicitly disabled. */
|
|
19
|
+
export function isFeatureEnabled(config: QolConfig, feature: string): boolean {
|
|
20
|
+
return config[feature]?.disabled !== true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Options for one feature; `{}` when absent, so features never handle undefined. */
|
|
24
|
+
export function featureOptions(
|
|
25
|
+
config: QolConfig,
|
|
26
|
+
feature: string,
|
|
27
|
+
): FeatureConfig {
|
|
28
|
+
return config[feature] ?? {};
|
|
29
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readJsonObject } from "./json";
|
|
5
|
+
|
|
6
|
+
/** `settings.json:externalEditor` -> $VISUAL -> $EDITOR -> "notepad" (win32) | "nano" */
|
|
7
|
+
export function resolveEditorCommand(): string {
|
|
8
|
+
const settings = readJsonObject(join(getAgentDir(), "settings.json"));
|
|
9
|
+
|
|
10
|
+
const configured = settings["externalEditor"];
|
|
11
|
+
|
|
12
|
+
if (typeof configured === "string" && configured.trim() !== "") {
|
|
13
|
+
return configured.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const env = process.env["VISUAL"] || process.env["EDITOR"];
|
|
17
|
+
if (env) return env;
|
|
18
|
+
|
|
19
|
+
return process.platform === "win32" ? "notepad" : "nano";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hand the terminal to the editor for `filePath` and resolve with its exit code
|
|
24
|
+
* (null when the editor could not be spawned).
|
|
25
|
+
* Caller is responsible for stopping/starting the TUI first
|
|
26
|
+
*/
|
|
27
|
+
export function spawnEditor(
|
|
28
|
+
command: string,
|
|
29
|
+
filePath: string,
|
|
30
|
+
): Promise<number | null> {
|
|
31
|
+
const [editor, ...args] = command.split(" ");
|
|
32
|
+
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const child = spawn(editor!, [...args, filePath], {
|
|
35
|
+
stdio: "inherit",
|
|
36
|
+
shell: process.platform === "win32",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
child.on("error", () => resolve(null));
|
|
40
|
+
child.on("close", (code) => resolve(code));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Read a JSON object from disk (BOM-tolerant). Missing, unreadable, or malformed input yields {}. Never throws */
|
|
4
|
+
export function readJsonObject(path: string): Record<string, unknown> {
|
|
5
|
+
try {
|
|
6
|
+
const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
|
|
9
|
+
if (
|
|
10
|
+
parsed !== null &&
|
|
11
|
+
typeof parsed === "object" &&
|
|
12
|
+
!Array.isArray(parsed)
|
|
13
|
+
) {
|
|
14
|
+
return parsed as Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// missing, unreadable, or malformed
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readJsonObject } from "./json";
|
|
5
|
+
|
|
6
|
+
export type UnbindResult =
|
|
7
|
+
| { ok: false; reason: string }
|
|
8
|
+
| { ok: true; replaced: (string | string[] | undefined)[] };
|
|
9
|
+
|
|
10
|
+
export function getKeybindingsPath(): string {
|
|
11
|
+
return join(getAgentDir(), "keybindings.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readKeybindings(): Record<string, unknown> {
|
|
15
|
+
return readJsonObject(getKeybindingsPath());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Keys currently bound to one pi action id, or undefined when unset. `[]` stays [] */
|
|
19
|
+
export function getBoundKeys(actionIds: string[]): (string[] | undefined)[] {
|
|
20
|
+
return actionIds.map((actionId) => {
|
|
21
|
+
const raw = readKeybindings()[actionId];
|
|
22
|
+
if (typeof raw === "string") return [raw];
|
|
23
|
+
|
|
24
|
+
if (Array.isArray(raw) && raw.every((v) => typeof v === "string")) {
|
|
25
|
+
return raw as string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return undefined;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Set `actionId` to [] in keybindings.json, preserving every other entry */
|
|
33
|
+
export function unbindAction(actionIds: string[]): UnbindResult {
|
|
34
|
+
const path = getKeybindingsPath();
|
|
35
|
+
let current: Record<string, unknown> = {};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const raw = readFileSync(path, "utf8").replace(/^\uFEFF/, "");
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
parsed !== null &&
|
|
44
|
+
typeof parsed === "object" &&
|
|
45
|
+
!Array.isArray(parsed)
|
|
46
|
+
) {
|
|
47
|
+
current = parsed as Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
return { ok: false, reason: "keybindings.json is not valid JSON" };
|
|
51
|
+
}
|
|
52
|
+
} catch (err: unknown) {
|
|
53
|
+
const code = (err as { code?: string }).code;
|
|
54
|
+
|
|
55
|
+
// ENOENT: file doesn't exist, we'll create it
|
|
56
|
+
if (code !== "ENOENT") {
|
|
57
|
+
return { ok: false, reason: String(err) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const replaced: (string | string[] | undefined)[] = [];
|
|
62
|
+
|
|
63
|
+
for (const actionId of actionIds) {
|
|
64
|
+
const previous = current[actionId];
|
|
65
|
+
current[actionId] = [];
|
|
66
|
+
|
|
67
|
+
replaced.push(
|
|
68
|
+
typeof previous === "string"
|
|
69
|
+
? previous
|
|
70
|
+
: Array.isArray(previous)
|
|
71
|
+
? (previous as string[])
|
|
72
|
+
: undefined,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
current[actionId] = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(path, JSON.stringify(current, null, 2) + "\n", "utf8");
|
|
80
|
+
} catch (err: unknown) {
|
|
81
|
+
return { ok: false, reason: String(err) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { ok: true, replaced };
|
|
85
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
// Built from pi-tui's own table so we never maintain a hand-copied key list.
|
|
4
|
+
const BASE_KEYS = new Map<string, string>();
|
|
5
|
+
|
|
6
|
+
for (const value of Object.values(Key)) {
|
|
7
|
+
if (typeof value === "string") BASE_KEYS.set(value.toLowerCase(), value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
for (const c of "abcdefghijklmnopqrstuvwxyz0123456789") BASE_KEYS.set(c, c);
|
|
11
|
+
|
|
12
|
+
const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
13
|
+
const MAX_PARTS = 5; // 4 modifiers + 1 key
|
|
14
|
+
|
|
15
|
+
/** Canonical key id, or null when the string is not a valid keybinding. */
|
|
16
|
+
export function normalizeKeyId(value: string): string | null {
|
|
17
|
+
const parts = value.trim().split("+");
|
|
18
|
+
|
|
19
|
+
if (parts.length === 0 || parts.length > MAX_PARTS) return null;
|
|
20
|
+
|
|
21
|
+
const key = BASE_KEYS.get(parts.at(-1)!.toLowerCase());
|
|
22
|
+
if (!key) return null;
|
|
23
|
+
|
|
24
|
+
const modifiers: string[] = [];
|
|
25
|
+
for (const raw of parts.slice(0, -1)) {
|
|
26
|
+
const modifier = raw.trim().toLowerCase();
|
|
27
|
+
if (!MODIFIERS.has(modifier) || modifiers.includes(modifier)) return null;
|
|
28
|
+
|
|
29
|
+
modifiers.push(modifier);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return [...modifiers, key].join("+");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when `value` names a valid key id. */
|
|
36
|
+
export function isValidKeyId(value: string): boolean {
|
|
37
|
+
return normalizeKeyId(value) !== null;
|
|
38
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { FeatureConfig } from "./config";
|
|
3
|
+
|
|
4
|
+
/** A self-contained QoL feature. `name` must match its `pi-qol.json` key. */
|
|
5
|
+
export interface QolFeature {
|
|
6
|
+
name: string;
|
|
7
|
+
register(pi: ExtensionAPI, options: FeatureConfig): void;
|
|
8
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@leo-alvarenga/pi-qol",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Small quality-of-life features for pi, individually toggleable",
|
|
5
|
+
"author": "Leonardo A. Alvarenga",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": "github.com/leo-alvarenga/pi-mono",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-extension",
|
|
13
|
+
"pi-package",
|
|
14
|
+
"pi"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"!extensions/lib/test",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
24
|
+
"@earendil-works/pi-tui": "*"
|
|
25
|
+
},
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./extensions/index.ts"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.0.0",
|
|
33
|
+
"typescript": "^7.0.2",
|
|
34
|
+
"vitest": "^3.2.4"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc --noEmit",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
}
|
|
41
|
+
}
|