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 +20 -16
- package/extensions/anycopy/config.json +1 -1
- package/extensions/anycopy/enter-navigation.ts +101 -0
- package/extensions/anycopy/index.ts +181 -52
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# anycopy for Pi (`pi-anycopy`)
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
| `
|
|
54
|
-
| `
|
|
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
|
|
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
|
-
-
|
|
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
|
-
- `
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
401
|
-
this.
|
|
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
|
-
` │
|
|
665
|
+
` │ Enter: navigate` +
|
|
666
|
+
` • ${formatKeyHint(this.keys.toggleSelect)}: select` +
|
|
557
667
|
` • ${formatKeyHint(this.keys.copy)}: copy` +
|
|
558
668
|
` • ${formatKeyHint(this.keys.clear)}: clear` +
|
|
559
|
-
` • ${
|
|
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
|
|
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 (
|
|
787
|
+
const openAnycopy = async (
|
|
788
|
+
ctx: ExtensionCommandContext,
|
|
789
|
+
opts?: { initialSelectedId?: string },
|
|
790
|
+
) => {
|
|
678
791
|
if (!ctx.hasUI) return;
|
|
679
792
|
|
|
680
|
-
const
|
|
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 = () =>
|
|
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
|
-
|
|
700
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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", "
|
|
770
|
-
|
|
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
|
-
"description": "
|
|
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
|
],
|