pi-interrupt-steer 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 +21 -0
- package/README.md +72 -0
- package/interrupt-steer.js +184 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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, 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,72 @@
|
|
|
1
|
+
# pi-interrupt-steer
|
|
2
|
+
|
|
3
|
+
Interrupt a running Pi turn and send the editor text as one new user message.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:pi-interrupt-steer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## External contract
|
|
12
|
+
|
|
13
|
+
The default shortcut is `ctrl+alt+enter`. While Pi is streaming, it aborts the
|
|
14
|
+
current operation and waits up to five seconds for Pi to become idle before
|
|
15
|
+
sending the editor text. Pi first restores queued steering messages, then
|
|
16
|
+
queued follow-up messages, and appends the text already in the editor. Each part
|
|
17
|
+
is separated by a blank line. Pi stores steering and follow-up messages in
|
|
18
|
+
separate queues, so their original cross-type typing order is not preserved. The
|
|
19
|
+
extension sends that combined text once, then treats a user `message_start` as
|
|
20
|
+
acceptance only when the message text exactly matches what it sent. For string
|
|
21
|
+
content it compares the string; for content parts it compares their text joined
|
|
22
|
+
together. Other user messages are ignored while it waits up to 60 seconds. It
|
|
23
|
+
clears the editor only if it still contains the submitted text. A prompt rewritten
|
|
24
|
+
by another extension can still be sent, but the original editor text stays and
|
|
25
|
+
the timeout warning appears. While waiting for Pi to become idle or start a
|
|
26
|
+
matching message, repeated shortcut presses show an info notice and do not abort
|
|
27
|
+
or send again. If Pi does not start a matching message within 60 seconds, the text
|
|
28
|
+
stays in the editor and the warning says to check the transcript before sending
|
|
29
|
+
it again; an input handler may have handled the prompt without starting a message.
|
|
30
|
+
|
|
31
|
+
When Pi is idle, the shortcut sends the editor text without aborting. With an
|
|
32
|
+
empty editor and no queued session messages, it leaves the run alone and shows a
|
|
33
|
+
notification. During compaction, the shortcut cannot see Pi's separate
|
|
34
|
+
compaction queue, so an empty-editor press with no session messages visible to
|
|
35
|
+
the shortcut does not interrupt Pi; Pi delivers its compaction queue after
|
|
36
|
+
compaction. For idle sends as well, the extension clears the editor only after a
|
|
37
|
+
matching user `message_start` and only if the editor still contains the submitted
|
|
38
|
+
text.
|
|
39
|
+
|
|
40
|
+
The terminal must send `ctrl+alt+enter` distinctly for this shortcut to fire.
|
|
41
|
+
A terminal without the kitty keyboard protocol may send the legacy `ESC CR`
|
|
42
|
+
sequence instead. Pi interprets that as `alt+enter`: the editor text is queued as
|
|
43
|
+
a follow-up, the current response finishes first, and the run is not interrupted.
|
|
44
|
+
|
|
45
|
+
## Settings
|
|
46
|
+
|
|
47
|
+
Set `key` in `pi-interrupt-steer.json` in the Pi agent settings directory, or use
|
|
48
|
+
`PI_INTERRUPT_STEER_KEY`:
|
|
49
|
+
|
|
50
|
+
| setting | default | environment variable | purpose |
|
|
51
|
+
|---|---|---|---|
|
|
52
|
+
| `key` | `ctrl+alt+enter` | `PI_INTERRUPT_STEER_KEY` | Shortcut with one or more modifiers and a letter, digit, or named key. |
|
|
53
|
+
|
|
54
|
+
For example:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"key": "ctrl+shift+x"
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Valid modifiers are `ctrl`, `alt`, `shift`, and `super`. Valid named keys are
|
|
63
|
+
`enter`, `escape`, `tab`, `space`, `backspace`, `delete`, `up`, `down`, `left`,
|
|
64
|
+
`right`, `home`, and `end`.
|
|
65
|
+
|
|
66
|
+
## If the contract is unmet
|
|
67
|
+
|
|
68
|
+
An invalid key uses `ctrl+alt+enter` and shows one warning. If Pi does not emit
|
|
69
|
+
a matching user `message_start` within 60 seconds after the send, the text remains
|
|
70
|
+
in the editor and the extension warns you to check the transcript before sending
|
|
71
|
+
it again. If the editor changes while Pi is processing the message, the extension
|
|
72
|
+
leaves the current text there when it differs from the submitted text.
|
|
@@ -0,0 +1,184 @@
|
|
|
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/interrupt-steer/interrupt-steer.ts
|
|
78
|
+
var DEFAULT_KEY = "ctrl+alt+enter";
|
|
79
|
+
var KEY_SETTING = { key: { default: DEFAULT_KEY, env: "PI_INTERRUPT_STEER_KEY" } };
|
|
80
|
+
var IDLE_WAIT_TIMEOUT_MS = 5e3;
|
|
81
|
+
var MESSAGE_ACCEPT_TIMEOUT_MS = 6e4;
|
|
82
|
+
var IDLE_POLL_INTERVAL_MS = 25;
|
|
83
|
+
async function waitUntilIdle(ctx) {
|
|
84
|
+
const deadline = Date.now() + IDLE_WAIT_TIMEOUT_MS;
|
|
85
|
+
while (!ctx.isIdle()) {
|
|
86
|
+
const remaining = deadline - Date.now();
|
|
87
|
+
if (remaining <= 0) return false;
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(IDLE_POLL_INTERVAL_MS, remaining)));
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
function isShortcutKey(value) {
|
|
93
|
+
if (typeof value !== "string") return false;
|
|
94
|
+
const parts = value.split("+");
|
|
95
|
+
const base = parts.pop();
|
|
96
|
+
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));
|
|
97
|
+
}
|
|
98
|
+
function waitForAcceptedUserMessage(pi, text) {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
let timeout;
|
|
101
|
+
let unsubscribe;
|
|
102
|
+
let settled = false;
|
|
103
|
+
const finish = (accepted) => {
|
|
104
|
+
if (settled) return;
|
|
105
|
+
settled = true;
|
|
106
|
+
if (timeout !== void 0) clearTimeout(timeout);
|
|
107
|
+
unsubscribe?.();
|
|
108
|
+
resolve(accepted);
|
|
109
|
+
};
|
|
110
|
+
unsubscribe = pi.on("message_start", (event) => {
|
|
111
|
+
const content = event.message.content;
|
|
112
|
+
const messageText = typeof content === "string" ? content : content.filter((part) => part.type === "text").map((part) => part.text).join("");
|
|
113
|
+
if (event.message.role === "user" && messageText === text) finish(true);
|
|
114
|
+
});
|
|
115
|
+
timeout = setTimeout(() => finish(false), MESSAGE_ACCEPT_TIMEOUT_MS);
|
|
116
|
+
try {
|
|
117
|
+
pi.sendUserMessage(text);
|
|
118
|
+
} catch {
|
|
119
|
+
finish(false);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async function sendEditorText(pi, ctx, text) {
|
|
124
|
+
if (!text) {
|
|
125
|
+
announce(ctx, "pi-interrupt-steer: nothing to send", "info");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!await waitForAcceptedUserMessage(pi, text)) {
|
|
129
|
+
announce(ctx, "pi-interrupt-steer: Pi has not started the message yet; text was kept. Check the transcript before sending it again.", "warning");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (ctx.ui.getEditorText() === text) ctx.ui.setEditorText("");
|
|
133
|
+
}
|
|
134
|
+
function registerInterruptSteer(pi, settingsRuntime = {}) {
|
|
135
|
+
const resolved = resolveSettings("pi-interrupt-steer", KEY_SETTING, {
|
|
136
|
+
cwd: process.cwd(),
|
|
137
|
+
hasUI: true,
|
|
138
|
+
isProjectTrusted: () => false
|
|
139
|
+
}, settingsRuntime);
|
|
140
|
+
const configuredKey = resolved.key.value;
|
|
141
|
+
const validKey = isShortcutKey(configuredKey);
|
|
142
|
+
const key = validKey ? configuredKey : DEFAULT_KEY;
|
|
143
|
+
let warnedAboutKey = false;
|
|
144
|
+
let inFlight = false;
|
|
145
|
+
pi.on("session_start", (_event, ctx) => {
|
|
146
|
+
if (!validKey && !warnedAboutKey) {
|
|
147
|
+
announce(ctx, "pi-interrupt-steer: invalid key setting; using ctrl+alt+enter", "warning", "pi-interrupt-steer:invalid-key");
|
|
148
|
+
warnedAboutKey = true;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
pi.registerShortcut(key, {
|
|
152
|
+
description: "Interrupt the current run and send the editor text",
|
|
153
|
+
handler: async (ctx) => {
|
|
154
|
+
if (inFlight) {
|
|
155
|
+
announce(ctx, "pi-interrupt-steer: already waiting for Pi; this press did not send again", "info");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
inFlight = true;
|
|
159
|
+
try {
|
|
160
|
+
const idle = ctx.isIdle();
|
|
161
|
+
const text = ctx.ui.getEditorText();
|
|
162
|
+
if (idle) {
|
|
163
|
+
await sendEditorText(pi, ctx, text);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!text && !ctx.hasPendingMessages()) {
|
|
167
|
+
announce(ctx, "pi-interrupt-steer: nothing to send", "info");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
ctx.abort();
|
|
171
|
+
if (!await waitUntilIdle(ctx)) {
|
|
172
|
+
announce(ctx, "pi-interrupt-steer: agent did not become idle within 5 seconds; text left in the editor", "warning");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
await sendEditorText(pi, ctx, ctx.ui.getEditorText());
|
|
176
|
+
} finally {
|
|
177
|
+
inFlight = false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
export {
|
|
183
|
+
registerInterruptSteer as default
|
|
184
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-interrupt-steer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interrupt a Pi run and send the editor text as the next prompt.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./interrupt-steer.js",
|
|
7
|
+
"exports": "./interrupt-steer.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"interrupt-steer.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/interrupt-steer"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/ezoushen/pi-extensions/tree/main/extensions/interrupt-steer#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
|
+
"./interrupt-steer.js"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|