pi-copy-message 4.0.0 → 4.0.2

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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 4.0.2 - 2026-11-01
6
+
7
+ <!-- npm-release:unreleased:start -->
8
+ <!-- npm-release:unreleased:end -->
9
+
10
+ - Automate reviewed npm patch releases (#46) ([4fd1e2f](https://github.com/fitchmultz/pi-copy-message/commit/4fd1e2f468eed1f84a929602dd9553aa2515ca3b))
11
+
12
+ ### What's Changed
13
+ * Automate reviewed npm patch releases by @fitchmultz in https://github.com/fitchmultz/pi-copy-message/pull/46
14
+
15
+
16
+ **Full Changelog**: https://github.com/fitchmultz/pi-copy-message/compare/v4.0.1...v4.0.2
17
+
18
+ ## 4.0.1 - 2026-10-31
19
+
20
+ - Stop enumerating graphemes once a message preview is known to need truncation; preserve preview and copied text.
21
+
22
+ - Wrap only enough of a long message to fill the `Tab` peek instead of the whole message; very long messages note that more lines remain instead of counting them. Copied text is unchanged.
23
+
24
+ - Cancel pending copy pickers when the host disposes their UI, including during reload, without interrupting sibling dialog teardown.
25
+
3
26
  ## 4.0.0 - 2026-10-01
4
27
 
5
28
  - Require Pi 1.0.0 and pin the official 1.0.0 development cohort.
package/README.md CHANGED
@@ -1,157 +1,74 @@
1
- # pi copy-message extension
1
+ # pi-copy-message
2
2
 
3
- A [pi](https://github.com/earendil-works/pi-mono) extension that adds `/copy-message`: a keyboard-first picker for copying raw session message text without terminal wrapping, padding, or rendered TUI artifacts. It also adds `/copy-user` as a direct shortcut for the most recent user message.
3
+ Copy message text from a [Pi](https://github.com/earendil-works/pi-mono) session with a searchable picker. The copied text keeps the original code, spaces, and line breaks.
4
4
 
5
- `pi-copy-message` supersedes [`pi-copy-user-message`](https://github.com/fitchmultz/pi-copy-user-message), which only copied the most recent user message.
5
+ ![Flow from the /copy-message command through message search and selection to the clipboard.](.github/readme/copy-flow.png)
6
6
 
7
- ## What it does
7
+ ## Install and start
8
8
 
9
- - Adds `/copy-message`
10
- - Adds `/copy-user` for copying the most recent user message directly
11
- - Copies raw stored session message text, not rendered terminal lines
12
- - Shows messages in chat order: oldest at top, newest at bottom
13
- - Selects the newest visible message by default
14
- - Supports role filters for user, assistant, tool/bash, and custom messages
15
- - Hides tool/bash messages by default
16
- - Supports type-to-filter search across role and message text, with `time:<term>` for timestamp search
17
- - Supports Home/End jumps for oldest/newest visible messages
18
- - Uses Pi's configured selection bindings for navigation, copy, and cancel
19
- - 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
21
- - Supports direct numbered copies like `/copy-message 3`
22
- - Supports metadata copies with `--with-meta`, `--with-metadata`, or `--with-role`
9
+ This extension requires Pi and Node.js. See the [development guide](https://github.com/fitchmultz/pi-copy-message/blob/main/docs/development.md) for version requirements.
23
10
 
24
- ## Install
25
-
26
- Install it from npm with pi:
11
+ Run this command:
27
12
 
28
13
  ```bash
29
14
  pi install npm:pi-copy-message
30
15
  ```
31
16
 
32
- Or install it directly from GitHub with pi:
33
-
34
- ```bash
35
- pi install https://github.com/fitchmultz/pi-copy-message
36
- ```
37
-
38
- Use `/reload` after updating extension code on current official Pi and the maintained fork. Restart Pi after changing dependencies or the host runtime.
39
-
40
- If you prefer to load it directly from a local checkout during development:
41
-
42
- ```bash
43
- pi -e ./extensions/copy-message.ts
44
- ```
45
-
46
- ## Usage
17
+ Start Pi. Run `/reload` if Pi is already open.
47
18
 
48
- Open the picker:
19
+ Enter this command in Pi:
49
20
 
50
21
  ```text
51
22
  /copy-message
52
23
  ```
53
24
 
54
- Copy the most recent user message directly:
25
+ Type words to search roles and message text. Use ↑ or ↓ to select a message. Press Enter to copy the message.
55
26
 
56
- ```text
57
- /copy-user
58
- ```
27
+ ## Use the picker
59
28
 
60
- Copy the latest visible default message directly:
29
+ Messages appear from oldest to newest. The picker selects the newest visible message first.
61
30
 
62
- ```text
63
- /copy-message latest
64
- ```
31
+ User, assistant, and visible custom messages appear by default. Tool and bash messages stay hidden.
65
32
 
66
- Aliases:
33
+ | Key | Action |
34
+ |---|---|
35
+ | Ctrl+T | Show or hide tool and bash messages |
36
+ | Ctrl+U / Ctrl+A / Alt+C | Show or hide user, assistant, or custom messages |
37
+ | Tab | Preview the selected message |
38
+ | Alt+M | Switch between raw text and text with role/time metadata |
39
+ | Esc / Ctrl+C | Cancel |
67
40
 
68
- ```text
69
- /copy-message last
70
- /copy-message newest
71
- ```
41
+ The picker uses your Pi selection bindings. Its key hints show those bindings.
72
42
 
73
- Copy the 3rd default-visible message directly, matching the picker's 1-based oldest-to-newest numbering:
43
+ ## Direct copies
74
44
 
75
- ```text
76
- /copy-message 3
77
- ```
45
+ | Command | What it copies |
46
+ |---|---|
47
+ | `/copy-user` | Your most recent message with text |
48
+ | `/copy-message latest` | The latest default-visible message in Pi's active context |
49
+ | `/copy-message 3` | The third default-visible message, counted oldest to newest |
50
+ | `/copy-message latest --with-meta` | The latest message with its role and local time, when available |
78
51
 
79
- Copy with role and timestamp metadata instead of raw text only:
52
+ `latest` follows Pi's active context, including context edits, omissions, and compaction. The picker, numbered copies, and `/copy-user` use stored messages on the current branch, including history before compaction.
80
53
 
81
- ```text
82
- /copy-message latest --with-meta
83
- /copy-message 3 --with-role
84
- /copy-user --with-meta
85
- ```
54
+ ## Limits and clipboard
86
55
 
87
- ## Keyboard controls
56
+ The picker requires interactive TUI mode. Direct commands also work outside TUI mode, where Pi may hide notifications.
88
57
 
89
- | Key | Action |
90
- |---|---|
91
- | Configured `tui.select.up` (default: `↑`) | Move to older visible message |
92
- | Configured `tui.select.down` (default: `↓`) | Move to newer visible message |
93
- | Configured `tui.select.pageUp` / `tui.select.pageDown` (default: `PageUp` / `PageDown`) | Move by one visible page in regular mode |
94
- | `Ctrl+PageUp` / `Ctrl+PageDown` | Move by one visible page in fullscreen mode |
95
- | `Home` / `End` | Jump to oldest/newest visible message in regular mode |
96
- | `Ctrl+Home` / `Ctrl+End` | Jump to oldest/newest visible message in fullscreen mode |
97
- | Type or paste text | Filter visible messages; pasted line breaks and tabs become spaces |
98
- | `time:<term>` | Search timestamps |
99
- | `Backspace` | Delete one search character |
100
- | `Ctrl+U` | Toggle user messages |
101
- | `Ctrl+A` | Toggle assistant messages |
102
- | `Ctrl+T` | Toggle tool/bash messages |
103
- | `Alt+C` | Toggle custom messages |
104
- | `Tab` | Toggle a wrapped preview of the selected message |
105
- | `Alt+M` | Toggle raw vs metadata copy format |
106
- | Configured `tui.select.confirm` (default: `Enter`) | Copy selected message text |
107
- | Configured `tui.select.cancel` (default: `Esc`/`Ctrl+C`) | Cancel |
108
-
109
- ## Behavior notes
110
-
111
- - Entry IDs are hidden from the picker.
112
- - 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.
114
- - The picker caps visible rows and scrolls instead of filling the screen.
115
- - Search preserves your original selected message and restores it when the search is cleared.
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
- - Filter labels honor the active pi theme.
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
- - 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
- - 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.
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.
125
- - `/copy-message` with no direct selector requires interactive TUI mode because the picker is a custom TUI component.
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.
127
-
128
- ## Compatibility
129
-
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.
133
- - Pi includes `Ctrl+X` for copying the latest assistant response; this extension remains useful for searchable history, other roles, metadata, and direct selectors.
134
- - Node.js `>=24.15.0`. `.nvmrc` pins Node 24.21.0 for local development.
135
-
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.
137
-
138
- ## Development
58
+ The extension uses Pi's clipboard support and OSC 52 fallback. It reports “Copied” only after local clipboard readback matches the copied text.
139
59
 
140
- ```bash
141
- npm ci
142
- npm run check # node --test + tsc --noEmit + pack dry-run
143
- ```
60
+ Unverified delivery produces a warning. A failed write produces an error.
144
61
 
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.
62
+ The extension cannot verify remote terminal delivery. Paste into a local editor to check delivery.
146
63
 
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:
64
+ Restart Pi after you change dependencies or the Pi runtime.
148
65
 
149
- ```bash
150
- npm install --package-lock-only --ignore-scripts --registry=https://registry.npmjs.org/
151
- npm ci
152
- ```
66
+ ## Docs and support
67
+
68
+ The [reference](https://github.com/fitchmultz/pi-copy-message/blob/main/docs/reference.md) lists all controls, command aliases, and clipboard details. The [development guide](https://github.com/fitchmultz/pi-copy-message/blob/main/docs/development.md) covers tests and releases.
69
+
70
+ [Changelog](CHANGELOG.md) · [Report an issue](https://github.com/fitchmultz/pi-copy-message/issues)
153
71
 
154
- Key files:
72
+ ## License
155
73
 
156
- - `extensions/copy-message.ts` — publishable extension implementation
157
- - `tests/copy-message.test.ts` — regression tests for command wiring, filtering, search, navigation, and copy formatting
74
+ [MIT](LICENSE) · Mitch Fultz
@@ -6,6 +6,7 @@ import { decodeKittyPrintable, getNativeClipboard, matchesKey, truncateToWidth,
6
6
 
7
7
  const MAX_VISIBLE_MESSAGES = 8;
8
8
  const MAX_PEEK_LINES = 16;
9
+ const PEEK_WRAP_CHARS = 65_536;
9
10
 
10
11
  export type CopyFormat = "raw" | "metadata";
11
12
 
@@ -65,10 +66,14 @@ function splitGraphemes(text: string): string[] {
65
66
 
66
67
  function truncateGraphemes(text: string, max: number): string {
67
68
  if (max <= 0) return "";
68
- const graphemes = splitGraphemes(text);
69
- if (graphemes.length <= max) return text;
70
- if (max === 1) return "…";
71
- return `${graphemes.slice(0, max - 1).join("")}…`;
69
+ let count = 0;
70
+ let prefixEnd = 0;
71
+ for (const { index } of graphemeSegmenter.segment(text)) {
72
+ if (count === max) return `${text.slice(0, prefixEnd)}…`;
73
+ if (count === max - 1) prefixEnd = index;
74
+ count++;
75
+ }
76
+ return text;
72
77
  }
73
78
 
74
79
  function safeDisplayText(text: string): string {
@@ -257,7 +262,7 @@ function messageMatchesSearch(message: CopyableMessage, search: string): boolean
257
262
  });
258
263
  }
259
264
 
260
- export function filteredMessages(messages: CopyableMessage[], visibility: MessageVisibility, search = ""): CopyableMessage[] {
265
+ function filteredMessages(messages: CopyableMessage[], visibility: MessageVisibility, search = ""): CopyableMessage[] {
261
266
  return messages.filter((message) => isVisibleMessage(message, visibility) && messageMatchesSearch(message, search));
262
267
  }
263
268
 
@@ -274,7 +279,7 @@ export function messageByDefaultNumber(messages: CopyableMessage[], number: numb
274
279
  return defaultVisibleMessages(messages)[number - 1];
275
280
  }
276
281
 
277
- export function formatMessageForCopy(message: CopyableMessage, format: CopyFormat): string {
282
+ function formatMessageForCopy(message: CopyableMessage, format: CopyFormat): string {
278
283
  if (format === "raw") return message.text;
279
284
  const time = formatTime(message.timestamp);
280
285
  const label = roleLabel(message.role);
@@ -339,13 +344,25 @@ function renderMessageLine(
339
344
 
340
345
  function renderPeekLines(message: CopyableMessage, width: number, theme: CopyMessageTheme, format: CopyFormat): string[] {
341
346
  const contentWidth = Math.max(1, width - 2);
342
- const text = safeDisplayText(formatMessageForCopy(message, format));
343
- const wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, text, false), contentWidth);
347
+ let wrapped: string[];
348
+ let complete: boolean;
349
+ // Wrap a growing prefix instead of the whole message. A cut prefix ends on visible text: styling
350
+ // codes attached to a trailing whitespace run would make the wrapper break it into blank lines that
351
+ // a full wrap drops. Greedy wrapping then changes only the prefix's last two wrapped lines, so stop
352
+ // once those lines fall outside the visible window.
353
+ for (let chars = PEEK_WRAP_CHARS; ; chars *= 2) {
354
+ complete = chars >= message.text.length;
355
+ const prefix = complete ? message : { ...message, text: message.text.slice(0, chars) };
356
+ const text = safeDisplayText(formatMessageForCopy(prefix, format));
357
+ wrapped = wrapTextWithAnsi(styleRoleText(theme, message.role, complete ? text : text.trimEnd(), false), contentWidth);
358
+ if (complete || wrapped.length >= MAX_PEEK_LINES + 2) break;
359
+ }
344
360
  const shown = wrapped.slice(0, MAX_PEEK_LINES);
345
361
  const remaining = wrapped.length - shown.length;
346
362
  const title = theme.fg("dim", `Peek ${format === "metadata" ? "metadata" : "raw"} ${roleLabel(message.role)} message`);
347
363
  const lines = [title, ...shown.map((line) => ` ${line}`)];
348
- if (remaining > 0) lines.push(theme.fg("dim", ` … ${remaining} more wrapped line${remaining === 1 ? "" : "s"}`));
364
+ if (!complete) lines.push(theme.fg("dim", " … more wrapped lines"));
365
+ else if (remaining > 0) lines.push(theme.fg("dim", ` … ${remaining} more wrapped line${remaining === 1 ? "" : "s"}`));
349
366
  return lines;
350
367
  }
351
368
 
@@ -679,7 +696,7 @@ function parseCopyArgs(args: string | undefined): ParsedCopyMessageArgs {
679
696
  return result;
680
697
  }
681
698
 
682
- export function copyArgumentCompletions(prefix: string, includeSelectors: boolean): AutocompleteItem[] | null {
699
+ function copyArgumentCompletions(prefix: string, includeSelectors: boolean): AutocompleteItem[] | null {
683
700
  if (/^\d+$/.test(prefix)) return null;
684
701
  const candidates = includeSelectors ? [...COPY_LATEST_SELECTORS, ...COPY_METADATA_FLAGS] : COPY_METADATA_FLAGS;
685
702
  const normalized = prefix.toLowerCase();
@@ -693,15 +710,22 @@ async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessa
693
710
  if (signal.aborted) return null;
694
711
  return ctx.ui.custom<{ message: CopyableMessage; text: string } | null>((tui, theme, keybindings, done) => {
695
712
  const state = new CopyMessagePickerState(messages, initialFormat);
696
- const abort = () => done(null);
713
+ let settled = false;
714
+ const finish = (result: Parameters<typeof done>[0], defer = false) => {
715
+ if (settled) return;
716
+ settled = true;
717
+ if (defer) queueMicrotask(() => done(result));
718
+ else done(result);
719
+ };
720
+ const abort = () => finish(null);
697
721
  if (signal.aborted) abort();
698
722
  else signal.addEventListener("abort", abort, { once: true });
699
723
  const act = (result: PickerInputResult) => {
700
724
  if (result === "copy") {
701
725
  const selected = state.selectedMessage();
702
726
  const text = state.selectedCopyText();
703
- done(selected && text !== undefined ? { message: selected, text } : null);
704
- } else if (result === "cancel") done(null);
727
+ finish(selected && text !== undefined ? { message: selected, text } : null);
728
+ } else if (result === "cancel") finish(null);
705
729
  else if (result === "render") tui.requestRender();
706
730
  };
707
731
  return {
@@ -715,7 +739,12 @@ async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessa
715
739
  act(result);
716
740
  if (result !== "none") return { handled: true, focus: result === "render" };
717
741
  },
718
- dispose: () => signal.removeEventListener("abort", abort),
742
+ dispose() {
743
+ signal.removeEventListener("abort", abort);
744
+ // Settle even if disposal precedes session_shutdown. Defer the host callback:
745
+ // it can remove this component from a live dialog-disposal iteration.
746
+ finish(null, true);
747
+ },
719
748
  };
720
749
  });
721
750
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-copy-message",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
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",
@@ -47,7 +47,7 @@
47
47
  "devDependencies": {
48
48
  "@earendil-works/pi-coding-agent": "1.0.0",
49
49
  "@earendil-works/pi-tui": "1.0.0",
50
- "@types/node": "24.19.0",
50
+ "@types/node": "24.19.1",
51
51
  "typescript": "7.0.2"
52
52
  },
53
53
  "pi": {