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 +19 -0
- package/README.md +76 -0
- package/package.json +37 -0
- package/quick-cancel.js +155 -0
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
|
+
}
|
package/quick-cancel.js
ADDED
|
@@ -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
|
+
};
|