pi-superwhisper-paste 0.1.0 → 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/CHANGELOG.md +13 -1
- package/docs/release.md +10 -3
- package/extensions/index.ts +209 -74
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,10 +4,22 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
This project follows semantic versioning.
|
|
6
6
|
|
|
7
|
+
## [0.1.1] - 2026-05-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Add automatic GitHub release creation for version bumps on `main`.
|
|
12
|
+
- Add manual and release-published npm publish entrypoints for Trusted Publishing.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Align CI and publish workflows with the maintained Pi OSS release pattern.
|
|
17
|
+
- Skip npm publish when the exact package version is already published.
|
|
18
|
+
|
|
7
19
|
## [0.1.0] - YYYY-MM-DD
|
|
8
20
|
|
|
9
21
|
### Added
|
|
10
22
|
|
|
11
23
|
- Initial Superwhisper paste bridge extension for Pi on Windows.
|
|
12
24
|
- Default-on clipboard watcher with `/sw-paste:on` and `/sw-paste:off` controls.
|
|
13
|
-
- CI and npm Trusted Publishing workflow from the Pi extension template.
|
|
25
|
+
- CI and npm Trusted Publishing workflow from the Pi extension template.
|
package/docs/release.md
CHANGED
|
@@ -16,15 +16,22 @@ On npmjs.com, configure Trusted Publishing for this package:
|
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
npm version patch
|
|
19
|
-
git push
|
|
19
|
+
git push
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
On `main`, `.github/workflows/auto-release.yml` checks `package.json` version. If `v<version>` does not exist yet, it creates the tag, creates the GitHub Release, then explicitly dispatches `.github/workflows/publish.yml` for that tag.
|
|
23
|
+
|
|
24
|
+
The `v*.*.*` tag also triggers `.github/workflows/publish.yml`, which runs CI and publishes to npm when tags are pushed manually.
|
|
25
|
+
Publishing also runs when a GitHub Release is published, and can be run manually from GitHub Actions with `workflow_dispatch`.
|
|
26
|
+
|
|
27
|
+
The workflow skips `name@version` if that exact package version already exists on npm.
|
|
23
28
|
|
|
24
29
|
## GitHub Actions requirements
|
|
25
30
|
|
|
26
31
|
- `permissions: id-token: write`
|
|
32
|
+
- `permissions: actions: write` on auto-release so it can dispatch `publish.yml`
|
|
27
33
|
- GitHub-hosted runner
|
|
34
|
+
- Node.js 24, so the release job uses a current npm CLI for Trusted Publishing
|
|
28
35
|
- No `NPM_TOKEN`
|
|
29
36
|
- `npm publish` from the configured workflow file
|
|
30
37
|
|
|
@@ -35,4 +42,4 @@ The `v*` tag triggers `.github/workflows/publish.yml`, which runs CI and publish
|
|
|
35
42
|
- [ ] npm Trusted Publisher is configured
|
|
36
43
|
- [ ] `npm run ci` passes
|
|
37
44
|
- [ ] `npm pack --dry-run` contains only intended files
|
|
38
|
-
- [ ] `CHANGELOG.md` has the release date
|
|
45
|
+
- [ ] `CHANGELOG.md` has the release date
|
package/extensions/index.ts
CHANGED
|
@@ -34,23 +34,28 @@ type PiRuntimeContext = {
|
|
|
34
34
|
ui: PiEditorUi;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
type SessionHandle = {
|
|
38
|
+
generation: number;
|
|
39
|
+
ctx: PiRuntimeContext;
|
|
40
|
+
};
|
|
41
|
+
|
|
37
42
|
type BridgeState = {
|
|
38
43
|
mode: BridgeMode;
|
|
39
44
|
interval?: NodeJS.Timeout;
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
generation: number;
|
|
46
|
+
session?: SessionHandle;
|
|
47
|
+
inFlightGeneration?: number;
|
|
48
|
+
activatingGeneration?: number;
|
|
42
49
|
terminalFocused: boolean;
|
|
43
50
|
unsubscribeTerminalInput?: () => void;
|
|
44
51
|
lastClipboard?: string;
|
|
45
52
|
lastPasted?: string;
|
|
46
53
|
lastPasteSummary?: string;
|
|
47
|
-
ctx?: PiRuntimeContext;
|
|
48
54
|
};
|
|
49
55
|
|
|
50
56
|
const state: BridgeState = {
|
|
51
57
|
mode: defaultMode(),
|
|
52
|
-
|
|
53
|
-
activating: false,
|
|
58
|
+
generation: 0,
|
|
54
59
|
terminalFocused: true,
|
|
55
60
|
};
|
|
56
61
|
|
|
@@ -77,15 +82,71 @@ function statusText(): string | undefined {
|
|
|
77
82
|
: `SW paste: ${focusText}`;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
function
|
|
81
|
-
|
|
85
|
+
function isCurrentSession(ctx: PiRuntimeContext, generation: number): boolean {
|
|
86
|
+
return state.session?.generation === generation && state.session.ctx === ctx;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function safeWithCurrentUi(
|
|
90
|
+
ctx: PiRuntimeContext,
|
|
91
|
+
generation: number,
|
|
92
|
+
action: (ui: PiEditorUi) => void,
|
|
93
|
+
): boolean {
|
|
94
|
+
if (!isCurrentSession(ctx, generation)) return false;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const ui = ctx.ui;
|
|
98
|
+
if (!isCurrentSession(ctx, generation)) return false;
|
|
99
|
+
action(ui);
|
|
100
|
+
return true;
|
|
101
|
+
} catch {
|
|
102
|
+
// A Pi ctx can become stale between any callback/await boundary. Treat UI
|
|
103
|
+
// failures as a closed session so the extension never bubbles the runner's
|
|
104
|
+
// stale-ctx guard up as an uncaughtException.
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setStatus(ctx: PiRuntimeContext, generation: number): boolean {
|
|
110
|
+
return safeWithCurrentUi(ctx, generation, (ui) => {
|
|
111
|
+
ui.setStatus(STATUS_KEY, statusText());
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function notify(
|
|
116
|
+
ctx: PiRuntimeContext,
|
|
117
|
+
generation: number,
|
|
118
|
+
message: string,
|
|
119
|
+
level?: "info" | "warning" | "error" | "success",
|
|
120
|
+
): boolean {
|
|
121
|
+
return safeWithCurrentUi(ctx, generation, (ui) => {
|
|
122
|
+
ui.notify(message, level);
|
|
123
|
+
});
|
|
82
124
|
}
|
|
83
125
|
|
|
84
|
-
function
|
|
126
|
+
function pasteToEditor(ctx: PiRuntimeContext, generation: number, text: string): boolean {
|
|
127
|
+
return safeWithCurrentUi(ctx, generation, (ui) => {
|
|
128
|
+
ui.pasteToEditor(text);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getEditorText(ctx: PiRuntimeContext, generation: number): string | undefined {
|
|
133
|
+
if (!isCurrentSession(ctx, generation)) return undefined;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const ui = ctx.ui;
|
|
137
|
+
if (!isCurrentSession(ctx, generation)) return undefined;
|
|
138
|
+
return String(ui.getEditorText?.() ?? "");
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function markPasted(ctx: PiRuntimeContext, generation: number, text: string): void {
|
|
145
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
85
146
|
state.lastPasted = text;
|
|
86
147
|
state.lastPasteSummary = `${text.length} chars`;
|
|
87
148
|
// pasteToEditor mutates the editor directly; setStatus nudges Pi to redraw.
|
|
88
|
-
setStatus(ctx);
|
|
149
|
+
setStatus(ctx, generation);
|
|
89
150
|
}
|
|
90
151
|
|
|
91
152
|
async function readClipboardText(): Promise<string | undefined> {
|
|
@@ -140,128 +201,196 @@ async function isActiveTab(): Promise<boolean> {
|
|
|
140
201
|
}
|
|
141
202
|
}
|
|
142
203
|
|
|
143
|
-
async function refreshClipboardBaseline(
|
|
204
|
+
async function refreshClipboardBaseline(
|
|
205
|
+
ctx: PiRuntimeContext,
|
|
206
|
+
generation: number,
|
|
207
|
+
): Promise<void> {
|
|
144
208
|
const text = await readClipboardText();
|
|
209
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
145
210
|
if (text !== undefined) state.lastClipboard = text;
|
|
146
211
|
}
|
|
147
212
|
|
|
148
|
-
async function activateTab(ctx: PiRuntimeContext): Promise<void> {
|
|
149
|
-
if (state.
|
|
213
|
+
async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
214
|
+
if (!isCurrentSession(ctx, generation) || state.activatingGeneration !== undefined) return;
|
|
150
215
|
|
|
151
|
-
state.
|
|
216
|
+
state.activatingGeneration = generation;
|
|
152
217
|
try {
|
|
218
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
153
219
|
state.terminalFocused = true;
|
|
154
|
-
await refreshClipboardBaseline();
|
|
220
|
+
await refreshClipboardBaseline(ctx, generation);
|
|
221
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
155
222
|
await claimActiveTab();
|
|
156
|
-
|
|
223
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
224
|
+
setStatus(ctx, generation);
|
|
157
225
|
} finally {
|
|
158
|
-
state.
|
|
226
|
+
if (state.activatingGeneration === generation) state.activatingGeneration = undefined;
|
|
159
227
|
}
|
|
160
228
|
}
|
|
161
229
|
|
|
162
|
-
async function markActiveFromInput(ctx: PiRuntimeContext): Promise<void> {
|
|
230
|
+
async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
231
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
163
232
|
state.terminalFocused = true;
|
|
164
233
|
await claimActiveTab();
|
|
165
|
-
|
|
234
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
235
|
+
setStatus(ctx, generation);
|
|
166
236
|
}
|
|
167
237
|
|
|
168
|
-
function shouldPaste(text: string, ctx: PiRuntimeContext): boolean {
|
|
238
|
+
function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): boolean {
|
|
169
239
|
if (!text.trim()) return false;
|
|
170
240
|
if (text.length > maxChars()) return false;
|
|
171
241
|
if (text === state.lastPasted) return false;
|
|
172
242
|
|
|
173
|
-
const current =
|
|
243
|
+
const current = getEditorText(ctx, generation);
|
|
244
|
+
if (current === undefined) return false;
|
|
174
245
|
if (current.endsWith(text)) return false;
|
|
175
246
|
|
|
176
247
|
return true;
|
|
177
248
|
}
|
|
178
249
|
|
|
179
|
-
async function pasteClipboardChange(ctx: PiRuntimeContext): Promise<void> {
|
|
180
|
-
if (
|
|
250
|
+
async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
251
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
252
|
+
if (
|
|
253
|
+
state.inFlightGeneration !== undefined ||
|
|
254
|
+
state.activatingGeneration === generation ||
|
|
255
|
+
state.mode === "off"
|
|
256
|
+
) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
181
259
|
if (!ctx.hasUI) return;
|
|
182
260
|
if (!(await isActiveTab())) return;
|
|
261
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
183
262
|
|
|
184
|
-
state.
|
|
263
|
+
state.inFlightGeneration = generation;
|
|
185
264
|
try {
|
|
186
265
|
const text = await readClipboardText();
|
|
266
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
187
267
|
if (text === undefined || text === state.lastClipboard) return;
|
|
188
268
|
|
|
189
269
|
state.lastClipboard = text;
|
|
190
|
-
if (!shouldPaste(text, ctx)) return;
|
|
270
|
+
if (!shouldPaste(text, ctx, generation)) return;
|
|
191
271
|
if (!(await isActiveTab())) return;
|
|
272
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
192
273
|
|
|
193
|
-
|
|
194
|
-
markPasted(ctx, text);
|
|
274
|
+
if (!pasteToEditor(ctx, generation, text)) return;
|
|
275
|
+
markPasted(ctx, generation, text);
|
|
195
276
|
} catch {
|
|
196
277
|
// Clipboard polling should stay quiet while the user is typing.
|
|
197
278
|
} finally {
|
|
198
|
-
state.
|
|
279
|
+
if (state.inFlightGeneration === generation) state.inFlightGeneration = undefined;
|
|
199
280
|
}
|
|
200
281
|
}
|
|
201
282
|
|
|
202
|
-
function setupTerminalFocusTracking(ctx: PiRuntimeContext): void {
|
|
283
|
+
function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number): void {
|
|
284
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
203
285
|
if (state.unsubscribeTerminalInput) return;
|
|
204
286
|
|
|
205
|
-
|
|
206
|
-
|
|
287
|
+
try {
|
|
288
|
+
process.stdout.write(ENABLE_FOCUS_REPORTING);
|
|
289
|
+
} catch {
|
|
290
|
+
// Best-effort terminal focus setup only.
|
|
291
|
+
}
|
|
292
|
+
void activateTab(ctx, generation);
|
|
207
293
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return { consume: true };
|
|
212
|
-
}
|
|
294
|
+
safeWithCurrentUi(ctx, generation, (ui) => {
|
|
295
|
+
state.unsubscribeTerminalInput = ui.onTerminalInput((data) => {
|
|
296
|
+
if (!isCurrentSession(ctx, generation)) return undefined;
|
|
213
297
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
298
|
+
if (data === FOCUS_IN) {
|
|
299
|
+
void activateTab(ctx, generation);
|
|
300
|
+
return { consume: true };
|
|
301
|
+
}
|
|
219
302
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
303
|
+
if (data === FOCUS_OUT) {
|
|
304
|
+
state.terminalFocused = false;
|
|
305
|
+
setStatus(ctx, generation);
|
|
306
|
+
return { consume: true };
|
|
307
|
+
}
|
|
223
308
|
|
|
224
|
-
|
|
309
|
+
if (data.length > 0) {
|
|
310
|
+
void markActiveFromInput(ctx, generation);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return undefined;
|
|
314
|
+
});
|
|
225
315
|
});
|
|
226
316
|
}
|
|
227
317
|
|
|
228
318
|
function teardownTerminalFocusTracking(): void {
|
|
229
|
-
|
|
319
|
+
try {
|
|
320
|
+
state.unsubscribeTerminalInput?.();
|
|
321
|
+
} catch {
|
|
322
|
+
// The old subscription may already belong to an invalidated session.
|
|
323
|
+
}
|
|
230
324
|
state.unsubscribeTerminalInput = undefined;
|
|
231
|
-
process.stdout.write(DISABLE_FOCUS_REPORTING);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function ensurePolling(ctx: PiRuntimeContext): void {
|
|
235
|
-
state.ctx = ctx;
|
|
236
|
-
setupTerminalFocusTracking(ctx);
|
|
237
|
-
setStatus(ctx);
|
|
238
325
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
326
|
+
try {
|
|
327
|
+
process.stdout.write(DISABLE_FOCUS_REPORTING);
|
|
328
|
+
} catch {
|
|
329
|
+
// Best-effort terminal focus cleanup only.
|
|
330
|
+
}
|
|
243
331
|
}
|
|
244
332
|
|
|
245
|
-
function
|
|
333
|
+
function teardownSessionResources(): void {
|
|
246
334
|
if (state.interval) {
|
|
247
335
|
clearInterval(state.interval);
|
|
248
336
|
state.interval = undefined;
|
|
249
337
|
}
|
|
250
338
|
teardownTerminalFocusTracking();
|
|
339
|
+
state.inFlightGeneration = undefined;
|
|
340
|
+
state.activatingGeneration = undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function beginSession(ctx: PiRuntimeContext): number {
|
|
344
|
+
teardownSessionResources();
|
|
345
|
+
const generation = state.generation + 1;
|
|
346
|
+
state.generation = generation;
|
|
347
|
+
state.session = { generation, ctx };
|
|
348
|
+
return generation;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function ensureCurrentSession(ctx: PiRuntimeContext): number {
|
|
352
|
+
if (state.session?.ctx === ctx) return state.session.generation;
|
|
353
|
+
return beginSession(ctx);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function endSession(): void {
|
|
357
|
+
teardownSessionResources();
|
|
358
|
+
state.generation += 1;
|
|
359
|
+
state.session = undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
|
|
363
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
364
|
+
|
|
365
|
+
setupTerminalFocusTracking(ctx, generation);
|
|
366
|
+
setStatus(ctx, generation);
|
|
367
|
+
|
|
368
|
+
if (state.interval) return;
|
|
369
|
+
state.interval = setInterval(() => {
|
|
370
|
+
if (isCurrentSession(ctx, generation)) void pasteClipboardChange(ctx, generation);
|
|
371
|
+
}, intervalMs());
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function stopPolling(ctx: PiRuntimeContext, generation: number): void {
|
|
375
|
+
teardownSessionResources();
|
|
251
376
|
state.mode = "off";
|
|
252
|
-
|
|
377
|
+
setStatus(ctx, generation);
|
|
253
378
|
}
|
|
254
379
|
|
|
255
|
-
async function startPolling(ctx: PiRuntimeContext): Promise<void> {
|
|
256
|
-
|
|
257
|
-
|
|
380
|
+
async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
381
|
+
const text = await readClipboardText();
|
|
382
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
383
|
+
state.lastClipboard = text;
|
|
384
|
+
ensurePolling(ctx, generation);
|
|
258
385
|
}
|
|
259
386
|
|
|
260
|
-
async function arm(ctx: PiRuntimeContext): Promise<void> {
|
|
261
|
-
|
|
387
|
+
async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
388
|
+
const text = await readClipboardText();
|
|
389
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
390
|
+
state.lastClipboard = text;
|
|
262
391
|
state.mode = "on";
|
|
263
|
-
ensurePolling(ctx);
|
|
264
|
-
|
|
392
|
+
ensurePolling(ctx, generation);
|
|
393
|
+
notify(ctx, generation, "Superwhisper paste bridge: enabled", "info");
|
|
265
394
|
}
|
|
266
395
|
|
|
267
396
|
function parseControlCommand(text: string): BridgeMode | undefined {
|
|
@@ -270,32 +399,38 @@ function parseControlCommand(text: string): BridgeMode | undefined {
|
|
|
270
399
|
return match[1].toLowerCase() as BridgeMode;
|
|
271
400
|
}
|
|
272
401
|
|
|
273
|
-
async function runControlAction(
|
|
402
|
+
async function runControlAction(
|
|
403
|
+
action: BridgeMode,
|
|
404
|
+
ctx: PiRuntimeContext,
|
|
405
|
+
generation: number,
|
|
406
|
+
): Promise<void> {
|
|
274
407
|
if (action === "on") {
|
|
275
|
-
await arm(ctx);
|
|
408
|
+
await arm(ctx, generation);
|
|
276
409
|
return;
|
|
277
410
|
}
|
|
278
411
|
|
|
279
|
-
stopPolling(ctx);
|
|
280
|
-
|
|
412
|
+
stopPolling(ctx, generation);
|
|
413
|
+
notify(ctx, generation, "Superwhisper paste bridge: disabled", "info");
|
|
281
414
|
}
|
|
282
415
|
|
|
283
416
|
export default function superwhisperPaste(pi: ExtensionAPI) {
|
|
284
417
|
pi.on("session_start", async (_event, ctx) => {
|
|
285
418
|
const runtimeCtx = ctx as PiRuntimeContext;
|
|
286
|
-
|
|
287
|
-
if (state.mode !== "off") await startPolling(runtimeCtx);
|
|
419
|
+
const generation = beginSession(runtimeCtx);
|
|
420
|
+
if (state.mode !== "off") await startPolling(runtimeCtx, generation);
|
|
288
421
|
});
|
|
289
422
|
|
|
290
423
|
pi.on("session_shutdown", () => {
|
|
291
|
-
|
|
424
|
+
endSession();
|
|
292
425
|
});
|
|
293
426
|
|
|
294
427
|
pi.on("input", async (event, ctx) => {
|
|
295
428
|
const action = parseControlCommand(event.text);
|
|
296
429
|
if (!action) return { action: "continue" };
|
|
297
430
|
|
|
298
|
-
|
|
431
|
+
const runtimeCtx = ctx as PiRuntimeContext;
|
|
432
|
+
const generation = ensureCurrentSession(runtimeCtx);
|
|
433
|
+
await runControlAction(action, runtimeCtx, generation);
|
|
299
434
|
return { action: "handled" };
|
|
300
435
|
});
|
|
301
|
-
}
|
|
436
|
+
}
|