pi-extensions 0.1.32 → 0.1.34

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.
@@ -4,8 +4,28 @@ All notable changes to this extension will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.18] - 2026-04-19
8
+
9
+ ### Changed
10
+ - Show symlinks with a `↗` marker in the `/readfiles` tree.
11
+
12
+ ### Fixed
13
+ - Let `/readfiles` navigate into directory symlinks in both non-git folders and git repos instead of rendering them as inert files or empty directories.
14
+ - Guard symlink directory scanning against ancestor cycles so links like `foo -> .` or `foo -> ..` don't recurse forever.
15
+ - Treat git-tracked and untracked directory symlinks as lazily scannable directories rather than plain files.
16
+
17
+ ### Thanks
18
+ - Thanks to @xapids for reporting the original macOS symlink navigation issue ([#9](https://github.com/tmustier/pi-extensions/issues/9)).
19
+
20
+ ## [0.1.17] - 2026-04-19
21
+
7
22
  ### Changed
8
23
  - Make the inline comment editor multiline with wrapped footer rendering, `Enter` for a new line, and `Ctrl+Enter`/`Ctrl+D` to send.
24
+ - Add an `m` toggle for rendered vs raw Markdown in the viewer, and fall back to raw mode before line-based search or selection.
25
+ - Show a sent/queued confirmation toast after returning an inline comment to the agent.
26
+
27
+ ### Thanks
28
+ - Thanks to avg8888 in the Pi Discord for surfacing the comment editor and Markdown review issues fixed in this release.
9
29
 
10
30
  ## [0.1.16] - 2026-04-19
11
31
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  In-terminal file browser and diff viewer widget for Pi. Navigate files, view diffs, select code, and send comments to the agent without leaving the terminal and without interrupting your agent.
4
4
 
5
+ Directory symlinks are shown with a `↗` marker and can be expanded like normal folders.
6
+
5
7
  <video controls autoplay loop muted playsinline>
6
8
  <source src="demo.mp4" type="video/mp4" />
7
9
  </video>
@@ -113,6 +115,7 @@ If missing, `/review` or `/diff` will show a clear install prompt.
113
115
  - `PgUp/PgDn`: page up/down
114
116
  - `g/G`: top/bottom
115
117
  - `d`: toggle diff (tracked files only)
118
+ - `m`: toggle rendered/raw view for Markdown files
116
119
  - `/`: search (type to search)
117
120
  - `n` / `N`: next/prev match
118
121
  - `v`: select mode (line selection)
@@ -126,6 +129,7 @@ If missing, `/review` or `/diff` will show a clear install prompt.
126
129
  ## Notes
127
130
 
128
131
  - Untracked files show as `[UNTRACKED]` and open in normal view.
132
+ - Searching in rendered Markdown switches to raw mode first, and selecting from rendered Markdown first switches you back to raw so line-based matches and comments stay aligned with the source file.
129
133
  - Folder LOCs are shown only when the folder is collapsed (expanded folders would duplicate counts).
130
134
  - Line counts load asynchronously; the header shows activity while counts are computed.
131
135
  - Large non-git folders load progressively and may show `[partial]` while loading in safe mode.
@@ -46,7 +46,7 @@
46
46
  ### Markdown Rendering
47
47
  - [x] Detect `glow` availability
48
48
  - [x] Shell out to `glow` for .md files
49
- - [ ] Toggle between rendered and raw (`m` key?)
49
+ - [x] Toggle between rendered and raw (`m`)
50
50
  - [x] Fallback to syntax-highlighted raw
51
51
 
52
52
  ### Diff View
@@ -74,7 +74,7 @@
74
74
  - [x] Format message with file path, line range, code snippet, comment
75
75
  - [x] Use `pi.sendUserMessage()` with `deliverAs: "followUp"`
76
76
  - [x] Handle case when agent is idle vs streaming
77
- - [ ] Show confirmation notification
77
+ - [x] Show confirmation notification
78
78
 
79
79
  ## Phase 4: tuicr Integration (Optional)
80
80
 
@@ -1,6 +1,7 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
- import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { lstatSync, realpathSync, statSync } from "node:fs";
4
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
5
6
  import { basename, join, relative, resolve, sep } from "node:path";
6
7
 
@@ -104,6 +105,51 @@ function getNodeDepth(node: FileNode, cwd: string): number {
104
105
  return rel.split(sep).length;
105
106
  }
106
107
 
108
+ function safeRealPathSync(path: string): string {
109
+ try {
110
+ return realpathSync(path);
111
+ } catch {
112
+ return resolve(path);
113
+ }
114
+ }
115
+
116
+ function getPathInfoSync(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
117
+ try {
118
+ const linkStat = lstatSync(path);
119
+ const isSymlink = linkStat.isSymbolicLink();
120
+ const targetStat = isSymlink ? statSync(path) : linkStat;
121
+ return {
122
+ isDirectory: targetStat.isDirectory(),
123
+ isSymlink,
124
+ realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
125
+ };
126
+ } catch {
127
+ return { isDirectory: false, isSymlink: false };
128
+ }
129
+ }
130
+
131
+ async function getPathInfo(path: string, isSymlink: boolean): Promise<{ isDirectory: boolean; isSymlink: boolean; realPath?: string }> {
132
+ try {
133
+ const targetStat = await stat(path);
134
+ return {
135
+ isDirectory: targetStat.isDirectory(),
136
+ isSymlink,
137
+ realPath: targetStat.isDirectory() ? await realpath(path).catch(() => resolve(path)) : undefined,
138
+ };
139
+ } catch {
140
+ return { isDirectory: false, isSymlink };
141
+ }
142
+ }
143
+
144
+ function hasAncestorRealPath(node: FileNode | undefined, realPath: string): boolean {
145
+ let current = node;
146
+ while (current) {
147
+ if (current.realPath === realPath) return true;
148
+ current = current.parent;
149
+ }
150
+ return false;
151
+ }
152
+
107
153
  function shouldSafeMode(cwd: string): boolean {
108
154
  const resolved = resolve(cwd);
109
155
  const home = resolve(homedir());
@@ -183,14 +229,19 @@ function formatNodeMeta(node: FileNode, theme: Theme): string {
183
229
  return parts.length > 0 ? ` ${parts.join(" ")}` : "";
184
230
  }
185
231
 
232
+ function withSymlinkMarker(label: string, node: FileNode, theme: Theme): string {
233
+ return node.isSymlink ? `${label}${theme.fg("dim", " ↗")}` : label;
234
+ }
235
+
186
236
  function formatNodeName(node: FileNode, theme: Theme): string {
187
- if (isIgnoredStatus(node.gitStatus)) return theme.fg("dim", node.name);
237
+ if (isIgnoredStatus(node.gitStatus)) return withSymlinkMarker(theme.fg("dim", node.name), node, theme);
188
238
  if (node.isDirectory) {
189
239
  const label = node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
190
- return node.loading ? `${label}${theme.fg("dim", " ⏳")}` : label;
240
+ const rendered = withSymlinkMarker(label, node, theme);
241
+ return node.loading ? `${rendered}${theme.fg("dim", " ⏳")}` : rendered;
191
242
  }
192
- if (node.gitStatus) return theme.fg("warning", node.name);
193
- return node.name;
243
+ if (node.gitStatus) return withSymlinkMarker(theme.fg("warning", node.name), node, theme);
244
+ return withSymlinkMarker(node.name, node, theme);
194
245
  }
195
246
 
196
247
  function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
@@ -227,6 +278,7 @@ export function createFileBrowser(
227
278
  name: ".",
228
279
  path: cwd,
229
280
  isDirectory: true,
281
+ realPath: safeRealPathSync(cwd),
230
282
  children: undefined,
231
283
  expanded: true,
232
284
  hasChangedChildren: false,
@@ -350,6 +402,14 @@ export function createFileBrowser(
350
402
  }
351
403
 
352
404
  function shouldAutoScan(depth: number): boolean {
405
+ // In git repos the main tree comes from git file lists, not from filesystem
406
+ // crawling. If the user expands a symlinked directory inside that tree, only
407
+ // scan one level on demand; nested directories stay lazy until explicitly
408
+ // expanded so links into large trees (iCloud/Drive/$HOME) don't trigger a
409
+ // broad recursive crawl.
410
+ if (repo) {
411
+ return false;
412
+ }
353
413
  if (browser.scanState.mode === "safe") {
354
414
  return depth <= 0;
355
415
  }
@@ -399,31 +459,74 @@ export function createFileBrowser(
399
459
  for (const entry of sorted) {
400
460
  if (ignored.has(entry.name) || entry.name.startsWith(".")) continue;
401
461
  const fullPath = join(node.path, entry.name);
462
+ const childDepth = depth + 1;
463
+
402
464
  if (entry.isDirectory()) {
403
465
  const dirNode: FileNode = {
404
466
  name: entry.name,
405
467
  path: fullPath,
406
468
  isDirectory: true,
469
+ realPath: await realpath(fullPath).catch(() => resolve(fullPath)),
470
+ parent: node,
407
471
  children: undefined,
408
- expanded: depth + 1 < 1,
472
+ expanded: childDepth < 1,
409
473
  hasChangedChildren: false,
410
474
  };
411
475
  dirs.push(dirNode);
412
476
  browser.nodeByPath.set(fullPath, dirNode);
413
- if (shouldAutoScan(depth + 1)) {
414
- enqueueScan(dirNode, depth + 1);
477
+ if (shouldAutoScan(childDepth)) {
478
+ enqueueScan(dirNode, childDepth);
415
479
  }
416
- } else {
417
- const fileNode: FileNode = {
480
+ continue;
481
+ }
482
+
483
+ if (entry.isSymbolicLink()) {
484
+ const pathInfo = await getPathInfo(fullPath, true);
485
+ if (pathInfo.isDirectory) {
486
+ const isCycle = pathInfo.realPath ? hasAncestorRealPath(node, pathInfo.realPath) : false;
487
+ const dirNode: FileNode = {
488
+ name: entry.name,
489
+ path: fullPath,
490
+ isDirectory: true,
491
+ isSymlink: true,
492
+ realPath: pathInfo.realPath,
493
+ parent: node,
494
+ children: isCycle ? [] : undefined,
495
+ expanded: childDepth < 1,
496
+ hasChangedChildren: false,
497
+ };
498
+ dirs.push(dirNode);
499
+ browser.nodeByPath.set(fullPath, dirNode);
500
+ if (!isCycle && shouldAutoScan(childDepth)) {
501
+ enqueueScan(dirNode, childDepth);
502
+ }
503
+ continue;
504
+ }
505
+
506
+ const symlinkFileNode: FileNode = {
418
507
  name: entry.name,
419
508
  path: fullPath,
420
509
  isDirectory: false,
510
+ isSymlink: true,
511
+ parent: node,
421
512
  agentModified: agentModifiedFiles.has(fullPath),
422
513
  };
423
- files.push(fileNode);
424
- browser.nodeByPath.set(fullPath, fileNode);
425
- queueLineCount(fileNode);
514
+ files.push(symlinkFileNode);
515
+ browser.nodeByPath.set(fullPath, symlinkFileNode);
516
+ queueLineCount(symlinkFileNode);
517
+ continue;
426
518
  }
519
+
520
+ const fileNode: FileNode = {
521
+ name: entry.name,
522
+ path: fullPath,
523
+ isDirectory: false,
524
+ parent: node,
525
+ agentModified: agentModifiedFiles.has(fullPath),
526
+ };
527
+ files.push(fileNode);
528
+ browser.nodeByPath.set(fullPath, fileNode);
529
+ queueLineCount(fileNode);
427
530
  }
428
531
 
429
532
  node.children = [...dirs, ...files];
@@ -483,14 +586,13 @@ export function createFileBrowser(
483
586
 
484
587
  function applyGitUpdates(): void {
485
588
  for (const node of browser.nodeByPath.values()) {
486
- if (node.isDirectory) continue;
487
589
  const relPath = normalizeGitPath(relative(cwd, node.path));
488
590
  node.gitStatus = gitStatus.get(relPath);
489
591
  node.diffStats = diffStats.get(relPath);
490
592
  }
491
593
  }
492
594
 
493
- function ensureFileNode(relPath: string): FileNode | null {
595
+ function ensureNode(relPath: string): FileNode | null {
494
596
  if (!browser.root) return null;
495
597
  let normalized = relPath.trim();
496
598
  if (!normalized) return null;
@@ -517,6 +619,8 @@ export function createFileBrowser(
517
619
  name: part,
518
620
  path: dirPath,
519
621
  isDirectory: true,
622
+ realPath: safeRealPathSync(dirPath),
623
+ parent: current,
520
624
  children: [],
521
625
  expanded: depth < 1,
522
626
  hasChangedChildren: false,
@@ -536,10 +640,36 @@ export function createFileBrowser(
536
640
  const existing = browser.nodeByPath.get(filePath);
537
641
  if (existing) return existing;
538
642
 
643
+ const pathInfo = getPathInfoSync(filePath);
644
+ if (pathInfo.isDirectory) {
645
+ const isCycle = pathInfo.realPath ? hasAncestorRealPath(current, pathInfo.realPath) : false;
646
+ const dirNode: FileNode = {
647
+ name: fileName,
648
+ path: filePath,
649
+ isDirectory: true,
650
+ isSymlink: pathInfo.isSymlink,
651
+ realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
652
+ parent: current,
653
+ children: pathInfo.isSymlink && !isCycle ? undefined : [],
654
+ expanded: false,
655
+ hasChangedChildren: false,
656
+ gitStatus: gitStatus.get(normalized),
657
+ diffStats: diffStats.get(normalized),
658
+ };
659
+
660
+ current.children ??= [];
661
+ current.children.push(dirNode);
662
+ sortChildren(current);
663
+ browser.nodeByPath.set(filePath, dirNode);
664
+ return dirNode;
665
+ }
666
+
539
667
  const fileNode: FileNode = {
540
668
  name: fileName,
541
669
  path: filePath,
542
670
  isDirectory: false,
671
+ isSymlink: pathInfo.isSymlink,
672
+ parent: current,
543
673
  gitStatus: gitStatus.get(normalized),
544
674
  agentModified: agentModifiedFiles.has(filePath),
545
675
  diffStats: diffStats.get(normalized),
@@ -555,11 +685,13 @@ export function createFileBrowser(
555
685
  function addUntrackedNodes(): void {
556
686
  for (const [relPath, status] of gitStatus.entries()) {
557
687
  if (!isUntrackedStatus(status)) continue;
558
- const node = ensureFileNode(relPath);
688
+ const node = ensureNode(relPath);
559
689
  if (node) {
560
690
  node.gitStatus = status;
561
691
  node.diffStats = diffStats.get(relPath);
562
- queueLineCount(node, true);
692
+ if (!node.isDirectory) {
693
+ queueLineCount(node, true);
694
+ }
563
695
  }
564
696
  }
565
697
  }
@@ -673,7 +805,7 @@ export function createFileBrowser(
673
805
  function toggleDir(node: FileNode): void {
674
806
  if (node.isDirectory) {
675
807
  node.expanded = !node.expanded;
676
- if (!repo && node.expanded && node.children === undefined) {
808
+ if (node.expanded && node.children === undefined) {
677
809
  enqueueScan(node, getNodeDepth(node, cwd), true);
678
810
  }
679
811
  refreshLists();
@@ -1,4 +1,4 @@
1
- import { statSync } from "node:fs";
1
+ import { lstatSync, realpathSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { MAX_TREE_DEPTH } from "./constants";
@@ -6,11 +6,26 @@ import type { DiffStats, FileNode, FlatNode } from "./types";
6
6
 
7
7
  const collator = new Intl.Collator(undefined, { sensitivity: "base" });
8
8
 
9
- function isDirectoryPath(path: string): boolean {
9
+ function safeRealPathSync(path: string): string {
10
10
  try {
11
- return statSync(path).isDirectory();
11
+ return realpathSync(path);
12
12
  } catch {
13
- return false;
13
+ return path;
14
+ }
15
+ }
16
+
17
+ function getPathInfo(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
18
+ try {
19
+ const linkStat = lstatSync(path);
20
+ const isSymlink = linkStat.isSymbolicLink();
21
+ const targetStat = isSymlink ? statSync(path) : linkStat;
22
+ return {
23
+ isDirectory: targetStat.isDirectory(),
24
+ isSymlink,
25
+ realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
26
+ };
27
+ } catch {
28
+ return { isDirectory: false, isSymlink: false };
14
29
  }
15
30
  }
16
31
 
@@ -105,6 +120,7 @@ export function buildFileTreeFromPaths(
105
120
  name: ".",
106
121
  path: cwd,
107
122
  isDirectory: true,
123
+ realPath: safeRealPathSync(cwd),
108
124
  children: [],
109
125
  expanded: true,
110
126
  hasChangedChildren: false,
@@ -145,6 +161,8 @@ export function buildFileTreeFromPaths(
145
161
  name: part,
146
162
  path: join(cwd, relPath),
147
163
  isDirectory: true,
164
+ realPath: safeRealPathSync(join(cwd, relPath)),
165
+ parent: current,
148
166
  children: [],
149
167
  expanded: depth < 1,
150
168
  hasChangedChildren: false,
@@ -178,14 +196,18 @@ export function buildFileTreeFromPaths(
178
196
  continue;
179
197
  }
180
198
 
181
- const isDirEntry = normalized.endsWith("/") || isDirectoryPath(filePath);
199
+ const pathInfo = getPathInfo(filePath);
200
+ const isDirEntry = normalized.endsWith("/") || pathInfo.isDirectory;
182
201
  if (isDirEntry) {
183
202
  const depth = parts.length;
184
203
  const dirNode: FileNode = {
185
204
  name: fileName,
186
205
  path: filePath,
187
206
  isDirectory: true,
188
- children: [],
207
+ isSymlink: pathInfo.isSymlink,
208
+ realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
209
+ parent: current,
210
+ children: pathInfo.isSymlink ? undefined : [],
189
211
  expanded: depth < 1,
190
212
  hasChangedChildren: false,
191
213
  gitStatus: fileGitStatus,
@@ -202,6 +224,8 @@ export function buildFileTreeFromPaths(
202
224
  name: fileName,
203
225
  path: filePath,
204
226
  isDirectory: false,
227
+ isSymlink: pathInfo.isSymlink,
228
+ parent: current,
205
229
  gitStatus: fileGitStatus,
206
230
  agentModified: agentModified.has(filePath),
207
231
  diffStats: fileDiffStats,
@@ -3,7 +3,7 @@ import { execSync } from "node:child_process";
3
3
  import { readFileSync, statSync } from "node:fs";
4
4
 
5
5
  import { isGitRepo } from "./git";
6
- import { hasCommand, stripLeadingEmptyLines } from "./utils";
6
+ import { hasCommand, isMarkdownPath, stripLeadingEmptyLines } from "./utils";
7
7
 
8
8
  const DIFF_CONTENT_PREFIXES = new Set(["+", "-", " "]);
9
9
 
@@ -152,14 +152,20 @@ function wrapDeltaLines(lines: string[], width: number): string[] {
152
152
  return wrapped;
153
153
  }
154
154
 
155
+ export interface LoadedFileContent {
156
+ lines: string[];
157
+ renderedMarkdown: boolean;
158
+ }
159
+
155
160
  export function loadFileContent(
156
161
  filePath: string,
157
162
  cwd: string,
158
163
  diffMode: boolean,
159
164
  hasChanges: boolean,
160
- width?: number
161
- ): string[] {
162
- const isMarkdown = filePath.endsWith(".md");
165
+ width?: number,
166
+ renderMarkdown = true
167
+ ): LoadedFileContent {
168
+ const isMarkdown = isMarkdownPath(filePath);
163
169
  const termWidth = width || process.stdout.columns || 80;
164
170
 
165
171
  try {
@@ -195,7 +201,7 @@ export function loadFileContent(
195
201
  }
196
202
 
197
203
  if (!diffOutput.trim()) {
198
- return ["No diff available - file may be untracked or unchanged"];
204
+ return { lines: ["No diff available - file may be untracked or unchanged"], renderedMarkdown: false };
199
205
  }
200
206
 
201
207
  if (hasCommand("delta")) {
@@ -211,23 +217,23 @@ export function loadFileContent(
211
217
  stdio: ["pipe", "pipe", "pipe"],
212
218
  }
213
219
  );
214
- return wrapDeltaLines(stripLeadingEmptyLines(deltaOutput.split("\n")), termWidth);
220
+ return { lines: wrapDeltaLines(stripLeadingEmptyLines(deltaOutput.split("\n")), termWidth), renderedMarkdown: false };
215
221
  } catch {
216
222
  // Fall back to raw diff
217
223
  }
218
224
  }
219
225
 
220
- return wrapDiffLines(stripLeadingEmptyLines(diffOutput.split("\n")), termWidth);
226
+ return { lines: wrapDiffLines(stripLeadingEmptyLines(diffOutput.split("\n")), termWidth), renderedMarkdown: false };
221
227
  } catch (e: any) {
222
- return [`Diff error: ${e.message}`];
228
+ return { lines: [`Diff error: ${e.message}`], renderedMarkdown: false };
223
229
  }
224
230
  }
225
231
 
226
- if (isMarkdown && hasCommand("glow")) {
232
+ if (isMarkdown && renderMarkdown && hasCommand("glow")) {
227
233
  try {
228
234
  const output = execSync(`glow -s dark -w ${termWidth} "${filePath}"`, { encoding: "utf-8", timeout: 10000 });
229
235
  if (output.trim()) {
230
- return stripLeadingEmptyLines(output.split("\n"));
236
+ return { lines: stripLeadingEmptyLines(output.split("\n")), renderedMarkdown: true };
231
237
  }
232
238
  } catch {
233
239
  // Fall through to bat
@@ -236,16 +242,22 @@ export function loadFileContent(
236
242
 
237
243
  if (hasCommand("bat")) {
238
244
  try {
239
- return execSync(
240
- `bat --style=numbers --color=always --paging=never --wrap=auto --terminal-width=${termWidth} "${filePath}"`,
241
- { encoding: "utf-8", timeout: 10000 }
242
- ).split("\n");
245
+ return {
246
+ lines: execSync(
247
+ `bat --style=numbers --color=always --paging=never --wrap=auto --terminal-width=${termWidth} "${filePath}"`,
248
+ { encoding: "utf-8", timeout: 10000 }
249
+ ).split("\n"),
250
+ renderedMarkdown: false,
251
+ };
243
252
  } catch {
244
253
  try {
245
- return execSync(
246
- `bat --style=numbers --color=always --paging=never --terminal-width=${termWidth} "${filePath}"`,
247
- { encoding: "utf-8", timeout: 10000 }
248
- ).split("\n");
254
+ return {
255
+ lines: execSync(
256
+ `bat --style=numbers --color=always --paging=never --terminal-width=${termWidth} "${filePath}"`,
257
+ { encoding: "utf-8", timeout: 10000 }
258
+ ).split("\n"),
259
+ renderedMarkdown: false,
260
+ };
249
261
  } catch {
250
262
  // Fall through to raw file read
251
263
  }
@@ -253,8 +265,11 @@ export function loadFileContent(
253
265
  }
254
266
 
255
267
  const raw = readFileSync(filePath, "utf-8");
256
- return raw.split("\n").map((line, i) => `${String(i + 1).padStart(4)} │ ${line}`);
268
+ return {
269
+ lines: raw.split("\n").map((line, i) => `${String(i + 1).padStart(4)} │ ${line}`),
270
+ renderedMarkdown: false,
271
+ };
257
272
  } catch (e: any) {
258
- return [`Error loading file: ${e.message}`];
273
+ return { lines: [`Error loading file: ${e.message}`], renderedMarkdown: false };
259
274
  }
260
275
  }
@@ -41,10 +41,14 @@ export default function editorExtension(pi: ExtensionAPI): void {
41
41
  };
42
42
 
43
43
  const requestComment = (payload: { relPath: string; lineRange: string; ext: string; selectedText: string }, comment: string) => {
44
- pi.sendUserMessage(formatCommentMessage(payload, comment), {
45
- deliverAs: "followUp",
46
- streamingBehavior: "followUp" as any,
47
- } as any);
44
+ const message = formatCommentMessage(payload, comment);
45
+ if (ctx.isIdle()) {
46
+ pi.sendUserMessage(message);
47
+ ctx.ui.notify(`Comment sent to agent for ${payload.relPath} (${payload.lineRange})`, "success");
48
+ } else {
49
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
50
+ ctx.ui.notify(`Comment queued for agent for ${payload.relPath} (${payload.lineRange})`, "info");
51
+ }
48
52
  };
49
53
 
50
54
  const requestRender = () => tui.requestRender();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -7,6 +7,9 @@ export interface FileNode {
7
7
  name: string;
8
8
  path: string;
9
9
  isDirectory: boolean;
10
+ isSymlink?: boolean;
11
+ realPath?: string;
12
+ parent?: FileNode;
10
13
  children?: FileNode[];
11
14
  expanded?: boolean;
12
15
  gitStatus?: string;
@@ -17,6 +17,10 @@ export function isIgnoredStatus(status?: string): boolean {
17
17
  return status === "!" || status === "!!";
18
18
  }
19
19
 
20
+ export function isMarkdownPath(path: string): boolean {
21
+ return path.toLowerCase().endsWith(".md");
22
+ }
23
+
20
24
  export function stripLeadingEmptyLines(lines: string[]): string[] {
21
25
  let startIdx = 0;
22
26
  while (startIdx < lines.length && !lines[startIdx].trim()) {
@@ -11,7 +11,7 @@ import {
11
11
  } from "./constants";
12
12
  import { loadFileContent } from "./file-viewer";
13
13
  import type { FileNode } from "./types";
14
- import { isUntrackedStatus } from "./utils";
14
+ import { isMarkdownPath, isUntrackedStatus } from "./utils";
15
15
  import { createTextInputBuffer } from "./input-utils";
16
16
 
17
17
  const COMMENT_EDITOR_MAX_VISIBLE_LINES = 4;
@@ -36,6 +36,7 @@ interface ViewerState {
36
36
  rawContent: string;
37
37
  scroll: number;
38
38
  diffMode: boolean;
39
+ renderMarkdown: boolean;
39
40
  mode: ViewerMode;
40
41
  selectStart: number;
41
42
  selectEnd: number;
@@ -72,6 +73,7 @@ export function createViewer(
72
73
  rawContent: "",
73
74
  scroll: 0,
74
75
  diffMode: false,
76
+ renderMarkdown: true,
75
77
  mode: "normal",
76
78
  selectStart: 0,
77
79
  selectEnd: 0,
@@ -84,6 +86,31 @@ export function createViewer(
84
86
  height: DEFAULT_VIEWER_HEIGHT,
85
87
  };
86
88
 
89
+ function isMarkdownFile(): boolean {
90
+ return !!state.file && isMarkdownPath(state.file.path);
91
+ }
92
+
93
+ function isRenderedMarkdownMode(): boolean {
94
+ return isMarkdownFile() && !state.diffMode && state.renderMarkdown;
95
+ }
96
+
97
+ function switchMarkdownToRaw(): boolean {
98
+ if (!isRenderedMarkdownMode()) return false;
99
+ state.renderMarkdown = false;
100
+ const width = state.lastRenderWidth || process.stdout.columns || 80;
101
+ reloadContent(width);
102
+ return true;
103
+ }
104
+
105
+ function toggleMarkdownMode(): void {
106
+ if (!isMarkdownFile() || state.diffMode) return;
107
+ state.renderMarkdown = !state.renderMarkdown;
108
+ state.lastRenderWidth = 0;
109
+ resetSearch();
110
+ setMode("normal");
111
+ clampScroll();
112
+ }
113
+
87
114
  function resetSearch(): void {
88
115
  state.searchQuery = "";
89
116
  state.searchMatches = [];
@@ -150,7 +177,9 @@ export function createViewer(
150
177
  if (!state.file) return;
151
178
  refreshRawContent();
152
179
  const hasChanges = !!state.file.gitStatus;
153
- state.content = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width);
180
+ const result = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width, state.renderMarkdown);
181
+ state.content = result.lines;
182
+ state.renderMarkdown = result.renderedMarkdown;
154
183
  state.lastRenderWidth = width;
155
184
  clampScroll();
156
185
  if (state.searchQuery) {
@@ -239,6 +268,8 @@ export function createViewer(
239
268
  header += theme.fg("dim", " [UNTRACKED]");
240
269
  } else if (state.diffMode) {
241
270
  header += theme.fg("warning", " [DIFF]");
271
+ } else if (isMarkdownFile()) {
272
+ header += theme.fg("accent", state.renderMarkdown ? " [RENDERED]" : " [RAW]");
242
273
  }
243
274
  if (state.mode === "select" || state.mode === "comment") {
244
275
  header += theme.fg("accent", ` [SELECT ${state.selectStart + 1}-${state.selectEnd + 1}]`);
@@ -320,9 +351,10 @@ export function createViewer(
320
351
  help = theme.fg("dim", "Type to search Enter: confirm Esc: cancel");
321
352
  } else {
322
353
  const isUntracked = state.file && isUntrackedStatus(state.file.gitStatus);
354
+ const markdownHelp = isMarkdownFile() && !state.diffMode ? "m: raw/render " : "";
323
355
  help = theme.fg(
324
356
  "dim",
325
- `j/k: scroll /: search n/N: next/prev match []: files ${state.file?.gitStatus && !isUntracked ? "d: diff " : ""}q: back ${pct}%`
357
+ `j/k: scroll /: search n/N: next/prev match ${markdownHelp}[]: files ${state.file?.gitStatus && !isUntracked ? "d: diff " : ""}q: back ${pct}%`
326
358
  );
327
359
  }
328
360
  lines.push(truncateToWidth(help, width));
@@ -343,6 +375,7 @@ export function createViewer(
343
375
  state.file = file;
344
376
  state.scroll = 0;
345
377
  state.diffMode = !!file.gitStatus && !isUntrackedStatus(file.gitStatus);
378
+ state.renderMarkdown = isMarkdownPath(file.path);
346
379
  setMode("normal");
347
380
  state.content = [];
348
381
  state.lastRenderWidth = 0;
@@ -358,6 +391,7 @@ export function createViewer(
358
391
  state.file = null;
359
392
  state.content = [];
360
393
  state.rawContent = "";
394
+ state.renderMarkdown = true;
361
395
  state.lastLoadedMtimeMs = null;
362
396
  setMode("normal");
363
397
  },
@@ -452,6 +486,7 @@ export function createViewer(
452
486
  return { type: "none" };
453
487
  }
454
488
  if (matchesKey(data, "/") && state.mode !== "select") {
489
+ switchMarkdownToRaw();
455
490
  setMode("search");
456
491
  return { type: "none" };
457
492
  }
@@ -511,7 +546,14 @@ export function createViewer(
511
546
  state.scroll = 0;
512
547
  return { type: "none" };
513
548
  }
549
+ if (matchesKey(data, "m") && state.mode !== "select" && state.mode !== "comment") {
550
+ toggleMarkdownMode();
551
+ return { type: "none" };
552
+ }
514
553
  if (matchesKey(data, "v") && state.mode !== "select") {
554
+ if (switchMarkdownToRaw()) {
555
+ return { type: "none" };
556
+ }
515
557
  state.mode = "select";
516
558
  state.selectStart = state.scroll;
517
559
  state.selectEnd = state.scroll;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensions",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "keywords": [