pi-copy-message 3.0.0 → 4.0.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 4.0.1 - 2026-10-31
6
+
7
+ - Stop enumerating graphemes once a message preview is known to need truncation; preserve preview and copied text.
8
+
9
+ - Wrap only enough of a long message to fill the `Tab` peek instead of the whole message; very long messages note that more lines remain instead of counting them. Copied text is unchanged.
10
+
11
+ - Cancel pending copy pickers when the host disposes their UI, including during reload, without interrupting sibling dialog teardown.
12
+
13
+ ## 4.0.0 - 2026-10-01
14
+
15
+ - Require Pi 1.0.0 and pin the official 1.0.0 development cohort.
16
+ - 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.
17
+ - 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.
18
+ - Cancel pending pickers on session/tree/shutdown lifecycle changes; suppress stale completion notifications.
19
+ - Add native mouse navigation/copy with restored editor focus and safe isolated clipboard/SDK lifecycle regression coverage.
20
+
3
21
  ## 3.0.0 - 2026-09-26
4
22
 
5
23
  - Require Node.js 24.15 or newer for repository tooling; `.nvmrc` pins 24.21.0 and `packageManager` is npm 12.1.0.
package/README.md CHANGED
@@ -1,153 +1,74 @@
1
- # pi copy-message extension
1
+ # pi-copy-message
2
2
 
3
- A [pi](https://github.com/earendil-works/pi-mono) extension that adds `/copy-message`: a keyboard-first picker for copying raw session message text without terminal wrapping, padding, or rendered TUI artifacts. It also adds `/copy-user` as a direct shortcut for the most recent user message.
3
+ Copy message text from a [Pi](https://github.com/earendil-works/pi-mono) session with a searchable picker. The copied text keeps the original code, spaces, and line breaks.
4
4
 
5
- `pi-copy-message` supersedes [`pi-copy-user-message`](https://github.com/fitchmultz/pi-copy-user-message), which only copied the most recent user message.
5
+ ![Flow from the /copy-message command through message search and selection to the clipboard.](.github/readme/copy-flow.png)
6
6
 
7
- ## What it does
7
+ ## Install and start
8
8
 
9
- - Adds `/copy-message`
10
- - Adds `/copy-user` for copying the most recent user message directly
11
- - Copies raw stored session message text, not rendered terminal lines
12
- - Shows messages in chat order: oldest at top, newest at bottom
13
- - Selects the newest visible message by default
14
- - Supports role filters for user, assistant, tool/bash, and custom messages
15
- - Hides tool/bash messages by default
16
- - Supports type-to-filter search across role and message text, with `time:<term>` for timestamp search
17
- - Supports Home/End jumps for oldest/newest visible messages
18
- - Uses Pi's configured selection bindings for navigation, copy, and cancel
19
- - Uses Pi's clipboard implementation, including its OSC 52 terminal fallback
20
- - Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`
21
- - Supports direct numbered copies like `/copy-message 3`
22
- - Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
9
+ This extension requires Pi and Node.js. See the [development guide](https://github.com/fitchmultz/pi-copy-message/blob/main/docs/development.md) for version requirements.
23
10
 
24
- ## Install
25
-
26
- Install it from npm with pi:
11
+ Run this command:
27
12
 
28
13
  ```bash
29
14
  pi install npm:pi-copy-message
30
15
  ```
31
16
 
32
- Or install it directly from GitHub with pi:
33
-
34
- ```bash
35
- pi install https://github.com/fitchmultz/pi-copy-message
36
- ```
37
-
38
- Restart Pi after installing or updating extension code or dependencies. The maintained fork's `/reload` refreshes resources and reinitializes cached code; it does not apply code updates.
39
-
40
- If you prefer to load it directly from a local checkout during development:
41
-
42
- ```bash
43
- pi -e ./extensions/copy-message.ts
44
- ```
45
-
46
- ## Usage
17
+ Start Pi. Run `/reload` if Pi is already open.
47
18
 
48
- Open the picker:
19
+ Enter this command in Pi:
49
20
 
50
21
  ```text
51
22
  /copy-message
52
23
  ```
53
24
 
54
- Copy the most recent user message directly:
25
+ Type words to search roles and message text. Use ↑ or ↓ to select a message. Press Enter to copy the message.
55
26
 
56
- ```text
57
- /copy-user
58
- ```
27
+ ## Use the picker
59
28
 
60
- Copy the latest visible default message directly:
29
+ Messages appear from oldest to newest. The picker selects the newest visible message first.
61
30
 
62
- ```text
63
- /copy-message latest
64
- ```
31
+ User, assistant, and visible custom messages appear by default. Tool and bash messages stay hidden.
65
32
 
66
- Aliases:
33
+ | Key | Action |
34
+ |---|---|
35
+ | Ctrl+T | Show or hide tool and bash messages |
36
+ | Ctrl+U / Ctrl+A / Alt+C | Show or hide user, assistant, or custom messages |
37
+ | Tab | Preview the selected message |
38
+ | Alt+M | Switch between raw text and text with role/time metadata |
39
+ | Esc / Ctrl+C | Cancel |
67
40
 
68
- ```text
69
- /copy-message last
70
- /copy-message newest
71
- ```
41
+ The picker uses your Pi selection bindings. Its key hints show those bindings.
72
42
 
73
- Copy the 3rd default-visible message directly, matching the picker's 1-based oldest-to-newest numbering:
43
+ ## Direct copies
74
44
 
75
- ```text
76
- /copy-message 3
77
- ```
45
+ | Command | What it copies |
46
+ |---|---|
47
+ | `/copy-user` | Your most recent message with text |
48
+ | `/copy-message latest` | The latest default-visible message in Pi's active context |
49
+ | `/copy-message 3` | The third default-visible message, counted oldest to newest |
50
+ | `/copy-message latest --with-meta` | The latest message with its role and local time, when available |
78
51
 
79
- Copy with role and timestamp metadata instead of raw text only:
52
+ `latest` follows Pi's active context, including context edits, omissions, and compaction. The picker, numbered copies, and `/copy-user` use stored messages on the current branch, including history before compaction.
80
53
 
81
- ```text
82
- /copy-message latest --with-meta
83
- /copy-message 3 --with-role
84
- /copy-user --with-meta
85
- ```
54
+ ## Limits and clipboard
86
55
 
87
- ## Keyboard controls
56
+ The picker requires interactive TUI mode. Direct commands also work outside TUI mode, where Pi may hide notifications.
88
57
 
89
- | Key | Action |
90
- |---|---|
91
- | Configured `tui.select.up` (default: `↑`) | Move to older visible message |
92
- | Configured `tui.select.down` (default: `↓`) | Move to newer visible message |
93
- | Configured `tui.select.pageUp` / `tui.select.pageDown` (default: `PageUp` / `PageDown`) | Move by one visible page in regular mode |
94
- | `Ctrl+PageUp` / `Ctrl+PageDown` | Move by one visible page in fullscreen mode |
95
- | `Home` / `End` | Jump to oldest/newest visible message in regular mode |
96
- | `Ctrl+Home` / `Ctrl+End` | Jump to oldest/newest visible message in fullscreen mode |
97
- | Type or paste text | Filter visible messages; pasted line breaks and tabs become spaces |
98
- | `time:<term>` | Search timestamps |
99
- | `Backspace` | Delete one search character |
100
- | `Ctrl+U` | Toggle user messages |
101
- | `Ctrl+A` | Toggle assistant messages |
102
- | `Ctrl+T` | Toggle tool/bash messages |
103
- | `Alt+C` | Toggle custom messages |
104
- | `Tab` | Toggle a wrapped preview of the selected message |
105
- | `Alt+M` | Toggle raw vs metadata copy format |
106
- | Configured `tui.select.confirm` (default: `Enter`) | Copy selected message text |
107
- | Configured `tui.select.cancel` (default: `Esc`/`Ctrl+C`) | Cancel |
108
-
109
- ## Behavior notes
110
-
111
- - Entry IDs are hidden from the picker.
112
- - The picker caps visible rows and scrolls instead of filling the screen.
113
- - Search preserves your original selected message and restores it when the search is cleared.
114
- - General search does not match timestamps; use `time:<term>` when you want to search by displayed time.
115
- - Picker previews and copy notifications remove terminal control characters; the copied text stays raw.
116
- - Filter labels honor the active pi theme.
117
- - 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.
118
- - 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`.
119
- - Copy notifications include the role and a short preview so you can verify what was copied.
120
- - `/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.
121
- - `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
122
- - 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.
123
-
124
- ## Compatibility
125
-
126
- - Declared Pi floor: 0.84.0. Qualified on official Pi 0.87.1 and the maintained fork's `main` (not a claim that every later release has been tested).
127
- - The maintained fork persists in-progress assistant snapshots and context-window boundaries; the picker coalesces those so each response appears once. Official Pi writes neither, and the same code handles both.
128
- - Android/Termux clipboard writes use Pi's normal fallback chain, including OSC 52 when native clipboard commands are unavailable.
129
- - Pi includes `Ctrl+X` for copying the latest assistant response; this extension remains useful for searchable history, other roles, metadata, and direct selectors.
130
- - Node.js `>=24.15.0`. `.nvmrc` pins Node 24.21.0 for local development.
131
-
132
- 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.
133
-
134
- ## Development
58
+ The extension uses Pi's clipboard support and OSC 52 fallback. It reports “Copied” only after local clipboard readback matches the copied text.
135
59
 
136
- ```bash
137
- npm ci
138
- npm run check # node --test + tsc --noEmit + pack dry-run
139
- ```
60
+ Unverified delivery produces a warning. A failed write produces an error.
140
61
 
141
- 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 picker behavior and formatting without clipboard writes; native clipboard delivery, including Android/Termux and OSC 52, requires separate platform qualification.
62
+ The extension cannot verify remote terminal delivery. Paste into a local editor to check delivery.
142
63
 
143
- `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:
64
+ Restart Pi after you change dependencies or the Pi runtime.
144
65
 
145
- ```bash
146
- npm install --package-lock-only --ignore-scripts --registry=https://registry.npmjs.org/
147
- npm ci
148
- ```
66
+ ## Docs and support
67
+
68
+ The [reference](https://github.com/fitchmultz/pi-copy-message/blob/main/docs/reference.md) lists all controls, command aliases, and clipboard details. The [development guide](https://github.com/fitchmultz/pi-copy-message/blob/main/docs/development.md) covers tests and releases.
69
+
70
+ [Changelog](CHANGELOG.md) · [Report an issue](https://github.com/fitchmultz/pi-copy-message/issues)
149
71
 
150
- Key files:
72
+ ## License
151
73
 
152
- - `extensions/copy-message.ts` — publishable extension implementation
153
- - `tests/copy-message.test.ts` — regression tests for command wiring, filtering, search, navigation, and copy formatting
74
+ [MIT](LICENSE) · Mitch Fultz
@@ -1,9 +1,12 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
1
3
  import { copyToClipboard, type ExtensionAPI, type ExtensionCommandContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
4
  import type { AutocompleteItem, TuiMode } from "@earendil-works/pi-tui";
3
- 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";
4
6
 
5
7
  const MAX_VISIBLE_MESSAGES = 8;
6
8
  const MAX_PEEK_LINES = 16;
9
+ const PEEK_WRAP_CHARS = 65_536;
7
10
 
8
11
  export type CopyFormat = "raw" | "metadata";
9
12
 
@@ -63,10 +66,14 @@ function splitGraphemes(text: string): string[] {
63
66
 
64
67
  function truncateGraphemes(text: string, max: number): string {
65
68
  if (max <= 0) return "";
66
- const graphemes = splitGraphemes(text);
67
- if (graphemes.length <= max) return text;
68
- if (max === 1) return "…";
69
- return `${graphemes.slice(0, max - 1).join("")}…`;
69
+ let count = 0;
70
+ let prefixEnd = 0;
71
+ for (const { index } of graphemeSegmenter.segment(text)) {
72
+ if (count === max) return `${text.slice(0, prefixEnd)}…`;
73
+ if (count === max - 1) prefixEnd = index;
74
+ count++;
75
+ }
76
+ return text;
70
77
  }
71
78
 
72
79
  function safeDisplayText(text: string): string {
@@ -169,6 +176,17 @@ export function collectCopyableMessages(ctx: { sessionManager: { getBranch(): un
169
176
  });
170
177
  }
171
178
 
179
+ function collectProjectedMessages(ctx: ExtensionCommandContext): CopyableMessage[] {
180
+ const entries = ctx.sessionManager.buildSessionProjection().entries.flatMap(({ sourceEntry, messages }): unknown[] => {
181
+ // Retain saved fork boundaries for legacy response-snapshot coalescing.
182
+ if ((sourceEntry.type as string) === "context_window") return [sourceEntry];
183
+ return messages
184
+ .filter((message) => !(sourceEntry.type === "compaction" && message.role === "system"))
185
+ .map((message) => ({ ...sourceEntry, type: "message", message }));
186
+ });
187
+ return collectCopyableMessages({ sessionManager: { getBranch: () => entries } });
188
+ }
189
+
172
190
  export type MostRecentUserMessageResult =
173
191
  | { kind: "message"; message: CopyableMessage }
174
192
  | { kind: "no-user-message" }
@@ -244,7 +262,7 @@ function messageMatchesSearch(message: CopyableMessage, search: string): boolean
244
262
  });
245
263
  }
246
264
 
247
- export function filteredMessages(messages: CopyableMessage[], visibility: MessageVisibility, search = ""): CopyableMessage[] {
265
+ function filteredMessages(messages: CopyableMessage[], visibility: MessageVisibility, search = ""): CopyableMessage[] {
248
266
  return messages.filter((message) => isVisibleMessage(message, visibility) && messageMatchesSearch(message, search));
249
267
  }
250
268
 
@@ -261,7 +279,7 @@ export function messageByDefaultNumber(messages: CopyableMessage[], number: numb
261
279
  return defaultVisibleMessages(messages)[number - 1];
262
280
  }
263
281
 
264
- export function formatMessageForCopy(message: CopyableMessage, format: CopyFormat): string {
282
+ function formatMessageForCopy(message: CopyableMessage, format: CopyFormat): string {
265
283
  if (format === "raw") return message.text;
266
284
  const time = formatTime(message.timestamp);
267
285
  const label = roleLabel(message.role);
@@ -326,13 +344,25 @@ function renderMessageLine(
326
344
 
327
345
  function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMessageTheme, format: CopyFormat): string[] {
328
346
  const contentWidth = Math.max(1, width - 2);
329
- const text = safeDisplayText(formatMessageForCopy(message, format));
330
- const wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, text, false), contentWidth);
347
+ let wrapped: string[];
348
+ let complete: boolean;
349
+ // Wrap a growing prefix instead of the whole message. A cut prefix ends on visible text: styling
350
+ // codes attached to a trailing whitespace run would make the wrapper break it into blank lines that
351
+ // a full wrap drops. Greedy wrapping then changes only the prefix's last two wrapped lines, so stop
352
+ // once those lines fall outside the visible window.
353
+ for (let chars = PEEK_WRAP_CHARS; ; chars *= 2) {
354
+ complete = chars >= message.text.length;
355
+ const prefix = complete ? message : { ...message, text: message.text.slice(0, chars) };
356
+ const text = safeDisplayText(formatMessageForCopy(prefix, format));
357
+ wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, complete ? text : text.trimEnd(), false), contentWidth);
358
+ if (complete || wrapped.length >= MAX_PEEK_LINES + 2) break;
359
+ }
331
360
  const shown = wrapped.slice(0, MAX_PEEK_LINES);
332
361
  const remaining = wrapped.length - shown.length;
333
362
  const title = theme.fg("dim", `Peek ${format === "metadata" ? "metadata" : "raw"} ${roleLabel(message.role)} message`);
334
363
  const lines = [title, ...shown.map((line) => ` ${line}`)];
335
- if (remaining > 0) lines.push(theme.fg("dim", ` … ${remaining} more wrapped line${remaining === 1 ? "" : "s"}`));
364
+ if (!complete) lines.push(theme.fg("dim", " … more wrapped lines"));
365
+ else if (remaining > 0) lines.push(theme.fg("dim", ` … ${remaining} more wrapped line${remaining === 1 ? "" : "s"}`));
336
366
  return lines;
337
367
  }
338
368
 
@@ -414,6 +444,7 @@ export class CopyMessagePickerState {
414
444
  peek = false;
415
445
  private searchAnchorId: string | undefined;
416
446
  private readonly messages: CopyableMessage[];
447
+ private firstVisibleIndex = 0;
417
448
 
418
449
  constructor(messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
419
450
  this.messages = messages;
@@ -435,6 +466,7 @@ export class CopyMessagePickerState {
435
466
  const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
436
467
  const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
437
468
  const end = Math.min(this.visibleMessages.length, start + maxVisible);
469
+ this.firstVisibleIndex = start;
438
470
  const hasCustomMessages = this.messages.some((message) => message.role === "custom");
439
471
  const userState = filterLabel(theme, "user", this.visibility.showUser, "warning");
440
472
  const assistantState = filterLabel(theme, "assistant", this.visibility.showAssistant, "accent");
@@ -584,6 +616,20 @@ export class CopyMessagePickerState {
584
616
  return "none";
585
617
  }
586
618
 
619
+ handleMouse(event: TuiMouseEvent): PickerInputResult {
620
+ if (event.type === "wheel" && event.wheelDelta) {
621
+ this.move(event.wheelDelta < 0 ? -1 : 1);
622
+ return "render";
623
+ }
624
+ if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return "none";
625
+ const row = event.y - 2; // The message list follows the title and spacer.
626
+ if (row < 0 || row >= MAX_VISIBLE_MESSAGES) return "none";
627
+ const index = this.firstVisibleIndex + row;
628
+ if (!this.visibleMessages[index]) return "none";
629
+ this.selectedIndex = index;
630
+ return event.type === "click" ? "copy" : "render";
631
+ }
632
+
587
633
  private refreshMessages(preferredId?: string) {
588
634
  const selectedId = preferredId ?? this.visibleMessages[this.selectedIndex]?.id;
589
635
  this.visibleMessages = filteredMessages(this.messages, this.visibility, this.search);
@@ -650,7 +696,7 @@ function parseCopyArgs(args: string | undefined): ParsedCopyMessageArgs {
650
696
  return result;
651
697
  }
652
698
 
653
- export function copyArgumentCompletions(prefix: string, includeSelectors: boolean): AutocompleteItem[] | null {
699
+ function copyArgumentCompletions(prefix: string, includeSelectors: boolean): AutocompleteItem[] | null {
654
700
  if (/^\d+$/.test(prefix)) return null;
655
701
  const candidates = includeSelectors ? [...COPY_LATEST_SELECTORS, ...COPY_METADATA_FLAGS] : COPY_METADATA_FLAGS;
656
702
  const normalized = prefix.toLowerCase();
@@ -660,27 +706,44 @@ export function copyArgumentCompletions(prefix: string, includeSelectors: boolea
660
706
  return items.length > 0 ? items : null;
661
707
  }
662
708
 
663
- async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat) {
709
+ async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat, signal: AbortSignal) {
710
+ if (signal.aborted) return null;
664
711
  return ctx.ui.custom<{ message: CopyableMessage; text: string } | null>((tui, theme, keybindings, done) => {
665
712
  const state = new CopyMessagePickerState(messages, initialFormat);
713
+ let settled = false;
714
+ const finish = (result: Parameters<typeof done>[0], defer = false) => {
715
+ if (settled) return;
716
+ settled = true;
717
+ if (defer) queueMicrotask(() => done(result));
718
+ else done(result);
719
+ };
720
+ const abort = () => finish(null);
721
+ if (signal.aborted) abort();
722
+ else signal.addEventListener("abort", abort, { once: true });
723
+ const act = (result: PickerInputResult) => {
724
+ if (result === "copy") {
725
+ const selected = state.selectedMessage();
726
+ const text = state.selectedCopyText();
727
+ finish(selected && text !== undefined ? { message: selected, text } : null);
728
+ } else if (result === "cancel") finish(null);
729
+ else if (result === "render") tui.requestRender();
730
+ };
666
731
  return {
667
732
  render(width: number) {
668
733
  return state.render(width, theme, keybindings, tui.mode);
669
734
  },
670
735
  invalidate() {},
671
- handleInput(data: string) {
672
- const result = state.handleInput(data, keybindings);
673
- if (result === "copy") {
674
- const selected = state.selectedMessage();
675
- const text = state.selectedCopyText();
676
- done(selected && text !== undefined ? { message: selected, text } : null);
677
- return;
678
- }
679
- if (result === "cancel") {
680
- done(null);
681
- return;
682
- }
683
- if (result === "render") tui.requestRender();
736
+ handleInput(data: string) { act(state.handleInput(data, keybindings)); },
737
+ handleMouse(event) {
738
+ const result = state.handleMouse(event);
739
+ act(result);
740
+ if (result !== "none") return { handled: true, focus: result === "render" };
741
+ },
742
+ dispose() {
743
+ signal.removeEventListener("abort", abort);
744
+ // Settle even if disposal precedes session_shutdown. Defer the host callback:
745
+ // it can remove this component from a live dialog-disposal iteration.
746
+ finish(null, true);
684
747
  },
685
748
  };
686
749
  });
@@ -690,74 +753,132 @@ function copyNotificationText(selected: CopyableMessage): string {
690
753
  return `Copied ${roleLabel(selected.role)} message: “${compactPreview(selected.text, 48)}”`;
691
754
  }
692
755
 
693
- async function copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage, text = selected.text) {
756
+ const runClipboardReader = promisify(execFile);
757
+
758
+ async function readCopiedText(expected: string): Promise<string | null | undefined> {
759
+ if (process.platform !== "linux") {
760
+ try {
761
+ const text = await getNativeClipboard()?.getText();
762
+ if (text !== undefined) return text;
763
+ } catch {
764
+ // The platform command fallback can still work if the native helper fails.
765
+ }
766
+ }
767
+ const commands: [string, string[]][] = [];
768
+ const windowsRead = ["-NoProfile", "-Command", "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::Write((Get-Clipboard -Raw))"];
769
+ if (process.platform === "darwin") commands.push(["pbpaste", []]);
770
+ else if (process.platform === "win32") commands.push(["powershell", windowsRead]);
771
+ else {
772
+ if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-get", []]);
773
+ if (process.env.WAYLAND_DISPLAY) commands.push(["wl-paste", ["--no-newline", "--type", "text"]]);
774
+ if (process.env.DISPLAY) commands.push(["xclip", ["-selection", "clipboard", "-out"]], ["xsel", ["--clipboard", "--output"]]);
775
+ if (!commands.length && (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)) commands.push(["powershell.exe", windowsRead]);
776
+ }
777
+ for (const [command, args] of commands) {
778
+ try {
779
+ const { stdout } = await runClipboardReader(command, args, {
780
+ encoding: "utf8", timeout: 3000, maxBuffer: Math.max(1024 * 1024, Buffer.byteLength(expected) + 4096),
781
+ });
782
+ return stdout || null;
783
+ } catch {
784
+ // Try the next platform reader; never expose clipboard contents on failure.
785
+ }
786
+ }
787
+ return undefined;
788
+ }
789
+
790
+ type Notify = ExtensionCommandContext["ui"]["notify"];
791
+
792
+ async function copySelectedMessage(notify: Notify, selected: CopyableMessage, text = selected.text) {
694
793
  try {
695
794
  await copyToClipboard(text);
696
- ctx.ui.notify(copyNotificationText(selected), "info");
795
+ const actual = await readCopiedText(text);
796
+ const remote = Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.MOSH_CONNECTION);
797
+ if (actual === text && !remote) notify(copyNotificationText(selected), "info");
798
+ else notify(
799
+ actual === text ? "Local clipboard verified; remote terminal clipboard delivery is unverified."
800
+ : "Clipboard command completed, but delivery could not be verified.",
801
+ "warning",
802
+ );
697
803
  } catch (error) {
698
- ctx.ui.notify(error instanceof Error ? error.message : "Failed to copy to clipboard", "error");
804
+ notify(error instanceof Error ? error.message : "Failed to copy to clipboard", "error");
699
805
  }
700
806
  }
701
807
 
702
- async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
808
+ async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager">, notify: Notify, format: CopyFormat) {
703
809
  const result = getMostRecentUserMessage(ctx);
704
810
  if (result.kind === "no-user-message") {
705
- ctx.ui.notify("No user messages found", "warning");
811
+ notify("No user messages found", "warning");
706
812
  return;
707
813
  }
708
814
  if (result.kind === "no-text") {
709
- ctx.ui.notify("No user message text found", "warning");
815
+ notify("No user message text found", "warning");
710
816
  return;
711
817
  }
712
-
713
- await copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
818
+ await copySelectedMessage(notify, result.message, formatMessageForCopy(result.message, format));
714
819
  }
715
820
 
716
- export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
821
+ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand" | "on">) {
822
+ let generation = 0;
823
+ let pickerAbort: AbortController | undefined;
824
+ const reset = () => {
825
+ generation++;
826
+ pickerAbort?.abort();
827
+ pickerAbort = undefined;
828
+ };
829
+ pi.on("session_start", reset);
830
+ pi.on("session_shutdown", reset);
831
+ pi.on("session_tree", reset);
832
+ const notifications = (ctx: ExtensionCommandContext): Notify => {
833
+ const current = generation;
834
+ return (message, type) => { if (current === generation) ctx.ui.notify(message, type); };
835
+ };
717
836
  pi.registerCommand("copy-message", {
718
837
  description: "Select a session message and copy its text to the clipboard",
719
838
  getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, true),
720
839
  handler: async (args, ctx) => {
840
+ const notify = notifications(ctx);
721
841
  const parsedArgs = parseCopyArgs(args);
722
- const messages = collectCopyableMessages(ctx);
842
+ const messages = parsedArgs.selector === "latest" ? collectProjectedMessages(ctx) : collectCopyableMessages(ctx);
723
843
  if (messages.length === 0) {
724
- ctx.ui.notify("No copyable messages found in the current branch", "error");
844
+ notify("No copyable messages found in the current branch", "error");
725
845
  return;
726
846
  }
727
-
728
847
  if (parsedArgs.selector === "latest") {
729
848
  const latestVisible = latestDefaultMessage(messages);
730
- if (latestVisible) await copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
849
+ if (latestVisible) await copySelectedMessage(notify, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
731
850
  return;
732
851
  }
733
-
734
852
  if (typeof parsedArgs.selector === "object") {
735
853
  const selected = messageByDefaultNumber(messages, parsedArgs.selector.number);
736
854
  if (!selected) {
737
- ctx.ui.notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
855
+ notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
738
856
  return;
739
857
  }
740
- await copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
858
+ await copySelectedMessage(notify, selected, formatMessageForCopy(selected, parsedArgs.format));
741
859
  return;
742
860
  }
743
-
744
861
  if (ctx.mode !== "tui") {
745
- ctx.ui.notify("/copy-message requires interactive TUI mode unless you pass latest/last/newest or a message number", "error");
862
+ notify("/copy-message requires interactive TUI mode unless you pass latest/last/newest or a message number", "error");
746
863
  return;
747
864
  }
748
-
749
- const selected = await pickMessage(ctx, messages, parsedArgs.format);
750
- if (!selected) return;
751
-
752
- await copySelectedMessage(ctx, selected.message, selected.text);
865
+ pickerAbort?.abort();
866
+ const controller = new AbortController();
867
+ pickerAbort = controller;
868
+ try {
869
+ const selected = await pickMessage(ctx, messages, parsedArgs.format, controller.signal);
870
+ if (!selected || controller.signal.aborted) return;
871
+ await copySelectedMessage(notify, selected.message, selected.text);
872
+ } finally {
873
+ if (pickerAbort === controller) pickerAbort = undefined;
874
+ }
753
875
  },
754
876
  });
755
-
756
877
  pi.registerCommand("copy-user", {
757
878
  description: "Copy the most recent user message to the clipboard",
758
879
  getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, false),
759
880
  handler: async (args, ctx) => {
760
- await copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
881
+ await copyMostRecentUserMessage(ctx, notifications(ctx), parseCopyArgs(args).format);
761
882
  },
762
883
  });
763
884
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-copy-message",
3
- "version": "3.0.0",
3
+ "version": "4.0.1",
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",
@@ -45,9 +45,9 @@
45
45
  "LICENSE"
46
46
  ],
47
47
  "devDependencies": {
48
- "@earendil-works/pi-coding-agent": "0.87.1",
49
- "@earendil-works/pi-tui": "0.87.1",
50
- "@types/node": "24.19.0",
48
+ "@earendil-works/pi-coding-agent": "1.0.0",
49
+ "@earendil-works/pi-tui": "1.0.0",
50
+ "@types/node": "24.19.1",
51
51
  "typescript": "7.0.2"
52
52
  },
53
53
  "pi": {
@@ -55,7 +55,7 @@
55
55
  "./extensions"
56
56
  ]
57
57
  },
58
- "packageManager": "npm@12.1.0",
58
+ "packageManager": "npm@12.2.0",
59
59
  "peerDependencies": {
60
60
  "@earendil-works/pi-coding-agent": "*",
61
61
  "@earendil-works/pi-tui": "*"