pi-md-tree 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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -0
  3. package/index.ts +605 -0
  4. package/package.json +54 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Bianchi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # pi-md-tree
2
+
3
+ Save assistant responses as Markdown, then browse, search, open, load, or preview them from a keyboard-driven tree inside [Pi](https://pi.dev).
4
+
5
+ `pi-md-tree` keeps temporary notes out of your working tree by default while making project documentation and saved notes available from one picker.
6
+
7
+ ## Features
8
+
9
+ - Save the latest assistant response without thinking or tool-call content.
10
+ - Keep default saves under the repository's private `.git` metadata.
11
+ - Browse configured notes, hidden saves, and repository documentation together.
12
+ - Find Markdown with an incremental, case-insensitive path search.
13
+ - Load a document into Pi's editor as context.
14
+ - Open a document in its system application or editor.
15
+ - Preview through [`pi-markdown-preview`](https://github.com/omaclaren/pi-markdown-preview) in the browser or inline in a supported terminal.
16
+ - Navigate with arrows or Vim-style keys.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pi install npm:pi-md-tree
22
+ ```
23
+
24
+ Reload an existing Pi session after installation:
25
+
26
+ ```text
27
+ /reload
28
+ ```
29
+
30
+ ### Preview prerequisites
31
+
32
+ Previewing requires [Pandoc](https://pandoc.org/installing.html):
33
+
34
+ ```bash
35
+ brew install pandoc
36
+ ```
37
+
38
+ Inline terminal previews also require a Chromium-based browser and an image-capable terminal such as Ghostty, Kitty, iTerm2, or WezTerm. Browser preview works without terminal image support.
39
+
40
+ ## Commands
41
+
42
+ ### Save a response
43
+
44
+ ```text
45
+ /save-md <name> [directory]
46
+ ```
47
+
48
+ The `.md` suffix is optional. Existing files are never overwritten.
49
+
50
+ Inside a Git repository, the default destination is:
51
+
52
+ ```text
53
+ .git/pi-save-md/<session-working-directory>/
54
+ ```
55
+
56
+ The path is scoped to Pi's working directory within the repository. Outside Git, the fallback is `saved-md/` beneath the current directory.
57
+
58
+ Examples:
59
+
60
+ ```text
61
+ /save-md query-plan
62
+ /save-md design ./notes
63
+ /save-md design "~/Documents/project notes"
64
+ ```
65
+
66
+ ### Browse documents
67
+
68
+ ```text
69
+ /md [directory]
70
+ /md-tree [directory]
71
+ ```
72
+
73
+ `/md-tree` is an alias for `/md`. The picker combines these sources when they contain matching files:
74
+
75
+ 1. A directory passed to `/md`, or the configured `PI_SAVE_MD_DIR`.
76
+ 2. The hidden `.git/pi-save-md/<session-working-directory>/` directory, even when another save directory is configured.
77
+ 3. Tracked and unignored, untracked `.md` and `.AGENTS` files from the current Git repository, including `AGENTS.md`.
78
+
79
+ ## Picker keys
80
+
81
+ | Key | Action |
82
+ | --- | --- |
83
+ | `j` / `k`, `↓` / `↑` | Move down or up |
84
+ | `Ctrl+D` / `Ctrl+U` | Move down or up half a page |
85
+ | `h` / `l` | Collapse or expand a directory |
86
+ | `Enter` | Toggle a directory or load a file into Pi's editor |
87
+ | `/` | Enter or edit path search |
88
+ | `Enter` while searching | Apply the current filter |
89
+ | `Esc` while searching | Clear the filter |
90
+ | `o` or `e` | Open the selected file |
91
+ | `p`, then `b` | Preview the selected file in a browser |
92
+ | `p`, then `t` | Preview the selected file inline in the terminal |
93
+ | `Esc` | Cancel the current action or close the picker |
94
+
95
+ Search is case-insensitive and matches both labels and full paths. Matching files retain their parent directories in the filtered tree.
96
+
97
+ ## Configuration
98
+
99
+ Set `PI_SAVE_MD_DIR` before starting Pi to choose the default save directory:
100
+
101
+ ```bash
102
+ export PI_SAVE_MD_DIR="$HOME/Documents/pi-notes"
103
+ ```
104
+
105
+ A directory supplied directly to `/save-md` or `/md` overrides this value for that command. The hidden repository save location remains visible in `/md` either way.
106
+
107
+ ## Opening files
108
+
109
+ On macOS, `o` uses `open`; on Linux, it uses `xdg-open`.
110
+
111
+ When `HERDR_ENV=1`, `o` opens a focused sibling Herdr pane and starts `$VISUAL`, `$EDITOR`, or `nvim` with the selected file.
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ npm install
117
+ npm run check
118
+ ```
119
+
120
+ ## License
121
+
122
+ [MIT](LICENSE)
package/index.ts ADDED
@@ -0,0 +1,605 @@
1
+ import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
2
+ import { homedir, platform } from "node:os";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+
5
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
6
+ import type {
7
+ ExtensionAPI,
8
+ ExtensionCommandContext,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
11
+ import {
12
+ closeSharedPreviewBrowser,
13
+ openPreview,
14
+ openPreviewInBrowser,
15
+ } from "pi-markdown-preview";
16
+
17
+ interface MarkdownNode {
18
+ kind: "directory" | "file";
19
+ depth: number;
20
+ key: string;
21
+ label: string;
22
+ parentKey?: string;
23
+ path: string;
24
+ }
25
+
26
+ interface MarkdownSource {
27
+ files: string[];
28
+ id: string;
29
+ label: string;
30
+ root: string;
31
+ }
32
+
33
+ interface PickerResult {
34
+ action: "load" | "open" | "preview-browser" | "preview-terminal";
35
+ path: string;
36
+ }
37
+
38
+ function textContent(content: unknown): string {
39
+ if (!Array.isArray(content)) return "";
40
+
41
+ return content
42
+ .filter(
43
+ (block): block is { type: "text"; text: string } =>
44
+ typeof block === "object" &&
45
+ block !== null &&
46
+ "type" in block &&
47
+ block.type === "text" &&
48
+ "text" in block &&
49
+ typeof block.text === "string",
50
+ )
51
+ .map((block) => block.text)
52
+ .join("\n\n");
53
+ }
54
+
55
+ // Command arguments are shell-like so paths containing spaces can be quoted.
56
+ export function parseArguments(input: string): string[] {
57
+ const values: string[] = [];
58
+ let value = "";
59
+ let quote: "'" | '"' | undefined;
60
+ let escaping = false;
61
+
62
+ for (const character of input.trim()) {
63
+ if (escaping) {
64
+ value += character;
65
+ escaping = false;
66
+ } else if (character === "\\" && quote !== "'") {
67
+ escaping = true;
68
+ } else if (quote) {
69
+ if (character === quote) quote = undefined;
70
+ else value += character;
71
+ } else if (character === "'" || character === '"') {
72
+ quote = character;
73
+ } else if (/\s/.test(character)) {
74
+ if (value) {
75
+ values.push(value);
76
+ value = "";
77
+ }
78
+ } else {
79
+ value += character;
80
+ }
81
+ }
82
+ if (escaping) value += "\\";
83
+ if (quote) throw new Error("Unclosed quote in command arguments");
84
+ if (value) values.push(value);
85
+ return values;
86
+ }
87
+
88
+ async function defaultMarkdownDirectory(pi: ExtensionAPI, cwd: string): Promise<string> {
89
+ const repository = await pi.exec("git", [
90
+ "-C",
91
+ cwd,
92
+ "rev-parse",
93
+ "--show-toplevel",
94
+ "--absolute-git-dir",
95
+ ]);
96
+ if (repository.code !== 0) {
97
+ return join(cwd, "saved-md");
98
+ }
99
+
100
+ const [repositoryRoot, gitDirectory] = repository.stdout.trim().split("\n");
101
+ if (!repositoryRoot || !gitDirectory) {
102
+ throw new Error("Could not locate the Git repository metadata");
103
+ }
104
+ const canonicalCwd = await realpath(cwd);
105
+ return join(gitDirectory, "pi-save-md", relative(repositoryRoot, canonicalCwd));
106
+ }
107
+
108
+ function shellQuote(value: string): string {
109
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
110
+ }
111
+
112
+ function expandDirectory(path: string, cwd: string): string {
113
+ if (path === "~") return homedir();
114
+ if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
115
+ return resolve(cwd, path);
116
+ }
117
+
118
+ async function markdownDirectory(
119
+ pi: ExtensionAPI,
120
+ cwd: string,
121
+ directory?: string,
122
+ ): Promise<string> {
123
+ const configuredDirectory = directory ?? process.env.PI_SAVE_MD_DIR;
124
+ return configuredDirectory
125
+ ? expandDirectory(configuredDirectory, cwd)
126
+ : defaultMarkdownDirectory(pi, cwd);
127
+ }
128
+
129
+ function isMarkdownOrAgentsFile(path: string): boolean {
130
+ const name = basename(path).toLowerCase();
131
+ return name.endsWith(".md") || name === ".agents";
132
+ }
133
+
134
+ async function collectMarkdownFiles(root: string): Promise<string[]> {
135
+ const files: string[] = [];
136
+
137
+ async function visit(directory: string): Promise<void> {
138
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
139
+ const path = join(directory, entry.name);
140
+ if (entry.isDirectory()) await visit(path);
141
+ else if (entry.isFile() && isMarkdownOrAgentsFile(path)) files.push(path);
142
+ }
143
+ }
144
+
145
+ try {
146
+ await visit(root);
147
+ } catch (error) {
148
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
149
+ }
150
+ return files;
151
+ }
152
+
153
+ async function repositoryMarkdownSource(
154
+ pi: ExtensionAPI,
155
+ cwd: string,
156
+ ): Promise<MarkdownSource | undefined> {
157
+ const repository = await pi.exec("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
158
+ if (repository.code !== 0) return undefined;
159
+ const root = repository.stdout.trim();
160
+ if (!root) return undefined;
161
+
162
+ const listed = await pi.exec("git", [
163
+ "-C",
164
+ root,
165
+ "ls-files",
166
+ "--cached",
167
+ "--others",
168
+ "--exclude-standard",
169
+ "-z",
170
+ ]);
171
+ if (listed.code !== 0) {
172
+ throw new Error(listed.stderr.trim() || "Could not list repository files");
173
+ }
174
+ const files = listed.stdout
175
+ .split("\0")
176
+ .filter(isMarkdownOrAgentsFile)
177
+ .map((path) => join(root, path));
178
+ return { files, id: "repository", label: `Repository — ${root}`, root };
179
+ }
180
+
181
+ function collectMarkdownNodes(sources: MarkdownSource[]): MarkdownNode[] {
182
+ const nodes: MarkdownNode[] = [];
183
+ for (const source of sources) {
184
+ const rootKey = `${source.id}:`;
185
+ nodes.push({
186
+ kind: "directory",
187
+ depth: 0,
188
+ key: rootKey,
189
+ label: source.label,
190
+ path: source.root,
191
+ });
192
+ const directories = new Set<string>();
193
+ for (const file of source.files) {
194
+ let directory = dirname(relative(source.root, file));
195
+ while (directory !== ".") {
196
+ directories.add(directory);
197
+ const parent = dirname(directory);
198
+ if (parent === directory) break;
199
+ directory = parent;
200
+ }
201
+ }
202
+ for (const directory of [...directories].sort((a, b) => {
203
+ const depthDifference = a.split("/").length - b.split("/").length;
204
+ return depthDifference || a.localeCompare(b);
205
+ })) {
206
+ const parent = dirname(directory);
207
+ nodes.push({
208
+ kind: "directory",
209
+ depth: directory.split("/").length,
210
+ key: `${source.id}:${directory}`,
211
+ label: basename(directory),
212
+ parentKey: parent === "." ? rootKey : `${source.id}:${parent}`,
213
+ path: join(source.root, directory),
214
+ });
215
+ }
216
+ for (const file of source.files.sort((a, b) => a.localeCompare(b))) {
217
+ const filePath = relative(source.root, file);
218
+ const parent = dirname(filePath);
219
+ nodes.push({
220
+ kind: "file",
221
+ depth: filePath.split("/").length,
222
+ key: `${source.id}:${filePath}`,
223
+ label: basename(file),
224
+ parentKey: parent === "." ? rootKey : `${source.id}:${parent}`,
225
+ path: file,
226
+ });
227
+ }
228
+ }
229
+
230
+ const byParent = new Map<string, MarkdownNode[]>();
231
+ for (const node of nodes) {
232
+ if (!node.parentKey) continue;
233
+ const siblings = byParent.get(node.parentKey) ?? [];
234
+ siblings.push(node);
235
+ byParent.set(node.parentKey, siblings);
236
+ }
237
+ const ordered: MarkdownNode[] = [];
238
+ const append = (node: MarkdownNode) => {
239
+ ordered.push(node);
240
+ const children = (byParent.get(node.key) ?? []).sort((a, b) => {
241
+ if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1;
242
+ return a.label.localeCompare(b.label);
243
+ });
244
+ for (const child of children) append(child);
245
+ };
246
+ for (const source of sources) {
247
+ const root = nodes.find((node) => node.key === `${source.id}:`);
248
+ if (root) append(root);
249
+ }
250
+ return ordered;
251
+ }
252
+
253
+ async function pickMarkdown(
254
+ ctx: ExtensionCommandContext,
255
+ nodes: MarkdownNode[],
256
+ ): Promise<PickerResult | null> {
257
+ if (ctx.mode !== "tui") return null;
258
+ let selectedKey = nodes[0]?.key;
259
+ if (!selectedKey) return null;
260
+ const collapsed = new Set<string>();
261
+ const nodesByKey = new Map(nodes.map((node) => [node.key, node]));
262
+ let choosingPreviewTarget = false;
263
+ let query = "";
264
+ let searching = false;
265
+ const visibleNodes = () => {
266
+ const normalizedQuery = query.toLowerCase();
267
+ let matchingKeys: Set<string> | undefined;
268
+ if (normalizedQuery) {
269
+ matchingKeys = new Set<string>();
270
+ for (const node of nodes) {
271
+ if (!`${node.label} ${node.path}`.toLowerCase().includes(normalizedQuery)) continue;
272
+ let current: MarkdownNode | undefined = node;
273
+ while (current) {
274
+ matchingKeys.add(current.key);
275
+ current = current.parentKey ? nodesByKey.get(current.parentKey) : undefined;
276
+ }
277
+ }
278
+ }
279
+ return nodes.filter((node) => {
280
+ if (matchingKeys && !matchingKeys.has(node.key)) return false;
281
+ if (normalizedQuery) return true;
282
+ let parentKey = node.parentKey;
283
+ while (parentKey) {
284
+ if (collapsed.has(parentKey)) return false;
285
+ parentKey = nodesByKey.get(parentKey)?.parentKey;
286
+ }
287
+ return true;
288
+ });
289
+ };
290
+
291
+ return ctx.ui.custom<PickerResult | null>((tui, theme, _keybindings, done) => ({
292
+ render(width: number): string[] {
293
+ const allVisible = visibleNodes();
294
+ let selected = allVisible.findIndex((node) => node.key === selectedKey);
295
+ if (selected < 0) {
296
+ selected = 0;
297
+ if (allVisible[0]) selectedKey = allVisible[0].key;
298
+ }
299
+ const height = 18;
300
+ const start = Math.max(
301
+ 0,
302
+ Math.min(selected - Math.floor(height / 2), allVisible.length - height),
303
+ );
304
+ const visible = allVisible.slice(start, start + height).map((node) => {
305
+ const prefix = node.key === selectedKey ? theme.fg("accent", "> ") : " ";
306
+ const marker =
307
+ node.kind === "directory" ? (collapsed.has(node.key) ? "▸" : "▾") : "└─";
308
+ const label = `${" ".repeat(node.depth)}${marker} ${node.label}`;
309
+ const text = node.kind === "directory" ? theme.fg("muted", label) : label;
310
+ return truncateToWidth(prefix + text, width);
311
+ });
312
+ const help = choosingPreviewTarget
313
+ ? "Preview selected file: b browser • t terminal • esc back"
314
+ : searching
315
+ ? "Type to search • enter apply • esc clear"
316
+ : "j/k move • ctrl+u/d half-page • / search • enter load/toggle • o open • p preview • esc cancel";
317
+ const title = searching
318
+ ? `Search: ${query}▌`
319
+ : query
320
+ ? `Markdown files — filter: ${query}`
321
+ : "Markdown files";
322
+ return [
323
+ truncateToWidth(theme.fg("accent", theme.bold(title)), width),
324
+ ...(visible.length > 0 ? visible : [theme.fg("muted", " No matches")]),
325
+ truncateToWidth(theme.fg("dim", help), width),
326
+ ];
327
+ },
328
+ handleInput(data: string): void {
329
+ if (searching) {
330
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
331
+ query = "";
332
+ searching = false;
333
+ } else if (matchesKey(data, Key.enter)) {
334
+ searching = false;
335
+ } else if (matchesKey(data, Key.backspace)) {
336
+ query = query.slice(0, -1);
337
+ } else if (data.length === 1 && data >= " ") {
338
+ query += data;
339
+ }
340
+ tui.requestRender();
341
+ return;
342
+ }
343
+
344
+ const allVisible = visibleNodes();
345
+ let selected = allVisible.findIndex((node) => node.key === selectedKey);
346
+ if (selected < 0) {
347
+ selected = 0;
348
+ if (allVisible[0]) selectedKey = allVisible[0].key;
349
+ }
350
+ const node = allVisible[selected];
351
+ const move = (step: number) => {
352
+ if (allVisible.length > 0) {
353
+ const nextIndex = Math.max(0, Math.min(selected + step, allVisible.length - 1));
354
+ selectedKey = allVisible[nextIndex]!.key;
355
+ }
356
+ tui.requestRender();
357
+ };
358
+ const loadOrToggle = () => {
359
+ if (!node) return;
360
+ if (node.kind === "file") done({ action: "load", path: node.path });
361
+ else {
362
+ if (collapsed.has(node.key)) collapsed.delete(node.key);
363
+ else collapsed.add(node.key);
364
+ tui.requestRender();
365
+ }
366
+ };
367
+ if (choosingPreviewTarget) {
368
+ if (data === "b" && node) done({ action: "preview-browser", path: node.path });
369
+ else if (data === "t" && node) done({ action: "preview-terminal", path: node.path });
370
+ else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
371
+ choosingPreviewTarget = false;
372
+ tui.requestRender();
373
+ }
374
+ return;
375
+ }
376
+ if (data === "/") {
377
+ searching = true;
378
+ tui.requestRender();
379
+ } else if (matchesKey(data, Key.ctrl("u"))) move(-9);
380
+ else if (matchesKey(data, Key.ctrl("d"))) move(9);
381
+ else if (matchesKey(data, Key.up) || data === "k") move(-1);
382
+ else if (matchesKey(data, Key.down) || data === "j") move(1);
383
+ else if (data === "h" && node) {
384
+ if (node.kind === "directory" && !collapsed.has(node.key)) collapsed.add(node.key);
385
+ else if (node.parentKey) selectedKey = node.parentKey;
386
+ tui.requestRender();
387
+ } else if (data === "l" && node) {
388
+ if (node.kind === "directory") {
389
+ collapsed.delete(node.key);
390
+ tui.requestRender();
391
+ } else done({ action: "load", path: node.path });
392
+ } else if (matchesKey(data, Key.enter)) loadOrToggle();
393
+ else if ((data === "o" || data === "e") && node?.kind === "file") {
394
+ done({ action: "open", path: node.path });
395
+ } else if (data === "p" && node?.kind === "file") {
396
+ choosingPreviewTarget = true;
397
+ tui.requestRender();
398
+ } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) done(null);
399
+ },
400
+ invalidate() {},
401
+ }));
402
+ }
403
+
404
+ async function previewMarkdown(
405
+ ctx: ExtensionCommandContext,
406
+ path: string,
407
+ target: "browser" | "terminal",
408
+ ): Promise<void> {
409
+ const markdown = await readFile(path, "utf8");
410
+ if (target === "browser") {
411
+ await openPreviewInBrowser(ctx, markdown, dirname(path));
412
+ } else {
413
+ await openPreview(ctx, markdown, dirname(path));
414
+ }
415
+ }
416
+
417
+ async function openMarkdown(pi: ExtensionAPI, path: string): Promise<void> {
418
+ if (process.env.HERDR_ENV === "1") {
419
+ const split = await pi.exec("herdr", [
420
+ "pane",
421
+ "split",
422
+ "--current",
423
+ "--direction",
424
+ "right",
425
+ "--cwd",
426
+ dirname(path),
427
+ "--focus",
428
+ ]);
429
+ if (split.code !== 0) throw new Error(split.stderr.trim() || "Could not open a Herdr pane");
430
+ const response = JSON.parse(split.stdout) as { result?: { pane?: { pane_id?: string } } };
431
+ const pane = response.result?.pane?.pane_id;
432
+ if (!pane) throw new Error("Herdr did not return the new pane ID");
433
+ const editor = process.env.VISUAL || process.env.EDITOR || "nvim";
434
+ const opened = await pi.exec("herdr", [
435
+ "pane",
436
+ "run",
437
+ pane,
438
+ `${editor} ${shellQuote(path)}`,
439
+ ]);
440
+ if (opened.code !== 0) throw new Error(opened.stderr.trim() || "Could not start the editor");
441
+ return;
442
+ }
443
+
444
+ const command = platform() === "darwin" ? "open" : "xdg-open";
445
+ const opened = await pi.exec(command, [path]);
446
+ if (opened.code !== 0) throw new Error(opened.stderr.trim() || `Could not open ${path}`);
447
+ }
448
+
449
+ export default function saveMarkdownExtension(pi: ExtensionAPI) {
450
+ pi.registerCommand("save-md", {
451
+ description: "Save the latest assistant response as Markdown",
452
+ getArgumentCompletions: () => null,
453
+ handler: async (args, ctx) => {
454
+ await ctx.waitForIdle();
455
+
456
+ let parsed: string[];
457
+ try {
458
+ parsed = parseArguments(args);
459
+ } catch (error) {
460
+ ctx.ui.notify((error as Error).message, "warning");
461
+ return;
462
+ }
463
+ if (parsed.length < 1 || parsed.length > 2) {
464
+ ctx.ui.notify("Usage: /save-md <name> [directory]", "warning");
465
+ return;
466
+ }
467
+ const [name, requestedDirectory] = parsed as [string, string?];
468
+ if (basename(name) !== name || name === "." || name === "..") {
469
+ ctx.ui.notify("The name must be a file name, not a path", "warning");
470
+ return;
471
+ }
472
+
473
+ const branch = ctx.sessionManager.getBranch();
474
+ let assistantMessage: AssistantMessage | undefined;
475
+ for (let index = branch.length - 1; index >= 0; index--) {
476
+ const entry = branch[index];
477
+ if (entry?.type === "message" && entry.message.role === "assistant") {
478
+ assistantMessage = entry.message;
479
+ break;
480
+ }
481
+ }
482
+ if (!assistantMessage) {
483
+ ctx.ui.notify("No assistant response to save", "warning");
484
+ return;
485
+ }
486
+ const markdown = textContent(assistantMessage.content);
487
+ if (!markdown.trim()) {
488
+ ctx.ui.notify("The latest assistant response has no Markdown text", "warning");
489
+ return;
490
+ }
491
+
492
+ let saveDirectory: string;
493
+ try {
494
+ saveDirectory = await markdownDirectory(pi, ctx.cwd, requestedDirectory);
495
+ } catch (error) {
496
+ ctx.ui.notify((error as Error).message, "error");
497
+ return;
498
+ }
499
+ const fileName = name.endsWith(".md") ? name : `${name}.md`;
500
+ const path = join(saveDirectory, fileName);
501
+ try {
502
+ await mkdir(saveDirectory, { recursive: true });
503
+ await writeFile(path, markdown.endsWith("\n") ? markdown : `${markdown}\n`, {
504
+ encoding: "utf8",
505
+ flag: "wx",
506
+ });
507
+ } catch (error) {
508
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
509
+ ctx.ui.notify(`File already exists: ${path}`, "error");
510
+ return;
511
+ }
512
+ throw error;
513
+ }
514
+ ctx.ui.notify(`Saved Markdown to ${path}`, "info");
515
+ },
516
+ });
517
+
518
+ const browse = async (args: string, ctx: ExtensionCommandContext) => {
519
+ let requestedDirectory: string | undefined;
520
+ try {
521
+ const parsed = parseArguments(args);
522
+ if (parsed.length > 1) throw new Error("Usage: /md [directory]");
523
+ requestedDirectory = parsed[0];
524
+ } catch (error) {
525
+ ctx.ui.notify((error as Error).message, "warning");
526
+ return;
527
+ }
528
+ let sources: MarkdownSource[];
529
+ try {
530
+ const hiddenRoot = await defaultMarkdownDirectory(pi, ctx.cwd);
531
+ const configuredDirectory = requestedDirectory ?? process.env.PI_SAVE_MD_DIR;
532
+ const configuredRoot = configuredDirectory
533
+ ? expandDirectory(configuredDirectory, ctx.cwd)
534
+ : undefined;
535
+ sources = [];
536
+ if (configuredRoot && configuredRoot !== hiddenRoot) {
537
+ const files = await collectMarkdownFiles(configuredRoot);
538
+ if (files.length > 0) {
539
+ sources.push({
540
+ files,
541
+ id: "configured",
542
+ label: `Saved Markdown (configured) — ${configuredRoot}`,
543
+ root: configuredRoot,
544
+ });
545
+ }
546
+ }
547
+ const hiddenFiles = await collectMarkdownFiles(hiddenRoot);
548
+ if (hiddenFiles.length > 0) {
549
+ sources.push({
550
+ files: hiddenFiles,
551
+ id: "hidden",
552
+ label: `Saved Markdown (hidden) — ${hiddenRoot}`,
553
+ root: hiddenRoot,
554
+ });
555
+ }
556
+ const repository = await repositoryMarkdownSource(pi, ctx.cwd);
557
+ if (repository && repository.files.length > 0) sources.push(repository);
558
+ } catch (error) {
559
+ ctx.ui.notify((error as Error).message, "error");
560
+ return;
561
+ }
562
+ if (sources.length === 0) {
563
+ ctx.ui.notify("No Markdown or .AGENTS files found", "warning");
564
+ return;
565
+ }
566
+ const nodes = collectMarkdownNodes(sources);
567
+ const choice = await pickMarkdown(ctx, nodes);
568
+ if (!choice) return;
569
+ if (choice.action === "open") {
570
+ try {
571
+ await openMarkdown(pi, choice.path);
572
+ ctx.ui.notify(`Opened ${choice.path}`, "info");
573
+ } catch (error) {
574
+ ctx.ui.notify((error as Error).message, "error");
575
+ }
576
+ return;
577
+ }
578
+ if (choice.action === "preview-browser" || choice.action === "preview-terminal") {
579
+ try {
580
+ await previewMarkdown(
581
+ ctx,
582
+ choice.path,
583
+ choice.action === "preview-browser" ? "browser" : "terminal",
584
+ );
585
+ } catch (error) {
586
+ ctx.ui.notify((error as Error).message, "error");
587
+ }
588
+ return;
589
+ }
590
+ const markdown = await readFile(choice.path, "utf8");
591
+ ctx.ui.pasteToEditor(markdown);
592
+ ctx.ui.notify(`Loaded ${choice.path} into the editor`, "info");
593
+ };
594
+
595
+ pi.on("session_shutdown", closeSharedPreviewBrowser);
596
+
597
+ pi.registerCommand("md", {
598
+ description: "Browse saved and repository Markdown; Enter loads, o opens, p previews",
599
+ handler: browse,
600
+ });
601
+ pi.registerCommand("md-tree", {
602
+ description: "Alias for /md",
603
+ handler: browse,
604
+ });
605
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "pi-md-tree",
3
+ "version": "0.1.0",
4
+ "description": "Save, browse, search, open, and preview Markdown from Pi",
5
+ "type": "module",
6
+ "exports": "./index.ts",
7
+ "files": [
8
+ "index.ts",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "scripts": {
13
+ "check": "npm run typecheck && npm test",
14
+ "test": "node --test test/*.test.mjs",
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "keywords": [
18
+ "pi-package",
19
+ "pi-coding-agent",
20
+ "markdown",
21
+ "terminal",
22
+ "tui"
23
+ ],
24
+ "author": "Alexander Bianchi",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/alexanderbianchi/pi-md-tree.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/alexanderbianchi/pi-md-tree/issues"
32
+ },
33
+ "homepage": "https://github.com/alexanderbianchi/pi-md-tree#readme",
34
+ "pi": {
35
+ "extensions": [
36
+ "./index.ts"
37
+ ]
38
+ },
39
+ "dependencies": {
40
+ "pi-markdown-preview": "0.16.0"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-ai": "*",
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*"
46
+ },
47
+ "devDependencies": {
48
+ "jiti": "2.6.1",
49
+ "typescript": "5.9.3"
50
+ },
51
+ "engines": {
52
+ "node": ">=22"
53
+ }
54
+ }