pi-quick-cancel 0.2.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,19 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ezoushen
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.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # pi-quick-cancel
2
+
3
+ Cancel a running turn, rewind the chat to before the prompt was sent, and put
4
+ the prompt back in the editor — a true unwind, so it is as if the prompt was
5
+ never submitted (except that it is waiting in the editor).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pi install npm:pi-quick-cancel
11
+ ```
12
+
13
+ ## What it does
14
+
15
+ Pressing the shortcut (or running `/quick-cancel`) does one thing, whether a
16
+ turn is running or the last one already completed: it cancels any in-flight
17
+ run, rewinds the session tree so the canonical chat history ends just before
18
+ the prompt (the user message and any assistant output drop out of the context
19
+ entirely), rebuilds the visible transcript, and puts the prompt back in the
20
+ editor. If you were typing a new prompt, that text is left untouched.
21
+
22
+ Mechanically: the shortcut dispatches the `quick-cancel` command, which waits
23
+ for the loop to settle and invokes pi's tree navigation on the prompt's
24
+ session entry — the only API surface that rebuilds the transcript.
25
+
26
+ ### Why `ctrl+alt+x`
27
+
28
+ This extension registers its own shortcut that must actually fire. `escape` is
29
+ already pi's built-in interrupt, so binding this to it is **skipped by pi** — the
30
+ extension's handler would never be registered, and the shortcut would silently
31
+ do nothing. `ctrl+alt+x` avoids that: `ctrl`+`alt`+a letter is delivered as a
32
+ distinct escape sequence by terminals (so it does not collide with a free
33
+ letter, and is not stripped like `ctrl+shift+x` would be to `ctrl+x`). Bind
34
+ another key with the `key` setting if you prefer.
35
+
36
+ ## External contract
37
+
38
+ One shortcut (default `ctrl+alt+x`, or `key` / `PI_QUICK_CANCEL_KEY`) and one
39
+ command (`/quick-cancel`), both driving the same rewind described above. An
40
+ invalid `key` falls back to `ctrl+alt+x` with a warning once per session.
41
+
42
+ The terminal must actually send `ctrl+alt+x` for the shortcut to fire; a
43
+ terminal without the kitty keyboard protocol may send the legacy `ESC`
44
+ sequence instead, which pi does not bind. Verified against one such runtime:
45
+ `alt+<letter>` (the classic `ESC`+letter meta form) fires fine, while
46
+ `alt+escape` arrives as an ambiguous double-`ESC` and cannot be bound at all.
47
+
48
+ ## Settings
49
+
50
+ Set `key` in `pi-quick-cancel.json` in the Pi agent settings directory, or use
51
+ `PI_QUICK_CANCEL_KEY`:
52
+
53
+ | setting | default | environment variable | purpose |
54
+ |---|---|---|---|
55
+ | `key` | `ctrl+alt+x` | `PI_QUICK_CANCEL_KEY` | Shortcut with one or more modifiers and a letter, digit, or named key. |
56
+
57
+ For example:
58
+
59
+ ```json
60
+ {
61
+ "key": "ctrl+alt+e"
62
+ }
63
+ ```
64
+
65
+ ## Limitations
66
+
67
+ Only the prompt **text** is returned to the editor. Any images that were
68
+ attached to the original prompt cannot be restored through the editor, so they
69
+ are not returned — re-attach them when you resend.
70
+
71
+ ## If the contract is unmet
72
+
73
+ An invalid `key` falls back to `ctrl+alt+x` and warns once per session. If a key
74
+ is bound to a key pi already uses (such as `escape` or `ctrl+x`), pi skips the
75
+ extension's shortcut, so the shortcut never fires — pick a key that does not
76
+ conflict with a built-in, or use the `quick-cancel` command.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-quick-cancel",
3
+ "version": "0.2.0",
4
+ "description": "Cancel a running turn, rewind the chat to before the prompt was sent, and restore the prompt to the editor.",
5
+ "type": "module",
6
+ "main": "./quick-cancel.js",
7
+ "exports": "./quick-cancel.js",
8
+ "files": [
9
+ "quick-cancel.js",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "keywords": [
14
+ "pi-package"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ezoushen/pi-extensions.git",
20
+ "directory": "extensions/quick-cancel"
21
+ },
22
+ "homepage": "https://github.com/ezoushen/pi-extensions/tree/main/extensions/quick-cancel#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/ezoushen/pi-extensions/issues"
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-coding-agent": "*"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./quick-cancel.js"
35
+ ]
36
+ }
37
+ }
@@ -0,0 +1,155 @@
1
+ // shared/announce.ts
2
+ var stderrAnnounced = /* @__PURE__ */ new Set();
3
+ function announce(ctx, message, level, reason = message) {
4
+ if (ctx?.hasUI === false) {
5
+ if (stderrAnnounced.has(reason)) return;
6
+ stderrAnnounced.add(reason);
7
+ try {
8
+ process.stderr.write(`${message}
9
+ `);
10
+ } catch {
11
+ }
12
+ return;
13
+ }
14
+ try {
15
+ ctx?.ui?.notify?.(message, level);
16
+ } catch {
17
+ }
18
+ }
19
+
20
+ // shared/settings.ts
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
24
+ var announcedConfigErrors = /* @__PURE__ */ new Set();
25
+ function readConfig(path, ctx) {
26
+ if (!existsSync(path)) return {};
27
+ try {
28
+ return JSON.parse(readFileSync(path, "utf8"));
29
+ } catch (error) {
30
+ if (!announcedConfigErrors.has(path)) {
31
+ announcedConfigErrors.add(path);
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ announce(ctx, `settings: could not parse ${path} (${message}); using defaults.`, "warning", `settings-parse:${path}`);
34
+ }
35
+ return {};
36
+ }
37
+ }
38
+ function resolveSettings(name, definitions, context, runtime = {}) {
39
+ const environment = runtime.environment ?? process.env;
40
+ const globalPath = join(runtime.agentDir ?? getAgentDir(), `${name}.json`);
41
+ const projectPath = join(context.cwd, CONFIG_DIR_NAME, `${name}.json`);
42
+ const globalConfig = readConfig(globalPath, context);
43
+ const projectConfig = context.isProjectTrusted() ? readConfig(projectPath, context) : {};
44
+ const resolved = {};
45
+ for (const key of Object.keys(definitions)) {
46
+ const definition = definitions[key];
47
+ let value = definition.default;
48
+ let provenance = { source: "default" };
49
+ if (definition.discover) {
50
+ try {
51
+ const discovered = definition.discover();
52
+ if (discovered !== void 0) {
53
+ value = discovered.value;
54
+ provenance = { source: "discovered", name: definition.discoverName ?? "discovery" };
55
+ }
56
+ } catch {
57
+ }
58
+ }
59
+ if (Object.hasOwn(globalConfig, key)) {
60
+ value = globalConfig[key];
61
+ provenance = { source: "global", path: globalPath };
62
+ }
63
+ if (Object.hasOwn(projectConfig, key)) {
64
+ value = projectConfig[key];
65
+ provenance = { source: "project", path: projectPath };
66
+ }
67
+ const environmentValue = environment[definition.env];
68
+ if (environmentValue !== void 0) {
69
+ value = definition.parseEnv ? definition.parseEnv(environmentValue) : environmentValue;
70
+ provenance = { source: "environment", name: definition.env };
71
+ }
72
+ resolved[key] = { value, provenance };
73
+ }
74
+ return resolved;
75
+ }
76
+
77
+ // extensions/quick-cancel/quick-cancel.ts
78
+ var DEFAULT_KEY = "ctrl+alt+x";
79
+ var KEY_SETTING = { key: { default: DEFAULT_KEY, env: "PI_QUICK_CANCEL_KEY" } };
80
+ function isShortcutKey(value) {
81
+ if (typeof value !== "string") return false;
82
+ const parts = value.split("+");
83
+ const base = parts.pop();
84
+ return parts.length > 0 && new Set(parts).size === parts.length && parts.every((part) => ["ctrl", "alt", "shift", "super"].includes(part)) && base !== void 0 && (/^[a-z0-9]$/.test(base) || ["enter", "escape", "tab", "space", "backspace", "delete", "up", "down", "left", "right", "home", "end"].includes(base));
85
+ }
86
+ function contentText(content) {
87
+ if (typeof content === "string") return content;
88
+ if (Array.isArray(content)) {
89
+ return content.map((part) => part && typeof part === "object" && "text" in part ? String(part.text) : "").join("");
90
+ }
91
+ return "";
92
+ }
93
+ function findPromptEntryId(sm, prompt) {
94
+ const entries = sm.getEntries();
95
+ for (let i = entries.length - 1; i >= 0; i--) {
96
+ const entry = entries[i];
97
+ if (entry.type !== "message" || entry.message?.role !== "user") continue;
98
+ if (prompt === null || contentText(entry.message.content).trim() === prompt.trim()) return entry.id;
99
+ }
100
+ return void 0;
101
+ }
102
+ function registerQuickCancel(pi, settingsRuntime = {}) {
103
+ const resolved = resolveSettings("pi-quick-cancel", KEY_SETTING, {
104
+ cwd: process.cwd(),
105
+ hasUI: true,
106
+ isProjectTrusted: () => false
107
+ }, settingsRuntime);
108
+ const configuredKey = resolved.key.value;
109
+ const validKey = isShortcutKey(configuredKey);
110
+ const key = validKey ? configuredKey : DEFAULT_KEY;
111
+ let warnedAboutKey = false;
112
+ let captured = null;
113
+ pi.on("session_start", (_event, ctx) => {
114
+ if (!validKey && !warnedAboutKey) {
115
+ announce(ctx, "pi-quick-cancel: invalid key setting; using ctrl+alt+x", "warning", "pi-quick-cancel:invalid-key");
116
+ warnedAboutKey = true;
117
+ }
118
+ });
119
+ pi.on("before_agent_start", (event) => {
120
+ captured = event.prompt;
121
+ });
122
+ pi.on("agent_end", () => {
123
+ captured = null;
124
+ });
125
+ const rewind = async (ctx) => {
126
+ const prompt = captured;
127
+ if (!ctx.isIdle()) {
128
+ ctx.abort();
129
+ await ctx.waitForIdle();
130
+ }
131
+ const sm = ctx.sessionManager;
132
+ const entryId = findPromptEntryId(sm, prompt);
133
+ if (!entryId) {
134
+ announce(ctx, "pi-quick-cancel: nothing to rewind", "info");
135
+ return;
136
+ }
137
+ const result = await ctx.navigateTree(entryId);
138
+ if (result.cancelled) return;
139
+ const entry = sm.getEntry(entryId);
140
+ const promptText = prompt ?? (entry ? contentText(entry.message?.content) : "");
141
+ if (promptText && !ctx.ui?.getEditorText?.()) ctx.ui?.setEditorText?.(promptText);
142
+ announce(ctx, "pi-quick-cancel: rewound to before the prompt", "info");
143
+ };
144
+ pi.registerCommand("quick-cancel", {
145
+ description: "Rewind the chat to before the last prompt and return the prompt to the editor",
146
+ handler: (_args, ctx) => rewind(ctx)
147
+ });
148
+ pi.registerShortcut(key, {
149
+ description: "Rewind the chat to before the prompt (cancels the run)",
150
+ handler: () => pi.sendUserMessage("/quick-cancel", { expandPromptTemplates: true })
151
+ });
152
+ }
153
+ export {
154
+ registerQuickCancel as default
155
+ };