pi-anycopy 0.2.0 → 0.2.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/README.md +7 -2
- package/extensions/anycopy/config.json +1 -0
- package/extensions/anycopy/index.ts +105 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# anycopy for Pi (`pi-anycopy`)
|
|
2
2
|
|
|
3
|
-
This extension mirrors all the behaviors of Pi's native `/tree` while adding a live, syntax-highlighting preview of each node's content
|
|
3
|
+
This extension mirrors all the behaviors of Pi's native `/tree` while adding a live, syntax-highlighting preview of each node's content, the ability to copy any node(s) to the clipboard, and optional display of the timestamps of labeled nodes' last labelings.
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<img width="450" alt="anycopy demo" src="https://raw.githubusercontent.com/w-winter/dot314/main/assets/anycopy-demo.gif" />
|
|
@@ -51,6 +51,7 @@ Defaults (customizable in `config.json`):
|
|
|
51
51
|
| `Shift+C` | Copy selected nodes, or the focused node if nothing is selected |
|
|
52
52
|
| `Shift+X` | Clear selection |
|
|
53
53
|
| `Shift+L` | Label node (native tree behavior) |
|
|
54
|
+
| `Shift+T` | Toggle label timestamps for labeled nodes |
|
|
54
55
|
| `Shift+Up` / `Shift+Down` | Scroll node preview by line |
|
|
55
56
|
| `Shift+Left` / `Shift+Right` | Page through node preview |
|
|
56
57
|
| `Esc` | Close |
|
|
@@ -65,6 +66,9 @@ Notes:
|
|
|
65
66
|
- Single-node copies use just that node's content; role prefixes like `user:` or `assistant:` are only added when copying 2 or more nodes
|
|
66
67
|
- When copying multiple selected nodes, they are auto-sorted chronologically by position in the session tree, not by selection order
|
|
67
68
|
- Space/`Shift+C` multi-select copy behavior is unchanged by navigation support
|
|
69
|
+
- `Shift+T` is configurable via `keys.toggleLabelTimestamps` in `config.json`
|
|
70
|
+
- `Shift+T` shows timestamps for labeled nodes only, using the latest label-change time for each label
|
|
71
|
+
- Same-day labels show `HH:MM`; older labels show `M/D HH:MM`; cross-year labels show `YY/M/D HH:MM`
|
|
68
72
|
- Label edits are persisted via `pi.setLabel(...)`
|
|
69
73
|
|
|
70
74
|
## Configuration
|
|
@@ -73,7 +77,7 @@ Edit `~/.pi/agent/extensions/anycopy/config.json`:
|
|
|
73
77
|
|
|
74
78
|
- `treeFilterMode`: initial tree filter mode when opening `/anycopy`; defaults to `default` to match `/tree`
|
|
75
79
|
- one of: `default` | `no-tools` | `user-only` | `labeled-only` | `all`
|
|
76
|
-
- `keys`: keybindings used inside the `/anycopy` overlay for copy/preview actions
|
|
80
|
+
- `keys`: keybindings used inside the `/anycopy` overlay for copy/preview actions, including the label timestamp toggle
|
|
77
81
|
|
|
78
82
|
```json
|
|
79
83
|
{
|
|
@@ -82,6 +86,7 @@ Edit `~/.pi/agent/extensions/anycopy/config.json`:
|
|
|
82
86
|
"toggleSelect": "space",
|
|
83
87
|
"copy": "shift+c",
|
|
84
88
|
"clear": "shift+x",
|
|
89
|
+
"toggleLabelTimestamps": "shift+t",
|
|
85
90
|
"scrollUp": "shift+up",
|
|
86
91
|
"scrollDown": "shift+down",
|
|
87
92
|
"pageUp": "shift+left",
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Space - select/unselect focused node for copy
|
|
8
8
|
* Shift+C - copy selected nodes (or focused node if none selected)
|
|
9
9
|
* Shift+X - clear selection
|
|
10
|
-
* Shift+L - label node
|
|
10
|
+
* Shift+L - label node
|
|
11
|
+
* Shift+T - toggle label timestamps for labeled nodes
|
|
11
12
|
* Shift+↑/↓ - scroll preview
|
|
12
13
|
* Shift+←/→ - page preview
|
|
13
14
|
* Esc - close
|
|
@@ -36,12 +37,14 @@ type SessionTreeNode = {
|
|
|
36
37
|
entry: SessionEntry;
|
|
37
38
|
children: SessionTreeNode[];
|
|
38
39
|
label?: string;
|
|
40
|
+
labelTimestamp?: string;
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
type anycopyKeyConfig = {
|
|
42
44
|
toggleSelect: string;
|
|
43
45
|
copy: string;
|
|
44
46
|
clear: string;
|
|
47
|
+
toggleLabelTimestamps: string;
|
|
45
48
|
scrollDown: string;
|
|
46
49
|
scrollUp: string;
|
|
47
50
|
pageDown: string;
|
|
@@ -70,6 +73,7 @@ const DEFAULT_KEYS: anycopyKeyConfig = {
|
|
|
70
73
|
toggleSelect: "space",
|
|
71
74
|
copy: "shift+c",
|
|
72
75
|
clear: "shift+x",
|
|
76
|
+
toggleLabelTimestamps: "shift+t",
|
|
73
77
|
scrollDown: "shift+down",
|
|
74
78
|
scrollUp: "shift+up",
|
|
75
79
|
pageDown: "shift+right",
|
|
@@ -131,6 +135,10 @@ const loadConfig = (): anycopyRuntimeConfig => {
|
|
|
131
135
|
toggleSelect: typeof keys.toggleSelect === "string" ? keys.toggleSelect : DEFAULT_KEYS.toggleSelect,
|
|
132
136
|
copy: typeof keys.copy === "string" ? keys.copy : DEFAULT_KEYS.copy,
|
|
133
137
|
clear: typeof keys.clear === "string" ? keys.clear : DEFAULT_KEYS.clear,
|
|
138
|
+
toggleLabelTimestamps:
|
|
139
|
+
typeof keys.toggleLabelTimestamps === "string"
|
|
140
|
+
? keys.toggleLabelTimestamps
|
|
141
|
+
: DEFAULT_KEYS.toggleLabelTimestamps,
|
|
134
142
|
scrollDown: typeof keys.scrollDown === "string" ? keys.scrollDown : DEFAULT_KEYS.scrollDown,
|
|
135
143
|
scrollUp: typeof keys.scrollUp === "string" ? keys.scrollUp : DEFAULT_KEYS.scrollUp,
|
|
136
144
|
pageDown: typeof keys.pageDown === "string" ? keys.pageDown : DEFAULT_KEYS.pageDown,
|
|
@@ -368,6 +376,66 @@ const buildNodeOrder = (roots: SessionTreeNode[]): Map<string, number> => {
|
|
|
368
376
|
return order;
|
|
369
377
|
};
|
|
370
378
|
|
|
379
|
+
const buildLatestLabelTimestamps = (entries: SessionEntry[]): Map<string, string> => {
|
|
380
|
+
const timestampsById = new Map<string, string>();
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
if (entry.type !== "label") continue;
|
|
383
|
+
if (entry.label) {
|
|
384
|
+
timestampsById.set(entry.targetId, entry.timestamp);
|
|
385
|
+
} else {
|
|
386
|
+
timestampsById.delete(entry.targetId);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return timestampsById;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const applyLabelTimestampsToTree = (roots: SessionTreeNode[], timestampsById: Map<string, string>): void => {
|
|
393
|
+
const stack = [...roots];
|
|
394
|
+
while (stack.length > 0) {
|
|
395
|
+
const node = stack.pop()!;
|
|
396
|
+
node.labelTimestamp = timestampsById.get(node.entry.id);
|
|
397
|
+
for (const child of node.children) stack.push(child);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const formatLabelTimestamp = (timestamp: string): string => {
|
|
402
|
+
const date = new Date(timestamp);
|
|
403
|
+
if (Number.isNaN(date.getTime())) return timestamp;
|
|
404
|
+
|
|
405
|
+
const now = new Date();
|
|
406
|
+
const hours = date.getHours().toString().padStart(2, "0");
|
|
407
|
+
const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
408
|
+
const time = `${hours}:${minutes}`;
|
|
409
|
+
|
|
410
|
+
if (
|
|
411
|
+
date.getFullYear() === now.getFullYear() &&
|
|
412
|
+
date.getMonth() === now.getMonth() &&
|
|
413
|
+
date.getDate() === now.getDate()
|
|
414
|
+
) {
|
|
415
|
+
return time;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const month = date.getMonth() + 1;
|
|
419
|
+
const day = date.getDate();
|
|
420
|
+
if (date.getFullYear() === now.getFullYear()) {
|
|
421
|
+
return `${month}/${day} ${time}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const year = date.getFullYear().toString().slice(-2);
|
|
425
|
+
return `${year}/${month}/${day} ${time}`;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const insertLabelTimestampIntoLine = (line: string, node: SessionTreeNode, theme: any): string => {
|
|
429
|
+
if (!node.label || !node.labelTimestamp) return line;
|
|
430
|
+
|
|
431
|
+
const labelToken = `[${node.label}] `;
|
|
432
|
+
const labelIndex = line.indexOf(labelToken);
|
|
433
|
+
if (labelIndex === -1) return line;
|
|
434
|
+
|
|
435
|
+
const insertAt = labelIndex + labelToken.length;
|
|
436
|
+
return `${line.slice(0, insertAt)}${theme.fg("muted", `${formatLabelTimestamp(node.labelTimestamp)} `)}${line.slice(insertAt)}`;
|
|
437
|
+
};
|
|
438
|
+
|
|
371
439
|
/** Clipboard text omits role prefix for a single node and includes it for multi-node copies
|
|
372
440
|
* The preview pane is truncated for performance, while the clipboard copy is not
|
|
373
441
|
*/
|
|
@@ -392,6 +460,7 @@ class anycopyOverlay implements Focusable {
|
|
|
392
460
|
private _focused = false;
|
|
393
461
|
private previewScrollOffset = 0;
|
|
394
462
|
private lastPreviewHeight = 0;
|
|
463
|
+
private showLabelTimestamps = false;
|
|
395
464
|
private previewCache: {
|
|
396
465
|
entryId: string;
|
|
397
466
|
width: number;
|
|
@@ -441,6 +510,11 @@ class anycopyOverlay implements Focusable {
|
|
|
441
510
|
this.clearSelection();
|
|
442
511
|
return;
|
|
443
512
|
}
|
|
513
|
+
if (matchesKey(data, this.keys.toggleLabelTimestamps)) {
|
|
514
|
+
this.showLabelTimestamps = !this.showLabelTimestamps;
|
|
515
|
+
this.requestRender();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
444
518
|
|
|
445
519
|
if (matchesKey(data, this.keys.scrollDown)) {
|
|
446
520
|
this.previewScrollOffset += 1;
|
|
@@ -522,6 +596,10 @@ class anycopyOverlay implements Focusable {
|
|
|
522
596
|
return this.selectedNodeIds.has(id);
|
|
523
597
|
}
|
|
524
598
|
|
|
599
|
+
isShowingLabelTimestamps(): boolean {
|
|
600
|
+
return this.showLabelTimestamps;
|
|
601
|
+
}
|
|
602
|
+
|
|
525
603
|
copySelectedOrFocusedNode(): void {
|
|
526
604
|
const focused = this.getFocusedNode();
|
|
527
605
|
const ids =
|
|
@@ -588,6 +666,7 @@ class anycopyOverlay implements Focusable {
|
|
|
588
666
|
` • ${formatKeyHint(this.keys.toggleSelect)}: select` +
|
|
589
667
|
` • ${formatKeyHint(this.keys.copy)}: copy` +
|
|
590
668
|
` • ${formatKeyHint(this.keys.clear)}: clear` +
|
|
669
|
+
` • ${formatKeyHint(this.keys.toggleLabelTimestamps)}: label time` +
|
|
591
670
|
` • Esc: close`;
|
|
592
671
|
return truncateToWidth(this.theme.fg("dim", hint), width);
|
|
593
672
|
}
|
|
@@ -711,13 +790,22 @@ export default function anycopyExtension(pi: ExtensionAPI) {
|
|
|
711
790
|
) => {
|
|
712
791
|
if (!ctx.hasUI) return;
|
|
713
792
|
|
|
714
|
-
const
|
|
793
|
+
const buildAnnotatedTree = (): SessionTreeNode[] => {
|
|
794
|
+
const tree = ctx.sessionManager.getTree() as SessionTreeNode[];
|
|
795
|
+
applyLabelTimestampsToTree(tree, buildLatestLabelTimestamps(ctx.sessionManager.getEntries() as SessionEntry[]));
|
|
796
|
+
return tree;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const initialTree = buildAnnotatedTree();
|
|
715
800
|
if (initialTree.length === 0) {
|
|
716
801
|
ctx.ui.notify("No entries in session", "warning");
|
|
717
802
|
return;
|
|
718
803
|
}
|
|
719
804
|
|
|
720
|
-
const getTree = () =>
|
|
805
|
+
const getTree = () => buildAnnotatedTree();
|
|
806
|
+
const refreshInitialTreeLabelTimestamps = () => {
|
|
807
|
+
applyLabelTimestampsToTree(initialTree, buildLatestLabelTimestamps(ctx.sessionManager.getEntries() as SessionEntry[]));
|
|
808
|
+
};
|
|
721
809
|
const currentLeafId = ctx.sessionManager.getLeafId();
|
|
722
810
|
const skipSummaryPrompt = loadBranchSummarySkipPrompt(ctx.cwd);
|
|
723
811
|
|
|
@@ -751,6 +839,7 @@ export default function anycopyExtension(pi: ExtensionAPI) {
|
|
|
751
839
|
() => done(),
|
|
752
840
|
(entryId, label) => {
|
|
753
841
|
pi.setLabel(entryId, label);
|
|
842
|
+
refreshInitialTreeLabelTimestamps();
|
|
754
843
|
},
|
|
755
844
|
opts?.initialSelectedId,
|
|
756
845
|
treeFilterMode,
|
|
@@ -777,8 +866,15 @@ export default function anycopyExtension(pi: ExtensionAPI) {
|
|
|
777
866
|
|
|
778
867
|
const tl = treeList as any;
|
|
779
868
|
const filteredRaw = tl.filteredNodes;
|
|
869
|
+
const decorateStatusLine = (line: string): string => {
|
|
870
|
+
const status = overlay.isShowingLabelTimestamps() ? `${line}${theme.fg("muted", " [+label time]")}` : line;
|
|
871
|
+
return truncateToWidth(` ${status}`, width);
|
|
872
|
+
};
|
|
873
|
+
|
|
780
874
|
if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
|
|
781
|
-
return lines.map((line: string) =>
|
|
875
|
+
return lines.map((line: string, i: number) =>
|
|
876
|
+
i === lines.length - 1 ? decorateStatusLine(line) : truncateToWidth(` ${line}`, width),
|
|
877
|
+
);
|
|
782
878
|
}
|
|
783
879
|
const filtered = filteredRaw as { node: SessionTreeNode }[];
|
|
784
880
|
|
|
@@ -798,16 +894,18 @@ export default function anycopyExtension(pi: ExtensionAPI) {
|
|
|
798
894
|
const treeRowCount = Math.max(0, lines.length - 1);
|
|
799
895
|
|
|
800
896
|
return lines.map((line: string, i: number) => {
|
|
801
|
-
if (i >= treeRowCount) return
|
|
897
|
+
if (i >= treeRowCount) return decorateStatusLine(line);
|
|
802
898
|
|
|
803
899
|
const nodeIdx = startIdx + i;
|
|
804
900
|
const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
|
|
805
901
|
const nodeId = node?.entry?.id;
|
|
806
|
-
if (typeof nodeId !== "string") return
|
|
902
|
+
if (typeof nodeId !== "string") return truncateToWidth(` ${line}`, width);
|
|
807
903
|
|
|
808
904
|
const selected = overlay.isSelectedNode(nodeId);
|
|
809
905
|
const marker = selected ? theme.fg("success", "✓ ") : theme.fg("dim", "○ ");
|
|
810
|
-
|
|
906
|
+
const lineWithTimestamp =
|
|
907
|
+
overlay.isShowingLabelTimestamps() && node ? insertLabelTimestampIntoLine(line, node, theme) : line;
|
|
908
|
+
return truncateToWidth(marker + lineWithTimestamp, width);
|
|
811
909
|
});
|
|
812
910
|
};
|
|
813
911
|
|
package/package.json
CHANGED