pi-copy-message 2.1.0 → 4.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.0 - 2026-10-01
4
+
5
+ - Require Pi 1.0.0 and pin the official 1.0.0 development cohort.
6
+ - Use the native canonical projection for latest-message copies, honoring context edits and compaction while retaining raw historical numbered/picker/user copies and legacy response snapshots.
7
+ - Verify clipboard text before reporting success; keep Pi's complete native/platform/OSC 52 write fallback chain and warn when delivery is unverified, empty, mismatched or remote.
8
+ - Cancel pending pickers on session/tree/shutdown lifecycle changes; suppress stale completion notifications.
9
+ - Add native mouse navigation/copy with restored editor focus and safe isolated clipboard/SDK lifecycle regression coverage.
10
+
11
+ ## 3.0.0 - 2026-09-26
12
+
13
+ - Require Node.js 24.15 or newer for repository tooling; `.nvmrc` pins 24.21.0 and `packageManager` is npm 12.1.0.
14
+ - Run tests with Node's built-in `node --test` and type stripping instead of `tsx`; typecheck with TypeScript 7.0.2 and `erasableSyntaxOnly`.
15
+ - Refresh the development baseline to Pi 0.87.1, qualified against official Pi and the maintained fork.
16
+ - Guard `package-lock.json` against private-registry URLs.
17
+ - First npm release containing the 2.1.1 clipboard change (2.1.1 was tagged but never published to npm).
18
+
19
+ ## 2.1.1 - 2026-08-25
20
+
21
+ - Reuse Pi's clipboard implementation instead of maintaining a duplicate command runner, enabling Pi's OSC 52 fallback on Android/Termux.
22
+
3
23
  ## 2.1.0 - 2026-08-17
4
24
 
5
25
  - Add an `Alt+C` picker filter and `MessageVisibility.showCustom` option for showing or hiding custom messages.
package/README.md CHANGED
@@ -16,7 +16,8 @@ A [pi](https://github.com/earendil-works/pi-mono) extension that adds `/copy-mes
16
16
  - Supports type-to-filter search across role and message text, with `time:<term>` for timestamp search
17
17
  - Supports Home/End jumps for oldest/newest visible messages
18
18
  - Uses Pi's configured selection bindings for navigation, copy, and cancel
19
- - Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`
19
+ - Keeps Pi's platform clipboard writes and OSC 52 fallback, but reports success only after local text readback
20
+ - Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`, using Pi's canonical active context
20
21
  - Supports direct numbered copies like `/copy-message 3`
21
22
  - Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
22
23
 
@@ -34,11 +35,7 @@ Or install it directly from GitHub with pi:
34
35
  pi install https://github.com/fitchmultz/pi-copy-message
35
36
  ```
36
37
 
37
- Then reload pi from inside the app:
38
-
39
- ```text
40
- /reload
41
- ```
38
+ Use `/reload` after updating extension code on current official Pi and the maintained fork. Restart Pi after changing dependencies or the host runtime.
42
39
 
43
40
  If you prefer to load it directly from a local checkout during development:
44
41
 
@@ -97,7 +94,7 @@ Copy with role and timestamp metadata instead of raw text only:
97
94
  | `Ctrl+PageUp` / `Ctrl+PageDown` | Move by one visible page in fullscreen mode |
98
95
  | `Home` / `End` | Jump to oldest/newest visible message in regular mode |
99
96
  | `Ctrl+Home` / `Ctrl+End` | Jump to oldest/newest visible message in fullscreen mode |
100
- | Type text | Filter visible messages |
97
+ | Type or paste text | Filter visible messages; pasted line breaks and tabs become spaces |
101
98
  | `time:<term>` | Search timestamps |
102
99
  | `Backspace` | Delete one search character |
103
100
  | `Ctrl+U` | Toggle user messages |
@@ -112,31 +109,46 @@ Copy with role and timestamp metadata instead of raw text only:
112
109
  ## Behavior notes
113
110
 
114
111
  - Entry IDs are hidden from the picker.
112
+ - Mouse wheel navigates; clicking a message copies it and returns keyboard focus to the unchanged draft.
113
+ - Session replacement, tree navigation, shutdown and reload cancel pending pickers without stale-context notifications.
115
114
  - The picker caps visible rows and scrolls instead of filling the screen.
116
115
  - Search preserves your original selected message and restores it when the search is cleared.
117
116
  - General search does not match timestamps; use `time:<term>` when you want to search by displayed time.
117
+ - Picker previews and copy notifications remove terminal control characters; the copied text stays raw.
118
118
  - Filter labels honor the active pi theme.
119
119
  - Selection key hints show the active Pi bindings. Configured selection actions take precedence if they collide with the picker's filter, preview, or format shortcuts.
120
120
  - Only custom messages with `display: true` can appear or be copied from current `custom_message` entries or legacy message-wrapped entries; visible ones can be toggled with `Alt+C`.
121
- - Copy notifications include the role and a short preview so you can verify what was copied.
121
+ - A verified local copy notification includes the role and a short preview. Unavailable, empty or mismatched readback warns instead of claiming success; rejected writes report an error. Remote terminal delivery via OSC 52 cannot be verified, even when the remote machine's local clipboard matches.
122
+ - Native clipboard reads are preferred where available. Bounded platform readers preserve exact text, including newlines; the extension never replaces Pi's working write fallback chain.
123
+ - `latest` uses Pi's canonical session projection, honoring context edits, omissions and compaction. Numbered selectors, the picker and `/copy-user` retain raw active-branch history, including older pre-compaction messages.
122
124
  - `/copy-message latest` and numbered selectors include user, assistant, and visible custom messages by default while hiding tool/bash messages. `Alt+C` only affects the picker. If only hidden messages exist, `latest` falls back to the newest message so the command still does something useful.
123
125
  - `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
124
126
  - Direct commands such as `/copy-user`, `/copy-message latest`, and `/copy-message 3` do not require TUI mode, though non-UI modes may not display notifications.
125
127
 
126
128
  ## Compatibility
127
129
 
128
- - Requires and is tested with Pi 0.84.0 or later
130
+ - Requires Pi 1.0.0 or later. Development and standalone/packed qualification use the official 1.0.0 cohort; a maintained fork must be qualified at its actual supported revision, not by version-string equivalence.
131
+ - The picker coalesces legacy in-progress assistant snapshots within their context-window boundaries so each response appears once. Current official Pi and the maintained fork no longer write those legacy boundaries.
132
+ - Android/Termux clipboard writes use Pi's normal fallback chain, including OSC 52 when native clipboard commands are unavailable.
129
133
  - Pi includes `Ctrl+X` for copying the latest assistant response; this extension remains useful for searchable history, other roles, metadata, and direct selectors.
130
- - Supported Node.js range for local repo tooling: `>=22.19.0`
131
- - `.nvmrc` pins Node 22.19.0 for local development
134
+ - Node.js `>=24.15.0`. `.nvmrc` pins Node 24.21.0 for local development.
132
135
 
133
136
  This package keeps pi core packages as wildcard peers (`*`) per pi package guidance. Pi aliases these imports to its own bundled copies when loading the extension, so the package never bundles or shadows pi core. Local development uses `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` as dev dependencies for typechecking and tests.
134
137
 
135
138
  ## Development
136
139
 
137
140
  ```bash
138
- npm install
139
- npm run check
141
+ npm ci
142
+ npm run check # node --test + tsc --noEmit + pack dry-run
143
+ ```
144
+
145
+ No production build or `prepare` is required. Tests run TypeScript directly with Node's type stripping, so source must stay erasable (`erasableSyntaxOnly`). Qualification uses the selected host installed in this checkout's dependency graph. The tests cover formatting, native command execution, canonical context and branch selection, both TUI modes, pointer focus and pending-picker disposal. Native addon interception and an isolated command PATH keep test clipboard writes in temporary fixture files, never the operator clipboard. Actual Android/Termux, Windows/WSL and remote terminal delivery still require platform qualification.
146
+
147
+ `package-lock.json` must only reference `registry.npmjs.org`; a test fails on private-registry URLs. Behind a registry proxy that rewrites tarball URLs, refresh the lock from public metadata and install through the proxy:
148
+
149
+ ```bash
150
+ npm install --package-lock-only --ignore-scripts --registry=https://registry.npmjs.org/
151
+ npm ci
140
152
  ```
141
153
 
142
154
  Key files:
@@ -1,7 +1,8 @@
1
- import { spawnSync } from "node:child_process";
2
- import type { ExtensionAPI, ExtensionCommandContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { copyToClipboard, type ExtensionAPI, type ExtensionCommandContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
3
4
  import type { AutocompleteItem, TuiMode } from "@earendil-works/pi-tui";
4
- import { decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
5
+ import { decodeKittyPrintable, getNativeClipboard, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
5
6
 
6
7
  const MAX_VISIBLE_MESSAGES = 8;
7
8
  const MAX_PEEK_LINES = 16;
@@ -33,7 +34,7 @@ function textFromContent(content: unknown): string {
33
34
  );
34
35
  })
35
36
  .map((part) => part.text)
36
- .join("\n\n");
37
+ .join("");
37
38
  }
38
39
 
39
40
  function textFromMessage(message: Record<string, unknown>): string {
@@ -56,14 +57,10 @@ function textFromMessage(message: Record<string, unknown>): string {
56
57
  return textFromContent(message.content);
57
58
  }
58
59
 
59
- type SegmenterCtor = new (locale?: string, options?: { granularity?: "grapheme" }) => {
60
- segment(input: string): Iterable<{ segment: string }>;
61
- };
60
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
62
61
 
63
62
  function splitGraphemes(text: string): string[] {
64
- const Segmenter = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
65
- if (!Segmenter) return Array.from(text);
66
- return Array.from(new Segmenter(undefined, { granularity: "grapheme" }).segment(text), (part) => part.segment);
63
+ return Array.from(graphemeSegmenter.segment(text), (part) => part.segment);
67
64
  }
68
65
 
69
66
  function truncateGraphemes(text: string, max: number): string {
@@ -74,8 +71,12 @@ function truncateGraphemes(text: string, max: number): string {
74
71
  return `${graphemes.slice(0, max - 1).join("")}…`;
75
72
  }
76
73
 
74
+ function safeDisplayText(text: string): string {
75
+ return text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
76
+ }
77
+
77
78
  function compactPreview(text: string, max = 96): string {
78
- const preview = text.replace(/\s+/gu, " ").trim();
79
+ const preview = safeDisplayText(text).replace(/\s+/gu, " ").trim();
79
80
  return truncateGraphemes(preview, max);
80
81
  }
81
82
 
@@ -96,7 +97,7 @@ function roleLabel(role: string): string {
96
97
  case "compactionSummary":
97
98
  return "compaction";
98
99
  default:
99
- return role || "message";
100
+ return safeDisplayText(role).replace(/\s+/gu, " ").trim() || "message";
100
101
  }
101
102
  }
102
103
 
@@ -138,12 +139,49 @@ function entryToCopyableMessage(entry: unknown): CopyableMessage | undefined {
138
139
  }
139
140
 
140
141
  export function collectCopyableMessages(ctx: { sessionManager: { getBranch(): unknown[] } }): CopyableMessage[] {
141
- return ctx.sessionManager.getBranch().flatMap((entry) => {
142
+ const entries: unknown[] = [];
143
+ // Pi persists assistant snapshots separately and coalesces them within each context window.
144
+ const responses = new Map<string, number>();
145
+ for (const entry of ctx.sessionManager.getBranch()) {
146
+ const record = entry as {
147
+ type?: string;
148
+ message?: { role?: string; responseId?: string; stopReason?: string; content?: unknown[] };
149
+ } | null;
150
+ if (record?.type === "context_window") responses.clear();
151
+ const responseId =
152
+ record?.type === "message" && record.message?.role === "assistant" && typeof record.message.responseId === "string"
153
+ ? record.message.responseId
154
+ : undefined;
155
+ const previous = responseId ? responses.get(responseId) : undefined;
156
+ if (previous !== undefined) {
157
+ const earlier = entries[previous] as typeof record;
158
+ if (
159
+ record?.message?.stopReason !== "pending" ||
160
+ (earlier?.message?.stopReason === "pending" && (earlier.message.content?.length ?? 0) <= (record.message.content?.length ?? 0))
161
+ ) entries[previous] = entry;
162
+ continue;
163
+ }
164
+ if (responseId) responses.set(responseId, entries.length);
165
+ entries.push(entry);
166
+ }
167
+
168
+ return entries.flatMap((entry) => {
142
169
  const message = entryToCopyableMessage(entry);
143
170
  return message ? [message] : [];
144
171
  });
145
172
  }
146
173
 
174
+ function collectProjectedMessages(ctx: ExtensionCommandContext): CopyableMessage[] {
175
+ const entries = ctx.sessionManager.buildSessionProjection().entries.flatMap(({ sourceEntry, messages }): unknown[] => {
176
+ // Retain saved fork boundaries for legacy response-snapshot coalescing.
177
+ if ((sourceEntry.type as string) === "context_window") return [sourceEntry];
178
+ return messages
179
+ .filter((message) => !(sourceEntry.type === "compaction" && message.role === "system"))
180
+ .map((message) => ({ ...sourceEntry, type: "message", message }));
181
+ });
182
+ return collectCopyableMessages({ sessionManager: { getBranch: () => entries } });
183
+ }
184
+
147
185
  export type MostRecentUserMessageResult =
148
186
  | { kind: "message"; message: CopyableMessage }
149
187
  | { kind: "no-user-message" }
@@ -181,58 +219,6 @@ export function getMostRecentUserMessage(ctx: { sessionManager: { getBranch(): u
181
219
  return sawUserMessage ? { kind: "no-text" } : { kind: "no-user-message" };
182
220
  }
183
221
 
184
- type ClipboardCommand = {
185
- name: string;
186
- args: string[];
187
- enabled: () => boolean;
188
- };
189
-
190
- const clipboardCommands: ClipboardCommand[] = [
191
- { name: "pbcopy", args: [], enabled: () => process.platform === "darwin" },
192
- { name: "termux-clipboard-set", args: [], enabled: () => Boolean(process.env.TERMUX_VERSION) },
193
- { name: "wl-copy", args: [], enabled: () => true },
194
- { name: "xclip", args: ["-selection", "clipboard"], enabled: () => true },
195
- { name: "xsel", args: ["--clipboard", "--input"], enabled: () => true },
196
- ];
197
-
198
- const CLIPBOARD_TIMEOUT_MS = 3000;
199
- const commandExistsCache = new Map<string, boolean>();
200
-
201
- function commandExists(command: string): boolean {
202
- const cached = commandExistsCache.get(command);
203
- if (cached !== undefined) return cached;
204
-
205
- const result = spawnSync("sh", ["-c", "command -v \"$1\" >/dev/null 2>&1", "sh", command], {
206
- stdio: "ignore",
207
- timeout: CLIPBOARD_TIMEOUT_MS,
208
- killSignal: "SIGKILL",
209
- });
210
- const exists = result.status === 0;
211
- commandExistsCache.set(command, exists);
212
- return exists;
213
- }
214
-
215
- function copyWith(command: string, args: string[], text: string): boolean {
216
- const result = spawnSync(command, args, { input: text, encoding: "utf8", timeout: CLIPBOARD_TIMEOUT_MS, killSignal: "SIGKILL" });
217
- return !result.error && result.status === 0;
218
- }
219
-
220
- function copyToClipboard(text: string): string | undefined {
221
- const failedCommands: string[] = [];
222
-
223
- for (const command of clipboardCommands) {
224
- if (!command.enabled() || !commandExists(command.name)) continue;
225
- if (copyWith(command.name, command.args, text)) return undefined;
226
- failedCommands.push(command.name);
227
- }
228
-
229
- if (failedCommands.length > 0) {
230
- return `Clipboard command${failedCommands.length === 1 ? "" : "s"} failed (${failedCommands.join(", ")})`;
231
- }
232
-
233
- return "No clipboard command found (tried pbcopy, termux-clipboard-set, wl-copy, xclip, xsel)";
234
- }
235
-
236
222
  function isToolMessage(message: CopyableMessage): boolean {
237
223
  return message.role === "toolResult" || message.role === "bashExecution";
238
224
  }
@@ -353,7 +339,7 @@ function renderMessageLine(
353
339
 
354
340
  function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMessageTheme, format: CopyFormat): string[] {
355
341
  const contentWidth = Math.max(1, width - 2);
356
- const text = formatMessageForCopy(message, format);
342
+ const text = safeDisplayText(formatMessageForCopy(message, format));
357
343
  const wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, text, false), contentWidth);
358
344
  const shown = wrapped.slice(0, MAX_PEEK_LINES);
359
345
  const remaining = wrapped.length - shown.length;
@@ -440,8 +426,11 @@ export class CopyMessagePickerState {
440
426
  format: CopyFormat;
441
427
  peek = false;
442
428
  private searchAnchorId: string | undefined;
429
+ private readonly messages: CopyableMessage[];
430
+ private firstVisibleIndex = 0;
443
431
 
444
- constructor(private readonly messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
432
+ constructor(messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
433
+ this.messages = messages;
445
434
  this.format = initialFormat;
446
435
  this.visibleMessages = filteredMessages(messages, this.visibility, this.search);
447
436
  this.selectedIndex = Math.max(0, this.visibleMessages.length - 1);
@@ -460,6 +449,7 @@ export class CopyMessagePickerState {
460
449
  const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
461
450
  const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
462
451
  const end = Math.min(this.visibleMessages.length, start + maxVisible);
452
+ this.firstVisibleIndex = start;
463
453
  const hasCustomMessages = this.messages.some((message) => message.role === "custom");
464
454
  const userState = filterLabel(theme, "user", this.visibility.showUser, "warning");
465
455
  const assistantState = filterLabel(theme, "assistant", this.visibility.showAssistant, "accent");
@@ -496,6 +486,13 @@ export class CopyMessagePickerState {
496
486
  }
497
487
 
498
488
  handleInput(data: string, keybindings?: PickerKeybindings): PickerInputResult {
489
+ // Pi delivers each paste as one input wrapped in bracketed-paste markers.
490
+ if (data.startsWith("\x1b[200~") && data.endsWith("\x1b[201~")) {
491
+ const text = data.slice(6, -6).replace(/\s+/gu, " ");
492
+ if (!isPrintableSearchInput(text)) return "none";
493
+ this.setSearch(this.search + text);
494
+ return "render";
495
+ }
499
496
  if (keybindings?.matches(data, "tui.select.up")) {
500
497
  this.move(-1);
501
498
  return "render";
@@ -545,7 +542,7 @@ export class CopyMessagePickerState {
545
542
  return "render";
546
543
  }
547
544
  if (matchesKey(data, "backspace") || data === "\x7f") {
548
- this.setSearch(this.search.slice(0, -1));
545
+ this.setSearch(splitGraphemes(this.search).slice(0, -1).join(""));
549
546
  return "render";
550
547
  }
551
548
  const printable = decodeKittyPrintable(data) ?? data;
@@ -602,6 +599,20 @@ export class CopyMessagePickerState {
602
599
  return "none";
603
600
  }
604
601
 
602
+ handleMouse(event: TuiMouseEvent): PickerInputResult {
603
+ if (event.type === "wheel" && event.wheelDelta) {
604
+ this.move(event.wheelDelta < 0 ? -1 : 1);
605
+ return "render";
606
+ }
607
+ if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return "none";
608
+ const row = event.y - 2; // The message list follows the title and spacer.
609
+ if (row < 0 || row >= MAX_VISIBLE_MESSAGES) return "none";
610
+ const index = this.firstVisibleIndex + row;
611
+ if (!this.visibleMessages[index]) return "none";
612
+ this.selectedIndex = index;
613
+ return event.type === "click" ? "copy" : "render";
614
+ }
615
+
605
616
  private refreshMessages(preferredId?: string) {
606
617
  const selectedId = preferredId ?? this.visibleMessages[this.selectedIndex]?.id;
607
618
  this.visibleMessages = filteredMessages(this.messages, this.visibility, this.search);
@@ -678,28 +689,33 @@ export function copyArgumentCompletions(prefix: string, includeSelectors: boolea
678
689
  return items.length > 0 ? items : null;
679
690
  }
680
691
 
681
- async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat) {
692
+ async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat, signal: AbortSignal) {
693
+ if (signal.aborted) return null;
682
694
  return ctx.ui.custom<{ message: CopyableMessage; text: string } | null>((tui, theme, keybindings, done) => {
683
695
  const state = new CopyMessagePickerState(messages, initialFormat);
696
+ const abort = () => done(null);
697
+ if (signal.aborted) abort();
698
+ else signal.addEventListener("abort", abort, { once: true });
699
+ const act = (result: PickerInputResult) => {
700
+ if (result === "copy") {
701
+ const selected = state.selectedMessage();
702
+ const text = state.selectedCopyText();
703
+ done(selected && text !== undefined ? { message: selected, text } : null);
704
+ } else if (result === "cancel") done(null);
705
+ else if (result === "render") tui.requestRender();
706
+ };
684
707
  return {
685
708
  render(width: number) {
686
709
  return state.render(width, theme, keybindings, tui.mode);
687
710
  },
688
711
  invalidate() {},
689
- handleInput(data: string) {
690
- const result = state.handleInput(data, keybindings);
691
- if (result === "copy") {
692
- const selected = state.selectedMessage();
693
- const text = state.selectedCopyText();
694
- done(selected && text !== undefined ? { message: selected, text } : null);
695
- return;
696
- }
697
- if (result === "cancel") {
698
- done(null);
699
- return;
700
- }
701
- if (result === "render") tui.requestRender();
712
+ handleInput(data: string) { act(state.handleInput(data, keybindings)); },
713
+ handleMouse(event) {
714
+ const result = state.handleMouse(event);
715
+ act(result);
716
+ if (result !== "none") return { handled: true, focus: result === "render" };
702
717
  },
718
+ dispose: () => signal.removeEventListener("abort", abort),
703
719
  };
704
720
  });
705
721
  }
@@ -708,75 +724,132 @@ function copyNotificationText(selected: CopyableMessage): string {
708
724
  return `Copied ${roleLabel(selected.role)} message: “${compactPreview(selected.text, 48)}”`;
709
725
  }
710
726
 
711
- function copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage, text = selected.text) {
712
- const error = copyToClipboard(text);
713
- if (error) {
714
- ctx.ui.notify(error, "error");
715
- return;
716
- }
727
+ const runClipboardReader = promisify(execFile);
717
728
 
718
- ctx.ui.notify(copyNotificationText(selected), "info");
729
+ async function readCopiedText(expected: string): Promise<string | null | undefined> {
730
+ if (process.platform !== "linux") {
731
+ try {
732
+ const text = await getNativeClipboard()?.getText();
733
+ if (text !== undefined) return text;
734
+ } catch {
735
+ // The platform command fallback can still work if the native helper fails.
736
+ }
737
+ }
738
+ const commands: [string, string[]][] = [];
739
+ const windowsRead = ["-NoProfile", "-Command", "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::Write((Get-Clipboard -Raw))"];
740
+ if (process.platform === "darwin") commands.push(["pbpaste", []]);
741
+ else if (process.platform === "win32") commands.push(["powershell", windowsRead]);
742
+ else {
743
+ if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-get", []]);
744
+ if (process.env.WAYLAND_DISPLAY) commands.push(["wl-paste", ["--no-newline", "--type", "text"]]);
745
+ if (process.env.DISPLAY) commands.push(["xclip", ["-selection", "clipboard", "-out"]], ["xsel", ["--clipboard", "--output"]]);
746
+ if (!commands.length && (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)) commands.push(["powershell.exe", windowsRead]);
747
+ }
748
+ for (const [command, args] of commands) {
749
+ try {
750
+ const { stdout } = await runClipboardReader(command, args, {
751
+ encoding: "utf8", timeout: 3000, maxBuffer: Math.max(1024 * 1024, Buffer.byteLength(expected) + 4096),
752
+ });
753
+ return stdout || null;
754
+ } catch {
755
+ // Try the next platform reader; never expose clipboard contents on failure.
756
+ }
757
+ }
758
+ return undefined;
759
+ }
760
+
761
+ type Notify = ExtensionCommandContext["ui"]["notify"];
762
+
763
+ async function copySelectedMessage(notify: Notify, selected: CopyableMessage, text = selected.text) {
764
+ try {
765
+ await copyToClipboard(text);
766
+ const actual = await readCopiedText(text);
767
+ const remote = Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.MOSH_CONNECTION);
768
+ if (actual === text && !remote) notify(copyNotificationText(selected), "info");
769
+ else notify(
770
+ actual === text ? "Local clipboard verified; remote terminal clipboard delivery is unverified."
771
+ : "Clipboard command completed, but delivery could not be verified.",
772
+ "warning",
773
+ );
774
+ } catch (error) {
775
+ notify(error instanceof Error ? error.message : "Failed to copy to clipboard", "error");
776
+ }
719
777
  }
720
778
 
721
- function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
779
+ async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager">, notify: Notify, format: CopyFormat) {
722
780
  const result = getMostRecentUserMessage(ctx);
723
781
  if (result.kind === "no-user-message") {
724
- ctx.ui.notify("No user messages found", "warning");
782
+ notify("No user messages found", "warning");
725
783
  return;
726
784
  }
727
785
  if (result.kind === "no-text") {
728
- ctx.ui.notify("No user message text found", "warning");
786
+ notify("No user message text found", "warning");
729
787
  return;
730
788
  }
731
-
732
- copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
789
+ await copySelectedMessage(notify, result.message, formatMessageForCopy(result.message, format));
733
790
  }
734
791
 
735
- export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
792
+ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand" | "on">) {
793
+ let generation = 0;
794
+ let pickerAbort: AbortController | undefined;
795
+ const reset = () => {
796
+ generation++;
797
+ pickerAbort?.abort();
798
+ pickerAbort = undefined;
799
+ };
800
+ pi.on("session_start", reset);
801
+ pi.on("session_shutdown", reset);
802
+ pi.on("session_tree", reset);
803
+ const notifications = (ctx: ExtensionCommandContext): Notify => {
804
+ const current = generation;
805
+ return (message, type) => { if (current === generation) ctx.ui.notify(message, type); };
806
+ };
736
807
  pi.registerCommand("copy-message", {
737
808
  description: "Select a session message and copy its text to the clipboard",
738
809
  getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, true),
739
810
  handler: async (args, ctx) => {
811
+ const notify = notifications(ctx);
740
812
  const parsedArgs = parseCopyArgs(args);
741
- const messages = collectCopyableMessages(ctx);
813
+ const messages = parsedArgs.selector === "latest" ? collectProjectedMessages(ctx) : collectCopyableMessages(ctx);
742
814
  if (messages.length === 0) {
743
- ctx.ui.notify("No copyable messages found in the current branch", "error");
815
+ notify("No copyable messages found in the current branch", "error");
744
816
  return;
745
817
  }
746
-
747
818
  if (parsedArgs.selector === "latest") {
748
819
  const latestVisible = latestDefaultMessage(messages);
749
- if (latestVisible) copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
820
+ if (latestVisible) await copySelectedMessage(notify, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
750
821
  return;
751
822
  }
752
-
753
823
  if (typeof parsedArgs.selector === "object") {
754
824
  const selected = messageByDefaultNumber(messages, parsedArgs.selector.number);
755
825
  if (!selected) {
756
- ctx.ui.notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
826
+ notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
757
827
  return;
758
828
  }
759
- copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
829
+ await copySelectedMessage(notify, selected, formatMessageForCopy(selected, parsedArgs.format));
760
830
  return;
761
831
  }
762
-
763
832
  if (ctx.mode !== "tui") {
764
- ctx.ui.notify("/copy-message requires interactive TUI mode unless you pass latest/last/newest or a message number", "error");
833
+ notify("/copy-message requires interactive TUI mode unless you pass latest/last/newest or a message number", "error");
765
834
  return;
766
835
  }
767
-
768
- const selected = await pickMessage(ctx, messages, parsedArgs.format);
769
- if (!selected) return;
770
-
771
- copySelectedMessage(ctx, selected.message, selected.text);
836
+ pickerAbort?.abort();
837
+ const controller = new AbortController();
838
+ pickerAbort = controller;
839
+ try {
840
+ const selected = await pickMessage(ctx, messages, parsedArgs.format, controller.signal);
841
+ if (!selected || controller.signal.aborted) return;
842
+ await copySelectedMessage(notify, selected.message, selected.text);
843
+ } finally {
844
+ if (pickerAbort === controller) pickerAbort = undefined;
845
+ }
772
846
  },
773
847
  });
774
-
775
848
  pi.registerCommand("copy-user", {
776
849
  description: "Copy the most recent user message to the clipboard",
777
850
  getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, false),
778
851
  handler: async (args, ctx) => {
779
- copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
852
+ await copyMostRecentUserMessage(ctx, notifications(ctx), parseCopyArgs(args).format);
780
853
  },
781
854
  });
782
855
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-copy-message",
3
- "version": "2.1.0",
3
+ "version": "4.0.0",
4
4
  "description": "Pi extension with a searchable TUI picker for copying raw session messages",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "engines": {
9
- "node": ">=22.19.0"
9
+ "node": ">=24.15.0"
10
10
  },
11
11
  "keywords": [
12
12
  "pi-package",
@@ -32,10 +32,11 @@
32
32
  },
33
33
  "homepage": "https://github.com/fitchmultz/pi-copy-message#readme",
34
34
  "scripts": {
35
- "test": "tsx tests/copy-message.test.ts",
35
+ "test": "node --test tests/*.test.ts",
36
36
  "typecheck": "tsc --noEmit",
37
- "check": "npm test && npm run typecheck",
38
- "prepublishOnly": "npm run check && npm pack --dry-run"
37
+ "check": "npm test && npm run typecheck && npm pack --dry-run",
38
+ "check:compat": "npm run check",
39
+ "prepublishOnly": "npm run check"
39
40
  },
40
41
  "files": [
41
42
  "extensions",
@@ -44,18 +45,17 @@
44
45
  "LICENSE"
45
46
  ],
46
47
  "devDependencies": {
47
- "@earendil-works/pi-coding-agent": "0.84.0",
48
- "@earendil-works/pi-tui": "0.84.0",
49
- "@types/node": "^25.9.1",
50
- "tsx": "^4.22.4",
51
- "typescript": "^6.0.3"
48
+ "@earendil-works/pi-coding-agent": "1.0.0",
49
+ "@earendil-works/pi-tui": "1.0.0",
50
+ "@types/node": "24.19.0",
51
+ "typescript": "7.0.2"
52
52
  },
53
53
  "pi": {
54
54
  "extensions": [
55
55
  "./extensions"
56
56
  ]
57
57
  },
58
- "packageManager": "npm@11.16.0",
58
+ "packageManager": "npm@12.2.0",
59
59
  "peerDependencies": {
60
60
  "@earendil-works/pi-coding-agent": "*",
61
61
  "@earendil-works/pi-tui": "*"