@saifymatteo/pi-extensions 0.0.0-stage → 0.1.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.
Files changed (183) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +151 -2
  4. package/inventory.json +73 -0
  5. package/package.json +74 -4
  6. package/packages/pi-bg-bash/LICENSE +661 -0
  7. package/packages/pi-bg-bash/NOTICE.md +3 -0
  8. package/packages/pi-bg-bash/README.md +41 -0
  9. package/packages/pi-bg-bash/examples/settings.example.json +6 -0
  10. package/packages/pi-bg-bash/package.json +38 -0
  11. package/packages/pi-bg-bash/src/index.ts +969 -0
  12. package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
  13. package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
  14. package/packages/pi-claude-usage/LICENSE +661 -0
  15. package/packages/pi-claude-usage/NOTICE.md +3 -0
  16. package/packages/pi-claude-usage/README.md +27 -0
  17. package/packages/pi-claude-usage/package.json +35 -0
  18. package/packages/pi-claude-usage/src/index.ts +130 -0
  19. package/packages/pi-clipboard-image/LICENSE +661 -0
  20. package/packages/pi-clipboard-image/NOTICE.md +3 -0
  21. package/packages/pi-clipboard-image/README.md +27 -0
  22. package/packages/pi-clipboard-image/package.json +36 -0
  23. package/packages/pi-clipboard-image/src/index.ts +527 -0
  24. package/packages/pi-codeblock-borders/LICENSE +661 -0
  25. package/packages/pi-codeblock-borders/NOTICE.md +3 -0
  26. package/packages/pi-codeblock-borders/README.md +30 -0
  27. package/packages/pi-codeblock-borders/package.json +35 -0
  28. package/packages/pi-codeblock-borders/src/index.ts +302 -0
  29. package/packages/pi-codegraph/LICENSE +661 -0
  30. package/packages/pi-codegraph/NOTICE.md +5 -0
  31. package/packages/pi-codegraph/README.md +35 -0
  32. package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
  33. package/packages/pi-codegraph/package.json +37 -0
  34. package/packages/pi-codegraph/src/index.ts +573 -0
  35. package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
  36. package/packages/pi-compact-tools/LICENSE +661 -0
  37. package/packages/pi-compact-tools/NOTICE.md +3 -0
  38. package/packages/pi-compact-tools/README.md +27 -0
  39. package/packages/pi-compact-tools/package.json +36 -0
  40. package/packages/pi-compact-tools/src/index.ts +1161 -0
  41. package/packages/pi-decisions/LICENSE +661 -0
  42. package/packages/pi-decisions/NOTICE.md +3 -0
  43. package/packages/pi-decisions/README.md +37 -0
  44. package/packages/pi-decisions/examples/service-models.example.json +27 -0
  45. package/packages/pi-decisions/package.json +37 -0
  46. package/packages/pi-decisions/src/index.ts +364 -0
  47. package/packages/pi-decisions/src/lib/service-models.ts +95 -0
  48. package/packages/pi-decisions/test/service-models.test.mjs +71 -0
  49. package/packages/pi-load-pi-dotenv/LICENSE +661 -0
  50. package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
  51. package/packages/pi-load-pi-dotenv/README.md +27 -0
  52. package/packages/pi-load-pi-dotenv/package.json +32 -0
  53. package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
  54. package/packages/pi-model-go/LICENSE +661 -0
  55. package/packages/pi-model-go/NOTICE.md +5 -0
  56. package/packages/pi-model-go/README.md +33 -0
  57. package/packages/pi-model-go/package.json +36 -0
  58. package/packages/pi-model-go/src/index.ts +171 -0
  59. package/packages/pi-model-go/src/lib/dialog.ts +31 -0
  60. package/packages/pi-model-go/src/lib/model-go.ts +239 -0
  61. package/packages/pi-model-go/test/behavior.test.mjs +17 -0
  62. package/packages/pi-prestart-working/LICENSE +661 -0
  63. package/packages/pi-prestart-working/NOTICE.md +3 -0
  64. package/packages/pi-prestart-working/README.md +25 -0
  65. package/packages/pi-prestart-working/package.json +35 -0
  66. package/packages/pi-prestart-working/src/index.ts +86 -0
  67. package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
  68. package/packages/pi-root-guard/LICENSE +661 -0
  69. package/packages/pi-root-guard/NOTICE.md +3 -0
  70. package/packages/pi-root-guard/README.md +27 -0
  71. package/packages/pi-root-guard/package.json +32 -0
  72. package/packages/pi-root-guard/src/index.ts +202 -0
  73. package/packages/pi-session-finder/LICENSE +661 -0
  74. package/packages/pi-session-finder/NOTICE.md +3 -0
  75. package/packages/pi-session-finder/README.md +33 -0
  76. package/packages/pi-session-finder/package.json +36 -0
  77. package/packages/pi-session-finder/src/index.ts +306 -0
  78. package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
  79. package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
  80. package/packages/pi-session-id/LICENSE +661 -0
  81. package/packages/pi-session-id/NOTICE.md +3 -0
  82. package/packages/pi-session-id/README.md +31 -0
  83. package/packages/pi-session-id/package.json +35 -0
  84. package/packages/pi-session-id/src/index.ts +20 -0
  85. package/packages/pi-session-id/test/behavior.test.mjs +25 -0
  86. package/packages/pi-session-nav/LICENSE +661 -0
  87. package/packages/pi-session-nav/NOTICE.md +3 -0
  88. package/packages/pi-session-nav/README.md +32 -0
  89. package/packages/pi-session-nav/package.json +36 -0
  90. package/packages/pi-session-nav/src/index.ts +203 -0
  91. package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
  92. package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
  93. package/packages/pi-session-rename/LICENSE +661 -0
  94. package/packages/pi-session-rename/NOTICE.md +3 -0
  95. package/packages/pi-session-rename/README.md +31 -0
  96. package/packages/pi-session-rename/package.json +35 -0
  97. package/packages/pi-session-rename/src/index.ts +456 -0
  98. package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
  99. package/packages/pi-session-retention/LICENSE +661 -0
  100. package/packages/pi-session-retention/NOTICE.md +3 -0
  101. package/packages/pi-session-retention/README.md +62 -0
  102. package/packages/pi-session-retention/package.json +36 -0
  103. package/packages/pi-session-retention/src/README.md +42 -0
  104. package/packages/pi-session-retention/src/core.ts +333 -0
  105. package/packages/pi-session-retention/src/dialog.ts +118 -0
  106. package/packages/pi-session-retention/src/index.ts +311 -0
  107. package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
  108. package/packages/pi-session-retention/src/safety.ts +50 -0
  109. package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
  110. package/packages/pi-skill-usage-log/LICENSE +661 -0
  111. package/packages/pi-skill-usage-log/NOTICE.md +3 -0
  112. package/packages/pi-skill-usage-log/README.md +32 -0
  113. package/packages/pi-skill-usage-log/package.json +36 -0
  114. package/packages/pi-skill-usage-log/src/index.ts +316 -0
  115. package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
  116. package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
  117. package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
  118. package/packages/pi-tool-usage-log/LICENSE +661 -0
  119. package/packages/pi-tool-usage-log/NOTICE.md +5 -0
  120. package/packages/pi-tool-usage-log/README.md +32 -0
  121. package/packages/pi-tool-usage-log/package.json +36 -0
  122. package/packages/pi-tool-usage-log/src/index.ts +233 -0
  123. package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
  124. package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
  125. package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
  126. package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
  127. package/packages/pi-tts/LICENSE +661 -0
  128. package/packages/pi-tts/NOTICE.md +3 -0
  129. package/packages/pi-tts/README.md +37 -0
  130. package/packages/pi-tts/examples/service-models.example.json +27 -0
  131. package/packages/pi-tts/package.json +37 -0
  132. package/packages/pi-tts/src/index.ts +135 -0
  133. package/packages/pi-tts/src/lib/service-models.ts +95 -0
  134. package/packages/pi-tts/test/tts-output.test.mjs +47 -0
  135. package/packages/pi-tts/test/tts.test.mjs +57 -0
  136. package/packages/pi-user-image-previews/LICENSE +661 -0
  137. package/packages/pi-user-image-previews/NOTICE.md +3 -0
  138. package/packages/pi-user-image-previews/README.md +27 -0
  139. package/packages/pi-user-image-previews/package.json +36 -0
  140. package/packages/pi-user-image-previews/src/index.ts +168 -0
  141. package/packages/pi-vision/LICENSE +661 -0
  142. package/packages/pi-vision/NOTICE.md +5 -0
  143. package/packages/pi-vision/README.md +43 -0
  144. package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
  145. package/packages/pi-vision/package.json +38 -0
  146. package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
  147. package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
  148. package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
  149. package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
  150. package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
  151. package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
  152. package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
  153. package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
  154. package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
  155. package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
  156. package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
  157. package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
  158. package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
  159. package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
  160. package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
  161. package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
  162. package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
  163. package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
  164. package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
  165. package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  166. package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
  167. package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
  168. package/packages/pi-vision/src/index.ts +728 -0
  169. package/packages/pi-vision/test/vision.test.mjs +30 -0
  170. package/packages/pi-wait/LICENSE +661 -0
  171. package/packages/pi-wait/NOTICE.md +3 -0
  172. package/packages/pi-wait/README.md +37 -0
  173. package/packages/pi-wait/package.json +37 -0
  174. package/packages/pi-wait/src/index.ts +88 -0
  175. package/packages/pi-wait/src/lib/async-work.ts +27 -0
  176. package/packages/pi-wait/test/wait.test.mjs +43 -0
  177. package/packages/pi-zvec/LICENSE +661 -0
  178. package/packages/pi-zvec/NOTICE.md +3 -0
  179. package/packages/pi-zvec/README.md +37 -0
  180. package/packages/pi-zvec/examples/zvec.example.json +5 -0
  181. package/packages/pi-zvec/package.json +37 -0
  182. package/packages/pi-zvec/src/index.ts +754 -0
  183. package/packages/pi-zvec/test/zvec.test.mjs +19 -0
@@ -0,0 +1,33 @@
1
+ # pi-session-finder
2
+
3
+ Search saved sessions across projects, display their fork families, and open or fork a conversation into the current project.
4
+
5
+ ## Running locally
6
+
7
+ From the monorepo root:
8
+
9
+ ```sh
10
+ pi -e ./packages/pi-session-finder
11
+ ```
12
+
13
+ **Warning:** this extension uses your existing Pi resources and settings unless you load it in an isolated home (see the root README). Do not load two copies of the same extension (for example an older standalone file alongside this package): commands and hooks can duplicate. The package never installs or relocates personal settings.
14
+
15
+ ## Commands and shortcuts
16
+
17
+ - `/session-global` — opens the searchable session tree.
18
+ - `Alt+I` — **preserved source behavior:** inserts `/find` into the editor, not `/session-global`; this package does not register `/find`.
19
+ - In the picker: type to search, Up/Down to move, Enter to open, Escape/Ctrl+C to cancel.
20
+
21
+ ## Settings and storage
22
+
23
+ Scans `PI_CODING_AGENT_SESSION_DIR` or `<agent-dir>/sessions`, plus the current session manager's directory. `<agent-dir>` is `PI_CODING_AGENT_DIR` or `~/.pi/agent`. No package-local configuration.
24
+
25
+ ## Privacy and operations
26
+
27
+ Reads full JSONL conversations, working directories, names and first user messages across the configured session stores. Same-project choices switch sessions. Cross-project choices ask before creating a new conversation fork; no project files are copied. Cancelled/failed unused forks are removed. No network requests.
28
+
29
+ ## Compatibility
30
+
31
+ Node.js >=24 for development checks; run extensions through Pi's TypeScript loader. Host imports are wildcard peers, not bundled runtime copies. Interactive custom terminal UI and Pi's `SessionManager.forkFrom`/session-switch APIs are required. The Alt+I `/find` mismatch is intentionally not fixed by packaging.
32
+
33
+ See [NOTICE.md](NOTICE.md) for provenance. Tests use the monorepo's TypeScript dev dependency and VM loader; they do not contact services or write to user-home resources.
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@saifymatteo/pi-session-finder",
3
+ "version": "0.1.0",
4
+ "license": "AGPL-3.0-only",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "NOTICE.md",
13
+ "LICENSE"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "peerDependencies": {
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-tui": "*"
23
+ },
24
+ "engines": {
25
+ "node": ">=24"
26
+ },
27
+ "description": "Search saved sessions across projects; open or fork them.",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/saifymatteo/pi-extensions.git",
34
+ "directory": "packages/pi-session-finder"
35
+ }
36
+ }
@@ -0,0 +1,306 @@
1
+ import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { DIALOG_OVERLAY, dialogRows, wheelAsArrows } from "./lib/dialog.ts";
4
+ import { createReadStream } from "node:fs";
5
+ import { readdir, stat, unlink } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join, resolve } from "node:path";
8
+ import { createInterface } from "node:readline";
9
+
10
+ type SessionInfo = { id: string; path: string; cwd: string; parentSession?: string; name?: string; firstMessage: string; modified: number };
11
+ type TreeRow = { kind: "folder"; label: string } | { kind: "session"; session: SessionInfo; treePrefix: string };
12
+
13
+ function sameDirectory(a: string, b: string): boolean {
14
+ const left = resolve(a), right = resolve(b);
15
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
16
+ }
17
+
18
+ function contentText(content: unknown): string {
19
+ if (typeof content === "string") return content;
20
+ if (!Array.isArray(content)) return "";
21
+ return content.flatMap((part: any) => part?.type === "text" && typeof part.text === "string" ? [part.text] : []).join(" ")
22
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
23
+ }
24
+
25
+ async function discoverSessionFiles(roots: string[]): Promise<string[]> {
26
+ const files = new Set<string>();
27
+ const visited = new Set<string>();
28
+ async function visit(dir: string): Promise<void> {
29
+ let real: string;
30
+ try { real = resolve(dir); } catch { return; }
31
+ if (visited.has(real)) return;
32
+ visited.add(real);
33
+ let entries;
34
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
35
+ for (const entry of entries) {
36
+ const path = join(dir, entry.name);
37
+ if (entry.isDirectory()) await visit(path);
38
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.add(path);
39
+ }
40
+ }
41
+ for (const root of roots) await visit(root);
42
+ return [...files];
43
+ }
44
+
45
+ async function readSessionInfo(path: string): Promise<SessionInfo | null> {
46
+ let header: any;
47
+ let name: string | undefined;
48
+ let parentSession: string | undefined;
49
+ let firstMessage = "";
50
+ try {
51
+ const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
52
+ for await (const line of lines) {
53
+ let entry: any;
54
+ try { entry = JSON.parse(line); } catch { continue; }
55
+ if (!header && entry.type === "session") {
56
+ header = entry;
57
+ if (typeof entry.parentSession === "string") parentSession = entry.parentSession;
58
+ }
59
+ else if (entry.type === "session_info" && typeof entry.name === "string" && entry.name.trim()) name = entry.name.trim();
60
+ else if (!firstMessage && entry.type === "message" && entry.message?.role === "user") firstMessage = contentText(entry.message.content);
61
+ }
62
+ } catch { return null; }
63
+ if (header?.type !== "session" || typeof header.id !== "string") return null;
64
+ let modified = 0;
65
+ try { modified = (await stat(path)).mtimeMs; } catch { /* file may disappear during scan */ }
66
+ return { id: header.id, path, cwd: typeof header.cwd === "string" ? header.cwd : "(unknown directory)", parentSession, name, firstMessage, modified };
67
+ }
68
+
69
+ export function buildSessionRows(sessions: SessionInfo[], query: string): TreeRow[] {
70
+ const needle = query.trim().toLocaleLowerCase();
71
+ const matches = (session: SessionInfo) => !needle || [session.name, session.cwd, session.firstMessage, session.id]
72
+ .some((value) => value?.toLocaleLowerCase().includes(needle));
73
+ const byPath = new Map(sessions.map((session) => [resolve(session.path), session]));
74
+ const included = new Set(sessions.filter(matches));
75
+ // Keep ancestors visible as context when a search matches only a child session.
76
+ for (const session of [...included]) {
77
+ let parent = session.parentSession ? byPath.get(resolve(session.parentSession)) : undefined;
78
+ while (parent && !included.has(parent)) {
79
+ included.add(parent);
80
+ parent = parent.parentSession ? byPath.get(resolve(parent.parentSession)) : undefined;
81
+ }
82
+ }
83
+ const children = new Map<string, SessionInfo[]>();
84
+ const roots = new Map<string, SessionInfo[]>();
85
+ for (const session of included) {
86
+ const parent = session.parentSession ? byPath.get(resolve(session.parentSession)) : undefined;
87
+ if (parent && included.has(parent) && sameDirectory(parent.cwd, session.cwd)) {
88
+ const list = children.get(parent.id) ?? [];
89
+ list.push(session);
90
+ children.set(parent.id, list);
91
+ } else {
92
+ const list = roots.get(session.cwd) ?? [];
93
+ list.push(session);
94
+ roots.set(session.cwd, list);
95
+ }
96
+ }
97
+ // A recently active fork promotes its whole branch, family, and project.
98
+ const activity = new Map<SessionInfo, number>();
99
+ const latestActivity = (session: SessionInfo): number => {
100
+ const cached = activity.get(session);
101
+ if (cached !== undefined) return cached;
102
+ let latest = session.modified;
103
+ activity.set(session, latest); // Guard against malformed parent cycles.
104
+ for (const child of children.get(session.id) ?? []) latest = Math.max(latest, latestActivity(child));
105
+ activity.set(session, latest);
106
+ return latest;
107
+ };
108
+ const newestFirst = (a: SessionInfo, b: SessionInfo) => latestActivity(b) - latestActivity(a);
109
+ const rows: TreeRow[] = [];
110
+ const addSession = (session: SessionInfo, treePrefix: string, ancestorContinues: boolean[], seen: Set<string>) => {
111
+ if (seen.has(session.id)) return;
112
+ seen.add(session.id);
113
+ rows.push({ kind: "session", session, treePrefix });
114
+ const nested = (children.get(session.id) ?? []).sort(newestFirst);
115
+ nested.forEach((child, index) => {
116
+ const isLast = index === nested.length - 1;
117
+ const continuation = ancestorContinues.map((continues) => continues ? "│ " : " ").join("");
118
+ // Root labels begin after 2 cells of row padding, 2 root-indent cells, and the 2-cell selector;
119
+ // connect child branches under the label rather than under the selector.
120
+ addSession(child, ` ${continuation}${isLast ? "└─ " : "├─ "}`, [...ancestorContinues, !isLast], seen);
121
+ });
122
+ };
123
+ for (const group of roots.values()) group.sort(newestFirst);
124
+ const groups = [...roots.entries()].sort(([a, aGroup], [b, bGroup]) => newestFirst(aGroup[0], bGroup[0]) || a.localeCompare(b));
125
+ for (const [cwd, group] of groups) {
126
+ rows.push({ kind: "folder", label: cwd });
127
+ for (const session of group) addSession(session, " ", [], new Set());
128
+ }
129
+ return rows;
130
+ }
131
+
132
+ class SessionFinder implements Component {
133
+ private query = "";
134
+ private selected = 0;
135
+ private offset = 0;
136
+ private rows: TreeRow[];
137
+ // Fixed lines: 2 borders, search, directory, footer.
138
+ private get pageSize(): number { return dialogRows(this.tui, 5); }
139
+
140
+ constructor(private readonly tui: any, private readonly sessions: SessionInfo[], private readonly activePath: string | undefined, private readonly done: (path: string | null) => void, private readonly theme: any) {
141
+ this.rows = buildSessionRows(sessions, this.query);
142
+ const activeIndex = this.rows.findIndex((row) => row.kind === "session" && resolve(row.session.path) === activePath);
143
+ if (activeIndex >= 0) {
144
+ this.selected = activeIndex;
145
+ this.offset = Math.max(0, this.selected - this.pageSize + 1);
146
+ } else {
147
+ this.selectFirstSession();
148
+ }
149
+ }
150
+
151
+ invalidate(): void {}
152
+
153
+ private selectableRows(): number[] {
154
+ return this.rows.flatMap((row, index) => row.kind === "session" ? [index] : []);
155
+ }
156
+
157
+ private selectFirstSession(): void {
158
+ const first = this.selectableRows()[0];
159
+ this.selected = first ?? 0;
160
+ this.offset = Math.max(0, this.selected - this.pageSize + 1);
161
+ }
162
+
163
+ render(width: number): string[] {
164
+ const border = (value: string) => this.theme.fg("border", value);
165
+ const inner = Math.max(1, width - 2);
166
+ const bodyWidth = Math.max(1, inner - 4);
167
+ const title = truncateToWidth(` Find session · ${this.selectableRows().length} results `, inner - 1, "…");
168
+ const lines = [border(`┏${title}${"━".repeat(Math.max(0, inner - visibleWidth(title)))}┓`)];
169
+ const plainRow = (value: string) => {
170
+ const fitted = truncateToWidth(value, bodyWidth, "…");
171
+ return border("┃") + ` ${fitted}${" ".repeat(Math.max(0, bodyWidth - visibleWidth(fitted)))} ` + border("┃");
172
+ };
173
+ lines.push(plainRow(this.theme.fg("warning", `Search: ${this.query}▏`)));
174
+ let activeDirectory = "";
175
+ let activeDirectoryIndex = -1;
176
+ for (let i = Math.min(this.offset, this.rows.length - 1); i >= 0; i--) {
177
+ const row = this.rows[i];
178
+ if (row?.kind === "folder") {
179
+ activeDirectory = row.label;
180
+ activeDirectoryIndex = i;
181
+ break;
182
+ }
183
+ }
184
+ lines.push(plainRow(this.theme.fg("dim", `▸ ${activeDirectory || "(no directory)"}`)));
185
+ const visible = this.rows
186
+ .map((row, index) => ({ row, index }))
187
+ .slice(this.offset, this.offset + this.pageSize + 1)
188
+ .filter(({ index }) => index !== activeDirectoryIndex)
189
+ .slice(0, this.pageSize);
190
+ for (let i = 0; i < this.pageSize; i++) {
191
+ const visibleRow = visible[i];
192
+ if (!visibleRow) { lines.push(plainRow("")); continue; }
193
+ const { row, index } = visibleRow;
194
+ if (row.kind === "folder") {
195
+ lines.push(plainRow(this.theme.fg("dim", `▸ ${row.label}`)));
196
+ continue;
197
+ }
198
+ const isSelected = index === this.selected;
199
+ const isActive = resolve(row.session.path) === this.activePath;
200
+ const label = row.session.name || row.session.firstMessage || `Session ${row.session.id.slice(0, 8)}`;
201
+ const item = `${row.treePrefix}${isSelected ? "▶ " : " "}${isActive ? "● current · " : ""}${label}${row.session.firstMessage && row.session.name ? ` · ${row.session.firstMessage}` : ""}`;
202
+ const fitted = truncateToWidth(item, bodyWidth, "…");
203
+ const padded = ` ${fitted}${" ".repeat(Math.max(0, bodyWidth - visibleWidth(fitted)))} `;
204
+ const styled = isSelected || isActive ? this.theme.fg("warning", padded) : this.theme.fg("text", padded);
205
+ lines.push(border("┃") + styled + border("┃"));
206
+ }
207
+ lines.push(plainRow(this.theme.fg("dim", "↑↓ navigate · type to search · Enter open · Esc close")));
208
+ lines.push(border(`┗${"━".repeat(inner)}┛`));
209
+ return lines;
210
+ }
211
+
212
+ handleMouse(event: TuiMouseEvent) { return wheelAsArrows(event, (key) => this.handleInput(key)); }
213
+
214
+ handleInput(data: string): void {
215
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) return this.done(null);
216
+ if (matchesKey(data, "backspace")) {
217
+ this.query = this.query.slice(0, -1);
218
+ this.rows = buildSessionRows(this.sessions, this.query);
219
+ this.selectFirstSession();
220
+ } else if (matchesKey(data, "up") || matchesKey(data, "down")) {
221
+ const choices = this.selectableRows();
222
+ const position = choices.indexOf(this.selected);
223
+ const next = matchesKey(data, "up") ? Math.max(0, position - 1) : Math.min(choices.length - 1, position + 1);
224
+ this.selected = choices[next] ?? 0;
225
+ } else if (matchesKey(data, "enter")) {
226
+ const row = this.rows[this.selected];
227
+ if (row?.kind === "session") return this.done(row.session.path);
228
+ } else if (!/[\x00-\x1f\x7f]/.test(data)) {
229
+ this.query += data;
230
+ this.rows = buildSessionRows(this.sessions, this.query);
231
+ this.selectFirstSession();
232
+ }
233
+ this.offset = Math.max(0, Math.min(this.selected - this.pageSize + 1, this.rows.length - this.pageSize));
234
+ this.tui.requestRender();
235
+ }
236
+ }
237
+
238
+ export default function (pi: ExtensionAPI): void {
239
+ pi.registerCommand("session-global", {
240
+ description: "Find and open a saved session from any project",
241
+ handler: async (_args, ctx) => {
242
+ const destinationCwd = ctx.cwd;
243
+ const destinationSessionDir = ctx.sessionManager.getSessionDir();
244
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
245
+ const roots = [process.env.PI_CODING_AGENT_SESSION_DIR || join(agentDir, "sessions"), destinationSessionDir];
246
+ const files = await discoverSessionFiles(roots);
247
+ const sessions = (await Promise.all(files.map(readSessionInfo))).filter((session): session is SessionInfo => session !== null);
248
+ if (!sessions.length) return ctx.ui.notify("No saved sessions found", "info");
249
+ let selectedPath: string | null;
250
+ try {
251
+ const currentPath = ctx.sessionManager.getSessionFile();
252
+ selectedPath = await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => new SessionFinder(tui, sessions, currentPath ? resolve(currentPath) : undefined, done, theme), DIALOG_OVERLAY);
253
+ } catch (error) {
254
+ return ctx.ui.notify(`Could not open session finder: ${error instanceof Error ? error.message : String(error)}`, "error");
255
+ }
256
+ if (!selectedPath) return;
257
+ const selected = sessions.find((session) => session.path === selectedPath);
258
+ if (!selected) return;
259
+ if (sameDirectory(selected.cwd, destinationCwd)) {
260
+ const result = await ctx.switchSession(selectedPath);
261
+ if (result.cancelled) ctx.ui.notify("Session switch cancelled", "info");
262
+ return;
263
+ }
264
+ const choice = await ctx.ui.select(
265
+ `Fork “${selected.name || selected.firstMessage || selected.id}” into this project?\nFrom: ${selected.cwd}\nInto: ${destinationCwd}\nCopies the conversation into a new session. The original stays unchanged. No project files are copied.`,
266
+ ["Cancel", "Fork here"],
267
+ );
268
+ if (choice !== "Fork here") return;
269
+ let forkPath: string | undefined;
270
+ const discardFork = async () => {
271
+ if (!forkPath) return;
272
+ try { await unlink(forkPath); }
273
+ catch (error) {
274
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
275
+ ctx.ui.notify(`Could not remove unused fork ${forkPath}: ${String(error)}`, "warning");
276
+ }
277
+ };
278
+ try {
279
+ // Use Pi's cross-project fork API; never change the original header or process cwd.
280
+ const fork = SessionManager.forkFrom(selectedPath, destinationCwd, destinationSessionDir);
281
+ forkPath = fork.getSessionFile();
282
+ if (!forkPath) throw new Error("Fork did not create a saved session");
283
+ fork.appendCustomMessageEntry("session-finder-transfer",
284
+ `This conversation was forked from ${selected.cwd} into ${destinationCwd}. The active workspace is now ${destinationCwd}; follow its project instructions. Earlier paths and findings refer to the source project unless explicitly stated otherwise. No project files were copied.`, true);
285
+ } catch (error) {
286
+ await discardFork();
287
+ ctx.ui.notify(`Could not fork session: ${error instanceof Error ? error.message : String(error)}`, "error");
288
+ return;
289
+ }
290
+ // Let the runtime report activation failures: it may already have replaced ctx.
291
+ const result = await ctx.switchSession(forkPath, {
292
+ withSession: async (fresh) => { fresh.ui.notify("Session forked into this project", "info"); },
293
+ });
294
+ if (result.cancelled) {
295
+ await discardFork();
296
+ ctx.ui.notify("Session fork cancelled", "info");
297
+ }
298
+ },
299
+ });
300
+ pi.registerShortcut(Key.alt("i"), {
301
+ description: "Find and open a saved session",
302
+ handler: async (ctx) => {
303
+ ctx.ui.setEditorText("/find");
304
+ },
305
+ });
306
+ }
@@ -0,0 +1,31 @@
1
+ // One size for every overlay dialog in this package. Change DIALOG_HEIGHT here and every dialog follows:
2
+ // the overlay is capped at that share of the terminal and each dialog pads its list to fill it.
3
+
4
+ /** Share of the terminal height every dialog fills. */
5
+ export const DIALOG_HEIGHT = 0.8
6
+
7
+ /** `ctx.ui.custom(factory, DIALOG_OVERLAY)`: centered, 92% wide, DIALOG_HEIGHT tall. */
8
+ export const DIALOG_OVERLAY = {
9
+ overlay: true,
10
+ overlayOptions: { anchor: 'center', width: '92%', maxHeight: `${DIALOG_HEIGHT * 100}%`, margin: 1 },
11
+ } as const
12
+
13
+ /**
14
+ * Scrolling rows that fit once `chrome` fixed lines (borders, search, header, footer) are drawn.
15
+ * Same floor as the overlay's own percentage, so dialog and overlay agree; read it in render() so a
16
+ * resize takes effect on the next frame.
17
+ */
18
+ export const dialogRows = (tui: { terminal?: { rows: number } }, chrome: number, min = 1): number =>
19
+ Math.max(min, Math.floor((tui.terminal?.rows ?? 24) * DIALOG_HEIGHT) - chrome)
20
+
21
+ /**
22
+ * Mouse wheel → ↑/↓ key presses, so a dialog reuses its own keyboard handling (clamping, render).
23
+ * Use as `handleMouse(e) { return wheelAsArrows(e, (k) => this.handleInput(k)) }`. Fullscreen mode only:
24
+ * regular mode leaves the mouse to the terminal. Handling the wheel also stops it scrolling the transcript behind.
25
+ */
26
+ export function wheelAsArrows(event: { type: string; wheelDelta?: number }, handleInput: (data: string) => void): { handled: true } | undefined {
27
+ if (event.type !== 'wheel' || !event.wheelDelta) return undefined
28
+ const key = event.wheelDelta < 0 ? '\x1b[A' : '\x1b[B'
29
+ for (let i = 0; i < Math.min(Math.abs(event.wheelDelta), 20); i++) handleInput(key)
30
+ return { handled: true }
31
+ }
@@ -0,0 +1,18 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { resolve } from 'node:path';
4
+ import { loadExtension } from '../../../test/load-extension.mjs';
5
+ const { buildSessionRows } = loadExtension(new URL('../src/index.ts', import.meta.url), {
6
+ '@earendil-works/pi-coding-agent': {}, '@earendil-works/pi-tui': {},
7
+ });
8
+ test('child search retains ancestors and family activity controls project ordering', () => {
9
+ const root = { id: 'root', path: resolve('fixtures/root.jsonl'), cwd: '/project-a', name: 'parent', firstMessage: '', modified: 1 };
10
+ const child = { id: 'child', path: resolve('fixtures/child.jsonl'), cwd: '/project-a', parentSession: root.path, name: 'needle', firstMessage: '', modified: 100 };
11
+ const other = { id: 'other', path: resolve('fixtures/other.jsonl'), cwd: '/project-b', firstMessage: 'other', modified: 50 };
12
+ const all = buildSessionRows([root, other, child], '');
13
+ assert.equal(all[0].label, '/project-a');
14
+ const filtered = buildSessionRows([root, other, child], 'NEEDLE');
15
+ assert.deepEqual(Array.from(filtered.filter(x => x.kind === 'session'), x => x.session.id), ['root', 'child']);
16
+ assert.match(filtered[2].treePrefix, /└─/);
17
+ assert.equal(buildSessionRows([root, child], 'missing').length, 0);
18
+ });