pi-anycopy 0.2.1 → 0.2.3

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
@@ -47,7 +47,7 @@ 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) |
@@ -65,11 +65,13 @@ Notes:
65
65
  - If no nodes are selected, `Shift+C` copies the focused node
66
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
67
67
  - When copying multiple selected nodes, they are auto-sorted chronologically by position in the session tree, not by selection order
68
- - 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
69
  - `Shift+T` is configurable via `keys.toggleLabelTimestamps` in `config.json`
70
70
  - `Shift+T` shows timestamps for labeled nodes only, using the latest label-change time for each label
71
71
  - Same-day labels show `HH:MM`; older labels show `M/D HH:MM`; cross-year labels show `YY/M/D HH:MM`
72
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
73
75
 
74
76
  ## Configuration
75
77
 
@@ -77,13 +79,15 @@ Edit `~/.pi/agent/extensions/anycopy/config.json`:
77
79
 
78
80
  - `treeFilterMode`: initial tree filter mode when opening `/anycopy`; defaults to `default` to match `/tree`
79
81
  - one of: `default` | `no-tools` | `user-only` | `labeled-only` | `all`
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
80
83
  - `keys`: keybindings used inside the `/anycopy` overlay for copy/preview actions, including the label timestamp toggle
81
84
 
82
85
  ```json
83
86
  {
84
87
  "treeFilterMode": "default",
88
+ "persistFoldState": true,
85
89
  "keys": {
86
- "toggleSelect": "space",
90
+ "toggleSelect": "shift+a",
87
91
  "copy": "shift+c",
88
92
  "clear": "shift+x",
89
93
  "toggleLabelTimestamps": "shift+t",
@@ -1,7 +1,8 @@
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",
7
8
  "toggleLabelTimestamps": "shift+t",
@@ -0,0 +1,146 @@
1
+ import type { SessionEntry } from "@mariozechner/pi-coding-agent";
2
+
3
+ export const ANYCOPY_FOLD_STATE_CUSTOM_TYPE = "anycopy-fold-state";
4
+ const FOLD_STATE_SCHEMA_VERSION = 1;
5
+
6
+ type TreeSelectorLike = {
7
+ getTreeList(): unknown;
8
+ };
9
+
10
+ type TreeListInternals = {
11
+ foldedNodes?: Set<string>;
12
+ flatNodes?: Array<{ node?: { entry?: { id?: string } } }>;
13
+ applyFilter?: () => void;
14
+ isFoldable?: (entryId: string) => boolean;
15
+ };
16
+
17
+ export type AnycopyFoldStateEntryData = {
18
+ v: 1;
19
+ foldedNodeIds: string[];
20
+ };
21
+
22
+ type MergeExplicitFoldMutationArgs = {
23
+ durableFoldedNodeIds: Iterable<string>;
24
+ beforeTransientFoldedNodeIds: Iterable<string>;
25
+ afterTransientFoldedNodeIds: Iterable<string>;
26
+ validNodeIds?: ReadonlySet<string>;
27
+ };
28
+
29
+ export const normalizeFoldedNodeIds = (
30
+ nodeIds: Iterable<unknown>,
31
+ validNodeIds?: ReadonlySet<string>,
32
+ ): string[] => {
33
+ const uniqueNodeIds = new Set<string>();
34
+ for (const nodeId of nodeIds) {
35
+ if (typeof nodeId !== "string" || nodeId.length === 0) continue;
36
+ if (validNodeIds && !validNodeIds.has(nodeId)) continue;
37
+ uniqueNodeIds.add(nodeId);
38
+ }
39
+ return [...uniqueNodeIds].sort();
40
+ };
41
+
42
+ export const foldStateNodeIdListsEqual = (left: readonly string[], right: readonly string[]): boolean =>
43
+ left.length === right.length && left.every((value, index) => value === right[index]);
44
+
45
+ export const createFoldStateEntryData = (
46
+ foldedNodeIds: Iterable<string>,
47
+ validNodeIds?: ReadonlySet<string>,
48
+ ): AnycopyFoldStateEntryData => ({
49
+ v: FOLD_STATE_SCHEMA_VERSION,
50
+ foldedNodeIds: normalizeFoldedNodeIds(foldedNodeIds, validNodeIds),
51
+ });
52
+
53
+ export const parseFoldStateEntryData = (
54
+ data: unknown,
55
+ validNodeIds?: ReadonlySet<string>,
56
+ ): AnycopyFoldStateEntryData | null => {
57
+ if (!data || typeof data !== "object") return null;
58
+
59
+ const candidate = data as { v?: unknown; foldedNodeIds?: unknown };
60
+ if (candidate.v !== FOLD_STATE_SCHEMA_VERSION || !Array.isArray(candidate.foldedNodeIds)) {
61
+ return null;
62
+ }
63
+
64
+ return createFoldStateEntryData(candidate.foldedNodeIds, validNodeIds);
65
+ };
66
+
67
+ const getTreeListInternals = (selector: TreeSelectorLike): TreeListInternals | null => {
68
+ const treeList = selector.getTreeList();
69
+ if (!treeList || typeof treeList !== "object") return null;
70
+ return treeList as TreeListInternals;
71
+ };
72
+
73
+ export const getSelectorFoldedNodeIds = (selector: TreeSelectorLike): string[] => {
74
+ const internals = getTreeListInternals(selector);
75
+ if (!internals?.foldedNodes) return [];
76
+ return normalizeFoldedNodeIds(internals.foldedNodes);
77
+ };
78
+
79
+ export const setSelectorFoldedNodeIds = (selector: TreeSelectorLike, entryIds: Iterable<string>): string[] => {
80
+ const internals = getTreeListInternals(selector);
81
+ if (!internals?.foldedNodes || !internals.applyFilter || !Array.isArray(internals.flatNodes)) {
82
+ return [];
83
+ }
84
+
85
+ const validNodeIds = new Set(
86
+ internals.flatNodes
87
+ .map((flatNode) => flatNode.node?.entry?.id)
88
+ .filter((entryId): entryId is string => typeof entryId === "string"),
89
+ );
90
+
91
+ internals.foldedNodes.clear();
92
+ internals.applyFilter();
93
+
94
+ for (const entryId of normalizeFoldedNodeIds(entryIds, validNodeIds)) {
95
+ if (!internals.isFoldable || internals.isFoldable(entryId)) {
96
+ internals.foldedNodes.add(entryId);
97
+ }
98
+ }
99
+
100
+ internals.applyFilter();
101
+ return normalizeFoldedNodeIds(internals.foldedNodes);
102
+ };
103
+
104
+ export const loadLatestFoldStateFromEntries = (
105
+ entries: Iterable<SessionEntry>,
106
+ validNodeIds?: ReadonlySet<string>,
107
+ ): AnycopyFoldStateEntryData | null => {
108
+ const sessionEntries = [...entries];
109
+ for (let index = sessionEntries.length - 1; index >= 0; index -= 1) {
110
+ const entry = sessionEntries[index];
111
+ if (entry.type !== "custom" || entry.customType !== ANYCOPY_FOLD_STATE_CUSTOM_TYPE) {
112
+ continue;
113
+ }
114
+
115
+ const parsedEntry = parseFoldStateEntryData(entry.data, validNodeIds);
116
+ if (parsedEntry) {
117
+ return parsedEntry;
118
+ }
119
+ }
120
+
121
+ return null;
122
+ };
123
+
124
+ export const mergeExplicitFoldMutation = ({
125
+ durableFoldedNodeIds,
126
+ beforeTransientFoldedNodeIds,
127
+ afterTransientFoldedNodeIds,
128
+ validNodeIds,
129
+ }: MergeExplicitFoldMutationArgs): string[] => {
130
+ const normalizedDurableFoldedNodeIds = normalizeFoldedNodeIds(durableFoldedNodeIds, validNodeIds);
131
+ const normalizedBeforeTransientFoldedNodeIds = normalizeFoldedNodeIds(beforeTransientFoldedNodeIds, validNodeIds);
132
+ const normalizedAfterTransientFoldedNodeIds = normalizeFoldedNodeIds(afterTransientFoldedNodeIds, validNodeIds);
133
+
134
+ const removedNodeIds = new Set(
135
+ normalizedBeforeTransientFoldedNodeIds.filter((nodeId) => !normalizedAfterTransientFoldedNodeIds.includes(nodeId)),
136
+ );
137
+ const addedNodeIds = new Set(
138
+ normalizedAfterTransientFoldedNodeIds.filter((nodeId) => !normalizedBeforeTransientFoldedNodeIds.includes(nodeId)),
139
+ );
140
+ const nextDurableFoldedNodeIds = normalizedDurableFoldedNodeIds.filter((nodeId) => !removedNodeIds.has(nodeId));
141
+ for (const nodeId of addedNodeIds) {
142
+ nextDurableFoldedNodeIds.push(nodeId);
143
+ }
144
+
145
+ return normalizeFoldedNodeIds(nextDurableFoldedNodeIds, validNodeIds);
146
+ };
@@ -4,7 +4,7 @@
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
10
  * Shift+L - label node
@@ -23,7 +23,7 @@ import {
23
23
  TreeSelectorComponent,
24
24
  } from "@mariozechner/pi-coding-agent";
25
25
 
26
- import { Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
26
+ import { getKeybindings, Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
27
27
  import type { Focusable } from "@mariozechner/pi-tui";
28
28
 
29
29
  import { existsSync, readFileSync } from "fs";
@@ -32,6 +32,16 @@ import { dirname, join } from "path";
32
32
  import { fileURLToPath } from "url";
33
33
 
34
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";
35
45
 
36
46
  type SessionTreeNode = {
37
47
  entry: SessionEntry;
@@ -56,11 +66,13 @@ type TreeFilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "a
56
66
  type anycopyConfig = {
57
67
  keys?: Partial<anycopyKeyConfig>;
58
68
  treeFilterMode?: TreeFilterMode;
69
+ persistFoldState?: boolean;
59
70
  };
60
71
 
61
72
  type anycopyRuntimeConfig = {
62
73
  keys: anycopyKeyConfig;
63
74
  treeFilterMode: TreeFilterMode;
75
+ persistFoldState: boolean;
64
76
  };
65
77
 
66
78
  type BranchSummarySettingsFile = {
@@ -70,7 +82,7 @@ type BranchSummarySettingsFile = {
70
82
  };
71
83
 
72
84
  const DEFAULT_KEYS: anycopyKeyConfig = {
73
- toggleSelect: "space",
85
+ toggleSelect: "shift+a",
74
86
  copy: "shift+c",
75
87
  clear: "shift+x",
76
88
  toggleLabelTimestamps: "shift+t",
@@ -81,6 +93,7 @@ const DEFAULT_KEYS: anycopyKeyConfig = {
81
93
  };
82
94
 
83
95
  const DEFAULT_TREE_FILTER_MODE: TreeFilterMode = "default";
96
+ const DEFAULT_PERSIST_FOLD_STATE = true;
84
97
 
85
98
  const getExtensionDir = (): string => {
86
99
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -116,6 +129,7 @@ const loadConfig = (): anycopyRuntimeConfig => {
116
129
  return {
117
130
  keys: { ...DEFAULT_KEYS },
118
131
  treeFilterMode: DEFAULT_TREE_FILTER_MODE,
132
+ persistFoldState: DEFAULT_PERSIST_FOLD_STATE,
119
133
  };
120
134
  }
121
135
 
@@ -129,6 +143,8 @@ const loadConfig = (): anycopyRuntimeConfig => {
129
143
  typeof treeFilterModeRaw === "string" && validTreeFilterModes.includes(treeFilterModeRaw as TreeFilterMode)
130
144
  ? (treeFilterModeRaw as TreeFilterMode)
131
145
  : DEFAULT_TREE_FILTER_MODE;
146
+ const persistFoldState =
147
+ typeof parsed.persistFoldState === "boolean" ? parsed.persistFoldState : DEFAULT_PERSIST_FOLD_STATE;
132
148
 
133
149
  return {
134
150
  keys: {
@@ -145,11 +161,13 @@ const loadConfig = (): anycopyRuntimeConfig => {
145
161
  pageUp: typeof keys.pageUp === "string" ? keys.pageUp : DEFAULT_KEYS.pageUp,
146
162
  },
147
163
  treeFilterMode,
164
+ persistFoldState,
148
165
  };
149
166
  } catch {
150
167
  return {
151
168
  keys: { ...DEFAULT_KEYS },
152
169
  treeFilterMode: DEFAULT_TREE_FILTER_MODE,
170
+ persistFoldState: DEFAULT_PERSIST_FOLD_STATE,
153
171
  };
154
172
  }
155
173
  };
@@ -473,6 +491,10 @@ class anycopyOverlay implements Focusable {
473
491
  private getTree: () => SessionTreeNode[],
474
492
  private nodeById: Map<string, SessionTreeNode>,
475
493
  private keys: anycopyKeyConfig,
494
+ private onExplicitFoldMutation: ((
495
+ beforeTransientFoldedNodeIds: string[],
496
+ afterTransientFoldedNodeIds: string[],
497
+ ) => void) | null,
476
498
  private onClose: () => void,
477
499
  private getTermHeight: () => number,
478
500
  private requestRender: () => void,
@@ -539,7 +561,18 @@ class anycopyOverlay implements Focusable {
539
561
  return;
540
562
  }
541
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
+
542
570
  this.selector.handleInput(data);
571
+
572
+ if (beforeTransientFoldedNodeIds) {
573
+ this.onExplicitFoldMutation?.(beforeTransientFoldedNodeIds, getSelectorFoldedNodeIds(this.selector));
574
+ }
575
+
543
576
  this.requestRender();
544
577
  }
545
578
 
@@ -783,6 +816,7 @@ export default function anycopyExtension(pi: ExtensionAPI) {
783
816
  const config = loadConfig();
784
817
  const keys = config.keys;
785
818
  const treeFilterMode = config.treeFilterMode;
819
+ const persistFoldState = config.persistFoldState;
786
820
 
787
821
  const openAnycopy = async (
788
822
  ctx: ExtensionCommandContext,
@@ -812,6 +846,13 @@ export default function anycopyExtension(pi: ExtensionAPI) {
812
846
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
813
847
  const termRows = tui.terminal?.rows ?? 40;
814
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;
815
856
 
816
857
  const selector = new TreeSelectorComponent(
817
858
  initialTree,
@@ -844,14 +885,60 @@ export default function anycopyExtension(pi: ExtensionAPI) {
844
885
  opts?.initialSelectedId,
845
886
  treeFilterMode,
846
887
  );
847
- const effectiveLeafIdForNoop = selector.getTreeList().getSelectedNode()?.entry.id ?? currentLeafId;
848
888
 
849
- 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;
850
936
  const overlay = new anycopyOverlay(
851
937
  selector,
852
938
  getTree,
853
939
  nodeById,
854
940
  keys,
941
+ persistFoldState ? handleExplicitFoldMutation : null,
855
942
  () => done(),
856
943
  () => tui.terminal?.rows ?? 40,
857
944
  () => tui.requestRender(),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-anycopy",
3
- "version": "0.2.1",
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.3",
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": {
@@ -29,6 +29,7 @@
29
29
  "copy": [
30
30
  { "from": "../../extensions/anycopy/index.ts", "to": "extensions/anycopy/index.ts" },
31
31
  { "from": "../../extensions/anycopy/enter-navigation.ts", "to": "extensions/anycopy/enter-navigation.ts" },
32
+ { "from": "../../extensions/anycopy/fold-state.ts", "to": "extensions/anycopy/fold-state.ts" },
32
33
  { "from": "../../extensions/anycopy/config.json", "to": "extensions/anycopy/config.json" },
33
34
  { "from": "../../LICENSE", "to": "LICENSE" }
34
35
  ],