pi-copy-message 4.0.0 → 4.0.1
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 +10 -0
- package/README.md +41 -124
- package/extensions/copy-message.ts +43 -14
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 4.0.1 - 2026-10-31
|
|
6
|
+
|
|
7
|
+
- Stop enumerating graphemes once a message preview is known to need truncation; preserve preview and copied text.
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
- Cancel pending copy pickers when the host disposes their UI, including during reload, without interrupting sibling dialog teardown.
|
|
12
|
+
|
|
3
13
|
## 4.0.0 - 2026-10-01
|
|
4
14
|
|
|
5
15
|
- Require Pi 1.0.0 and pin the official 1.0.0 development cohort.
|
package/README.md
CHANGED
|
@@ -1,157 +1,74 @@
|
|
|
1
|
-
# pi
|
|
1
|
+
# pi-copy-message
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+

|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Install and start
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
+
Enter this command in Pi:
|
|
49
20
|
|
|
50
21
|
```text
|
|
51
22
|
/copy-message
|
|
52
23
|
```
|
|
53
24
|
|
|
54
|
-
|
|
25
|
+
Type words to search roles and message text. Use ↑ or ↓ to select a message. Press Enter to copy the message.
|
|
55
26
|
|
|
56
|
-
|
|
57
|
-
/copy-user
|
|
58
|
-
```
|
|
27
|
+
## Use the picker
|
|
59
28
|
|
|
60
|
-
|
|
29
|
+
Messages appear from oldest to newest. The picker selects the newest visible message first.
|
|
61
30
|
|
|
62
|
-
|
|
63
|
-
/copy-message latest
|
|
64
|
-
```
|
|
31
|
+
User, assistant, and visible custom messages appear by default. Tool and bash messages stay hidden.
|
|
65
32
|
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
## Direct copies
|
|
74
44
|
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
+
The picker requires interactive TUI mode. Direct commands also work outside TUI mode, where Pi may hide notifications.
|
|
88
57
|
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
The extension cannot verify remote terminal delivery. Paste into a local editor to check delivery.
|
|
146
63
|
|
|
147
|
-
|
|
64
|
+
Restart Pi after you change dependencies or the Pi runtime.
|
|
148
65
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
72
|
+
## License
|
|
155
73
|
|
|
156
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
} else if (result === "cancel")
|
|
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
|
|
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.
|
|
3
|
+
"version": "4.0.1",
|
|
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.
|
|
50
|
+
"@types/node": "24.19.1",
|
|
51
51
|
"typescript": "7.0.2"
|
|
52
52
|
},
|
|
53
53
|
"pi": {
|