pi-anycopy 0.1.4 → 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 CHANGED
@@ -1,8 +1,6 @@
1
1
  # anycopy for Pi (`pi-anycopy`)
2
2
 
3
- Browse session tree nodes with a live preview and copy any of them to the clipboard.
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" />
@@ -42,42 +40,43 @@ Restart Pi after installation.
42
40
  /anycopy
43
41
  ```
44
42
 
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
-
47
43
  ## Keys
48
44
 
49
45
  Defaults (customizable in `config.json`):
50
46
 
51
47
  | Key | Action |
52
48
  |-----|--------|
53
- | `Space` | Select/unselect focused node |
54
- | `Shift+C` | Copy selected nodes (or focused node if nothing is selected) |
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 |
55
52
  | `Shift+X` | Clear selection |
56
53
  | `Shift+L` | Label node (native tree behavior) |
57
54
  | `Shift+Up` / `Shift+Down` | Scroll node preview by line |
58
55
  | `Shift+Left` / `Shift+Right` | Page through node preview |
59
- | `Esc`, or configured global `shortcut` | Close |
56
+ | `Esc` | Close |
60
57
 
61
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
62
64
  - If no nodes are selected, `Shift+C` copies the focused node
63
- - When copying multiple selected nodes, they are auto-sorted chronologically (by position in the session tree), not by selection order
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
64
68
  - Label edits are persisted via `pi.setLabel(...)`
65
- - 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)
66
69
 
67
70
  ## Configuration
68
71
 
69
72
  Edit `~/.pi/agent/extensions/anycopy/config.json`:
70
73
 
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))
74
+ - `treeFilterMode`: initial tree filter mode when opening `/anycopy`; defaults to `default` to match `/tree`
75
75
  - one of: `default` | `no-tools` | `user-only` | `labeled-only` | `all`
76
- - `keys`: keybindings used inside the `/anycopy` overlay (see above)
76
+ - `keys`: keybindings used inside the `/anycopy` overlay for copy/preview actions
77
77
 
78
78
  ```json
79
79
  {
80
- "shortcut": "ctrl+`",
81
80
  "treeFilterMode": "default",
82
81
  "keys": {
83
82
  "toggleSelect": "space",
@@ -1,5 +1,4 @@
1
1
  {
2
- "shortcut": "ctrl+`",
3
2
  "treeFilterMode": "no-tools",
4
3
  "keys": {
5
4
  "toggleSelect": "space",
@@ -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
+ }
@@ -13,7 +13,7 @@
13
13
  * Esc - close
14
14
  */
15
15
 
16
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
16
+ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@mariozechner/pi-coding-agent";
17
17
  import {
18
18
  copyToClipboard,
19
19
  getLanguageFromPath,
@@ -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[];
@@ -48,17 +51,21 @@ type anycopyKeyConfig = {
48
51
  type TreeFilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
49
52
 
50
53
  type anycopyConfig = {
51
- shortcut?: string | null;
52
54
  keys?: Partial<anycopyKeyConfig>;
53
55
  treeFilterMode?: TreeFilterMode;
54
56
  };
55
57
 
56
58
  type anycopyRuntimeConfig = {
57
- shortcut: string | null;
58
59
  keys: anycopyKeyConfig;
59
60
  treeFilterMode: TreeFilterMode;
60
61
  };
61
62
 
63
+ type BranchSummarySettingsFile = {
64
+ branchSummary?: {
65
+ skipPrompt?: boolean;
66
+ };
67
+ };
68
+
62
69
  const DEFAULT_KEYS: anycopyKeyConfig = {
63
70
  toggleSelect: "space",
64
71
  copy: "shift+c",
@@ -70,7 +77,6 @@ const DEFAULT_KEYS: anycopyKeyConfig = {
70
77
  };
71
78
 
72
79
  const DEFAULT_TREE_FILTER_MODE: TreeFilterMode = "default";
73
- const DEFAULT_SHORTCUT = "ctrl+`";
74
80
 
75
81
  const getExtensionDir = (): string => {
76
82
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -78,11 +84,32 @@ const getExtensionDir = (): string => {
78
84
  return dirname(fileURLToPath(import.meta.url));
79
85
  };
80
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
+
81
109
  const loadConfig = (): anycopyRuntimeConfig => {
82
110
  const configPath = join(getExtensionDir(), "config.json");
83
111
  if (!existsSync(configPath)) {
84
112
  return {
85
- shortcut: DEFAULT_SHORTCUT,
86
113
  keys: { ...DEFAULT_KEYS },
87
114
  treeFilterMode: DEFAULT_TREE_FILTER_MODE,
88
115
  };
@@ -92,8 +119,6 @@ const loadConfig = (): anycopyRuntimeConfig => {
92
119
  const raw = readFileSync(configPath, "utf8");
93
120
  const parsed = JSON.parse(raw) as anycopyConfig;
94
121
  const keys = parsed.keys ?? {};
95
- const shortcut =
96
- parsed.shortcut === null ? null : typeof parsed.shortcut === "string" ? parsed.shortcut : DEFAULT_SHORTCUT;
97
122
  const treeFilterModeRaw = parsed.treeFilterMode;
98
123
  const validTreeFilterModes: TreeFilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
99
124
  const treeFilterMode =
@@ -102,7 +127,6 @@ const loadConfig = (): anycopyRuntimeConfig => {
102
127
  : DEFAULT_TREE_FILTER_MODE;
103
128
 
104
129
  return {
105
- shortcut,
106
130
  keys: {
107
131
  toggleSelect: typeof keys.toggleSelect === "string" ? keys.toggleSelect : DEFAULT_KEYS.toggleSelect,
108
132
  copy: typeof keys.copy === "string" ? keys.copy : DEFAULT_KEYS.copy,
@@ -116,7 +140,6 @@ const loadConfig = (): anycopyRuntimeConfig => {
116
140
  };
117
141
  } catch {
118
142
  return {
119
- shortcut: DEFAULT_SHORTCUT,
120
143
  keys: { ...DEFAULT_KEYS },
121
144
  treeFilterMode: DEFAULT_TREE_FILTER_MODE,
122
145
  };
@@ -345,10 +368,14 @@ const buildNodeOrder = (roots: SessionTreeNode[]): Map<string, number> => {
345
368
  return order;
346
369
  };
347
370
 
348
- /** Clipboard text: role:\n\ncontent\n\n---\n\nrole:\n\ncontent
371
+ /** Clipboard text omits role prefix for a single node and includes it for multi-node copies
349
372
  * The preview pane is truncated for performance, while the clipboard copy is not
350
373
  */
351
374
  const buildClipboardText = (nodes: SessionTreeNode[]): string => {
375
+ if (nodes.length === 1) {
376
+ return getEntryContent(nodes[0]!.entry);
377
+ }
378
+
352
379
  return nodes
353
380
  .map((node) => {
354
381
  const label = getEntryRoleLabel(node.entry);
@@ -377,7 +404,6 @@ class anycopyOverlay implements Focusable {
377
404
  private getTree: () => SessionTreeNode[],
378
405
  private nodeById: Map<string, SessionTreeNode>,
379
406
  private keys: anycopyKeyConfig,
380
- private closeShortcut: string | null,
381
407
  private onClose: () => void,
382
408
  private getTermHeight: () => number,
383
409
  private requestRender: () => void,
@@ -397,10 +423,12 @@ class anycopyOverlay implements Focusable {
397
423
  }
398
424
 
399
425
  handleInput(data: string): void {
400
- if (this.closeShortcut && matchesKey(data, this.closeShortcut)) {
401
- this.onClose();
426
+ if (this.isEditingNodeLabel()) {
427
+ this.selector.handleInput(data);
428
+ this.requestRender();
402
429
  return;
403
430
  }
431
+
404
432
  if (matchesKey(data, this.keys.toggleSelect)) {
405
433
  this.toggleSelectedFocusedNode();
406
434
  return;
@@ -441,6 +469,10 @@ class anycopyOverlay implements Focusable {
441
469
  this.requestRender();
442
470
  }
443
471
 
472
+ private isEditingNodeLabel(): boolean {
473
+ return Boolean((this.selector as { labelInput?: unknown }).labelInput);
474
+ }
475
+
444
476
  invalidate(): void {
445
477
  // Preview is derived from focused entry + width; invalidate forces recompute
446
478
  this.previewCache = null;
@@ -551,12 +583,12 @@ class anycopyOverlay implements Focusable {
551
583
  }
552
584
 
553
585
  private renderTreeHeaderHint(width: number): string {
554
- const closeHint = this.closeShortcut ? `${formatKeyHint(this.closeShortcut)}/Esc: close` : "Esc: close";
555
586
  const hint =
556
- ` │ ${formatKeyHint(this.keys.toggleSelect)}: select` +
587
+ ` │ Enter: navigate` +
588
+ ` • ${formatKeyHint(this.keys.toggleSelect)}: select` +
557
589
  ` • ${formatKeyHint(this.keys.copy)}: copy` +
558
590
  ` • ${formatKeyHint(this.keys.clear)}: clear` +
559
- ` • ${closeHint}`;
591
+ ` • Esc: close`;
560
592
  return truncateToWidth(this.theme.fg("dim", hint), width);
561
593
  }
562
594
 
@@ -636,7 +668,7 @@ class anycopyOverlay implements Focusable {
636
668
  const selectorLines = this.selector.render(width);
637
669
  const headerHint = this.renderTreeHeaderHint(width);
638
670
 
639
- // Inject shortcut hint near the tree header (above the list)
671
+ // Inject action hints near the tree header (above the list)
640
672
  const insertAfter = Math.max(0, selectorLines.findIndex((l) => l.includes("Type to search")));
641
673
  if (selectorLines.length > 0) {
642
674
  const idx = insertAfter >= 0 ? insertAfter + 1 : 1;
@@ -670,11 +702,13 @@ class anycopyOverlay implements Focusable {
670
702
 
671
703
  export default function anycopyExtension(pi: ExtensionAPI) {
672
704
  const config = loadConfig();
673
- const shortcut = config.shortcut;
674
705
  const keys = config.keys;
675
706
  const treeFilterMode = config.treeFilterMode;
676
707
 
677
- const openAnycopy = async (ctx: ExtensionContext) => {
708
+ const openAnycopy = async (
709
+ ctx: ExtensionCommandContext,
710
+ opts?: { initialSelectedId?: string },
711
+ ) => {
678
712
  if (!ctx.hasUI) return;
679
713
 
680
714
  const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
@@ -685,35 +719,50 @@ export default function anycopyExtension(pi: ExtensionAPI) {
685
719
 
686
720
  const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
687
721
  const currentLeafId = ctx.sessionManager.getLeafId();
722
+ const skipSummaryPrompt = loadBranchSummarySkipPrompt(ctx.cwd);
688
723
 
689
724
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
690
725
  const termRows = tui.terminal?.rows ?? 40;
691
- // Pass reduced height so tree takes ~35% of terminal (it uses floor(h/2) internally)
692
726
  const treeTermHeight = Math.floor(termRows * 0.65);
693
727
 
694
728
  const selector = new TreeSelectorComponent(
695
729
  initialTree,
696
730
  currentLeafId,
697
731
  treeTermHeight,
698
- () => {
699
- // Intentionally ignore Enter: closing on Enter is counterintuitive here.
700
- // Use Esc to close the overlay.
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
+ });
701
750
  },
702
751
  () => done(),
703
752
  (entryId, label) => {
704
753
  pi.setLabel(entryId, label);
705
754
  },
755
+ opts?.initialSelectedId,
756
+ treeFilterMode,
706
757
  );
758
+ const effectiveLeafIdForNoop = selector.getTreeList().getSelectedNode()?.entry.id ?? currentLeafId;
707
759
 
708
- // Build a node map once for parent traversal (toolResult → parent assistant toolCall args)
709
760
  const nodeById = buildNodeMap(initialTree);
710
-
711
761
  const overlay = new anycopyOverlay(
712
762
  selector,
713
763
  getTree,
714
764
  nodeById,
715
765
  keys,
716
- shortcut,
717
766
  () => done(),
718
767
  () => tui.terminal?.rows ?? 40,
719
768
  () => tui.requestRender(),
@@ -721,15 +770,6 @@ export default function anycopyExtension(pi: ExtensionAPI) {
721
770
  );
722
771
 
723
772
  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();
730
- }
731
-
732
- // Monkey-patch render to inject checkbox markers (✓/○) into tree rows
733
773
  const originalRender = treeList.render.bind(treeList);
734
774
  treeList.render = (width: number) => {
735
775
  const innerWidth = Math.max(10, width - 2);
@@ -766,7 +806,7 @@ export default function anycopyExtension(pi: ExtensionAPI) {
766
806
  if (typeof nodeId !== "string") return " " + line;
767
807
 
768
808
  const selected = overlay.isSelectedNode(nodeId);
769
- const marker = selected ? theme.fg("success", "\u2713 ") : theme.fg("dim", "\u25CB ");
809
+ const marker = selected ? theme.fg("success", " ") : theme.fg("dim", " ");
770
810
  return marker + line;
771
811
  });
772
812
  };
@@ -782,13 +822,4 @@ export default function anycopyExtension(pi: ExtensionAPI) {
782
822
  await openAnycopy(ctx);
783
823
  },
784
824
  });
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
- }
794
825
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-anycopy",
3
- "version": "0.1.4",
4
- "description": "Copy any single message, or multiple selected messages, from the session tree, with scrollable message preview",
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
  ],