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 +14 -5
- package/extensions/anycopy/config.json +3 -1
- package/extensions/anycopy/index.ts +197 -12
- package/package.json +2 -2
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" />
|
|
@@ -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
|
-
| `
|
|
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
|
-
-
|
|
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
|
-
- `
|
|
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": "
|
|
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": "
|
|
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
|
-
*
|
|
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
|
|
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: "
|
|
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
|
|
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 = () =>
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Pi's /tree with a live syntax-highlighted preview
|
|
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": {
|