@guneriu/pi-files 0.2.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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/extensions/pi-files.ts +754 -0
- package/package.json +40 -0
- package/src/core.ts +373 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Uğur Güneri (guneriu)
|
|
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,109 @@
|
|
|
1
|
+
# @guneriu/pi-files
|
|
2
|
+
|
|
3
|
+
Shows the files the agent edited this session in a compact widget above the
|
|
4
|
+
input bar, and opens an interactive, gitignore-aware project tree on demand.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- **Compact widget** above the editor: `+` new / `M` modified, capped rows with
|
|
9
|
+
`… +N more` overflow so a big change set never swamps the terminal.
|
|
10
|
+
- **Idle hint** when nothing is edited yet (toggle off in settings).
|
|
11
|
+
- **`/pi-files`** — full-screen tree overlay with keyboard navigation;
|
|
12
|
+
auto-expands to your edited files on open.
|
|
13
|
+
- **`Enter`** opens the selected file in your OS default app.
|
|
14
|
+
- **`Space`** opens a scrollable, syntax-highlighted in-TUI peek (Quick Look style).
|
|
15
|
+
- **Type anything** to filter all project files instantly — no prefix needed.
|
|
16
|
+
- **`/pi-files-settings`** — interactive settings menu to toggle the widget,
|
|
17
|
+
collapse it for the session, adjust the row cap, set the peek size limit, and more.
|
|
18
|
+
- Respects `.gitignore` via `git ls-files`; falls back to a filesystem walk
|
|
19
|
+
outside git repos.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install npm:@guneriu/pi-files
|
|
25
|
+
# or the whole mono:
|
|
26
|
+
pi install git:github.com/guneriu/pi-extension-mono
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Commands & shortcuts
|
|
30
|
+
|
|
31
|
+
### Slash commands
|
|
32
|
+
|
|
33
|
+
| Command | Description |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `/pi-files` | Open the interactive project tree overlay |
|
|
36
|
+
| `/pi-files-settings` | Open the settings menu |
|
|
37
|
+
|
|
38
|
+
### Inside the project tree (`/pi-files`)
|
|
39
|
+
|
|
40
|
+
| Key | Action |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `↑` / `↓` | Move selection |
|
|
43
|
+
| `Enter` | **Open file in OS default app** — expands a directory |
|
|
44
|
+
| `Space` | **Peek** — in-TUI syntax-highlighted preview (toggle: same key closes) |
|
|
45
|
+
| `→` | Expand a directory |
|
|
46
|
+
| `←` | Collapse a directory — or jump to its parent if already collapsed |
|
|
47
|
+
| `Esc` | Close the overlay (clears filter first if one is active) |
|
|
48
|
+
| *any printable char* | **Filter** — type to search all project files instantly |
|
|
49
|
+
| `Backspace` | Remove last filter character; empty filter returns to tree view |
|
|
50
|
+
|
|
51
|
+
### Type-to-filter search
|
|
52
|
+
|
|
53
|
+
Start typing anywhere in the tree — no prefix key needed. The view switches to
|
|
54
|
+
a flat filtered list of **all** project files (including inside collapsed
|
|
55
|
+
directories) whose path contains your query (case-insensitive).
|
|
56
|
+
|
|
57
|
+
- The header shows `/ query▌ N results esc clear` while filtering.
|
|
58
|
+
- Matched portion of each path is highlighted.
|
|
59
|
+
- `↑/↓`, `Enter`, and `Space` (peek) all work on filter results.
|
|
60
|
+
- `Esc` clears the filter and returns to the tree. `Esc` again closes the overlay.
|
|
61
|
+
- `Backspace` removes one character at a time; empties = back to tree.
|
|
62
|
+
- `→` / `←` expand/collapse are inactive while filtering.
|
|
63
|
+
|
|
64
|
+
### Opening files
|
|
65
|
+
|
|
66
|
+
- **`Enter`** launches the file with your OS default application (`open` on
|
|
67
|
+
macOS, `xdg-open` on Linux, `start` on Windows). On a headless/SSH box with
|
|
68
|
+
no GUI handler you'll get a notification instead.
|
|
69
|
+
- **`Space`** opens a scrollable, syntax-highlighted preview *inside* pi — no
|
|
70
|
+
need to leave the terminal. Press `Space` again (or `Esc`/`q`) to close.
|
|
71
|
+
- Scroll with `↑/↓`, page with `PgUp/PgDn`, jump to top/bottom with `g`/`G`.
|
|
72
|
+
- Markdown files get structural highlighting (headings, code blocks, lists,
|
|
73
|
+
links) via a built-in pure ANSI renderer — no extra tools required.
|
|
74
|
+
- Files larger than **Max peek size** (default 512 KB) are refused with a
|
|
75
|
+
hint to open them externally instead.
|
|
76
|
+
- Binary files are detected and refused (open them externally).
|
|
77
|
+
|
|
78
|
+
### Inside the settings menu (`/pi-files-settings`)
|
|
79
|
+
|
|
80
|
+
| Key | Action |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `↑` / `↓` | Move between items |
|
|
83
|
+
| `Space` or `Enter` | Toggle a boolean setting |
|
|
84
|
+
| `←` / `→` | Decrease / increase a number setting |
|
|
85
|
+
| `Esc` or `q` | Close the menu |
|
|
86
|
+
|
|
87
|
+
## Settings
|
|
88
|
+
|
|
89
|
+
Settings can be changed interactively via `/pi-files-settings` or by editing
|
|
90
|
+
`<agent-dir>/extensions/pi-files/settings.json` directly.
|
|
91
|
+
|
|
92
|
+
| Key | Default | Meaning | Persists? |
|
|
93
|
+
|---|---|---|---|
|
|
94
|
+
| `enabled` | `true` | Master on/off for the widget | ✅ yes |
|
|
95
|
+
| `maxWidgetRows` | `6` | Max file rows shown in the compact widget | ✅ yes |
|
|
96
|
+
| `showIdleHint` | `true` | Show a one-line hint when no files have been edited yet | ✅ yes |
|
|
97
|
+
| `maxPeekBytes` | `524288` | Largest file (bytes) the in-TUI peek will render | ✅ yes |
|
|
98
|
+
|
|
99
|
+
In the settings menu, **Max widget rows** is adjusted with `←/→` (range 1–20)
|
|
100
|
+
and **Max peek size (KB)** in 64 KB steps (range 64 KB–8 MB).
|
|
101
|
+
|
|
102
|
+
### Session collapse
|
|
103
|
+
|
|
104
|
+
The settings menu also offers **Collapse this session** — a temporary hide that
|
|
105
|
+
lasts only until you close pi. Unlike `enabled`, it is never written to disk, so
|
|
106
|
+
the widget always comes back on the next session start without any manual action.
|
|
107
|
+
|
|
108
|
+
Use it when you want to free up screen space mid-session without permanently
|
|
109
|
+
disabling the feature.
|
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-files (@guneriu/pi-files)
|
|
3
|
+
*
|
|
4
|
+
* Compact widget above the input bar listing files the agent edited this
|
|
5
|
+
* session, plus an on-demand interactive project tree (/pi-files, /files).
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { getAgentDir, isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { matchesKey, Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename, relative, resolve } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
ancestorsOf,
|
|
15
|
+
applyInlineMarkdown,
|
|
16
|
+
buildOpenCommand,
|
|
17
|
+
buildTree,
|
|
18
|
+
buildWidgetLines,
|
|
19
|
+
classifyEdit,
|
|
20
|
+
detectLanguageFromPath,
|
|
21
|
+
extractEditsFromBranch,
|
|
22
|
+
filterFiles,
|
|
23
|
+
flattenVisible,
|
|
24
|
+
highlightMarkdown,
|
|
25
|
+
isPreviewable,
|
|
26
|
+
listProjectFiles,
|
|
27
|
+
looksBinary,
|
|
28
|
+
statusGlyph,
|
|
29
|
+
type EditStatus,
|
|
30
|
+
type EditedFile,
|
|
31
|
+
} from "../src/core";
|
|
32
|
+
|
|
33
|
+
// ─── Settings ───────────────────────────────────────────────────────────────
|
|
34
|
+
interface Settings {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
maxWidgetRows: number;
|
|
37
|
+
showIdleHint: boolean;
|
|
38
|
+
maxPeekBytes: number;
|
|
39
|
+
}
|
|
40
|
+
const DEFAULTS: Settings = {
|
|
41
|
+
enabled: true,
|
|
42
|
+
maxWidgetRows: 6,
|
|
43
|
+
showIdleHint: true,
|
|
44
|
+
maxPeekBytes: 524288, // 512 KB
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function getSettingsFile(): string {
|
|
48
|
+
const dir = `${getAgentDir()}/extensions/pi-files`;
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
return `${dir}/settings.json`;
|
|
51
|
+
}
|
|
52
|
+
function loadSettings(): Settings {
|
|
53
|
+
try {
|
|
54
|
+
return { ...DEFAULTS, ...JSON.parse(readFileSync(getSettingsFile(), "utf-8")) };
|
|
55
|
+
} catch {
|
|
56
|
+
return { ...DEFAULTS };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function saveSettings(s: Settings): void {
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(getSettingsFile(), JSON.stringify(s, null, 2), "utf-8");
|
|
63
|
+
} catch {
|
|
64
|
+
// non-fatal
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const WIDGET_ID = "pi-files";
|
|
69
|
+
|
|
70
|
+
export default function (pi: ExtensionAPI) {
|
|
71
|
+
// absPath -> status, insertion-ordered (oldest first; rendered newest-first).
|
|
72
|
+
const edited = new Map<string, EditStatus>();
|
|
73
|
+
// toolCallId -> pre-execution context, committed on success (S1).
|
|
74
|
+
const pending = new Map<
|
|
75
|
+
string,
|
|
76
|
+
{ abs: string; kind: "write" | "edit"; existsBefore: boolean }
|
|
77
|
+
>();
|
|
78
|
+
// Loaded once per session, not on every tool call (S2).
|
|
79
|
+
let settings: Settings = loadSettings();
|
|
80
|
+
// Session-only collapse flag — never written to disk, resets on session_start.
|
|
81
|
+
let collapsed = false;
|
|
82
|
+
|
|
83
|
+
function updateSettings(fn: (s: Settings) => void, ctx: any): void {
|
|
84
|
+
fn(settings);
|
|
85
|
+
saveSettings(settings);
|
|
86
|
+
renderWidget(ctx);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toEditedFiles(cwd: string): EditedFile[] {
|
|
90
|
+
// Newest-first so the compact widget shows the most recent edits (C1).
|
|
91
|
+
return [...edited.entries()].reverse().map(([abs, status]) => ({
|
|
92
|
+
relPath: relative(cwd, abs) || abs,
|
|
93
|
+
status,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderWidget(ctx: any) {
|
|
98
|
+
if (ctx.mode !== "tui") return;
|
|
99
|
+
if (!settings.enabled || collapsed) {
|
|
100
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const cwd = ctx.sessionManager.getCwd();
|
|
104
|
+
const files = toEditedFiles(cwd);
|
|
105
|
+
|
|
106
|
+
if (files.length === 0) {
|
|
107
|
+
if (settings.showIdleHint) {
|
|
108
|
+
ctx.ui.setWidget(WIDGET_ID, (_tui: any, theme: any) => ({
|
|
109
|
+
render: () => [theme.fg("dim", "📁 /files — file tree")],
|
|
110
|
+
invalidate: () => {},
|
|
111
|
+
}));
|
|
112
|
+
} else {
|
|
113
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const w = buildWidgetLines(files, settings.maxWidgetRows);
|
|
119
|
+
const shown = files.slice(0, settings.maxWidgetRows);
|
|
120
|
+
ctx.ui.setWidget(WIDGET_ID, (_tui: any, theme: any) => ({
|
|
121
|
+
render: () => {
|
|
122
|
+
const lines: string[] = [];
|
|
123
|
+
if (w.header) {
|
|
124
|
+
lines.push(theme.fg("accent", w.header) + theme.fg("dim", " · /files"));
|
|
125
|
+
}
|
|
126
|
+
for (const f of shown) {
|
|
127
|
+
const color = f.status === "new" ? "success" : "warning";
|
|
128
|
+
lines.push(theme.fg(color, statusGlyph(f.status) + " ") + theme.fg("muted", f.relPath));
|
|
129
|
+
}
|
|
130
|
+
if (w.overflow) lines.push(theme.fg("dim", w.overflow));
|
|
131
|
+
return lines;
|
|
132
|
+
},
|
|
133
|
+
invalidate: () => {},
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function rebuildFromHistory(ctx: any) {
|
|
138
|
+
edited.clear();
|
|
139
|
+
const branch = ctx.sessionManager.getBranch();
|
|
140
|
+
for (const e of extractEditsFromBranch(branch)) {
|
|
141
|
+
const abs = resolve(ctx.sessionManager.getCwd(), e.path);
|
|
142
|
+
// Reconstruction cannot know the pre-write filesystem state, so we treat
|
|
143
|
+
// history-derived edits as "modified" (existsBefore = true). Live
|
|
144
|
+
// tool_call tracking provides accurate new/modified during the session.
|
|
145
|
+
const status = classifyEdit(e.kind, true, edited.get(abs));
|
|
146
|
+
edited.set(abs, status);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
151
|
+
if (ctx.mode !== "tui") return;
|
|
152
|
+
settings = loadSettings();
|
|
153
|
+
collapsed = false; // session collapse always resets on fresh session
|
|
154
|
+
rebuildFromHistory(ctx);
|
|
155
|
+
renderWidget(ctx);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
159
|
+
edited.clear();
|
|
160
|
+
pending.clear();
|
|
161
|
+
if (ctx?.mode === "tui") ctx.ui.setWidget(WIDGET_ID, undefined); // N1
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Capture pre-execution state on tool_call (fires before the tool runs), so
|
|
165
|
+
// existsSync reflects the pre-write filesystem (new vs modified).
|
|
166
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
167
|
+
if (ctx.mode !== "tui") return;
|
|
168
|
+
let kind: "write" | "edit" | undefined;
|
|
169
|
+
if (isToolCallEventType("write", event)) kind = "write";
|
|
170
|
+
else if (isToolCallEventType("edit", event)) kind = "edit";
|
|
171
|
+
if (!kind) return;
|
|
172
|
+
const rawPath = (event.input as { path?: string }).path;
|
|
173
|
+
if (!rawPath) return;
|
|
174
|
+
const abs = resolve(ctx.sessionManager.getCwd(), rawPath);
|
|
175
|
+
pending.set(event.toolCallId, { abs, kind, existsBefore: existsSync(abs) });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Commit only on success (S1): a failed write/edit must not appear as edited.
|
|
179
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
180
|
+
if (ctx.mode !== "tui") return;
|
|
181
|
+
const p = pending.get(event.toolCallId);
|
|
182
|
+
if (!p) return;
|
|
183
|
+
pending.delete(event.toolCallId);
|
|
184
|
+
if (event.isError) return;
|
|
185
|
+
const prev = edited.get(p.abs); // read BEFORE delete so sticky-new survives
|
|
186
|
+
edited.delete(p.abs); // re-insert so the newest edit sorts last
|
|
187
|
+
edited.set(p.abs, classifyEdit(p.kind, p.existsBefore, prev));
|
|
188
|
+
renderWidget(ctx);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
registerTreeCommands(pi, edited, () => settings);
|
|
192
|
+
registerSettingsCommand(
|
|
193
|
+
pi,
|
|
194
|
+
() => settings,
|
|
195
|
+
(fn, ctx) => updateSettings(fn, ctx),
|
|
196
|
+
() => collapsed,
|
|
197
|
+
(v, ctx) => { collapsed = v; renderWidget(ctx); },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Settings menu ──────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function registerSettingsCommand(
|
|
204
|
+
pi: ExtensionAPI,
|
|
205
|
+
getSettings: () => Settings,
|
|
206
|
+
updateSettings: (fn: (s: Settings) => void, ctx: any) => void,
|
|
207
|
+
getCollapsed: () => boolean,
|
|
208
|
+
setCollapsed: (v: boolean, ctx: any) => void,
|
|
209
|
+
) {
|
|
210
|
+
pi.registerCommand("pi-files-settings", {
|
|
211
|
+
description: "Open pi-files settings menu",
|
|
212
|
+
handler: async (_args, ctx) => {
|
|
213
|
+
if (ctx.mode !== "tui") {
|
|
214
|
+
ctx.ui.notify("/pi-files-settings requires TUI mode", "error");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let selected = 0;
|
|
219
|
+
|
|
220
|
+
type ToggleItem = {
|
|
221
|
+
kind: "toggle";
|
|
222
|
+
label: string;
|
|
223
|
+
hint?: string;
|
|
224
|
+
get: () => boolean;
|
|
225
|
+
toggle: () => void;
|
|
226
|
+
};
|
|
227
|
+
type NumberItem = {
|
|
228
|
+
kind: "number";
|
|
229
|
+
label: string;
|
|
230
|
+
get: () => number;
|
|
231
|
+
inc: () => void;
|
|
232
|
+
dec: () => void;
|
|
233
|
+
min: number;
|
|
234
|
+
max: number;
|
|
235
|
+
};
|
|
236
|
+
type MenuItem = ToggleItem | NumberItem;
|
|
237
|
+
|
|
238
|
+
const items: MenuItem[] = [
|
|
239
|
+
{
|
|
240
|
+
kind: "toggle",
|
|
241
|
+
label: "Widget enabled",
|
|
242
|
+
hint: "persists across sessions",
|
|
243
|
+
get: () => getSettings().enabled,
|
|
244
|
+
toggle: () => updateSettings((s) => { s.enabled = !s.enabled; }, ctx),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
kind: "toggle",
|
|
248
|
+
label: "Collapse this session",
|
|
249
|
+
hint: "resets when you restart pi",
|
|
250
|
+
get: () => getCollapsed(),
|
|
251
|
+
toggle: () => setCollapsed(!getCollapsed(), ctx),
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
kind: "number",
|
|
255
|
+
label: "Max widget rows",
|
|
256
|
+
get: () => getSettings().maxWidgetRows,
|
|
257
|
+
inc: () => updateSettings((s) => { s.maxWidgetRows = Math.min(20, s.maxWidgetRows + 1); }, ctx),
|
|
258
|
+
dec: () => updateSettings((s) => { s.maxWidgetRows = Math.max(1, s.maxWidgetRows - 1); }, ctx),
|
|
259
|
+
min: 1,
|
|
260
|
+
max: 20,
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
kind: "toggle",
|
|
264
|
+
label: "Show idle hint",
|
|
265
|
+
hint: "persists across sessions",
|
|
266
|
+
get: () => getSettings().showIdleHint,
|
|
267
|
+
toggle: () => updateSettings((s) => { s.showIdleHint = !s.showIdleHint; }, ctx),
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
kind: "number",
|
|
271
|
+
label: "Max peek size (KB)",
|
|
272
|
+
get: () => Math.round(getSettings().maxPeekBytes / 1024),
|
|
273
|
+
inc: () => updateSettings((s) => {
|
|
274
|
+
s.maxPeekBytes = Math.min(8192 * 1024, s.maxPeekBytes + 64 * 1024);
|
|
275
|
+
}, ctx),
|
|
276
|
+
dec: () => updateSettings((s) => {
|
|
277
|
+
s.maxPeekBytes = Math.max(64 * 1024, s.maxPeekBytes - 64 * 1024);
|
|
278
|
+
}, ctx),
|
|
279
|
+
min: 64,
|
|
280
|
+
max: 8192,
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
await ctx.ui.custom(
|
|
285
|
+
(tui: any, theme: any, _kb: any, done: (v: null) => void) => {
|
|
286
|
+
const B = (s: string) => theme.fg("border", s);
|
|
287
|
+
|
|
288
|
+
const buildRow = (item: MenuItem, i: number, innerW: number): string => {
|
|
289
|
+
const isSelected = i === selected;
|
|
290
|
+
const gutter = isSelected ? theme.fg("accent", "›") : " ";
|
|
291
|
+
|
|
292
|
+
let rowContent: string;
|
|
293
|
+
if (item.kind === "toggle") {
|
|
294
|
+
const on = item.get();
|
|
295
|
+
// Dim the "Collapse" option when widget is fully disabled — it's a no-op.
|
|
296
|
+
const dimmed = item.label === "Collapse this session" && !getSettings().enabled;
|
|
297
|
+
const box = dimmed
|
|
298
|
+
? theme.fg("dim", "[ ]")
|
|
299
|
+
: on
|
|
300
|
+
? theme.fg("success", "[●]")
|
|
301
|
+
: theme.fg("dim", "[ ]");
|
|
302
|
+
const labelColor = dimmed ? "dim" : isSelected ? "accent" : "muted";
|
|
303
|
+
const label = theme.fg(labelColor, item.label);
|
|
304
|
+
const hintStr = item.hint ? theme.fg("dim", ` ${item.hint}`) : "";
|
|
305
|
+
rowContent = ` ${box} ${label}${hintStr}`;
|
|
306
|
+
} else {
|
|
307
|
+
const val = item.get();
|
|
308
|
+
const atMin = val <= item.min;
|
|
309
|
+
const atMax = val >= item.max;
|
|
310
|
+
const labelColor = isSelected ? "accent" : "muted";
|
|
311
|
+
const left = atMin ? theme.fg("dim", "‹") : theme.fg("accent", "‹");
|
|
312
|
+
const right = atMax ? theme.fg("dim", "›") : theme.fg("accent", "›");
|
|
313
|
+
rowContent = ` ${theme.fg(labelColor, item.label)}: ${left} ${theme.fg("success", String(val))} ${right}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const full = gutter + rowContent;
|
|
317
|
+
const cell = truncateToWidth(full, innerW);
|
|
318
|
+
return B("│") + cell + " ".repeat(Math.max(0, innerW - visibleWidth(cell))) + B("│");
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const build = (width: number): string[] => {
|
|
322
|
+
const innerW = width - 2;
|
|
323
|
+
const H = "─";
|
|
324
|
+
const lines: string[] = [];
|
|
325
|
+
|
|
326
|
+
lines.push(B("╭" + H.repeat(innerW) + "╮"));
|
|
327
|
+
const title = " ⚙ Agent Files Settings";
|
|
328
|
+
const hint = "↑↓ move spc/↵ toggle ←→ adjust esc close ";
|
|
329
|
+
const gap = Math.max(1, innerW - visibleWidth(title) - visibleWidth(hint));
|
|
330
|
+
lines.push(
|
|
331
|
+
B("│") + theme.fg("accent", title) + " ".repeat(gap) + theme.fg("dim", hint) + B("│"),
|
|
332
|
+
);
|
|
333
|
+
lines.push(B("├" + H.repeat(innerW) + "┤"));
|
|
334
|
+
|
|
335
|
+
for (let i = 0; i < items.length; i++) {
|
|
336
|
+
lines.push(buildRow(items[i], i, innerW));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
lines.push(B("╰" + H.repeat(innerW) + "╯"));
|
|
340
|
+
return lines;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
render: (w: number) => build(w),
|
|
345
|
+
invalidate: () => {},
|
|
346
|
+
handleInput: (data: string) => {
|
|
347
|
+
if (matchesKey(data, Key.escape) || data === "q") return done(null);
|
|
348
|
+
if (matchesKey(data, Key.up)) { selected = Math.max(0, selected - 1); tui.requestRender(); return; }
|
|
349
|
+
if (matchesKey(data, Key.down)) { selected = Math.min(items.length - 1, selected + 1); tui.requestRender(); return; }
|
|
350
|
+
|
|
351
|
+
const item = items[selected];
|
|
352
|
+
if (!item) return;
|
|
353
|
+
|
|
354
|
+
if (item.kind === "toggle" && (data === " " || data === "\r")) {
|
|
355
|
+
item.toggle();
|
|
356
|
+
tui.requestRender();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (item.kind === "number") {
|
|
360
|
+
if (matchesKey(data, Key.right)) { item.inc(); tui.requestRender(); return; }
|
|
361
|
+
if (matchesKey(data, Key.left)) { item.dec(); tui.requestRender(); return; }
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
overlay: true,
|
|
368
|
+
overlayOptions: { width: "60%", maxWidth: 72, minWidth: 52, maxHeight: "50%", anchor: "center" },
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── Project tree ─────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
function registerTreeCommands(
|
|
378
|
+
pi: ExtensionAPI,
|
|
379
|
+
edited: Map<string, EditStatus>,
|
|
380
|
+
getSettings: () => Settings,
|
|
381
|
+
) {
|
|
382
|
+
const openExternally = (ctx: any, absPath: string) => {
|
|
383
|
+
const { cmd, args } = buildOpenCommand(process.platform, absPath);
|
|
384
|
+
try {
|
|
385
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
386
|
+
child.on("error", () => {
|
|
387
|
+
ctx.ui.notify(`Could not open ${absPath}`, "error");
|
|
388
|
+
});
|
|
389
|
+
child.unref();
|
|
390
|
+
} catch {
|
|
391
|
+
ctx.ui.notify(`Could not open ${absPath}`, "error");
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const peek = async (ctx: any, absPath: string) => {
|
|
396
|
+
const max = getSettings().maxPeekBytes;
|
|
397
|
+
let size = 0;
|
|
398
|
+
try {
|
|
399
|
+
size = statSync(absPath).size;
|
|
400
|
+
} catch {
|
|
401
|
+
ctx.ui.notify(`Cannot read ${basename(absPath)}`, "error");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (!isPreviewable(size, max)) {
|
|
405
|
+
const kb = (size / 1024).toFixed(0);
|
|
406
|
+
ctx.ui.notify(
|
|
407
|
+
`${basename(absPath)} too large to preview (${kb} KB) — press Enter to open externally`,
|
|
408
|
+
"warning",
|
|
409
|
+
);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let raw: Buffer;
|
|
414
|
+
try {
|
|
415
|
+
raw = readFileSync(absPath);
|
|
416
|
+
} catch {
|
|
417
|
+
ctx.ui.notify(`Cannot read ${basename(absPath)}`, "error");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (looksBinary(raw.subarray(0, 4096))) {
|
|
421
|
+
ctx.ui.notify(
|
|
422
|
+
`${basename(absPath)} looks binary — press Enter to open externally`,
|
|
423
|
+
"warning",
|
|
424
|
+
);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const text = raw.toString("utf-8");
|
|
429
|
+
const lang = detectLanguageFromPath(absPath);
|
|
430
|
+
let rendered: string;
|
|
431
|
+
if (lang === "markdown") {
|
|
432
|
+
// Built-in pure highlighter — zero deps, 16-color ANSI, works on every OS.
|
|
433
|
+
rendered = highlightMarkdown(text);
|
|
434
|
+
} else {
|
|
435
|
+
try {
|
|
436
|
+
// S4: force color so cli-highlight (chalk) emits ANSI under pi's managed,
|
|
437
|
+
// non-TTY stdout. Without this, peek shows uncolored plain text.
|
|
438
|
+
process.env.FORCE_COLOR ||= "3";
|
|
439
|
+
// B2: lazy + graceful — if cli-highlight is missing, fall back to plain
|
|
440
|
+
// text instead of crashing the whole extension at module load.
|
|
441
|
+
const mod = await import("cli-highlight").catch(() => undefined);
|
|
442
|
+
rendered = mod?.highlight
|
|
443
|
+
? mod.highlight(text, { language: lang, ignoreIllegals: true })
|
|
444
|
+
: text;
|
|
445
|
+
} catch {
|
|
446
|
+
rendered = text; // never crash the peek on a highlight failure
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Tab-expand so widths are predictable; split into display lines.
|
|
450
|
+
const allLines = rendered.replace(/\t/g, " ").split("\n");
|
|
451
|
+
|
|
452
|
+
let peekScroll = 0;
|
|
453
|
+
await ctx.ui.custom(
|
|
454
|
+
(tui: any, theme: any, _kb: any, done: (v: null) => void) => {
|
|
455
|
+
const B = (s: string) => theme.fg("border", s);
|
|
456
|
+
const bodyH = (): number => Math.max(1, Math.floor(tui.terminal.rows * 0.8) - 4);
|
|
457
|
+
|
|
458
|
+
const buildPeek = (width: number): string[] => {
|
|
459
|
+
const innerW = width - 2;
|
|
460
|
+
const h = bodyH();
|
|
461
|
+
const maxScroll = Math.max(0, allLines.length - h);
|
|
462
|
+
if (peekScroll > maxScroll) peekScroll = maxScroll;
|
|
463
|
+
if (peekScroll < 0) peekScroll = 0;
|
|
464
|
+
const H = "─";
|
|
465
|
+
const lines: string[] = [];
|
|
466
|
+
lines.push(B("╭" + H.repeat(innerW) + "╮"));
|
|
467
|
+
const title = ` 👁 ${basename(absPath)}`;
|
|
468
|
+
const pos = `${peekScroll + 1}-${Math.min(peekScroll + h, allLines.length)}/${allLines.length} `;
|
|
469
|
+
const hint = `↑↓ scroll g/G ends spc/esc close ${pos}`;
|
|
470
|
+
const gap = Math.max(1, innerW - visibleWidth(title) - visibleWidth(hint));
|
|
471
|
+
lines.push(B("│") + theme.fg("accent", title) + " ".repeat(gap) +
|
|
472
|
+
theme.fg("dim", hint) + B("│"));
|
|
473
|
+
lines.push(B("├" + H.repeat(innerW) + "┤"));
|
|
474
|
+
const view = allLines.slice(peekScroll, peekScroll + h);
|
|
475
|
+
const rowsOut = view.length ? view : [theme.fg("dim", " (empty file)")];
|
|
476
|
+
for (const row of rowsOut) {
|
|
477
|
+
const cell = truncateToWidth(row, innerW);
|
|
478
|
+
// S5: append a hard reset so a multi-line highlight token (block
|
|
479
|
+
// comment, template literal) never bleeds color into the padding
|
|
480
|
+
// or right border of this or the next row.
|
|
481
|
+
const padded = cell + "\x1b[0m" + " ".repeat(Math.max(0, innerW - visibleWidth(cell)));
|
|
482
|
+
lines.push(B("│") + padded + B("│"));
|
|
483
|
+
}
|
|
484
|
+
lines.push(B("╰" + H.repeat(innerW) + "╯"));
|
|
485
|
+
return lines;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
render: (w: number) => buildPeek(w),
|
|
490
|
+
invalidate: () => {},
|
|
491
|
+
handleInput: (data: string) => {
|
|
492
|
+
const h = bodyH();
|
|
493
|
+
const maxScroll = Math.max(0, allLines.length - h);
|
|
494
|
+
if (matchesKey(data, Key.escape) || data === "q" || data === " ") return done(null);
|
|
495
|
+
if (matchesKey(data, Key.up)) { peekScroll = Math.max(0, peekScroll - 1); tui.requestRender(); return; }
|
|
496
|
+
if (matchesKey(data, Key.down)) { peekScroll = Math.min(maxScroll, peekScroll + 1); tui.requestRender(); return; }
|
|
497
|
+
if (matchesKey(data, Key.pageUp)) { peekScroll = Math.max(0, peekScroll - h); tui.requestRender(); return; }
|
|
498
|
+
if (matchesKey(data, Key.pageDown)) { peekScroll = Math.min(maxScroll, peekScroll + h); tui.requestRender(); return; }
|
|
499
|
+
if (data === "g") { peekScroll = 0; tui.requestRender(); return; }
|
|
500
|
+
if (data === "G") { peekScroll = maxScroll; tui.requestRender(); return; }
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
overlay: true,
|
|
506
|
+
overlayOptions: { width: "85%", maxWidth: 120, minWidth: 50, maxHeight: "80%", anchor: "center" },
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const open = async (ctx: any) => {
|
|
512
|
+
if (ctx.mode !== "tui") {
|
|
513
|
+
ctx.ui.notify("/pi-files requires TUI mode", "error");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const cwd = ctx.sessionManager.getCwd();
|
|
517
|
+
const allFiles = listProjectFiles(cwd);
|
|
518
|
+
const root = buildTree(allFiles);
|
|
519
|
+
|
|
520
|
+
// Edited files as cwd-relative posix paths for highlight + auto-expand.
|
|
521
|
+
const toRel = (abs: string) => relative(cwd, abs).split("\\").join("/");
|
|
522
|
+
const editedStatus = new Map<string, EditStatus>();
|
|
523
|
+
for (const [abs, status] of edited.entries()) editedStatus.set(toRel(abs), status);
|
|
524
|
+
|
|
525
|
+
const expanded = new Set<string>();
|
|
526
|
+
for (const rel of editedStatus.keys()) {
|
|
527
|
+
for (const dir of ancestorsOf(rel)) expanded.add(dir);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let selected = 0;
|
|
531
|
+
let scroll = 0;
|
|
532
|
+
let searchQuery = "";
|
|
533
|
+
let treeSelected = 0; // tree cursor saved when search starts, restored on clear
|
|
534
|
+
let treeScroll = 0;
|
|
535
|
+
|
|
536
|
+
await ctx.ui.custom(
|
|
537
|
+
(tui: any, theme: any, _kb: any, done: (v: null) => void) => {
|
|
538
|
+
const B = (s: string) => theme.fg("border", s);
|
|
539
|
+
|
|
540
|
+
// Highlight the matched portion of a path in accent color.
|
|
541
|
+
const highlightMatch = (path: string, query: string): string => {
|
|
542
|
+
if (!query) return theme.fg("muted", path);
|
|
543
|
+
const lo = path.toLowerCase().indexOf(query.toLowerCase());
|
|
544
|
+
if (lo < 0) return theme.fg("muted", path);
|
|
545
|
+
return (
|
|
546
|
+
theme.fg("muted", path.slice(0, lo)) +
|
|
547
|
+
theme.fg("accent", path.slice(lo, lo + query.length)) +
|
|
548
|
+
theme.fg("muted", path.slice(lo + query.length))
|
|
549
|
+
);
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const visibleBody = (): number => {
|
|
553
|
+
const max = Math.max(1, Math.floor(tui.terminal.rows * 0.8) - 4);
|
|
554
|
+
const total = searchQuery
|
|
555
|
+
? Math.max(1, filterFiles(allFiles, searchQuery).length)
|
|
556
|
+
: Math.max(1, flattenVisible(root, expanded).length);
|
|
557
|
+
return Math.min(max, total);
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const buildSearchBody = (innerW: number, bodyH: number): string[] => {
|
|
561
|
+
const results = filterFiles(allFiles, searchQuery);
|
|
562
|
+
if (selected >= results.length) selected = Math.max(0, results.length - 1);
|
|
563
|
+
if (selected < 0) selected = 0;
|
|
564
|
+
if (selected < scroll) scroll = selected;
|
|
565
|
+
if (selected >= scroll + bodyH) scroll = selected - bodyH + 1;
|
|
566
|
+
if (scroll < 0) scroll = 0;
|
|
567
|
+
|
|
568
|
+
return results.slice(scroll, scroll + bodyH).map((path, i) => {
|
|
569
|
+
const idx = scroll + i;
|
|
570
|
+
const isSelected = idx === selected;
|
|
571
|
+
const status = editedStatus.get(path);
|
|
572
|
+
const statusPart = status ? statusGlyph(status) + " " : "";
|
|
573
|
+
const gutter = isSelected ? theme.fg("accent", "›") : " ";
|
|
574
|
+
const prefix = status
|
|
575
|
+
? theme.fg(status === "new" ? "success" : "warning", statusGlyph(status) + " ")
|
|
576
|
+
: "";
|
|
577
|
+
const pathStyled = highlightMatch(path, searchQuery);
|
|
578
|
+
// Use plain-text width for padding calculation
|
|
579
|
+
const pad = " ".repeat(Math.max(0, innerW - 1 - visibleWidth(` ${statusPart}${path}`)));
|
|
580
|
+
return gutter + " " + prefix + pathStyled + pad;
|
|
581
|
+
});
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const buildBody = (innerW: number, bodyH: number): string[] => {
|
|
585
|
+
const rows = flattenVisible(root, expanded);
|
|
586
|
+
if (selected >= rows.length) selected = rows.length - 1;
|
|
587
|
+
if (selected < 0) selected = 0;
|
|
588
|
+
if (selected < scroll) scroll = selected;
|
|
589
|
+
if (selected >= scroll + bodyH) scroll = selected - bodyH + 1;
|
|
590
|
+
if (scroll < 0) scroll = 0;
|
|
591
|
+
|
|
592
|
+
return rows.slice(scroll, scroll + bodyH).map((n, i) => {
|
|
593
|
+
const idx = scroll + i;
|
|
594
|
+
const indent = " ".repeat(n.depth);
|
|
595
|
+
const caret = n.isDir ? (expanded.has(n.path) ? "▾ " : "▸ ") : " ";
|
|
596
|
+
const status = !n.isDir ? editedStatus.get(n.path) : undefined;
|
|
597
|
+
|
|
598
|
+
// S4: raw + styled share identical glyph prefixes so widths match.
|
|
599
|
+
const namePlain = status ? `${statusGlyph(status)} ${n.name}` : n.name;
|
|
600
|
+
const nameStyled = status
|
|
601
|
+
? theme.fg(status === "new" ? "success" : "warning", namePlain)
|
|
602
|
+
: n.isDir
|
|
603
|
+
? theme.fg("accent", n.name)
|
|
604
|
+
: theme.fg("muted", n.name);
|
|
605
|
+
|
|
606
|
+
// S3: reserve a 1-col cursor gutter; row content starts at column 2,
|
|
607
|
+
// so the selection marker never overwrites the caret/glyph.
|
|
608
|
+
const gutter = idx === selected ? theme.fg("accent", "›") : " ";
|
|
609
|
+
const contentPlain = ` ${indent}${caret}${namePlain}`;
|
|
610
|
+
const contentStyled = ` ${indent}${caret}${nameStyled}`;
|
|
611
|
+
const pad = " ".repeat(Math.max(0, innerW - 1 - visibleWidth(contentPlain)));
|
|
612
|
+
return gutter + contentStyled + pad;
|
|
613
|
+
});
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const build = (width: number): string[] => {
|
|
617
|
+
const innerW = width - 2;
|
|
618
|
+
const bodyH = visibleBody();
|
|
619
|
+
const H = "─";
|
|
620
|
+
const lines: string[] = [];
|
|
621
|
+
lines.push(B("╭" + H.repeat(innerW) + "╮"));
|
|
622
|
+
const title = " 📁 Project files";
|
|
623
|
+
if (searchQuery) {
|
|
624
|
+
const count = filterFiles(allFiles, searchQuery).length;
|
|
625
|
+
const prompt = theme.fg("success", `/ ${searchQuery}▌`);
|
|
626
|
+
const info = theme.fg("dim", ` ${count} result${count !== 1 ? "s" : ""} esc clear `);
|
|
627
|
+
const promptPlain = `/ ${searchQuery}▌`;
|
|
628
|
+
const infoPlain = ` ${count} result${count !== 1 ? "s" : ""} esc clear `;
|
|
629
|
+
const gap = Math.max(1, innerW - visibleWidth(title) - visibleWidth(promptPlain) - visibleWidth(infoPlain));
|
|
630
|
+
lines.push(B("│") + theme.fg("accent", title) + " ".repeat(gap) + prompt + info + B("│"));
|
|
631
|
+
} else {
|
|
632
|
+
const hint = "↑↓ move ↵ open → expand ← collapse spc peek type to filter esc close ";
|
|
633
|
+
const gap = Math.max(1, innerW - visibleWidth(title) - visibleWidth(hint));
|
|
634
|
+
lines.push(B("│") + theme.fg("accent", title) + " ".repeat(gap) +
|
|
635
|
+
theme.fg("dim", hint) + B("│"));
|
|
636
|
+
}
|
|
637
|
+
lines.push(B("├" + H.repeat(innerW) + "┤"));
|
|
638
|
+
const body = searchQuery ? buildSearchBody(innerW, bodyH) : buildBody(innerW, bodyH);
|
|
639
|
+
const empty = searchQuery ? " (no matches)" : " (no files)";
|
|
640
|
+
const rowsOut = body.length ? body : [theme.fg("dim", empty)];
|
|
641
|
+
for (const row of rowsOut) {
|
|
642
|
+
const cell = truncateToWidth(row, innerW);
|
|
643
|
+
lines.push(B("│") + cell + " ".repeat(Math.max(0, innerW - visibleWidth(cell))) + B("│"));
|
|
644
|
+
}
|
|
645
|
+
lines.push(B("╰" + H.repeat(innerW) + "╯"));
|
|
646
|
+
return lines;
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
render: (w: number) => build(w),
|
|
651
|
+
invalidate: () => {},
|
|
652
|
+
handleInput: (data: string) => {
|
|
653
|
+
// Esc: clear filter and restore tree cursor, or close when already clear
|
|
654
|
+
if (matchesKey(data, Key.escape)) {
|
|
655
|
+
if (searchQuery) { searchQuery = ""; selected = treeSelected; scroll = treeScroll; tui.requestRender(); }
|
|
656
|
+
else done(null);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Backspace: remove last filter char; restore tree cursor when emptied
|
|
661
|
+
if (data === "\x7f" || data === "\b") {
|
|
662
|
+
if (searchQuery.length > 0) {
|
|
663
|
+
searchQuery = searchQuery.slice(0, -1);
|
|
664
|
+
if (searchQuery.length === 0) { selected = treeSelected; scroll = treeScroll; }
|
|
665
|
+
else { selected = 0; scroll = 0; }
|
|
666
|
+
tui.requestRender();
|
|
667
|
+
}
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Space: peek selected file (Quick Look — works in both modes)
|
|
672
|
+
if (data === " ") {
|
|
673
|
+
if (searchQuery) {
|
|
674
|
+
const path = filterFiles(allFiles, searchQuery)[selected];
|
|
675
|
+
if (path) void peek(ctx, resolve(cwd, path));
|
|
676
|
+
} else {
|
|
677
|
+
const rows = flattenVisible(root, expanded);
|
|
678
|
+
const node = rows[selected];
|
|
679
|
+
if (node && !node.isDir) void peek(ctx, resolve(cwd, node.path));
|
|
680
|
+
else if (node?.isDir) ctx.ui.notify("Space peeks files — press Enter or → to expand directories", "warning");
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Up / Down: navigate
|
|
686
|
+
if (matchesKey(data, Key.up)) {
|
|
687
|
+
selected = Math.max(0, selected - 1); tui.requestRender(); return;
|
|
688
|
+
}
|
|
689
|
+
// Down: also uses pre-computed results if available to avoid extra call
|
|
690
|
+
if (matchesKey(data, Key.down)) {
|
|
691
|
+
const count = searchQuery
|
|
692
|
+
? filterFiles(allFiles, searchQuery).length
|
|
693
|
+
: flattenVisible(root, expanded).length;
|
|
694
|
+
selected = Math.min(Math.max(0, count - 1), selected + 1);
|
|
695
|
+
tui.requestRender(); return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Enter: open selected
|
|
699
|
+
if (data === "\r") {
|
|
700
|
+
if (searchQuery) {
|
|
701
|
+
const path = filterFiles(allFiles, searchQuery)[selected];
|
|
702
|
+
if (path) openExternally(ctx, resolve(cwd, path));
|
|
703
|
+
} else {
|
|
704
|
+
const rows = flattenVisible(root, expanded);
|
|
705
|
+
const node = rows[selected];
|
|
706
|
+
if (!node) return;
|
|
707
|
+
if (node.isDir) { expanded.add(node.path); tui.requestRender(); }
|
|
708
|
+
else openExternally(ctx, resolve(cwd, node.path));
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Tree-only keys: expand / collapse dirs (inactive while filtering)
|
|
714
|
+
if (!searchQuery) {
|
|
715
|
+
const rows = flattenVisible(root, expanded);
|
|
716
|
+
const node = rows[selected];
|
|
717
|
+
if (!node) return;
|
|
718
|
+
if (matchesKey(data, Key.right)) {
|
|
719
|
+
if (node.isDir) { expanded.add(node.path); tui.requestRender(); }
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (matchesKey(data, Key.left)) {
|
|
723
|
+
if (node.isDir && expanded.has(node.path)) {
|
|
724
|
+
expanded.delete(node.path);
|
|
725
|
+
} else {
|
|
726
|
+
const parents = ancestorsOf(node.path);
|
|
727
|
+
const parent = parents[parents.length - 1];
|
|
728
|
+
if (parent) {
|
|
729
|
+
const pIdx = flattenVisible(root, expanded).findIndex((n) => n.path === parent);
|
|
730
|
+
if (pIdx >= 0) selected = pIdx;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
tui.requestRender(); return;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Any printable char (excl. space): append to filter.
|
|
738
|
+
// Save tree cursor on first character so Esc/backspace can restore it.
|
|
739
|
+
if (data.length === 1 && data >= "!") {
|
|
740
|
+
if (!searchQuery) { treeSelected = selected; treeScroll = scroll; }
|
|
741
|
+
searchQuery += data; selected = 0; scroll = 0; tui.requestRender();
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
overlay: true,
|
|
748
|
+
overlayOptions: { width: "80%", maxWidth: 100, minWidth: 50, maxHeight: "80%", anchor: "center" },
|
|
749
|
+
},
|
|
750
|
+
);
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
pi.registerCommand("pi-files", { description: "Browse the project file tree (agent edits highlighted)", handler: (_a, ctx) => open(ctx) });
|
|
754
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guneriu/pi-files",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Shows agent-edited files in a compact widget above the input bar, plus an on-demand interactive project tree (gitignore-aware)",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"files",
|
|
10
|
+
"tree",
|
|
11
|
+
"explorer",
|
|
12
|
+
"session"
|
|
13
|
+
],
|
|
14
|
+
"author": "Uğur Güneri (guneriu)",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions/",
|
|
18
|
+
"src/",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/guneriu/pi-extension-mono",
|
|
25
|
+
"directory": "packages/pi-files"
|
|
26
|
+
},
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./extensions"
|
|
30
|
+
],
|
|
31
|
+
"image": "https://raw.githubusercontent.com/guneriu/pi-extension-mono/main/assets/pi-files-preview.png"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"cli-highlight": "^2.1.11"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
38
|
+
"@earendil-works/pi-tui": "*"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
export type EditStatus = "new" | "modified";
|
|
2
|
+
export type EditKind = "write" | "edit";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decide the status glyph for a write/edit.
|
|
6
|
+
* - "new" is sticky once set (the agent created the file this session).
|
|
7
|
+
* - write to a path that does not currently exist => "new".
|
|
8
|
+
* - everything else => "modified".
|
|
9
|
+
*/
|
|
10
|
+
export function classifyEdit(
|
|
11
|
+
kind: EditKind,
|
|
12
|
+
existsBefore: boolean,
|
|
13
|
+
previous: EditStatus | undefined,
|
|
14
|
+
): EditStatus {
|
|
15
|
+
if (previous === "new") return "new";
|
|
16
|
+
if (kind === "write" && !existsBefore) return "new";
|
|
17
|
+
return "modified";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EditedFile {
|
|
21
|
+
relPath: string;
|
|
22
|
+
status: EditStatus;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WidgetLines {
|
|
26
|
+
header: string | undefined;
|
|
27
|
+
rows: string[];
|
|
28
|
+
overflow: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function statusGlyph(status: EditStatus): string {
|
|
32
|
+
return status === "new" ? "+" : "M";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build plain (unstyled) widget content; the extension applies theme colors.
|
|
37
|
+
* `files` MUST already be in display order (newest edit first) — the extension
|
|
38
|
+
* is responsible for ordering. We keep the first `maxRows` so the newest edits
|
|
39
|
+
* are shown and older ones fold into the overflow line.
|
|
40
|
+
*/
|
|
41
|
+
export function buildWidgetLines(files: EditedFile[], maxRows: number): WidgetLines {
|
|
42
|
+
if (files.length === 0) {
|
|
43
|
+
return { header: undefined, rows: [], overflow: undefined };
|
|
44
|
+
}
|
|
45
|
+
const shown = files.slice(0, maxRows);
|
|
46
|
+
const rows = shown.map((f) => `${statusGlyph(f.status)} ${f.relPath}`);
|
|
47
|
+
const hidden = files.length - shown.length;
|
|
48
|
+
return {
|
|
49
|
+
header: `Edited files (${files.length})`,
|
|
50
|
+
rows,
|
|
51
|
+
overflow: hidden > 0 ? `… +${hidden} more` : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TreeNode {
|
|
56
|
+
name: string; // basename
|
|
57
|
+
path: string; // posix-style relative path from cwd
|
|
58
|
+
isDir: boolean;
|
|
59
|
+
depth: number; // 0 for top-level entries
|
|
60
|
+
children: TreeNode[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Parse `git ls-files` style newline output into clean relative paths. */
|
|
64
|
+
export function parseGitFileList(out: string): string[] {
|
|
65
|
+
return out
|
|
66
|
+
.split("\n")
|
|
67
|
+
.map((l) => l.trim())
|
|
68
|
+
.filter((l) => l.length > 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** All ancestor directory paths of a file, root-first. "docs/plans/x" -> ["docs","docs/plans"]. */
|
|
72
|
+
export function ancestorsOf(relPath: string): string[] {
|
|
73
|
+
const parts = relPath.split("/");
|
|
74
|
+
const dirs: string[] = [];
|
|
75
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
76
|
+
dirs.push(parts.slice(0, i + 1).join("/"));
|
|
77
|
+
}
|
|
78
|
+
return dirs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build a synthetic root node whose children are the top-level entries. */
|
|
82
|
+
export function buildTree(relPaths: string[]): TreeNode {
|
|
83
|
+
const root: TreeNode = { name: "", path: "", isDir: true, depth: -1, children: [] };
|
|
84
|
+
for (const rel of relPaths) {
|
|
85
|
+
const parts = rel.split("/");
|
|
86
|
+
let node = root;
|
|
87
|
+
for (let i = 0; i < parts.length; i++) {
|
|
88
|
+
const name = parts[i];
|
|
89
|
+
const isDir = i < parts.length - 1;
|
|
90
|
+
const path = parts.slice(0, i + 1).join("/");
|
|
91
|
+
let child = node.children.find((c) => c.name === name && c.isDir === isDir);
|
|
92
|
+
if (!child) {
|
|
93
|
+
child = { name, path, isDir, depth: i, children: [] };
|
|
94
|
+
node.children.push(child);
|
|
95
|
+
}
|
|
96
|
+
node = child;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
sortTree(root);
|
|
100
|
+
return root;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sortTree(node: TreeNode): void {
|
|
104
|
+
node.children.sort((a, b) => {
|
|
105
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; // dirs first
|
|
106
|
+
return a.name.localeCompare(b.name);
|
|
107
|
+
});
|
|
108
|
+
for (const c of node.children) sortTree(c);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** DFS over the tree, descending into a dir only when its path is in `expanded`. */
|
|
112
|
+
export function flattenVisible(root: TreeNode, expanded: Set<string>): TreeNode[] {
|
|
113
|
+
const out: TreeNode[] = [];
|
|
114
|
+
const walk = (n: TreeNode) => {
|
|
115
|
+
for (const child of n.children) {
|
|
116
|
+
out.push(child);
|
|
117
|
+
if (child.isDir && expanded.has(child.path)) walk(child);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
walk(root);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface BranchEdit {
|
|
125
|
+
path: string;
|
|
126
|
+
kind: EditKind;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Scan a session branch (ctx.sessionManager.getBranch()) for write/edit tool
|
|
131
|
+
* calls and return their target paths in order. Mirrors the toolCall shape used
|
|
132
|
+
* across pi sessions: assistant message -> content[] -> { type:"toolCall", name, arguments }.
|
|
133
|
+
*/
|
|
134
|
+
export function extractEditsFromBranch(branch: any[]): BranchEdit[] {
|
|
135
|
+
const edits: BranchEdit[] = [];
|
|
136
|
+
for (const entry of branch) {
|
|
137
|
+
if (entry?.type !== "message") continue;
|
|
138
|
+
if (entry.message?.role !== "assistant") continue;
|
|
139
|
+
for (const block of entry.message?.content ?? []) {
|
|
140
|
+
if (block?.type !== "toolCall") continue;
|
|
141
|
+
const kind = block.name;
|
|
142
|
+
if (kind !== "write" && kind !== "edit") continue;
|
|
143
|
+
const path = block.arguments?.path;
|
|
144
|
+
if (typeof path !== "string" || path.length === 0) continue;
|
|
145
|
+
edits.push({ path, kind });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return edits;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
import { readdirSync, existsSync } from "node:fs";
|
|
152
|
+
import { join } from "node:path";
|
|
153
|
+
import { execFileSync } from "node:child_process";
|
|
154
|
+
|
|
155
|
+
const ALWAYS_EXCLUDE = new Set([".git", "node_modules"]);
|
|
156
|
+
|
|
157
|
+
/** Recursive readdir fallback returning posix-relative paths, excluding noise dirs. */
|
|
158
|
+
export function walkDirRelative(cwd: string): string[] {
|
|
159
|
+
const out: string[] = [];
|
|
160
|
+
const walk = (absDir: string, relPrefix: string) => {
|
|
161
|
+
let entries: import("node:fs").Dirent[];
|
|
162
|
+
try {
|
|
163
|
+
entries = readdirSync(absDir, { withFileTypes: true });
|
|
164
|
+
} catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const e of entries) {
|
|
168
|
+
if (e.isDirectory() && ALWAYS_EXCLUDE.has(e.name)) continue;
|
|
169
|
+
const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
|
|
170
|
+
if (e.isDirectory()) {
|
|
171
|
+
walk(`${absDir}/${e.name}`, rel);
|
|
172
|
+
} else if (e.isFile()) {
|
|
173
|
+
out.push(rel);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
walk(cwd, "");
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Preferred source: git ls-files (respects .gitignore exactly). Falls back to a
|
|
183
|
+
* filesystem walk when not a git repo or git is unavailable.
|
|
184
|
+
*
|
|
185
|
+
* Note: `git ls-files --cached` can report staged-but-deleted paths, so we drop
|
|
186
|
+
* entries that no longer exist on disk before building the tree.
|
|
187
|
+
*/
|
|
188
|
+
export function listProjectFiles(cwd: string): string[] {
|
|
189
|
+
try {
|
|
190
|
+
const out = execFileSync(
|
|
191
|
+
"git",
|
|
192
|
+
["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
193
|
+
{ cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
|
|
194
|
+
);
|
|
195
|
+
const files = parseGitFileList(out).filter((rel) => existsSync(join(cwd, rel)));
|
|
196
|
+
return files; // trust git result even when empty — avoids surfacing gitignored files via fallback
|
|
197
|
+
} catch {
|
|
198
|
+
return walkDirRelative(cwd);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Open & Peek helpers ────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export interface OpenCommand {
|
|
205
|
+
cmd: string;
|
|
206
|
+
args: string[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Map a platform + absolute path to a spawnable OS "open with default app"
|
|
211
|
+
* command. Pure so it can be unit-tested without spawning anything.
|
|
212
|
+
* - darwin -> `open <path>`
|
|
213
|
+
* - win32 -> `cmd /c start "" <path>` (empty "" is the start window title)
|
|
214
|
+
* - other -> `xdg-open <path>` (linux, *bsd, incl. WSL)
|
|
215
|
+
*/
|
|
216
|
+
export function buildOpenCommand(
|
|
217
|
+
platform: NodeJS.Platform,
|
|
218
|
+
absPath: string,
|
|
219
|
+
): OpenCommand {
|
|
220
|
+
if (platform === "darwin") return { cmd: "open", args: [absPath] };
|
|
221
|
+
if (platform === "win32") return { cmd: "cmd", args: ["/c", "start", "", absPath] };
|
|
222
|
+
return { cmd: "xdg-open", args: [absPath] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Minimal extension -> cli-highlight language id map. Undefined => auto-detect. */
|
|
226
|
+
const EXT_LANG: Record<string, string> = {
|
|
227
|
+
ts: "typescript",
|
|
228
|
+
tsx: "typescript",
|
|
229
|
+
js: "javascript",
|
|
230
|
+
jsx: "javascript",
|
|
231
|
+
mjs: "javascript",
|
|
232
|
+
cjs: "javascript",
|
|
233
|
+
json: "json",
|
|
234
|
+
md: "markdown",
|
|
235
|
+
markdown: "markdown",
|
|
236
|
+
css: "css",
|
|
237
|
+
scss: "scss",
|
|
238
|
+
html: "html",
|
|
239
|
+
xml: "xml",
|
|
240
|
+
yml: "yaml",
|
|
241
|
+
yaml: "yaml",
|
|
242
|
+
sh: "bash",
|
|
243
|
+
bash: "bash",
|
|
244
|
+
py: "python",
|
|
245
|
+
rs: "rust",
|
|
246
|
+
go: "go",
|
|
247
|
+
c: "c",
|
|
248
|
+
h: "c",
|
|
249
|
+
cpp: "cpp",
|
|
250
|
+
java: "java",
|
|
251
|
+
rb: "ruby",
|
|
252
|
+
toml: "ini",
|
|
253
|
+
sql: "sql",
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** Language id for cli-highlight from a file path, or undefined to auto-detect. */
|
|
257
|
+
export function detectLanguageFromPath(path: string): string | undefined {
|
|
258
|
+
const base = path.split("/").pop() ?? path;
|
|
259
|
+
const dot = base.lastIndexOf(".");
|
|
260
|
+
if (dot <= 0) return undefined; // no ext, or dotfile like ".env"
|
|
261
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
262
|
+
return EXT_LANG[ext];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Heuristic: a NUL byte in the sampled bytes means "binary". */
|
|
266
|
+
export function looksBinary(buf: Buffer | Uint8Array): boolean {
|
|
267
|
+
for (let i = 0; i < buf.length; i++) {
|
|
268
|
+
if (buf[i] === 0) return true;
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** True when a file of `sizeBytes` is within the peek cap `maxBytes`. */
|
|
274
|
+
export function isPreviewable(sizeBytes: number, maxBytes: number): boolean {
|
|
275
|
+
return sizeBytes <= maxBytes;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Case-insensitive substring filter over a flat file list.
|
|
280
|
+
* Empty query returns the full list unchanged.
|
|
281
|
+
*/
|
|
282
|
+
export function filterFiles(files: string[], query: string): string[] {
|
|
283
|
+
if (!query) return files;
|
|
284
|
+
const q = query.toLowerCase();
|
|
285
|
+
return files.filter((f) => f.toLowerCase().includes(q));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Markdown highlighter ───────────────────────────────────────────────────
|
|
289
|
+
// Pure, zero-dependency, 16-color ANSI — works on macOS, Linux, Windows.
|
|
290
|
+
// Uses only the base 16-color range so there is no truecolor/256-color
|
|
291
|
+
// requirement; every terminal and Windows Terminal supports these codes.
|
|
292
|
+
|
|
293
|
+
const _R = "\x1b[0m"; // reset
|
|
294
|
+
const _B = "\x1b[1m"; // bold
|
|
295
|
+
const _DIM = "\x1b[2m"; // dim
|
|
296
|
+
const _IT = "\x1b[3m"; // italic
|
|
297
|
+
const _CYN = "\x1b[36m"; // cyan — headings
|
|
298
|
+
const _YLW = "\x1b[33m"; // yellow — code
|
|
299
|
+
const _GRN = "\x1b[32m"; // green — list markers
|
|
300
|
+
const _MGT = "\x1b[35m"; // magenta — links
|
|
301
|
+
const _BLU = "\x1b[94m"; // bright blue — blockquotes
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Apply inline markdown spans to a single string: inline code, bold, italic,
|
|
305
|
+
* links. Exported so it can be unit-tested independently.
|
|
306
|
+
*/
|
|
307
|
+
export function applyInlineMarkdown(text: string): string {
|
|
308
|
+
// Inline code first — everything inside backticks is literal.
|
|
309
|
+
text = text.replace(/`([^`\n]+)`/g, `${_YLW}\`$1\`${_R}`);
|
|
310
|
+
// Bold: **text** or __text__ — exclude spans that contain backticks (already code-styled)
|
|
311
|
+
text = text.replace(/\*\*([^*\n`]+)\*\*/g, `${_B}**$1**${_R}`);
|
|
312
|
+
text = text.replace(/__([^_\n`]+)__/g, `${_B}__$1__${_R}`);
|
|
313
|
+
// Italic: *text* or _text_ — lookbehind/lookahead on backtick prevents matching
|
|
314
|
+
// inside code spans whose replacement output still contains the ` char.
|
|
315
|
+
text = text.replace(/(?<![*_`])\*([^*\n`]+)\*(?![*`])/g, `${_IT}*$1*${_R}`);
|
|
316
|
+
text = text.replace(/(?<![_`])_([^_\n`]+)_(?![_`])/g, `${_IT}_$1_${_R}`);
|
|
317
|
+
// Links / images: [text](url)
|
|
318
|
+
text = text.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, `${_MGT}[$1]${_R}${_DIM}($2)${_R}`);
|
|
319
|
+
return text;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Syntax-highlight a full markdown document for terminal display.
|
|
324
|
+
* Processes line-by-line with stateful fenced-code-block tracking.
|
|
325
|
+
* Emits standard 16-color ANSI codes only — compatible with every terminal.
|
|
326
|
+
*/
|
|
327
|
+
export function highlightMarkdown(text: string): string {
|
|
328
|
+
let inFence = false;
|
|
329
|
+
return text
|
|
330
|
+
.split("\n")
|
|
331
|
+
.map((line) => {
|
|
332
|
+
// Fenced code block delimiter (``` or ~~~)
|
|
333
|
+
if (/^(`{3,}|~{3,})/.test(line)) {
|
|
334
|
+
inFence = !inFence;
|
|
335
|
+
return `${_DIM}${_YLW}${line}${_R}`;
|
|
336
|
+
}
|
|
337
|
+
// Inside a fenced code block — dim yellow, no further processing
|
|
338
|
+
if (inFence) return `${_YLW}${_DIM}${line}${_R}`;
|
|
339
|
+
|
|
340
|
+
// ATX headings: # … ######
|
|
341
|
+
const hm = line.match(/^(#{1,6})\s(.+)$/);
|
|
342
|
+
if (hm) {
|
|
343
|
+
const marks = hm[1];
|
|
344
|
+
const body = applyInlineMarkdown(hm[2]);
|
|
345
|
+
if (marks.length === 1) return `${_B}${_CYN}${marks} ${_R}${_B}${body}${_R}`;
|
|
346
|
+
if (marks.length === 2) return `${_B}${_CYN}${marks} ${_R}${body}`;
|
|
347
|
+
return `${_DIM}${_CYN}${marks} ${_R}${body}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Blockquote
|
|
351
|
+
if (/^>/.test(line)) return `${_BLU}${_DIM}${line}${_R}`;
|
|
352
|
+
|
|
353
|
+
// Horizontal rule (must come before setext-underline check)
|
|
354
|
+
if (/^(\*{3,}|-{3,}|_{3,})$/.test(line)) return `${_DIM}${line}${_R}`;
|
|
355
|
+
|
|
356
|
+
// Unordered list item: - / * / + (with optional indentation)
|
|
357
|
+
const ulm = line.match(/^(\s*)([-*+]) (.*)$/);
|
|
358
|
+
if (ulm) return `${ulm[1]}${_GRN}${ulm[2]}${_R} ${applyInlineMarkdown(ulm[3])}`;
|
|
359
|
+
|
|
360
|
+
// Ordered list item: 1. / 12. (with optional indentation)
|
|
361
|
+
const olm = line.match(/^(\s*)(\d+\.) (.*)$/);
|
|
362
|
+
if (olm) return `${olm[1]}${_GRN}${olm[2]}${_R} ${applyInlineMarkdown(olm[3])}`;
|
|
363
|
+
|
|
364
|
+
// Table separator row |---|---|
|
|
365
|
+
if (/^\|[-: |]+\|$/.test(line)) return `${_DIM}${line}${_R}`;
|
|
366
|
+
// Table data row — dim the pipe characters
|
|
367
|
+
if (/^\|/.test(line)) return line.replace(/\|/g, `${_DIM}|${_R}`);
|
|
368
|
+
|
|
369
|
+
// Plain paragraph text — apply inline spans only
|
|
370
|
+
return applyInlineMarkdown(line);
|
|
371
|
+
})
|
|
372
|
+
.join("\n");
|
|
373
|
+
}
|