pi-copy-message 3.0.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,13 @@
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
+
3
11
  ## 3.0.0 - 2026-09-26
4
12
 
5
13
  - 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
@@ -16,8 +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
- - 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`
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
21
21
  - Supports direct numbered copies like `/copy-message 3`
22
22
  - Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
23
23
 
@@ -35,7 +35,7 @@ Or install it directly from GitHub with pi:
35
35
  pi install https://github.com/fitchmultz/pi-copy-message
36
36
  ```
37
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.
38
+ Use `/reload` after updating extension code on current official Pi and the maintained fork. Restart Pi after changing dependencies or the host runtime.
39
39
 
40
40
  If you prefer to load it directly from a local checkout during development:
41
41
 
@@ -109,6 +109,8 @@ Copy with role and timestamp metadata instead of raw text only:
109
109
  ## Behavior notes
110
110
 
111
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.
112
114
  - The picker caps visible rows and scrolls instead of filling the screen.
113
115
  - Search preserves your original selected message and restores it when the search is cleared.
114
116
  - General search does not match timestamps; use `time:<term>` when you want to search by displayed time.
@@ -116,15 +118,17 @@ Copy with role and timestamp metadata instead of raw text only:
116
118
  - Filter labels honor the active pi theme.
117
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.
118
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`.
119
- - 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.
120
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.
121
125
  - `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
122
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.
123
127
 
124
128
  ## Compatibility
125
129
 
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.
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.
128
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
134
  - Node.js `>=24.15.0`. `.nvmrc` pins Node 24.21.0 for local development.
@@ -138,7 +142,7 @@ npm ci
138
142
  npm run check # node --test + tsc --noEmit + pack dry-run
139
143
  ```
140
144
 
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.
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.
142
146
 
143
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:
144
148
 
@@ -1,6 +1,8 @@
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;
@@ -169,6 +171,17 @@ export function collectCopyableMessages(ctx: { sessionManager: { getBranch(): un
169
171
  });
170
172
  }
171
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
+
172
185
  export type MostRecentUserMessageResult =
173
186
  | { kind: "message"; message: CopyableMessage }
174
187
  | { kind: "no-user-message" }
@@ -414,6 +427,7 @@ export class CopyMessagePickerState {
414
427
  peek = false;
415
428
  private searchAnchorId: string | undefined;
416
429
  private readonly messages: CopyableMessage[];
430
+ private firstVisibleIndex = 0;
417
431
 
418
432
  constructor(messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
419
433
  this.messages = messages;
@@ -435,6 +449,7 @@ export class CopyMessagePickerState {
435
449
  const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
436
450
  const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
437
451
  const end = Math.min(this.visibleMessages.length, start + maxVisible);
452
+ this.firstVisibleIndex = start;
438
453
  const hasCustomMessages = this.messages.some((message) => message.role === "custom");
439
454
  const userState = filterLabel(theme, "user", this.visibility.showUser, "warning");
440
455
  const assistantState = filterLabel(theme, "assistant", this.visibility.showAssistant, "accent");
@@ -584,6 +599,20 @@ export class CopyMessagePickerState {
584
599
  return "none";
585
600
  }
586
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
+
587
616
  private refreshMessages(preferredId?: string) {
588
617
  const selectedId = preferredId ?? this.visibleMessages[this.selectedIndex]?.id;
589
618
  this.visibleMessages = filteredMessages(this.messages, this.visibility, this.search);
@@ -660,28 +689,33 @@ export function copyArgumentCompletions(prefix: string, includeSelectors: boolea
660
689
  return items.length > 0 ? items : null;
661
690
  }
662
691
 
663
- 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;
664
694
  return ctx.ui.custom<{ message: CopyableMessage; text: string } | null>((tui, theme, keybindings, done) => {
665
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
+ };
666
707
  return {
667
708
  render(width: number) {
668
709
  return state.render(width, theme, keybindings, tui.mode);
669
710
  },
670
711
  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();
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" };
684
717
  },
718
+ dispose: () => signal.removeEventListener("abort", abort),
685
719
  };
686
720
  });
687
721
  }
@@ -690,74 +724,132 @@ function copyNotificationText(selected: CopyableMessage): string {
690
724
  return `Copied ${roleLabel(selected.role)} message: “${compactPreview(selected.text, 48)}”`;
691
725
  }
692
726
 
693
- async function copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage, text = selected.text) {
727
+ const runClipboardReader = promisify(execFile);
728
+
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) {
694
764
  try {
695
765
  await copyToClipboard(text);
696
- ctx.ui.notify(copyNotificationText(selected), "info");
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
+ );
697
774
  } catch (error) {
698
- ctx.ui.notify(error instanceof Error ? error.message : "Failed to copy to clipboard", "error");
775
+ notify(error instanceof Error ? error.message : "Failed to copy to clipboard", "error");
699
776
  }
700
777
  }
701
778
 
702
- async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
779
+ async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager">, notify: Notify, format: CopyFormat) {
703
780
  const result = getMostRecentUserMessage(ctx);
704
781
  if (result.kind === "no-user-message") {
705
- ctx.ui.notify("No user messages found", "warning");
782
+ notify("No user messages found", "warning");
706
783
  return;
707
784
  }
708
785
  if (result.kind === "no-text") {
709
- ctx.ui.notify("No user message text found", "warning");
786
+ notify("No user message text found", "warning");
710
787
  return;
711
788
  }
712
-
713
- await copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
789
+ await copySelectedMessage(notify, result.message, formatMessageForCopy(result.message, format));
714
790
  }
715
791
 
716
- 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
+ };
717
807
  pi.registerCommand("copy-message", {
718
808
  description: "Select a session message and copy its text to the clipboard",
719
809
  getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, true),
720
810
  handler: async (args, ctx) => {
811
+ const notify = notifications(ctx);
721
812
  const parsedArgs = parseCopyArgs(args);
722
- const messages = collectCopyableMessages(ctx);
813
+ const messages = parsedArgs.selector === "latest" ? collectProjectedMessages(ctx) : collectCopyableMessages(ctx);
723
814
  if (messages.length === 0) {
724
- ctx.ui.notify("No copyable messages found in the current branch", "error");
815
+ notify("No copyable messages found in the current branch", "error");
725
816
  return;
726
817
  }
727
-
728
818
  if (parsedArgs.selector === "latest") {
729
819
  const latestVisible = latestDefaultMessage(messages);
730
- if (latestVisible) await copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
820
+ if (latestVisible) await copySelectedMessage(notify, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
731
821
  return;
732
822
  }
733
-
734
823
  if (typeof parsedArgs.selector === "object") {
735
824
  const selected = messageByDefaultNumber(messages, parsedArgs.selector.number);
736
825
  if (!selected) {
737
- 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");
738
827
  return;
739
828
  }
740
- await copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
829
+ await copySelectedMessage(notify, selected, formatMessageForCopy(selected, parsedArgs.format));
741
830
  return;
742
831
  }
743
-
744
832
  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");
833
+ notify("/copy-message requires interactive TUI mode unless you pass latest/last/newest or a message number", "error");
746
834
  return;
747
835
  }
748
-
749
- const selected = await pickMessage(ctx, messages, parsedArgs.format);
750
- if (!selected) return;
751
-
752
- await 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
+ }
753
846
  },
754
847
  });
755
-
756
848
  pi.registerCommand("copy-user", {
757
849
  description: "Copy the most recent user message to the clipboard",
758
850
  getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, false),
759
851
  handler: async (args, ctx) => {
760
- await copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
852
+ await copyMostRecentUserMessage(ctx, notifications(ctx), parseCopyArgs(args).format);
761
853
  },
762
854
  });
763
855
  }
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.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",
@@ -45,8 +45,8 @@
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",
48
+ "@earendil-works/pi-coding-agent": "1.0.0",
49
+ "@earendil-works/pi-tui": "1.0.0",
50
50
  "@types/node": "24.19.0",
51
51
  "typescript": "7.0.2"
52
52
  },
@@ -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": "*"