pi-anycopy 0.2.0 → 0.2.2

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" />
@@ -47,10 +47,11 @@ Defaults (customizable in `config.json`):
47
47
  | Key | Action |
48
48
  |-----|--------|
49
49
  | `Enter` | Navigate to the focused node (same semantics as `/tree`) |
50
- | `Space` | Select/unselect focused node for copy |
50
+ | `Shift+A` | Select/unselect focused node for copy |
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 |
@@ -64,8 +65,13 @@ Notes:
64
65
  - If no nodes are selected, `Shift+C` copies the focused node
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
- - Space/`Shift+C` multi-select copy behavior is unchanged by navigation support
68
+ - `Shift+A`/`Shift+C` multi-select copy behavior is unchanged by navigation support, while plain space remains available for search queries
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(...)`
73
+ - [Folded](https://github.com/badlogic/pi-mono/blob/09e9de5749193beab234f30ed220a77f3d91cfad/packages/coding-agent/docs/tree.md#controls) branches are persisted by default in hidden `/anycopy` session entries, so closing/reopening `/anycopy`, switching to a sibling branch, or revisiting the session later restores the same folded view until you explicitly unfold it again
74
+ - Search and filter changes still reset the live overlay's fold state temporarily; reopening `/anycopy` restores the persisted folded branches
69
75
 
70
76
  ## Configuration
71
77
 
@@ -73,15 +79,18 @@ Edit `~/.pi/agent/extensions/anycopy/config.json`:
73
79
 
74
80
  - `treeFilterMode`: initial tree filter mode when opening `/anycopy`; defaults to `default` to match `/tree`
75
81
  - one of: `default` | `no-tools` | `user-only` | `labeled-only` | `all`
76
- - `keys`: keybindings used inside the `/anycopy` overlay for copy/preview actions
82
+ - `persistFoldState`: whether `/anycopy` persists folded branches across reopenings and later sessions; defaults to `true`; when disabled, `/anycopy` does not read or write hidden fold-state session entries
83
+ - `keys`: keybindings used inside the `/anycopy` overlay for copy/preview actions, including the label timestamp toggle
77
84
 
78
85
  ```json
79
86
  {
80
87
  "treeFilterMode": "default",
88
+ "persistFoldState": true,
81
89
  "keys": {
82
- "toggleSelect": "space",
90
+ "toggleSelect": "shift+a",
83
91
  "copy": "shift+c",
84
92
  "clear": "shift+x",
93
+ "toggleLabelTimestamps": "shift+t",
85
94
  "scrollUp": "shift+up",
86
95
  "scrollDown": "shift+down",
87
96
  "pageUp": "shift+left",
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "treeFilterMode": "no-tools",
3
+ "persistFoldState": true,
3
4
  "keys": {
4
- "toggleSelect": "space",
5
+ "toggleSelect": "shift+a",
5
6
  "copy": "shift+c",
6
7
  "clear": "shift+x",
8
+ "toggleLabelTimestamps": "shift+t",
7
9
  "scrollUp": "shift+up",
8
10
  "scrollDown": "shift+down",
9
11
  "pageUp": "shift+left",
@@ -4,10 +4,11 @@
4
4
  * Layout: native TreeSelectorComponent at top, status bar, preview below
5
5
  *
6
6
  * Default keys (customizable via ./config.json):
7
- * Space - select/unselect focused node for copy
7
+ * Shift+A - 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
@@ -22,7 +23,7 @@ import {
22
23
  TreeSelectorComponent,
23
24
  } from "@mariozechner/pi-coding-agent";
24
25
 
25
- import { Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
26
+ import { getKeybindings, Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
26
27
  import type { Focusable } from "@mariozechner/pi-tui";
27
28
 
28
29
  import { existsSync, readFileSync } from "fs";
@@ -31,17 +32,29 @@ import { dirname, join } from "path";
31
32
  import { fileURLToPath } from "url";
32
33
 
33
34
  import { runAnycopyEnterNavigation } from "./enter-navigation.ts";
35
+ import {
36
+ ANYCOPY_FOLD_STATE_CUSTOM_TYPE,
37
+ createFoldStateEntryData,
38
+ foldStateNodeIdListsEqual,
39
+ getSelectorFoldedNodeIds,
40
+ loadLatestFoldStateFromEntries,
41
+ mergeExplicitFoldMutation,
42
+ normalizeFoldedNodeIds,
43
+ setSelectorFoldedNodeIds,
44
+ } from "./fold-state.ts";
34
45
 
35
46
  type SessionTreeNode = {
36
47
  entry: SessionEntry;
37
48
  children: SessionTreeNode[];
38
49
  label?: string;
50
+ labelTimestamp?: string;
39
51
  };
40
52
 
41
53
  type anycopyKeyConfig = {
42
54
  toggleSelect: string;
43
55
  copy: string;
44
56
  clear: string;
57
+ toggleLabelTimestamps: string;
45
58
  scrollDown: string;
46
59
  scrollUp: string;
47
60
  pageDown: string;
@@ -53,11 +66,13 @@ type TreeFilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "a
53
66
  type anycopyConfig = {
54
67
  keys?: Partial<anycopyKeyConfig>;
55
68
  treeFilterMode?: TreeFilterMode;
69
+ persistFoldState?: boolean;
56
70
  };
57
71
 
58
72
  type anycopyRuntimeConfig = {
59
73
  keys: anycopyKeyConfig;
60
74
  treeFilterMode: TreeFilterMode;
75
+ persistFoldState: boolean;
61
76
  };
62
77
 
63
78
  type BranchSummarySettingsFile = {
@@ -67,9 +82,10 @@ type BranchSummarySettingsFile = {
67
82
  };
68
83
 
69
84
  const DEFAULT_KEYS: anycopyKeyConfig = {
70
- toggleSelect: "space",
85
+ toggleSelect: "shift+a",
71
86
  copy: "shift+c",
72
87
  clear: "shift+x",
88
+ toggleLabelTimestamps: "shift+t",
73
89
  scrollDown: "shift+down",
74
90
  scrollUp: "shift+up",
75
91
  pageDown: "shift+right",
@@ -77,6 +93,7 @@ const DEFAULT_KEYS: anycopyKeyConfig = {
77
93
  };
78
94
 
79
95
  const DEFAULT_TREE_FILTER_MODE: TreeFilterMode = "default";
96
+ const DEFAULT_PERSIST_FOLD_STATE = true;
80
97
 
81
98
  const getExtensionDir = (): string => {
82
99
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -112,6 +129,7 @@ const loadConfig = (): anycopyRuntimeConfig => {
112
129
  return {
113
130
  keys: { ...DEFAULT_KEYS },
114
131
  treeFilterMode: DEFAULT_TREE_FILTER_MODE,
132
+ persistFoldState: DEFAULT_PERSIST_FOLD_STATE,
115
133
  };
116
134
  }
117
135
 
@@ -125,23 +143,31 @@ const loadConfig = (): anycopyRuntimeConfig => {
125
143
  typeof treeFilterModeRaw === "string" && validTreeFilterModes.includes(treeFilterModeRaw as TreeFilterMode)
126
144
  ? (treeFilterModeRaw as TreeFilterMode)
127
145
  : DEFAULT_TREE_FILTER_MODE;
146
+ const persistFoldState =
147
+ typeof parsed.persistFoldState === "boolean" ? parsed.persistFoldState : DEFAULT_PERSIST_FOLD_STATE;
128
148
 
129
149
  return {
130
150
  keys: {
131
151
  toggleSelect: typeof keys.toggleSelect === "string" ? keys.toggleSelect : DEFAULT_KEYS.toggleSelect,
132
152
  copy: typeof keys.copy === "string" ? keys.copy : DEFAULT_KEYS.copy,
133
153
  clear: typeof keys.clear === "string" ? keys.clear : DEFAULT_KEYS.clear,
154
+ toggleLabelTimestamps:
155
+ typeof keys.toggleLabelTimestamps === "string"
156
+ ? keys.toggleLabelTimestamps
157
+ : DEFAULT_KEYS.toggleLabelTimestamps,
134
158
  scrollDown: typeof keys.scrollDown === "string" ? keys.scrollDown : DEFAULT_KEYS.scrollDown,
135
159
  scrollUp: typeof keys.scrollUp === "string" ? keys.scrollUp : DEFAULT_KEYS.scrollUp,
136
160
  pageDown: typeof keys.pageDown === "string" ? keys.pageDown : DEFAULT_KEYS.pageDown,
137
161
  pageUp: typeof keys.pageUp === "string" ? keys.pageUp : DEFAULT_KEYS.pageUp,
138
162
  },
139
163
  treeFilterMode,
164
+ persistFoldState,
140
165
  };
141
166
  } catch {
142
167
  return {
143
168
  keys: { ...DEFAULT_KEYS },
144
169
  treeFilterMode: DEFAULT_TREE_FILTER_MODE,
170
+ persistFoldState: DEFAULT_PERSIST_FOLD_STATE,
145
171
  };
146
172
  }
147
173
  };
@@ -368,6 +394,66 @@ const buildNodeOrder = (roots: SessionTreeNode[]): Map<string, number> => {
368
394
  return order;
369
395
  };
370
396
 
397
+ const buildLatestLabelTimestamps = (entries: SessionEntry[]): Map<string, string> => {
398
+ const timestampsById = new Map<string, string>();
399
+ for (const entry of entries) {
400
+ if (entry.type !== "label") continue;
401
+ if (entry.label) {
402
+ timestampsById.set(entry.targetId, entry.timestamp);
403
+ } else {
404
+ timestampsById.delete(entry.targetId);
405
+ }
406
+ }
407
+ return timestampsById;
408
+ };
409
+
410
+ const applyLabelTimestampsToTree = (roots: SessionTreeNode[], timestampsById: Map<string, string>): void => {
411
+ const stack = [...roots];
412
+ while (stack.length > 0) {
413
+ const node = stack.pop()!;
414
+ node.labelTimestamp = timestampsById.get(node.entry.id);
415
+ for (const child of node.children) stack.push(child);
416
+ }
417
+ };
418
+
419
+ const formatLabelTimestamp = (timestamp: string): string => {
420
+ const date = new Date(timestamp);
421
+ if (Number.isNaN(date.getTime())) return timestamp;
422
+
423
+ const now = new Date();
424
+ const hours = date.getHours().toString().padStart(2, "0");
425
+ const minutes = date.getMinutes().toString().padStart(2, "0");
426
+ const time = `${hours}:${minutes}`;
427
+
428
+ if (
429
+ date.getFullYear() === now.getFullYear() &&
430
+ date.getMonth() === now.getMonth() &&
431
+ date.getDate() === now.getDate()
432
+ ) {
433
+ return time;
434
+ }
435
+
436
+ const month = date.getMonth() + 1;
437
+ const day = date.getDate();
438
+ if (date.getFullYear() === now.getFullYear()) {
439
+ return `${month}/${day} ${time}`;
440
+ }
441
+
442
+ const year = date.getFullYear().toString().slice(-2);
443
+ return `${year}/${month}/${day} ${time}`;
444
+ };
445
+
446
+ const insertLabelTimestampIntoLine = (line: string, node: SessionTreeNode, theme: any): string => {
447
+ if (!node.label || !node.labelTimestamp) return line;
448
+
449
+ const labelToken = `[${node.label}] `;
450
+ const labelIndex = line.indexOf(labelToken);
451
+ if (labelIndex === -1) return line;
452
+
453
+ const insertAt = labelIndex + labelToken.length;
454
+ return `${line.slice(0, insertAt)}${theme.fg("muted", `${formatLabelTimestamp(node.labelTimestamp)} `)}${line.slice(insertAt)}`;
455
+ };
456
+
371
457
  /** Clipboard text omits role prefix for a single node and includes it for multi-node copies
372
458
  * The preview pane is truncated for performance, while the clipboard copy is not
373
459
  */
@@ -392,6 +478,7 @@ class anycopyOverlay implements Focusable {
392
478
  private _focused = false;
393
479
  private previewScrollOffset = 0;
394
480
  private lastPreviewHeight = 0;
481
+ private showLabelTimestamps = false;
395
482
  private previewCache: {
396
483
  entryId: string;
397
484
  width: number;
@@ -404,6 +491,10 @@ class anycopyOverlay implements Focusable {
404
491
  private getTree: () => SessionTreeNode[],
405
492
  private nodeById: Map<string, SessionTreeNode>,
406
493
  private keys: anycopyKeyConfig,
494
+ private onExplicitFoldMutation: ((
495
+ beforeTransientFoldedNodeIds: string[],
496
+ afterTransientFoldedNodeIds: string[],
497
+ ) => void) | null,
407
498
  private onClose: () => void,
408
499
  private getTermHeight: () => number,
409
500
  private requestRender: () => void,
@@ -441,6 +532,11 @@ class anycopyOverlay implements Focusable {
441
532
  this.clearSelection();
442
533
  return;
443
534
  }
535
+ if (matchesKey(data, this.keys.toggleLabelTimestamps)) {
536
+ this.showLabelTimestamps = !this.showLabelTimestamps;
537
+ this.requestRender();
538
+ return;
539
+ }
444
540
 
445
541
  if (matchesKey(data, this.keys.scrollDown)) {
446
542
  this.previewScrollOffset += 1;
@@ -465,7 +561,18 @@ class anycopyOverlay implements Focusable {
465
561
  return;
466
562
  }
467
563
 
564
+ const keybindings = getKeybindings();
565
+ const shouldTrackExplicitFoldMutation =
566
+ this.onExplicitFoldMutation !== null &&
567
+ (keybindings.matches(data, "app.tree.foldOrUp") || keybindings.matches(data, "app.tree.unfoldOrDown"));
568
+ const beforeTransientFoldedNodeIds = shouldTrackExplicitFoldMutation ? getSelectorFoldedNodeIds(this.selector) : null;
569
+
468
570
  this.selector.handleInput(data);
571
+
572
+ if (beforeTransientFoldedNodeIds) {
573
+ this.onExplicitFoldMutation?.(beforeTransientFoldedNodeIds, getSelectorFoldedNodeIds(this.selector));
574
+ }
575
+
469
576
  this.requestRender();
470
577
  }
471
578
 
@@ -522,6 +629,10 @@ class anycopyOverlay implements Focusable {
522
629
  return this.selectedNodeIds.has(id);
523
630
  }
524
631
 
632
+ isShowingLabelTimestamps(): boolean {
633
+ return this.showLabelTimestamps;
634
+ }
635
+
525
636
  copySelectedOrFocusedNode(): void {
526
637
  const focused = this.getFocusedNode();
527
638
  const ids =
@@ -588,6 +699,7 @@ class anycopyOverlay implements Focusable {
588
699
  ` • ${formatKeyHint(this.keys.toggleSelect)}: select` +
589
700
  ` • ${formatKeyHint(this.keys.copy)}: copy` +
590
701
  ` • ${formatKeyHint(this.keys.clear)}: clear` +
702
+ ` • ${formatKeyHint(this.keys.toggleLabelTimestamps)}: label time` +
591
703
  ` • Esc: close`;
592
704
  return truncateToWidth(this.theme.fg("dim", hint), width);
593
705
  }
@@ -704,6 +816,7 @@ export default function anycopyExtension(pi: ExtensionAPI) {
704
816
  const config = loadConfig();
705
817
  const keys = config.keys;
706
818
  const treeFilterMode = config.treeFilterMode;
819
+ const persistFoldState = config.persistFoldState;
707
820
 
708
821
  const openAnycopy = async (
709
822
  ctx: ExtensionCommandContext,
@@ -711,19 +824,35 @@ export default function anycopyExtension(pi: ExtensionAPI) {
711
824
  ) => {
712
825
  if (!ctx.hasUI) return;
713
826
 
714
- const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
827
+ const buildAnnotatedTree = (): SessionTreeNode[] => {
828
+ const tree = ctx.sessionManager.getTree() as SessionTreeNode[];
829
+ applyLabelTimestampsToTree(tree, buildLatestLabelTimestamps(ctx.sessionManager.getEntries() as SessionEntry[]));
830
+ return tree;
831
+ };
832
+
833
+ const initialTree = buildAnnotatedTree();
715
834
  if (initialTree.length === 0) {
716
835
  ctx.ui.notify("No entries in session", "warning");
717
836
  return;
718
837
  }
719
838
 
720
- const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
839
+ const getTree = () => buildAnnotatedTree();
840
+ const refreshInitialTreeLabelTimestamps = () => {
841
+ applyLabelTimestampsToTree(initialTree, buildLatestLabelTimestamps(ctx.sessionManager.getEntries() as SessionEntry[]));
842
+ };
721
843
  const currentLeafId = ctx.sessionManager.getLeafId();
722
844
  const skipSummaryPrompt = loadBranchSummarySkipPrompt(ctx.cwd);
723
845
 
724
846
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
725
847
  const termRows = tui.terminal?.rows ?? 40;
726
848
  const treeTermHeight = Math.floor(termRows * 0.65);
849
+ const nodeById = buildNodeMap(initialTree);
850
+ const validNodeIds = new Set(nodeById.keys());
851
+ const restoredFoldState = persistFoldState
852
+ ? loadLatestFoldStateFromEntries(ctx.sessionManager.getEntries() as SessionEntry[], validNodeIds)
853
+ : null;
854
+ let durableFoldedNodeIds = restoredFoldState?.foldedNodeIds ?? [];
855
+ let lastPersistedFoldedNodeIds = durableFoldedNodeIds;
727
856
 
728
857
  const selector = new TreeSelectorComponent(
729
858
  initialTree,
@@ -751,18 +880,65 @@ export default function anycopyExtension(pi: ExtensionAPI) {
751
880
  () => done(),
752
881
  (entryId, label) => {
753
882
  pi.setLabel(entryId, label);
883
+ refreshInitialTreeLabelTimestamps();
754
884
  },
755
885
  opts?.initialSelectedId,
756
886
  treeFilterMode,
757
887
  );
758
- const effectiveLeafIdForNoop = selector.getTreeList().getSelectedNode()?.entry.id ?? currentLeafId;
759
888
 
760
- const nodeById = buildNodeMap(initialTree);
889
+ if (persistFoldState) {
890
+ const restoredFoldedNodeIds = normalizeFoldedNodeIds(
891
+ setSelectorFoldedNodeIds(selector, durableFoldedNodeIds),
892
+ validNodeIds,
893
+ );
894
+ durableFoldedNodeIds = restoredFoldedNodeIds;
895
+ lastPersistedFoldedNodeIds = restoredFoldedNodeIds;
896
+ }
897
+
898
+ const persistDurableFoldState = (nextDurableFoldedNodeIds: string[]): void => {
899
+ if (!persistFoldState || foldStateNodeIdListsEqual(nextDurableFoldedNodeIds, lastPersistedFoldedNodeIds)) {
900
+ return;
901
+ }
902
+
903
+ try {
904
+ pi.appendEntry(
905
+ ANYCOPY_FOLD_STATE_CUSTOM_TYPE,
906
+ createFoldStateEntryData(nextDurableFoldedNodeIds, validNodeIds),
907
+ );
908
+ lastPersistedFoldedNodeIds = nextDurableFoldedNodeIds;
909
+ } catch (error) {
910
+ ctx.ui.notify(
911
+ error instanceof Error ? error.message : "Failed to persist /anycopy fold state",
912
+ "error",
913
+ );
914
+ }
915
+ };
916
+
917
+ const handleExplicitFoldMutation = (
918
+ beforeTransientFoldedNodeIds: string[],
919
+ afterTransientFoldedNodeIds: string[],
920
+ ): void => {
921
+ const nextDurableFoldedNodeIds = mergeExplicitFoldMutation({
922
+ durableFoldedNodeIds,
923
+ beforeTransientFoldedNodeIds,
924
+ afterTransientFoldedNodeIds,
925
+ validNodeIds,
926
+ });
927
+ if (foldStateNodeIdListsEqual(nextDurableFoldedNodeIds, durableFoldedNodeIds)) {
928
+ return;
929
+ }
930
+
931
+ durableFoldedNodeIds = nextDurableFoldedNodeIds;
932
+ persistDurableFoldState(nextDurableFoldedNodeIds);
933
+ };
934
+
935
+ const effectiveLeafIdForNoop = selector.getTreeList().getSelectedNode()?.entry.id ?? currentLeafId;
761
936
  const overlay = new anycopyOverlay(
762
937
  selector,
763
938
  getTree,
764
939
  nodeById,
765
940
  keys,
941
+ persistFoldState ? handleExplicitFoldMutation : null,
766
942
  () => done(),
767
943
  () => tui.terminal?.rows ?? 40,
768
944
  () => tui.requestRender(),
@@ -777,8 +953,15 @@ export default function anycopyExtension(pi: ExtensionAPI) {
777
953
 
778
954
  const tl = treeList as any;
779
955
  const filteredRaw = tl.filteredNodes;
956
+ const decorateStatusLine = (line: string): string => {
957
+ const status = overlay.isShowingLabelTimestamps() ? `${line}${theme.fg("muted", " [+label time]")}` : line;
958
+ return truncateToWidth(` ${status}`, width);
959
+ };
960
+
780
961
  if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
781
- return lines.map((line: string) => " " + line);
962
+ return lines.map((line: string, i: number) =>
963
+ i === lines.length - 1 ? decorateStatusLine(line) : truncateToWidth(` ${line}`, width),
964
+ );
782
965
  }
783
966
  const filtered = filteredRaw as { node: SessionTreeNode }[];
784
967
 
@@ -798,16 +981,18 @@ export default function anycopyExtension(pi: ExtensionAPI) {
798
981
  const treeRowCount = Math.max(0, lines.length - 1);
799
982
 
800
983
  return lines.map((line: string, i: number) => {
801
- if (i >= treeRowCount) return " " + line;
984
+ if (i >= treeRowCount) return decorateStatusLine(line);
802
985
 
803
986
  const nodeIdx = startIdx + i;
804
987
  const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
805
988
  const nodeId = node?.entry?.id;
806
- if (typeof nodeId !== "string") return " " + line;
989
+ if (typeof nodeId !== "string") return truncateToWidth(` ${line}`, width);
807
990
 
808
991
  const selected = overlay.isSelectedNode(nodeId);
809
992
  const marker = selected ? theme.fg("success", "✓ ") : theme.fg("dim", "○ ");
810
- return marker + line;
993
+ const lineWithTimestamp =
994
+ overlay.isShowingLabelTimestamps() && node ? insertLabelTimestampIntoLine(line, node, theme) : line;
995
+ return truncateToWidth(marker + lineWithTimestamp, width);
811
996
  });
812
997
  };
813
998
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-anycopy",
3
- "version": "0.2.0",
4
- "description": "Pi's /tree with a live syntax-highlighted preview and ability to copy any node(s) to the clipboard",
3
+ "version": "0.2.2",
4
+ "description": "Pi's /tree with a live syntax-highlighted preview, ability to copy any node(s) to the clipboard, and persistence of folded branches",
5
5
  "keywords": ["pi-package", "pi", "pi-coding-agent"],
6
6
  "license": "MIT",
7
7
  "repository": {