pi-copy-message 1.0.7 → 1.0.9

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 1.0.9 - 2026-07-11
6
+
7
+ - Update the local Pi development baseline and compatibility documentation to 0.80.6.
8
+ - Read current branch-summary, compaction-summary, and visible custom-message session entries in `/copy-message`; hidden custom messages stay excluded.
9
+ - Honor configured Pi selection keybindings before local picker shortcuts and render their effective hints.
10
+
11
+ ## 1.0.8 - 2026-06-24
12
+
13
+ - Add `/copy-message` and `/copy-user` argument completions for `latest`/`last`/`newest` and `--with-meta`/`--with-metadata`/`--with-role`.
14
+ - Bound clipboard commands with a 3s timeout so a wedged `xclip`/`wl-copy` daemon cannot hang the command.
15
+ - Drop the obsolete `basic-ftp` npm override (no longer in the dependency tree).
16
+ - Align pi core peer dependencies with package guidance (non-optional wildcard peers; pi aliases them to its bundled copies at load time).
17
+ - Update the local pi development baseline to `0.80.2` and refresh the npm lockfile.
18
+
3
19
  ## 1.0.7 - 2026-06-23
4
20
 
5
21
  - updated the local pi development baseline to `@earendil-works/pi-coding-agent` / `@earendil-works/pi-tui` `0.80.1` and refreshed the npm lockfile
package/README.md CHANGED
@@ -15,6 +15,7 @@ A [pi](https://github.com/earendil-works/pi-mono) extension that adds `/copy-mes
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
+ - Uses Pi's configured selection bindings for navigation, copy, and cancel
18
19
  - Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`
19
20
  - Supports direct numbered copies like `/copy-message 3`
20
21
  - Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
@@ -90,8 +91,8 @@ Copy with role and timestamp metadata instead of raw text only:
90
91
 
91
92
  | Key | Action |
92
93
  |---|---|
93
- | `↑` | Move to older visible message |
94
- | `↓` | Move to newer visible message |
94
+ | Configured `tui.select.up` (default: `↑`) | Move to older visible message |
95
+ | Configured `tui.select.down` (default: `↓`) | Move to newer visible message |
95
96
  | `Home` | Jump to oldest visible message |
96
97
  | `End` | Jump to newest visible message |
97
98
  | Type text | Filter visible messages |
@@ -102,8 +103,8 @@ Copy with role and timestamp metadata instead of raw text only:
102
103
  | `Ctrl+T` | Toggle tool/bash messages |
103
104
  | `Tab` | Toggle a wrapped preview of the selected message |
104
105
  | `Alt+M` | Toggle raw vs metadata copy format |
105
- | `Enter` | Copy selected message text |
106
- | `Esc` | Cancel |
106
+ | Configured `tui.select.confirm` (default: `Enter`) | Copy selected message text |
107
+ | Configured `tui.select.cancel` (default: `Esc`/`Ctrl+C`) | Cancel |
107
108
 
108
109
  ## Behavior notes
109
110
 
@@ -112,6 +113,8 @@ Copy with role and timestamp metadata instead of raw text only:
112
113
  - Search preserves your original selected message and restores it when the search is cleared.
113
114
  - General search does not match timestamps; use `time:<term>` when you want to search by displayed time.
114
115
  - Filter labels honor the active pi theme.
116
+ - 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.
117
+ - 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.
115
118
  - Copy notifications include the role and a short preview so you can verify what was copied.
116
119
  - `/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.
117
120
  - `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
@@ -119,11 +122,11 @@ Copy with role and timestamp metadata instead of raw text only:
119
122
 
120
123
  ## Compatibility
121
124
 
122
- - Tested with pi 0.80.1
125
+ - Tested with pi 0.80.6
123
126
  - Supported Node.js range for local repo tooling: `>=22.19.0`
124
127
  - `.nvmrc` pins Node 22.19.0 for local development
125
128
 
126
- This package keeps pi core packages as optional wildcard peers per current pi package guidance. Local development uses `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` as dev dependencies for typechecking and tests.
129
+ 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.
127
130
 
128
131
  ## Development
129
132
 
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionCommandContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
3
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
4
  import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
5
 
5
6
  const MAX_VISIBLE_MESSAGES = 8;
@@ -109,14 +110,25 @@ function formatTime(timestamp: unknown): string {
109
110
  function entryToCopyableMessage(entry: unknown): CopyableMessage | undefined {
110
111
  if (entry === null || typeof entry !== "object") return undefined;
111
112
  const record = entry as Record<string, unknown>;
112
- if (record.type !== "message") return undefined;
113
- if (record.message === null || typeof record.message !== "object") return undefined;
113
+ let role: string;
114
+ let text: string;
114
115
 
115
- const message = record.message as Record<string, unknown>;
116
- const role = typeof message.role === "string" ? message.role : "message";
117
- const text = textFromMessage(message);
118
- if (!text.trim()) return undefined;
116
+ if (record.type === "message" && record.message !== null && typeof record.message === "object") {
117
+ const message = record.message as Record<string, unknown>;
118
+ role = typeof message.role === "string" ? message.role : "message";
119
+ if (role === "custom" && message.display !== true) return undefined;
120
+ text = textFromMessage(message);
121
+ } else if (record.type === "branch_summary" || record.type === "compaction") {
122
+ role = record.type === "branch_summary" ? "branchSummary" : "compactionSummary";
123
+ text = typeof record.summary === "string" ? record.summary : "";
124
+ } else if (record.type === "custom_message" && record.display === true) {
125
+ role = "custom";
126
+ text = textFromContent(record.content);
127
+ } else {
128
+ return undefined;
129
+ }
119
130
 
131
+ if (!text.trim()) return undefined;
120
132
  return {
121
133
  id: typeof record.id === "string" ? record.id : "unknown",
122
134
  role,
@@ -183,20 +195,25 @@ const clipboardCommands: ClipboardCommand[] = [
183
195
  { name: "xsel", args: ["--clipboard", "--input"], enabled: () => true },
184
196
  ];
185
197
 
198
+ const CLIPBOARD_TIMEOUT_MS = 3000;
186
199
  const commandExistsCache = new Map<string, boolean>();
187
200
 
188
201
  function commandExists(command: string): boolean {
189
202
  const cached = commandExistsCache.get(command);
190
203
  if (cached !== undefined) return cached;
191
204
 
192
- const result = spawnSync("sh", ["-c", "command -v \"$1\" >/dev/null 2>&1", "sh", command], { stdio: "ignore" });
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
+ });
193
210
  const exists = result.status === 0;
194
211
  commandExistsCache.set(command, exists);
195
212
  return exists;
196
213
  }
197
214
 
198
215
  function copyWith(command: string, args: string[], text: string): boolean {
199
- const result = spawnSync(command, args, { input: text, encoding: "utf8" });
216
+ const result = spawnSync(command, args, { input: text, encoding: "utf8", timeout: CLIPBOARD_TIMEOUT_MS, killSignal: "SIGKILL" });
200
217
  return !result.error && result.status === 0;
201
218
  }
202
219
 
@@ -344,10 +361,58 @@ function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMes
344
361
  return lines;
345
362
  }
346
363
 
347
- function helpLine(width: number): string {
348
- if (width < 48) return "↑↓ · Tab peek · Alt+M meta · Enter · Esc";
349
- if (width < 74) return "↑↓ nav · Home/End · Tab peek · Ctrl+U/A/T filters · Enter · Esc";
350
- return "type search · ↑↓ navigate · Home/End · Tab peek · Ctrl+U/A/T filters · Alt+M meta · Enter · Esc";
364
+ type PickerKeybindings = Pick<KeybindingsManager, "getKeys" | "matches">;
365
+
366
+ function bindingHint(keybindings: PickerKeybindings | undefined, action: "up" | "down" | "confirm" | "cancel"): string {
367
+ const id = `tui.select.${action}` as const;
368
+ if (!keybindings) return { up: "up", down: "down", confirm: "enter", cancel: "escape/ctrl+c" }[action];
369
+ const keys = keybindings.getKeys(id);
370
+ return keys.reduce((shortest, key) => visibleWidth(key) < visibleWidth(shortest) ? key : shortest, keys[0] ?? "unbound");
371
+ }
372
+
373
+ function helpLines(width: number, keybindings?: PickerKeybindings): string[] {
374
+ const up = bindingHint(keybindings, "up");
375
+ const down = bindingHint(keybindings, "down");
376
+ const confirm = bindingHint(keybindings, "confirm");
377
+ const cancel = bindingHint(keybindings, "cancel");
378
+ const available = (data: string) =>
379
+ !keybindings ||
380
+ !(keybindings.matches(data, "tui.select.up") ||
381
+ keybindings.matches(data, "tui.select.down") ||
382
+ keybindings.matches(data, "tui.select.confirm") ||
383
+ keybindings.matches(data, "tui.select.cancel"));
384
+ const peek = available("\t") ? "Tab peek" : undefined;
385
+ const filters = [
386
+ { hint: "U", data: "\x15" },
387
+ { hint: "A", data: "\x01" },
388
+ { hint: "T", data: "\x14" },
389
+ ].filter(({ data }) => available(data));
390
+ const filterHint = filters.length > 0 ? `Ctrl+${filters.map(({ hint }) => hint).join("/")} filters` : undefined;
391
+ const meta = available("\x1bm") ? "Alt+M meta" : undefined;
392
+ const jumps = [
393
+ { hint: "Home", data: "\x1b[H" },
394
+ { hint: "End", data: "\x1b[F" },
395
+ ].filter(({ data }) => available(data));
396
+ const jumpHint = jumps.length > 0 ? `${jumps.map(({ hint }) => hint).join("/")} jump` : undefined;
397
+ const join = (...hints: Array<string | undefined>) => hints.filter(Boolean).join(" · ");
398
+ const core = width < 74 ? [`${up}/${down} nav`, `${confirm} copy`, `${cancel} cancel`] : [`${up} older`, `${down} newer`, `${confirm} copy`, `${cancel} cancel`];
399
+ const optional: string[] = [];
400
+
401
+ if (visibleWidth(join(...core)) > width) {
402
+ const lines: string[] = [];
403
+ for (const hint of [`${up} older`, `${down} newer`, `${confirm} copy`, `${cancel} cancel`]) {
404
+ const candidate = join(lines.at(-1), hint);
405
+ if (lines.length === 0 || visibleWidth(candidate) > width) lines.push(hint);
406
+ else lines[lines.length - 1] = candidate;
407
+ }
408
+ return lines;
409
+ }
410
+
411
+ for (const hint of width < 74 ? [jumpHint, peek, filterHint, meta] : ["type search", jumpHint, peek, filterHint, meta]) {
412
+ if (hint && visibleWidth(join(...optional, hint, ...core)) <= width) optional.push(hint);
413
+ }
414
+
415
+ return [join(...optional, ...core)];
351
416
  }
352
417
 
353
418
  type PickerInputResult = "copy" | "cancel" | "render" | "none";
@@ -380,7 +445,7 @@ export class CopyMessagePickerState {
380
445
  return selected ? formatMessageForCopy(selected, this.format) : undefined;
381
446
  }
382
447
 
383
- render(width: number, theme: CopyMessageTheme): string[] {
448
+ render(width: number, theme: CopyMessageTheme, keybindings?: PickerKeybindings): string[] {
384
449
  const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
385
450
  const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
386
451
  const end = Math.min(this.visibleMessages.length, start + maxVisible);
@@ -411,12 +476,24 @@ export class CopyMessagePickerState {
411
476
  const position = this.visibleMessages.length === 0 ? "0/0" : `${this.selectedIndex + 1}/${this.visibleMessages.length}`;
412
477
  lines.push(`${theme.fg("dim", `(${position})`)} · ${userState} · ${assistantState} · ${toolState} · ${formatState} · ${searchState}`);
413
478
  lines.push("");
414
- lines.push(hotkeyHint(theme, helpLine(width)));
479
+ lines.push(...helpLines(width, keybindings).map((line) => hotkeyHint(theme, line)));
415
480
  lines.push("");
416
481
  return lines.map((line) => truncateToWidth(line, width, ""));
417
482
  }
418
483
 
419
- handleInput(data: string): PickerInputResult {
484
+ handleInput(data: string, keybindings?: PickerKeybindings): PickerInputResult {
485
+ if (keybindings?.matches(data, "tui.select.up")) {
486
+ this.move(-1);
487
+ return "render";
488
+ }
489
+ if (keybindings?.matches(data, "tui.select.down")) {
490
+ this.move(1);
491
+ return "render";
492
+ }
493
+ if (keybindings?.matches(data, "tui.select.confirm")) {
494
+ return this.visibleMessages.length > 0 ? "copy" : "none";
495
+ }
496
+ if (keybindings?.matches(data, "tui.select.cancel")) return "cancel";
420
497
  if (matchesKey(data, "ctrl+t")) {
421
498
  this.visibility.showTools = !this.visibility.showTools;
422
499
  this.refreshMessages();
@@ -448,11 +525,11 @@ export class CopyMessagePickerState {
448
525
  this.setSearch(this.search + data);
449
526
  return "render";
450
527
  }
451
- if (matchesKey(data, "up")) {
528
+ if (!keybindings && matchesKey(data, "up")) {
452
529
  this.move(-1);
453
530
  return "render";
454
531
  }
455
- if (matchesKey(data, "down")) {
532
+ if (!keybindings && matchesKey(data, "down")) {
456
533
  this.move(1);
457
534
  return "render";
458
535
  }
@@ -464,10 +541,10 @@ export class CopyMessagePickerState {
464
541
  this.jumpToBottom();
465
542
  return "render";
466
543
  }
467
- if (matchesKey(data, "enter") || matchesKey(data, "return")) {
544
+ if (!keybindings && (matchesKey(data, "enter") || matchesKey(data, "return"))) {
468
545
  return this.visibleMessages.length > 0 ? "copy" : "none";
469
546
  }
470
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
547
+ if (!keybindings && (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))) {
471
548
  return "cancel";
472
549
  }
473
550
  return "none";
@@ -513,6 +590,9 @@ export class CopyMessagePickerState {
513
590
  }
514
591
  }
515
592
 
593
+ const COPY_METADATA_FLAGS = ["--with-meta", "--with-metadata", "--with-role"];
594
+ const COPY_LATEST_SELECTORS = ["latest", "last", "newest"];
595
+
516
596
  type ParsedCopyMessageArgs = {
517
597
  format: CopyFormat;
518
598
  selector?: "latest" | { number: number };
@@ -521,11 +601,11 @@ type ParsedCopyMessageArgs = {
521
601
  function parseCopyArgs(args: string | undefined): ParsedCopyMessageArgs {
522
602
  const result: ParsedCopyMessageArgs = { format: "raw" };
523
603
  for (const token of (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean)) {
524
- if (token === "--with-meta" || token === "--with-metadata" || token === "--with-role") {
604
+ if (COPY_METADATA_FLAGS.includes(token)) {
525
605
  result.format = "metadata";
526
606
  continue;
527
607
  }
528
- if (token === "last" || token === "latest" || token === "newest") {
608
+ if (COPY_LATEST_SELECTORS.includes(token)) {
529
609
  result.selector = "latest";
530
610
  continue;
531
611
  }
@@ -536,16 +616,26 @@ function parseCopyArgs(args: string | undefined): ParsedCopyMessageArgs {
536
616
  return result;
537
617
  }
538
618
 
619
+ export function copyArgumentCompletions(prefix: string, includeSelectors: boolean): AutocompleteItem[] | null {
620
+ if (/^\d+$/.test(prefix)) return null;
621
+ const candidates = includeSelectors ? [...COPY_LATEST_SELECTORS, ...COPY_METADATA_FLAGS] : COPY_METADATA_FLAGS;
622
+ const normalized = prefix.toLowerCase();
623
+ const items = candidates
624
+ .filter((candidate) => candidate.startsWith(normalized))
625
+ .map((candidate) => ({ value: candidate, label: candidate }));
626
+ return items.length > 0 ? items : null;
627
+ }
628
+
539
629
  async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat) {
540
- return ctx.ui.custom<{ message: CopyableMessage; text: string } | null>((tui, theme, _keybindings, done) => {
630
+ return ctx.ui.custom<{ message: CopyableMessage; text: string } | null>((tui, theme, keybindings, done) => {
541
631
  const state = new CopyMessagePickerState(messages, initialFormat);
542
632
  return {
543
633
  render(width: number) {
544
- return state.render(width, theme);
634
+ return state.render(width, theme, keybindings);
545
635
  },
546
636
  invalidate() {},
547
637
  handleInput(data: string) {
548
- const result = state.handleInput(data);
638
+ const result = state.handleInput(data, keybindings);
549
639
  if (result === "copy") {
550
640
  const selected = state.selectedMessage();
551
641
  const text = state.selectedCopyText();
@@ -593,6 +683,7 @@ function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionMa
593
683
  export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
594
684
  pi.registerCommand("copy-message", {
595
685
  description: "Select a session message and copy its text to the clipboard",
686
+ getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, true),
596
687
  handler: async (args, ctx) => {
597
688
  const parsedArgs = parseCopyArgs(args);
598
689
  const messages = collectCopyableMessages(ctx);
@@ -631,6 +722,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
631
722
 
632
723
  pi.registerCommand("copy-user", {
633
724
  description: "Copy the most recent user message to the clipboard",
725
+ getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, false),
634
726
  handler: async (args, ctx) => {
635
727
  copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
636
728
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-copy-message",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
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",
@@ -44,15 +44,12 @@
44
44
  "LICENSE"
45
45
  ],
46
46
  "devDependencies": {
47
- "@earendil-works/pi-coding-agent": "^0.80.1",
48
- "@earendil-works/pi-tui": "^0.80.1",
47
+ "@earendil-works/pi-coding-agent": "^0.80.6",
48
+ "@earendil-works/pi-tui": "^0.80.6",
49
49
  "@types/node": "^25.9.1",
50
50
  "tsx": "^4.22.4",
51
51
  "typescript": "^6.0.3"
52
52
  },
53
- "overrides": {
54
- "basic-ftp": "6.0.1"
55
- },
56
53
  "pi": {
57
54
  "extensions": [
58
55
  "./extensions"
@@ -62,13 +59,5 @@
62
59
  "peerDependencies": {
63
60
  "@earendil-works/pi-coding-agent": "*",
64
61
  "@earendil-works/pi-tui": "*"
65
- },
66
- "peerDependenciesMeta": {
67
- "@earendil-works/pi-coding-agent": {
68
- "optional": true
69
- },
70
- "@earendil-works/pi-tui": {
71
- "optional": true
72
- }
73
62
  }
74
63
  }