pi-copy-message 1.0.8 → 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,13 @@
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
+
3
11
  ## 1.0.8 - 2026-06-24
4
12
 
5
13
  - Add `/copy-message` and `/copy-user` argument completions for `latest`/`last`/`newest` and `--with-meta`/`--with-metadata`/`--with-role`.
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,7 +122,7 @@ Copy with role and timestamp metadata instead of raw text only:
119
122
 
120
123
  ## Compatibility
121
124
 
122
- - Tested with pi 0.80.2
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
 
@@ -1,5 +1,5 @@
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
3
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
4
4
  import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
5
5
 
@@ -110,14 +110,25 @@ function formatTime(timestamp: unknown): string {
110
110
  function entryToCopyableMessage(entry: unknown): CopyableMessage | undefined {
111
111
  if (entry === null || typeof entry !== "object") return undefined;
112
112
  const record = entry as Record<string, unknown>;
113
- if (record.type !== "message") return undefined;
114
- if (record.message === null || typeof record.message !== "object") return undefined;
113
+ let role: string;
114
+ let text: string;
115
115
 
116
- const message = record.message as Record<string, unknown>;
117
- const role = typeof message.role === "string" ? message.role : "message";
118
- const text = textFromMessage(message);
119
- 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
+ }
120
130
 
131
+ if (!text.trim()) return undefined;
121
132
  return {
122
133
  id: typeof record.id === "string" ? record.id : "unknown",
123
134
  role,
@@ -350,10 +361,58 @@ function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMes
350
361
  return lines;
351
362
  }
352
363
 
353
- function helpLine(width: number): string {
354
- if (width < 48) return "↑↓ · Tab peek · Alt+M meta · Enter · Esc";
355
- if (width < 74) return "↑↓ nav · Home/End · Tab peek · Ctrl+U/A/T filters · Enter · Esc";
356
- 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)];
357
416
  }
358
417
 
359
418
  type PickerInputResult = "copy" | "cancel" | "render" | "none";
@@ -386,7 +445,7 @@ export class CopyMessagePickerState {
386
445
  return selected ? formatMessageForCopy(selected, this.format) : undefined;
387
446
  }
388
447
 
389
- render(width: number, theme: CopyMessageTheme): string[] {
448
+ render(width: number, theme: CopyMessageTheme, keybindings?: PickerKeybindings): string[] {
390
449
  const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
391
450
  const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
392
451
  const end = Math.min(this.visibleMessages.length, start + maxVisible);
@@ -417,12 +476,24 @@ export class CopyMessagePickerState {
417
476
  const position = this.visibleMessages.length === 0 ? "0/0" : `${this.selectedIndex + 1}/${this.visibleMessages.length}`;
418
477
  lines.push(`${theme.fg("dim", `(${position})`)} · ${userState} · ${assistantState} · ${toolState} · ${formatState} · ${searchState}`);
419
478
  lines.push("");
420
- lines.push(hotkeyHint(theme, helpLine(width)));
479
+ lines.push(...helpLines(width, keybindings).map((line) => hotkeyHint(theme, line)));
421
480
  lines.push("");
422
481
  return lines.map((line) => truncateToWidth(line, width, ""));
423
482
  }
424
483
 
425
- 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";
426
497
  if (matchesKey(data, "ctrl+t")) {
427
498
  this.visibility.showTools = !this.visibility.showTools;
428
499
  this.refreshMessages();
@@ -454,11 +525,11 @@ export class CopyMessagePickerState {
454
525
  this.setSearch(this.search + data);
455
526
  return "render";
456
527
  }
457
- if (matchesKey(data, "up")) {
528
+ if (!keybindings && matchesKey(data, "up")) {
458
529
  this.move(-1);
459
530
  return "render";
460
531
  }
461
- if (matchesKey(data, "down")) {
532
+ if (!keybindings && matchesKey(data, "down")) {
462
533
  this.move(1);
463
534
  return "render";
464
535
  }
@@ -470,10 +541,10 @@ export class CopyMessagePickerState {
470
541
  this.jumpToBottom();
471
542
  return "render";
472
543
  }
473
- if (matchesKey(data, "enter") || matchesKey(data, "return")) {
544
+ if (!keybindings && (matchesKey(data, "enter") || matchesKey(data, "return"))) {
474
545
  return this.visibleMessages.length > 0 ? "copy" : "none";
475
546
  }
476
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
547
+ if (!keybindings && (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))) {
477
548
  return "cancel";
478
549
  }
479
550
  return "none";
@@ -556,15 +627,15 @@ export function copyArgumentCompletions(prefix: string, includeSelectors: boolea
556
627
  }
557
628
 
558
629
  async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat) {
559
- 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) => {
560
631
  const state = new CopyMessagePickerState(messages, initialFormat);
561
632
  return {
562
633
  render(width: number) {
563
- return state.render(width, theme);
634
+ return state.render(width, theme, keybindings);
564
635
  },
565
636
  invalidate() {},
566
637
  handleInput(data: string) {
567
- const result = state.handleInput(data);
638
+ const result = state.handleInput(data, keybindings);
568
639
  if (result === "copy") {
569
640
  const selected = state.selectedMessage();
570
641
  const text = state.selectedCopyText();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-copy-message",
3
- "version": "1.0.8",
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,8 +44,8 @@
44
44
  "LICENSE"
45
45
  ],
46
46
  "devDependencies": {
47
- "@earendil-works/pi-coding-agent": "^0.80.2",
48
- "@earendil-works/pi-tui": "^0.80.2",
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"