pi-anycopy 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Warren Winter
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,81 @@
1
+ # anycopy for Pi (`pi-anycopy`)
2
+
3
+ Browse session tree nodes with a live preview and copy any of them to the clipboard.
4
+
5
+ By comparison to Pi's native `/copy` (copies only the last assistant message) and `/md` (bulk-exports the entire branch as a Markdown transcript), `/anycopy` allows you to navigate the full session tree, preview each node's content with syntax highlighting, and copy to the clipboard any node(s) from the tree.
6
+
7
+ <p align="center">
8
+ <img width="450" alt="anycopy demo" src="https://raw.githubusercontent.com/w-winter/dot314/main/assets/anycopy-demo.gif" />
9
+ </p>
10
+
11
+ ## Install
12
+
13
+ From npm:
14
+
15
+ ```bash
16
+ pi install npm:pi-anycopy
17
+ ```
18
+
19
+ From the dot314 git bundle (filtered install):
20
+
21
+ Add to `~/.pi/agent/settings.json` (or replace an existing unfiltered `git:github.com/w-winter/dot314` entry):
22
+
23
+ ```json
24
+ {
25
+ "packages": [
26
+ {
27
+ "source": "git:github.com/w-winter/dot314",
28
+ "extensions": ["extensions/anycopy/index.ts"],
29
+ "skills": [],
30
+ "themes": [],
31
+ "prompts": []
32
+ }
33
+ ]
34
+ }
35
+ ```
36
+
37
+ Restart Pi after installation.
38
+
39
+ ## Usage
40
+
41
+ ```text
42
+ /anycopy
43
+ ```
44
+
45
+ ## Keys
46
+
47
+ Defaults (customizable in `config.json`):
48
+
49
+ | Key | Action |
50
+ |-----|--------|
51
+ | `Space` | Select/unselect focused node |
52
+ | `Shift+C` | Copy selected nodes (or focused node if nothing is selected) |
53
+ | `Shift+X` | Clear selection |
54
+ | `Shift+L` | Label node (native tree behavior) |
55
+ | `Shift+Up` / `Shift+Down` | Scroll node preview by line |
56
+ | `Shift+Left` / `Shift+Right` | Page through node preview |
57
+ | `Esc` | Close |
58
+
59
+ Notes:
60
+ - If no nodes are selected, `Shift+C` copies the focused node
61
+ - When copying multiple selected nodes, they are auto-sorted chronologically (by position in the session tree), not by selection order
62
+ - Label edits are persisted via `pi.setLabel(...)`
63
+ - Despite reoffering node labeling (`/anycopy` is arguably a better UI than `/tree` to also perform this action in), this extension doesn't offer a full reproduction of `/tree`'s other features (e.g., branch switching and summarization are not included)
64
+
65
+ ## Configuration
66
+
67
+ Edit `~/.pi/agent/extensions/anycopy/config.json`:
68
+
69
+ ```json
70
+ {
71
+ "keys": {
72
+ "toggleSelect": "space",
73
+ "copy": "shift+c",
74
+ "clear": "shift+x",
75
+ "scrollUp": "shift+up",
76
+ "scrollDown": "shift+down",
77
+ "pageUp": "shift+left",
78
+ "pageDown": "shift+right"
79
+ }
80
+ }
81
+ ```
@@ -0,0 +1,11 @@
1
+ {
2
+ "keys": {
3
+ "toggleSelect": "space",
4
+ "copy": "shift+c",
5
+ "clear": "shift+x",
6
+ "scrollUp": "shift+up",
7
+ "scrollDown": "shift+down",
8
+ "pageUp": "shift+left",
9
+ "pageDown": "shift+right"
10
+ }
11
+ }
@@ -0,0 +1,726 @@
1
+ /**
2
+ * anycopy — browse session tree nodes with preview and copy any of them
3
+ *
4
+ * Layout: native TreeSelectorComponent at top, status bar, preview below
5
+ *
6
+ * Default keys (customizable via ./config.json):
7
+ * Space - select/unselect focused node for copy
8
+ * Shift+C - copy selected nodes (or focused node if none selected)
9
+ * Shift+X - clear selection
10
+ * Shift+L - label node (native tree behavior)
11
+ * Shift+↑/↓ - scroll preview
12
+ * Shift+←/→ - page preview
13
+ * Esc - close
14
+ */
15
+
16
+ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@mariozechner/pi-coding-agent";
17
+ import {
18
+ copyToClipboard,
19
+ getLanguageFromPath,
20
+ getMarkdownTheme,
21
+ highlightCode,
22
+ TreeSelectorComponent,
23
+ } from "@mariozechner/pi-coding-agent";
24
+
25
+ import { Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
26
+ import type { Focusable } from "@mariozechner/pi-tui";
27
+
28
+ import { existsSync, readFileSync } from "fs";
29
+ import { dirname, join } from "path";
30
+ import { fileURLToPath } from "url";
31
+
32
+ type SessionTreeNode = {
33
+ entry: SessionEntry;
34
+ children: SessionTreeNode[];
35
+ label?: string;
36
+ };
37
+
38
+ type anycopyKeyConfig = {
39
+ toggleSelect: string;
40
+ copy: string;
41
+ clear: string;
42
+ scrollDown: string;
43
+ scrollUp: string;
44
+ pageDown: string;
45
+ pageUp: string;
46
+ };
47
+
48
+ type anycopyConfig = {
49
+ keys?: Partial<anycopyKeyConfig>;
50
+ };
51
+
52
+ const DEFAULT_KEYS: anycopyKeyConfig = {
53
+ toggleSelect: "space",
54
+ copy: "shift+c",
55
+ clear: "shift+x",
56
+ scrollDown: "shift+down",
57
+ scrollUp: "shift+up",
58
+ pageDown: "shift+right",
59
+ pageUp: "shift+left",
60
+ };
61
+
62
+ const getExtensionDir = (): string => {
63
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
64
+ if (typeof __dirname !== "undefined") return __dirname;
65
+ return dirname(fileURLToPath(import.meta.url));
66
+ };
67
+
68
+ const loadConfig = (): anycopyKeyConfig => {
69
+ const configPath = join(getExtensionDir(), "config.json");
70
+ if (!existsSync(configPath)) return { ...DEFAULT_KEYS };
71
+
72
+ try {
73
+ const raw = readFileSync(configPath, "utf8");
74
+ const parsed = JSON.parse(raw) as anycopyConfig;
75
+ const keys = parsed.keys ?? {};
76
+ return {
77
+ toggleSelect: typeof keys.toggleSelect === "string" ? keys.toggleSelect : DEFAULT_KEYS.toggleSelect,
78
+ copy: typeof keys.copy === "string" ? keys.copy : DEFAULT_KEYS.copy,
79
+ clear: typeof keys.clear === "string" ? keys.clear : DEFAULT_KEYS.clear,
80
+ scrollDown: typeof keys.scrollDown === "string" ? keys.scrollDown : DEFAULT_KEYS.scrollDown,
81
+ scrollUp: typeof keys.scrollUp === "string" ? keys.scrollUp : DEFAULT_KEYS.scrollUp,
82
+ pageDown: typeof keys.pageDown === "string" ? keys.pageDown : DEFAULT_KEYS.pageDown,
83
+ pageUp: typeof keys.pageUp === "string" ? keys.pageUp : DEFAULT_KEYS.pageUp,
84
+ };
85
+ } catch {
86
+ return { ...DEFAULT_KEYS };
87
+ }
88
+ };
89
+
90
+ const formatKeyHint = (key: string): string => {
91
+ const normalized = key.trim().toLowerCase();
92
+ if (normalized === "space") return "Space";
93
+ const parts = normalized.split("+");
94
+ return parts
95
+ .map((part) => {
96
+ if (part === "shift") return "Shift";
97
+ if (part === "ctrl") return "Ctrl";
98
+ if (part === "alt") return "Alt";
99
+ if (part.length === 1) return part.toUpperCase();
100
+ return part;
101
+ })
102
+ .join("+");
103
+ };
104
+
105
+ const pluralizeNode = (count: number): string => (count === 1 ? "node" : "nodes");
106
+
107
+ const MAX_PREVIEW_CHARS = 7000;
108
+ const MAX_PREVIEW_LINES = 200;
109
+ const FLASH_DURATION_MS = 2000;
110
+
111
+ const getTextContent = (content: unknown): string => {
112
+ if (typeof content === "string") return content;
113
+ if (!Array.isArray(content)) return "";
114
+ return content
115
+ .filter(
116
+ (b): b is { type: "text"; text: string } =>
117
+ typeof b === "object" && b !== null && (b as { type?: string }).type === "text",
118
+ )
119
+ .map((b) => b.text)
120
+ .join("");
121
+ };
122
+
123
+ const clipText = (text: string): string => {
124
+ if (text.length <= MAX_PREVIEW_CHARS) return text;
125
+ return `${text.slice(0, MAX_PREVIEW_CHARS)}\n… [truncated]`;
126
+ };
127
+
128
+ /** Role/type label for clipboard display */
129
+ const getEntryRoleLabel = (entry: SessionEntry): string => {
130
+ if (entry.type === "message") {
131
+ return (entry.message as { role?: string }).role ?? "message";
132
+ }
133
+ if (entry.type === "custom_message") return entry.customType;
134
+ return entry.type;
135
+ };
136
+
137
+ /** Plain text content for clipboard and preview (no metadata) */
138
+ const getEntryContent = (entry: SessionEntry): string => {
139
+ switch (entry.type) {
140
+ case "message": {
141
+ const msg = entry.message as {
142
+ role?: string;
143
+ content?: unknown;
144
+ command?: string;
145
+ errorMessage?: string;
146
+ };
147
+ if (msg.role === "bashExecution" && msg.command) return msg.command;
148
+ if (msg.errorMessage) return `(error) ${msg.errorMessage}`;
149
+ return getTextContent(msg.content).trim() || "(no text content)";
150
+ }
151
+ case "custom_message": {
152
+ if (typeof entry.content === "string") {
153
+ return entry.content || "(no text content)";
154
+ }
155
+ if (!Array.isArray(entry.content)) {
156
+ return "(no text content)";
157
+ }
158
+
159
+ const content = entry.content
160
+ .filter(
161
+ (b): b is { type: "text"; text: string } =>
162
+ typeof b === "object" &&
163
+ b !== null &&
164
+ (b as { type?: string }).type === "text" &&
165
+ typeof (b as { text?: unknown }).text === "string",
166
+ )
167
+ .map((b) => b.text)
168
+ .join("");
169
+ return content || "(no text content)";
170
+ }
171
+ case "compaction":
172
+ return entry.summary;
173
+ case "branch_summary":
174
+ return entry.summary;
175
+ case "custom":
176
+ return `[custom: ${entry.customType}]`;
177
+ case "label":
178
+ return `label: ${entry.label ?? "(cleared)"}`;
179
+ case "model_change":
180
+ return `${entry.provider}/${entry.modelId}`;
181
+ case "thinking_level_change":
182
+ return entry.thinkingLevel;
183
+ case "session_info":
184
+ return entry.name ?? "(unnamed)";
185
+ default:
186
+ return "";
187
+ }
188
+ };
189
+
190
+ const replaceTabs = (text: string): string => text.replace(/\t/g, " ");
191
+
192
+ const MAX_PARENT_TRAVERSAL_DEPTH = 30;
193
+
194
+ const getToolCallId = (entry: SessionEntry): string | null => {
195
+ if (entry.type !== "message") return null;
196
+ const msg = entry.message as { role?: string; toolCallId?: unknown };
197
+ if (msg.role !== "toolResult") return null;
198
+ return typeof msg.toolCallId === "string" ? msg.toolCallId : null;
199
+ };
200
+
201
+ const getToolName = (entry: SessionEntry): string | null => {
202
+ if (entry.type !== "message") return null;
203
+ const msg = entry.message as { role?: string; toolName?: unknown };
204
+ if (msg.role !== "toolResult") return null;
205
+ return typeof msg.toolName === "string" ? msg.toolName : null;
206
+ };
207
+
208
+ const resolveToolCallArgsFromParents = (
209
+ entry: SessionEntry,
210
+ nodeById: Map<string, SessionTreeNode>,
211
+ ): Record<string, unknown> | null => {
212
+ const toolCallId = getToolCallId(entry);
213
+ if (!toolCallId) return null;
214
+
215
+ let parentId = entry.parentId;
216
+ for (let depth = 0; depth < MAX_PARENT_TRAVERSAL_DEPTH && parentId; depth += 1) {
217
+ const parentNode = nodeById.get(parentId);
218
+ if (!parentNode) return null;
219
+
220
+ const parentEntry = parentNode.entry;
221
+ if (parentEntry.type === "message") {
222
+ const parentMsg = parentEntry.message as { role?: string; content?: unknown };
223
+ if (parentMsg.role === "assistant" && Array.isArray(parentMsg.content)) {
224
+ const toolCall = parentMsg.content.find(
225
+ (c: any) => c && c.type === "toolCall" && c.id === toolCallId,
226
+ ) as { arguments?: unknown } | undefined;
227
+
228
+ if (toolCall && typeof toolCall.arguments === "object" && toolCall.arguments !== null) {
229
+ return toolCall.arguments as Record<string, unknown>;
230
+ }
231
+ }
232
+ }
233
+
234
+ parentId = parentEntry.parentId;
235
+ }
236
+
237
+ return null;
238
+ };
239
+
240
+ const resolveReadToolLanguageFromParents = (
241
+ entry: SessionEntry,
242
+ nodeById: Map<string, SessionTreeNode>,
243
+ ): string | undefined => {
244
+ if (getToolName(entry) !== "read") return undefined;
245
+
246
+ const args = resolveToolCallArgsFromParents(entry, nodeById);
247
+ if (!args) return undefined;
248
+
249
+ const rawPath = args["file_path"] ?? args["path"];
250
+ if (typeof rawPath !== "string" || !rawPath.trim()) return undefined;
251
+ return getLanguageFromPath(rawPath);
252
+ };
253
+
254
+ const renderPreviewBodyLines = (
255
+ text: string,
256
+ entry: SessionEntry,
257
+ width: number,
258
+ theme: any,
259
+ nodeById: Map<string, SessionTreeNode>,
260
+ ): string[] => {
261
+ if (entry.type === "message") {
262
+ const msg = entry.message as { role?: string; command?: string };
263
+
264
+ // Bash execution nodes: highlight the command itself
265
+ if (msg.role === "bashExecution" && typeof msg.command === "string") {
266
+ return highlightCode(replaceTabs(text), "bash").map((line) => truncateToWidth(line, width));
267
+ }
268
+
269
+ // Read tool results: use parent toolCall args to infer language from path, matching pi's own renderer
270
+ if (getToolName(entry) === "read") {
271
+ const normalized = replaceTabs(text);
272
+ const lang = resolveReadToolLanguageFromParents(entry, nodeById);
273
+
274
+ const lines = lang
275
+ ? highlightCode(normalized, lang)
276
+ : normalized.split("\n").map((line) => theme.fg("toolOutput", line));
277
+
278
+ return lines.map((line) => truncateToWidth(line, width));
279
+ }
280
+ }
281
+
282
+ // Everything else: render with pi's markdown renderer/theme (matches main UI)
283
+ const markdown = new Markdown(text, 0, 0, getMarkdownTheme());
284
+ return markdown.render(width);
285
+ };
286
+
287
+ const buildNodeMap = (roots: SessionTreeNode[]): Map<string, SessionTreeNode> => {
288
+ const map = new Map<string, SessionTreeNode>();
289
+ const stack = [...roots];
290
+ while (stack.length > 0) {
291
+ const node = stack.pop()!;
292
+ map.set(node.entry.id, node);
293
+ for (const child of node.children) stack.push(child);
294
+ }
295
+ return map;
296
+ };
297
+
298
+ /** Pre-order DFS index for chronological sorting of selected nodes */
299
+ const buildNodeOrder = (roots: SessionTreeNode[]): Map<string, number> => {
300
+ const order = new Map<string, number>();
301
+ let idx = 0;
302
+ const visit = (nodes: SessionTreeNode[]) => {
303
+ for (const node of nodes) {
304
+ order.set(node.entry.id, idx++);
305
+ visit(node.children);
306
+ }
307
+ };
308
+ visit(roots);
309
+ return order;
310
+ };
311
+
312
+ /** Clipboard text: role:\n\ncontent\n\n---\n\nrole:\n\ncontent */
313
+ const buildClipboardText = (nodes: SessionTreeNode[]): string => {
314
+ return nodes
315
+ .map((node) => {
316
+ const label = getEntryRoleLabel(node.entry);
317
+ const content = clipText(getEntryContent(node.entry));
318
+ return `${label}:\n\n${content}`;
319
+ })
320
+ .join("\n\n---\n\n");
321
+ };
322
+
323
+ class anycopyOverlay implements Focusable {
324
+ private selectedNodeIds = new Set<string>();
325
+ private flashMessage: string | null = null;
326
+ private flashTimer: ReturnType<typeof setTimeout> | null = null;
327
+ private _focused = false;
328
+ private previewScrollOffset = 0;
329
+ private lastPreviewHeight = 0;
330
+ private previewCache: {
331
+ entryId: string;
332
+ width: number;
333
+ bodyLines: string[];
334
+ truncatedToMaxLines: boolean;
335
+ } | null = null;
336
+
337
+ constructor(
338
+ private selector: TreeSelectorComponent,
339
+ private getTree: () => SessionTreeNode[],
340
+ private nodeById: Map<string, SessionTreeNode>,
341
+ private keys: anycopyKeyConfig,
342
+ private getTermHeight: () => number,
343
+ private requestRender: () => void,
344
+ private theme: any,
345
+ ) {}
346
+
347
+ get focused(): boolean {
348
+ return this._focused;
349
+ }
350
+ set focused(value: boolean) {
351
+ this._focused = value;
352
+ this.selector.focused = value;
353
+ }
354
+
355
+ getTreeList() {
356
+ return this.selector.getTreeList();
357
+ }
358
+
359
+ handleInput(data: string): void {
360
+ if (matchesKey(data, this.keys.toggleSelect)) {
361
+ this.toggleSelectedFocusedNode();
362
+ return;
363
+ }
364
+ if (matchesKey(data, this.keys.copy)) {
365
+ this.copySelectedOrFocusedNode();
366
+ return;
367
+ }
368
+ if (matchesKey(data, this.keys.clear)) {
369
+ this.clearSelection();
370
+ return;
371
+ }
372
+
373
+ if (matchesKey(data, this.keys.scrollDown)) {
374
+ this.previewScrollOffset += 1;
375
+ this.requestRender();
376
+ return;
377
+ }
378
+ if (matchesKey(data, this.keys.scrollUp)) {
379
+ this.previewScrollOffset -= 1;
380
+ this.requestRender();
381
+ return;
382
+ }
383
+ if (matchesKey(data, this.keys.pageDown)) {
384
+ const step = Math.max(1, (this.lastPreviewHeight > 0 ? this.lastPreviewHeight : 10) - 1);
385
+ this.previewScrollOffset += step;
386
+ this.requestRender();
387
+ return;
388
+ }
389
+ if (matchesKey(data, this.keys.pageUp)) {
390
+ const step = Math.max(1, (this.lastPreviewHeight > 0 ? this.lastPreviewHeight : 10) - 1);
391
+ this.previewScrollOffset -= step;
392
+ this.requestRender();
393
+ return;
394
+ }
395
+
396
+ this.selector.handleInput(data);
397
+ this.requestRender();
398
+ }
399
+
400
+ invalidate(): void {
401
+ // Preview is derived from focused entry + width; invalidate forces recompute
402
+ this.previewCache = null;
403
+ this.previewScrollOffset = 0;
404
+ this.lastPreviewHeight = 0;
405
+ this.selector.invalidate();
406
+ }
407
+
408
+ private getFocusedNode(): SessionTreeNode | undefined {
409
+ return this.selector.getTreeList().getSelectedNode();
410
+ }
411
+
412
+ private flash(message: string): void {
413
+ this.flashMessage = message;
414
+ if (this.flashTimer) clearTimeout(this.flashTimer);
415
+ this.flashTimer = setTimeout(() => {
416
+ this.flashMessage = null;
417
+ this.flashTimer = null;
418
+ this.requestRender();
419
+ }, FLASH_DURATION_MS);
420
+ this.requestRender();
421
+ }
422
+
423
+ toggleSelectedFocusedNode(): void {
424
+ const focused = this.getFocusedNode();
425
+ if (!focused) return;
426
+ const id = focused.entry.id;
427
+ if (this.selectedNodeIds.has(id)) {
428
+ this.selectedNodeIds.delete(id);
429
+ this.flash("Unselected node");
430
+ } else {
431
+ this.selectedNodeIds.add(id);
432
+ this.flash(`Selected (${this.selectedNodeIds.size} ${pluralizeNode(this.selectedNodeIds.size)})`);
433
+ }
434
+ }
435
+
436
+ clearSelection(): void {
437
+ if (this.selectedNodeIds.size === 0) {
438
+ this.flash("Selection already empty");
439
+ return;
440
+ }
441
+ this.selectedNodeIds.clear();
442
+ this.flash("Cleared selection");
443
+ }
444
+
445
+ isSelectedNode(id: string): boolean {
446
+ return this.selectedNodeIds.has(id);
447
+ }
448
+
449
+ copySelectedOrFocusedNode(): void {
450
+ const focused = this.getFocusedNode();
451
+ const ids =
452
+ this.selectedNodeIds.size > 0
453
+ ? [...this.selectedNodeIds]
454
+ : focused
455
+ ? [focused.entry.id]
456
+ : [];
457
+
458
+ if (ids.length === 0) {
459
+ this.flash("Nothing selected");
460
+ return;
461
+ }
462
+
463
+ const tree = this.getTree();
464
+ const nodeById = buildNodeMap(tree);
465
+ const nodeOrder = buildNodeOrder(tree);
466
+ const nodes = ids
467
+ .map((id) => nodeById.get(id))
468
+ .filter((n): n is SessionTreeNode => Boolean(n))
469
+ .sort((a, b) => {
470
+ const oa = nodeOrder.get(a.entry.id) ?? Infinity;
471
+ const ob = nodeOrder.get(b.entry.id) ?? Infinity;
472
+ return oa - ob;
473
+ });
474
+
475
+ copyToClipboard(buildClipboardText(nodes));
476
+ this.flash(`Copied ${nodes.length} ${pluralizeNode(nodes.length)} to clipboard`);
477
+ }
478
+
479
+ private renderStatusBar(width: number): string[] {
480
+ const lines: string[] = [];
481
+ lines.push(truncateToWidth(this.theme.fg("dim", "─".repeat(width)), width));
482
+
483
+ // Status only (selection count / flash)
484
+ if (this.flashMessage) {
485
+ lines.push(truncateToWidth(this.theme.fg("success", ` ${this.flashMessage}`), width));
486
+ } else if (this.selectedNodeIds.size > 0) {
487
+ lines.push(
488
+ truncateToWidth(
489
+ this.theme.fg(
490
+ "accent",
491
+ ` ${this.selectedNodeIds.size} selected ${pluralizeNode(this.selectedNodeIds.size)}`,
492
+ ),
493
+ width,
494
+ ),
495
+ );
496
+ } else {
497
+ lines.push("");
498
+ }
499
+
500
+ // Preview-scrolling hints belong above the preview pane
501
+ const previewHint =
502
+ ` ${formatKeyHint(this.keys.scrollUp)}/${formatKeyHint(this.keys.scrollDown)}: scroll` +
503
+ ` • ${formatKeyHint(this.keys.pageUp)}/${formatKeyHint(this.keys.pageDown)}: page`;
504
+ lines.push(truncateToWidth(this.theme.fg("dim", previewHint), width));
505
+
506
+ return lines;
507
+ }
508
+
509
+ private renderTreeHeaderHint(width: number): string {
510
+ const hint =
511
+ ` │ ${formatKeyHint(this.keys.toggleSelect)}: select` +
512
+ ` • ${formatKeyHint(this.keys.copy)}: copy` +
513
+ ` • ${formatKeyHint(this.keys.clear)}: clear` +
514
+ " • Esc: close";
515
+ return truncateToWidth(this.theme.fg("dim", hint), width);
516
+ }
517
+
518
+ private renderPreview(width: number, height: number): string[] {
519
+ if (height <= 0) return [];
520
+
521
+ this.lastPreviewHeight = height;
522
+
523
+ const focused = this.getFocusedNode();
524
+ const lines: string[] = [];
525
+ if (!focused) {
526
+ lines.push(truncateToWidth(this.theme.fg("dim", " (no node selected)"), width));
527
+ while (lines.length < height) lines.push("");
528
+ return lines;
529
+ }
530
+
531
+ const entryId = focused.entry.id;
532
+
533
+ let bodyLines: string[];
534
+ let truncatedToMaxLines: boolean;
535
+
536
+ if (this.previewCache && this.previewCache.entryId === entryId && this.previewCache.width === width) {
537
+ ({ bodyLines, truncatedToMaxLines } = this.previewCache);
538
+ } else {
539
+ const content = getEntryContent(focused.entry);
540
+ const clipped = clipText(content);
541
+ const rendered = renderPreviewBodyLines(clipped, focused.entry, width, this.theme, this.nodeById);
542
+
543
+ truncatedToMaxLines = rendered.length > MAX_PREVIEW_LINES;
544
+ bodyLines = rendered.slice(0, MAX_PREVIEW_LINES);
545
+
546
+ this.previewCache = { entryId, width, bodyLines, truncatedToMaxLines };
547
+ this.previewScrollOffset = 0;
548
+ }
549
+
550
+ // Clamp scroll offset based on available rendered lines
551
+ const maxOffset = Math.max(0, bodyLines.length - height);
552
+ this.previewScrollOffset = Math.max(0, Math.min(this.previewScrollOffset, maxOffset));
553
+
554
+ const start = this.previewScrollOffset;
555
+ const end = Math.min(bodyLines.length, start + height);
556
+ let visible = bodyLines.slice(start, end);
557
+
558
+ const above = start;
559
+ const below = bodyLines.length - end;
560
+
561
+ if (height > 0) {
562
+ if (above > 0) {
563
+ const indicator = truncateToWidth(this.theme.fg("muted", `… ${above} line(s) above`), width);
564
+ visible = height === 1 ? [indicator] : [indicator, ...visible.slice(0, height - 1)];
565
+ }
566
+
567
+ if (below > 0) {
568
+ const indicator = truncateToWidth(this.theme.fg("muted", `… ${below} more line(s)`), width);
569
+ visible = height === 1 ? [indicator] : [...visible.slice(0, height - 1), indicator];
570
+ } else if (truncatedToMaxLines) {
571
+ const indicator = truncateToWidth(
572
+ this.theme.fg("muted", `… [truncated to ${MAX_PREVIEW_LINES} lines]`),
573
+ width,
574
+ );
575
+ visible = height === 1 ? [indicator] : [...visible.slice(0, height - 1), indicator];
576
+ }
577
+ }
578
+
579
+ for (let i = 0; i < Math.min(height, visible.length); i += 1) {
580
+ lines.push(visible[i] ?? "");
581
+ }
582
+
583
+ while (lines.length < height) lines.push("");
584
+ return lines;
585
+ }
586
+
587
+ render(width: number): string[] {
588
+ const height = this.getTermHeight();
589
+ const output: string[] = [];
590
+
591
+ const selectorLines = this.selector.render(width);
592
+ const headerHint = this.renderTreeHeaderHint(width);
593
+
594
+ // Inject shortcut hint near the tree header (above the list)
595
+ const insertAfter = Math.max(0, selectorLines.findIndex((l) => l.includes("Type to search")));
596
+ if (selectorLines.length > 0) {
597
+ const idx = insertAfter >= 0 ? insertAfter + 1 : 1;
598
+ selectorLines.splice(Math.min(idx, selectorLines.length), 0, headerHint);
599
+ }
600
+
601
+ output.push(...selectorLines);
602
+ output.push(...this.renderStatusBar(width));
603
+
604
+ const previewHeight = Math.max(0, height - output.length);
605
+ if (previewHeight > 0) {
606
+ output.push(...this.renderPreview(width, previewHeight));
607
+ }
608
+
609
+ while (output.length < height) output.push("");
610
+ if (output.length > height) output.length = height;
611
+ return output;
612
+ }
613
+
614
+ dispose(): void {
615
+ if (this.flashTimer) {
616
+ clearTimeout(this.flashTimer);
617
+ this.flashTimer = null;
618
+ }
619
+ this.previewCache = null;
620
+ this.previewScrollOffset = 0;
621
+ this.lastPreviewHeight = 0;
622
+ this.nodeById.clear();
623
+ }
624
+ }
625
+
626
+ export default function anycopyExtension(pi: ExtensionAPI) {
627
+ const keys = loadConfig();
628
+
629
+ pi.registerCommand("anycopy", {
630
+ description: "Browse session tree with preview and copy any node(s) to clipboard",
631
+ handler: async (args, ctx: ExtensionCommandContext) => {
632
+ if (!ctx.hasUI) return;
633
+
634
+ const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
635
+ if (initialTree.length === 0) {
636
+ ctx.ui.notify("No entries in session", "warning");
637
+ return;
638
+ }
639
+
640
+ const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
641
+ const currentLeafId = ctx.sessionManager.getLeafId();
642
+
643
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
644
+ const termRows = tui.terminal?.rows ?? 40;
645
+ // Pass reduced height so tree takes ~35% of terminal (it uses floor(h/2) internally)
646
+ const treeTermHeight = Math.floor(termRows * 0.65);
647
+
648
+ const selector = new TreeSelectorComponent(
649
+ initialTree,
650
+ currentLeafId,
651
+ treeTermHeight,
652
+ () => {
653
+ // Intentionally ignore Enter: closing on Enter is counterintuitive here.
654
+ // Use Esc to close the overlay.
655
+ },
656
+ () => done(),
657
+ (entryId, label) => {
658
+ pi.setLabel(entryId, label);
659
+ },
660
+ );
661
+
662
+ // Build a node map once for parent traversal (toolResult → parent assistant toolCall args)
663
+ const nodeById = buildNodeMap(initialTree);
664
+
665
+ const overlay = new anycopyOverlay(
666
+ selector,
667
+ getTree,
668
+ nodeById,
669
+ keys,
670
+ () => tui.terminal?.rows ?? 40,
671
+ () => tui.requestRender(),
672
+ theme,
673
+ );
674
+
675
+ const treeList = selector.getTreeList();
676
+
677
+ // Monkey-patch render to inject checkbox markers (✓/○) into tree rows
678
+ const originalRender = treeList.render.bind(treeList);
679
+ treeList.render = (width: number) => {
680
+ const innerWidth = Math.max(10, width - 2);
681
+ const lines = originalRender(innerWidth);
682
+
683
+ const tl = treeList as any;
684
+ const filteredRaw = tl.filteredNodes;
685
+ if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
686
+ return lines.map((line: string) => " " + line);
687
+ }
688
+ const filtered = filteredRaw as { node: SessionTreeNode }[];
689
+
690
+ const selectedIdxRaw = tl.selectedIndex;
691
+ const maxVisibleRaw = tl.maxVisibleLines;
692
+ const selectedIdx =
693
+ typeof selectedIdxRaw === "number" && Number.isFinite(selectedIdxRaw) ? selectedIdxRaw : 0;
694
+ const maxVisible =
695
+ typeof maxVisibleRaw === "number" && Number.isFinite(maxVisibleRaw) && maxVisibleRaw > 0
696
+ ? maxVisibleRaw
697
+ : filtered.length;
698
+
699
+ const startIdx = Math.max(
700
+ 0,
701
+ Math.min(selectedIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
702
+ );
703
+ const treeRowCount = Math.max(0, lines.length - 1);
704
+
705
+ return lines.map((line: string, i: number) => {
706
+ if (i >= treeRowCount) return " " + line;
707
+
708
+ const nodeIdx = startIdx + i;
709
+ const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
710
+ const nodeId = node?.entry?.id;
711
+ if (typeof nodeId !== "string") return " " + line;
712
+
713
+ const selected = overlay.isSelectedNode(nodeId);
714
+ const marker = selected
715
+ ? theme.fg("success", "\u2713 ")
716
+ : theme.fg("dim", "\u25CB ");
717
+ return marker + line;
718
+ });
719
+ };
720
+
721
+ tui.setFocus?.(overlay);
722
+ return overlay;
723
+ });
724
+ },
725
+ });
726
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "pi-anycopy",
3
+ "version": "0.1.0",
4
+ "description": "Copy any single message, or multiple selected messages, from the session tree, with scrollable message preview",
5
+ "keywords": ["pi-package", "pi", "pi-coding-agent"],
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/w-winter/dot314.git",
10
+ "directory": "packages/pi-anycopy"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/w-winter/dot314/issues"
14
+ },
15
+ "homepage": "https://github.com/w-winter/dot314#readme",
16
+ "pi": {
17
+ "extensions": ["extensions/anycopy/index.ts"],
18
+ "image": "https://raw.githubusercontent.com/w-winter/dot314/main/assets/anycopy-demo.gif"
19
+ },
20
+ "peerDependencies": {
21
+ "@mariozechner/pi-coding-agent": "*",
22
+ "@mariozechner/pi-tui": "*"
23
+ },
24
+ "scripts": {
25
+ "prepack": "node ../../scripts/pi-package-prepack.mjs"
26
+ },
27
+ "files": ["extensions/**", "README.md", "LICENSE", "package.json"],
28
+ "dot314Prepack": {
29
+ "copy": [
30
+ { "from": "../../extensions/anycopy/index.ts", "to": "extensions/anycopy/index.ts" },
31
+ { "from": "../../extensions/anycopy/config.json", "to": "extensions/anycopy/config.json" },
32
+ { "from": "../../LICENSE", "to": "LICENSE" }
33
+ ],
34
+ "allowJson": ["extensions/anycopy/config.json"]
35
+ }
36
+ }