pi-anycopy 0.1.2 → 0.1.4
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 +12 -1
- package/extensions/anycopy/config.json +2 -0
- package/extensions/anycopy/index.ts +168 -102
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,8 @@ Restart Pi after installation.
|
|
|
42
42
|
/anycopy
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
You can also open the overlay via the configurable shortcut in `config.json` without clearing the current editor draft. The default is **ctrl+`**.
|
|
46
|
+
|
|
45
47
|
## Keys
|
|
46
48
|
|
|
47
49
|
Defaults (customizable in `config.json`):
|
|
@@ -54,7 +56,7 @@ Defaults (customizable in `config.json`):
|
|
|
54
56
|
| `Shift+L` | Label node (native tree behavior) |
|
|
55
57
|
| `Shift+Up` / `Shift+Down` | Scroll node preview by line |
|
|
56
58
|
| `Shift+Left` / `Shift+Right` | Page through node preview |
|
|
57
|
-
| `Esc` | Close |
|
|
59
|
+
| `Esc`, or configured global `shortcut` | Close |
|
|
58
60
|
|
|
59
61
|
Notes:
|
|
60
62
|
- If no nodes are selected, `Shift+C` copies the focused node
|
|
@@ -66,8 +68,17 @@ Notes:
|
|
|
66
68
|
|
|
67
69
|
Edit `~/.pi/agent/extensions/anycopy/config.json`:
|
|
68
70
|
|
|
71
|
+
- `shortcut`: global shortcut that opens the `/anycopy` overlay while preserving whatever is currently in the editor
|
|
72
|
+
- default: **ctrl+`**
|
|
73
|
+
- set to `null` to disable it, or change it to another Pi key id such as `ctrl+a`
|
|
74
|
+
- `treeFilterMode`: initial tree filter mode when opening `/anycopy` (idea sourced from [lajarre](https://github.com/lajarre)'s [pi-mono/issues/1845](https://github.com/badlogic/pi-mono/issues/1845))
|
|
75
|
+
- one of: `default` | `no-tools` | `user-only` | `labeled-only` | `all`
|
|
76
|
+
- `keys`: keybindings used inside the `/anycopy` overlay (see above)
|
|
77
|
+
|
|
69
78
|
```json
|
|
70
79
|
{
|
|
80
|
+
"shortcut": "ctrl+`",
|
|
81
|
+
"treeFilterMode": "default",
|
|
71
82
|
"keys": {
|
|
72
83
|
"toggleSelect": "space",
|
|
73
84
|
"copy": "shift+c",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Esc - close
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
17
17
|
import {
|
|
18
18
|
copyToClipboard,
|
|
19
19
|
getLanguageFromPath,
|
|
@@ -45,8 +45,18 @@ type anycopyKeyConfig = {
|
|
|
45
45
|
pageUp: string;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
type TreeFilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
|
49
|
+
|
|
48
50
|
type anycopyConfig = {
|
|
51
|
+
shortcut?: string | null;
|
|
49
52
|
keys?: Partial<anycopyKeyConfig>;
|
|
53
|
+
treeFilterMode?: TreeFilterMode;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type anycopyRuntimeConfig = {
|
|
57
|
+
shortcut: string | null;
|
|
58
|
+
keys: anycopyKeyConfig;
|
|
59
|
+
treeFilterMode: TreeFilterMode;
|
|
50
60
|
};
|
|
51
61
|
|
|
52
62
|
const DEFAULT_KEYS: anycopyKeyConfig = {
|
|
@@ -59,31 +69,57 @@ const DEFAULT_KEYS: anycopyKeyConfig = {
|
|
|
59
69
|
pageUp: "shift+left",
|
|
60
70
|
};
|
|
61
71
|
|
|
72
|
+
const DEFAULT_TREE_FILTER_MODE: TreeFilterMode = "default";
|
|
73
|
+
const DEFAULT_SHORTCUT = "ctrl+`";
|
|
74
|
+
|
|
62
75
|
const getExtensionDir = (): string => {
|
|
63
76
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
64
77
|
if (typeof __dirname !== "undefined") return __dirname;
|
|
65
78
|
return dirname(fileURLToPath(import.meta.url));
|
|
66
79
|
};
|
|
67
80
|
|
|
68
|
-
const loadConfig = ():
|
|
81
|
+
const loadConfig = (): anycopyRuntimeConfig => {
|
|
69
82
|
const configPath = join(getExtensionDir(), "config.json");
|
|
70
|
-
if (!existsSync(configPath))
|
|
83
|
+
if (!existsSync(configPath)) {
|
|
84
|
+
return {
|
|
85
|
+
shortcut: DEFAULT_SHORTCUT,
|
|
86
|
+
keys: { ...DEFAULT_KEYS },
|
|
87
|
+
treeFilterMode: DEFAULT_TREE_FILTER_MODE,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
71
90
|
|
|
72
91
|
try {
|
|
73
92
|
const raw = readFileSync(configPath, "utf8");
|
|
74
93
|
const parsed = JSON.parse(raw) as anycopyConfig;
|
|
75
94
|
const keys = parsed.keys ?? {};
|
|
95
|
+
const shortcut =
|
|
96
|
+
parsed.shortcut === null ? null : typeof parsed.shortcut === "string" ? parsed.shortcut : DEFAULT_SHORTCUT;
|
|
97
|
+
const treeFilterModeRaw = parsed.treeFilterMode;
|
|
98
|
+
const validTreeFilterModes: TreeFilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
|
|
99
|
+
const treeFilterMode =
|
|
100
|
+
typeof treeFilterModeRaw === "string" && validTreeFilterModes.includes(treeFilterModeRaw as TreeFilterMode)
|
|
101
|
+
? (treeFilterModeRaw as TreeFilterMode)
|
|
102
|
+
: DEFAULT_TREE_FILTER_MODE;
|
|
103
|
+
|
|
76
104
|
return {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
105
|
+
shortcut,
|
|
106
|
+
keys: {
|
|
107
|
+
toggleSelect: typeof keys.toggleSelect === "string" ? keys.toggleSelect : DEFAULT_KEYS.toggleSelect,
|
|
108
|
+
copy: typeof keys.copy === "string" ? keys.copy : DEFAULT_KEYS.copy,
|
|
109
|
+
clear: typeof keys.clear === "string" ? keys.clear : DEFAULT_KEYS.clear,
|
|
110
|
+
scrollDown: typeof keys.scrollDown === "string" ? keys.scrollDown : DEFAULT_KEYS.scrollDown,
|
|
111
|
+
scrollUp: typeof keys.scrollUp === "string" ? keys.scrollUp : DEFAULT_KEYS.scrollUp,
|
|
112
|
+
pageDown: typeof keys.pageDown === "string" ? keys.pageDown : DEFAULT_KEYS.pageDown,
|
|
113
|
+
pageUp: typeof keys.pageUp === "string" ? keys.pageUp : DEFAULT_KEYS.pageUp,
|
|
114
|
+
},
|
|
115
|
+
treeFilterMode,
|
|
84
116
|
};
|
|
85
117
|
} catch {
|
|
86
|
-
return {
|
|
118
|
+
return {
|
|
119
|
+
shortcut: DEFAULT_SHORTCUT,
|
|
120
|
+
keys: { ...DEFAULT_KEYS },
|
|
121
|
+
treeFilterMode: DEFAULT_TREE_FILTER_MODE,
|
|
122
|
+
};
|
|
87
123
|
}
|
|
88
124
|
};
|
|
89
125
|
|
|
@@ -341,6 +377,8 @@ class anycopyOverlay implements Focusable {
|
|
|
341
377
|
private getTree: () => SessionTreeNode[],
|
|
342
378
|
private nodeById: Map<string, SessionTreeNode>,
|
|
343
379
|
private keys: anycopyKeyConfig,
|
|
380
|
+
private closeShortcut: string | null,
|
|
381
|
+
private onClose: () => void,
|
|
344
382
|
private getTermHeight: () => number,
|
|
345
383
|
private requestRender: () => void,
|
|
346
384
|
private theme: any,
|
|
@@ -359,6 +397,10 @@ class anycopyOverlay implements Focusable {
|
|
|
359
397
|
}
|
|
360
398
|
|
|
361
399
|
handleInput(data: string): void {
|
|
400
|
+
if (this.closeShortcut && matchesKey(data, this.closeShortcut)) {
|
|
401
|
+
this.onClose();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
362
404
|
if (matchesKey(data, this.keys.toggleSelect)) {
|
|
363
405
|
this.toggleSelectedFocusedNode();
|
|
364
406
|
return;
|
|
@@ -509,11 +551,12 @@ class anycopyOverlay implements Focusable {
|
|
|
509
551
|
}
|
|
510
552
|
|
|
511
553
|
private renderTreeHeaderHint(width: number): string {
|
|
554
|
+
const closeHint = this.closeShortcut ? `${formatKeyHint(this.closeShortcut)}/Esc: close` : "Esc: close";
|
|
512
555
|
const hint =
|
|
513
556
|
` │ ${formatKeyHint(this.keys.toggleSelect)}: select` +
|
|
514
557
|
` • ${formatKeyHint(this.keys.copy)}: copy` +
|
|
515
558
|
` • ${formatKeyHint(this.keys.clear)}: clear` +
|
|
516
|
-
|
|
559
|
+
` • ${closeHint}`;
|
|
517
560
|
return truncateToWidth(this.theme.fg("dim", hint), width);
|
|
518
561
|
}
|
|
519
562
|
|
|
@@ -626,103 +669,126 @@ class anycopyOverlay implements Focusable {
|
|
|
626
669
|
}
|
|
627
670
|
|
|
628
671
|
export default function anycopyExtension(pi: ExtensionAPI) {
|
|
629
|
-
const
|
|
672
|
+
const config = loadConfig();
|
|
673
|
+
const shortcut = config.shortcut;
|
|
674
|
+
const keys = config.keys;
|
|
675
|
+
const treeFilterMode = config.treeFilterMode;
|
|
630
676
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
handler: async (args, ctx: ExtensionCommandContext) => {
|
|
634
|
-
if (!ctx.hasUI) return;
|
|
677
|
+
const openAnycopy = async (ctx: ExtensionContext) => {
|
|
678
|
+
if (!ctx.hasUI) return;
|
|
635
679
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
680
|
+
const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
|
|
681
|
+
if (initialTree.length === 0) {
|
|
682
|
+
ctx.ui.notify("No entries in session", "warning");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
|
|
687
|
+
const currentLeafId = ctx.sessionManager.getLeafId();
|
|
688
|
+
|
|
689
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
690
|
+
const termRows = tui.terminal?.rows ?? 40;
|
|
691
|
+
// Pass reduced height so tree takes ~35% of terminal (it uses floor(h/2) internally)
|
|
692
|
+
const treeTermHeight = Math.floor(termRows * 0.65);
|
|
693
|
+
|
|
694
|
+
const selector = new TreeSelectorComponent(
|
|
695
|
+
initialTree,
|
|
696
|
+
currentLeafId,
|
|
697
|
+
treeTermHeight,
|
|
698
|
+
() => {
|
|
699
|
+
// Intentionally ignore Enter: closing on Enter is counterintuitive here.
|
|
700
|
+
// Use Esc to close the overlay.
|
|
701
|
+
},
|
|
702
|
+
() => done(),
|
|
703
|
+
(entryId, label) => {
|
|
704
|
+
pi.setLabel(entryId, label);
|
|
705
|
+
},
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
// Build a node map once for parent traversal (toolResult → parent assistant toolCall args)
|
|
709
|
+
const nodeById = buildNodeMap(initialTree);
|
|
710
|
+
|
|
711
|
+
const overlay = new anycopyOverlay(
|
|
712
|
+
selector,
|
|
713
|
+
getTree,
|
|
714
|
+
nodeById,
|
|
715
|
+
keys,
|
|
716
|
+
shortcut,
|
|
717
|
+
() => done(),
|
|
718
|
+
() => tui.terminal?.rows ?? 40,
|
|
719
|
+
() => tui.requestRender(),
|
|
720
|
+
theme,
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
const treeList = selector.getTreeList();
|
|
724
|
+
|
|
725
|
+
// Set initial tree filter mode (same semantics as `/tree`)
|
|
726
|
+
const rawTreeList = treeList as any;
|
|
727
|
+
if (rawTreeList && typeof rawTreeList === "object") {
|
|
728
|
+
rawTreeList.filterMode = treeFilterMode;
|
|
729
|
+
if (typeof rawTreeList.applyFilter === "function") rawTreeList.applyFilter();
|
|
640
730
|
}
|
|
641
731
|
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
// Pass reduced height so tree takes ~35% of terminal (it uses floor(h/2) internally)
|
|
648
|
-
const treeTermHeight = Math.floor(termRows * 0.65);
|
|
649
|
-
|
|
650
|
-
const selector = new TreeSelectorComponent(
|
|
651
|
-
initialTree,
|
|
652
|
-
currentLeafId,
|
|
653
|
-
treeTermHeight,
|
|
654
|
-
() => {
|
|
655
|
-
// Intentionally ignore Enter: closing on Enter is counterintuitive here.
|
|
656
|
-
// Use Esc to close the overlay.
|
|
657
|
-
},
|
|
658
|
-
() => done(),
|
|
659
|
-
(entryId, label) => {
|
|
660
|
-
pi.setLabel(entryId, label);
|
|
661
|
-
},
|
|
662
|
-
);
|
|
732
|
+
// Monkey-patch render to inject checkbox markers (✓/○) into tree rows
|
|
733
|
+
const originalRender = treeList.render.bind(treeList);
|
|
734
|
+
treeList.render = (width: number) => {
|
|
735
|
+
const innerWidth = Math.max(10, width - 2);
|
|
736
|
+
const lines = originalRender(innerWidth);
|
|
663
737
|
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
738
|
+
const tl = treeList as any;
|
|
739
|
+
const filteredRaw = tl.filteredNodes;
|
|
740
|
+
if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
|
|
741
|
+
return lines.map((line: string) => " " + line);
|
|
742
|
+
}
|
|
743
|
+
const filtered = filteredRaw as { node: SessionTreeNode }[];
|
|
744
|
+
|
|
745
|
+
const selectedIdxRaw = tl.selectedIndex;
|
|
746
|
+
const maxVisibleRaw = tl.maxVisibleLines;
|
|
747
|
+
const selectedIdx =
|
|
748
|
+
typeof selectedIdxRaw === "number" && Number.isFinite(selectedIdxRaw) ? selectedIdxRaw : 0;
|
|
749
|
+
const maxVisible =
|
|
750
|
+
typeof maxVisibleRaw === "number" && Number.isFinite(maxVisibleRaw) && maxVisibleRaw > 0
|
|
751
|
+
? maxVisibleRaw
|
|
752
|
+
: filtered.length;
|
|
753
|
+
|
|
754
|
+
const startIdx = Math.max(
|
|
755
|
+
0,
|
|
756
|
+
Math.min(selectedIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
|
|
675
757
|
);
|
|
758
|
+
const treeRowCount = Math.max(0, lines.length - 1);
|
|
676
759
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
const startIdx = Math.max(
|
|
702
|
-
0,
|
|
703
|
-
Math.min(selectedIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
|
|
704
|
-
);
|
|
705
|
-
const treeRowCount = Math.max(0, lines.length - 1);
|
|
706
|
-
|
|
707
|
-
return lines.map((line: string, i: number) => {
|
|
708
|
-
if (i >= treeRowCount) return " " + line;
|
|
709
|
-
|
|
710
|
-
const nodeIdx = startIdx + i;
|
|
711
|
-
const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
|
|
712
|
-
const nodeId = node?.entry?.id;
|
|
713
|
-
if (typeof nodeId !== "string") return " " + line;
|
|
714
|
-
|
|
715
|
-
const selected = overlay.isSelectedNode(nodeId);
|
|
716
|
-
const marker = selected
|
|
717
|
-
? theme.fg("success", "\u2713 ")
|
|
718
|
-
: theme.fg("dim", "\u25CB ");
|
|
719
|
-
return marker + line;
|
|
720
|
-
});
|
|
721
|
-
};
|
|
722
|
-
|
|
723
|
-
tui.setFocus?.(overlay);
|
|
724
|
-
return overlay;
|
|
725
|
-
});
|
|
760
|
+
return lines.map((line: string, i: number) => {
|
|
761
|
+
if (i >= treeRowCount) return " " + line;
|
|
762
|
+
|
|
763
|
+
const nodeIdx = startIdx + i;
|
|
764
|
+
const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
|
|
765
|
+
const nodeId = node?.entry?.id;
|
|
766
|
+
if (typeof nodeId !== "string") return " " + line;
|
|
767
|
+
|
|
768
|
+
const selected = overlay.isSelectedNode(nodeId);
|
|
769
|
+
const marker = selected ? theme.fg("success", "\u2713 ") : theme.fg("dim", "\u25CB ");
|
|
770
|
+
return marker + line;
|
|
771
|
+
});
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
tui.setFocus?.(overlay);
|
|
775
|
+
return overlay;
|
|
776
|
+
});
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
pi.registerCommand("anycopy", {
|
|
780
|
+
description: "Browse session tree with preview and copy any node(s) to clipboard",
|
|
781
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
782
|
+
await openAnycopy(ctx);
|
|
726
783
|
},
|
|
727
784
|
});
|
|
785
|
+
|
|
786
|
+
if (shortcut) {
|
|
787
|
+
pi.registerShortcut(shortcut as any, {
|
|
788
|
+
description: "Open anycopy session tree overlay",
|
|
789
|
+
handler: async (ctx) => {
|
|
790
|
+
await openAnycopy(ctx);
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
}
|
|
728
794
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-anycopy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Copy any single message, or multiple selected messages, from the session tree, with scrollable message preview",
|
|
5
5
|
"keywords": ["pi-package", "pi", "pi-coding-agent"],
|
|
6
6
|
"license": "MIT",
|