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 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 and the ability to copy any node(s) to the clipboard.
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",
@@ -4,6 +4,7 @@
4
4
  "toggleSelect": "space",
5
5
  "copy": "shift+c",
6
6
  "clear": "shift+x",
7
+ "toggleLabelTimestamps": "shift+t",
7
8
  "scrollUp": "shift+up",
8
9
  "scrollDown": "shift+down",
9
10
  "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 (native tree behavior)
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 initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
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 = () => ctx.sessionManager.getTree() as SessionTreeNode[];
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) => " " + line);
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 " " + line;
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 " " + line;
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
- return marker + line;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anycopy",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pi's /tree with a live syntax-highlighted preview and ability to copy any node(s) to the clipboard",
5
5
  "keywords": ["pi-package", "pi", "pi-coding-agent"],
6
6
  "license": "MIT",