pi-anycopy 0.1.4 → 0.2.1

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, the ability to copy any node(s) to the clipboard, and optional display of the timestamps of labeled nodes' last labelings.
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,47 +40,53 @@ 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) |
54
+ | `Shift+T` | Toggle label timestamps for labeled nodes |
57
55
  | `Shift+Up` / `Shift+Down` | Scroll node preview by line |
58
56
  | `Shift+Left` / `Shift+Right` | Page through node preview |
59
- | `Esc`, or configured global `shortcut` | Close |
57
+ | `Esc` | Close |
60
58
 
61
59
  Notes:
60
+ - `Enter` always navigates the focused node, not the marked set
61
+ - After `Enter`, `/anycopy` offers the same summary choices as `/tree`: `No summary`, `Summarize`, and `Summarize with custom prompt`
62
+ - If `branchSummary.skipPrompt` is `true` in Pi settings, `/anycopy` matches native `/tree` and skips the summary chooser, defaulting to no summary
63
+ - Escaping the summary chooser reopens `/anycopy` with focus restored to the node you tried to select
64
+ - Cancelling the custom summarization editor returns to the summary chooser
62
65
  - 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
66
+ - Single-node copies use just that node's content; role prefixes like `user:` or `assistant:` are only added when copying 2 or more nodes
67
+ - When copying multiple selected nodes, they are auto-sorted chronologically by position in the session tree, not by selection order
68
+ - Space/`Shift+C` multi-select copy behavior is unchanged by navigation support
69
+ - `Shift+T` is configurable via `keys.toggleLabelTimestamps` in `config.json`
70
+ - `Shift+T` shows timestamps for labeled nodes only, using the latest label-change time for each label
71
+ - Same-day labels show `HH:MM`; older labels show `M/D HH:MM`; cross-year labels show `YY/M/D HH:MM`
64
72
  - 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
73
 
67
74
  ## Configuration
68
75
 
69
76
  Edit `~/.pi/agent/extensions/anycopy/config.json`:
70
77
 
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))
78
+ - `treeFilterMode`: initial tree filter mode when opening `/anycopy`; defaults to `default` to match `/tree`
75
79
  - one of: `default` | `no-tools` | `user-only` | `labeled-only` | `all`
76
- - `keys`: keybindings used inside the `/anycopy` overlay (see above)
80
+ - `keys`: keybindings used inside the `/anycopy` overlay for copy/preview actions, including the label timestamp toggle
77
81
 
78
82
  ```json
79
83
  {
80
- "shortcut": "ctrl+`",
81
84
  "treeFilterMode": "default",
82
85
  "keys": {
83
86
  "toggleSelect": "space",
84
87
  "copy": "shift+c",
85
88
  "clear": "shift+x",
89
+ "toggleLabelTimestamps": "shift+t",
86
90
  "scrollUp": "shift+up",
87
91
  "scrollDown": "shift+down",
88
92
  "pageUp": "shift+left",
@@ -1,10 +1,10 @@
1
1
  {
2
- "shortcut": "ctrl+`",
3
2
  "treeFilterMode": "no-tools",
4
3
  "keys": {
5
4
  "toggleSelect": "space",
6
5
  "copy": "shift+c",
7
6
  "clear": "shift+x",
7
+ "toggleLabelTimestamps": "shift+t",
8
8
  "scrollUp": "shift+up",
9
9
  "scrollDown": "shift+down",
10
10
  "pageUp": "shift+left",
@@ -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
+ }
@@ -7,13 +7,14 @@
7
7
  * Space - select/unselect focused node for copy
8
8
  * Shift+C - copy selected nodes (or focused node if none selected)
9
9
  * Shift+X - clear selection
10
- * Shift+L - label node (native tree behavior)
10
+ * Shift+L - label node
11
+ * Shift+T - toggle label timestamps for labeled nodes
11
12
  * Shift+↑/↓ - scroll preview
12
13
  * Shift+←/→ - page preview
13
14
  * Esc - close
14
15
  */
15
16
 
16
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
17
+ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@mariozechner/pi-coding-agent";
17
18
  import {
18
19
  copyToClipboard,
19
20
  getLanguageFromPath,
@@ -26,19 +27,24 @@ import { Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
26
27
  import type { Focusable } from "@mariozechner/pi-tui";
27
28
 
28
29
  import { existsSync, readFileSync } from "fs";
30
+ import { homedir } from "os";
29
31
  import { dirname, join } from "path";
30
32
  import { fileURLToPath } from "url";
31
33
 
34
+ import { runAnycopyEnterNavigation } from "./enter-navigation.ts";
35
+
32
36
  type SessionTreeNode = {
33
37
  entry: SessionEntry;
34
38
  children: SessionTreeNode[];
35
39
  label?: string;
40
+ labelTimestamp?: string;
36
41
  };
37
42
 
38
43
  type anycopyKeyConfig = {
39
44
  toggleSelect: string;
40
45
  copy: string;
41
46
  clear: string;
47
+ toggleLabelTimestamps: string;
42
48
  scrollDown: string;
43
49
  scrollUp: string;
44
50
  pageDown: string;
@@ -48,21 +54,26 @@ type anycopyKeyConfig = {
48
54
  type TreeFilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
49
55
 
50
56
  type anycopyConfig = {
51
- shortcut?: string | null;
52
57
  keys?: Partial<anycopyKeyConfig>;
53
58
  treeFilterMode?: TreeFilterMode;
54
59
  };
55
60
 
56
61
  type anycopyRuntimeConfig = {
57
- shortcut: string | null;
58
62
  keys: anycopyKeyConfig;
59
63
  treeFilterMode: TreeFilterMode;
60
64
  };
61
65
 
66
+ type BranchSummarySettingsFile = {
67
+ branchSummary?: {
68
+ skipPrompt?: boolean;
69
+ };
70
+ };
71
+
62
72
  const DEFAULT_KEYS: anycopyKeyConfig = {
63
73
  toggleSelect: "space",
64
74
  copy: "shift+c",
65
75
  clear: "shift+x",
76
+ toggleLabelTimestamps: "shift+t",
66
77
  scrollDown: "shift+down",
67
78
  scrollUp: "shift+up",
68
79
  pageDown: "shift+right",
@@ -70,7 +81,6 @@ const DEFAULT_KEYS: anycopyKeyConfig = {
70
81
  };
71
82
 
72
83
  const DEFAULT_TREE_FILTER_MODE: TreeFilterMode = "default";
73
- const DEFAULT_SHORTCUT = "ctrl+`";
74
84
 
75
85
  const getExtensionDir = (): string => {
76
86
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -78,11 +88,32 @@ const getExtensionDir = (): string => {
78
88
  return dirname(fileURLToPath(import.meta.url));
79
89
  };
80
90
 
91
+ const getAgentDir = (): string => process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
92
+
93
+ const readJsonFile = <T>(path: string): T | undefined => {
94
+ if (!existsSync(path)) return undefined;
95
+
96
+ try {
97
+ return JSON.parse(readFileSync(path, "utf8")) as T;
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ };
102
+
103
+ const loadBranchSummarySkipPrompt = (cwd: string): boolean => {
104
+ const globalSettings = readJsonFile<BranchSummarySettingsFile>(join(getAgentDir(), "settings.json"));
105
+ const projectSettings = readJsonFile<BranchSummarySettingsFile>(join(cwd, ".pi", "settings.json"));
106
+ const projectSkipPrompt = projectSettings?.branchSummary?.skipPrompt;
107
+ if (typeof projectSkipPrompt === "boolean") return projectSkipPrompt;
108
+
109
+ const globalSkipPrompt = globalSettings?.branchSummary?.skipPrompt;
110
+ return typeof globalSkipPrompt === "boolean" ? globalSkipPrompt : false;
111
+ };
112
+
81
113
  const loadConfig = (): anycopyRuntimeConfig => {
82
114
  const configPath = join(getExtensionDir(), "config.json");
83
115
  if (!existsSync(configPath)) {
84
116
  return {
85
- shortcut: DEFAULT_SHORTCUT,
86
117
  keys: { ...DEFAULT_KEYS },
87
118
  treeFilterMode: DEFAULT_TREE_FILTER_MODE,
88
119
  };
@@ -92,8 +123,6 @@ const loadConfig = (): anycopyRuntimeConfig => {
92
123
  const raw = readFileSync(configPath, "utf8");
93
124
  const parsed = JSON.parse(raw) as anycopyConfig;
94
125
  const keys = parsed.keys ?? {};
95
- const shortcut =
96
- parsed.shortcut === null ? null : typeof parsed.shortcut === "string" ? parsed.shortcut : DEFAULT_SHORTCUT;
97
126
  const treeFilterModeRaw = parsed.treeFilterMode;
98
127
  const validTreeFilterModes: TreeFilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
99
128
  const treeFilterMode =
@@ -102,11 +131,14 @@ const loadConfig = (): anycopyRuntimeConfig => {
102
131
  : DEFAULT_TREE_FILTER_MODE;
103
132
 
104
133
  return {
105
- shortcut,
106
134
  keys: {
107
135
  toggleSelect: typeof keys.toggleSelect === "string" ? keys.toggleSelect : DEFAULT_KEYS.toggleSelect,
108
136
  copy: typeof keys.copy === "string" ? keys.copy : DEFAULT_KEYS.copy,
109
137
  clear: typeof keys.clear === "string" ? keys.clear : DEFAULT_KEYS.clear,
138
+ toggleLabelTimestamps:
139
+ typeof keys.toggleLabelTimestamps === "string"
140
+ ? keys.toggleLabelTimestamps
141
+ : DEFAULT_KEYS.toggleLabelTimestamps,
110
142
  scrollDown: typeof keys.scrollDown === "string" ? keys.scrollDown : DEFAULT_KEYS.scrollDown,
111
143
  scrollUp: typeof keys.scrollUp === "string" ? keys.scrollUp : DEFAULT_KEYS.scrollUp,
112
144
  pageDown: typeof keys.pageDown === "string" ? keys.pageDown : DEFAULT_KEYS.pageDown,
@@ -116,7 +148,6 @@ const loadConfig = (): anycopyRuntimeConfig => {
116
148
  };
117
149
  } catch {
118
150
  return {
119
- shortcut: DEFAULT_SHORTCUT,
120
151
  keys: { ...DEFAULT_KEYS },
121
152
  treeFilterMode: DEFAULT_TREE_FILTER_MODE,
122
153
  };
@@ -345,10 +376,74 @@ const buildNodeOrder = (roots: SessionTreeNode[]): Map<string, number> => {
345
376
  return order;
346
377
  };
347
378
 
348
- /** Clipboard text: role:\n\ncontent\n\n---\n\nrole:\n\ncontent
379
+ const buildLatestLabelTimestamps = (entries: SessionEntry[]): Map<string, string> => {
380
+ const timestampsById = new Map<string, string>();
381
+ for (const entry of entries) {
382
+ if (entry.type !== "label") continue;
383
+ if (entry.label) {
384
+ timestampsById.set(entry.targetId, entry.timestamp);
385
+ } else {
386
+ timestampsById.delete(entry.targetId);
387
+ }
388
+ }
389
+ return timestampsById;
390
+ };
391
+
392
+ const applyLabelTimestampsToTree = (roots: SessionTreeNode[], timestampsById: Map<string, string>): void => {
393
+ const stack = [...roots];
394
+ while (stack.length > 0) {
395
+ const node = stack.pop()!;
396
+ node.labelTimestamp = timestampsById.get(node.entry.id);
397
+ for (const child of node.children) stack.push(child);
398
+ }
399
+ };
400
+
401
+ const formatLabelTimestamp = (timestamp: string): string => {
402
+ const date = new Date(timestamp);
403
+ if (Number.isNaN(date.getTime())) return timestamp;
404
+
405
+ const now = new Date();
406
+ const hours = date.getHours().toString().padStart(2, "0");
407
+ const minutes = date.getMinutes().toString().padStart(2, "0");
408
+ const time = `${hours}:${minutes}`;
409
+
410
+ if (
411
+ date.getFullYear() === now.getFullYear() &&
412
+ date.getMonth() === now.getMonth() &&
413
+ date.getDate() === now.getDate()
414
+ ) {
415
+ return time;
416
+ }
417
+
418
+ const month = date.getMonth() + 1;
419
+ const day = date.getDate();
420
+ if (date.getFullYear() === now.getFullYear()) {
421
+ return `${month}/${day} ${time}`;
422
+ }
423
+
424
+ const year = date.getFullYear().toString().slice(-2);
425
+ return `${year}/${month}/${day} ${time}`;
426
+ };
427
+
428
+ const insertLabelTimestampIntoLine = (line: string, node: SessionTreeNode, theme: any): string => {
429
+ if (!node.label || !node.labelTimestamp) return line;
430
+
431
+ const labelToken = `[${node.label}] `;
432
+ const labelIndex = line.indexOf(labelToken);
433
+ if (labelIndex === -1) return line;
434
+
435
+ const insertAt = labelIndex + labelToken.length;
436
+ return `${line.slice(0, insertAt)}${theme.fg("muted", `${formatLabelTimestamp(node.labelTimestamp)} `)}${line.slice(insertAt)}`;
437
+ };
438
+
439
+ /** Clipboard text omits role prefix for a single node and includes it for multi-node copies
349
440
  * The preview pane is truncated for performance, while the clipboard copy is not
350
441
  */
351
442
  const buildClipboardText = (nodes: SessionTreeNode[]): string => {
443
+ if (nodes.length === 1) {
444
+ return getEntryContent(nodes[0]!.entry);
445
+ }
446
+
352
447
  return nodes
353
448
  .map((node) => {
354
449
  const label = getEntryRoleLabel(node.entry);
@@ -365,6 +460,7 @@ class anycopyOverlay implements Focusable {
365
460
  private _focused = false;
366
461
  private previewScrollOffset = 0;
367
462
  private lastPreviewHeight = 0;
463
+ private showLabelTimestamps = false;
368
464
  private previewCache: {
369
465
  entryId: string;
370
466
  width: number;
@@ -377,7 +473,6 @@ class anycopyOverlay implements Focusable {
377
473
  private getTree: () => SessionTreeNode[],
378
474
  private nodeById: Map<string, SessionTreeNode>,
379
475
  private keys: anycopyKeyConfig,
380
- private closeShortcut: string | null,
381
476
  private onClose: () => void,
382
477
  private getTermHeight: () => number,
383
478
  private requestRender: () => void,
@@ -397,10 +492,12 @@ class anycopyOverlay implements Focusable {
397
492
  }
398
493
 
399
494
  handleInput(data: string): void {
400
- if (this.closeShortcut && matchesKey(data, this.closeShortcut)) {
401
- this.onClose();
495
+ if (this.isEditingNodeLabel()) {
496
+ this.selector.handleInput(data);
497
+ this.requestRender();
402
498
  return;
403
499
  }
500
+
404
501
  if (matchesKey(data, this.keys.toggleSelect)) {
405
502
  this.toggleSelectedFocusedNode();
406
503
  return;
@@ -413,6 +510,11 @@ class anycopyOverlay implements Focusable {
413
510
  this.clearSelection();
414
511
  return;
415
512
  }
513
+ if (matchesKey(data, this.keys.toggleLabelTimestamps)) {
514
+ this.showLabelTimestamps = !this.showLabelTimestamps;
515
+ this.requestRender();
516
+ return;
517
+ }
416
518
 
417
519
  if (matchesKey(data, this.keys.scrollDown)) {
418
520
  this.previewScrollOffset += 1;
@@ -441,6 +543,10 @@ class anycopyOverlay implements Focusable {
441
543
  this.requestRender();
442
544
  }
443
545
 
546
+ private isEditingNodeLabel(): boolean {
547
+ return Boolean((this.selector as { labelInput?: unknown }).labelInput);
548
+ }
549
+
444
550
  invalidate(): void {
445
551
  // Preview is derived from focused entry + width; invalidate forces recompute
446
552
  this.previewCache = null;
@@ -490,6 +596,10 @@ class anycopyOverlay implements Focusable {
490
596
  return this.selectedNodeIds.has(id);
491
597
  }
492
598
 
599
+ isShowingLabelTimestamps(): boolean {
600
+ return this.showLabelTimestamps;
601
+ }
602
+
493
603
  copySelectedOrFocusedNode(): void {
494
604
  const focused = this.getFocusedNode();
495
605
  const ids =
@@ -551,12 +661,13 @@ class anycopyOverlay implements Focusable {
551
661
  }
552
662
 
553
663
  private renderTreeHeaderHint(width: number): string {
554
- const closeHint = this.closeShortcut ? `${formatKeyHint(this.closeShortcut)}/Esc: close` : "Esc: close";
555
664
  const hint =
556
- ` │ ${formatKeyHint(this.keys.toggleSelect)}: select` +
665
+ ` │ Enter: navigate` +
666
+ ` • ${formatKeyHint(this.keys.toggleSelect)}: select` +
557
667
  ` • ${formatKeyHint(this.keys.copy)}: copy` +
558
668
  ` • ${formatKeyHint(this.keys.clear)}: clear` +
559
- ` • ${closeHint}`;
669
+ ` • ${formatKeyHint(this.keys.toggleLabelTimestamps)}: label time` +
670
+ ` • Esc: close`;
560
671
  return truncateToWidth(this.theme.fg("dim", hint), width);
561
672
  }
562
673
 
@@ -636,7 +747,7 @@ class anycopyOverlay implements Focusable {
636
747
  const selectorLines = this.selector.render(width);
637
748
  const headerHint = this.renderTreeHeaderHint(width);
638
749
 
639
- // Inject shortcut hint near the tree header (above the list)
750
+ // Inject action hints near the tree header (above the list)
640
751
  const insertAfter = Math.max(0, selectorLines.findIndex((l) => l.includes("Type to search")));
641
752
  if (selectorLines.length > 0) {
642
753
  const idx = insertAfter >= 0 ? insertAfter + 1 : 1;
@@ -670,50 +781,77 @@ class anycopyOverlay implements Focusable {
670
781
 
671
782
  export default function anycopyExtension(pi: ExtensionAPI) {
672
783
  const config = loadConfig();
673
- const shortcut = config.shortcut;
674
784
  const keys = config.keys;
675
785
  const treeFilterMode = config.treeFilterMode;
676
786
 
677
- const openAnycopy = async (ctx: ExtensionContext) => {
787
+ const openAnycopy = async (
788
+ ctx: ExtensionCommandContext,
789
+ opts?: { initialSelectedId?: string },
790
+ ) => {
678
791
  if (!ctx.hasUI) return;
679
792
 
680
- const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
793
+ const buildAnnotatedTree = (): SessionTreeNode[] => {
794
+ const tree = ctx.sessionManager.getTree() as SessionTreeNode[];
795
+ applyLabelTimestampsToTree(tree, buildLatestLabelTimestamps(ctx.sessionManager.getEntries() as SessionEntry[]));
796
+ return tree;
797
+ };
798
+
799
+ const initialTree = buildAnnotatedTree();
681
800
  if (initialTree.length === 0) {
682
801
  ctx.ui.notify("No entries in session", "warning");
683
802
  return;
684
803
  }
685
804
 
686
- const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
805
+ const getTree = () => buildAnnotatedTree();
806
+ const refreshInitialTreeLabelTimestamps = () => {
807
+ applyLabelTimestampsToTree(initialTree, buildLatestLabelTimestamps(ctx.sessionManager.getEntries() as SessionEntry[]));
808
+ };
687
809
  const currentLeafId = ctx.sessionManager.getLeafId();
810
+ const skipSummaryPrompt = loadBranchSummarySkipPrompt(ctx.cwd);
688
811
 
689
812
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
690
813
  const termRows = tui.terminal?.rows ?? 40;
691
- // Pass reduced height so tree takes ~35% of terminal (it uses floor(h/2) internally)
692
814
  const treeTermHeight = Math.floor(termRows * 0.65);
693
815
 
694
816
  const selector = new TreeSelectorComponent(
695
817
  initialTree,
696
818
  currentLeafId,
697
819
  treeTermHeight,
698
- () => {
699
- // Intentionally ignore Enter: closing on Enter is counterintuitive here.
700
- // Use Esc to close the overlay.
820
+ (entryId) => {
821
+ void runAnycopyEnterNavigation({
822
+ entryId,
823
+ effectiveLeafIdForNoop,
824
+ skipSummaryPrompt,
825
+ close: done,
826
+ reopen: (reopenOpts) => {
827
+ void openAnycopy(ctx, reopenOpts);
828
+ },
829
+ navigateTree: async (targetId, options) => ctx.navigateTree(targetId, options),
830
+ ui: {
831
+ select: (title, options) => ctx.ui.select(title, options),
832
+ editor: (title) => ctx.ui.editor(title),
833
+ setStatus: (source, message) => ctx.ui.setStatus(source, message),
834
+ setWorkingMessage: (message) => ctx.ui.setWorkingMessage(message),
835
+ notify: (message, level) => ctx.ui.notify(message, level),
836
+ },
837
+ });
701
838
  },
702
839
  () => done(),
703
840
  (entryId, label) => {
704
841
  pi.setLabel(entryId, label);
842
+ refreshInitialTreeLabelTimestamps();
705
843
  },
844
+ opts?.initialSelectedId,
845
+ treeFilterMode,
706
846
  );
847
+ const effectiveLeafIdForNoop = selector.getTreeList().getSelectedNode()?.entry.id ?? currentLeafId;
707
848
 
708
- // Build a node map once for parent traversal (toolResult → parent assistant toolCall args)
709
849
  const nodeById = buildNodeMap(initialTree);
710
-
711
850
  const overlay = new anycopyOverlay(
712
851
  selector,
713
852
  getTree,
714
853
  nodeById,
715
854
  keys,
716
- shortcut,
717
855
  () => done(),
718
856
  () => tui.terminal?.rows ?? 40,
719
857
  () => tui.requestRender(),
@@ -721,15 +859,6 @@ export default function anycopyExtension(pi: ExtensionAPI) {
721
859
  );
722
860
 
723
861
  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
862
  const originalRender = treeList.render.bind(treeList);
734
863
  treeList.render = (width: number) => {
735
864
  const innerWidth = Math.max(10, width - 2);
@@ -737,8 +866,15 @@ export default function anycopyExtension(pi: ExtensionAPI) {
737
866
 
738
867
  const tl = treeList as any;
739
868
  const filteredRaw = tl.filteredNodes;
869
+ const decorateStatusLine = (line: string): string => {
870
+ const status = overlay.isShowingLabelTimestamps() ? `${line}${theme.fg("muted", " [+label time]")}` : line;
871
+ return truncateToWidth(` ${status}`, width);
872
+ };
873
+
740
874
  if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
741
- return lines.map((line: string) => " " + line);
875
+ return lines.map((line: string, i: number) =>
876
+ i === lines.length - 1 ? decorateStatusLine(line) : truncateToWidth(` ${line}`, width),
877
+ );
742
878
  }
743
879
  const filtered = filteredRaw as { node: SessionTreeNode }[];
744
880
 
@@ -758,16 +894,18 @@ export default function anycopyExtension(pi: ExtensionAPI) {
758
894
  const treeRowCount = Math.max(0, lines.length - 1);
759
895
 
760
896
  return lines.map((line: string, i: number) => {
761
- if (i >= treeRowCount) return " " + line;
897
+ if (i >= treeRowCount) return decorateStatusLine(line);
762
898
 
763
899
  const nodeIdx = startIdx + i;
764
900
  const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
765
901
  const nodeId = node?.entry?.id;
766
- if (typeof nodeId !== "string") return " " + line;
902
+ if (typeof nodeId !== "string") return truncateToWidth(` ${line}`, width);
767
903
 
768
904
  const selected = overlay.isSelectedNode(nodeId);
769
- const marker = selected ? theme.fg("success", "\u2713 ") : theme.fg("dim", "\u25CB ");
770
- return marker + line;
905
+ const marker = selected ? theme.fg("success", " ") : theme.fg("dim", " ");
906
+ const lineWithTimestamp =
907
+ overlay.isShowingLabelTimestamps() && node ? insertLabelTimestampIntoLine(line, node, theme) : line;
908
+ return truncateToWidth(marker + lineWithTimestamp, width);
771
909
  });
772
910
  };
773
911
 
@@ -782,13 +920,4 @@ export default function anycopyExtension(pi: ExtensionAPI) {
782
920
  await openAnycopy(ctx);
783
921
  },
784
922
  });
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
923
  }
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.1",
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
  ],