pi-superwhisper-paste 0.1.4 → 0.1.14
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 +65 -0
- package/README.md +2 -0
- package/docs/examples.md +6 -0
- package/extensions/index.ts +248 -22
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,71 @@ 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.14] - 2026-06-05
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Re-add `Code` / `Cursor` host processes to the clipboard-owner denylist so editor-hosted clipboard updates are blocked again during debugging.
|
|
12
|
+
|
|
13
|
+
## [0.1.13] - 2026-06-05
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Remove `Code.exe` / `Cursor.exe` from the terminal-owner denylist so editor-hosted Superwhisper clipboard updates are not blocked just because Pi is running inside Code/Cursor.
|
|
18
|
+
|
|
19
|
+
## [0.1.12] - 2026-06-05
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Add an explicit `v0.1.12` marker to the status text so stale package loads are visible during live debugging.
|
|
24
|
+
|
|
25
|
+
## [0.1.11] - 2026-06-05
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Broaden the terminal clipboard-owner denylist with `OpenConsole` / explicit `.exe` variants.
|
|
30
|
+
- Surface the last clipboard decision in the Pi status text (`blocked ...`, `... via ...`) so real-machine owner mismatches can be diagnosed quickly.
|
|
31
|
+
|
|
32
|
+
## [0.1.10] - 2026-06-05
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Fix the Windows PowerShell clipboard-owner probe script so the owner-aware bridge works in real Pi sessions instead of failing on multiline `Add-Type` syntax.
|
|
37
|
+
|
|
38
|
+
## [0.1.9] - 2026-06-05
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Replace the strict Superwhisper owner allowlist with a terminal-owner denylist so unknown Superwhisper owner names still auto-paste while terminal/CLI clipboard owners stay blocked.
|
|
43
|
+
|
|
44
|
+
## [0.1.8] - 2026-06-05
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Filter clipboard updates by clipboard-owner process so Pi auto-pastes only when the clipboard owner looks like Superwhisper.
|
|
49
|
+
- Keep local terminal copy suppression as a fallback for forwarded copy shortcuts while blocking mouse/right-click terminal copies via owner filtering.
|
|
50
|
+
|
|
51
|
+
## [0.1.7] - 2026-06-05
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- Suppress auto-paste for a short window after local terminal copy shortcuts so copying inside the active CLI no longer pastes into Pi.
|
|
56
|
+
- Refresh the clipboard baseline after local copy intent so the bridge keeps waiting for the next Superwhisper clipboard update.
|
|
57
|
+
|
|
58
|
+
## [0.1.6] - 2026-06-04
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- Added `version:check` PR guard support: package script + `scripts/check-version-bump.mjs`.
|
|
63
|
+
- Added CI verification that publishable changes must bump `package.json` and update `CHANGELOG.md` in the same PR.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- Truncate clipboard text in PowerShell before writing stdout so huge clipboards no longer exceed Node `maxBuffer` on extension load.
|
|
68
|
+
- Treat clipboard read failures (including `maxBuffer`) as empty reads so `session_start` never surfaces as an extension error.
|
|
69
|
+
|
|
70
|
+
## [0.1.5] - 2026-06-03
|
|
71
|
+
|
|
7
72
|
## [0.1.4] - 2026-06-03
|
|
8
73
|
|
|
9
74
|
### Changed
|
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ pi install git:github.com/eiei114/pi-superwhisper-paste
|
|
|
50
50
|
- Default-on after the extension loads.
|
|
51
51
|
- Slash commands: `/sw-paste:on` and `/sw-paste:off`.
|
|
52
52
|
- Windows clipboard polling with active-tab gating.
|
|
53
|
+
- Brief copy-suppression window so local terminal copy actions do not get auto-pasted back into Pi.
|
|
54
|
+
- Clipboard-owner filtering so terminal/CLI-owned clipboard updates stay blocked by default.
|
|
53
55
|
|
|
54
56
|
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
|
55
57
|
|
package/docs/examples.md
CHANGED
|
@@ -28,6 +28,8 @@ Run these commands inside Pi's input box:
|
|
|
28
28
|
| `PI_SUPERWHISPER_PASTE` | `on` | Set to `off`, `0`, `false`, or `no` to disable by default. |
|
|
29
29
|
| `PI_SUPERWHISPER_PASTE_INTERVAL_MS` | `800` | Clipboard polling interval. |
|
|
30
30
|
| `PI_SUPERWHISPER_PASTE_MAX_CHARS` | `8000` | Maximum clipboard text length to paste. |
|
|
31
|
+
| `PI_SUPERWHISPER_PASTE_IGNORE_COPY_MS` | `1500` | Ignore clipboard changes briefly after local terminal copy shortcuts. |
|
|
32
|
+
| `PI_SUPERWHISPER_PASTE_OWNER_DENYLIST` | `windowsterminal,...` | Comma-separated clipboard owner process/path substrings blocked from auto-paste. |
|
|
31
33
|
|
|
32
34
|
## Expected workflow
|
|
33
35
|
|
|
@@ -40,3 +42,7 @@ Run these commands inside Pi's input box:
|
|
|
40
42
|
When multiple Pi tabs are open, only the tab that most recently had terminal focus or keyboard input should paste the new clipboard text.
|
|
41
43
|
|
|
42
44
|
The bridge does not wait for Pi's agent turn to become idle. If Pi is thinking, the active tab should still receive the next Superwhisper clipboard update.
|
|
45
|
+
|
|
46
|
+
When you copy text inside the active CLI, the bridge now suppresses auto-paste briefly and refreshes the clipboard baseline so the copied text is not inserted into Pi.
|
|
47
|
+
|
|
48
|
+
The bridge also inspects the Windows clipboard owner and blocks auto-paste when the owner looks like a terminal/CLI app.
|
package/extensions/index.ts
CHANGED
|
@@ -6,19 +6,53 @@ import { join } from "node:path";
|
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
9
|
+
const EXTENSION_VERSION = "0.1.14";
|
|
9
10
|
|
|
10
11
|
const STATUS_KEY = "superwhisper-paste";
|
|
11
12
|
const DEFAULT_INTERVAL_MS = 800;
|
|
12
13
|
const DEFAULT_MAX_CHARS = 8000;
|
|
14
|
+
const DEFAULT_IGNORE_COPY_MS = 1500;
|
|
15
|
+
const BASELINE_REFRESH_DELAY_MS = 120;
|
|
16
|
+
const DEFAULT_OWNER_DENYLIST = [
|
|
17
|
+
"windowsterminal",
|
|
18
|
+
"windowsterminal.exe",
|
|
19
|
+
"windows terminal",
|
|
20
|
+
"openconsole",
|
|
21
|
+
"openconsole.exe",
|
|
22
|
+
"wezterm",
|
|
23
|
+
"alacritty",
|
|
24
|
+
"mintty",
|
|
25
|
+
"conhost",
|
|
26
|
+
"cmd.exe",
|
|
27
|
+
"code",
|
|
28
|
+
"code.exe",
|
|
29
|
+
"powershell",
|
|
30
|
+
"pwsh",
|
|
31
|
+
"bash",
|
|
32
|
+
"cursor",
|
|
33
|
+
"cursor.exe",
|
|
34
|
+
"wsl",
|
|
35
|
+
];
|
|
13
36
|
const ACTIVE_STATE_FILE = "pi-superwhisper-paste-active.json";
|
|
14
37
|
const FOCUS_IN = "\x1b[I";
|
|
15
38
|
const FOCUS_OUT = "\x1b[O";
|
|
16
39
|
const ENABLE_FOCUS_REPORTING = "\x1b[?1004h";
|
|
17
40
|
const DISABLE_FOCUS_REPORTING = "\x1b[?1004l";
|
|
18
41
|
const INSTANCE_ID = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
42
|
+
const CTRL_C = "\x03";
|
|
43
|
+
const CTRL_INSERT = "\x1b[2;5~";
|
|
44
|
+
const CTRL_SHIFT_INSERT = "\x1b[2;6~";
|
|
45
|
+
const CTRL_SHIFT_C_CSI_U = "\x1b[99;6u";
|
|
46
|
+
const CTRL_SHIFT_UPPER_C_CSI_U = "\x1b[67;6u";
|
|
19
47
|
|
|
20
48
|
type BridgeMode = "off" | "on";
|
|
21
49
|
|
|
50
|
+
type ClipboardSnapshot = {
|
|
51
|
+
text?: string;
|
|
52
|
+
ownerProcessName?: string;
|
|
53
|
+
ownerProcessPath?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
22
56
|
type PiEditorUi = {
|
|
23
57
|
setStatus(key: string, value: string | undefined): void;
|
|
24
58
|
notify(message: string, level?: "info" | "warning" | "error" | "success"): void;
|
|
@@ -42,11 +76,13 @@ type SessionHandle = {
|
|
|
42
76
|
type BridgeState = {
|
|
43
77
|
mode: BridgeMode;
|
|
44
78
|
interval?: NodeJS.Timeout;
|
|
79
|
+
baselineRefreshTimeout?: NodeJS.Timeout;
|
|
45
80
|
generation: number;
|
|
46
81
|
session?: SessionHandle;
|
|
47
82
|
inFlightGeneration?: number;
|
|
48
83
|
activatingGeneration?: number;
|
|
49
84
|
terminalFocused: boolean;
|
|
85
|
+
suppressPasteUntil?: number;
|
|
50
86
|
unsubscribeTerminalInput?: () => void;
|
|
51
87
|
lastClipboard?: string;
|
|
52
88
|
lastPasted?: string;
|
|
@@ -59,33 +95,70 @@ const state: BridgeState = {
|
|
|
59
95
|
terminalFocused: true,
|
|
60
96
|
};
|
|
61
97
|
|
|
98
|
+
/** Resolves bridge on/off from `PI_SUPERWHISPER_PASTE` (default on). */
|
|
62
99
|
function defaultMode(): BridgeMode {
|
|
63
100
|
const raw = (process.env.PI_SUPERWHISPER_PASTE ?? "on").trim().toLowerCase();
|
|
64
101
|
return ["0", "false", "no", "off"].includes(raw) ? "off" : "on";
|
|
65
102
|
}
|
|
66
103
|
|
|
104
|
+
/** Poll interval from env or default. */
|
|
67
105
|
function intervalMs(): number {
|
|
68
106
|
const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_INTERVAL_MS);
|
|
69
107
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_INTERVAL_MS;
|
|
70
108
|
}
|
|
71
109
|
|
|
110
|
+
/** Max pasted characters from env or default. */
|
|
72
111
|
function maxChars(): number {
|
|
73
112
|
const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_MAX_CHARS);
|
|
74
113
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CHARS;
|
|
75
114
|
}
|
|
76
115
|
|
|
116
|
+
/** Ignore clipboard changes briefly after local copy shortcuts. */
|
|
117
|
+
function ignoreCopyMs(): number {
|
|
118
|
+
const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_IGNORE_COPY_MS);
|
|
119
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_IGNORE_COPY_MS;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Process-name/path substrings denied from triggering auto-paste. */
|
|
123
|
+
function ownerDenylist(): string[] {
|
|
124
|
+
const raw = process.env.PI_SUPERWHISPER_PASTE_OWNER_DENYLIST;
|
|
125
|
+
if (!raw) return DEFAULT_OWNER_DENYLIST;
|
|
126
|
+
|
|
127
|
+
const tokens = raw
|
|
128
|
+
.split(",")
|
|
129
|
+
.map((token) => token.trim().toLowerCase())
|
|
130
|
+
.filter(Boolean);
|
|
131
|
+
|
|
132
|
+
return tokens.length > 0 ? tokens : DEFAULT_OWNER_DENYLIST;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Status bar label for the paste bridge. */
|
|
77
136
|
function statusText(): string | undefined {
|
|
78
137
|
if (state.mode === "off") return undefined;
|
|
79
138
|
const focusText = state.terminalFocused ? "active" : "standby";
|
|
80
139
|
return state.lastPasteSummary
|
|
81
|
-
? `SW paste: ${focusText} (${state.lastPasteSummary})`
|
|
82
|
-
: `SW paste: ${focusText}`;
|
|
140
|
+
? `SW paste v${EXTENSION_VERSION}: ${focusText} (${state.lastPasteSummary})`
|
|
141
|
+
: `SW paste v${EXTENSION_VERSION}: ${focusText}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Short display name for clipboard owner debugging. */
|
|
145
|
+
function clipboardOwnerSummary(snapshot: ClipboardSnapshot): string {
|
|
146
|
+
const name = snapshot.ownerProcessName?.trim();
|
|
147
|
+
if (name) return name;
|
|
148
|
+
|
|
149
|
+
const rawPath = snapshot.ownerProcessPath?.trim();
|
|
150
|
+
if (!rawPath) return "unknown";
|
|
151
|
+
|
|
152
|
+
const segments = rawPath.split(/[\\/]/).filter(Boolean);
|
|
153
|
+
return segments.at(-1) ?? rawPath;
|
|
83
154
|
}
|
|
84
155
|
|
|
156
|
+
/** True when `ctx`/`generation` still match the active Pi session. */
|
|
85
157
|
function isCurrentSession(ctx: PiRuntimeContext, generation: number): boolean {
|
|
86
158
|
return state.session?.generation === generation && state.session.ctx === ctx;
|
|
87
159
|
}
|
|
88
160
|
|
|
161
|
+
/** Runs a UI callback only for the current session; swallows stale-ctx errors. */
|
|
89
162
|
function safeWithCurrentUi(
|
|
90
163
|
ctx: PiRuntimeContext,
|
|
91
164
|
generation: number,
|
|
@@ -106,12 +179,14 @@ function safeWithCurrentUi(
|
|
|
106
179
|
}
|
|
107
180
|
}
|
|
108
181
|
|
|
182
|
+
/** Updates Pi status bar when session is current. */
|
|
109
183
|
function setStatus(ctx: PiRuntimeContext, generation: number): boolean {
|
|
110
184
|
return safeWithCurrentUi(ctx, generation, (ui) => {
|
|
111
185
|
ui.setStatus(STATUS_KEY, statusText());
|
|
112
186
|
});
|
|
113
187
|
}
|
|
114
188
|
|
|
189
|
+
/** Shows a Pi notification when session is current. */
|
|
115
190
|
function notify(
|
|
116
191
|
ctx: PiRuntimeContext,
|
|
117
192
|
generation: number,
|
|
@@ -123,12 +198,14 @@ function notify(
|
|
|
123
198
|
});
|
|
124
199
|
}
|
|
125
200
|
|
|
201
|
+
/** Pastes text into the editor when session is current. */
|
|
126
202
|
function pasteToEditor(ctx: PiRuntimeContext, generation: number, text: string): boolean {
|
|
127
203
|
return safeWithCurrentUi(ctx, generation, (ui) => {
|
|
128
204
|
ui.pasteToEditor(text);
|
|
129
205
|
});
|
|
130
206
|
}
|
|
131
207
|
|
|
208
|
+
/** Reads editor text when session is current. */
|
|
132
209
|
function getEditorText(ctx: PiRuntimeContext, generation: number): string | undefined {
|
|
133
210
|
if (!isCurrentSession(ctx, generation)) return undefined;
|
|
134
211
|
|
|
@@ -141,6 +218,7 @@ function getEditorText(ctx: PiRuntimeContext, generation: number): string | unde
|
|
|
141
218
|
}
|
|
142
219
|
}
|
|
143
220
|
|
|
221
|
+
/** Records last paste and refreshes status after insert. */
|
|
144
222
|
function markPasted(ctx: PiRuntimeContext, generation: number, text: string): void {
|
|
145
223
|
if (!isCurrentSession(ctx, generation)) return;
|
|
146
224
|
state.lastPasted = text;
|
|
@@ -149,31 +227,85 @@ function markPasted(ctx: PiRuntimeContext, generation: number, text: string): vo
|
|
|
149
227
|
setStatus(ctx, generation);
|
|
150
228
|
}
|
|
151
229
|
|
|
152
|
-
|
|
153
|
-
|
|
230
|
+
/** Builds a PowerShell command that reads clipboard text truncated to `limit` characters. */
|
|
231
|
+
function clipboardPowerShellScript(limit: number): string {
|
|
232
|
+
return [
|
|
154
233
|
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
|
|
234
|
+
"$signature = @\"",
|
|
235
|
+
"using System;",
|
|
236
|
+
"using System.Runtime.InteropServices;",
|
|
237
|
+
"public static class ClipboardOwnerNative {",
|
|
238
|
+
" [DllImport(\"user32.dll\")] public static extern IntPtr GetClipboardOwner();",
|
|
239
|
+
" [DllImport(\"user32.dll\")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);",
|
|
240
|
+
"}",
|
|
241
|
+
"\"@",
|
|
242
|
+
"Add-Type -TypeDefinition $signature -ErrorAction SilentlyContinue",
|
|
243
|
+
`$limit = ${limit}`,
|
|
155
244
|
"$text = Get-Clipboard -Raw -Format Text -ErrorAction SilentlyContinue",
|
|
156
|
-
"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
"
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
245
|
+
"$ownerProcessName = $null",
|
|
246
|
+
"$ownerProcessPath = $null",
|
|
247
|
+
"try {",
|
|
248
|
+
" $ownerWindow = [ClipboardOwnerNative]::GetClipboardOwner()",
|
|
249
|
+
" if ($ownerWindow -ne [IntPtr]::Zero) {",
|
|
250
|
+
" $ownerPid = 0",
|
|
251
|
+
" [void][ClipboardOwnerNative]::GetWindowThreadProcessId($ownerWindow, [ref]$ownerPid)",
|
|
252
|
+
" if ($ownerPid -gt 0) {",
|
|
253
|
+
" $ownerProcess = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue",
|
|
254
|
+
" if ($null -ne $ownerProcess) {",
|
|
255
|
+
" $ownerProcessName = $ownerProcess.ProcessName",
|
|
256
|
+
" $ownerProcessPath = $ownerProcess.Path",
|
|
257
|
+
" }",
|
|
258
|
+
" }",
|
|
259
|
+
" }",
|
|
260
|
+
"} catch {}",
|
|
261
|
+
"if ($null -ne $text) {",
|
|
262
|
+
" if ($text.Length -gt $limit) { $text = $text.Substring(0, $limit) }",
|
|
263
|
+
"}",
|
|
264
|
+
"$result = @{ text = $text; ownerProcessName = $ownerProcessName; ownerProcessPath = $ownerProcessPath }",
|
|
265
|
+
"[Console]::Out.Write(($result | ConvertTo-Json -Compress))",
|
|
266
|
+
].join("\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Reads clipboard text and owner metadata via PowerShell. */
|
|
270
|
+
async function readClipboardSnapshot(): Promise<ClipboardSnapshot | undefined> {
|
|
271
|
+
const limit = maxChars();
|
|
169
272
|
|
|
170
|
-
|
|
273
|
+
try {
|
|
274
|
+
const result = await execFileAsync(
|
|
275
|
+
"powershell.exe",
|
|
276
|
+
["-NoProfile", "-Command", clipboardPowerShellScript(limit)],
|
|
277
|
+
{
|
|
278
|
+
encoding: "utf8",
|
|
279
|
+
maxBuffer: limit * 4 + 1024,
|
|
280
|
+
timeout: 2500,
|
|
281
|
+
windowsHide: true,
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : undefined;
|
|
286
|
+
if (!stdout) return undefined;
|
|
287
|
+
|
|
288
|
+
const snapshot = JSON.parse(stdout) as ClipboardSnapshot;
|
|
289
|
+
const text = typeof snapshot.text === "string" ? snapshot.text : undefined;
|
|
290
|
+
return {
|
|
291
|
+
text: text && text.length > limit ? text.slice(0, limit) : text,
|
|
292
|
+
ownerProcessName:
|
|
293
|
+
typeof snapshot.ownerProcessName === "string" ? snapshot.ownerProcessName : undefined,
|
|
294
|
+
ownerProcessPath:
|
|
295
|
+
typeof snapshot.ownerProcessPath === "string" ? snapshot.ownerProcessPath : undefined,
|
|
296
|
+
};
|
|
297
|
+
} catch {
|
|
298
|
+
// Oversized clipboard, PowerShell failure, or maxBuffer must not break extension load.
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
171
301
|
}
|
|
172
302
|
|
|
303
|
+
/** Temp file path for cross-tab active-instance claim. */
|
|
173
304
|
function activeStatePath(): string {
|
|
174
305
|
return join(tmpdir(), ACTIVE_STATE_FILE);
|
|
175
306
|
}
|
|
176
307
|
|
|
308
|
+
/** Writes this Pi instance as the active tab for paste gating. */
|
|
177
309
|
async function claimActiveTab(): Promise<void> {
|
|
178
310
|
const claim = {
|
|
179
311
|
instanceId: INSTANCE_ID,
|
|
@@ -188,6 +320,7 @@ async function claimActiveTab(): Promise<void> {
|
|
|
188
320
|
}
|
|
189
321
|
}
|
|
190
322
|
|
|
323
|
+
/** True when this instance owns the active-tab claim and has focus. */
|
|
191
324
|
async function isActiveTab(): Promise<boolean> {
|
|
192
325
|
if (!state.terminalFocused) return false;
|
|
193
326
|
|
|
@@ -201,15 +334,32 @@ async function isActiveTab(): Promise<boolean> {
|
|
|
201
334
|
}
|
|
202
335
|
}
|
|
203
336
|
|
|
337
|
+
/** Seeds `lastClipboard` without pasting (focus/tab activation). */
|
|
204
338
|
async function refreshClipboardBaseline(
|
|
205
339
|
ctx: PiRuntimeContext,
|
|
206
340
|
generation: number,
|
|
207
341
|
): Promise<void> {
|
|
208
|
-
const text = await
|
|
342
|
+
const text = (await readClipboardSnapshot())?.text;
|
|
209
343
|
if (!isCurrentSession(ctx, generation)) return;
|
|
210
344
|
if (text !== undefined) state.lastClipboard = text;
|
|
211
345
|
}
|
|
212
346
|
|
|
347
|
+
/** Best-effort delayed baseline refresh after local copy shortcuts. */
|
|
348
|
+
function scheduleClipboardBaselineRefresh(ctx: PiRuntimeContext, generation: number): void {
|
|
349
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
350
|
+
|
|
351
|
+
if (state.baselineRefreshTimeout) {
|
|
352
|
+
clearTimeout(state.baselineRefreshTimeout);
|
|
353
|
+
state.baselineRefreshTimeout = undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
state.baselineRefreshTimeout = setTimeout(() => {
|
|
357
|
+
state.baselineRefreshTimeout = undefined;
|
|
358
|
+
if (isCurrentSession(ctx, generation)) void refreshClipboardBaseline(ctx, generation);
|
|
359
|
+
}, BASELINE_REFRESH_DELAY_MS);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Marks tab focused, refreshes clipboard baseline, claims active tab. */
|
|
213
363
|
async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
214
364
|
if (!isCurrentSession(ctx, generation) || state.activatingGeneration !== undefined) return;
|
|
215
365
|
|
|
@@ -227,6 +377,7 @@ async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<v
|
|
|
227
377
|
}
|
|
228
378
|
}
|
|
229
379
|
|
|
380
|
+
/** Claims active tab when user types in the terminal. */
|
|
230
381
|
async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
231
382
|
if (!isCurrentSession(ctx, generation)) return;
|
|
232
383
|
state.terminalFocused = true;
|
|
@@ -235,6 +386,39 @@ async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): P
|
|
|
235
386
|
setStatus(ctx, generation);
|
|
236
387
|
}
|
|
237
388
|
|
|
389
|
+
/** True while recent local copy shortcuts should suppress bridge pastes. */
|
|
390
|
+
function shouldSuppressPasteForRecentCopy(): boolean {
|
|
391
|
+
return (state.suppressPasteUntil ?? 0) > Date.now();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Detects common terminal copy shortcuts forwarded to Pi. */
|
|
395
|
+
function isLocalCopyShortcut(data: string): boolean {
|
|
396
|
+
return [
|
|
397
|
+
CTRL_C,
|
|
398
|
+
CTRL_INSERT,
|
|
399
|
+
CTRL_SHIFT_INSERT,
|
|
400
|
+
CTRL_SHIFT_C_CSI_U,
|
|
401
|
+
CTRL_SHIFT_UPPER_C_CSI_U,
|
|
402
|
+
].includes(data);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Suppresses auto-paste briefly after local terminal copy intent. */
|
|
406
|
+
function suppressPasteAfterLocalCopy(ctx: PiRuntimeContext, generation: number): void {
|
|
407
|
+
if (!isCurrentSession(ctx, generation)) return;
|
|
408
|
+
state.suppressPasteUntil = Date.now() + ignoreCopyMs();
|
|
409
|
+
state.lastPasteSummary = "blocked recent copy";
|
|
410
|
+
void markActiveFromInput(ctx, generation);
|
|
411
|
+
scheduleClipboardBaselineRefresh(ctx, generation);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** True when clipboard owner metadata does not look like a terminal copy source. */
|
|
415
|
+
function shouldAcceptClipboardSource(snapshot: ClipboardSnapshot): boolean {
|
|
416
|
+
const owner = `${snapshot.ownerProcessName ?? ""}\n${snapshot.ownerProcessPath ?? ""}`.toLowerCase();
|
|
417
|
+
if (!owner.trim()) return true;
|
|
418
|
+
return !ownerDenylist().some((token) => owner.includes(token));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Whether clipboard text should be auto-pasted into the editor. */
|
|
238
422
|
function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): boolean {
|
|
239
423
|
if (!text.trim()) return false;
|
|
240
424
|
if (text.length > maxChars()) return false;
|
|
@@ -247,6 +431,7 @@ function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): b
|
|
|
247
431
|
return true;
|
|
248
432
|
}
|
|
249
433
|
|
|
434
|
+
/** Poll handler: paste new clipboard text when gates pass. */
|
|
250
435
|
async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
251
436
|
if (!isCurrentSession(ctx, generation)) return;
|
|
252
437
|
if (
|
|
@@ -262,17 +447,35 @@ async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number):
|
|
|
262
447
|
|
|
263
448
|
state.inFlightGeneration = generation;
|
|
264
449
|
try {
|
|
265
|
-
const
|
|
450
|
+
const snapshot = await readClipboardSnapshot();
|
|
451
|
+
const text = snapshot?.text;
|
|
266
452
|
if (!isCurrentSession(ctx, generation)) return;
|
|
267
453
|
if (text === undefined || text === state.lastClipboard) return;
|
|
268
454
|
|
|
269
455
|
state.lastClipboard = text;
|
|
456
|
+
if (!snapshot) {
|
|
457
|
+
state.lastPasteSummary = "clipboard read failed";
|
|
458
|
+
setStatus(ctx, generation);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!shouldAcceptClipboardSource(snapshot)) {
|
|
462
|
+
state.lastPasteSummary = `blocked ${clipboardOwnerSummary(snapshot)}`;
|
|
463
|
+
setStatus(ctx, generation);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (shouldSuppressPasteForRecentCopy()) {
|
|
467
|
+
state.lastPasteSummary = `blocked recent copy (${clipboardOwnerSummary(snapshot)})`;
|
|
468
|
+
setStatus(ctx, generation);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
270
471
|
if (!shouldPaste(text, ctx, generation)) return;
|
|
271
472
|
if (!(await isActiveTab())) return;
|
|
272
473
|
if (!isCurrentSession(ctx, generation)) return;
|
|
273
474
|
|
|
274
475
|
if (!pasteToEditor(ctx, generation, text)) return;
|
|
275
476
|
markPasted(ctx, generation, text);
|
|
477
|
+
state.lastPasteSummary = `${text.length} chars via ${clipboardOwnerSummary(snapshot)}`;
|
|
478
|
+
setStatus(ctx, generation);
|
|
276
479
|
} catch {
|
|
277
480
|
// Clipboard polling should stay quiet while the user is typing.
|
|
278
481
|
} finally {
|
|
@@ -280,6 +483,7 @@ async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number):
|
|
|
280
483
|
}
|
|
281
484
|
}
|
|
282
485
|
|
|
486
|
+
/** Enables terminal focus reporting and input hooks for this session. */
|
|
283
487
|
function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number): void {
|
|
284
488
|
if (!isCurrentSession(ctx, generation)) return;
|
|
285
489
|
if (state.unsubscribeTerminalInput) return;
|
|
@@ -306,6 +510,11 @@ function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number):
|
|
|
306
510
|
return { consume: true };
|
|
307
511
|
}
|
|
308
512
|
|
|
513
|
+
if (isLocalCopyShortcut(data)) {
|
|
514
|
+
suppressPasteAfterLocalCopy(ctx, generation);
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
|
|
309
518
|
if (data.length > 0) {
|
|
310
519
|
void markActiveFromInput(ctx, generation);
|
|
311
520
|
}
|
|
@@ -315,6 +524,7 @@ function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number):
|
|
|
315
524
|
});
|
|
316
525
|
}
|
|
317
526
|
|
|
527
|
+
/** Disables focus reporting and removes terminal input subscription. */
|
|
318
528
|
function teardownTerminalFocusTracking(): void {
|
|
319
529
|
try {
|
|
320
530
|
state.unsubscribeTerminalInput?.();
|
|
@@ -330,16 +540,23 @@ function teardownTerminalFocusTracking(): void {
|
|
|
330
540
|
}
|
|
331
541
|
}
|
|
332
542
|
|
|
543
|
+
/** Clears poll interval and terminal hooks for the current session. */
|
|
333
544
|
function teardownSessionResources(): void {
|
|
334
545
|
if (state.interval) {
|
|
335
546
|
clearInterval(state.interval);
|
|
336
547
|
state.interval = undefined;
|
|
337
548
|
}
|
|
549
|
+
if (state.baselineRefreshTimeout) {
|
|
550
|
+
clearTimeout(state.baselineRefreshTimeout);
|
|
551
|
+
state.baselineRefreshTimeout = undefined;
|
|
552
|
+
}
|
|
338
553
|
teardownTerminalFocusTracking();
|
|
339
554
|
state.inFlightGeneration = undefined;
|
|
340
555
|
state.activatingGeneration = undefined;
|
|
556
|
+
state.suppressPasteUntil = undefined;
|
|
341
557
|
}
|
|
342
558
|
|
|
559
|
+
/** Starts a new session generation and tears down the previous one. */
|
|
343
560
|
function beginSession(ctx: PiRuntimeContext): number {
|
|
344
561
|
teardownSessionResources();
|
|
345
562
|
const generation = state.generation + 1;
|
|
@@ -348,17 +565,20 @@ function beginSession(ctx: PiRuntimeContext): number {
|
|
|
348
565
|
return generation;
|
|
349
566
|
}
|
|
350
567
|
|
|
568
|
+
/** Returns current session generation, beginning one if needed. */
|
|
351
569
|
function ensureCurrentSession(ctx: PiRuntimeContext): number {
|
|
352
570
|
if (state.session?.ctx === ctx) return state.session.generation;
|
|
353
571
|
return beginSession(ctx);
|
|
354
572
|
}
|
|
355
573
|
|
|
574
|
+
/** Invalidates the current session and releases resources. */
|
|
356
575
|
function endSession(): void {
|
|
357
576
|
teardownSessionResources();
|
|
358
577
|
state.generation += 1;
|
|
359
578
|
state.session = undefined;
|
|
360
579
|
}
|
|
361
580
|
|
|
581
|
+
/** Starts clipboard polling and focus tracking when mode is on. */
|
|
362
582
|
function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
|
|
363
583
|
if (!isCurrentSession(ctx, generation)) return;
|
|
364
584
|
|
|
@@ -371,21 +591,24 @@ function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
|
|
|
371
591
|
}, intervalMs());
|
|
372
592
|
}
|
|
373
593
|
|
|
594
|
+
/** Stops polling and clears bridge status. */
|
|
374
595
|
function stopPolling(ctx: PiRuntimeContext, generation: number): void {
|
|
375
596
|
teardownSessionResources();
|
|
376
597
|
state.mode = "off";
|
|
377
598
|
setStatus(ctx, generation);
|
|
378
599
|
}
|
|
379
600
|
|
|
601
|
+
/** Baselines clipboard then starts polling (session_start path). */
|
|
380
602
|
async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
381
|
-
const text = await
|
|
603
|
+
const text = (await readClipboardSnapshot())?.text;
|
|
382
604
|
if (!isCurrentSession(ctx, generation)) return;
|
|
383
605
|
state.lastClipboard = text;
|
|
384
606
|
ensurePolling(ctx, generation);
|
|
385
607
|
}
|
|
386
608
|
|
|
609
|
+
/** Enables bridge mode and notifies the user. */
|
|
387
610
|
async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
388
|
-
const text = await
|
|
611
|
+
const text = (await readClipboardSnapshot())?.text;
|
|
389
612
|
if (!isCurrentSession(ctx, generation)) return;
|
|
390
613
|
state.lastClipboard = text;
|
|
391
614
|
state.mode = "on";
|
|
@@ -393,12 +616,14 @@ async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
|
|
|
393
616
|
notify(ctx, generation, "Superwhisper paste bridge: enabled", "info");
|
|
394
617
|
}
|
|
395
618
|
|
|
619
|
+
/** Parses `/sw-paste:on` or `/sw-paste:off` from user input. */
|
|
396
620
|
function parseControlCommand(text: string): BridgeMode | undefined {
|
|
397
621
|
const match = text.trim().match(/^\/sw-paste:(on|off)$/i);
|
|
398
622
|
if (!match) return undefined;
|
|
399
623
|
return match[1].toLowerCase() as BridgeMode;
|
|
400
624
|
}
|
|
401
625
|
|
|
626
|
+
/** Applies on/off control command for the current session. */
|
|
402
627
|
async function runControlAction(
|
|
403
628
|
action: BridgeMode,
|
|
404
629
|
ctx: PiRuntimeContext,
|
|
@@ -413,6 +638,7 @@ async function runControlAction(
|
|
|
413
638
|
notify(ctx, generation, "Superwhisper paste bridge: disabled", "info");
|
|
414
639
|
}
|
|
415
640
|
|
|
641
|
+
/** Pi extension entry: Superwhisper clipboard bridge for Windows. */
|
|
416
642
|
export default function superwhisperPaste(pi: ExtensionAPI) {
|
|
417
643
|
pi.registerCommand("sw-paste:on", {
|
|
418
644
|
description: "Enable the Superwhisper clipboard paste bridge",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-superwhisper-paste",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Superwhisper clipboard bridge for Pi on Windows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
38
|
"test": "node --test tests/*.test.mjs",
|
|
39
39
|
"ci": "npm run typecheck && npm test && npm run pack:check",
|
|
40
|
-
"pack:check": "npm pack --dry-run"
|
|
40
|
+
"pack:check": "npm pack --dry-run",
|
|
41
|
+
"version:check": "node scripts/check-version-bump.mjs"
|
|
41
42
|
},
|
|
42
43
|
"pi": {
|
|
43
44
|
"extensions": [
|
|
@@ -56,7 +57,7 @@
|
|
|
56
57
|
"@earendil-works/pi-ai": "latest",
|
|
57
58
|
"@earendil-works/pi-coding-agent": "latest",
|
|
58
59
|
"@earendil-works/pi-tui": "latest",
|
|
59
|
-
"@types/node": "^
|
|
60
|
+
"@types/node": "^25.9.1",
|
|
60
61
|
"typescript": "^6.0.3"
|
|
61
62
|
}
|
|
62
63
|
}
|