pi-copy-message 2.1.0 → 3.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.0 - 2026-09-26
4
+
5
+ - Require Node.js 24.15 or newer for repository tooling; `.nvmrc` pins 24.21.0 and `packageManager` is npm 12.1.0.
6
+ - Run tests with Node's built-in `node --test` and type stripping instead of `tsx`; typecheck with TypeScript 7.0.2 and `erasableSyntaxOnly`.
7
+ - Refresh the development baseline to Pi 0.87.1, qualified against official Pi and the maintained fork.
8
+ - Guard `package-lock.json` against private-registry URLs.
9
+ - First npm release containing the 2.1.1 clipboard change (2.1.1 was tagged but never published to npm).
10
+
11
+ ## 2.1.1 - 2026-08-25
12
+
13
+ - Reuse Pi's clipboard implementation instead of maintaining a duplicate command runner, enabling Pi's OSC 52 fallback on Android/Termux.
14
+
3
15
  ## 2.1.0 - 2026-08-17
4
16
 
5
17
  - Add an `Alt+C` picker filter and `MessageVisibility.showCustom` option for showing or hiding custom messages.
package/README.md CHANGED
@@ -16,6 +16,7 @@ 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
19
20
  - Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`
20
21
  - Supports direct numbered copies like `/copy-message 3`
21
22
  - Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
@@ -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
+ 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.
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 |
@@ -115,6 +112,7 @@ Copy with role and timestamp metadata instead of raw text only:
115
112
  - The picker caps visible rows and scrolls instead of filling the screen.
116
113
  - Search preserves your original selected message and restores it when the search is cleared.
117
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.
118
116
  - Filter labels honor the active pi theme.
119
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.
120
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`.
@@ -125,18 +123,28 @@ Copy with role and timestamp metadata instead of raw text only:
125
123
 
126
124
  ## Compatibility
127
125
 
128
- - Requires and is tested with Pi 0.84.0 or later
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
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
- - Supported Node.js range for local repo tooling: `>=22.19.0`
131
- - `.nvmrc` pins Node 22.19.0 for local development
130
+ - Node.js `>=24.15.0`. `.nvmrc` pins Node 24.21.0 for local development.
132
131
 
133
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.
134
133
 
135
134
  ## Development
136
135
 
137
136
  ```bash
138
- npm install
139
- npm run check
137
+ npm ci
138
+ npm run check # node --test + tsc --noEmit + pack dry-run
139
+ ```
140
+
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.
142
+
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:
144
+
145
+ ```bash
146
+ npm install --package-lock-only --ignore-scripts --registry=https://registry.npmjs.org/
147
+ npm ci
140
148
  ```
141
149
 
142
150
  Key files:
@@ -1,5 +1,4 @@
1
- import { spawnSync } from "node:child_process";
2
- import type { ExtensionAPI, ExtensionCommandContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
1
+ import { copyToClipboard, type ExtensionAPI, type ExtensionCommandContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
3
2
  import type { AutocompleteItem, TuiMode } from "@earendil-works/pi-tui";
4
3
  import { decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
5
4
 
@@ -33,7 +32,7 @@ function textFromContent(content: unknown): string {
33
32
  );
34
33
  })
35
34
  .map((part) => part.text)
36
- .join("\n\n");
35
+ .join("");
37
36
  }
38
37
 
39
38
  function textFromMessage(message: Record<string, unknown>): string {
@@ -56,14 +55,10 @@ function textFromMessage(message: Record<string, unknown>): string {
56
55
  return textFromContent(message.content);
57
56
  }
58
57
 
59
- type SegmenterCtor = new (locale?: string, options?: { granularity?: "grapheme" }) => {
60
- segment(input: string): Iterable<{ segment: string }>;
61
- };
58
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
62
59
 
63
60
  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);
61
+ return Array.from(graphemeSegmenter.segment(text), (part) => part.segment);
67
62
  }
68
63
 
69
64
  function truncateGraphemes(text: string, max: number): string {
@@ -74,8 +69,12 @@ function truncateGraphemes(text: string, max: number): string {
74
69
  return `${graphemes.slice(0, max - 1).join("")}…`;
75
70
  }
76
71
 
72
+ function safeDisplayText(text: string): string {
73
+ return text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
74
+ }
75
+
77
76
  function compactPreview(text: string, max = 96): string {
78
- const preview = text.replace(/\s+/gu, " ").trim();
77
+ const preview = safeDisplayText(text).replace(/\s+/gu, " ").trim();
79
78
  return truncateGraphemes(preview, max);
80
79
  }
81
80
 
@@ -96,7 +95,7 @@ function roleLabel(role: string): string {
96
95
  case "compactionSummary":
97
96
  return "compaction";
98
97
  default:
99
- return role || "message";
98
+ return safeDisplayText(role).replace(/\s+/gu, " ").trim() || "message";
100
99
  }
101
100
  }
102
101
 
@@ -138,7 +137,33 @@ function entryToCopyableMessage(entry: unknown): CopyableMessage | undefined {
138
137
  }
139
138
 
140
139
  export function collectCopyableMessages(ctx: { sessionManager: { getBranch(): unknown[] } }): CopyableMessage[] {
141
- return ctx.sessionManager.getBranch().flatMap((entry) => {
140
+ const entries: unknown[] = [];
141
+ // Pi persists assistant snapshots separately and coalesces them within each context window.
142
+ const responses = new Map<string, number>();
143
+ for (const entry of ctx.sessionManager.getBranch()) {
144
+ const record = entry as {
145
+ type?: string;
146
+ message?: { role?: string; responseId?: string; stopReason?: string; content?: unknown[] };
147
+ } | null;
148
+ if (record?.type === "context_window") responses.clear();
149
+ const responseId =
150
+ record?.type === "message" && record.message?.role === "assistant" && typeof record.message.responseId === "string"
151
+ ? record.message.responseId
152
+ : undefined;
153
+ const previous = responseId ? responses.get(responseId) : undefined;
154
+ if (previous !== undefined) {
155
+ const earlier = entries[previous] as typeof record;
156
+ if (
157
+ record?.message?.stopReason !== "pending" ||
158
+ (earlier?.message?.stopReason === "pending" && (earlier.message.content?.length ?? 0) <= (record.message.content?.length ?? 0))
159
+ ) entries[previous] = entry;
160
+ continue;
161
+ }
162
+ if (responseId) responses.set(responseId, entries.length);
163
+ entries.push(entry);
164
+ }
165
+
166
+ return entries.flatMap((entry) => {
142
167
  const message = entryToCopyableMessage(entry);
143
168
  return message ? [message] : [];
144
169
  });
@@ -181,58 +206,6 @@ export function getMostRecentUserMessage(ctx: { sessionManager: { getBranch(): u
181
206
  return sawUserMessage ? { kind: "no-text" } : { kind: "no-user-message" };
182
207
  }
183
208
 
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
209
  function isToolMessage(message: CopyableMessage): boolean {
237
210
  return message.role === "toolResult" || message.role === "bashExecution";
238
211
  }
@@ -353,7 +326,7 @@ function renderMessageLine(
353
326
 
354
327
  function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMessageTheme, format: CopyFormat): string[] {
355
328
  const contentWidth = Math.max(1, width - 2);
356
- const text = formatMessageForCopy(message, format);
329
+ const text = safeDisplayText(formatMessageForCopy(message, format));
357
330
  const wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, text, false), contentWidth);
358
331
  const shown = wrapped.slice(0, MAX_PEEK_LINES);
359
332
  const remaining = wrapped.length - shown.length;
@@ -440,8 +413,10 @@ export class CopyMessagePickerState {
440
413
  format: CopyFormat;
441
414
  peek = false;
442
415
  private searchAnchorId: string | undefined;
416
+ private readonly messages: CopyableMessage[];
443
417
 
444
- constructor(private readonly messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
418
+ constructor(messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
419
+ this.messages = messages;
445
420
  this.format = initialFormat;
446
421
  this.visibleMessages = filteredMessages(messages, this.visibility, this.search);
447
422
  this.selectedIndex = Math.max(0, this.visibleMessages.length - 1);
@@ -496,6 +471,13 @@ export class CopyMessagePickerState {
496
471
  }
497
472
 
498
473
  handleInput(data: string, keybindings?: PickerKeybindings): PickerInputResult {
474
+ // Pi delivers each paste as one input wrapped in bracketed-paste markers.
475
+ if (data.startsWith("\x1b[200~") && data.endsWith("\x1b[201~")) {
476
+ const text = data.slice(6, -6).replace(/\s+/gu, " ");
477
+ if (!isPrintableSearchInput(text)) return "none";
478
+ this.setSearch(this.search + text);
479
+ return "render";
480
+ }
499
481
  if (keybindings?.matches(data, "tui.select.up")) {
500
482
  this.move(-1);
501
483
  return "render";
@@ -545,7 +527,7 @@ export class CopyMessagePickerState {
545
527
  return "render";
546
528
  }
547
529
  if (matchesKey(data, "backspace") || data === "\x7f") {
548
- this.setSearch(this.search.slice(0, -1));
530
+ this.setSearch(splitGraphemes(this.search).slice(0, -1).join(""));
549
531
  return "render";
550
532
  }
551
533
  const printable = decodeKittyPrintable(data) ?? data;
@@ -708,17 +690,16 @@ function copyNotificationText(selected: CopyableMessage): string {
708
690
  return `Copied ${roleLabel(selected.role)} message: “${compactPreview(selected.text, 48)}”`;
709
691
  }
710
692
 
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;
693
+ async function copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage, text = selected.text) {
694
+ try {
695
+ await copyToClipboard(text);
696
+ ctx.ui.notify(copyNotificationText(selected), "info");
697
+ } catch (error) {
698
+ ctx.ui.notify(error instanceof Error ? error.message : "Failed to copy to clipboard", "error");
716
699
  }
717
-
718
- ctx.ui.notify(copyNotificationText(selected), "info");
719
700
  }
720
701
 
721
- function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
702
+ async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
722
703
  const result = getMostRecentUserMessage(ctx);
723
704
  if (result.kind === "no-user-message") {
724
705
  ctx.ui.notify("No user messages found", "warning");
@@ -729,7 +710,7 @@ function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionMa
729
710
  return;
730
711
  }
731
712
 
732
- copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
713
+ await copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
733
714
  }
734
715
 
735
716
  export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
@@ -746,7 +727,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
746
727
 
747
728
  if (parsedArgs.selector === "latest") {
748
729
  const latestVisible = latestDefaultMessage(messages);
749
- if (latestVisible) copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
730
+ if (latestVisible) await copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
750
731
  return;
751
732
  }
752
733
 
@@ -756,7 +737,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
756
737
  ctx.ui.notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
757
738
  return;
758
739
  }
759
- copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
740
+ await copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
760
741
  return;
761
742
  }
762
743
 
@@ -768,7 +749,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
768
749
  const selected = await pickMessage(ctx, messages, parsedArgs.format);
769
750
  if (!selected) return;
770
751
 
771
- copySelectedMessage(ctx, selected.message, selected.text);
752
+ await copySelectedMessage(ctx, selected.message, selected.text);
772
753
  },
773
754
  });
774
755
 
@@ -776,7 +757,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
776
757
  description: "Copy the most recent user message to the clipboard",
777
758
  getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, false),
778
759
  handler: async (args, ctx) => {
779
- copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
760
+ await copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
780
761
  },
781
762
  });
782
763
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-copy-message",
3
- "version": "2.1.0",
3
+ "version": "3.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": "0.87.1",
49
+ "@earendil-works/pi-tui": "0.87.1",
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.1.0",
59
59
  "peerDependencies": {
60
60
  "@earendil-works/pi-coding-agent": "*",
61
61
  "@earendil-works/pi-tui": "*"