@pi-archimedes/notify 2.10.4 → 2.11.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/README.md +2 -2
- package/package.json +5 -5
- package/src/cancel-pending.test.ts +306 -0
- package/src/index.ts +77 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Step away without losing track.**
|
|
4
4
|
|
|
5
|
-
Leave the terminal for a coffee. When the work has actually settled — or something needs your decision — notify tells you, once, after a short delay.
|
|
5
|
+
Leave the terminal for a coffee. When the work has actually settled — or something needs your decision — notify tells you, once, after a short delay. Press a key and the pending alert is cancelled; there's no inactivity scrutiny, no focus tracking. Keystrokes are the only signal — a terminal answering Pi's own questions (colour palette, device attributes, cell size) is not one.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -31,7 +31,7 @@ After installing Pi, choose one installation command above, then `cd` into your
|
|
|
31
31
|
- **Settled runs** — on Pi's `agent_settled` event: the run has fully settled (no automatic retry, compaction, or queued continuation still to fire), not merely "a turn ended".
|
|
32
32
|
- **Blocking prompts** — on `ui_prompt_start`: any extension prompt is waiting on you (a tabbed ask, a sudo password or command-confirm prompt) — direct **or** subagent-relayed, since the event fires in the parent process.
|
|
33
33
|
|
|
34
|
-
The alert fires after a fixed `delayMs` (30 s by default) from the trigger. It is a delay, not an idle timer: nothing measures how long you've been reading.
|
|
34
|
+
The alert fires after a fixed `delayMs` (30 s by default) from the trigger. It is a delay, not an idle timer: nothing measures how long you've been reading. A keystroke in the terminal cancels a pending alert immediately — and only a keystroke: pi-tui's decoder decides, so protocol traffic that reaches the listener (a device-attributes reply, a cell-size report, focus and mouse sequences) cannot silently swallow an alert nobody dismissed. Anything you actually typed counts, including the characters the decoder has no name for — an accented key or an IME commit on a terminal that doesn't speak the Kitty keyboard protocol. A new agent run cancels pending alerts, whichever kind of run start it was, and a prompt that closes without you typing (say, a confirm dialog that times out and auto-declines) cancels its own timer — so a long-gone question can't ring.
|
|
35
35
|
|
|
36
36
|
## Terminal delivery
|
|
37
37
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/notify",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
".": "./src/index.ts"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@pi-archimedes/core": "2.
|
|
21
|
+
"@pi-archimedes/core": "2.11.1"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"@earendil-works/pi-coding-agent": ">=0.84.4",
|
|
25
|
-
"@earendil-works/pi-tui": ">=0.
|
|
25
|
+
"@earendil-works/pi-tui": ">=0.84.4"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@earendil-works/pi-coding-agent": "
|
|
29
|
-
"@earendil-works/pi-tui": "
|
|
28
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
29
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
30
30
|
"typescript": "^6.0.0"
|
|
31
31
|
},
|
|
32
32
|
"pi": {
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── What this pins ──────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Two cancel paths, both of which pi 1.x changed underneath us:
|
|
6
|
+
//
|
|
7
|
+
// 1. `before_agent_start`. pi 1.2.0 made the event a union on `messageType`
|
|
8
|
+
// ("user" for a submitted prompt, "custom" for `pi.sendMessage(…,
|
|
9
|
+
// { triggerTurn: true })` fired while idle) and fires it for both. A run
|
|
10
|
+
// that is *starting* makes a pending "Task complete — waiting for input"
|
|
11
|
+
// stale either way, so the handler stays unconditional and this file pins
|
|
12
|
+
// both members of the union.
|
|
13
|
+
//
|
|
14
|
+
// 2. Terminal input. The cancel used to be "any byte that reaches the
|
|
15
|
+
// listener". pi now puts far more protocol traffic through the terminal —
|
|
16
|
+
// and `TuiBase.handleTerminalInput()` (pi-tui `dist/tui.js`) forwards some
|
|
17
|
+
// of it to extension listeners and swallows the rest:
|
|
18
|
+
// - OSC 10/11/4 colour replies and the colour-scheme report are consumed
|
|
19
|
+
// *before* listeners run, so a palette reply can never reach notify;
|
|
20
|
+
// - a primary-device-attributes reply (`ESC [ ? … c`) is consumed before
|
|
21
|
+
// listeners only while a colour query is pending, so a late or
|
|
22
|
+
// unsolicited one *is* forwarded;
|
|
23
|
+
// - a cell-size report (`ESC [ 6 ; h ; w t`) is consumed only *after*
|
|
24
|
+
// listeners, so it always reaches them.
|
|
25
|
+
// So a reply is not a keystroke: only input pi-tui decodes to a key
|
|
26
|
+
// (`parseKey`) cancels.
|
|
27
|
+
|
|
28
|
+
vi.mock("@pi-archimedes/core/settings-io", () => ({
|
|
29
|
+
loadConfig: vi.fn(),
|
|
30
|
+
saveConfig: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import { loadConfig } from "@pi-archimedes/core/settings-io";
|
|
34
|
+
import { DEFAULT_NOTIFY_CONFIG, registerNotify } from "./index.js";
|
|
35
|
+
|
|
36
|
+
const DELAY_MS = 1_000;
|
|
37
|
+
|
|
38
|
+
/** The OSC 777 fallback payload `notify()` writes for each trigger. */
|
|
39
|
+
const TASK_COMPLETE_OSC = "\x1b]777;notify;Pi;Task complete — waiting for input\x07";
|
|
40
|
+
const QUESTION_OSC = "\x1b]777;notify;Pi;A question needs your answer\x07";
|
|
41
|
+
|
|
42
|
+
interface Harness {
|
|
43
|
+
/** Fire a pi event at the handlers `registerNotify` subscribed. */
|
|
44
|
+
emit(event: string, payload?: unknown): void;
|
|
45
|
+
/** Feed one chunk of raw terminal input to the registered listener. */
|
|
46
|
+
terminalInput(data: string): void;
|
|
47
|
+
/** Notification payloads written to stderr so far. */
|
|
48
|
+
written(): string[];
|
|
49
|
+
/** Let the pending notification's delay elapse (and then some). */
|
|
50
|
+
waitPastDelay(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setup(): Harness {
|
|
54
|
+
vi.mocked(loadConfig).mockReturnValue({ ...DEFAULT_NOTIFY_CONFIG, delayMs: DELAY_MS });
|
|
55
|
+
// Fall through to the OSC 777 dispatcher: no terminal-specific branch.
|
|
56
|
+
vi.stubEnv("WT_SESSION", "");
|
|
57
|
+
vi.stubEnv("KITTY_WINDOW_ID", "");
|
|
58
|
+
vi.stubEnv("TERM_PROGRAM", "");
|
|
59
|
+
vi.stubEnv("ITERM_SESSION_ID", "");
|
|
60
|
+
vi.stubEnv("TMUX", "");
|
|
61
|
+
const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
62
|
+
|
|
63
|
+
const handlers: Record<string, Array<(payload: unknown, ctx: unknown) => void>> = {};
|
|
64
|
+
let terminalListener: ((data: string) => unknown) | undefined;
|
|
65
|
+
|
|
66
|
+
const pi = {
|
|
67
|
+
on: vi.fn((event: string, handler: (payload: unknown, ctx: unknown) => void) => {
|
|
68
|
+
(handlers[event] ??= []).push(handler);
|
|
69
|
+
}),
|
|
70
|
+
} as unknown as Parameters<typeof registerNotify>[0];
|
|
71
|
+
|
|
72
|
+
registerNotify(pi);
|
|
73
|
+
|
|
74
|
+
const ctx = {
|
|
75
|
+
hasUI: true,
|
|
76
|
+
ui: {
|
|
77
|
+
onTerminalInput: vi.fn((handler: (data: string) => unknown) => {
|
|
78
|
+
terminalListener = handler;
|
|
79
|
+
return () => {
|
|
80
|
+
terminalListener = undefined;
|
|
81
|
+
};
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
for (const handler of handlers["session_start"] ?? []) handler(undefined, ctx);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
emit(event, payload) {
|
|
89
|
+
for (const handler of handlers[event] ?? []) handler(payload ?? { type: event }, ctx);
|
|
90
|
+
},
|
|
91
|
+
terminalInput(data) {
|
|
92
|
+
terminalListener?.(data);
|
|
93
|
+
},
|
|
94
|
+
written() {
|
|
95
|
+
return write.mock.calls.map((call) => String(call[0]));
|
|
96
|
+
},
|
|
97
|
+
waitPastDelay() {
|
|
98
|
+
vi.advanceTimersByTime(DELAY_MS * 2);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
vi.useRealTimers();
|
|
109
|
+
vi.restoreAllMocks();
|
|
110
|
+
vi.unstubAllEnvs();
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("notify cancel-on-input under pi 1.x terminal traffic", () => {
|
|
115
|
+
it("does not cancel on a forwarded primary-device-attributes reply", () => {
|
|
116
|
+
const harness = setup();
|
|
117
|
+
|
|
118
|
+
harness.emit("agent_settled");
|
|
119
|
+
// The DA1 reply shape pi-tui forwards to extension listeners.
|
|
120
|
+
harness.terminalInput("\x1b[?62;1;9c");
|
|
121
|
+
harness.waitPastDelay();
|
|
122
|
+
|
|
123
|
+
expect(harness.written()).toContain(TASK_COMPLETE_OSC);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not cancel on a cell-size report", () => {
|
|
127
|
+
const harness = setup();
|
|
128
|
+
|
|
129
|
+
harness.emit("agent_settled");
|
|
130
|
+
// ESC [ 6 ; height ; width t — consumed by pi-tui only after listeners.
|
|
131
|
+
harness.terminalInput("\x1b[6;24;80t");
|
|
132
|
+
harness.waitPastDelay();
|
|
133
|
+
|
|
134
|
+
expect(harness.written()).toContain(TASK_COMPLETE_OSC);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("does not cancel on a colour reply or a colour-scheme report", () => {
|
|
138
|
+
const harness = setup();
|
|
139
|
+
|
|
140
|
+
harness.emit("agent_settled");
|
|
141
|
+
harness.terminalInput("\x1b]10;rgb:ffff/ffff/ffff\x07");
|
|
142
|
+
harness.terminalInput("\x1b]11;rgb:0000/0000/0000\x1b\\");
|
|
143
|
+
harness.terminalInput("\x1b]4;1;rgb:ff00/0000/0000\x1b\\");
|
|
144
|
+
harness.terminalInput("\x1b[?u"); // the Kitty keyboard-protocol query reply
|
|
145
|
+
harness.waitPastDelay();
|
|
146
|
+
|
|
147
|
+
expect(harness.written()).toContain(TASK_COMPLETE_OSC);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does not cancel on focus, mouse or bracketed-paste traffic", () => {
|
|
151
|
+
const harness = setup();
|
|
152
|
+
|
|
153
|
+
harness.emit("agent_settled");
|
|
154
|
+
// None of these is typing, and pi-tui passes all of them to listeners: focus
|
|
155
|
+
// in/out, an SGR mouse press, and the bracketed-paste marker.
|
|
156
|
+
harness.terminalInput("\x1b[I");
|
|
157
|
+
harness.terminalInput("\x1b[O");
|
|
158
|
+
harness.terminalInput("\x1b[<0;10;10M");
|
|
159
|
+
harness.terminalInput("\x1b[200~");
|
|
160
|
+
harness.waitPastDelay();
|
|
161
|
+
|
|
162
|
+
expect(harness.written()).toContain(TASK_COMPLETE_OSC);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("cancels on a plain keystroke", () => {
|
|
166
|
+
const harness = setup();
|
|
167
|
+
|
|
168
|
+
harness.emit("agent_settled");
|
|
169
|
+
harness.terminalInput("a");
|
|
170
|
+
harness.waitPastDelay();
|
|
171
|
+
|
|
172
|
+
expect(harness.written()).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// What `parseKey()` names is not the whole set of keystrokes. pi-tui's
|
|
176
|
+
// `StdinBuffer` splits batched input into single sequences before listeners
|
|
177
|
+
// run, so a typed character reaches us on its own — but the decoder only
|
|
178
|
+
// resolves ASCII bytes and escape sequences. A character outside ASCII
|
|
179
|
+
// arrives as itself (`é` is the two bytes C3 A9 on any terminal that did not
|
|
180
|
+
// take the Kitty keyboard protocol, which is Terminal.app, VTE, xterm and
|
|
181
|
+
// tmux), `parseKey()` has no name for it, and the keystroke would be missed:
|
|
182
|
+
// the alert fires at someone sitting at the keyboard, typing an accented
|
|
183
|
+
// letter. Under Kitty the same key arrives as CSI-u and does decode, so this
|
|
184
|
+
// is exactly the legacy-terminal path.
|
|
185
|
+
it("cancels on a typed character pi-tui's decoder does not name", () => {
|
|
186
|
+
const harness = setup();
|
|
187
|
+
|
|
188
|
+
harness.emit("agent_settled");
|
|
189
|
+
harness.terminalInput("é"); // one key press, no Kitty protocol
|
|
190
|
+
harness.waitPastDelay();
|
|
191
|
+
|
|
192
|
+
expect(harness.written()).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("cancels on a typed CJK character", () => {
|
|
196
|
+
const harness = setup();
|
|
197
|
+
|
|
198
|
+
harness.emit("agent_settled");
|
|
199
|
+
harness.terminalInput("中");
|
|
200
|
+
harness.waitPastDelay();
|
|
201
|
+
|
|
202
|
+
expect(harness.written()).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does not cancel on the ASCII tail of a reply split across reads", () => {
|
|
206
|
+
const harness = setup();
|
|
207
|
+
|
|
208
|
+
// A reply whose ESC header pi-tui consumed can leave a bare tail (digits
|
|
209
|
+
// and semicolons) to be flushed as input. It is printable ASCII with no
|
|
210
|
+
// escape byte, so "is it text?" is the wrong question to ask — only a
|
|
211
|
+
// character that could only have come from a key press cancels.
|
|
212
|
+
harness.emit("agent_settled");
|
|
213
|
+
harness.terminalInput("62;1;9c");
|
|
214
|
+
harness.waitPastDelay();
|
|
215
|
+
|
|
216
|
+
expect(harness.written()).toContain(TASK_COMPLETE_OSC);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("cancels on a Kitty-protocol key press and on its release", () => {
|
|
220
|
+
const harness = setup();
|
|
221
|
+
|
|
222
|
+
harness.emit("agent_settled");
|
|
223
|
+
harness.terminalInput("\x1b[97:1u"); // CSI-u press for 'a'
|
|
224
|
+
harness.waitPastDelay();
|
|
225
|
+
expect(harness.written()).toEqual([]);
|
|
226
|
+
|
|
227
|
+
harness.emit("agent_settled");
|
|
228
|
+
harness.terminalInput("\x1b[97:3u"); // the matching release
|
|
229
|
+
harness.waitPastDelay();
|
|
230
|
+
expect(harness.written()).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("fires when the terminal stays quiet", () => {
|
|
234
|
+
const harness = setup();
|
|
235
|
+
|
|
236
|
+
harness.emit("agent_settled");
|
|
237
|
+
harness.waitPastDelay();
|
|
238
|
+
|
|
239
|
+
expect(harness.written()).toEqual([TASK_COMPLETE_OSC]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("notify cancel-on-run-start (pi 1.2.0 before_agent_start union)", () => {
|
|
244
|
+
const base = {
|
|
245
|
+
type: "before_agent_start",
|
|
246
|
+
systemPrompt: "prompt",
|
|
247
|
+
systemPromptOptions: {},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
it("cancels a pending notification for a user-prompt run", () => {
|
|
251
|
+
const harness = setup();
|
|
252
|
+
|
|
253
|
+
harness.emit("agent_settled");
|
|
254
|
+
harness.emit("before_agent_start", { ...base, messageType: "user", prompt: "hi" });
|
|
255
|
+
harness.waitPastDelay();
|
|
256
|
+
|
|
257
|
+
expect(harness.written()).toEqual([]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("cancels a pending notification for an idle custom message with triggerTurn", () => {
|
|
261
|
+
const harness = setup();
|
|
262
|
+
|
|
263
|
+
harness.emit("agent_settled");
|
|
264
|
+
harness.emit("before_agent_start", {
|
|
265
|
+
...base,
|
|
266
|
+
messageType: "custom",
|
|
267
|
+
message: { role: "custom", customType: "note", content: "hello", display: true },
|
|
268
|
+
});
|
|
269
|
+
harness.waitPastDelay();
|
|
270
|
+
|
|
271
|
+
expect(harness.written()).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("cancels on agent_start as before", () => {
|
|
275
|
+
const harness = setup();
|
|
276
|
+
|
|
277
|
+
harness.emit("agent_settled");
|
|
278
|
+
harness.emit("agent_start");
|
|
279
|
+
harness.waitPastDelay();
|
|
280
|
+
|
|
281
|
+
expect(harness.written()).toEqual([]);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("notify ui_prompt_end scoping (ADR 0013)", () => {
|
|
286
|
+
it("cancels a pending question notification when the prompt closes", () => {
|
|
287
|
+
const harness = setup();
|
|
288
|
+
|
|
289
|
+
harness.emit("ui_prompt_start");
|
|
290
|
+
harness.emit("ui_prompt_end");
|
|
291
|
+
harness.waitPastDelay();
|
|
292
|
+
|
|
293
|
+
expect(harness.written()).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("leaves a pending task-complete notification alone", () => {
|
|
297
|
+
const harness = setup();
|
|
298
|
+
|
|
299
|
+
harness.emit("agent_settled");
|
|
300
|
+
harness.emit("ui_prompt_end");
|
|
301
|
+
harness.waitPastDelay();
|
|
302
|
+
|
|
303
|
+
expect(harness.written()).toEqual([TASK_COMPLETE_OSC]);
|
|
304
|
+
expect(harness.written()).not.toContain(QUESTION_OSC);
|
|
305
|
+
});
|
|
306
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { parseKey } from "@earendil-works/pi-tui";
|
|
3
4
|
import { loadConfig, saveConfig } from "@pi-archimedes/core/settings-io";
|
|
4
5
|
import { execFile } from "node:child_process";
|
|
5
6
|
|
|
6
7
|
// ── Trigger constants ────────────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// Relation to pi's own OSC 7501 program status (pi >= 1.1.0):
|
|
10
|
+
// pi's ProgramStatusReporter already reports this session's state to the
|
|
11
|
+
// terminal — `done` when a run settles and `blocked` when a dialog opens
|
|
12
|
+
// (`pi-coding-agent/dist/modes/interactive/program-status-reporter.js`,
|
|
13
|
+
// encoded by pi-tui's `formatProgramStatus`). The two triggers below map onto
|
|
14
|
+
// it one-for-one: AGENT_SETTLED <-> `done`, UI_PROMPT <-> `blocked`. We emit
|
|
15
|
+
// nothing 7501-related; what this package adds on top of that report is the
|
|
16
|
+
// DELAYED desktop notification (OSC 777/9/99, Windows toast, tmux DCS wrap)
|
|
17
|
+
// and the cancellation rules below — pi reports state changes immediately and
|
|
18
|
+
// to the terminal only. Full comparison: docs/features/notify.md.
|
|
7
19
|
|
|
8
20
|
const TRIGGER = {
|
|
9
21
|
AGENT_SETTLED: "agent_settled",
|
|
@@ -39,6 +51,58 @@ export function saveNotifyConfig(config: NotifyConfig): void {
|
|
|
39
51
|
|
|
40
52
|
// ── Terminal detection helpers ──────────────────────────────────────────────
|
|
41
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Did the user press a key, or is this the terminal answering a question?
|
|
56
|
+
*
|
|
57
|
+
* pi-tui decodes input for us, and the decode is the test: `parseKey()` returns
|
|
58
|
+
* a key id for a keystroke (legacy byte, escape sequence, Kitty CSI-u press,
|
|
59
|
+
* repeat **or** release) and `undefined` for everything else. Cancel on the
|
|
60
|
+
* keystrokes only.
|
|
61
|
+
*
|
|
62
|
+
* The rule used to be "any byte that reaches the listener", which pi 1.x makes
|
|
63
|
+
* wrong, because far more protocol traffic goes through the terminal now and
|
|
64
|
+
* `TuiBase.handleTerminalInput()` (pi-tui `dist/tui.js:687-712`) forwards some
|
|
65
|
+
* of it to extension listeners and swallows the rest:
|
|
66
|
+
*
|
|
67
|
+
* - The colour-scheme report is consumed before listeners, unconditionally. The
|
|
68
|
+
* OSC 10/11/4 colour replies go through the same pending-query branch as the
|
|
69
|
+
* device-attributes reply below, so during pi's palette query — at startup and
|
|
70
|
+
* on every theme switch, when that branch is armed — they are swallowed there;
|
|
71
|
+
* a reply that turns up with no query outstanding is forwarded like any other
|
|
72
|
+
* byte.
|
|
73
|
+
* - A primary-device-attributes reply (`ESC [ ? … c`) is swallowed only while a
|
|
74
|
+
* colour query is pending (`tui.js`, `pendingTerminalColorQueries`), so a
|
|
75
|
+
* late or unsolicited one — the shape is `\x1b[?62;1;9c` — **is** forwarded.
|
|
76
|
+
* - A cell-size report (`ESC [ 6 ; height ; width t`) is consumed only *after*
|
|
77
|
+
* listeners, so it always reaches them.
|
|
78
|
+
* - Focus in/out, mouse reporting, bracketed-paste markers and the Kitty
|
|
79
|
+
* protocol query reply reach listeners too, and none of them is typing.
|
|
80
|
+
*
|
|
81
|
+
* So a reply used to be able to cancel an alert nobody asked for. A mouse event
|
|
82
|
+
* no longer counts either — notify's contract is keystrokes (see README), and
|
|
83
|
+
* `parseKey()` is what says whether one happened.
|
|
84
|
+
*
|
|
85
|
+
* The decoder is the test, but not the whole test: it names ASCII bytes and
|
|
86
|
+
* escape sequences, and nothing else. A character outside ASCII is delivered as
|
|
87
|
+
* itself — `é` is the two bytes C3 A9 on every terminal that did not take the
|
|
88
|
+
* Kitty keyboard protocol (Terminal.app, VTE/GNOME Terminal, xterm, tmux) — and
|
|
89
|
+
* `parseKey()` returns `undefined` for it. Treating that as "not a keystroke"
|
|
90
|
+
* would fire an alert at someone standing at the keyboard typing an accented
|
|
91
|
+
* letter, which is the failure this handler exists to prevent, so a printable
|
|
92
|
+
* character with no control byte in it counts as typing too. The test stays
|
|
93
|
+
* narrow on purpose: the `é`-shaped answer cannot mistake protocol traffic for
|
|
94
|
+
* a key press, because every reply pi-tui forwards opens with `ESC`, and the
|
|
95
|
+
* only other printable bytes a listener can see are the *tail* of a reply split
|
|
96
|
+
* across two reads (`62;1;9c`) — printable ASCII, still no key press.
|
|
97
|
+
*/
|
|
98
|
+
const TYPED_NON_ASCII = /[\u00a0-\uffff]/;
|
|
99
|
+
const CONTROL_BYTE = /[\u0000-\u001f\u007f-\u009f]/;
|
|
100
|
+
|
|
101
|
+
function isKeystroke(data: string): boolean {
|
|
102
|
+
if (parseKey(data) !== undefined) return true;
|
|
103
|
+
return TYPED_NON_ASCII.test(data) && !CONTROL_BYTE.test(data);
|
|
104
|
+
}
|
|
105
|
+
|
|
42
106
|
/** Wrap an OSC sequence for tmux passthrough. */
|
|
43
107
|
export function wrapForTmux(sequence: string): string {
|
|
44
108
|
if (!process.env.TMUX) {
|
|
@@ -189,6 +253,13 @@ export function registerNotify(pi: ExtensionAPI): void {
|
|
|
189
253
|
// parent process for direct and subagent-relayed prompts alike.
|
|
190
254
|
pi.on("ui_prompt_start", (_event) => scheduleNotify(TRIGGER.UI_PROMPT));
|
|
191
255
|
pi.on("input", () => cancelPending());
|
|
256
|
+
// Any run that *starts* makes a pending alert stale, so this handler takes no
|
|
257
|
+
// filter. pi 1.2.0 made `before_agent_start` a union on `messageType` —
|
|
258
|
+
// `"user"` for a submitted prompt, RPC prompt or `pi.sendUserMessage()`, and
|
|
259
|
+
// `"custom"` for `pi.sendMessage(…, { triggerTurn: true })` fired while idle,
|
|
260
|
+
// which is new traffic that fires it too — and both members mean the same
|
|
261
|
+
// thing here: work is beginning, so "Task complete — waiting for input" is
|
|
262
|
+
// now a lie, whoever caused the run. Same reason `agent_start` cancels.
|
|
192
263
|
pi.on("before_agent_start", () => cancelPending());
|
|
193
264
|
pi.on("agent_start", () => cancelPending());
|
|
194
265
|
// A prompt that closes without terminal input (e.g. a ctx.ui.confirm
|
|
@@ -201,11 +272,14 @@ export function registerNotify(pi: ExtensionAPI): void {
|
|
|
201
272
|
}
|
|
202
273
|
});
|
|
203
274
|
|
|
204
|
-
// Listen for raw terminal keystrokes — cancel on any key press
|
|
275
|
+
// Listen for raw terminal keystrokes — cancel on any key press, and only on
|
|
276
|
+
// one: terminal protocol traffic is not a keystroke (see `isKeystroke`).
|
|
205
277
|
let unsubTerminalInput: (() => void) | null = null;
|
|
206
278
|
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
207
|
-
unsubTerminalInput = ctx.ui.onTerminalInput((
|
|
208
|
-
|
|
279
|
+
unsubTerminalInput = ctx.ui.onTerminalInput((data) => {
|
|
280
|
+
if (isKeystroke(data)) {
|
|
281
|
+
cancelPending();
|
|
282
|
+
}
|
|
209
283
|
});
|
|
210
284
|
});
|
|
211
285
|
|