pi-copy-message 2.0.0 → 3.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 +17 -0
- package/README.md +23 -14
- package/extensions/copy-message.ts +79 -86
- package/package.json +11 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.0.0 - 2026-09-26
|
|
4
|
+
|
|
5
|
+
- Require Node.js 24.15 or newer for repository tooling; `.nvmrc` pins 24.21.0 and `packageManager` is npm 12.1.0.
|
|
6
|
+
- Run tests with Node's built-in `node --test` and type stripping instead of `tsx`; typecheck with TypeScript 7.0.2 and `erasableSyntaxOnly`.
|
|
7
|
+
- Refresh the development baseline to Pi 0.87.1, qualified against official Pi and the maintained fork.
|
|
8
|
+
- Guard `package-lock.json` against private-registry URLs.
|
|
9
|
+
- First npm release containing the 2.1.1 clipboard change (2.1.1 was tagged but never published to npm).
|
|
10
|
+
|
|
11
|
+
## 2.1.1 - 2026-08-25
|
|
12
|
+
|
|
13
|
+
- Reuse Pi's clipboard implementation instead of maintaining a duplicate command runner, enabling Pi's OSC 52 fallback on Android/Termux.
|
|
14
|
+
|
|
15
|
+
## 2.1.0 - 2026-08-17
|
|
16
|
+
|
|
17
|
+
- Add an `Alt+C` picker filter and `MessageVisibility.showCustom` option for showing or hiding custom messages.
|
|
18
|
+
- Hide picker shortcut hints that collide with configured selection bindings.
|
|
19
|
+
|
|
3
20
|
## 2.0.0 - 2026-08-06
|
|
4
21
|
|
|
5
22
|
- Set pi 0.84.0 as the minimum supported version and refresh the local coding-agent and TUI development baseline.
|
package/README.md
CHANGED
|
@@ -11,11 +11,12 @@ A [pi](https://github.com/earendil-works/pi-mono) extension that adds `/copy-mes
|
|
|
11
11
|
- Copies raw stored session message text, not rendered terminal lines
|
|
12
12
|
- Shows messages in chat order: oldest at top, newest at bottom
|
|
13
13
|
- Selects the newest visible message by default
|
|
14
|
-
- Supports role filters for user, assistant,
|
|
14
|
+
- Supports role filters for user, assistant, tool/bash, and custom messages
|
|
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
18
|
- Uses Pi's configured selection bindings for navigation, copy, and cancel
|
|
19
|
+
- Uses Pi's clipboard implementation, including its OSC 52 terminal fallback
|
|
19
20
|
- Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`
|
|
20
21
|
- Supports direct numbered copies like `/copy-message 3`
|
|
21
22
|
- Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
|
|
@@ -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
|
+
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.
|
|
42
39
|
|
|
43
40
|
If you prefer to load it directly from a local checkout during development:
|
|
44
41
|
|
|
@@ -97,12 +94,13 @@ 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 |
|
|
104
101
|
| `Ctrl+A` | Toggle assistant messages |
|
|
105
102
|
| `Ctrl+T` | Toggle tool/bash messages |
|
|
103
|
+
| `Alt+C` | Toggle custom messages |
|
|
106
104
|
| `Tab` | Toggle a wrapped preview of the selected message |
|
|
107
105
|
| `Alt+M` | Toggle raw vs metadata copy format |
|
|
108
106
|
| Configured `tui.select.confirm` (default: `Enter`) | Copy selected message text |
|
|
@@ -114,28 +112,39 @@ Copy with role and timestamp metadata instead of raw text only:
|
|
|
114
112
|
- The picker caps visible rows and scrolls instead of filling the screen.
|
|
115
113
|
- Search preserves your original selected message and restores it when the search is cleared.
|
|
116
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.
|
|
117
116
|
- Filter labels honor the active pi theme.
|
|
118
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.
|
|
119
|
-
-
|
|
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`.
|
|
120
119
|
- Copy notifications include the role and a short preview so you can verify what was copied.
|
|
121
|
-
- `/copy-message latest`
|
|
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.
|
|
122
121
|
- `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
|
|
123
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.
|
|
124
123
|
|
|
125
124
|
## Compatibility
|
|
126
125
|
|
|
127
|
-
-
|
|
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.
|
|
128
129
|
- Pi includes `Ctrl+X` for copying the latest assistant response; this extension remains useful for searchable history, other roles, metadata, and direct selectors.
|
|
129
|
-
-
|
|
130
|
-
- `.nvmrc` pins Node 22.19.0 for local development
|
|
130
|
+
- Node.js `>=24.15.0`. `.nvmrc` pins Node 24.21.0 for local development.
|
|
131
131
|
|
|
132
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
133
|
|
|
134
134
|
## Development
|
|
135
135
|
|
|
136
136
|
```bash
|
|
137
|
-
npm
|
|
138
|
-
npm run check
|
|
137
|
+
npm ci
|
|
138
|
+
npm run check # node --test + tsc --noEmit + pack dry-run
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
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 picker behavior and formatting without clipboard writes; native clipboard delivery, including Android/Termux and OSC 52, requires separate platform qualification.
|
|
142
|
+
|
|
143
|
+
`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:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm install --package-lock-only --ignore-scripts --registry=https://registry.npmjs.org/
|
|
147
|
+
npm ci
|
|
139
148
|
```
|
|
140
149
|
|
|
141
150
|
Key files:
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ExtensionAPI, ExtensionCommandContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { copyToClipboard, type ExtensionAPI, type ExtensionCommandContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import type { AutocompleteItem, TuiMode } from "@earendil-works/pi-tui";
|
|
4
3
|
import { decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
5
4
|
|
|
@@ -33,7 +32,7 @@ function textFromContent(content: unknown): string {
|
|
|
33
32
|
);
|
|
34
33
|
})
|
|
35
34
|
.map((part) => part.text)
|
|
36
|
-
.join("
|
|
35
|
+
.join("");
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
function textFromMessage(message: Record<string, unknown>): string {
|
|
@@ -56,14 +55,10 @@ function textFromMessage(message: Record<string, unknown>): string {
|
|
|
56
55
|
return textFromContent(message.content);
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
segment(input: string): Iterable<{ segment: string }>;
|
|
61
|
-
};
|
|
58
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
62
59
|
|
|
63
60
|
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);
|
|
61
|
+
return Array.from(graphemeSegmenter.segment(text), (part) => part.segment);
|
|
67
62
|
}
|
|
68
63
|
|
|
69
64
|
function truncateGraphemes(text: string, max: number): string {
|
|
@@ -74,8 +69,12 @@ function truncateGraphemes(text: string, max: number): string {
|
|
|
74
69
|
return `${graphemes.slice(0, max - 1).join("")}…`;
|
|
75
70
|
}
|
|
76
71
|
|
|
72
|
+
function safeDisplayText(text: string): string {
|
|
73
|
+
return text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
77
76
|
function compactPreview(text: string, max = 96): string {
|
|
78
|
-
const preview = text.replace(/\s+/gu, " ").trim();
|
|
77
|
+
const preview = safeDisplayText(text).replace(/\s+/gu, " ").trim();
|
|
79
78
|
return truncateGraphemes(preview, max);
|
|
80
79
|
}
|
|
81
80
|
|
|
@@ -96,7 +95,7 @@ function roleLabel(role: string): string {
|
|
|
96
95
|
case "compactionSummary":
|
|
97
96
|
return "compaction";
|
|
98
97
|
default:
|
|
99
|
-
return role || "message";
|
|
98
|
+
return safeDisplayText(role).replace(/\s+/gu, " ").trim() || "message";
|
|
100
99
|
}
|
|
101
100
|
}
|
|
102
101
|
|
|
@@ -138,7 +137,33 @@ function entryToCopyableMessage(entry: unknown): CopyableMessage | undefined {
|
|
|
138
137
|
}
|
|
139
138
|
|
|
140
139
|
export function collectCopyableMessages(ctx: { sessionManager: { getBranch(): unknown[] } }): CopyableMessage[] {
|
|
141
|
-
|
|
140
|
+
const entries: unknown[] = [];
|
|
141
|
+
// Pi persists assistant snapshots separately and coalesces them within each context window.
|
|
142
|
+
const responses = new Map<string, number>();
|
|
143
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
144
|
+
const record = entry as {
|
|
145
|
+
type?: string;
|
|
146
|
+
message?: { role?: string; responseId?: string; stopReason?: string; content?: unknown[] };
|
|
147
|
+
} | null;
|
|
148
|
+
if (record?.type === "context_window") responses.clear();
|
|
149
|
+
const responseId =
|
|
150
|
+
record?.type === "message" && record.message?.role === "assistant" && typeof record.message.responseId === "string"
|
|
151
|
+
? record.message.responseId
|
|
152
|
+
: undefined;
|
|
153
|
+
const previous = responseId ? responses.get(responseId) : undefined;
|
|
154
|
+
if (previous !== undefined) {
|
|
155
|
+
const earlier = entries[previous] as typeof record;
|
|
156
|
+
if (
|
|
157
|
+
record?.message?.stopReason !== "pending" ||
|
|
158
|
+
(earlier?.message?.stopReason === "pending" && (earlier.message.content?.length ?? 0) <= (record.message.content?.length ?? 0))
|
|
159
|
+
) entries[previous] = entry;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (responseId) responses.set(responseId, entries.length);
|
|
163
|
+
entries.push(entry);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return entries.flatMap((entry) => {
|
|
142
167
|
const message = entryToCopyableMessage(entry);
|
|
143
168
|
return message ? [message] : [];
|
|
144
169
|
});
|
|
@@ -181,58 +206,6 @@ export function getMostRecentUserMessage(ctx: { sessionManager: { getBranch(): u
|
|
|
181
206
|
return sawUserMessage ? { kind: "no-text" } : { kind: "no-user-message" };
|
|
182
207
|
}
|
|
183
208
|
|
|
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
209
|
function isToolMessage(message: CopyableMessage): boolean {
|
|
237
210
|
return message.role === "toolResult" || message.role === "bashExecution";
|
|
238
211
|
}
|
|
@@ -241,12 +214,14 @@ export interface MessageVisibility {
|
|
|
241
214
|
showAssistant: boolean;
|
|
242
215
|
showUser: boolean;
|
|
243
216
|
showTools: boolean;
|
|
217
|
+
showCustom: boolean;
|
|
244
218
|
}
|
|
245
219
|
|
|
246
220
|
function isVisibleMessage(message: CopyableMessage, visibility: MessageVisibility): boolean {
|
|
247
221
|
if (isToolMessage(message)) return visibility.showTools;
|
|
248
222
|
if (message.role === "assistant") return visibility.showAssistant;
|
|
249
223
|
if (message.role === "user") return visibility.showUser;
|
|
224
|
+
if (message.role === "custom") return visibility.showCustom;
|
|
250
225
|
return true;
|
|
251
226
|
}
|
|
252
227
|
|
|
@@ -274,7 +249,7 @@ export function filteredMessages(messages: CopyableMessage[], visibility: Messag
|
|
|
274
249
|
}
|
|
275
250
|
|
|
276
251
|
export function defaultVisibleMessages(messages: CopyableMessage[]): CopyableMessage[] {
|
|
277
|
-
return filteredMessages(messages, { showAssistant: true, showUser: true, showTools: false });
|
|
252
|
+
return filteredMessages(messages, { showAssistant: true, showUser: true, showTools: false, showCustom: true });
|
|
278
253
|
}
|
|
279
254
|
|
|
280
255
|
export function latestDefaultMessage(messages: CopyableMessage[]): CopyableMessage | undefined {
|
|
@@ -351,7 +326,7 @@ function renderMessageLine(
|
|
|
351
326
|
|
|
352
327
|
function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMessageTheme, format: CopyFormat): string[] {
|
|
353
328
|
const contentWidth = Math.max(1, width - 2);
|
|
354
|
-
const text = formatMessageForCopy(message, format);
|
|
329
|
+
const text = safeDisplayText(formatMessageForCopy(message, format));
|
|
355
330
|
const wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, text, false), contentWidth);
|
|
356
331
|
const shown = wrapped.slice(0, MAX_PEEK_LINES);
|
|
357
332
|
const remaining = wrapped.length - shown.length;
|
|
@@ -370,7 +345,7 @@ function bindingHint(keybindings: PickerKeybindings | undefined, action: "up" |
|
|
|
370
345
|
return keys.reduce((shortest, key) => visibleWidth(key) < visibleWidth(shortest) ? key : shortest, keys[0] ?? "unbound");
|
|
371
346
|
}
|
|
372
347
|
|
|
373
|
-
function helpLines(width: number, keybindings
|
|
348
|
+
function helpLines(width: number, keybindings: PickerKeybindings | undefined, tuiMode: TuiMode, hasCustomMessages: boolean): string[] {
|
|
374
349
|
const up = bindingHint(keybindings, "up");
|
|
375
350
|
const down = bindingHint(keybindings, "down");
|
|
376
351
|
const confirm = bindingHint(keybindings, "confirm");
|
|
@@ -390,7 +365,8 @@ function helpLines(width: number, keybindings?: PickerKeybindings, tuiMode: TuiM
|
|
|
390
365
|
{ hint: "T", data: "\x14" },
|
|
391
366
|
].filter(({ data }) => available(data));
|
|
392
367
|
const filterHint = filters.length > 0 ? `Ctrl+${filters.map(({ hint }) => hint).join("/")} filters` : undefined;
|
|
393
|
-
const
|
|
368
|
+
const custom = hasCustomMessages && available("\x1b[99;3u") ? "Alt+C custom" : undefined;
|
|
369
|
+
const meta = available("\x1b[109;3u") ? "Alt+M meta" : undefined;
|
|
394
370
|
const jumps = (tuiMode === "fullscreen"
|
|
395
371
|
? [
|
|
396
372
|
{ hint: "Ctrl+Home", data: "\x1b[1;5H" },
|
|
@@ -415,7 +391,7 @@ function helpLines(width: number, keybindings?: PickerKeybindings, tuiMode: TuiM
|
|
|
415
391
|
return lines;
|
|
416
392
|
}
|
|
417
393
|
|
|
418
|
-
for (const hint of width < 74 ? [jumpHint, peek, filterHint, meta] : ["type search", jumpHint, peek, filterHint, meta]) {
|
|
394
|
+
for (const hint of width < 74 ? [jumpHint, peek, filterHint, custom, meta] : ["type search", jumpHint, peek, filterHint, custom, meta]) {
|
|
419
395
|
if (hint && visibleWidth(join(...optional, hint, ...core)) <= width) optional.push(hint);
|
|
420
396
|
}
|
|
421
397
|
|
|
@@ -429,6 +405,7 @@ export class CopyMessagePickerState {
|
|
|
429
405
|
showAssistant: true,
|
|
430
406
|
showUser: true,
|
|
431
407
|
showTools: false,
|
|
408
|
+
showCustom: true,
|
|
432
409
|
};
|
|
433
410
|
search = "";
|
|
434
411
|
visibleMessages: CopyableMessage[];
|
|
@@ -436,8 +413,10 @@ export class CopyMessagePickerState {
|
|
|
436
413
|
format: CopyFormat;
|
|
437
414
|
peek = false;
|
|
438
415
|
private searchAnchorId: string | undefined;
|
|
416
|
+
private readonly messages: CopyableMessage[];
|
|
439
417
|
|
|
440
|
-
constructor(
|
|
418
|
+
constructor(messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
|
|
419
|
+
this.messages = messages;
|
|
441
420
|
this.format = initialFormat;
|
|
442
421
|
this.visibleMessages = filteredMessages(messages, this.visibility, this.search);
|
|
443
422
|
this.selectedIndex = Math.max(0, this.visibleMessages.length - 1);
|
|
@@ -456,6 +435,7 @@ export class CopyMessagePickerState {
|
|
|
456
435
|
const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
|
|
457
436
|
const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
|
|
458
437
|
const end = Math.min(this.visibleMessages.length, start + maxVisible);
|
|
438
|
+
const hasCustomMessages = this.messages.some((message) => message.role === "custom");
|
|
459
439
|
const userState = filterLabel(theme, "user", this.visibility.showUser, "warning");
|
|
460
440
|
const assistantState = filterLabel(theme, "assistant", this.visibility.showAssistant, "accent");
|
|
461
441
|
const toolState = filterLabel(theme, "tools", this.visibility.showTools, "dim");
|
|
@@ -481,14 +461,23 @@ export class CopyMessagePickerState {
|
|
|
481
461
|
}
|
|
482
462
|
|
|
483
463
|
const position = this.visibleMessages.length === 0 ? "0/0" : `${this.selectedIndex + 1}/${this.visibleMessages.length}`;
|
|
484
|
-
|
|
464
|
+
const filters = [userState, assistantState, toolState];
|
|
465
|
+
if (hasCustomMessages) filters.push(filterLabel(theme, "custom", this.visibility.showCustom, "dim"));
|
|
466
|
+
lines.push([theme.fg("dim", `(${position})`), ...filters, searchState, formatState].join(" · "));
|
|
485
467
|
lines.push("");
|
|
486
|
-
lines.push(...helpLines(width, keybindings, tuiMode).map((line) => hotkeyHint(theme, line)));
|
|
468
|
+
lines.push(...helpLines(width, keybindings, tuiMode, hasCustomMessages).map((line) => hotkeyHint(theme, line)));
|
|
487
469
|
lines.push("");
|
|
488
470
|
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
489
471
|
}
|
|
490
472
|
|
|
491
473
|
handleInput(data: string, keybindings?: PickerKeybindings): PickerInputResult {
|
|
474
|
+
// Pi delivers each paste as one input wrapped in bracketed-paste markers.
|
|
475
|
+
if (data.startsWith("\x1b[200~") && data.endsWith("\x1b[201~")) {
|
|
476
|
+
const text = data.slice(6, -6).replace(/\s+/gu, " ");
|
|
477
|
+
if (!isPrintableSearchInput(text)) return "none";
|
|
478
|
+
this.setSearch(this.search + text);
|
|
479
|
+
return "render";
|
|
480
|
+
}
|
|
492
481
|
if (keybindings?.matches(data, "tui.select.up")) {
|
|
493
482
|
this.move(-1);
|
|
494
483
|
return "render";
|
|
@@ -524,6 +513,11 @@ export class CopyMessagePickerState {
|
|
|
524
513
|
this.refreshMessages();
|
|
525
514
|
return "render";
|
|
526
515
|
}
|
|
516
|
+
if (matchesKey(data, "alt+c")) {
|
|
517
|
+
this.visibility.showCustom = !this.visibility.showCustom;
|
|
518
|
+
this.refreshMessages();
|
|
519
|
+
return "render";
|
|
520
|
+
}
|
|
527
521
|
if (matchesKey(data, "alt+m")) {
|
|
528
522
|
this.format = this.format === "raw" ? "metadata" : "raw";
|
|
529
523
|
return "render";
|
|
@@ -533,7 +527,7 @@ export class CopyMessagePickerState {
|
|
|
533
527
|
return "render";
|
|
534
528
|
}
|
|
535
529
|
if (matchesKey(data, "backspace") || data === "\x7f") {
|
|
536
|
-
this.setSearch(this.search.slice(0, -1));
|
|
530
|
+
this.setSearch(splitGraphemes(this.search).slice(0, -1).join(""));
|
|
537
531
|
return "render";
|
|
538
532
|
}
|
|
539
533
|
const printable = decodeKittyPrintable(data) ?? data;
|
|
@@ -696,17 +690,16 @@ function copyNotificationText(selected: CopyableMessage): string {
|
|
|
696
690
|
return `Copied ${roleLabel(selected.role)} message: “${compactPreview(selected.text, 48)}”`;
|
|
697
691
|
}
|
|
698
692
|
|
|
699
|
-
function copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage, text = selected.text) {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
ctx.ui.notify(
|
|
703
|
-
|
|
693
|
+
async function copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage, text = selected.text) {
|
|
694
|
+
try {
|
|
695
|
+
await copyToClipboard(text);
|
|
696
|
+
ctx.ui.notify(copyNotificationText(selected), "info");
|
|
697
|
+
} catch (error) {
|
|
698
|
+
ctx.ui.notify(error instanceof Error ? error.message : "Failed to copy to clipboard", "error");
|
|
704
699
|
}
|
|
705
|
-
|
|
706
|
-
ctx.ui.notify(copyNotificationText(selected), "info");
|
|
707
700
|
}
|
|
708
701
|
|
|
709
|
-
function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
|
|
702
|
+
async function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
|
|
710
703
|
const result = getMostRecentUserMessage(ctx);
|
|
711
704
|
if (result.kind === "no-user-message") {
|
|
712
705
|
ctx.ui.notify("No user messages found", "warning");
|
|
@@ -717,7 +710,7 @@ function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionMa
|
|
|
717
710
|
return;
|
|
718
711
|
}
|
|
719
712
|
|
|
720
|
-
copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
|
|
713
|
+
await copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
|
|
721
714
|
}
|
|
722
715
|
|
|
723
716
|
export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
|
|
@@ -734,7 +727,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
|
|
|
734
727
|
|
|
735
728
|
if (parsedArgs.selector === "latest") {
|
|
736
729
|
const latestVisible = latestDefaultMessage(messages);
|
|
737
|
-
if (latestVisible) copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
|
|
730
|
+
if (latestVisible) await copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
|
|
738
731
|
return;
|
|
739
732
|
}
|
|
740
733
|
|
|
@@ -744,7 +737,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
|
|
|
744
737
|
ctx.ui.notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
|
|
745
738
|
return;
|
|
746
739
|
}
|
|
747
|
-
copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
|
|
740
|
+
await copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
|
|
748
741
|
return;
|
|
749
742
|
}
|
|
750
743
|
|
|
@@ -756,7 +749,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
|
|
|
756
749
|
const selected = await pickMessage(ctx, messages, parsedArgs.format);
|
|
757
750
|
if (!selected) return;
|
|
758
751
|
|
|
759
|
-
copySelectedMessage(ctx, selected.message, selected.text);
|
|
752
|
+
await copySelectedMessage(ctx, selected.message, selected.text);
|
|
760
753
|
},
|
|
761
754
|
});
|
|
762
755
|
|
|
@@ -764,7 +757,7 @@ export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCom
|
|
|
764
757
|
description: "Copy the most recent user message to the clipboard",
|
|
765
758
|
getArgumentCompletions: (argumentPrefix) => copyArgumentCompletions(argumentPrefix, false),
|
|
766
759
|
handler: async (args, ctx) => {
|
|
767
|
-
copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
|
|
760
|
+
await copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
|
|
768
761
|
},
|
|
769
762
|
});
|
|
770
763
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-copy-message",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.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": "0.87.1",
|
|
49
|
+
"@earendil-works/pi-tui": "0.87.1",
|
|
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.1.0",
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@earendil-works/pi-coding-agent": "*",
|
|
61
61
|
"@earendil-works/pi-tui": "*"
|