pi-anycopy 0.1.3 → 0.2.0
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 -9
- package/extensions/anycopy/enter-navigation.ts +101 -0
- package/extensions/anycopy/index.ts +166 -100
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# anycopy for Pi (`pi-anycopy`)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
By comparison to Pi's native `/copy` (copies only the last assistant message) and `/md` (bulk-exports the entire branch as a Markdown transcript), `/anycopy` allows you to navigate the full session tree, preview each node's content with syntax highlighting, and copy to the clipboard any node(s) from the tree.
|
|
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.
|
|
6
4
|
|
|
7
5
|
<p align="center">
|
|
8
6
|
<img width="450" alt="anycopy demo" src="https://raw.githubusercontent.com/w-winter/dot314/main/assets/anycopy-demo.gif" />
|
|
@@ -48,8 +46,9 @@ Defaults (customizable in `config.json`):
|
|
|
48
46
|
|
|
49
47
|
| Key | Action |
|
|
50
48
|
|-----|--------|
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
49
|
+
| `Enter` | Navigate to the focused node (same semantics as `/tree`) |
|
|
50
|
+
| `Space` | Select/unselect focused node for copy |
|
|
51
|
+
| `Shift+C` | Copy selected nodes, or the focused node if nothing is selected |
|
|
53
52
|
| `Shift+X` | Clear selection |
|
|
54
53
|
| `Shift+L` | Label node (native tree behavior) |
|
|
55
54
|
| `Shift+Up` / `Shift+Down` | Scroll node preview by line |
|
|
@@ -57,18 +56,24 @@ Defaults (customizable in `config.json`):
|
|
|
57
56
|
| `Esc` | Close |
|
|
58
57
|
|
|
59
58
|
Notes:
|
|
59
|
+
- `Enter` always navigates the focused node, not the marked set
|
|
60
|
+
- After `Enter`, `/anycopy` offers the same summary choices as `/tree`: `No summary`, `Summarize`, and `Summarize with custom prompt`
|
|
61
|
+
- If `branchSummary.skipPrompt` is `true` in Pi settings, `/anycopy` matches native `/tree` and skips the summary chooser, defaulting to no summary
|
|
62
|
+
- Escaping the summary chooser reopens `/anycopy` with focus restored to the node you tried to select
|
|
63
|
+
- Cancelling the custom summarization editor returns to the summary chooser
|
|
60
64
|
- If no nodes are selected, `Shift+C` copies the focused node
|
|
61
|
-
-
|
|
65
|
+
- 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
|
+
- 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
|
|
62
68
|
- Label edits are persisted via `pi.setLabel(...)`
|
|
63
|
-
- Despite reoffering node labeling (`/anycopy` is arguably a better UI than `/tree` to also perform this action in), this extension doesn't offer a full reproduction of `/tree`'s other features (e.g., branch switching and summarization are not included)
|
|
64
69
|
|
|
65
70
|
## Configuration
|
|
66
71
|
|
|
67
72
|
Edit `~/.pi/agent/extensions/anycopy/config.json`:
|
|
68
73
|
|
|
69
|
-
- `treeFilterMode`: initial tree filter mode when opening `/anycopy
|
|
74
|
+
- `treeFilterMode`: initial tree filter mode when opening `/anycopy`; defaults to `default` to match `/tree`
|
|
70
75
|
- one of: `default` | `no-tools` | `user-only` | `labeled-only` | `all`
|
|
71
|
-
- `keys`: keybindings
|
|
76
|
+
- `keys`: keybindings used inside the `/anycopy` overlay for copy/preview actions
|
|
72
77
|
|
|
73
78
|
```json
|
|
74
79
|
{
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export type AnycopyEnterNavigationResult = "reopen" | "closed";
|
|
2
|
+
|
|
3
|
+
export type AnycopySummaryChoice = "No summary" | "Summarize" | "Summarize with custom prompt";
|
|
4
|
+
|
|
5
|
+
export type AnycopyEnterNavigationDeps = {
|
|
6
|
+
entryId: string;
|
|
7
|
+
effectiveLeafIdForNoop: string | null;
|
|
8
|
+
skipSummaryPrompt: boolean;
|
|
9
|
+
close: () => void;
|
|
10
|
+
reopen: (options: { initialSelectedId: string }) => void;
|
|
11
|
+
navigateTree: (
|
|
12
|
+
targetId: string,
|
|
13
|
+
options?: { summarize?: boolean; customInstructions?: string },
|
|
14
|
+
) => Promise<{ cancelled: boolean; aborted?: boolean }>;
|
|
15
|
+
ui: {
|
|
16
|
+
select: (title: string, options: AnycopySummaryChoice[]) => Promise<AnycopySummaryChoice | undefined>;
|
|
17
|
+
editor: (title: string) => Promise<string | undefined>;
|
|
18
|
+
setStatus: (source: string, message: string) => void;
|
|
19
|
+
setWorkingMessage: (message?: string) => void;
|
|
20
|
+
notify: (message: string, level: "error") => void;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function runAnycopyEnterNavigation(
|
|
25
|
+
deps: AnycopyEnterNavigationDeps,
|
|
26
|
+
): Promise<AnycopyEnterNavigationResult> {
|
|
27
|
+
const { entryId, effectiveLeafIdForNoop, skipSummaryPrompt, close, reopen, navigateTree, ui } = deps;
|
|
28
|
+
|
|
29
|
+
if (effectiveLeafIdForNoop !== null && entryId === effectiveLeafIdForNoop) {
|
|
30
|
+
close();
|
|
31
|
+
ui.setStatus("anycopy", "Already at this point");
|
|
32
|
+
return "closed";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
close();
|
|
36
|
+
|
|
37
|
+
let wantsSummary = false;
|
|
38
|
+
let customInstructions: string | undefined;
|
|
39
|
+
|
|
40
|
+
if (!skipSummaryPrompt) {
|
|
41
|
+
while (true) {
|
|
42
|
+
const choice = await ui.select("Summarize branch?", [
|
|
43
|
+
"No summary",
|
|
44
|
+
"Summarize",
|
|
45
|
+
"Summarize with custom prompt",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
if (choice === undefined) {
|
|
49
|
+
reopen({ initialSelectedId: entryId });
|
|
50
|
+
return "reopen";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (choice === "No summary") {
|
|
54
|
+
wantsSummary = false;
|
|
55
|
+
customInstructions = undefined;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (choice === "Summarize") {
|
|
60
|
+
wantsSummary = true;
|
|
61
|
+
customInstructions = undefined;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
customInstructions = await ui.editor("Custom summarization instructions");
|
|
66
|
+
if (customInstructions === undefined) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
wantsSummary = true;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ui.setWorkingMessage("Navigating tree…");
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const result = await navigateTree(entryId, {
|
|
79
|
+
summarize: wantsSummary,
|
|
80
|
+
customInstructions,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (result.cancelled) {
|
|
84
|
+
if (wantsSummary) {
|
|
85
|
+
ui.setStatus("anycopy", "Branch summarization cancelled");
|
|
86
|
+
reopen({ initialSelectedId: entryId });
|
|
87
|
+
return "reopen";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
ui.setStatus("anycopy", "Navigation cancelled");
|
|
91
|
+
return "closed";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return "closed";
|
|
95
|
+
} catch (error) {
|
|
96
|
+
ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
97
|
+
return "closed";
|
|
98
|
+
} finally {
|
|
99
|
+
ui.setWorkingMessage();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -26,9 +26,12 @@ import { Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
|
26
26
|
import type { Focusable } from "@mariozechner/pi-tui";
|
|
27
27
|
|
|
28
28
|
import { existsSync, readFileSync } from "fs";
|
|
29
|
+
import { homedir } from "os";
|
|
29
30
|
import { dirname, join } from "path";
|
|
30
31
|
import { fileURLToPath } from "url";
|
|
31
32
|
|
|
33
|
+
import { runAnycopyEnterNavigation } from "./enter-navigation.ts";
|
|
34
|
+
|
|
32
35
|
type SessionTreeNode = {
|
|
33
36
|
entry: SessionEntry;
|
|
34
37
|
children: SessionTreeNode[];
|
|
@@ -57,6 +60,12 @@ type anycopyRuntimeConfig = {
|
|
|
57
60
|
treeFilterMode: TreeFilterMode;
|
|
58
61
|
};
|
|
59
62
|
|
|
63
|
+
type BranchSummarySettingsFile = {
|
|
64
|
+
branchSummary?: {
|
|
65
|
+
skipPrompt?: boolean;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
60
69
|
const DEFAULT_KEYS: anycopyKeyConfig = {
|
|
61
70
|
toggleSelect: "space",
|
|
62
71
|
copy: "shift+c",
|
|
@@ -75,10 +84,35 @@ const getExtensionDir = (): string => {
|
|
|
75
84
|
return dirname(fileURLToPath(import.meta.url));
|
|
76
85
|
};
|
|
77
86
|
|
|
87
|
+
const getAgentDir = (): string => process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
88
|
+
|
|
89
|
+
const readJsonFile = <T>(path: string): T | undefined => {
|
|
90
|
+
if (!existsSync(path)) return undefined;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const loadBranchSummarySkipPrompt = (cwd: string): boolean => {
|
|
100
|
+
const globalSettings = readJsonFile<BranchSummarySettingsFile>(join(getAgentDir(), "settings.json"));
|
|
101
|
+
const projectSettings = readJsonFile<BranchSummarySettingsFile>(join(cwd, ".pi", "settings.json"));
|
|
102
|
+
const projectSkipPrompt = projectSettings?.branchSummary?.skipPrompt;
|
|
103
|
+
if (typeof projectSkipPrompt === "boolean") return projectSkipPrompt;
|
|
104
|
+
|
|
105
|
+
const globalSkipPrompt = globalSettings?.branchSummary?.skipPrompt;
|
|
106
|
+
return typeof globalSkipPrompt === "boolean" ? globalSkipPrompt : false;
|
|
107
|
+
};
|
|
108
|
+
|
|
78
109
|
const loadConfig = (): anycopyRuntimeConfig => {
|
|
79
110
|
const configPath = join(getExtensionDir(), "config.json");
|
|
80
111
|
if (!existsSync(configPath)) {
|
|
81
|
-
return {
|
|
112
|
+
return {
|
|
113
|
+
keys: { ...DEFAULT_KEYS },
|
|
114
|
+
treeFilterMode: DEFAULT_TREE_FILTER_MODE,
|
|
115
|
+
};
|
|
82
116
|
}
|
|
83
117
|
|
|
84
118
|
try {
|
|
@@ -105,7 +139,10 @@ const loadConfig = (): anycopyRuntimeConfig => {
|
|
|
105
139
|
treeFilterMode,
|
|
106
140
|
};
|
|
107
141
|
} catch {
|
|
108
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
keys: { ...DEFAULT_KEYS },
|
|
144
|
+
treeFilterMode: DEFAULT_TREE_FILTER_MODE,
|
|
145
|
+
};
|
|
109
146
|
}
|
|
110
147
|
};
|
|
111
148
|
|
|
@@ -331,10 +368,14 @@ const buildNodeOrder = (roots: SessionTreeNode[]): Map<string, number> => {
|
|
|
331
368
|
return order;
|
|
332
369
|
};
|
|
333
370
|
|
|
334
|
-
/** Clipboard text
|
|
371
|
+
/** Clipboard text omits role prefix for a single node and includes it for multi-node copies
|
|
335
372
|
* The preview pane is truncated for performance, while the clipboard copy is not
|
|
336
373
|
*/
|
|
337
374
|
const buildClipboardText = (nodes: SessionTreeNode[]): string => {
|
|
375
|
+
if (nodes.length === 1) {
|
|
376
|
+
return getEntryContent(nodes[0]!.entry);
|
|
377
|
+
}
|
|
378
|
+
|
|
338
379
|
return nodes
|
|
339
380
|
.map((node) => {
|
|
340
381
|
const label = getEntryRoleLabel(node.entry);
|
|
@@ -363,6 +404,7 @@ class anycopyOverlay implements Focusable {
|
|
|
363
404
|
private getTree: () => SessionTreeNode[],
|
|
364
405
|
private nodeById: Map<string, SessionTreeNode>,
|
|
365
406
|
private keys: anycopyKeyConfig,
|
|
407
|
+
private onClose: () => void,
|
|
366
408
|
private getTermHeight: () => number,
|
|
367
409
|
private requestRender: () => void,
|
|
368
410
|
private theme: any,
|
|
@@ -381,6 +423,12 @@ class anycopyOverlay implements Focusable {
|
|
|
381
423
|
}
|
|
382
424
|
|
|
383
425
|
handleInput(data: string): void {
|
|
426
|
+
if (this.isEditingNodeLabel()) {
|
|
427
|
+
this.selector.handleInput(data);
|
|
428
|
+
this.requestRender();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
384
432
|
if (matchesKey(data, this.keys.toggleSelect)) {
|
|
385
433
|
this.toggleSelectedFocusedNode();
|
|
386
434
|
return;
|
|
@@ -421,6 +469,10 @@ class anycopyOverlay implements Focusable {
|
|
|
421
469
|
this.requestRender();
|
|
422
470
|
}
|
|
423
471
|
|
|
472
|
+
private isEditingNodeLabel(): boolean {
|
|
473
|
+
return Boolean((this.selector as { labelInput?: unknown }).labelInput);
|
|
474
|
+
}
|
|
475
|
+
|
|
424
476
|
invalidate(): void {
|
|
425
477
|
// Preview is derived from focused entry + width; invalidate forces recompute
|
|
426
478
|
this.previewCache = null;
|
|
@@ -532,10 +584,11 @@ class anycopyOverlay implements Focusable {
|
|
|
532
584
|
|
|
533
585
|
private renderTreeHeaderHint(width: number): string {
|
|
534
586
|
const hint =
|
|
535
|
-
` │
|
|
587
|
+
` │ Enter: navigate` +
|
|
588
|
+
` • ${formatKeyHint(this.keys.toggleSelect)}: select` +
|
|
536
589
|
` • ${formatKeyHint(this.keys.copy)}: copy` +
|
|
537
590
|
` • ${formatKeyHint(this.keys.clear)}: clear` +
|
|
538
|
-
|
|
591
|
+
` • Esc: close`;
|
|
539
592
|
return truncateToWidth(this.theme.fg("dim", hint), width);
|
|
540
593
|
}
|
|
541
594
|
|
|
@@ -615,7 +668,7 @@ class anycopyOverlay implements Focusable {
|
|
|
615
668
|
const selectorLines = this.selector.render(width);
|
|
616
669
|
const headerHint = this.renderTreeHeaderHint(width);
|
|
617
670
|
|
|
618
|
-
// Inject
|
|
671
|
+
// Inject action hints near the tree header (above the list)
|
|
619
672
|
const insertAfter = Math.max(0, selectorLines.findIndex((l) => l.includes("Type to search")));
|
|
620
673
|
if (selectorLines.length > 0) {
|
|
621
674
|
const idx = insertAfter >= 0 ? insertAfter + 1 : 1;
|
|
@@ -652,108 +705,121 @@ export default function anycopyExtension(pi: ExtensionAPI) {
|
|
|
652
705
|
const keys = config.keys;
|
|
653
706
|
const treeFilterMode = config.treeFilterMode;
|
|
654
707
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
708
|
+
const openAnycopy = async (
|
|
709
|
+
ctx: ExtensionCommandContext,
|
|
710
|
+
opts?: { initialSelectedId?: string },
|
|
711
|
+
) => {
|
|
712
|
+
if (!ctx.hasUI) return;
|
|
659
713
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
714
|
+
const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
|
|
715
|
+
if (initialTree.length === 0) {
|
|
716
|
+
ctx.ui.notify("No entries in session", "warning");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
665
719
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
720
|
+
const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
|
|
721
|
+
const currentLeafId = ctx.sessionManager.getLeafId();
|
|
722
|
+
const skipSummaryPrompt = loadBranchSummarySkipPrompt(ctx.cwd);
|
|
723
|
+
|
|
724
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
725
|
+
const termRows = tui.terminal?.rows ?? 40;
|
|
726
|
+
const treeTermHeight = Math.floor(termRows * 0.65);
|
|
727
|
+
|
|
728
|
+
const selector = new TreeSelectorComponent(
|
|
729
|
+
initialTree,
|
|
730
|
+
currentLeafId,
|
|
731
|
+
treeTermHeight,
|
|
732
|
+
(entryId) => {
|
|
733
|
+
void runAnycopyEnterNavigation({
|
|
734
|
+
entryId,
|
|
735
|
+
effectiveLeafIdForNoop,
|
|
736
|
+
skipSummaryPrompt,
|
|
737
|
+
close: done,
|
|
738
|
+
reopen: (reopenOpts) => {
|
|
739
|
+
void openAnycopy(ctx, reopenOpts);
|
|
740
|
+
},
|
|
741
|
+
navigateTree: async (targetId, options) => ctx.navigateTree(targetId, options),
|
|
742
|
+
ui: {
|
|
743
|
+
select: (title, options) => ctx.ui.select(title, options),
|
|
744
|
+
editor: (title) => ctx.ui.editor(title),
|
|
745
|
+
setStatus: (source, message) => ctx.ui.setStatus(source, message),
|
|
746
|
+
setWorkingMessage: (message) => ctx.ui.setWorkingMessage(message),
|
|
747
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
},
|
|
751
|
+
() => done(),
|
|
752
|
+
(entryId, label) => {
|
|
753
|
+
pi.setLabel(entryId, label);
|
|
754
|
+
},
|
|
755
|
+
opts?.initialSelectedId,
|
|
756
|
+
treeFilterMode,
|
|
757
|
+
);
|
|
758
|
+
const effectiveLeafIdForNoop = selector.getTreeList().getSelectedNode()?.entry.id ?? currentLeafId;
|
|
759
|
+
|
|
760
|
+
const nodeById = buildNodeMap(initialTree);
|
|
761
|
+
const overlay = new anycopyOverlay(
|
|
762
|
+
selector,
|
|
763
|
+
getTree,
|
|
764
|
+
nodeById,
|
|
765
|
+
keys,
|
|
766
|
+
() => done(),
|
|
767
|
+
() => tui.terminal?.rows ?? 40,
|
|
768
|
+
() => tui.requestRender(),
|
|
769
|
+
theme,
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
const treeList = selector.getTreeList();
|
|
773
|
+
const originalRender = treeList.render.bind(treeList);
|
|
774
|
+
treeList.render = (width: number) => {
|
|
775
|
+
const innerWidth = Math.max(10, width - 2);
|
|
776
|
+
const lines = originalRender(innerWidth);
|
|
687
777
|
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
778
|
+
const tl = treeList as any;
|
|
779
|
+
const filteredRaw = tl.filteredNodes;
|
|
780
|
+
if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
|
|
781
|
+
return lines.map((line: string) => " " + line);
|
|
782
|
+
}
|
|
783
|
+
const filtered = filteredRaw as { node: SessionTreeNode }[];
|
|
784
|
+
|
|
785
|
+
const selectedIdxRaw = tl.selectedIndex;
|
|
786
|
+
const maxVisibleRaw = tl.maxVisibleLines;
|
|
787
|
+
const selectedIdx =
|
|
788
|
+
typeof selectedIdxRaw === "number" && Number.isFinite(selectedIdxRaw) ? selectedIdxRaw : 0;
|
|
789
|
+
const maxVisible =
|
|
790
|
+
typeof maxVisibleRaw === "number" && Number.isFinite(maxVisibleRaw) && maxVisibleRaw > 0
|
|
791
|
+
? maxVisibleRaw
|
|
792
|
+
: filtered.length;
|
|
793
|
+
|
|
794
|
+
const startIdx = Math.max(
|
|
795
|
+
0,
|
|
796
|
+
Math.min(selectedIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
|
|
699
797
|
);
|
|
798
|
+
const treeRowCount = Math.max(0, lines.length - 1);
|
|
700
799
|
|
|
701
|
-
|
|
800
|
+
return lines.map((line: string, i: number) => {
|
|
801
|
+
if (i >= treeRowCount) return " " + line;
|
|
702
802
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
if (typeof rawTreeList.applyFilter === "function") rawTreeList.applyFilter();
|
|
708
|
-
}
|
|
803
|
+
const nodeIdx = startIdx + i;
|
|
804
|
+
const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
|
|
805
|
+
const nodeId = node?.entry?.id;
|
|
806
|
+
if (typeof nodeId !== "string") return " " + line;
|
|
709
807
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const tl = treeList as any;
|
|
717
|
-
const filteredRaw = tl.filteredNodes;
|
|
718
|
-
if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
|
|
719
|
-
return lines.map((line: string) => " " + line);
|
|
720
|
-
}
|
|
721
|
-
const filtered = filteredRaw as { node: SessionTreeNode }[];
|
|
722
|
-
|
|
723
|
-
const selectedIdxRaw = tl.selectedIndex;
|
|
724
|
-
const maxVisibleRaw = tl.maxVisibleLines;
|
|
725
|
-
const selectedIdx =
|
|
726
|
-
typeof selectedIdxRaw === "number" && Number.isFinite(selectedIdxRaw) ? selectedIdxRaw : 0;
|
|
727
|
-
const maxVisible =
|
|
728
|
-
typeof maxVisibleRaw === "number" && Number.isFinite(maxVisibleRaw) && maxVisibleRaw > 0
|
|
729
|
-
? maxVisibleRaw
|
|
730
|
-
: filtered.length;
|
|
731
|
-
|
|
732
|
-
const startIdx = Math.max(
|
|
733
|
-
0,
|
|
734
|
-
Math.min(selectedIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
|
|
735
|
-
);
|
|
736
|
-
const treeRowCount = Math.max(0, lines.length - 1);
|
|
737
|
-
|
|
738
|
-
return lines.map((line: string, i: number) => {
|
|
739
|
-
if (i >= treeRowCount) return " " + line;
|
|
740
|
-
|
|
741
|
-
const nodeIdx = startIdx + i;
|
|
742
|
-
const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
|
|
743
|
-
const nodeId = node?.entry?.id;
|
|
744
|
-
if (typeof nodeId !== "string") return " " + line;
|
|
745
|
-
|
|
746
|
-
const selected = overlay.isSelectedNode(nodeId);
|
|
747
|
-
const marker = selected
|
|
748
|
-
? theme.fg("success", "\u2713 ")
|
|
749
|
-
: theme.fg("dim", "\u25CB ");
|
|
750
|
-
return marker + line;
|
|
751
|
-
});
|
|
752
|
-
};
|
|
808
|
+
const selected = overlay.isSelectedNode(nodeId);
|
|
809
|
+
const marker = selected ? theme.fg("success", "✓ ") : theme.fg("dim", "○ ");
|
|
810
|
+
return marker + line;
|
|
811
|
+
});
|
|
812
|
+
};
|
|
753
813
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
814
|
+
tui.setFocus?.(overlay);
|
|
815
|
+
return overlay;
|
|
816
|
+
});
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
pi.registerCommand("anycopy", {
|
|
820
|
+
description: "Browse session tree with preview and copy any node(s) to clipboard",
|
|
821
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
822
|
+
await openAnycopy(ctx);
|
|
757
823
|
},
|
|
758
824
|
});
|
|
759
825
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-anycopy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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",
|
|
5
5
|
"keywords": ["pi-package", "pi", "pi-coding-agent"],
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"dot314Prepack": {
|
|
29
29
|
"copy": [
|
|
30
30
|
{ "from": "../../extensions/anycopy/index.ts", "to": "extensions/anycopy/index.ts" },
|
|
31
|
+
{ "from": "../../extensions/anycopy/enter-navigation.ts", "to": "extensions/anycopy/enter-navigation.ts" },
|
|
31
32
|
{ "from": "../../extensions/anycopy/config.json", "to": "extensions/anycopy/config.json" },
|
|
32
33
|
{ "from": "../../LICENSE", "to": "LICENSE" }
|
|
33
34
|
],
|