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 +8 -0
- package/README.md +8 -5
- package/extensions/copy-message.ts +92 -21
- package/package.json +3 -3
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.
|
|
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
|
-
|
|
114
|
-
|
|
113
|
+
let role: string;
|
|
114
|
+
let text: string;
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
48
|
-
"@earendil-works/pi-tui": "^0.80.
|
|
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"
|