pi-copy-message 3.0.0 → 4.0.1
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 +18 -0
- package/README.md +41 -120
- package/extensions/copy-message.ts +171 -50
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 4.0.1 - 2026-10-31
|
|
6
|
+
|
|
7
|
+
- Stop enumerating graphemes once a message preview is known to need truncation; preserve preview and copied text.
|
|
8
|
+
|
|
9
|
+
- Wrap only enough of a long message to fill the `Tab` peek instead of the whole message; very long messages note that more lines remain instead of counting them. Copied text is unchanged.
|
|
10
|
+
|
|
11
|
+
- Cancel pending copy pickers when the host disposes their UI, including during reload, without interrupting sibling dialog teardown.
|
|
12
|
+
|
|
13
|
+
## 4.0.0 - 2026-10-01
|
|
14
|
+
|
|
15
|
+
- Require Pi 1.0.0 and pin the official 1.0.0 development cohort.
|
|
16
|
+
- Use the native canonical projection for latest-message copies, honoring context edits and compaction while retaining raw historical numbered/picker/user copies and legacy response snapshots.
|
|
17
|
+
- Verify clipboard text before reporting success; keep Pi's complete native/platform/OSC 52 write fallback chain and warn when delivery is unverified, empty, mismatched or remote.
|
|
18
|
+
- Cancel pending pickers on session/tree/shutdown lifecycle changes; suppress stale completion notifications.
|
|
19
|
+
- Add native mouse navigation/copy with restored editor focus and safe isolated clipboard/SDK lifecycle regression coverage.
|
|
20
|
+
|
|
3
21
|
## 3.0.0 - 2026-09-26
|
|
4
22
|
|
|
5
23
|
- Require Node.js 24.15 or newer for repository tooling; `.nvmrc` pins 24.21.0 and `packageManager` is npm 12.1.0.
|
package/README.md
CHANGED
|
@@ -1,153 +1,74 @@
|
|
|
1
|
-
# pi
|
|
1
|
+
# pi-copy-message
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Copy message text from a [Pi](https://github.com/earendil-works/pi-mono) session with a searchable picker. The copied text keeps the original code, spaces, and line breaks.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+

|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Install and start
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- Adds `/copy-user` for copying the most recent user message directly
|
|
11
|
-
- Copies raw stored session message text, not rendered terminal lines
|
|
12
|
-
- Shows messages in chat order: oldest at top, newest at bottom
|
|
13
|
-
- Selects the newest visible message by default
|
|
14
|
-
- Supports role filters for user, assistant, tool/bash, and custom messages
|
|
15
|
-
- Hides tool/bash messages by default
|
|
16
|
-
- Supports type-to-filter search across role and message text, with `time:<term>` for timestamp search
|
|
17
|
-
- Supports Home/End jumps for oldest/newest visible messages
|
|
18
|
-
- Uses Pi's configured selection bindings for navigation, copy, and cancel
|
|
19
|
-
- Uses Pi's clipboard implementation, including its OSC 52 terminal fallback
|
|
20
|
-
- Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`
|
|
21
|
-
- Supports direct numbered copies like `/copy-message 3`
|
|
22
|
-
- Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
|
|
9
|
+
This extension requires Pi and Node.js. See the [development guide](https://github.com/fitchmultz/pi-copy-message/blob/main/docs/development.md) for version requirements.
|
|
23
10
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
Install it from npm with pi:
|
|
11
|
+
Run this command:
|
|
27
12
|
|
|
28
13
|
```bash
|
|
29
14
|
pi install npm:pi-copy-message
|
|
30
15
|
```
|
|
31
16
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
pi install https://github.com/fitchmultz/pi-copy-message
|
|
36
|
-
```
|
|
37
|
-
|
|
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.
|
|
39
|
-
|
|
40
|
-
If you prefer to load it directly from a local checkout during development:
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
pi -e ./extensions/copy-message.ts
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
## Usage
|
|
17
|
+
Start Pi. Run `/reload` if Pi is already open.
|
|
47
18
|
|
|
48
|
-
|
|
19
|
+
Enter this command in Pi:
|
|
49
20
|
|
|
50
21
|
```text
|
|
51
22
|
/copy-message
|
|
52
23
|
```
|
|
53
24
|
|
|
54
|
-
|
|
25
|
+
Type words to search roles and message text. Use ↑ or ↓ to select a message. Press Enter to copy the message.
|
|
55
26
|
|
|
56
|
-
|
|
57
|
-
/copy-user
|
|
58
|
-
```
|
|
27
|
+
## Use the picker
|
|
59
28
|
|
|
60
|
-
|
|
29
|
+
Messages appear from oldest to newest. The picker selects the newest visible message first.
|
|
61
30
|
|
|
62
|
-
|
|
63
|
-
/copy-message latest
|
|
64
|
-
```
|
|
31
|
+
User, assistant, and visible custom messages appear by default. Tool and bash messages stay hidden.
|
|
65
32
|
|
|
66
|
-
|
|
33
|
+
| Key | Action |
|
|
34
|
+
|---|---|
|
|
35
|
+
| Ctrl+T | Show or hide tool and bash messages |
|
|
36
|
+
| Ctrl+U / Ctrl+A / Alt+C | Show or hide user, assistant, or custom messages |
|
|
37
|
+
| Tab | Preview the selected message |
|
|
38
|
+
| Alt+M | Switch between raw text and text with role/time metadata |
|
|
39
|
+
| Esc / Ctrl+C | Cancel |
|
|
67
40
|
|
|
68
|
-
|
|
69
|
-
/copy-message last
|
|
70
|
-
/copy-message newest
|
|
71
|
-
```
|
|
41
|
+
The picker uses your Pi selection bindings. Its key hints show those bindings.
|
|
72
42
|
|
|
73
|
-
|
|
43
|
+
## Direct copies
|
|
74
44
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
45
|
+
| Command | What it copies |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `/copy-user` | Your most recent message with text |
|
|
48
|
+
| `/copy-message latest` | The latest default-visible message in Pi's active context |
|
|
49
|
+
| `/copy-message 3` | The third default-visible message, counted oldest to newest |
|
|
50
|
+
| `/copy-message latest --with-meta` | The latest message with its role and local time, when available |
|
|
78
51
|
|
|
79
|
-
|
|
52
|
+
`latest` follows Pi's active context, including context edits, omissions, and compaction. The picker, numbered copies, and `/copy-user` use stored messages on the current branch, including history before compaction.
|
|
80
53
|
|
|
81
|
-
|
|
82
|
-
/copy-message latest --with-meta
|
|
83
|
-
/copy-message 3 --with-role
|
|
84
|
-
/copy-user --with-meta
|
|
85
|
-
```
|
|
54
|
+
## Limits and clipboard
|
|
86
55
|
|
|
87
|
-
|
|
56
|
+
The picker requires interactive TUI mode. Direct commands also work outside TUI mode, where Pi may hide notifications.
|
|
88
57
|
|
|
89
|
-
|
|
90
|
-
|---|---|
|
|
91
|
-
| Configured `tui.select.up` (default: `↑`) | Move to older visible message |
|
|
92
|
-
| Configured `tui.select.down` (default: `↓`) | Move to newer visible message |
|
|
93
|
-
| Configured `tui.select.pageUp` / `tui.select.pageDown` (default: `PageUp` / `PageDown`) | Move by one visible page in regular mode |
|
|
94
|
-
| `Ctrl+PageUp` / `Ctrl+PageDown` | Move by one visible page in fullscreen mode |
|
|
95
|
-
| `Home` / `End` | Jump to oldest/newest visible message in regular mode |
|
|
96
|
-
| `Ctrl+Home` / `Ctrl+End` | Jump to oldest/newest visible message in fullscreen mode |
|
|
97
|
-
| Type or paste text | Filter visible messages; pasted line breaks and tabs become spaces |
|
|
98
|
-
| `time:<term>` | Search timestamps |
|
|
99
|
-
| `Backspace` | Delete one search character |
|
|
100
|
-
| `Ctrl+U` | Toggle user messages |
|
|
101
|
-
| `Ctrl+A` | Toggle assistant messages |
|
|
102
|
-
| `Ctrl+T` | Toggle tool/bash messages |
|
|
103
|
-
| `Alt+C` | Toggle custom messages |
|
|
104
|
-
| `Tab` | Toggle a wrapped preview of the selected message |
|
|
105
|
-
| `Alt+M` | Toggle raw vs metadata copy format |
|
|
106
|
-
| Configured `tui.select.confirm` (default: `Enter`) | Copy selected message text |
|
|
107
|
-
| Configured `tui.select.cancel` (default: `Esc`/`Ctrl+C`) | Cancel |
|
|
108
|
-
|
|
109
|
-
## Behavior notes
|
|
110
|
-
|
|
111
|
-
- Entry IDs are hidden from the picker.
|
|
112
|
-
- The picker caps visible rows and scrolls instead of filling the screen.
|
|
113
|
-
- Search preserves your original selected message and restores it when the search is cleared.
|
|
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.
|
|
116
|
-
- Filter labels honor the active pi theme.
|
|
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.
|
|
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`.
|
|
119
|
-
- Copy notifications include the role and a short preview so you can verify what was copied.
|
|
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.
|
|
121
|
-
- `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
|
|
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.
|
|
123
|
-
|
|
124
|
-
## Compatibility
|
|
125
|
-
|
|
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
|
-
- Pi includes `Ctrl+X` for copying the latest assistant response; this extension remains useful for searchable history, other roles, metadata, and direct selectors.
|
|
130
|
-
- Node.js `>=24.15.0`. `.nvmrc` pins Node 24.21.0 for local development.
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
## Development
|
|
58
|
+
The extension uses Pi's clipboard support and OSC 52 fallback. It reports “Copied” only after local clipboard readback matches the copied text.
|
|
135
59
|
|
|
136
|
-
|
|
137
|
-
npm ci
|
|
138
|
-
npm run check # node --test + tsc --noEmit + pack dry-run
|
|
139
|
-
```
|
|
60
|
+
Unverified delivery produces a warning. A failed write produces an error.
|
|
140
61
|
|
|
141
|
-
|
|
62
|
+
The extension cannot verify remote terminal delivery. Paste into a local editor to check delivery.
|
|
142
63
|
|
|
143
|
-
|
|
64
|
+
Restart Pi after you change dependencies or the Pi runtime.
|
|
144
65
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
66
|
+
## Docs and support
|
|
67
|
+
|
|
68
|
+
The [reference](https://github.com/fitchmultz/pi-copy-message/blob/main/docs/reference.md) lists all controls, command aliases, and clipboard details. The [development guide](https://github.com/fitchmultz/pi-copy-message/blob/main/docs/development.md) covers tests and releases.
|
|
69
|
+
|
|
70
|
+
[Changelog](CHANGELOG.md) · [Report an issue](https://github.com/fitchmultz/pi-copy-message/issues)
|
|
149
71
|
|
|
150
|
-
|
|
72
|
+
## License
|
|
151
73
|
|
|
152
|
-
|
|
153
|
-
- `tests/copy-message.test.ts` — regression tests for command wiring, filtering, search, navigation, and copy formatting
|
|
74
|
+
[MIT](LICENSE) · Mitch Fultz
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
1
3
|
import { copyToClipboard, type ExtensionAPI, type ExtensionCommandContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import type { AutocompleteItem, TuiMode } from "@earendil-works/pi-tui";
|
|
3
|
-
import { decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
5
|
+
import { decodeKittyPrintable, getNativeClipboard, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
6
|
|
|
5
7
|
const MAX_VISIBLE_MESSAGES = 8;
|
|
6
8
|
const MAX_PEEK_LINES = 16;
|
|
9
|
+
const PEEK_WRAP_CHARS = 65_536;
|
|
7
10
|
|
|
8
11
|
export type CopyFormat = "raw" | "metadata";
|
|
9
12
|
|
|
@@ -63,10 +66,14 @@ function splitGraphemes(text: string): string[] {
|
|
|
63
66
|
|
|
64
67
|
function truncateGraphemes(text: string, max: number): string {
|
|
65
68
|
if (max <= 0) return "";
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
let count = 0;
|
|
70
|
+
let prefixEnd = 0;
|
|
71
|
+
for (const { index } of graphemeSegmenter.segment(text)) {
|
|
72
|
+
if (count === max) return `${text.slice(0, prefixEnd)}…`;
|
|
73
|
+
if (count === max - 1) prefixEnd = index;
|
|
74
|
+
count++;
|
|
75
|
+
}
|
|
76
|
+
return text;
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
function safeDisplayText(text: string): string {
|
|
@@ -169,6 +176,17 @@ export function collectCopyableMessages(ctx: { sessionManager: { getBranch(): un
|
|
|
169
176
|
});
|
|
170
177
|
}
|
|
171
178
|
|
|
179
|
+
function collectProjectedMessages(ctx: ExtensionCommandContext): CopyableMessage[] {
|
|
180
|
+
const entries = ctx.sessionManager.buildSessionProjection().entries.flatMap(({ sourceEntry, messages }): unknown[] => {
|
|
181
|
+
// Retain saved fork boundaries for legacy response-snapshot coalescing.
|
|
182
|
+
if ((sourceEntry.type as string) === "context_window") return [sourceEntry];
|
|
183
|
+
return messages
|
|
184
|
+
.filter((message) => !(sourceEntry.type === "compaction" && message.role === "system"))
|
|
185
|
+
.map((message) => ({ ...sourceEntry, type: "message", message }));
|
|
186
|
+
});
|
|
187
|
+
return collectCopyableMessages({ sessionManager: { getBranch: () => entries } });
|
|
188
|
+
}
|
|
189
|
+
|
|
172
190
|
export type MostRecentUserMessageResult =
|
|
173
191
|
| { kind: "message"; message: CopyableMessage }
|
|
174
192
|
| { kind: "no-user-message" }
|
|
@@ -244,7 +262,7 @@ function messageMatchesSearch(message: CopyableMessage, search: string): boolean
|
|
|
244
262
|
});
|
|
245
263
|
}
|
|
246
264
|
|
|
247
|
-
|
|
265
|
+
function filteredMessages(messages: CopyableMessage[], visibility: MessageVisibility, search = ""): CopyableMessage[] {
|
|
248
266
|
return messages.filter((message) => isVisibleMessage(message, visibility) && messageMatchesSearch(message, search));
|
|
249
267
|
}
|
|
250
268
|
|
|
@@ -261,7 +279,7 @@ export function messageByDefaultNumber(messages: CopyableMessage[], number: numb
|
|
|
261
279
|
return defaultVisibleMessages(messages)[number - 1];
|
|
262
280
|
}
|
|
263
281
|
|
|
264
|
-
|
|
282
|
+
function formatMessageForCopy(message: CopyableMessage, format: CopyFormat): string {
|
|
265
283
|
if (format === "raw") return message.text;
|
|
266
284
|
const time = formatTime(message.timestamp);
|
|
267
285
|
const label = roleLabel(message.role);
|
|
@@ -326,13 +344,25 @@ function renderMessageLine(
|
|
|
326
344
|
|
|
327
345
|
function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMessageTheme, format: CopyFormat): string[] {
|
|
328
346
|
const contentWidth = Math.max(1, width - 2);
|
|
329
|
-
|
|
330
|
-
|
|
347
|
+
let wrapped: string[];
|
|
348
|
+
let complete: boolean;
|
|
349
|
+
// Wrap a growing prefix instead of the whole message. A cut prefix ends on visible text: styling
|
|
350
|
+
// codes attached to a trailing whitespace run would make the wrapper break it into blank lines that
|
|
351
|
+
// a full wrap drops. Greedy wrapping then changes only the prefix's last two wrapped lines, so stop
|
|
352
|
+
// once those lines fall outside the visible window.
|
|
353
|
+
for (let chars = PEEK_WRAP_CHARS; ; chars *= 2) {
|
|
354
|
+
complete = chars >= message.text.length;
|
|
355
|
+
const prefix = complete ? message : { ...message, text: message.text.slice(0, chars) };
|
|
356
|
+
const text = safeDisplayText(formatMessageForCopy(prefix, format));
|
|
357
|
+
wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, complete ? text : text.trimEnd(), false), contentWidth);
|
|
358
|
+
if (complete || wrapped.length >= MAX_PEEK_LINES + 2) break;
|
|
359
|
+
}
|
|
331
360
|
const shown = wrapped.slice(0, MAX_PEEK_LINES);
|
|
332
361
|
const remaining = wrapped.length - shown.length;
|
|
333
362
|
const title = theme.fg("dim", `Peek ${format === "metadata" ? "metadata" : "raw"} ${roleLabel(message.role)} message`);
|
|
334
363
|
const lines = [title, ...shown.map((line) => ` ${line}`)];
|
|
335
|
-
if (
|
|
364
|
+
if (!complete) lines.push(theme.fg("dim", " … more wrapped lines"));
|
|
365
|
+
else if (remaining > 0) lines.push(theme.fg("dim", ` … ${remaining} more wrapped line${remaining === 1 ? "" : "s"}`));
|
|
336
366
|
return lines;
|
|
337
367
|
}
|
|
338
368
|
|
|
@@ -414,6 +444,7 @@ export class CopyMessagePickerState {
|
|
|
414
444
|
peek = false;
|
|
415
445
|
private searchAnchorId: string | undefined;
|
|
416
446
|
private readonly messages: CopyableMessage[];
|
|
447
|
+
private firstVisibleIndex = 0;
|
|
417
448
|
|
|
418
449
|
constructor(messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
|
|
419
450
|
this.messages = messages;
|
|
@@ -435,6 +466,7 @@ export class CopyMessagePickerState {
|
|
|
435
466
|
const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
|
|
436
467
|
const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
|
|
437
468
|
const end = Math.min(this.visibleMessages.length, start + maxVisible);
|
|
469
|
+
this.firstVisibleIndex = start;
|
|
438
470
|
const hasCustomMessages = this.messages.some((message) => message.role === "custom");
|
|
439
471
|
const userState = filterLabel(theme, "user", this.visibility.showUser, "warning");
|
|
440
472
|
const assistantState = filterLabel(theme, "assistant", this.visibility.showAssistant, "accent");
|
|
@@ -584,6 +616,20 @@ export class CopyMessagePickerState {
|
|
|
584
616
|
return "none";
|
|
585
617
|
}
|
|
586
618
|
|
|
619
|
+
handleMouse(event: TuiMouseEvent): PickerInputResult {
|
|
620
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
621
|
+
this.move(event.wheelDelta < 0 ? -1 : 1);
|
|
622
|
+
return "render";
|
|
623
|
+
}
|
|
624
|
+
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return "none";
|
|
625
|
+
const row = event.y - 2; // The message list follows the title and spacer.
|
|
626
|
+
if (row < 0 || row >= MAX_VISIBLE_MESSAGES) return "none";
|
|
627
|
+
const index = this.firstVisibleIndex + row;
|
|
628
|
+
if (!this.visibleMessages[index]) return "none";
|
|
629
|
+
this.selectedIndex = index;
|
|
630
|
+
return event.type === "click" ? "copy" : "render";
|
|
631
|
+
}
|
|
632
|
+
|
|
587
633
|
private refreshMessages(preferredId?: string) {
|
|
588
634
|
const selectedId = preferredId ?? this.visibleMessages[this.selectedIndex]?.id;
|
|
589
635
|
this.visibleMessages = filteredMessages(this.messages, this.visibility, this.search);
|
|
@@ -650,7 +696,7 @@ function parseCopyArgs(args: string | undefined): ParsedCopyMessageArgs {
|
|
|
650
696
|
return result;
|
|
651
697
|
}
|
|
652
698
|
|
|
653
|
-
|
|
699
|
+
function copyArgumentCompletions(prefix: string, includeSelectors: boolean): AutocompleteItem[] | null {
|
|
654
700
|
if (/^\d+$/.test(prefix)) return null;
|
|
655
701
|
const candidates = includeSelectors ? [...COPY_LATEST_SELECTORS, ...COPY_METADATA_FLAGS] : COPY_METADATA_FLAGS;
|
|
656
702
|
const normalized = prefix.toLowerCase();
|
|
@@ -660,27 +706,44 @@ export function copyArgumentCompletions(prefix: string, includeSelectors: boolea
|
|
|
660
706
|
return items.length > 0 ? items : null;
|
|
661
707
|
}
|
|
662
708
|
|
|
663
|
-
async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat) {
|
|
709
|
+
async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat, signal: AbortSignal) {
|
|
710
|
+
if (signal.aborted) return null;
|
|
664
711
|
return ctx.ui.custom<{ message: CopyableMessage; text: string } | null>((tui, theme, keybindings, done) => {
|
|
665
712
|
const state = new CopyMessagePickerState(messages, initialFormat);
|
|
713
|
+
let settled = false;
|
|
714
|
+
const finish = (result: Parameters<typeof done>[0], defer = false) => {
|
|
715
|
+
if (settled) return;
|
|
716
|
+
settled = true;
|
|
717
|
+
if (defer) queueMicrotask(() => done(result));
|
|
718
|
+
else done(result);
|
|
719
|
+
};
|
|
720
|
+
const abort = () => finish(null);
|
|
721
|
+
if (signal.aborted) abort();
|
|
722
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
723
|
+
const act = (result: PickerInputResult) => {
|
|
724
|
+
if (result === "copy") {
|
|
725
|
+
const selected = state.selectedMessage();
|
|
726
|
+
const text = state.selectedCopyText();
|
|
727
|
+
finish(selected && text !== undefined ? { message: selected, text } : null);
|
|
728
|
+
} else if (result === "cancel") finish(null);
|
|
729
|
+
else if (result === "render") tui.requestRender();
|
|
730
|
+
};
|
|
666
731
|
return {
|
|
667
732
|
render(width: number) {
|
|
668
733
|
return state.render(width, theme, keybindings, tui.mode);
|
|
669
734
|
},
|
|
670
735
|
invalidate() {},
|
|
671
|
-
handleInput(data: string) {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
if
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
if (result === "render") tui.requestRender();
|
|
736
|
+
handleInput(data: string) { act(state.handleInput(data, keybindings)); },
|
|
737
|
+
handleMouse(event) {
|
|
738
|
+
const result = state.handleMouse(event);
|
|
739
|
+
act(result);
|
|
740
|
+
if (result !== "none") return { handled: true, focus: result === "render" };
|
|
741
|
+
},
|
|
742
|
+
dispose() {
|
|
743
|
+
signal.removeEventListener("abort", abort);
|
|
744
|
+
// Settle even if disposal precedes session_shutdown. Defer the host callback:
|
|
745
|
+
// it can remove this component from a live dialog-disposal iteration.
|
|
746
|
+
finish(null, true);
|
|
684
747
|
},
|
|
685
748
|
};
|
|
686
749
|
});
|
|
@@ -690,74 +753,132 @@ function copyNotificationText(selected: CopyableMessage): string {
|
|
|
690
753
|
return `Copied ${roleLabel(selected.role)} message: “${compactPreview(selected.text, 48)}”`;
|
|
691
754
|
}
|
|
692
755
|
|
|
693
|
-
|
|
756
|
+
const runClipboardReader = promisify(execFile);
|
|
757
|
+
|
|
758
|
+
async function readCopiedText(expected: string): Promise<string | null | undefined> {
|
|
759
|
+
if (process.platform !== "linux") {
|
|
760
|
+
try {
|
|
761
|
+
const text = await getNativeClipboard()?.getText();
|
|
762
|
+
if (text !== undefined) return text;
|
|
763
|
+
} catch {
|
|
764
|
+
// The platform command fallback can still work if the native helper fails.
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const commands: [string, string[]][] = [];
|
|
768
|
+
const windowsRead = ["-NoProfile", "-Command", "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::Write((Get-Clipboard -Raw))"];
|
|
769
|
+
if (process.platform === "darwin") commands.push(["pbpaste", []]);
|
|
770
|
+
else if (process.platform === "win32") commands.push(["powershell", windowsRead]);
|
|
771
|
+
else {
|
|
772
|
+
if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-get", []]);
|
|
773
|
+
if (process.env.WAYLAND_DISPLAY) commands.push(["wl-paste", ["--no-newline", "--type", "text"]]);
|
|
774
|
+
if (process.env.DISPLAY) commands.push(["xclip", ["-selection", "clipboard", "-out"]], ["xsel", ["--clipboard", "--output"]]);
|
|
775
|
+
if (!commands.length && (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)) commands.push(["powershell.exe", windowsRead]);
|
|
776
|
+
}
|
|
777
|
+
for (const [command, args] of commands) {
|
|
778
|
+
try {
|
|
779
|
+
const { stdout } = await runClipboardReader(command, args, {
|
|
780
|
+
encoding: "utf8", timeout: 3000, maxBuffer: Math.max(1024 * 1024, Buffer.byteLength(expected) + 4096),
|
|
781
|
+
});
|
|
782
|
+
return stdout || null;
|
|
783
|
+
} catch {
|
|
784
|
+
// Try the next platform reader; never expose clipboard contents on failure.
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return undefined;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
type Notify = ExtensionCommandContext["ui"]["notify"];
|
|
791
|
+
|
|
792
|
+
async function copySelectedMessage(notify: Notify, selected: CopyableMessage, text = selected.text) {
|
|
694
793
|
try {
|
|
695
794
|
await copyToClipboard(text);
|
|
696
|
-
|
|
795
|
+
const actual = await readCopiedText(text);
|
|
796
|
+
const remote = Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.MOSH_CONNECTION);
|
|
797
|
+
if (actual === text && !remote) notify(copyNotificationText(selected), "info");
|
|
798
|
+
else notify(
|
|
799
|
+
actual === text ? "Local clipboard verified; remote terminal clipboard delivery is unverified."
|
|
800
|
+
: "Clipboard command completed, but delivery could not be verified.",
|
|
801
|
+
"warning",
|
|
802
|
+
);
|
|
697
803
|
} catch (error) {
|
|
698
|
-
|
|
804
|
+
notify(error instanceof Error ? error.message : "Failed to copy to clipboard", "error");
|
|
699
805
|
}
|
|
700
806
|
}
|
|
701
807
|
|
|
702
|
-
async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager"
|
|
808
|
+
async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager">, notify: Notify, format: CopyFormat) {
|
|
703
809
|
const result = getMostRecentUserMessage(ctx);
|
|
704
810
|
if (result.kind === "no-user-message") {
|
|
705
|
-
|
|
811
|
+
notify("No user messages found", "warning");
|
|
706
812
|
return;
|
|
707
813
|
}
|
|
708
814
|
if (result.kind === "no-text") {
|
|
709
|
-
|
|
815
|
+
notify("No user message text found", "warning");
|
|
710
816
|
return;
|
|
711
817
|
}
|
|
712
|
-
|
|
713
|
-
await copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
|
|
818
|
+
await copySelectedMessage(notify, result.message, formatMessageForCopy(result.message, format));
|
|
714
819
|
}
|
|
715
820
|
|
|
716
|
-
export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
|
|
821
|
+
export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand" | "on">) {
|
|
822
|
+
let generation = 0;
|
|
823
|
+
let pickerAbort: AbortController | undefined;
|
|
824
|
+
const reset = () => {
|
|
825
|
+
generation++;
|
|
826
|
+
pickerAbort?.abort();
|
|
827
|
+
pickerAbort = undefined;
|
|
828
|
+
};
|
|
829
|
+
pi.on("session_start", reset);
|
|
830
|
+
pi.on("session_shutdown", reset);
|
|
831
|
+
pi.on("session_tree", reset);
|
|
832
|
+
const notifications = (ctx: ExtensionCommandContext): Notify => {
|
|
833
|
+
const current = generation;
|
|
834
|
+
return (message, type) => { if (current === generation) ctx.ui.notify(message, type); };
|
|
835
|
+
};
|
|
717
836
|
pi.registerCommand("copy-message", {
|
|
718
837
|
description: "Select a session message and copy its text to the clipboard",
|
|
719
838
|
getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, true),
|
|
720
839
|
handler: async (args, ctx) => {
|
|
840
|
+
const notify = notifications(ctx);
|
|
721
841
|
const parsedArgs = parseCopyArgs(args);
|
|
722
|
-
const messages = collectCopyableMessages(ctx);
|
|
842
|
+
const messages = parsedArgs.selector === "latest" ? collectProjectedMessages(ctx) : collectCopyableMessages(ctx);
|
|
723
843
|
if (messages.length === 0) {
|
|
724
|
-
|
|
844
|
+
notify("No copyable messages found in the current branch", "error");
|
|
725
845
|
return;
|
|
726
846
|
}
|
|
727
|
-
|
|
728
847
|
if (parsedArgs.selector === "latest") {
|
|
729
848
|
const latestVisible = latestDefaultMessage(messages);
|
|
730
|
-
if (latestVisible) await copySelectedMessage(
|
|
849
|
+
if (latestVisible) await copySelectedMessage(notify, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
|
|
731
850
|
return;
|
|
732
851
|
}
|
|
733
|
-
|
|
734
852
|
if (typeof parsedArgs.selector === "object") {
|
|
735
853
|
const selected = messageByDefaultNumber(messages, parsedArgs.selector.number);
|
|
736
854
|
if (!selected) {
|
|
737
|
-
|
|
855
|
+
notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
|
|
738
856
|
return;
|
|
739
857
|
}
|
|
740
|
-
await copySelectedMessage(
|
|
858
|
+
await copySelectedMessage(notify, selected, formatMessageForCopy(selected, parsedArgs.format));
|
|
741
859
|
return;
|
|
742
860
|
}
|
|
743
|
-
|
|
744
861
|
if (ctx.mode !== "tui") {
|
|
745
|
-
|
|
862
|
+
notify("/copy-message requires interactive TUI mode unless you pass latest/last/newest or a message number", "error");
|
|
746
863
|
return;
|
|
747
864
|
}
|
|
748
|
-
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
865
|
+
pickerAbort?.abort();
|
|
866
|
+
const controller = new AbortController();
|
|
867
|
+
pickerAbort = controller;
|
|
868
|
+
try {
|
|
869
|
+
const selected = await pickMessage(ctx, messages, parsedArgs.format, controller.signal);
|
|
870
|
+
if (!selected || controller.signal.aborted) return;
|
|
871
|
+
await copySelectedMessage(notify, selected.message, selected.text);
|
|
872
|
+
} finally {
|
|
873
|
+
if (pickerAbort === controller) pickerAbort = undefined;
|
|
874
|
+
}
|
|
753
875
|
},
|
|
754
876
|
});
|
|
755
|
-
|
|
756
877
|
pi.registerCommand("copy-user", {
|
|
757
878
|
description: "Copy the most recent user message to the clipboard",
|
|
758
879
|
getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, false),
|
|
759
880
|
handler: async (args, ctx) => {
|
|
760
|
-
await copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
|
|
881
|
+
await copyMostRecentUserMessage(ctx, notifications(ctx), parseCopyArgs(args).format);
|
|
761
882
|
},
|
|
762
883
|
});
|
|
763
884
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-copy-message",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
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",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"LICENSE"
|
|
46
46
|
],
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
49
|
-
"@earendil-works/pi-tui": "0.
|
|
50
|
-
"@types/node": "24.19.
|
|
48
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
49
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
50
|
+
"@types/node": "24.19.1",
|
|
51
51
|
"typescript": "7.0.2"
|
|
52
52
|
},
|
|
53
53
|
"pi": {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"./extensions"
|
|
56
56
|
]
|
|
57
57
|
},
|
|
58
|
-
"packageManager": "npm@12.
|
|
58
|
+
"packageManager": "npm@12.2.0",
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@earendil-works/pi-coding-agent": "*",
|
|
61
61
|
"@earendil-works/pi-tui": "*"
|