pi-copy-message 2.1.0 → 4.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 +20 -0
- package/README.md +25 -13
- package/extensions/copy-message.ts +184 -111
- package/package.json +11 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.0 - 2026-10-01
|
|
4
|
+
|
|
5
|
+
- Require Pi 1.0.0 and pin the official 1.0.0 development cohort.
|
|
6
|
+
- 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.
|
|
7
|
+
- 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.
|
|
8
|
+
- Cancel pending pickers on session/tree/shutdown lifecycle changes; suppress stale completion notifications.
|
|
9
|
+
- Add native mouse navigation/copy with restored editor focus and safe isolated clipboard/SDK lifecycle regression coverage.
|
|
10
|
+
|
|
11
|
+
## 3.0.0 - 2026-09-26
|
|
12
|
+
|
|
13
|
+
- Require Node.js 24.15 or newer for repository tooling; `.nvmrc` pins 24.21.0 and `packageManager` is npm 12.1.0.
|
|
14
|
+
- Run tests with Node's built-in `node --test` and type stripping instead of `tsx`; typecheck with TypeScript 7.0.2 and `erasableSyntaxOnly`.
|
|
15
|
+
- Refresh the development baseline to Pi 0.87.1, qualified against official Pi and the maintained fork.
|
|
16
|
+
- Guard `package-lock.json` against private-registry URLs.
|
|
17
|
+
- First npm release containing the 2.1.1 clipboard change (2.1.1 was tagged but never published to npm).
|
|
18
|
+
|
|
19
|
+
## 2.1.1 - 2026-08-25
|
|
20
|
+
|
|
21
|
+
- Reuse Pi's clipboard implementation instead of maintaining a duplicate command runner, enabling Pi's OSC 52 fallback on Android/Termux.
|
|
22
|
+
|
|
3
23
|
## 2.1.0 - 2026-08-17
|
|
4
24
|
|
|
5
25
|
- Add an `Alt+C` picker filter and `MessageVisibility.showCustom` option for showing or hiding custom messages.
|
package/README.md
CHANGED
|
@@ -16,7 +16,8 @@ A [pi](https://github.com/earendil-works/pi-mono) extension that adds `/copy-mes
|
|
|
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
|
-
-
|
|
19
|
+
- Keeps Pi's platform clipboard writes and OSC 52 fallback, but reports success only after local text readback
|
|
20
|
+
- Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`, using Pi's canonical active context
|
|
20
21
|
- Supports direct numbered copies like `/copy-message 3`
|
|
21
22
|
- Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
|
|
22
23
|
|
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
```text
|
|
40
|
-
/reload
|
|
41
|
-
```
|
|
38
|
+
Use `/reload` after updating extension code on current official Pi and the maintained fork. Restart Pi after changing dependencies or the host runtime.
|
|
42
39
|
|
|
43
40
|
If you prefer to load it directly from a local checkout during development:
|
|
44
41
|
|
|
@@ -97,7 +94,7 @@ 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 |
|
|
@@ -112,31 +109,46 @@ Copy with role and timestamp metadata instead of raw text only:
|
|
|
112
109
|
## Behavior notes
|
|
113
110
|
|
|
114
111
|
- Entry IDs are hidden from the picker.
|
|
112
|
+
- Mouse wheel navigates; clicking a message copies it and returns keyboard focus to the unchanged draft.
|
|
113
|
+
- Session replacement, tree navigation, shutdown and reload cancel pending pickers without stale-context notifications.
|
|
115
114
|
- The picker caps visible rows and scrolls instead of filling the screen.
|
|
116
115
|
- Search preserves your original selected message and restores it when the search is cleared.
|
|
117
116
|
- General search does not match timestamps; use `time:<term>` when you want to search by displayed time.
|
|
117
|
+
- Picker previews and copy notifications remove terminal control characters; the copied text stays raw.
|
|
118
118
|
- Filter labels honor the active pi theme.
|
|
119
119
|
- 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.
|
|
120
120
|
- 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`.
|
|
121
|
-
-
|
|
121
|
+
- A verified local copy notification includes the role and a short preview. Unavailable, empty or mismatched readback warns instead of claiming success; rejected writes report an error. Remote terminal delivery via OSC 52 cannot be verified, even when the remote machine's local clipboard matches.
|
|
122
|
+
- Native clipboard reads are preferred where available. Bounded platform readers preserve exact text, including newlines; the extension never replaces Pi's working write fallback chain.
|
|
123
|
+
- `latest` uses Pi's canonical session projection, honoring context edits, omissions and compaction. Numbered selectors, the picker and `/copy-user` retain raw active-branch history, including older pre-compaction messages.
|
|
122
124
|
- `/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.
|
|
123
125
|
- `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
|
|
124
126
|
- 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.
|
|
125
127
|
|
|
126
128
|
## Compatibility
|
|
127
129
|
|
|
128
|
-
- Requires and
|
|
130
|
+
- Requires Pi 1.0.0 or later. Development and standalone/packed qualification use the official 1.0.0 cohort; a maintained fork must be qualified at its actual supported revision, not by version-string equivalence.
|
|
131
|
+
- The picker coalesces legacy in-progress assistant snapshots within their context-window boundaries so each response appears once. Current official Pi and the maintained fork no longer write those legacy boundaries.
|
|
132
|
+
- Android/Termux clipboard writes use Pi's normal fallback chain, including OSC 52 when native clipboard commands are unavailable.
|
|
129
133
|
- Pi includes `Ctrl+X` for copying the latest assistant response; this extension remains useful for searchable history, other roles, metadata, and direct selectors.
|
|
130
|
-
-
|
|
131
|
-
- `.nvmrc` pins Node 22.19.0 for local development
|
|
134
|
+
- Node.js `>=24.15.0`. `.nvmrc` pins Node 24.21.0 for local development.
|
|
132
135
|
|
|
133
136
|
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.
|
|
134
137
|
|
|
135
138
|
## Development
|
|
136
139
|
|
|
137
140
|
```bash
|
|
138
|
-
npm
|
|
139
|
-
npm run check
|
|
141
|
+
npm ci
|
|
142
|
+
npm run check # node --test + tsc --noEmit + pack dry-run
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
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 formatting, native command execution, canonical context and branch selection, both TUI modes, pointer focus and pending-picker disposal. Native addon interception and an isolated command PATH keep test clipboard writes in temporary fixture files, never the operator clipboard. Actual Android/Termux, Windows/WSL and remote terminal delivery still require platform qualification.
|
|
146
|
+
|
|
147
|
+
`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:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npm install --package-lock-only --ignore-scripts --registry=https://registry.npmjs.org/
|
|
151
|
+
npm ci
|
|
140
152
|
```
|
|
141
153
|
|
|
142
154
|
Key files:
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { copyToClipboard, type ExtensionAPI, type ExtensionCommandContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import type { AutocompleteItem, TuiMode } from "@earendil-works/pi-tui";
|
|
4
|
-
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";
|
|
5
6
|
|
|
6
7
|
const MAX_VISIBLE_MESSAGES = 8;
|
|
7
8
|
const MAX_PEEK_LINES = 16;
|
|
@@ -33,7 +34,7 @@ function textFromContent(content: unknown): string {
|
|
|
33
34
|
);
|
|
34
35
|
})
|
|
35
36
|
.map((part) => part.text)
|
|
36
|
-
.join("
|
|
37
|
+
.join("");
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
function textFromMessage(message: Record<string, unknown>): string {
|
|
@@ -56,14 +57,10 @@ function textFromMessage(message: Record<string, unknown>): string {
|
|
|
56
57
|
return textFromContent(message.content);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
segment(input: string): Iterable<{ segment: string }>;
|
|
61
|
-
};
|
|
60
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
62
61
|
|
|
63
62
|
function splitGraphemes(text: string): string[] {
|
|
64
|
-
|
|
65
|
-
if (!Segmenter) return Array.from(text);
|
|
66
|
-
return Array.from(new Segmenter(undefined, { granularity: "grapheme" }).segment(text), (part) => part.segment);
|
|
63
|
+
return Array.from(graphemeSegmenter.segment(text), (part) => part.segment);
|
|
67
64
|
}
|
|
68
65
|
|
|
69
66
|
function truncateGraphemes(text: string, max: number): string {
|
|
@@ -74,8 +71,12 @@ function truncateGraphemes(text: string, max: number): string {
|
|
|
74
71
|
return `${graphemes.slice(0, max - 1).join("")}…`;
|
|
75
72
|
}
|
|
76
73
|
|
|
74
|
+
function safeDisplayText(text: string): string {
|
|
75
|
+
return text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
|
|
76
|
+
}
|
|
77
|
+
|
|
77
78
|
function compactPreview(text: string, max = 96): string {
|
|
78
|
-
const preview = text.replace(/\s+/gu, " ").trim();
|
|
79
|
+
const preview = safeDisplayText(text).replace(/\s+/gu, " ").trim();
|
|
79
80
|
return truncateGraphemes(preview, max);
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -96,7 +97,7 @@ function roleLabel(role: string): string {
|
|
|
96
97
|
case "compactionSummary":
|
|
97
98
|
return "compaction";
|
|
98
99
|
default:
|
|
99
|
-
return role || "message";
|
|
100
|
+
return safeDisplayText(role).replace(/\s+/gu, " ").trim() || "message";
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -138,12 +139,49 @@ function entryToCopyableMessage(entry: unknown): CopyableMessage | undefined {
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
export function collectCopyableMessages(ctx: { sessionManager: { getBranch(): unknown[] } }): CopyableMessage[] {
|
|
141
|
-
|
|
142
|
+
const entries: unknown[] = [];
|
|
143
|
+
// Pi persists assistant snapshots separately and coalesces them within each context window.
|
|
144
|
+
const responses = new Map<string, number>();
|
|
145
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
146
|
+
const record = entry as {
|
|
147
|
+
type?: string;
|
|
148
|
+
message?: { role?: string; responseId?: string; stopReason?: string; content?: unknown[] };
|
|
149
|
+
} | null;
|
|
150
|
+
if (record?.type === "context_window") responses.clear();
|
|
151
|
+
const responseId =
|
|
152
|
+
record?.type === "message" && record.message?.role === "assistant" && typeof record.message.responseId === "string"
|
|
153
|
+
? record.message.responseId
|
|
154
|
+
: undefined;
|
|
155
|
+
const previous = responseId ? responses.get(responseId) : undefined;
|
|
156
|
+
if (previous !== undefined) {
|
|
157
|
+
const earlier = entries[previous] as typeof record;
|
|
158
|
+
if (
|
|
159
|
+
record?.message?.stopReason !== "pending" ||
|
|
160
|
+
(earlier?.message?.stopReason === "pending" && (earlier.message.content?.length ?? 0) <= (record.message.content?.length ?? 0))
|
|
161
|
+
) entries[previous] = entry;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (responseId) responses.set(responseId, entries.length);
|
|
165
|
+
entries.push(entry);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return entries.flatMap((entry) => {
|
|
142
169
|
const message = entryToCopyableMessage(entry);
|
|
143
170
|
return message ? [message] : [];
|
|
144
171
|
});
|
|
145
172
|
}
|
|
146
173
|
|
|
174
|
+
function collectProjectedMessages(ctx: ExtensionCommandContext): CopyableMessage[] {
|
|
175
|
+
const entries = ctx.sessionManager.buildSessionProjection().entries.flatMap(({ sourceEntry, messages }): unknown[] => {
|
|
176
|
+
// Retain saved fork boundaries for legacy response-snapshot coalescing.
|
|
177
|
+
if ((sourceEntry.type as string) === "context_window") return [sourceEntry];
|
|
178
|
+
return messages
|
|
179
|
+
.filter((message) => !(sourceEntry.type === "compaction" && message.role === "system"))
|
|
180
|
+
.map((message) => ({ ...sourceEntry, type: "message", message }));
|
|
181
|
+
});
|
|
182
|
+
return collectCopyableMessages({ sessionManager: { getBranch: () => entries } });
|
|
183
|
+
}
|
|
184
|
+
|
|
147
185
|
export type MostRecentUserMessageResult =
|
|
148
186
|
| { kind: "message"; message: CopyableMessage }
|
|
149
187
|
| { kind: "no-user-message" }
|
|
@@ -181,58 +219,6 @@ export function getMostRecentUserMessage(ctx: { sessionManager: { getBranch(): u
|
|
|
181
219
|
return sawUserMessage ? { kind: "no-text" } : { kind: "no-user-message" };
|
|
182
220
|
}
|
|
183
221
|
|
|
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
222
|
function isToolMessage(message: CopyableMessage): boolean {
|
|
237
223
|
return message.role === "toolResult" || message.role === "bashExecution";
|
|
238
224
|
}
|
|
@@ -353,7 +339,7 @@ function renderMessageLine(
|
|
|
353
339
|
|
|
354
340
|
function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMessageTheme, format: CopyFormat): string[] {
|
|
355
341
|
const contentWidth = Math.max(1, width - 2);
|
|
356
|
-
const text = formatMessageForCopy(message, format);
|
|
342
|
+
const text = safeDisplayText(formatMessageForCopy(message, format));
|
|
357
343
|
const wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, text, false), contentWidth);
|
|
358
344
|
const shown = wrapped.slice(0, MAX_PEEK_LINES);
|
|
359
345
|
const remaining = wrapped.length - shown.length;
|
|
@@ -440,8 +426,11 @@ export class CopyMessagePickerState {
|
|
|
440
426
|
format: CopyFormat;
|
|
441
427
|
peek = false;
|
|
442
428
|
private searchAnchorId: string | undefined;
|
|
429
|
+
private readonly messages: CopyableMessage[];
|
|
430
|
+
private firstVisibleIndex = 0;
|
|
443
431
|
|
|
444
|
-
constructor(
|
|
432
|
+
constructor(messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
|
|
433
|
+
this.messages = messages;
|
|
445
434
|
this.format = initialFormat;
|
|
446
435
|
this.visibleMessages = filteredMessages(messages, this.visibility, this.search);
|
|
447
436
|
this.selectedIndex = Math.max(0, this.visibleMessages.length - 1);
|
|
@@ -460,6 +449,7 @@ export class CopyMessagePickerState {
|
|
|
460
449
|
const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
|
|
461
450
|
const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
|
|
462
451
|
const end = Math.min(this.visibleMessages.length, start + maxVisible);
|
|
452
|
+
this.firstVisibleIndex = start;
|
|
463
453
|
const hasCustomMessages = this.messages.some((message) => message.role === "custom");
|
|
464
454
|
const userState = filterLabel(theme, "user", this.visibility.showUser, "warning");
|
|
465
455
|
const assistantState = filterLabel(theme, "assistant", this.visibility.showAssistant, "accent");
|
|
@@ -496,6 +486,13 @@ export class CopyMessagePickerState {
|
|
|
496
486
|
}
|
|
497
487
|
|
|
498
488
|
handleInput(data: string, keybindings?: PickerKeybindings): PickerInputResult {
|
|
489
|
+
// Pi delivers each paste as one input wrapped in bracketed-paste markers.
|
|
490
|
+
if (data.startsWith("\x1b[200~") && data.endsWith("\x1b[201~")) {
|
|
491
|
+
const text = data.slice(6, -6).replace(/\s+/gu, " ");
|
|
492
|
+
if (!isPrintableSearchInput(text)) return "none";
|
|
493
|
+
this.setSearch(this.search + text);
|
|
494
|
+
return "render";
|
|
495
|
+
}
|
|
499
496
|
if (keybindings?.matches(data, "tui.select.up")) {
|
|
500
497
|
this.move(-1);
|
|
501
498
|
return "render";
|
|
@@ -545,7 +542,7 @@ export class CopyMessagePickerState {
|
|
|
545
542
|
return "render";
|
|
546
543
|
}
|
|
547
544
|
if (matchesKey(data, "backspace") || data === "\x7f") {
|
|
548
|
-
this.setSearch(this.search.slice(0, -1));
|
|
545
|
+
this.setSearch(splitGraphemes(this.search).slice(0, -1).join(""));
|
|
549
546
|
return "render";
|
|
550
547
|
}
|
|
551
548
|
const printable = decodeKittyPrintable(data) ?? data;
|
|
@@ -602,6 +599,20 @@ export class CopyMessagePickerState {
|
|
|
602
599
|
return "none";
|
|
603
600
|
}
|
|
604
601
|
|
|
602
|
+
handleMouse(event: TuiMouseEvent): PickerInputResult {
|
|
603
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
604
|
+
this.move(event.wheelDelta < 0 ? -1 : 1);
|
|
605
|
+
return "render";
|
|
606
|
+
}
|
|
607
|
+
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return "none";
|
|
608
|
+
const row = event.y - 2; // The message list follows the title and spacer.
|
|
609
|
+
if (row < 0 || row >= MAX_VISIBLE_MESSAGES) return "none";
|
|
610
|
+
const index = this.firstVisibleIndex + row;
|
|
611
|
+
if (!this.visibleMessages[index]) return "none";
|
|
612
|
+
this.selectedIndex = index;
|
|
613
|
+
return event.type === "click" ? "copy" : "render";
|
|
614
|
+
}
|
|
615
|
+
|
|
605
616
|
private refreshMessages(preferredId?: string) {
|
|
606
617
|
const selectedId = preferredId ?? this.visibleMessages[this.selectedIndex]?.id;
|
|
607
618
|
this.visibleMessages = filteredMessages(this.messages, this.visibility, this.search);
|
|
@@ -678,28 +689,33 @@ export function copyArgumentCompletions(prefix: string, includeSelectors: boolea
|
|
|
678
689
|
return items.length > 0 ? items : null;
|
|
679
690
|
}
|
|
680
691
|
|
|
681
|
-
async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat) {
|
|
692
|
+
async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat, signal: AbortSignal) {
|
|
693
|
+
if (signal.aborted) return null;
|
|
682
694
|
return ctx.ui.custom<{ message: CopyableMessage; text: string } | null>((tui, theme, keybindings, done) => {
|
|
683
695
|
const state = new CopyMessagePickerState(messages, initialFormat);
|
|
696
|
+
const abort = () => done(null);
|
|
697
|
+
if (signal.aborted) abort();
|
|
698
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
699
|
+
const act = (result: PickerInputResult) => {
|
|
700
|
+
if (result === "copy") {
|
|
701
|
+
const selected = state.selectedMessage();
|
|
702
|
+
const text = state.selectedCopyText();
|
|
703
|
+
done(selected && text !== undefined ? { message: selected, text } : null);
|
|
704
|
+
} else if (result === "cancel") done(null);
|
|
705
|
+
else if (result === "render") tui.requestRender();
|
|
706
|
+
};
|
|
684
707
|
return {
|
|
685
708
|
render(width: number) {
|
|
686
709
|
return state.render(width, theme, keybindings, tui.mode);
|
|
687
710
|
},
|
|
688
711
|
invalidate() {},
|
|
689
|
-
handleInput(data: string) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
done(selected && text !== undefined ? { message: selected, text } : null);
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
if (result === "cancel") {
|
|
698
|
-
done(null);
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
if (result === "render") tui.requestRender();
|
|
712
|
+
handleInput(data: string) { act(state.handleInput(data, keybindings)); },
|
|
713
|
+
handleMouse(event) {
|
|
714
|
+
const result = state.handleMouse(event);
|
|
715
|
+
act(result);
|
|
716
|
+
if (result !== "none") return { handled: true, focus: result === "render" };
|
|
702
717
|
},
|
|
718
|
+
dispose: () => signal.removeEventListener("abort", abort),
|
|
703
719
|
};
|
|
704
720
|
});
|
|
705
721
|
}
|
|
@@ -708,75 +724,132 @@ function copyNotificationText(selected: CopyableMessage): string {
|
|
|
708
724
|
return `Copied ${roleLabel(selected.role)} message: “${compactPreview(selected.text, 48)}”`;
|
|
709
725
|
}
|
|
710
726
|
|
|
711
|
-
|
|
712
|
-
const error = copyToClipboard(text);
|
|
713
|
-
if (error) {
|
|
714
|
-
ctx.ui.notify(error, "error");
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
727
|
+
const runClipboardReader = promisify(execFile);
|
|
717
728
|
|
|
718
|
-
|
|
729
|
+
async function readCopiedText(expected: string): Promise<string | null | undefined> {
|
|
730
|
+
if (process.platform !== "linux") {
|
|
731
|
+
try {
|
|
732
|
+
const text = await getNativeClipboard()?.getText();
|
|
733
|
+
if (text !== undefined) return text;
|
|
734
|
+
} catch {
|
|
735
|
+
// The platform command fallback can still work if the native helper fails.
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const commands: [string, string[]][] = [];
|
|
739
|
+
const windowsRead = ["-NoProfile", "-Command", "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::Write((Get-Clipboard -Raw))"];
|
|
740
|
+
if (process.platform === "darwin") commands.push(["pbpaste", []]);
|
|
741
|
+
else if (process.platform === "win32") commands.push(["powershell", windowsRead]);
|
|
742
|
+
else {
|
|
743
|
+
if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-get", []]);
|
|
744
|
+
if (process.env.WAYLAND_DISPLAY) commands.push(["wl-paste", ["--no-newline", "--type", "text"]]);
|
|
745
|
+
if (process.env.DISPLAY) commands.push(["xclip", ["-selection", "clipboard", "-out"]], ["xsel", ["--clipboard", "--output"]]);
|
|
746
|
+
if (!commands.length && (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)) commands.push(["powershell.exe", windowsRead]);
|
|
747
|
+
}
|
|
748
|
+
for (const [command, args] of commands) {
|
|
749
|
+
try {
|
|
750
|
+
const { stdout } = await runClipboardReader(command, args, {
|
|
751
|
+
encoding: "utf8", timeout: 3000, maxBuffer: Math.max(1024 * 1024, Buffer.byteLength(expected) + 4096),
|
|
752
|
+
});
|
|
753
|
+
return stdout || null;
|
|
754
|
+
} catch {
|
|
755
|
+
// Try the next platform reader; never expose clipboard contents on failure.
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return undefined;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
type Notify = ExtensionCommandContext["ui"]["notify"];
|
|
762
|
+
|
|
763
|
+
async function copySelectedMessage(notify: Notify, selected: CopyableMessage, text = selected.text) {
|
|
764
|
+
try {
|
|
765
|
+
await copyToClipboard(text);
|
|
766
|
+
const actual = await readCopiedText(text);
|
|
767
|
+
const remote = Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.MOSH_CONNECTION);
|
|
768
|
+
if (actual === text && !remote) notify(copyNotificationText(selected), "info");
|
|
769
|
+
else notify(
|
|
770
|
+
actual === text ? "Local clipboard verified; remote terminal clipboard delivery is unverified."
|
|
771
|
+
: "Clipboard command completed, but delivery could not be verified.",
|
|
772
|
+
"warning",
|
|
773
|
+
);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
notify(error instanceof Error ? error.message : "Failed to copy to clipboard", "error");
|
|
776
|
+
}
|
|
719
777
|
}
|
|
720
778
|
|
|
721
|
-
function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager"
|
|
779
|
+
async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager">, notify: Notify, format: CopyFormat) {
|
|
722
780
|
const result = getMostRecentUserMessage(ctx);
|
|
723
781
|
if (result.kind === "no-user-message") {
|
|
724
|
-
|
|
782
|
+
notify("No user messages found", "warning");
|
|
725
783
|
return;
|
|
726
784
|
}
|
|
727
785
|
if (result.kind === "no-text") {
|
|
728
|
-
|
|
786
|
+
notify("No user message text found", "warning");
|
|
729
787
|
return;
|
|
730
788
|
}
|
|
731
|
-
|
|
732
|
-
copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
|
|
789
|
+
await copySelectedMessage(notify, result.message, formatMessageForCopy(result.message, format));
|
|
733
790
|
}
|
|
734
791
|
|
|
735
|
-
export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
|
|
792
|
+
export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand" | "on">) {
|
|
793
|
+
let generation = 0;
|
|
794
|
+
let pickerAbort: AbortController | undefined;
|
|
795
|
+
const reset = () => {
|
|
796
|
+
generation++;
|
|
797
|
+
pickerAbort?.abort();
|
|
798
|
+
pickerAbort = undefined;
|
|
799
|
+
};
|
|
800
|
+
pi.on("session_start", reset);
|
|
801
|
+
pi.on("session_shutdown", reset);
|
|
802
|
+
pi.on("session_tree", reset);
|
|
803
|
+
const notifications = (ctx: ExtensionCommandContext): Notify => {
|
|
804
|
+
const current = generation;
|
|
805
|
+
return (message, type) => { if (current === generation) ctx.ui.notify(message, type); };
|
|
806
|
+
};
|
|
736
807
|
pi.registerCommand("copy-message", {
|
|
737
808
|
description: "Select a session message and copy its text to the clipboard",
|
|
738
809
|
getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, true),
|
|
739
810
|
handler: async (args, ctx) => {
|
|
811
|
+
const notify = notifications(ctx);
|
|
740
812
|
const parsedArgs = parseCopyArgs(args);
|
|
741
|
-
const messages = collectCopyableMessages(ctx);
|
|
813
|
+
const messages = parsedArgs.selector === "latest" ? collectProjectedMessages(ctx) : collectCopyableMessages(ctx);
|
|
742
814
|
if (messages.length === 0) {
|
|
743
|
-
|
|
815
|
+
notify("No copyable messages found in the current branch", "error");
|
|
744
816
|
return;
|
|
745
817
|
}
|
|
746
|
-
|
|
747
818
|
if (parsedArgs.selector === "latest") {
|
|
748
819
|
const latestVisible = latestDefaultMessage(messages);
|
|
749
|
-
if (latestVisible) copySelectedMessage(
|
|
820
|
+
if (latestVisible) await copySelectedMessage(notify, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
|
|
750
821
|
return;
|
|
751
822
|
}
|
|
752
|
-
|
|
753
823
|
if (typeof parsedArgs.selector === "object") {
|
|
754
824
|
const selected = messageByDefaultNumber(messages, parsedArgs.selector.number);
|
|
755
825
|
if (!selected) {
|
|
756
|
-
|
|
826
|
+
notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
|
|
757
827
|
return;
|
|
758
828
|
}
|
|
759
|
-
copySelectedMessage(
|
|
829
|
+
await copySelectedMessage(notify, selected, formatMessageForCopy(selected, parsedArgs.format));
|
|
760
830
|
return;
|
|
761
831
|
}
|
|
762
|
-
|
|
763
832
|
if (ctx.mode !== "tui") {
|
|
764
|
-
|
|
833
|
+
notify("/copy-message requires interactive TUI mode unless you pass latest/last/newest or a message number", "error");
|
|
765
834
|
return;
|
|
766
835
|
}
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
836
|
+
pickerAbort?.abort();
|
|
837
|
+
const controller = new AbortController();
|
|
838
|
+
pickerAbort = controller;
|
|
839
|
+
try {
|
|
840
|
+
const selected = await pickMessage(ctx, messages, parsedArgs.format, controller.signal);
|
|
841
|
+
if (!selected || controller.signal.aborted) return;
|
|
842
|
+
await copySelectedMessage(notify, selected.message, selected.text);
|
|
843
|
+
} finally {
|
|
844
|
+
if (pickerAbort === controller) pickerAbort = undefined;
|
|
845
|
+
}
|
|
772
846
|
},
|
|
773
847
|
});
|
|
774
|
-
|
|
775
848
|
pi.registerCommand("copy-user", {
|
|
776
849
|
description: "Copy the most recent user message to the clipboard",
|
|
777
850
|
getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, false),
|
|
778
851
|
handler: async (args, ctx) => {
|
|
779
|
-
copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
|
|
852
|
+
await copyMostRecentUserMessage(ctx, notifications(ctx), parseCopyArgs(args).format);
|
|
780
853
|
},
|
|
781
854
|
});
|
|
782
855
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-copy-message",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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": ">=
|
|
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": "
|
|
35
|
+
"test": "node --test tests/*.test.ts",
|
|
36
36
|
"typecheck": "tsc --noEmit",
|
|
37
|
-
"check": "npm test && npm run typecheck",
|
|
38
|
-
"
|
|
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.
|
|
48
|
-
"@earendil-works/pi-tui": "0.
|
|
49
|
-
"@types/node": "
|
|
50
|
-
"
|
|
51
|
-
"typescript": "^6.0.3"
|
|
48
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
49
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
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@
|
|
58
|
+
"packageManager": "npm@12.2.0",
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@earendil-works/pi-coding-agent": "*",
|
|
61
61
|
"@earendil-works/pi-tui": "*"
|