pi-copy-message 1.0.2 → 1.0.4
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 +16 -0
- package/README.md +25 -3
- package/extensions/copy-message.ts +192 -61
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.4 - 2026-06-08
|
|
4
|
+
|
|
5
|
+
- Copy notifications now include the role and a short grapheme-safe preview.
|
|
6
|
+
- `/copy-user` now skips blank user entries and copies the most recent user message with text.
|
|
7
|
+
- Add direct numbered copies with `/copy-message <number>`.
|
|
8
|
+
- Add metadata copy format via `--with-meta`, `--with-metadata`, `--with-role`, and the picker `Alt+M` toggle.
|
|
9
|
+
- Add picker `Tab` peek mode for a wrapped preview of the selected message.
|
|
10
|
+
- Shorten picker help at narrow widths and remove the always-on subtitle.
|
|
11
|
+
- Stop matching timestamps in normal search; use `time:<term>` for timestamp search.
|
|
12
|
+
- Cache clipboard command lookups and try later clipboard commands when an earlier command fails.
|
|
13
|
+
- Document direct non-TUI usage for `/copy-user` and direct `/copy-message` selectors.
|
|
14
|
+
|
|
15
|
+
## 1.0.3 - 2026-06-07
|
|
16
|
+
|
|
17
|
+
- Register `/copy-message` before `/copy-user` so package command autocomplete prefers the picker over the shortcut.
|
|
18
|
+
|
|
3
19
|
## 1.0.2 - 2026-06-07
|
|
4
20
|
|
|
5
21
|
- Add `/copy-user` shortcut for copying the most recent user message directly.
|
package/README.md
CHANGED
|
@@ -13,9 +13,11 @@ A [pi](https://github.com/earendil-works/pi-mono) extension that adds `/copy-mes
|
|
|
13
13
|
- Selects the newest visible message by default
|
|
14
14
|
- Supports role filters for user, assistant, and tool/bash messages
|
|
15
15
|
- Hides tool/bash messages by default
|
|
16
|
-
- Supports type-to-filter search across role, time
|
|
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
|
- Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`
|
|
19
|
+
- Supports direct numbered copies like `/copy-message 3`
|
|
20
|
+
- Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
|
|
19
21
|
|
|
20
22
|
## Install
|
|
21
23
|
|
|
@@ -70,6 +72,20 @@ Aliases:
|
|
|
70
72
|
/copy-message newest
|
|
71
73
|
```
|
|
72
74
|
|
|
75
|
+
Copy the 3rd default-visible message directly, matching the picker's 1-based oldest-to-newest numbering:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
/copy-message 3
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Copy with role and timestamp metadata instead of raw text only:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
/copy-message latest --with-meta
|
|
85
|
+
/copy-message 3 --with-role
|
|
86
|
+
/copy-user --with-meta
|
|
87
|
+
```
|
|
88
|
+
|
|
73
89
|
## Keyboard controls
|
|
74
90
|
|
|
75
91
|
| Key | Action |
|
|
@@ -79,11 +95,14 @@ Aliases:
|
|
|
79
95
|
| `Home` | Jump to oldest visible message |
|
|
80
96
|
| `End` | Jump to newest visible message |
|
|
81
97
|
| Type text | Filter visible messages |
|
|
98
|
+
| `time:<term>` | Search timestamps |
|
|
82
99
|
| `Backspace` | Delete one search character |
|
|
83
100
|
| `Ctrl+U` | Toggle user messages |
|
|
84
101
|
| `Ctrl+A` | Toggle assistant messages |
|
|
85
102
|
| `Ctrl+T` | Toggle tool/bash messages |
|
|
86
|
-
| `
|
|
103
|
+
| `Tab` | Toggle a wrapped preview of the selected message |
|
|
104
|
+
| `Alt+M` | Toggle raw vs metadata copy format |
|
|
105
|
+
| `Enter` | Copy selected message text |
|
|
87
106
|
| `Esc` | Cancel |
|
|
88
107
|
|
|
89
108
|
## Behavior notes
|
|
@@ -91,9 +110,12 @@ Aliases:
|
|
|
91
110
|
- Entry IDs are hidden from the picker.
|
|
92
111
|
- The picker caps visible rows and scrolls instead of filling the screen.
|
|
93
112
|
- Search preserves your original selected message and restores it when the search is cleared.
|
|
113
|
+
- General search does not match timestamps; use `time:<term>` when you want to search by displayed time.
|
|
94
114
|
- Filter labels honor the active pi theme.
|
|
115
|
+
- Copy notifications include the role and a short preview so you can verify what was copied.
|
|
95
116
|
- `/copy-message latest` respects default visibility: user and assistant messages are visible, tool/bash messages are hidden. If only hidden messages exist, it falls back to the newest message so the command still does something useful.
|
|
96
|
-
-
|
|
117
|
+
- `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
|
|
118
|
+
- 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.
|
|
97
119
|
|
|
98
120
|
## Compatibility
|
|
99
121
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
4
|
|
|
5
5
|
const MAX_VISIBLE_MESSAGES = 8;
|
|
6
|
+
const MAX_PEEK_LINES = 16;
|
|
7
|
+
|
|
8
|
+
export type CopyFormat = "raw" | "metadata";
|
|
6
9
|
|
|
7
10
|
export interface CopyableMessage {
|
|
8
11
|
id: string;
|
|
@@ -52,10 +55,27 @@ function textFromMessage(message: Record<string, unknown>): string {
|
|
|
52
55
|
return textFromContent(message.content);
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
type SegmenterCtor = new (locale?: string, options?: { granularity?: "grapheme" }) => {
|
|
59
|
+
segment(input: string): Iterable<{ segment: string }>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function splitGraphemes(text: string): string[] {
|
|
63
|
+
const Segmenter = (Intl as unknown as { Segmenter?: SegmenterCtor }).Segmenter;
|
|
64
|
+
if (!Segmenter) return Array.from(text);
|
|
65
|
+
return Array.from(new Segmenter(undefined, { granularity: "grapheme" }).segment(text), (part) => part.segment);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function truncateGraphemes(text: string, max: number): string {
|
|
69
|
+
if (max <= 0) return "";
|
|
70
|
+
const graphemes = splitGraphemes(text);
|
|
71
|
+
if (graphemes.length <= max) return text;
|
|
72
|
+
if (max === 1) return "…";
|
|
73
|
+
return `${graphemes.slice(0, max - 1).join("")}…`;
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
function compactPreview(text: string, max = 96): string {
|
|
56
|
-
const preview = text.replace(/\s+/
|
|
57
|
-
|
|
58
|
-
return `${preview.slice(0, max - 1)}…`;
|
|
77
|
+
const preview = text.replace(/\s+/gu, " ").trim();
|
|
78
|
+
return truncateGraphemes(preview, max);
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
function roleLabel(role: string): string {
|
|
@@ -119,6 +139,7 @@ export type MostRecentUserMessageResult =
|
|
|
119
139
|
|
|
120
140
|
export function getMostRecentUserMessage(ctx: { sessionManager: { getBranch(): unknown[] } }): MostRecentUserMessageResult {
|
|
121
141
|
const branch = ctx.sessionManager.getBranch();
|
|
142
|
+
let sawUserMessage = false;
|
|
122
143
|
|
|
123
144
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
124
145
|
const entry = branch[i];
|
|
@@ -130,8 +151,9 @@ export function getMostRecentUserMessage(ctx: { sessionManager: { getBranch(): u
|
|
|
130
151
|
const message = record.message as Record<string, unknown>;
|
|
131
152
|
if (message.role !== "user") continue;
|
|
132
153
|
|
|
154
|
+
sawUserMessage = true;
|
|
133
155
|
const text = textFromMessage(message);
|
|
134
|
-
if (!text.trim())
|
|
156
|
+
if (!text.trim()) continue;
|
|
135
157
|
|
|
136
158
|
return {
|
|
137
159
|
kind: "message",
|
|
@@ -144,12 +166,33 @@ export function getMostRecentUserMessage(ctx: { sessionManager: { getBranch(): u
|
|
|
144
166
|
};
|
|
145
167
|
}
|
|
146
168
|
|
|
147
|
-
return { kind: "no-user-message" };
|
|
169
|
+
return sawUserMessage ? { kind: "no-text" } : { kind: "no-user-message" };
|
|
148
170
|
}
|
|
149
171
|
|
|
172
|
+
type ClipboardCommand = {
|
|
173
|
+
name: string;
|
|
174
|
+
args: string[];
|
|
175
|
+
enabled: () => boolean;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const clipboardCommands: ClipboardCommand[] = [
|
|
179
|
+
{ name: "pbcopy", args: [], enabled: () => process.platform === "darwin" },
|
|
180
|
+
{ name: "termux-clipboard-set", args: [], enabled: () => Boolean(process.env.TERMUX_VERSION) },
|
|
181
|
+
{ name: "wl-copy", args: [], enabled: () => true },
|
|
182
|
+
{ name: "xclip", args: ["-selection", "clipboard"], enabled: () => true },
|
|
183
|
+
{ name: "xsel", args: ["--clipboard", "--input"], enabled: () => true },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
const commandExistsCache = new Map<string, boolean>();
|
|
187
|
+
|
|
150
188
|
function commandExists(command: string): boolean {
|
|
189
|
+
const cached = commandExistsCache.get(command);
|
|
190
|
+
if (cached !== undefined) return cached;
|
|
191
|
+
|
|
151
192
|
const result = spawnSync("sh", ["-c", "command -v \"$1\" >/dev/null 2>&1", "sh", command], { stdio: "ignore" });
|
|
152
|
-
|
|
193
|
+
const exists = result.status === 0;
|
|
194
|
+
commandExistsCache.set(command, exists);
|
|
195
|
+
return exists;
|
|
153
196
|
}
|
|
154
197
|
|
|
155
198
|
function copyWith(command: string, args: string[], text: string): boolean {
|
|
@@ -158,24 +201,16 @@ function copyWith(command: string, args: string[], text: string): boolean {
|
|
|
158
201
|
}
|
|
159
202
|
|
|
160
203
|
function copyToClipboard(text: string): string | undefined {
|
|
161
|
-
|
|
162
|
-
return copyWith("pbcopy", [], text) ? undefined : "pbcopy failed";
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (process.env.TERMUX_VERSION && commandExists("termux-clipboard-set")) {
|
|
166
|
-
return copyWith("termux-clipboard-set", [], text) ? undefined : "termux-clipboard-set failed";
|
|
167
|
-
}
|
|
204
|
+
const failedCommands: string[] = [];
|
|
168
205
|
|
|
169
|
-
|
|
170
|
-
|
|
206
|
+
for (const command of clipboardCommands) {
|
|
207
|
+
if (!command.enabled() || !commandExists(command.name)) continue;
|
|
208
|
+
if (copyWith(command.name, command.args, text)) return undefined;
|
|
209
|
+
failedCommands.push(command.name);
|
|
171
210
|
}
|
|
172
211
|
|
|
173
|
-
if (
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (commandExists("xsel")) {
|
|
178
|
-
return copyWith("xsel", ["--clipboard", "--input"], text) ? undefined : "xsel failed";
|
|
212
|
+
if (failedCommands.length > 0) {
|
|
213
|
+
return `Clipboard command${failedCommands.length === 1 ? "" : "s"} failed (${failedCommands.join(", ")})`;
|
|
179
214
|
}
|
|
180
215
|
|
|
181
216
|
return "No clipboard command found (tried pbcopy, termux-clipboard-set, wl-copy, xclip, xsel)";
|
|
@@ -199,7 +234,7 @@ function isVisibleMessage(message: CopyableMessage, visibility: MessageVisibilit
|
|
|
199
234
|
}
|
|
200
235
|
|
|
201
236
|
function messageSearchText(message: CopyableMessage): string {
|
|
202
|
-
return [roleLabel(message.role),
|
|
237
|
+
return [roleLabel(message.role), message.text].join(" ").toLowerCase();
|
|
203
238
|
}
|
|
204
239
|
|
|
205
240
|
function messageMatchesSearch(message: CopyableMessage, search: string): boolean {
|
|
@@ -210,15 +245,35 @@ function messageMatchesSearch(message: CopyableMessage, search: string): boolean
|
|
|
210
245
|
.filter(Boolean);
|
|
211
246
|
if (terms.length === 0) return true;
|
|
212
247
|
const haystack = messageSearchText(message);
|
|
213
|
-
|
|
248
|
+
const time = formatTime(message.timestamp).toLowerCase();
|
|
249
|
+
return terms.every((term) => {
|
|
250
|
+
if (term.startsWith("time:")) return time.includes(term.slice("time:".length));
|
|
251
|
+
return haystack.includes(term);
|
|
252
|
+
});
|
|
214
253
|
}
|
|
215
254
|
|
|
216
255
|
export function filteredMessages(messages: CopyableMessage[], visibility: MessageVisibility, search = ""): CopyableMessage[] {
|
|
217
256
|
return messages.filter((message) => isVisibleMessage(message, visibility) && messageMatchesSearch(message, search));
|
|
218
257
|
}
|
|
219
258
|
|
|
259
|
+
export function defaultVisibleMessages(messages: CopyableMessage[]): CopyableMessage[] {
|
|
260
|
+
return filteredMessages(messages, { showAssistant: true, showUser: true, showTools: false });
|
|
261
|
+
}
|
|
262
|
+
|
|
220
263
|
export function latestDefaultMessage(messages: CopyableMessage[]): CopyableMessage | undefined {
|
|
221
|
-
return
|
|
264
|
+
return defaultVisibleMessages(messages).at(-1) ?? messages.at(-1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function messageByDefaultNumber(messages: CopyableMessage[], number: number): CopyableMessage | undefined {
|
|
268
|
+
if (!Number.isInteger(number) || number < 1) return undefined;
|
|
269
|
+
return defaultVisibleMessages(messages)[number - 1];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function formatMessageForCopy(message: CopyableMessage, format: CopyFormat): string {
|
|
273
|
+
if (format === "raw") return message.text;
|
|
274
|
+
const time = formatTime(message.timestamp);
|
|
275
|
+
const label = roleLabel(message.role);
|
|
276
|
+
return time ? `${label} at ${time}: ${message.text}` : `${label}: ${message.text}`;
|
|
222
277
|
}
|
|
223
278
|
|
|
224
279
|
function isPrintableSearchInput(data: string): boolean {
|
|
@@ -277,6 +332,24 @@ function renderMessageLine(
|
|
|
277
332
|
return preview ? `${meta} ${styledPreview}` : meta;
|
|
278
333
|
}
|
|
279
334
|
|
|
335
|
+
function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMessageTheme, format: CopyFormat): string[] {
|
|
336
|
+
const contentWidth = Math.max(1, width - 2);
|
|
337
|
+
const text = formatMessageForCopy(message, format);
|
|
338
|
+
const wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, text, false), contentWidth);
|
|
339
|
+
const shown = wrapped.slice(0, MAX_PEEK_LINES);
|
|
340
|
+
const remaining = wrapped.length - shown.length;
|
|
341
|
+
const title = theme.fg("dim", `Peek ${format === "metadata" ? "metadata" : "raw"} ${roleLabel(message.role)} message`);
|
|
342
|
+
const lines = [title, ...shown.map((line) => ` ${line}`)];
|
|
343
|
+
if (remaining > 0) lines.push(theme.fg("dim", ` … ${remaining} more wrapped line${remaining === 1 ? "" : "s"}`));
|
|
344
|
+
return lines;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function helpLine(width: number): string {
|
|
348
|
+
if (width < 48) return "↑↓ · Tab peek · Alt+M meta · Enter · Esc";
|
|
349
|
+
if (width < 74) return "↑↓ nav · Home/End · Tab peek · Ctrl+U/A/T filters · Enter · Esc";
|
|
350
|
+
return "type search · ↑↓ navigate · Home/End · Tab peek · Ctrl+U/A/T filters · Alt+M meta · Enter · Esc";
|
|
351
|
+
}
|
|
352
|
+
|
|
280
353
|
type PickerInputResult = "copy" | "cancel" | "render" | "none";
|
|
281
354
|
|
|
282
355
|
export class CopyMessagePickerState {
|
|
@@ -288,9 +361,12 @@ export class CopyMessagePickerState {
|
|
|
288
361
|
search = "";
|
|
289
362
|
visibleMessages: CopyableMessage[];
|
|
290
363
|
selectedIndex: number;
|
|
364
|
+
format: CopyFormat;
|
|
365
|
+
peek = false;
|
|
291
366
|
private searchAnchorId: string | undefined;
|
|
292
367
|
|
|
293
|
-
constructor(private readonly messages: CopyableMessage[]) {
|
|
368
|
+
constructor(private readonly messages: CopyableMessage[], initialFormat: CopyFormat = "raw") {
|
|
369
|
+
this.format = initialFormat;
|
|
294
370
|
this.visibleMessages = filteredMessages(messages, this.visibility, this.search);
|
|
295
371
|
this.selectedIndex = Math.max(0, this.visibleMessages.length - 1);
|
|
296
372
|
}
|
|
@@ -299,6 +375,11 @@ export class CopyMessagePickerState {
|
|
|
299
375
|
return this.visibleMessages[this.selectedIndex];
|
|
300
376
|
}
|
|
301
377
|
|
|
378
|
+
selectedCopyText(): string | undefined {
|
|
379
|
+
const selected = this.selectedMessage();
|
|
380
|
+
return selected ? formatMessageForCopy(selected, this.format) : undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
302
383
|
render(width: number, theme: CopyMessageTheme): string[] {
|
|
303
384
|
const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
|
|
304
385
|
const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
|
|
@@ -307,12 +388,9 @@ export class CopyMessagePickerState {
|
|
|
307
388
|
const assistantState = filterLabel(theme, "assistant", this.visibility.showAssistant, "accent");
|
|
308
389
|
const toolState = filterLabel(theme, "tools", this.visibility.showTools, "dim");
|
|
309
390
|
const searchState = this.search ? theme.fg("accent", `search “${this.search}”`) : theme.fg("dim", "type to filter");
|
|
391
|
+
const formatState = theme.fg(this.format === "metadata" ? "accent" : "dim", this.format === "metadata" ? "copy metadata" : "copy raw");
|
|
310
392
|
|
|
311
|
-
const lines = [
|
|
312
|
-
theme.bold(theme.fg("accent", "Copy raw message")),
|
|
313
|
-
theme.fg("muted", "Newest is selected at bottom. Up goes back in time."),
|
|
314
|
-
"",
|
|
315
|
-
];
|
|
393
|
+
const lines = [theme.bold(theme.fg("accent", "Copy message")), ""];
|
|
316
394
|
|
|
317
395
|
if (this.visibleMessages.length === 0) {
|
|
318
396
|
lines.push(theme.fg("warning", this.search ? "No messages match current filters and search." : "No messages visible with current filters."));
|
|
@@ -324,10 +402,16 @@ export class CopyMessagePickerState {
|
|
|
324
402
|
}
|
|
325
403
|
}
|
|
326
404
|
|
|
405
|
+
const selected = this.selectedMessage();
|
|
406
|
+
if (this.peek && selected) {
|
|
407
|
+
lines.push("");
|
|
408
|
+
lines.push(...renderPeekLines(selected, width, theme, this.format));
|
|
409
|
+
}
|
|
410
|
+
|
|
327
411
|
const position = this.visibleMessages.length === 0 ? "0/0" : `${this.selectedIndex + 1}/${this.visibleMessages.length}`;
|
|
328
|
-
lines.push(`${theme.fg("dim", `(${position})`)} · ${userState} · ${assistantState} · ${toolState} · ${searchState}`);
|
|
412
|
+
lines.push(`${theme.fg("dim", `(${position})`)} · ${userState} · ${assistantState} · ${toolState} · ${formatState} · ${searchState}`);
|
|
329
413
|
lines.push("");
|
|
330
|
-
lines.push(hotkeyHint(theme,
|
|
414
|
+
lines.push(hotkeyHint(theme, helpLine(width)));
|
|
331
415
|
lines.push("");
|
|
332
416
|
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
333
417
|
}
|
|
@@ -348,6 +432,14 @@ export class CopyMessagePickerState {
|
|
|
348
432
|
this.refreshMessages();
|
|
349
433
|
return "render";
|
|
350
434
|
}
|
|
435
|
+
if (matchesKey(data, "alt+m")) {
|
|
436
|
+
this.format = this.format === "raw" ? "metadata" : "raw";
|
|
437
|
+
return "render";
|
|
438
|
+
}
|
|
439
|
+
if (matchesKey(data, "tab")) {
|
|
440
|
+
this.peek = !this.peek;
|
|
441
|
+
return "render";
|
|
442
|
+
}
|
|
351
443
|
if (matchesKey(data, "backspace") || data === "\x7f") {
|
|
352
444
|
this.setSearch(this.search.slice(0, -1));
|
|
353
445
|
return "render";
|
|
@@ -421,9 +513,32 @@ export class CopyMessagePickerState {
|
|
|
421
513
|
}
|
|
422
514
|
}
|
|
423
515
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
516
|
+
type ParsedCopyMessageArgs = {
|
|
517
|
+
format: CopyFormat;
|
|
518
|
+
selector?: "latest" | { number: number };
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
function parseCopyArgs(args: string | undefined): ParsedCopyMessageArgs {
|
|
522
|
+
const result: ParsedCopyMessageArgs = { format: "raw" };
|
|
523
|
+
for (const token of (args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
524
|
+
if (token === "--with-meta" || token === "--with-metadata" || token === "--with-role") {
|
|
525
|
+
result.format = "metadata";
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (token === "last" || token === "latest" || token === "newest") {
|
|
529
|
+
result.selector = "latest";
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (/^\d+$/.test(token)) {
|
|
533
|
+
result.selector = { number: Number.parseInt(token, 10) };
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[], initialFormat: CopyFormat) {
|
|
540
|
+
return ctx.ui.custom<{ message: CopyableMessage; text: string } | null>((tui, theme, _keybindings, done) => {
|
|
541
|
+
const state = new CopyMessagePickerState(messages, initialFormat);
|
|
427
542
|
return {
|
|
428
543
|
render(width: number) {
|
|
429
544
|
return state.render(width, theme);
|
|
@@ -432,7 +547,9 @@ async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessa
|
|
|
432
547
|
handleInput(data: string) {
|
|
433
548
|
const result = state.handleInput(data);
|
|
434
549
|
if (result === "copy") {
|
|
435
|
-
|
|
550
|
+
const selected = state.selectedMessage();
|
|
551
|
+
const text = state.selectedCopyText();
|
|
552
|
+
done(selected && text !== undefined ? { message: selected, text } : null);
|
|
436
553
|
return;
|
|
437
554
|
}
|
|
438
555
|
if (result === "cancel") {
|
|
@@ -445,63 +562,77 @@ async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessa
|
|
|
445
562
|
});
|
|
446
563
|
}
|
|
447
564
|
|
|
448
|
-
function
|
|
449
|
-
|
|
565
|
+
function copyNotificationText(selected: CopyableMessage): string {
|
|
566
|
+
return `Copied ${roleLabel(selected.role)} message: “${compactPreview(selected.text, 48)}”`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage, text = selected.text) {
|
|
570
|
+
const error = copyToClipboard(text);
|
|
450
571
|
if (error) {
|
|
451
572
|
ctx.ui.notify(error, "error");
|
|
452
573
|
return;
|
|
453
574
|
}
|
|
454
575
|
|
|
455
|
-
ctx.ui.notify(
|
|
576
|
+
ctx.ui.notify(copyNotificationText(selected), "info");
|
|
456
577
|
}
|
|
457
578
|
|
|
458
|
-
function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui"
|
|
579
|
+
function copyMostRecentUserMessage(ctx: Pick<ExtensionCommandContext, "sessionManager" | "ui">, format: CopyFormat) {
|
|
459
580
|
const result = getMostRecentUserMessage(ctx);
|
|
460
581
|
if (result.kind === "no-user-message") {
|
|
461
582
|
ctx.ui.notify("No user messages found", "warning");
|
|
462
583
|
return;
|
|
463
584
|
}
|
|
464
585
|
if (result.kind === "no-text") {
|
|
465
|
-
ctx.ui.notify("
|
|
586
|
+
ctx.ui.notify("No user message text found", "warning");
|
|
466
587
|
return;
|
|
467
588
|
}
|
|
468
589
|
|
|
469
|
-
copySelectedMessage(ctx, result.message);
|
|
590
|
+
copySelectedMessage(ctx, result.message, formatMessageForCopy(result.message, format));
|
|
470
591
|
}
|
|
471
592
|
|
|
472
593
|
export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
|
|
473
|
-
pi.registerCommand("copy-user", {
|
|
474
|
-
description: "Copy the most recent user message to the clipboard",
|
|
475
|
-
handler: async (_args, ctx) => {
|
|
476
|
-
copyMostRecentUserMessage(ctx);
|
|
477
|
-
},
|
|
478
|
-
});
|
|
479
|
-
|
|
480
594
|
pi.registerCommand("copy-message", {
|
|
481
|
-
description: "Select a session message and copy its
|
|
595
|
+
description: "Select a session message and copy its text to the clipboard",
|
|
482
596
|
handler: async (args, ctx) => {
|
|
483
|
-
|
|
484
|
-
ctx.ui.notify("/copy-message requires interactive TUI mode", "error");
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
|
|
597
|
+
const parsedArgs = parseCopyArgs(args);
|
|
488
598
|
const messages = collectCopyableMessages(ctx);
|
|
489
599
|
if (messages.length === 0) {
|
|
490
600
|
ctx.ui.notify("No copyable messages found in the current branch", "error");
|
|
491
601
|
return;
|
|
492
602
|
}
|
|
493
603
|
|
|
494
|
-
|
|
495
|
-
if (trimmedArgs === "last" || trimmedArgs === "latest" || trimmedArgs === "newest") {
|
|
604
|
+
if (parsedArgs.selector === "latest") {
|
|
496
605
|
const latestVisible = latestDefaultMessage(messages);
|
|
497
|
-
if (latestVisible) copySelectedMessage(ctx, latestVisible);
|
|
606
|
+
if (latestVisible) copySelectedMessage(ctx, latestVisible, formatMessageForCopy(latestVisible, parsedArgs.format));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (typeof parsedArgs.selector === "object") {
|
|
611
|
+
const selected = messageByDefaultNumber(messages, parsedArgs.selector.number);
|
|
612
|
+
if (!selected) {
|
|
613
|
+
ctx.ui.notify(`No default visible message #${parsedArgs.selector.number} (found ${defaultVisibleMessages(messages).length})`, "warning");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
copySelectedMessage(ctx, selected, formatMessageForCopy(selected, parsedArgs.format));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (ctx.mode !== "tui") {
|
|
621
|
+
ctx.ui.notify("/copy-message requires interactive TUI mode unless you pass latest/last/newest or a message number", "error");
|
|
498
622
|
return;
|
|
499
623
|
}
|
|
500
624
|
|
|
501
|
-
const selected = await pickMessage(ctx, messages);
|
|
625
|
+
const selected = await pickMessage(ctx, messages, parsedArgs.format);
|
|
502
626
|
if (!selected) return;
|
|
503
627
|
|
|
504
|
-
copySelectedMessage(ctx, selected);
|
|
628
|
+
copySelectedMessage(ctx, selected.message, selected.text);
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
pi.registerCommand("copy-user", {
|
|
633
|
+
description: "Copy the most recent user message to the clipboard",
|
|
634
|
+
handler: async (args, ctx) => {
|
|
635
|
+
copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
|
|
505
636
|
},
|
|
506
637
|
});
|
|
507
638
|
}
|
package/package.json
CHANGED