pi-copy-message 1.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 +14 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/extensions/copy-message.ts +450 -0
- package/package.json +69 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 - 2026-06-07
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Add `/copy-message` custom TUI picker.
|
|
8
|
+
- Copy raw stored session message text instead of rendered terminal lines.
|
|
9
|
+
- Show messages in chronological chat order with newest selected by default.
|
|
10
|
+
- Add role filters for user, assistant, and tool/bash messages.
|
|
11
|
+
- Hide tool/bash messages by default.
|
|
12
|
+
- Add type-to-filter search with selection restore when search is cleared.
|
|
13
|
+
- Add Home/End jumps for oldest/newest visible messages.
|
|
14
|
+
- Add fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mitch Fultz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# pi copy-message extension
|
|
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.
|
|
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.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Adds `/copy-message`
|
|
10
|
+
- Copies raw stored session message text, not rendered terminal lines
|
|
11
|
+
- Shows messages in chat order: oldest at top, newest at bottom
|
|
12
|
+
- Selects the newest visible message by default
|
|
13
|
+
- Supports role filters for user, assistant, and tool/bash messages
|
|
14
|
+
- Hides tool/bash messages by default
|
|
15
|
+
- Supports type-to-filter search across role, time, and message text
|
|
16
|
+
- Supports Home/End jumps for oldest/newest visible messages
|
|
17
|
+
- Includes fast paths: `/copy-message latest`, `/copy-message last`, and `/copy-message newest`
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Install it from npm with pi:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:pi-copy-message
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or install it directly from GitHub with pi:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install https://github.com/fitchmultz/pi-copy-message
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then reload pi from inside the app:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
/reload
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If you prefer to load it directly from a local checkout during development:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pi -e ./extensions/copy-message.ts
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
Open the picker:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
/copy-message
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Copy the latest visible default message directly:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
/copy-message latest
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Aliases:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
/copy-message last
|
|
63
|
+
/copy-message newest
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Keyboard controls
|
|
67
|
+
|
|
68
|
+
| Key | Action |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `↑` | Move to older visible message |
|
|
71
|
+
| `↓` | Move to newer visible message |
|
|
72
|
+
| `Home` | Jump to oldest visible message |
|
|
73
|
+
| `End` | Jump to newest visible message |
|
|
74
|
+
| Type text | Filter visible messages |
|
|
75
|
+
| `Backspace` | Delete one search character |
|
|
76
|
+
| `Ctrl+U` | Toggle user messages |
|
|
77
|
+
| `Ctrl+A` | Toggle assistant messages |
|
|
78
|
+
| `Ctrl+T` | Toggle tool/bash messages |
|
|
79
|
+
| `Enter` | Copy selected raw message text |
|
|
80
|
+
| `Esc` | Cancel |
|
|
81
|
+
|
|
82
|
+
## Behavior notes
|
|
83
|
+
|
|
84
|
+
- Entry IDs are hidden from the picker.
|
|
85
|
+
- The picker caps visible rows and scrolls instead of filling the screen.
|
|
86
|
+
- Search preserves your original selected message and restores it when the search is cleared.
|
|
87
|
+
- Filter labels honor the active pi theme.
|
|
88
|
+
- `/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.
|
|
89
|
+
- The command requires interactive TUI mode because the picker is a custom TUI component.
|
|
90
|
+
|
|
91
|
+
## Compatibility
|
|
92
|
+
|
|
93
|
+
- Tested with pi 0.78.1
|
|
94
|
+
- Supported Node.js range for local repo tooling: `>=22.19.0`
|
|
95
|
+
- `.nvmrc` pins Node 22.19.0 for local development
|
|
96
|
+
|
|
97
|
+
This package keeps pi core packages as optional wildcard peers per current pi package guidance. Local development uses `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` as dev dependencies for typechecking and tests.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm install
|
|
103
|
+
npm run check
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Key files:
|
|
107
|
+
|
|
108
|
+
- `extensions/copy-message.ts` — publishable extension implementation
|
|
109
|
+
- `tests/copy-message.test.ts` — regression tests for command wiring, filtering, search, jumps, and clipboard behavior
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
const MAX_VISIBLE_MESSAGES = 8;
|
|
6
|
+
|
|
7
|
+
export interface CopyableMessage {
|
|
8
|
+
id: string;
|
|
9
|
+
role: string;
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type CopyMessageTheme = Parameters<Parameters<ExtensionCommandContext["ui"]["custom"]>[0]>[1];
|
|
15
|
+
|
|
16
|
+
function textFromContent(content: unknown): string {
|
|
17
|
+
if (typeof content === "string") return content;
|
|
18
|
+
if (!Array.isArray(content)) return "";
|
|
19
|
+
|
|
20
|
+
return content
|
|
21
|
+
.filter((part): part is { type: string; text: string } => {
|
|
22
|
+
return (
|
|
23
|
+
part !== null &&
|
|
24
|
+
typeof part === "object" &&
|
|
25
|
+
"type" in part &&
|
|
26
|
+
(part as { type?: unknown }).type === "text" &&
|
|
27
|
+
"text" in part &&
|
|
28
|
+
typeof (part as { text?: unknown }).text === "string"
|
|
29
|
+
);
|
|
30
|
+
})
|
|
31
|
+
.map((part) => part.text)
|
|
32
|
+
.join("\n\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function textFromMessage(message: Record<string, unknown>): string {
|
|
36
|
+
const role = message.role;
|
|
37
|
+
|
|
38
|
+
if (role === "bashExecution") {
|
|
39
|
+
const command = typeof message.command === "string" ? message.command : "";
|
|
40
|
+
const output = typeof message.output === "string" ? message.output : "";
|
|
41
|
+
return command ? `$ ${command}\n${output}`.trimEnd() : output;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (role === "branchSummary") {
|
|
45
|
+
return typeof message.summary === "string" ? message.summary : "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (role === "compactionSummary") {
|
|
49
|
+
return typeof message.summary === "string" ? message.summary : "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return textFromContent(message.content);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
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)}…`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function roleLabel(role: string): string {
|
|
62
|
+
switch (role) {
|
|
63
|
+
case "assistant":
|
|
64
|
+
return "assistant";
|
|
65
|
+
case "user":
|
|
66
|
+
return "user";
|
|
67
|
+
case "toolResult":
|
|
68
|
+
return "tool";
|
|
69
|
+
case "bashExecution":
|
|
70
|
+
return "bash";
|
|
71
|
+
case "custom":
|
|
72
|
+
return "custom";
|
|
73
|
+
case "branchSummary":
|
|
74
|
+
return "branch-summary";
|
|
75
|
+
case "compactionSummary":
|
|
76
|
+
return "compaction";
|
|
77
|
+
default:
|
|
78
|
+
return role || "message";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatTime(timestamp: unknown): string {
|
|
83
|
+
if (typeof timestamp !== "string") return "";
|
|
84
|
+
const date = new Date(timestamp);
|
|
85
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
86
|
+
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function collectCopyableMessages(ctx: { sessionManager: { getBranch(): unknown[] } }): CopyableMessage[] {
|
|
90
|
+
const messages: CopyableMessage[] = [];
|
|
91
|
+
|
|
92
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
93
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
94
|
+
const record = entry as Record<string, unknown>;
|
|
95
|
+
if (record.type !== "message") continue;
|
|
96
|
+
if (record.message === null || typeof record.message !== "object") continue;
|
|
97
|
+
|
|
98
|
+
const message = record.message as Record<string, unknown>;
|
|
99
|
+
const role = typeof message.role === "string" ? message.role : "message";
|
|
100
|
+
const text = textFromMessage(message);
|
|
101
|
+
if (!text.trim()) continue;
|
|
102
|
+
|
|
103
|
+
messages.push({
|
|
104
|
+
id: typeof record.id === "string" ? record.id : "unknown",
|
|
105
|
+
role,
|
|
106
|
+
timestamp: typeof record.timestamp === "string" ? record.timestamp : undefined,
|
|
107
|
+
text,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return messages;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function commandExists(command: string): boolean {
|
|
115
|
+
const result = spawnSync("sh", ["-c", "command -v \"$1\" >/dev/null 2>&1", "sh", command], { stdio: "ignore" });
|
|
116
|
+
return result.status === 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function copyWith(command: string, args: string[], text: string): boolean {
|
|
120
|
+
const result = spawnSync(command, args, { input: text, encoding: "utf8" });
|
|
121
|
+
return !result.error && result.status === 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function copyToClipboard(text: string): string | undefined {
|
|
125
|
+
if (process.platform === "darwin" && commandExists("pbcopy")) {
|
|
126
|
+
return copyWith("pbcopy", [], text) ? undefined : "pbcopy failed";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (process.env.TERMUX_VERSION && commandExists("termux-clipboard-set")) {
|
|
130
|
+
return copyWith("termux-clipboard-set", [], text) ? undefined : "termux-clipboard-set failed";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (commandExists("wl-copy")) {
|
|
134
|
+
return copyWith("wl-copy", [], text) ? undefined : "wl-copy failed";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (commandExists("xclip")) {
|
|
138
|
+
return copyWith("xclip", ["-selection", "clipboard"], text) ? undefined : "xclip failed";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (commandExists("xsel")) {
|
|
142
|
+
return copyWith("xsel", ["--clipboard", "--input"], text) ? undefined : "xsel failed";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return "No clipboard command found (tried pbcopy, termux-clipboard-set, wl-copy, xclip, xsel)";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isToolMessage(message: CopyableMessage): boolean {
|
|
149
|
+
return message.role === "toolResult" || message.role === "bashExecution";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface MessageVisibility {
|
|
153
|
+
showAssistant: boolean;
|
|
154
|
+
showUser: boolean;
|
|
155
|
+
showTools: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isVisibleMessage(message: CopyableMessage, visibility: MessageVisibility): boolean {
|
|
159
|
+
if (isToolMessage(message)) return visibility.showTools;
|
|
160
|
+
if (message.role === "assistant") return visibility.showAssistant;
|
|
161
|
+
if (message.role === "user") return visibility.showUser;
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function messageSearchText(message: CopyableMessage): string {
|
|
166
|
+
return [roleLabel(message.role), formatTime(message.timestamp), message.text].join(" ").toLowerCase();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function messageMatchesSearch(message: CopyableMessage, search: string): boolean {
|
|
170
|
+
const terms = search
|
|
171
|
+
.trim()
|
|
172
|
+
.toLowerCase()
|
|
173
|
+
.split(/\s+/)
|
|
174
|
+
.filter(Boolean);
|
|
175
|
+
if (terms.length === 0) return true;
|
|
176
|
+
const haystack = messageSearchText(message);
|
|
177
|
+
return terms.every((term) => haystack.includes(term));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function filteredMessages(messages: CopyableMessage[], visibility: MessageVisibility, search = ""): CopyableMessage[] {
|
|
181
|
+
return messages.filter((message) => isVisibleMessage(message, visibility) && messageMatchesSearch(message, search));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function latestDefaultMessage(messages: CopyableMessage[]): CopyableMessage | undefined {
|
|
185
|
+
return filteredMessages(messages, { showAssistant: true, showUser: true, showTools: false }).at(-1) ?? messages.at(-1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isPrintableSearchInput(data: string): boolean {
|
|
189
|
+
return data.length > 0 && [...data].every((char) => {
|
|
190
|
+
const code = char.charCodeAt(0);
|
|
191
|
+
return code >= 32 && code !== 127 && !(code >= 0x80 && code <= 0x9f);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function filterLabel(theme: CopyMessageTheme, label: string, enabled: boolean, color: "accent" | "warning" | "dim"): string {
|
|
196
|
+
const text = `${label} ${enabled ? "✓" : "—"}`;
|
|
197
|
+
return enabled ? theme.fg(color, text) : theme.fg("muted", text);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function hotkeyHint(theme: CopyMessageTheme, text: string): string {
|
|
201
|
+
return theme.fg("text", text);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function roleColor(theme: CopyMessageTheme, role: string, text: string): string {
|
|
205
|
+
switch (roleLabel(role)) {
|
|
206
|
+
case "user":
|
|
207
|
+
return theme.fg("warning", text);
|
|
208
|
+
case "assistant":
|
|
209
|
+
return theme.fg("accent", text);
|
|
210
|
+
case "tool":
|
|
211
|
+
case "bash":
|
|
212
|
+
return theme.fg("dim", text);
|
|
213
|
+
default:
|
|
214
|
+
return theme.fg("muted", text);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function styleRoleText(theme: CopyMessageTheme, role: string, text: string, selected: boolean): string {
|
|
219
|
+
return roleColor(theme, role, selected ? theme.bold(text) : text);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderMessageLine(
|
|
223
|
+
message: CopyableMessage,
|
|
224
|
+
index: number,
|
|
225
|
+
total: number,
|
|
226
|
+
width: number,
|
|
227
|
+
selected: boolean,
|
|
228
|
+
theme: CopyMessageTheme,
|
|
229
|
+
): string {
|
|
230
|
+
const numberWidth = String(total).length;
|
|
231
|
+
const arrow = selected ? theme.fg("accent", "→") : " ";
|
|
232
|
+
const number = `${String(index + 1).padStart(numberWidth)}.`;
|
|
233
|
+
const styledNumber = selected ? theme.fg("accent", theme.bold(number)) : theme.fg("dim", number);
|
|
234
|
+
const role = styleRoleText(theme, message.role, roleLabel(message.role), selected);
|
|
235
|
+
const time = theme.fg("muted", formatTime(message.timestamp));
|
|
236
|
+
const separator = theme.fg("dim", "·");
|
|
237
|
+
const meta = `${arrow} ${styledNumber} ${role} ${separator} ${time}`;
|
|
238
|
+
const previewWidth = Math.max(0, width - visibleWidth(meta) - 2);
|
|
239
|
+
const preview = truncateToWidth(compactPreview(message.text, 300), previewWidth, "…");
|
|
240
|
+
const styledPreview = styleRoleText(theme, message.role, preview, selected);
|
|
241
|
+
return preview ? `${meta} ${styledPreview}` : meta;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
type PickerInputResult = "copy" | "cancel" | "render" | "none";
|
|
245
|
+
|
|
246
|
+
export class CopyMessagePickerState {
|
|
247
|
+
readonly visibility: MessageVisibility = {
|
|
248
|
+
showAssistant: true,
|
|
249
|
+
showUser: true,
|
|
250
|
+
showTools: false,
|
|
251
|
+
};
|
|
252
|
+
search = "";
|
|
253
|
+
visibleMessages: CopyableMessage[];
|
|
254
|
+
selectedIndex: number;
|
|
255
|
+
private searchAnchorId: string | undefined;
|
|
256
|
+
|
|
257
|
+
constructor(private readonly messages: CopyableMessage[]) {
|
|
258
|
+
this.visibleMessages = filteredMessages(messages, this.visibility, this.search);
|
|
259
|
+
this.selectedIndex = Math.max(0, this.visibleMessages.length - 1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
selectedMessage(): CopyableMessage | undefined {
|
|
263
|
+
return this.visibleMessages[this.selectedIndex];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
render(width: number, theme: CopyMessageTheme): string[] {
|
|
267
|
+
const maxVisible = Math.min(this.visibleMessages.length, MAX_VISIBLE_MESSAGES);
|
|
268
|
+
const start = maxVisible === 0 ? 0 : Math.max(0, Math.min(this.selectedIndex - maxVisible + 1, this.visibleMessages.length - maxVisible));
|
|
269
|
+
const end = Math.min(this.visibleMessages.length, start + maxVisible);
|
|
270
|
+
const userState = filterLabel(theme, "user", this.visibility.showUser, "warning");
|
|
271
|
+
const assistantState = filterLabel(theme, "assistant", this.visibility.showAssistant, "accent");
|
|
272
|
+
const toolState = filterLabel(theme, "tools", this.visibility.showTools, "dim");
|
|
273
|
+
const searchState = this.search ? theme.fg("accent", `search “${this.search}”`) : theme.fg("dim", "type to filter");
|
|
274
|
+
|
|
275
|
+
const lines = [
|
|
276
|
+
theme.bold(theme.fg("accent", "Copy raw message")),
|
|
277
|
+
theme.fg("muted", "Newest is selected at bottom. Up goes back in time."),
|
|
278
|
+
"",
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
if (this.visibleMessages.length === 0) {
|
|
282
|
+
lines.push(theme.fg("warning", this.search ? "No messages match current filters and search." : "No messages visible with current filters."));
|
|
283
|
+
} else {
|
|
284
|
+
for (let i = start; i < end; i++) {
|
|
285
|
+
const message = this.visibleMessages[i];
|
|
286
|
+
if (!message) continue;
|
|
287
|
+
lines.push(renderMessageLine(message, i, this.visibleMessages.length, width, i === this.selectedIndex, theme));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const position = this.visibleMessages.length === 0 ? "0/0" : `${this.selectedIndex + 1}/${this.visibleMessages.length}`;
|
|
292
|
+
lines.push(`${theme.fg("dim", `(${position})`)} · ${userState} · ${assistantState} · ${toolState} · ${searchState}`);
|
|
293
|
+
lines.push("");
|
|
294
|
+
lines.push(hotkeyHint(theme, "type search · Home/End jump · Ctrl+U/A/T filters · Enter copy · Esc cancel"));
|
|
295
|
+
lines.push("");
|
|
296
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
handleInput(data: string): PickerInputResult {
|
|
300
|
+
if (matchesKey(data, "ctrl+t")) {
|
|
301
|
+
this.visibility.showTools = !this.visibility.showTools;
|
|
302
|
+
this.refreshMessages();
|
|
303
|
+
return "render";
|
|
304
|
+
}
|
|
305
|
+
if (matchesKey(data, "ctrl+a")) {
|
|
306
|
+
this.visibility.showAssistant = !this.visibility.showAssistant;
|
|
307
|
+
this.refreshMessages();
|
|
308
|
+
return "render";
|
|
309
|
+
}
|
|
310
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
311
|
+
this.visibility.showUser = !this.visibility.showUser;
|
|
312
|
+
this.refreshMessages();
|
|
313
|
+
return "render";
|
|
314
|
+
}
|
|
315
|
+
if (matchesKey(data, "backspace") || data === "\x7f") {
|
|
316
|
+
this.setSearch(this.search.slice(0, -1));
|
|
317
|
+
return "render";
|
|
318
|
+
}
|
|
319
|
+
if (isPrintableSearchInput(data)) {
|
|
320
|
+
this.setSearch(this.search + data);
|
|
321
|
+
return "render";
|
|
322
|
+
}
|
|
323
|
+
if (matchesKey(data, "up")) {
|
|
324
|
+
this.move(-1);
|
|
325
|
+
return "render";
|
|
326
|
+
}
|
|
327
|
+
if (matchesKey(data, "down")) {
|
|
328
|
+
this.move(1);
|
|
329
|
+
return "render";
|
|
330
|
+
}
|
|
331
|
+
if (matchesKey(data, "home")) {
|
|
332
|
+
this.jumpToTop();
|
|
333
|
+
return "render";
|
|
334
|
+
}
|
|
335
|
+
if (matchesKey(data, "end")) {
|
|
336
|
+
this.jumpToBottom();
|
|
337
|
+
return "render";
|
|
338
|
+
}
|
|
339
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
340
|
+
return this.visibleMessages.length > 0 ? "copy" : "none";
|
|
341
|
+
}
|
|
342
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
343
|
+
return "cancel";
|
|
344
|
+
}
|
|
345
|
+
return "none";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private refreshMessages(preferredId?: string) {
|
|
349
|
+
const selectedId = preferredId ?? this.visibleMessages[this.selectedIndex]?.id;
|
|
350
|
+
this.visibleMessages = filteredMessages(this.messages, this.visibility, this.search);
|
|
351
|
+
const nextIndex = selectedId ? this.visibleMessages.findIndex((message) => message.id === selectedId) : -1;
|
|
352
|
+
this.selectedIndex = nextIndex >= 0 ? nextIndex : Math.max(0, this.visibleMessages.length - 1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private setSearch(nextSearch: string) {
|
|
356
|
+
if (this.search.length === 0 && nextSearch.length > 0) {
|
|
357
|
+
this.searchAnchorId = this.visibleMessages[this.selectedIndex]?.id;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
this.search = nextSearch;
|
|
361
|
+
|
|
362
|
+
if (this.search.length === 0) {
|
|
363
|
+
const anchorId = this.searchAnchorId;
|
|
364
|
+
this.searchAnchorId = undefined;
|
|
365
|
+
this.refreshMessages(anchorId);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this.refreshMessages();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private move(delta: number) {
|
|
373
|
+
if (this.visibleMessages.length === 0) return;
|
|
374
|
+
this.selectedIndex = Math.max(0, Math.min(this.visibleMessages.length - 1, this.selectedIndex + delta));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private jumpToTop() {
|
|
378
|
+
if (this.visibleMessages.length === 0) return;
|
|
379
|
+
this.selectedIndex = 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private jumpToBottom() {
|
|
383
|
+
if (this.visibleMessages.length === 0) return;
|
|
384
|
+
this.selectedIndex = this.visibleMessages.length - 1;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function pickMessage(ctx: ExtensionCommandContext, messages: CopyableMessage[]) {
|
|
389
|
+
return ctx.ui.custom<CopyableMessage | null>((tui, theme, _keybindings, done) => {
|
|
390
|
+
const state = new CopyMessagePickerState(messages);
|
|
391
|
+
return {
|
|
392
|
+
render(width: number) {
|
|
393
|
+
return state.render(width, theme);
|
|
394
|
+
},
|
|
395
|
+
invalidate() {},
|
|
396
|
+
handleInput(data: string) {
|
|
397
|
+
const result = state.handleInput(data);
|
|
398
|
+
if (result === "copy") {
|
|
399
|
+
done(state.selectedMessage() ?? null);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (result === "cancel") {
|
|
403
|
+
done(null);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (result === "render") tui.requestRender();
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function copySelectedMessage(ctx: Pick<ExtensionCommandContext, "ui">, selected: CopyableMessage) {
|
|
413
|
+
const error = copyToClipboard(selected.text);
|
|
414
|
+
if (error) {
|
|
415
|
+
ctx.ui.notify(error, "error");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
ctx.ui.notify(`Copied ${roleLabel(selected.role)} message`, "info");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export default function copyMessageExtension(pi: Pick<ExtensionAPI, "registerCommand">) {
|
|
423
|
+
pi.registerCommand("copy-message", {
|
|
424
|
+
description: "Select a session message and copy its raw text to the clipboard",
|
|
425
|
+
handler: async (args, ctx) => {
|
|
426
|
+
if (ctx.mode !== "tui") {
|
|
427
|
+
ctx.ui.notify("/copy-message requires interactive TUI mode", "error");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const messages = collectCopyableMessages(ctx);
|
|
432
|
+
if (messages.length === 0) {
|
|
433
|
+
ctx.ui.notify("No copyable messages found in the current branch", "error");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const trimmedArgs = (args ?? "").trim().toLowerCase();
|
|
438
|
+
if (trimmedArgs === "last" || trimmedArgs === "latest" || trimmedArgs === "newest") {
|
|
439
|
+
const latestVisible = latestDefaultMessage(messages);
|
|
440
|
+
if (latestVisible) copySelectedMessage(ctx, latestVisible);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const selected = await pickMessage(ctx, messages);
|
|
445
|
+
if (!selected) return;
|
|
446
|
+
|
|
447
|
+
copySelectedMessage(ctx, selected);
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-copy-message",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "pi extension that adds /copy-message for copying raw session messages with role filters and search",
|
|
5
|
+
"author": "Mitch Fultz (https://github.com/fitchmultz)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.19.0"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-extension",
|
|
15
|
+
"extension",
|
|
16
|
+
"clipboard",
|
|
17
|
+
"copy",
|
|
18
|
+
"tui",
|
|
19
|
+
"typescript"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/fitchmultz/pi-copy-message.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/fitchmultz/pi-copy-message/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/fitchmultz/pi-copy-message#readme",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "tsx tests/copy-message.test.ts",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"check": "npm test && npm run typecheck",
|
|
33
|
+
"prepublishOnly": "npm run check && npm pack --dry-run"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"extensions",
|
|
37
|
+
"README.md",
|
|
38
|
+
"CHANGELOG.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "^0.78.1",
|
|
43
|
+
"@earendil-works/pi-tui": "^0.78.1",
|
|
44
|
+
"@types/node": "^25.9.1",
|
|
45
|
+
"tsx": "^4.22.4",
|
|
46
|
+
"typescript": "^6.0.3"
|
|
47
|
+
},
|
|
48
|
+
"overrides": {
|
|
49
|
+
"basic-ftp": "6.0.1"
|
|
50
|
+
},
|
|
51
|
+
"pi": {
|
|
52
|
+
"extensions": [
|
|
53
|
+
"./extensions"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"packageManager": "npm@11.16.0",
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
59
|
+
"@earendil-works/pi-tui": "*"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"@earendil-works/pi-coding-agent": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@earendil-works/pi-tui": {
|
|
66
|
+
"optional": true
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|