pi-copy-message 1.0.3 → 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 CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## 1.0.3 - 2026-06-07
4
16
 
5
17
  - Register `/copy-message` before `/copy-user` so package command autocomplete prefers the picker over the shortcut.
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, and message text
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
- | `Enter` | Copy selected raw message text |
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
- - The command requires interactive TUI mode because the picker is a custom TUI component.
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+/g, " ").trim();
57
- if (preview.length <= max) return preview;
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()) return { kind: "no-text" };
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
- return result.status === 0;
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
- if (process.platform === "darwin" && commandExists("pbcopy")) {
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
- }
168
-
169
- if (commandExists("wl-copy")) {
170
- return copyWith("wl-copy", [], text) ? undefined : "wl-copy failed";
171
- }
204
+ const failedCommands: string[] = [];
172
205
 
173
- if (commandExists("xclip")) {
174
- return copyWith("xclip", ["-selection", "clipboard"], text) ? undefined : "xclip failed";
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);
175
210
  }
176
211
 
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), formatTime(message.timestamp), message.text].join(" ").toLowerCase();
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
- return terms.every((term) => haystack.includes(term));
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 filteredMessages(messages, { showAssistant: true, showUser: true, showTools: false }).at(-1) ?? messages.at(-1);
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, "type search · Home/End jump · Ctrl+U/A/T filters · Enter copy · Esc cancel"));
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
- async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[]) {
425
- return ctx.ui.custom<CopyableMessage | null>((tui, theme, _keybindings, done) => {
426
- const state = new CopyMessagePickerState(messages);
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
- done(state.selectedMessage() ?? null);
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 copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage) {
449
- const error = copyToClipboard(selected.text);
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(`Copied ${roleLabel(selected.role)} message`, "info");
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("The most recent user message has no text to copy", "warning");
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
594
  pi.registerCommand("copy-message", {
474
- description: "Select a session message and copy its raw text to the clipboard",
595
+ description: "Select a session message and copy its text to the clipboard",
475
596
  handler: async (args, ctx) => {
476
- if (ctx.mode !== "tui") {
477
- ctx.ui.notify("/copy-message requires interactive TUI mode", "error");
478
- return;
479
- }
480
-
597
+ const parsedArgs = parseCopyArgs(args);
481
598
  const messages = collectCopyableMessages(ctx);
482
599
  if (messages.length === 0) {
483
600
  ctx.ui.notify("No copyable messages found in the current branch", "error");
484
601
  return;
485
602
  }
486
603
 
487
- const trimmedArgs = (args ?? "").trim().toLowerCase();
488
- if (trimmedArgs === "last" || trimmedArgs === "latest" || trimmedArgs === "newest") {
604
+ if (parsedArgs.selector === "latest") {
489
605
  const latestVisible = latestDefaultMessage(messages);
490
- 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));
491
617
  return;
492
618
  }
493
619
 
494
- const selected = await pickMessage(ctx, messages);
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");
622
+ return;
623
+ }
624
+
625
+ const selected = await pickMessage(ctx, messages, parsedArgs.format);
495
626
  if (!selected) return;
496
627
 
497
- copySelectedMessage(ctx, selected);
628
+ copySelectedMessage(ctx, selected.message, selected.text);
498
629
  },
499
630
  });
500
631
 
501
632
  pi.registerCommand("copy-user", {
502
633
  description: "Copy the most recent user message to the clipboard",
503
- handler: async (_args, ctx) => {
504
- copyMostRecentUserMessage(ctx);
634
+ handler: async (args, ctx) => {
635
+ copyMostRecentUserMessage(ctx, parseCopyArgs(args).format);
505
636
  },
506
637
  });
507
638
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-copy-message",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
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",