pi-superwhisper-paste 0.1.4 → 0.1.5
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 +7 -0
- package/extensions/index.ts +63 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ 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.5] - 2026-06-03
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Truncate clipboard text in PowerShell before writing stdout so huge clipboards no longer exceed Node `maxBuffer` on extension load.
|
|
12
|
+
- Treat clipboard read failures (including `maxBuffer`) as empty reads so `session_start` never surfaces as an extension error.
|
|
13
|
+
|
|
7
14
|
## [0.1.4] - 2026-06-03
|
|
8
15
|
|
|
9
16
|
### Changed
|
package/extensions/index.ts
CHANGED
|
@@ -59,21 +59,25 @@ const state: BridgeState = {
|
|
|
59
59
|
terminalFocused: true,
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
/** Resolves bridge on/off from `PI_SUPERWHISPER_PASTE` (default on). */
|
|
62
63
|
function defaultMode(): BridgeMode {
|
|
63
64
|
const raw = (process.env.PI_SUPERWHISPER_PASTE ?? "on").trim().toLowerCase();
|
|
64
65
|
return ["0", "false", "no", "off"].includes(raw) ? "off" : "on";
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/** Poll interval from env or default. */
|
|
67
69
|
function intervalMs(): number {
|
|
68
70
|
const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_INTERVAL_MS);
|
|
69
71
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_INTERVAL_MS;
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
/** Max pasted characters from env or default. */
|
|
72
75
|
function maxChars(): number {
|
|
73
76
|
const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_MAX_CHARS);
|
|
74
77
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CHARS;
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
/** Status bar label for the paste bridge. */
|
|
77
81
|
function statusText(): string | undefined {
|
|
78
82
|
if (state.mode === "off") return undefined;
|
|
79
83
|
const focusText = state.terminalFocused ? "active" : "standby";
|
|
@@ -82,10 +86,12 @@ function statusText(): string | undefined {
|
|
|
82
86
|
: `SW paste: ${focusText}`;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
/** True when `ctx`/`generation` still match the active Pi session. */
|
|
85
90
|
function isCurrentSession(ctx: PiRuntimeContext, generation: number): boolean {
|
|
86
91
|
return state.session?.generation === generation && state.session.ctx === ctx;
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
/** Runs a UI callback only for the current session; swallows stale-ctx errors. */
|
|
89
95
|
function safeWithCurrentUi(
|
|
90
96
|
ctx: PiRuntimeContext,
|
|
91
97
|
generation: number,
|
|
@@ -106,12 +112,14 @@ function safeWithCurrentUi(
|
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
|
|
115
|
+
/** Updates Pi status bar when session is current. */
|
|
109
116
|
function setStatus(ctx: PiRuntimeContext, generation: number): boolean {
|
|
110
117
|
return safeWithCurrentUi(ctx, generation, (ui) => {
|
|
111
118
|
ui.setStatus(STATUS_KEY, statusText());
|
|
112
119
|
});
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
/** Shows a Pi notification when session is current. */
|
|
115
123
|
function notify(
|
|
116
124
|
ctx: PiRuntimeContext,
|
|
117
125
|
generation: number,
|
|
@@ -123,12 +131,14 @@ function notify(
|
|
|
123
131
|
});
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
/** Pastes text into the editor when session is current. */
|
|
126
135
|
function pasteToEditor(ctx: PiRuntimeContext, generation: number, text: string): boolean {
|
|
127
136
|
return safeWithCurrentUi(ctx, generation, (ui) => {
|
|
128
137
|
ui.pasteToEditor(text);
|
|
129
138
|
});
|
|
130
139
|
}
|
|
131
140
|
|
|
141
|
+
/** Reads editor text when session is current. */
|
|
132
142
|
function getEditorText(ctx: PiRuntimeContext, generation: number): string | undefined {
|
|
133
143
|
if (!isCurrentSession(ctx, generation)) return undefined;
|
|
134
144
|
|
|
@@ -141,6 +151,7 @@ function getEditorText(ctx: PiRuntimeContext, generation: number): string | unde
|
|
|
141
151
|
}
|
|
142
152
|
}
|
|
143
153
|
|
|
154
|
+
/** Records last paste and refreshes status after insert. */
|
|
144
155
|
function markPasted(ctx: PiRuntimeContext, generation: number, text: string): void {
|
|
145
156
|
if (!isCurrentSession(ctx, generation)) return;
|
|
146
157
|
state.lastPasted = text;
|
|
@@ -149,31 +160,50 @@ function markPasted(ctx: PiRuntimeContext, generation: number, text: string): vo
|
|
|
149
160
|
setStatus(ctx, generation);
|
|
150
161
|
}
|
|
151
162
|
|
|
152
|
-
|
|
153
|
-
|
|
163
|
+
/** Builds a PowerShell command that reads clipboard text truncated to `limit` characters. */
|
|
164
|
+
function clipboardPowerShellScript(limit: number): string {
|
|
165
|
+
return [
|
|
154
166
|
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
|
|
167
|
+
`$limit = ${limit}`,
|
|
155
168
|
"$text = Get-Clipboard -Raw -Format Text -ErrorAction SilentlyContinue",
|
|
156
|
-
"if ($null -ne $text) {
|
|
169
|
+
"if ($null -ne $text) {",
|
|
170
|
+
" if ($text.Length -gt $limit) { $text = $text.Substring(0, $limit) }",
|
|
171
|
+
" [Console]::Out.Write($text)",
|
|
172
|
+
"}",
|
|
157
173
|
].join("; ");
|
|
174
|
+
}
|
|
158
175
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
{
|
|
163
|
-
encoding: "utf8",
|
|
164
|
-
maxBuffer: maxChars() * 4,
|
|
165
|
-
timeout: 2500,
|
|
166
|
-
windowsHide: true,
|
|
167
|
-
},
|
|
168
|
-
);
|
|
176
|
+
/** Reads clipboard text via PowerShell; returns undefined on failure or oversize output. */
|
|
177
|
+
async function readClipboardText(): Promise<string | undefined> {
|
|
178
|
+
const limit = maxChars();
|
|
169
179
|
|
|
170
|
-
|
|
180
|
+
try {
|
|
181
|
+
const result = await execFileAsync(
|
|
182
|
+
"powershell.exe",
|
|
183
|
+
["-NoProfile", "-Command", clipboardPowerShellScript(limit)],
|
|
184
|
+
{
|
|
185
|
+
encoding: "utf8",
|
|
186
|
+
maxBuffer: limit * 4 + 1024,
|
|
187
|
+
timeout: 2500,
|
|
188
|
+
windowsHide: true,
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : undefined;
|
|
193
|
+
if (stdout === undefined) return undefined;
|
|
194
|
+
return stdout.length > limit ? stdout.slice(0, limit) : stdout;
|
|
195
|
+
} catch {
|
|
196
|
+
// Oversized clipboard, PowerShell failure, or maxBuffer must not break extension load.
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
171
199
|
}
|
|
172
200
|
|
|
201
|
+
/** Temp file path for cross-tab active-instance claim. */
|
|
173
202
|
function activeStatePath(): string {
|
|
174
203
|
return join(tmpdir(), ACTIVE_STATE_FILE);
|
|
175
204
|
}
|
|
176
205
|
|
|
206
|
+
/** Writes this Pi instance as the active tab for paste gating. */
|
|
177
207
|
async function claimActiveTab(): Promise<void> {
|
|
178
208
|
const claim = {
|
|
179
209
|
instanceId: INSTANCE_ID,
|
|
@@ -188,6 +218,7 @@ async function claimActiveTab(): Promise<void> {
|
|
|
188
218
|
}
|
|
189
219
|
}
|
|
190
220
|
|
|
221
|
+
/** True when this instance owns the active-tab claim and has focus. */
|
|
191
222
|
async function isActiveTab(): Promise<boolean> {
|
|
192
223
|
if (!state.terminalFocused) return false;
|
|
193
224
|
|
|
@@ -201,6 +232,7 @@ async function isActiveTab(): Promise<boolean> {
|
|
|
201
232
|
}
|
|
202
233
|
}
|
|
203
234
|
|
|
235
|
+
/** Seeds `lastClipboard` without pasting (focus/tab activation). */
|
|
204
236
|
async function refreshClipboardBaseline(
|
|
205
237
|
ctx: PiRuntimeContext,
|
|
206
238
|
generation: number,
|
|
@@ -210,6 +242,7 @@ async function refreshClipboardBaseline(
|
|
|
210
242
|
if (text !== undefined) state.lastClipboard = text;
|
|
211
243
|
}
|
|
212
244
|
|
|
245
|
+
/** Marks tab focused, refreshes clipboard baseline, claims active tab. */
|
|
213
246
|
async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
214
247
|
if (!isCurrentSession(ctx, generation) || state.activatingGeneration !== undefined) return;
|
|
215
248
|
|
|
@@ -227,6 +260,7 @@ async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<v
|
|
|
227
260
|
}
|
|
228
261
|
}
|
|
229
262
|
|
|
263
|
+
/** Claims active tab when user types in the terminal. */
|
|
230
264
|
async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
231
265
|
if (!isCurrentSession(ctx, generation)) return;
|
|
232
266
|
state.terminalFocused = true;
|
|
@@ -235,6 +269,7 @@ async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): P
|
|
|
235
269
|
setStatus(ctx, generation);
|
|
236
270
|
}
|
|
237
271
|
|
|
272
|
+
/** Whether clipboard text should be auto-pasted into the editor. */
|
|
238
273
|
function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): boolean {
|
|
239
274
|
if (!text.trim()) return false;
|
|
240
275
|
if (text.length > maxChars()) return false;
|
|
@@ -247,6 +282,7 @@ function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): b
|
|
|
247
282
|
return true;
|
|
248
283
|
}
|
|
249
284
|
|
|
285
|
+
/** Poll handler: paste new clipboard text when gates pass. */
|
|
250
286
|
async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
251
287
|
if (!isCurrentSession(ctx, generation)) return;
|
|
252
288
|
if (
|
|
@@ -280,6 +316,7 @@ async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number):
|
|
|
280
316
|
}
|
|
281
317
|
}
|
|
282
318
|
|
|
319
|
+
/** Enables terminal focus reporting and input hooks for this session. */
|
|
283
320
|
function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number): void {
|
|
284
321
|
if (!isCurrentSession(ctx, generation)) return;
|
|
285
322
|
if (state.unsubscribeTerminalInput) return;
|
|
@@ -315,6 +352,7 @@ function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number):
|
|
|
315
352
|
});
|
|
316
353
|
}
|
|
317
354
|
|
|
355
|
+
/** Disables focus reporting and removes terminal input subscription. */
|
|
318
356
|
function teardownTerminalFocusTracking(): void {
|
|
319
357
|
try {
|
|
320
358
|
state.unsubscribeTerminalInput?.();
|
|
@@ -330,6 +368,7 @@ function teardownTerminalFocusTracking(): void {
|
|
|
330
368
|
}
|
|
331
369
|
}
|
|
332
370
|
|
|
371
|
+
/** Clears poll interval and terminal hooks for the current session. */
|
|
333
372
|
function teardownSessionResources(): void {
|
|
334
373
|
if (state.interval) {
|
|
335
374
|
clearInterval(state.interval);
|
|
@@ -340,6 +379,7 @@ function teardownSessionResources(): void {
|
|
|
340
379
|
state.activatingGeneration = undefined;
|
|
341
380
|
}
|
|
342
381
|
|
|
382
|
+
/** Starts a new session generation and tears down the previous one. */
|
|
343
383
|
function beginSession(ctx: PiRuntimeContext): number {
|
|
344
384
|
teardownSessionResources();
|
|
345
385
|
const generation = state.generation + 1;
|
|
@@ -348,17 +388,20 @@ function beginSession(ctx: PiRuntimeContext): number {
|
|
|
348
388
|
return generation;
|
|
349
389
|
}
|
|
350
390
|
|
|
391
|
+
/** Returns current session generation, beginning one if needed. */
|
|
351
392
|
function ensureCurrentSession(ctx: PiRuntimeContext): number {
|
|
352
393
|
if (state.session?.ctx === ctx) return state.session.generation;
|
|
353
394
|
return beginSession(ctx);
|
|
354
395
|
}
|
|
355
396
|
|
|
397
|
+
/** Invalidates the current session and releases resources. */
|
|
356
398
|
function endSession(): void {
|
|
357
399
|
teardownSessionResources();
|
|
358
400
|
state.generation += 1;
|
|
359
401
|
state.session = undefined;
|
|
360
402
|
}
|
|
361
403
|
|
|
404
|
+
/** Starts clipboard polling and focus tracking when mode is on. */
|
|
362
405
|
function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
|
|
363
406
|
if (!isCurrentSession(ctx, generation)) return;
|
|
364
407
|
|
|
@@ -371,12 +414,14 @@ function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
|
|
|
371
414
|
}, intervalMs());
|
|
372
415
|
}
|
|
373
416
|
|
|
417
|
+
/** Stops polling and clears bridge status. */
|
|
374
418
|
function stopPolling(ctx: PiRuntimeContext, generation: number): void {
|
|
375
419
|
teardownSessionResources();
|
|
376
420
|
state.mode = "off";
|
|
377
421
|
setStatus(ctx, generation);
|
|
378
422
|
}
|
|
379
423
|
|
|
424
|
+
/** Baselines clipboard then starts polling (session_start path). */
|
|
380
425
|
async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
381
426
|
const text = await readClipboardText();
|
|
382
427
|
if (!isCurrentSession(ctx, generation)) return;
|
|
@@ -384,6 +429,7 @@ async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<
|
|
|
384
429
|
ensurePolling(ctx, generation);
|
|
385
430
|
}
|
|
386
431
|
|
|
432
|
+
/** Enables bridge mode and notifies the user. */
|
|
387
433
|
async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
388
434
|
const text = await readClipboardText();
|
|
389
435
|
if (!isCurrentSession(ctx, generation)) return;
|
|
@@ -393,12 +439,14 @@ async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
|
393
439
|
notify(ctx, generation, "Superwhisper paste bridge: enabled", "info");
|
|
394
440
|
}
|
|
395
441
|
|
|
442
|
+
/** Parses `/sw-paste:on` or `/sw-paste:off` from user input. */
|
|
396
443
|
function parseControlCommand(text: string): BridgeMode | undefined {
|
|
397
444
|
const match = text.trim().match(/^\/sw-paste:(on|off)$/i);
|
|
398
445
|
if (!match) return undefined;
|
|
399
446
|
return match[1].toLowerCase() as BridgeMode;
|
|
400
447
|
}
|
|
401
448
|
|
|
449
|
+
/** Applies on/off control command for the current session. */
|
|
402
450
|
async function runControlAction(
|
|
403
451
|
action: BridgeMode,
|
|
404
452
|
ctx: PiRuntimeContext,
|
|
@@ -413,6 +461,7 @@ async function runControlAction(
|
|
|
413
461
|
notify(ctx, generation, "Superwhisper paste bridge: disabled", "info");
|
|
414
462
|
}
|
|
415
463
|
|
|
464
|
+
/** Pi extension entry: Superwhisper clipboard bridge for Windows. */
|
|
416
465
|
export default function superwhisperPaste(pi: ExtensionAPI) {
|
|
417
466
|
pi.registerCommand("sw-paste:on", {
|
|
418
467
|
description: "Enable the Superwhisper clipboard paste bridge",
|