pi-anycopy 0.1.3 → 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 +8 -2
- package/extensions/anycopy/config.json +1 -0
- package/extensions/anycopy/index.ts +133 -98
- 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,12 +68,16 @@ 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`
|
|
69
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))
|
|
70
75
|
- one of: `default` | `no-tools` | `user-only` | `labeled-only` | `all`
|
|
71
|
-
- `keys`: keybindings (see above)
|
|
76
|
+
- `keys`: keybindings used inside the `/anycopy` overlay (see above)
|
|
72
77
|
|
|
73
78
|
```json
|
|
74
79
|
{
|
|
80
|
+
"shortcut": "ctrl+`",
|
|
75
81
|
"treeFilterMode": "default",
|
|
76
82
|
"keys": {
|
|
77
83
|
"toggleSelect": "space",
|
|
@@ -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,
|
|
@@ -48,11 +48,13 @@ type anycopyKeyConfig = {
|
|
|
48
48
|
type TreeFilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
|
49
49
|
|
|
50
50
|
type anycopyConfig = {
|
|
51
|
+
shortcut?: string | null;
|
|
51
52
|
keys?: Partial<anycopyKeyConfig>;
|
|
52
53
|
treeFilterMode?: TreeFilterMode;
|
|
53
54
|
};
|
|
54
55
|
|
|
55
56
|
type anycopyRuntimeConfig = {
|
|
57
|
+
shortcut: string | null;
|
|
56
58
|
keys: anycopyKeyConfig;
|
|
57
59
|
treeFilterMode: TreeFilterMode;
|
|
58
60
|
};
|
|
@@ -68,6 +70,7 @@ const DEFAULT_KEYS: anycopyKeyConfig = {
|
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
const DEFAULT_TREE_FILTER_MODE: TreeFilterMode = "default";
|
|
73
|
+
const DEFAULT_SHORTCUT = "ctrl+`";
|
|
71
74
|
|
|
72
75
|
const getExtensionDir = (): string => {
|
|
73
76
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
@@ -78,13 +81,19 @@ const getExtensionDir = (): string => {
|
|
|
78
81
|
const loadConfig = (): anycopyRuntimeConfig => {
|
|
79
82
|
const configPath = join(getExtensionDir(), "config.json");
|
|
80
83
|
if (!existsSync(configPath)) {
|
|
81
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
shortcut: DEFAULT_SHORTCUT,
|
|
86
|
+
keys: { ...DEFAULT_KEYS },
|
|
87
|
+
treeFilterMode: DEFAULT_TREE_FILTER_MODE,
|
|
88
|
+
};
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
try {
|
|
85
92
|
const raw = readFileSync(configPath, "utf8");
|
|
86
93
|
const parsed = JSON.parse(raw) as anycopyConfig;
|
|
87
94
|
const keys = parsed.keys ?? {};
|
|
95
|
+
const shortcut =
|
|
96
|
+
parsed.shortcut === null ? null : typeof parsed.shortcut === "string" ? parsed.shortcut : DEFAULT_SHORTCUT;
|
|
88
97
|
const treeFilterModeRaw = parsed.treeFilterMode;
|
|
89
98
|
const validTreeFilterModes: TreeFilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
|
|
90
99
|
const treeFilterMode =
|
|
@@ -93,6 +102,7 @@ const loadConfig = (): anycopyRuntimeConfig => {
|
|
|
93
102
|
: DEFAULT_TREE_FILTER_MODE;
|
|
94
103
|
|
|
95
104
|
return {
|
|
105
|
+
shortcut,
|
|
96
106
|
keys: {
|
|
97
107
|
toggleSelect: typeof keys.toggleSelect === "string" ? keys.toggleSelect : DEFAULT_KEYS.toggleSelect,
|
|
98
108
|
copy: typeof keys.copy === "string" ? keys.copy : DEFAULT_KEYS.copy,
|
|
@@ -105,7 +115,11 @@ const loadConfig = (): anycopyRuntimeConfig => {
|
|
|
105
115
|
treeFilterMode,
|
|
106
116
|
};
|
|
107
117
|
} catch {
|
|
108
|
-
return {
|
|
118
|
+
return {
|
|
119
|
+
shortcut: DEFAULT_SHORTCUT,
|
|
120
|
+
keys: { ...DEFAULT_KEYS },
|
|
121
|
+
treeFilterMode: DEFAULT_TREE_FILTER_MODE,
|
|
122
|
+
};
|
|
109
123
|
}
|
|
110
124
|
};
|
|
111
125
|
|
|
@@ -363,6 +377,8 @@ class anycopyOverlay implements Focusable {
|
|
|
363
377
|
private getTree: () => SessionTreeNode[],
|
|
364
378
|
private nodeById: Map<string, SessionTreeNode>,
|
|
365
379
|
private keys: anycopyKeyConfig,
|
|
380
|
+
private closeShortcut: string | null,
|
|
381
|
+
private onClose: () => void,
|
|
366
382
|
private getTermHeight: () => number,
|
|
367
383
|
private requestRender: () => void,
|
|
368
384
|
private theme: any,
|
|
@@ -381,6 +397,10 @@ class anycopyOverlay implements Focusable {
|
|
|
381
397
|
}
|
|
382
398
|
|
|
383
399
|
handleInput(data: string): void {
|
|
400
|
+
if (this.closeShortcut && matchesKey(data, this.closeShortcut)) {
|
|
401
|
+
this.onClose();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
384
404
|
if (matchesKey(data, this.keys.toggleSelect)) {
|
|
385
405
|
this.toggleSelectedFocusedNode();
|
|
386
406
|
return;
|
|
@@ -531,11 +551,12 @@ class anycopyOverlay implements Focusable {
|
|
|
531
551
|
}
|
|
532
552
|
|
|
533
553
|
private renderTreeHeaderHint(width: number): string {
|
|
554
|
+
const closeHint = this.closeShortcut ? `${formatKeyHint(this.closeShortcut)}/Esc: close` : "Esc: close";
|
|
534
555
|
const hint =
|
|
535
556
|
` │ ${formatKeyHint(this.keys.toggleSelect)}: select` +
|
|
536
557
|
` • ${formatKeyHint(this.keys.copy)}: copy` +
|
|
537
558
|
` • ${formatKeyHint(this.keys.clear)}: clear` +
|
|
538
|
-
|
|
559
|
+
` • ${closeHint}`;
|
|
539
560
|
return truncateToWidth(this.theme.fg("dim", hint), width);
|
|
540
561
|
}
|
|
541
562
|
|
|
@@ -649,111 +670,125 @@ class anycopyOverlay implements Focusable {
|
|
|
649
670
|
|
|
650
671
|
export default function anycopyExtension(pi: ExtensionAPI) {
|
|
651
672
|
const config = loadConfig();
|
|
673
|
+
const shortcut = config.shortcut;
|
|
652
674
|
const keys = config.keys;
|
|
653
675
|
const treeFilterMode = config.treeFilterMode;
|
|
654
676
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
677
|
+
const openAnycopy = async (ctx: ExtensionContext) => {
|
|
678
|
+
if (!ctx.hasUI) return;
|
|
679
|
+
|
|
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
|
+
);
|
|
659
722
|
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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();
|
|
664
730
|
}
|
|
665
731
|
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
const
|
|
671
|
-
// Pass reduced height so tree takes ~35% of terminal (it uses floor(h/2) internally)
|
|
672
|
-
const treeTermHeight = Math.floor(termRows * 0.65);
|
|
673
|
-
|
|
674
|
-
const selector = new TreeSelectorComponent(
|
|
675
|
-
initialTree,
|
|
676
|
-
currentLeafId,
|
|
677
|
-
treeTermHeight,
|
|
678
|
-
() => {
|
|
679
|
-
// Intentionally ignore Enter: closing on Enter is counterintuitive here.
|
|
680
|
-
// Use Esc to close the overlay.
|
|
681
|
-
},
|
|
682
|
-
() => done(),
|
|
683
|
-
(entryId, label) => {
|
|
684
|
-
pi.setLabel(entryId, label);
|
|
685
|
-
},
|
|
686
|
-
);
|
|
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);
|
|
687
737
|
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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),
|
|
699
757
|
);
|
|
758
|
+
const treeRowCount = Math.max(0, lines.length - 1);
|
|
700
759
|
|
|
701
|
-
|
|
760
|
+
return lines.map((line: string, i: number) => {
|
|
761
|
+
if (i >= treeRowCount) return " " + line;
|
|
702
762
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
if (typeof rawTreeList.applyFilter === "function") rawTreeList.applyFilter();
|
|
708
|
-
}
|
|
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;
|
|
709
767
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
};
|
|
753
|
-
|
|
754
|
-
tui.setFocus?.(overlay);
|
|
755
|
-
return overlay;
|
|
756
|
-
});
|
|
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);
|
|
757
783
|
},
|
|
758
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
|
+
}
|
|
759
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",
|