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 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",
@@ -1,4 +1,5 @@
1
1
  {
2
+ "shortcut": "ctrl+`",
2
3
  "treeFilterMode": "no-tools",
3
4
  "keys": {
4
5
  "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 { keys: { ...DEFAULT_KEYS }, treeFilterMode: DEFAULT_TREE_FILTER_MODE };
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 { keys: { ...DEFAULT_KEYS }, treeFilterMode: DEFAULT_TREE_FILTER_MODE };
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
- "Esc: close";
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
- pi.registerCommand("anycopy", {
656
- description: "Browse session tree with preview and copy any node(s) to clipboard",
657
- handler: async (args, ctx: ExtensionCommandContext) => {
658
- if (!ctx.hasUI) return;
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 initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
661
- if (initialTree.length === 0) {
662
- ctx.ui.notify("No entries in session", "warning");
663
- return;
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
- const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
667
- const currentLeafId = ctx.sessionManager.getLeafId();
668
-
669
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
670
- const termRows = tui.terminal?.rows ?? 40;
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
- // Build a node map once for parent traversal (toolResult → parent assistant toolCall args)
689
- const nodeById = buildNodeMap(initialTree);
690
-
691
- const overlay = new anycopyOverlay(
692
- selector,
693
- getTree,
694
- nodeById,
695
- keys,
696
- () => tui.terminal?.rows ?? 40,
697
- () => tui.requestRender(),
698
- 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),
699
757
  );
758
+ const treeRowCount = Math.max(0, lines.length - 1);
700
759
 
701
- const treeList = selector.getTreeList();
760
+ return lines.map((line: string, i: number) => {
761
+ if (i >= treeRowCount) return " " + line;
702
762
 
703
- // Set initial tree filter mode (same semantics as `/tree`)
704
- const rawTreeList = treeList as any;
705
- if (rawTreeList && typeof rawTreeList === "object") {
706
- rawTreeList.filterMode = treeFilterMode;
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
- // Monkey-patch render to inject checkbox markers (✓/○) into tree rows
711
- const originalRender = treeList.render.bind(treeList);
712
- treeList.render = (width: number) => {
713
- const innerWidth = Math.max(10, width - 2);
714
- const lines = originalRender(innerWidth);
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
- };
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",
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",