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