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 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",
@@ -1,4 +1,6 @@
1
1
  {
2
+ "shortcut": "ctrl+`",
3
+ "treeFilterMode": "no-tools",
2
4
  "keys": {
3
5
  "toggleSelect": "space",
4
6
  "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 = (): anycopyKeyConfig => {
81
+ const loadConfig = (): anycopyRuntimeConfig => {
69
82
  const configPath = join(getExtensionDir(), "config.json");
70
- if (!existsSync(configPath)) return { ...DEFAULT_KEYS };
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
- toggleSelect: typeof keys.toggleSelect === "string" ? keys.toggleSelect : DEFAULT_KEYS.toggleSelect,
78
- copy: typeof keys.copy === "string" ? keys.copy : DEFAULT_KEYS.copy,
79
- clear: typeof keys.clear === "string" ? keys.clear : DEFAULT_KEYS.clear,
80
- scrollDown: typeof keys.scrollDown === "string" ? keys.scrollDown : DEFAULT_KEYS.scrollDown,
81
- scrollUp: typeof keys.scrollUp === "string" ? keys.scrollUp : DEFAULT_KEYS.scrollUp,
82
- pageDown: typeof keys.pageDown === "string" ? keys.pageDown : DEFAULT_KEYS.pageDown,
83
- pageUp: typeof keys.pageUp === "string" ? keys.pageUp : DEFAULT_KEYS.pageUp,
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 { ...DEFAULT_KEYS };
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
- "Esc: close";
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 keys = loadConfig();
672
+ const config = loadConfig();
673
+ const shortcut = config.shortcut;
674
+ const keys = config.keys;
675
+ const treeFilterMode = config.treeFilterMode;
630
676
 
631
- pi.registerCommand("anycopy", {
632
- description: "Browse session tree with preview and copy any node(s) to clipboard",
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
- const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
637
- if (initialTree.length === 0) {
638
- ctx.ui.notify("No entries in session", "warning");
639
- return;
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
- const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
643
- const currentLeafId = ctx.sessionManager.getLeafId();
644
-
645
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
646
- const termRows = tui.terminal?.rows ?? 40;
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
- // Build a node map once for parent traversal (toolResult → parent assistant toolCall args)
665
- const nodeById = buildNodeMap(initialTree);
666
-
667
- const overlay = new anycopyOverlay(
668
- selector,
669
- getTree,
670
- nodeById,
671
- keys,
672
- () => tui.terminal?.rows ?? 40,
673
- () => tui.requestRender(),
674
- theme,
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
- const treeList = selector.getTreeList();
678
-
679
- // Monkey-patch render to inject checkbox markers (✓/○) into tree rows
680
- const originalRender = treeList.render.bind(treeList);
681
- treeList.render = (width: number) => {
682
- const innerWidth = Math.max(10, width - 2);
683
- const lines = originalRender(innerWidth);
684
-
685
- const tl = treeList as any;
686
- const filteredRaw = tl.filteredNodes;
687
- if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
688
- return lines.map((line: string) => " " + line);
689
- }
690
- const filtered = filteredRaw as { node: SessionTreeNode }[];
691
-
692
- const selectedIdxRaw = tl.selectedIndex;
693
- const maxVisibleRaw = tl.maxVisibleLines;
694
- const selectedIdx =
695
- typeof selectedIdxRaw === "number" && Number.isFinite(selectedIdxRaw) ? selectedIdxRaw : 0;
696
- const maxVisible =
697
- typeof maxVisibleRaw === "number" && Number.isFinite(maxVisibleRaw) && maxVisibleRaw > 0
698
- ? maxVisibleRaw
699
- : filtered.length;
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.2",
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",