pi-copy-message 2.0.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,22 @@
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
+
15
+ ## 2.1.0 - 2026-08-17
16
+
17
+ - Add an `Alt+C` picker filter and `MessageVisibility.showCustom` option for showing or hiding custom messages.
18
+ - Hide picker shortcut hints that collide with configured selection bindings.
19
+
3
20
  ## 2.0.0 - 2026-08-06
4
21
 
5
22
  - Set pi 0.84.0 as the minimum supported version and refresh the local coding-agent and TUI development baseline.
package/README.md CHANGED
@@ -11,11 +11,12 @@ A [pi](https://github.com/earendil-works/pi-mono) extension that adds `/copy-mes
11
11
  - Copies raw stored session message text, not rendered terminal lines
12
12
  - Shows messages in chat order: oldest at top, newest at bottom
13
13
  - Selects the newest visible message by default
14
- - Supports role filters for user, assistant, and tool/bash messages
14
+ - Supports role filters for user, assistant, tool/bash, and custom messages
15
15
  - Hides tool/bash messages by default
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,12 +94,13 @@ 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 |
104
101
  | `Ctrl+A` | Toggle assistant messages |
105
102
  | `Ctrl+T` | Toggle tool/bash messages |
103
+ | `Alt+C` | Toggle custom messages |
106
104
  | `Tab` | Toggle a wrapped preview of the selected message |
107
105
  | `Alt+M` | Toggle raw vs metadata copy format |
108
106
  | Configured `tui.select.confirm` (default: `Enter`) | Copy selected message text |
@@ -114,28 +112,39 @@ Copy with role and timestamp metadata instead of raw text only:
114
112
  - The picker caps visible rows and scrolls instead of filling the screen.
115
113
  - Search preserves your original selected message and restores it when the search is cleared.
116
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.
117
116
  - Filter labels honor the active pi theme.
118
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.
119
- - Hidden custom messages are excluded in both current `custom_message` entries and legacy message-wrapped entries; only messages with `display: true` can appear or be copied.
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`.
120
119
  - Copy notifications include the role and a short preview so you can verify what was copied.
121
- - `/copy-message latest` respects default visibility: user and assistant messages are visible, tool/bash messages are hidden. If only hidden messages exist, it falls back to the newest message so the command still does something useful.
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.
122
121
  - `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
123
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.
124
123
 
125
124
  ## Compatibility
126
125
 
127
- - 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.
128
129
  - Pi includes `Ctrl+X` for copying the latest assistant response; this extension remains useful for searchable history, other roles, metadata, and direct selectors.
129
- - Supported Node.js range for local repo tooling: `>=22.19.0`
130
- - `.nvmrc` pins Node 22.19.0 for local development
130
+ - Node.js `>=24.15.0`. `.nvmrc` pins Node 24.21.0 for local development.
131
131
 
132
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
133
 
134
134
  ## Development
135
135
 
136
136
  ```bash
137
- npm install
138
- 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
139
148
  ```
140
149
 
141
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
  }
@@ -241,12 +214,14 @@ export interface MessageVisibility {
241
214
  showAssistant: boolean;
242
215
  showUser: boolean;
243
216
  showTools: boolean;
217
+ showCustom: boolean;
244
218
  }
245
219
 
246
220
  function isVisibleMessage(message: CopyableMessage, visibility: MessageVisibility): boolean {
247
221
  if (isToolMessage(message)) return visibility.showTools;
248
222
  if (message.role === "assistant") return visibility.showAssistant;
249
223
  if (message.role === "user") return visibility.showUser;
224
+ if (message.role === "custom") return visibility.showCustom;
250
225
  return true;
251
226
  }
252
227
 
@@ -274,7 +249,7 @@ export function filteredMessages(messages: CopyableMessage[], visibility: Messag
274
249
  }
275
250
 
276
251
  export function defaultVisibleMessages(messages: CopyableMessage[]): CopyableMessage[] {
277
- return filteredMessages(messages, { showAssistant: true, showUser: true, showTools: false });
252
+ return filteredMessages(messages, { showAssistant: true, showUser: true, showTools: false, showCustom: true });
278
253
  }
279
254
 
280
255
  export function latestDefaultMessage(messages: CopyableMessage[]): CopyableMessage | undefined {
@@ -351,7 +326,7 @@ function renderMessageLine(
351
326
 
352
327
  function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMessageTheme, format: CopyFormat): string[] {
353
328
  const contentWidth = Math.max(1, width - 2);
354
- const text = formatMessageForCopy(message, format);
329
+ const text = safeDisplayText(formatMessageForCopy(message, format));
355
330
  const wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, text, false), contentWidth);
356
331
  const shown = wrapped.slice(0, MAX_PEEK_LINES);
357
332
  const remaining = wrapped.length - shown.length;
@@ -370,7 +345,7 @@ function bindingHint(keybindings: PickerKeybindings | undefined, action: "up" |
370
345
  return keys.reduce((shortest, key) => visibleWidth(key) < visibleWidth(shortest) ? key : shortest, keys[0] ?? "unbound");
371
346
  }
372
347
 
373
- function helpLines(width: number, keybindings?: PickerKeybindings, tuiMode: TuiMode = "regular"): string[] {
348
+ function helpLines(width: number, keybindings: PickerKeybindings | undefined, tuiMode: TuiMode, hasCustomMessages: boolean): string[] {
374
349
  const up = bindingHint(keybindings, "up");
375
350
  const down = bindingHint(keybindings, "down");
376
351
  const confirm = bindingHint(keybindings, "confirm");
@@ -390,7 +365,8 @@ function helpLines(width: number, keybindings?: PickerKeybindings, tuiMode: TuiM
390
365
  { hint: "T", data: "\x14" },
391
366
  ].filter(({ data }) => available(data));
392
367
  const filterHint = filters.length > 0 ? `Ctrl+${filters.map(({ hint }) => hint).join("/")} filters` : undefined;
393
- const meta = available("\x1bm") ? "Alt+M meta" : undefined;
368
+ const custom = hasCustomMessages && available("\x1b[99;3u") ? "Alt+C custom" : undefined;
369
+ const meta = available("\x1b[109;3u") ? "Alt+M meta" : undefined;
394
370
  const jumps = (tuiMode === "fullscreen"
395
371
  ? [
396
372
  { hint: "Ctrl+Home", data: "\x1b[1;5H" },
@@ -415,7 +391,7 @@ function helpLines(width: number, keybindings?: PickerKeybindings, tuiMode: TuiM
415
391
  return lines;
416
392
  }
417
393
 
418
- for (const hint of width < 74 ? [jumpHint, peek, filterHint, meta] : ["type search", jumpHint, peek, filterHint, meta]) {
394
+ for (const hint of width < 74 ? [jumpHint, peek, filterHint, custom, meta] : ["type search", jumpHint, peek, filterHint, custom, meta]) {
419
395
  if (hint && visibleWidth(join(...optional, hint, ...core)) <= width) optional.push(hint);
420
396
  }
421
397
 
@@ -429,6 +405,7 @@ export class CopyMessagePickerState {
429
405
  showAssistant: true,
430
406
  showUser: true,
431
407
  showTools: false,
408
+ showCustom: true,
432
409
  };
433
410
  search = "";
434
411
  visibleMessages: CopyableMessage[];
@@ -436,8 +413,10 @@ export class CopyMessagePickerState {
436
413
  format: CopyFormat;
437
414
  peek = false;
438
415
  private searchAnchorId: string | undefined;
416
+ private readonly messages: CopyableMessage[];
439
417
 
440
- constructor(private readonly messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
418
+ constructor(messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
419
+ this.messages = messages;
441
420
  this.format = initialFormat;
442
421
  this.visibleMessages = filteredMessages(messages, this.visibility, this.search);
443
422
  this.selectedIndex = Math.max(0, this.visibleMessages.length - 1);
@@ -456,6 +435,7 @@ export class CopyMessagePickerState {
456
435
  const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
457
436
  const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
458
437
  const end = Math.min(this.visibleMessages.length, start + maxVisible);
438
+ const hasCustomMessages = this.messages.some((message) => message.role === "custom");
459
439
  const userState = filterLabel(theme, "user", this.visibility.showUser, "warning");
460
440
  const assistantState = filterLabel(theme, "assistant", this.visibility.showAssistant, "accent");
461
441
  const toolState = filterLabel(theme, "tools", this.visibility.showTools, "dim");
@@ -481,14 +461,23 @@ export class CopyMessagePickerState {
481
461
  }
482
462
 
483
463
  const position = this.visibleMessages.length === 0 ? "0/0" : `${this.selectedIndex + 1}/${this.visibleMessages.length}`;
484
- lines.push(`${theme.fg("dim", `(${position})`)} · ${userState} · ${assistantState} · ${toolState} · ${formatState} · ${searchState}`);
464
+ const filters = [userState, assistantState, toolState];
465
+ if (hasCustomMessages) filters.push(filterLabel(theme, "custom", this.visibility.showCustom, "dim"));
466
+ lines.push([theme.fg("dim", `(${position})`), ...filters, searchState, formatState].join(" · "));
485
467
  lines.push("");
486
- lines.push(...helpLines(width, keybindings, tuiMode).map((line) => hotkeyHint(theme, line)));
468
+ lines.push(...helpLines(width, keybindings, tuiMode, hasCustomMessages).map((line) => hotkeyHint(theme, line)));
487
469
  lines.push("");
488
470
  return lines.map((line) => truncateToWidth(line, width, ""));
489
471
  }
490
472
 
491
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
+ }
492
481
  if (keybindings?.matches(data, "tui.select.up")) {
493
482
  this.move(-1);
494
483
  return "render";
@@ -524,6 +513,11 @@ export class CopyMessagePickerState {
524
513
  this.refreshMessages();
525
514
  return "render";
526
515
  }
516
+ if (matchesKey(data, "alt+c")) {
517
+ this.visibility.showCustom = !this.visibility.showCustom;
518
+ this.refreshMessages();
519
+ return "render";
520
+ }
527
521
  if (matchesKey(data, "alt+m")) {
528
522
  this.format = this.format === "raw" ? "metadata" : "raw";
529
523
  return "render";
@@ -533,7 +527,7 @@ export class CopyMessagePickerState {
533
527
  return "render";
534
528
  }
535
529
  if (matchesKey(data, "backspace") || data === "\x7f") {
536
- this.setSearch(this.search.slice(0, -1));
530
+ this.setSearch(splitGraphemes(this.search).slice(0, -1).join(""));
537
531
  return "render";
538
532
  }
539
533
  const printable = decodeKittyPrintable(data) ?? data;
@@ -696,17 +690,16 @@ function copyNotificationText(selected: CopyableMessage): string {
696
690
  return `Copied ${roleLabel(selected.role)} message: “${compactPreview(selected.text, 48)}”`;
697
691
  }
698
692
 
699
- function copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage, text = selected.text) {
700
- const error = copyToClipboard(text);
701
- if (error) {
702
- ctx.ui.notify(error, "error");
703
- 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");
704
699
  }
705
-
706
- ctx.ui.notify(copyNotificationText(selected), "info");
707
700
  }
708
701
 
709
- function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
702
+ async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
710
703
  const result = getMostRecentUserMessage(ctx);
711
704
  if (result.kind === "no-user-message") {
712
705
  ctx.ui.notify("No user messages found", "warning");
@@ -717,7 +710,7 @@ function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionMa
717
710
  return;
718
711
  }
719
712
 
720
- copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
713
+ await copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
721
714
  }
722
715
 
723
716
  export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
@@ -734,7 +727,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
734
727
 
735
728
  if (parsedArgs.selector === "latest") {
736
729
  const latestVisible = latestDefaultMessage(messages);
737
- if (latestVisible) copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
730
+ if (latestVisible) await copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
738
731
  return;
739
732
  }
740
733
 
@@ -744,7 +737,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
744
737
  ctx.ui.notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
745
738
  return;
746
739
  }
747
- copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
740
+ await copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
748
741
  return;
749
742
  }
750
743
 
@@ -756,7 +749,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
756
749
  const selected = await pickMessage(ctx, messages, parsedArgs.format);
757
750
  if (!selected) return;
758
751
 
759
- copySelectedMessage(ctx, selected.message, selected.text);
752
+ await copySelectedMessage(ctx, selected.message, selected.text);
760
753
  },
761
754
  });
762
755
 
@@ -764,7 +757,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
764
757
  description: "Copy the most recent user message to the clipboard",
765
758
  getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, false),
766
759
  handler: async (args, ctx) => {
767
- copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
760
+ await copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
768
761
  },
769
762
  });
770
763
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-copy-message",
3
- "version": "2.0.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": "*"