@wierdbytes/pi-facelift 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.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/bash-rendering.test.ts +169 -0
- package/image-rendering.test.ts +153 -0
- package/index.ts +1560 -0
- package/media/demo.png +0 -0
- package/package.json +69 -0
- package/scripts/demo.ts +359 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @wierdbytes/pi-facelift — Cosmetic facelift for built-in pi tool output.
|
|
3
|
+
*
|
|
4
|
+
* @module @wierdbytes/pi-facelift
|
|
5
|
+
* @see https://github.com/wierdbytes/pi-wierd-stuff/tree/master/packages/pi-facelift
|
|
6
|
+
*
|
|
7
|
+
* Enhances:
|
|
8
|
+
* • read — syntax-highlighted file content with line numbers
|
|
9
|
+
* • bash — colored exit status, stderr highlighting
|
|
10
|
+
* • ls — tree-view directory listing with file-type icons
|
|
11
|
+
* • find — grouped results with file-type icons
|
|
12
|
+
* • grep — syntax-highlighted match context with line numbers
|
|
13
|
+
*
|
|
14
|
+
* Architecture:
|
|
15
|
+
* 1. Wrap SDK factory tools (createReadTool, createBashTool, etc.)
|
|
16
|
+
* 2. Delegate to original execute() — no behavior changes
|
|
17
|
+
* 3. Attach metadata in result.details for custom renderCall/renderResult
|
|
18
|
+
* 4. Async Shiki highlighting with ctx.invalidate() for non-blocking renders
|
|
19
|
+
*
|
|
20
|
+
* Performance:
|
|
21
|
+
* • Shared Shiki singleton (managed by @shikijs/cli)
|
|
22
|
+
* • LRU cache for highlighted blocks
|
|
23
|
+
* • Large-file fallback (skip highlighting, still show line numbers)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as childProcess from "node:child_process";
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import { basename, dirname, extname, join, relative } from "node:path";
|
|
29
|
+
|
|
30
|
+
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
31
|
+
import type {
|
|
32
|
+
AgentToolResult,
|
|
33
|
+
AgentToolUpdateCallback,
|
|
34
|
+
BashToolInput,
|
|
35
|
+
ExtensionCommandContext,
|
|
36
|
+
ExtensionContext,
|
|
37
|
+
FindToolInput,
|
|
38
|
+
GrepToolInput,
|
|
39
|
+
LsToolInput,
|
|
40
|
+
ReadToolInput,
|
|
41
|
+
ToolRenderResultOptions,
|
|
42
|
+
} from "@earendil-works/pi-coding-agent";
|
|
43
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
44
|
+
import { codeToANSI } from "@shikijs/cli";
|
|
45
|
+
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Config
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const DEFAULT_THEME: BundledTheme = "github-dark";
|
|
52
|
+
|
|
53
|
+
function getDefaultAgentDir(): string | undefined {
|
|
54
|
+
const home = process.env.HOME ?? "";
|
|
55
|
+
return home ? join(home, ".pi/agent") : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readThemeFromSettings(agentDir?: string): BundledTheme | undefined {
|
|
59
|
+
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
|
|
60
|
+
if (!resolvedAgentDir) return undefined;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const settings = JSON.parse(readFileSync(join(resolvedAgentDir, "settings.json"), "utf8")) as {
|
|
64
|
+
theme?: unknown;
|
|
65
|
+
};
|
|
66
|
+
return typeof settings.theme === "string" ? (settings.theme as BundledTheme) : undefined;
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolvePrettyTheme(agentDir?: string): BundledTheme {
|
|
73
|
+
return (
|
|
74
|
+
(process.env.FACELIFT_THEME as BundledTheme | undefined) ??
|
|
75
|
+
readThemeFromSettings(agentDir) ??
|
|
76
|
+
DEFAULT_THEME
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let THEME: BundledTheme = resolvePrettyTheme();
|
|
81
|
+
|
|
82
|
+
function setPrettyTheme(agentDir?: string): void {
|
|
83
|
+
const resolvedTheme = resolvePrettyTheme(agentDir);
|
|
84
|
+
if (resolvedTheme === THEME) return;
|
|
85
|
+
THEME = resolvedTheme;
|
|
86
|
+
_cache.clear();
|
|
87
|
+
codeToANSI("", "typescript", THEME).catch(() => {});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function envInt(name: string, fallback: number): number {
|
|
91
|
+
const v = Number.parseInt(process.env[name] ?? "", 10);
|
|
92
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const MAX_HL_CHARS = envInt("FACELIFT_MAX_HL_CHARS", 80_000);
|
|
96
|
+
const MAX_PREVIEW_LINES = envInt("FACELIFT_MAX_PREVIEW_LINES", 80);
|
|
97
|
+
const CACHE_LIMIT = envInt("FACELIFT_CACHE_LIMIT", 128);
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// ANSI
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
let RST = "\x1b[0m";
|
|
104
|
+
const BOLD = "\x1b[1m";
|
|
105
|
+
|
|
106
|
+
const FG_LNUM = "\x1b[38;2;100;100;100m";
|
|
107
|
+
const FG_DIM = "\x1b[38;2;80;80;80m";
|
|
108
|
+
const FG_RULE = "\x1b[38;2;50;50;50m";
|
|
109
|
+
const FG_GREEN = "\x1b[38;2;100;180;120m";
|
|
110
|
+
const FG_RED = "\x1b[38;2;200;100;100m";
|
|
111
|
+
const FG_YELLOW = "\x1b[38;2;220;180;80m";
|
|
112
|
+
const FG_BLUE = "\x1b[38;2;100;140;220m";
|
|
113
|
+
const FG_MUTED = "\x1b[38;2;139;148;158m";
|
|
114
|
+
|
|
115
|
+
const BG_DEFAULT = "\x1b[49m";
|
|
116
|
+
let BG_BASE = BG_DEFAULT; // tool box success/base bg — updated from theme's toolBg/background
|
|
117
|
+
// Note: pi-facelift no longer fills tool blocks with toolSuccessBg/toolErrorBg.
|
|
118
|
+
// Each tool sets `renderShell: "self"` and draws its own frame instead
|
|
119
|
+
// (see frameTop/frameBottom/frameBodyLines).
|
|
120
|
+
|
|
121
|
+
type BgTheme = { getBgAnsi?: (key: string) => string };
|
|
122
|
+
type FgTheme = { fg: (key: string, text: string) => string };
|
|
123
|
+
|
|
124
|
+
/** Parse an ANSI 24-bit color escape into { r, g, b }. Handles both fg (38;2) and bg (48;2). */
|
|
125
|
+
function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
|
|
126
|
+
const m = ansi.match(new RegExp(`${ESC_RE}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
|
|
127
|
+
return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
|
|
131
|
+
try {
|
|
132
|
+
const bgAnsi = theme.getBgAnsi?.(key);
|
|
133
|
+
return bgAnsi && parseAnsiRgb(bgAnsi) ? bgAnsi : null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Read themed tool background and update BG_BASE + RST.
|
|
140
|
+
* Recompute on each render so runtime theme changes are respected.
|
|
141
|
+
*
|
|
142
|
+
* Used only to keep ANSI resets blending into the host theme's background
|
|
143
|
+
* on terminals that paint tool rows with a custom bg — the open-right
|
|
144
|
+
* frame itself never relies on a fill. */
|
|
145
|
+
function resolveBaseBackground(theme: BgTheme | null | undefined): void {
|
|
146
|
+
if (!theme?.getBgAnsi) return;
|
|
147
|
+
|
|
148
|
+
BG_BASE = getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
|
|
149
|
+
RST = `\x1b[0m${BG_BASE}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderToolError(error: string, theme: FgTheme): string {
|
|
153
|
+
return frameResult(theme.fg("error", error), "error");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const ESC_RE = "\u001b";
|
|
157
|
+
const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Low-contrast fix (same as pi-diff)
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function isLowContrastShikiFg(params: string): boolean {
|
|
164
|
+
if (params === "30" || params === "90") return true;
|
|
165
|
+
if (params === "38;5;0" || params === "38;5;8") return true;
|
|
166
|
+
if (!params.startsWith("38;2;")) return false;
|
|
167
|
+
const parts = params.split(";").map(Number);
|
|
168
|
+
if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
|
|
169
|
+
const [, , r, g, b] = parts;
|
|
170
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
171
|
+
return luminance < 72;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeShikiContrast(ansi: string): string {
|
|
175
|
+
return ansi.replace(ANSI_CAPTURE_RE, (seq, params: string) => (isLowContrastShikiFg(params) ? FG_MUTED : seq));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Utilities
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function normalizeLineEndings(text: string): string {
|
|
183
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function termW(): number {
|
|
187
|
+
const stderrWithColumns = process.stderr as NodeJS.WriteStream & { columns?: number };
|
|
188
|
+
const raw =
|
|
189
|
+
process.stdout.columns || stderrWithColumns.columns || Number.parseInt(process.env.COLUMNS ?? "", 10) || 200;
|
|
190
|
+
// pi-tui's `Text.render(width)` is called with `terminal.columns` (see
|
|
191
|
+
// pi-tui/dist/tui.js — `const width = this.terminal.columns`). Frames must
|
|
192
|
+
// match that exact width or Text.render pads the line with trailing spaces,
|
|
193
|
+
// which used to leave a visible right-side gap on the open-right frame.
|
|
194
|
+
return Math.max(1, Math.min(raw, 210));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function shortPath(cwd: string, home: string, p: string): string {
|
|
198
|
+
if (!p) return "";
|
|
199
|
+
const r = relative(cwd, p);
|
|
200
|
+
if (!r.startsWith("..") && !r.startsWith("/")) return r;
|
|
201
|
+
return p.replace(home, "~");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function lnum(n: number, w: number): string {
|
|
205
|
+
const v = String(n);
|
|
206
|
+
return `${FG_LNUM}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Frame — open-right rounded box for tool output.
|
|
211
|
+
//
|
|
212
|
+
// Replaces pi core's `toolSuccessBg` fill (the green left rail in the
|
|
213
|
+
// default tool shell) with a status-aware single-line frame that reads
|
|
214
|
+
// the same way as the settings modal in pi-wierd-stuff
|
|
215
|
+
// (`packages/common/settings/frame.ts`), but with the right side left
|
|
216
|
+
// open so long lines fade out naturally:
|
|
217
|
+
//
|
|
218
|
+
// ╭── read /some/file.ext ─────
|
|
219
|
+
// │ 12 lines
|
|
220
|
+
// │ 535 │ const x = 1;
|
|
221
|
+
// │ 536 │ const y = 2;
|
|
222
|
+
// ╰─────────────────────────
|
|
223
|
+
//
|
|
224
|
+
// Tools opt in by setting `renderShell: "self"` on their definition,
|
|
225
|
+
// which tells pi core to skip the colored Box wrapper and let the tool
|
|
226
|
+
// draw its own framing.
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
type FrameStatus = "pending" | "success" | "error";
|
|
230
|
+
|
|
231
|
+
function getFrameStatus(ctx: { isError?: boolean; isPartial?: boolean }): FrameStatus {
|
|
232
|
+
if (ctx.isError) return "error";
|
|
233
|
+
if (ctx.isPartial) return "pending";
|
|
234
|
+
return "success";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function getFrameColor(status: FrameStatus): string {
|
|
238
|
+
switch (status) {
|
|
239
|
+
case "error":
|
|
240
|
+
return FG_RED;
|
|
241
|
+
case "pending":
|
|
242
|
+
return FG_YELLOW;
|
|
243
|
+
default:
|
|
244
|
+
return FG_GREEN;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Inner content width — terminal width minus the `│` left rail (1 col). */
|
|
249
|
+
function bodyW(): number {
|
|
250
|
+
return Math.max(1, termW() - 1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Render the top border of the frame: `╭── <title> ─────`.
|
|
255
|
+
* The title may contain ANSI codes; visible width is measured with pi-tui.
|
|
256
|
+
* Truncated with `…` if it would overflow the terminal width.
|
|
257
|
+
*/
|
|
258
|
+
function frameTop(titleAnsi: string, status: FrameStatus): string {
|
|
259
|
+
const color = getFrameColor(status);
|
|
260
|
+
const w = termW();
|
|
261
|
+
// Layout: `╭` (1) + `──` (2) + ` ` (1) + title + ` ` (1) + trailing dashes (≥1)
|
|
262
|
+
const minTrailing = 1;
|
|
263
|
+
const fixed = 1 + 2 + 1 + 1 + minTrailing;
|
|
264
|
+
const maxTitleW = Math.max(0, w - fixed);
|
|
265
|
+
|
|
266
|
+
let title = titleAnsi;
|
|
267
|
+
let tw = visibleWidth(titleAnsi);
|
|
268
|
+
if (tw > maxTitleW) {
|
|
269
|
+
title = truncateToWidth(titleAnsi, maxTitleW, "…");
|
|
270
|
+
tw = visibleWidth(title);
|
|
271
|
+
}
|
|
272
|
+
const trailing = Math.max(minTrailing, w - 1 - 2 - 1 - tw - 1);
|
|
273
|
+
return `${color}╭──${RST} ${title} ${color}${"─".repeat(trailing)}${RST}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Render the bottom border of the frame: `╰─────────────`. */
|
|
277
|
+
function frameBottom(status: FrameStatus): string {
|
|
278
|
+
const color = getFrameColor(status);
|
|
279
|
+
const w = termW();
|
|
280
|
+
return `${color}╰${"─".repeat(Math.max(1, w - 1))}${RST}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Render the bottom border with an inline label, mirroring `frameTop`:
|
|
285
|
+
* `╰── <label> ──────`. Used by the bash renderer to keep the exit
|
|
286
|
+
* summary attached to the frame instead of as the first body line.
|
|
287
|
+
*/
|
|
288
|
+
function frameBottomWithLabel(labelAnsi: string, status: FrameStatus): string {
|
|
289
|
+
const color = getFrameColor(status);
|
|
290
|
+
const w = termW();
|
|
291
|
+
const minTrailing = 1;
|
|
292
|
+
const fixed = 1 + 2 + 1 + 1 + minTrailing;
|
|
293
|
+
const maxLabelW = Math.max(0, w - fixed);
|
|
294
|
+
|
|
295
|
+
let label = labelAnsi;
|
|
296
|
+
let lw = visibleWidth(labelAnsi);
|
|
297
|
+
if (lw > maxLabelW) {
|
|
298
|
+
label = truncateToWidth(labelAnsi, maxLabelW, "…");
|
|
299
|
+
lw = visibleWidth(label);
|
|
300
|
+
}
|
|
301
|
+
const trailing = Math.max(minTrailing, w - 1 - 2 - 1 - lw - 1);
|
|
302
|
+
return `${color}╰──${RST} ${label} ${color}${"─".repeat(trailing)}${RST}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Prefix every line in `text` with the frame's left rail (`│`).
|
|
307
|
+
* Each logical line is right-truncated to `bodyW()` so the visible width
|
|
308
|
+
* including the rail never exceeds `termW()`.
|
|
309
|
+
*/
|
|
310
|
+
function frameBodyLines(text: string, status: FrameStatus): string {
|
|
311
|
+
const color = getFrameColor(status);
|
|
312
|
+
const rail = `${color}│${RST}`;
|
|
313
|
+
const w = bodyW();
|
|
314
|
+
return text
|
|
315
|
+
.split("\n")
|
|
316
|
+
.map((line) => {
|
|
317
|
+
const fitted = line && visibleWidth(line) > w ? truncateToWidth(line, w, "") : line;
|
|
318
|
+
return `${rail}${fitted}`;
|
|
319
|
+
})
|
|
320
|
+
.join("\n");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Wrap `body` as the result-side of the frame: rail-prefixed lines + bottom border.
|
|
324
|
+
* An empty `body` collapses to just the bottom border, mirroring
|
|
325
|
+
* `frameResultWithBottomLabel` so the frame doesn't show an empty rail row. */
|
|
326
|
+
function frameResult(body: string, status: FrameStatus): string {
|
|
327
|
+
const body_ = body ? `${frameBodyLines(body, status)}\n` : "";
|
|
328
|
+
return `${body_}${frameBottom(status)}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Same as `frameResult` but embeds `label` inline in the bottom border. */
|
|
332
|
+
function frameResultWithBottomLabel(body: string, labelAnsi: string, status: FrameStatus): string {
|
|
333
|
+
const body_ = body ? `${frameBodyLines(body, status)}\n` : "";
|
|
334
|
+
return `${body_}${frameBottomWithLabel(labelAnsi, status)}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Language detection
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
const EXT_LANG: Record<string, BundledLanguage> = {
|
|
342
|
+
ts: "typescript",
|
|
343
|
+
tsx: "tsx",
|
|
344
|
+
js: "javascript",
|
|
345
|
+
jsx: "jsx",
|
|
346
|
+
mjs: "javascript",
|
|
347
|
+
cjs: "javascript",
|
|
348
|
+
py: "python",
|
|
349
|
+
rb: "ruby",
|
|
350
|
+
rs: "rust",
|
|
351
|
+
go: "go",
|
|
352
|
+
java: "java",
|
|
353
|
+
c: "c",
|
|
354
|
+
cpp: "cpp",
|
|
355
|
+
h: "c",
|
|
356
|
+
hpp: "cpp",
|
|
357
|
+
cs: "csharp",
|
|
358
|
+
swift: "swift",
|
|
359
|
+
kt: "kotlin",
|
|
360
|
+
html: "html",
|
|
361
|
+
css: "css",
|
|
362
|
+
scss: "scss",
|
|
363
|
+
less: "css",
|
|
364
|
+
json: "json",
|
|
365
|
+
jsonc: "jsonc",
|
|
366
|
+
yaml: "yaml",
|
|
367
|
+
yml: "yaml",
|
|
368
|
+
toml: "toml",
|
|
369
|
+
md: "markdown",
|
|
370
|
+
mdx: "mdx",
|
|
371
|
+
sql: "sql",
|
|
372
|
+
sh: "bash",
|
|
373
|
+
bash: "bash",
|
|
374
|
+
zsh: "bash",
|
|
375
|
+
lua: "lua",
|
|
376
|
+
php: "php",
|
|
377
|
+
dart: "dart",
|
|
378
|
+
xml: "xml",
|
|
379
|
+
graphql: "graphql",
|
|
380
|
+
svelte: "svelte",
|
|
381
|
+
vue: "vue",
|
|
382
|
+
dockerfile: "dockerfile",
|
|
383
|
+
makefile: "make",
|
|
384
|
+
zig: "zig",
|
|
385
|
+
nim: "nim",
|
|
386
|
+
elixir: "elixir",
|
|
387
|
+
ex: "elixir",
|
|
388
|
+
erb: "erb",
|
|
389
|
+
hbs: "handlebars",
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
function lang(fp: string): BundledLanguage | undefined {
|
|
393
|
+
const base = basename(fp).toLowerCase();
|
|
394
|
+
if (base === "dockerfile") return "dockerfile";
|
|
395
|
+
if (base === "makefile" || base === "gnumakefile") return "make";
|
|
396
|
+
if (base === ".envrc" || base === ".env") return "bash";
|
|
397
|
+
return EXT_LANG[extname(fp).slice(1).toLowerCase()];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Terminal image rendering (iTerm2 / Kitty / Ghostty inline image protocols)
|
|
402
|
+
// Handles tmux passthrough for image protocols.
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
type ImageProtocol = "iterm2" | "kitty" | "none";
|
|
406
|
+
|
|
407
|
+
let _tmuxClientTermCache: string | null | undefined;
|
|
408
|
+
let _tmuxAllowPassthroughCache: boolean | null | undefined;
|
|
409
|
+
let _tmuxClientTermOverrideForTests: string | null | undefined;
|
|
410
|
+
let _tmuxAllowPassthroughOverrideForTests: boolean | null | undefined;
|
|
411
|
+
|
|
412
|
+
function isTmuxSession(): boolean {
|
|
413
|
+
return !!process.env.TMUX || /^(tmux|screen)/.test(process.env.TERM ?? "");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function normalizeTerminalName(term: string): string {
|
|
417
|
+
const t = term.toLowerCase();
|
|
418
|
+
if (t.includes("kitty")) return "kitty";
|
|
419
|
+
if (t.includes("ghostty")) return "ghostty";
|
|
420
|
+
if (t.includes("wezterm")) return "WezTerm";
|
|
421
|
+
if (t.includes("iterm")) return "iTerm.app";
|
|
422
|
+
if (t.includes("mintty")) return "mintty";
|
|
423
|
+
return term;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function readTmuxClientTerm(): string | null {
|
|
427
|
+
if (_tmuxClientTermOverrideForTests !== undefined) {
|
|
428
|
+
return _tmuxClientTermOverrideForTests ? normalizeTerminalName(_tmuxClientTermOverrideForTests) : null;
|
|
429
|
+
}
|
|
430
|
+
if (!isTmuxSession()) return null;
|
|
431
|
+
if (_tmuxClientTermCache !== undefined) return _tmuxClientTermCache;
|
|
432
|
+
try {
|
|
433
|
+
const term = childProcess
|
|
434
|
+
.execFileSync("tmux", ["display-message", "-p", "#{client_termname}"], {
|
|
435
|
+
encoding: "utf8",
|
|
436
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
437
|
+
timeout: 200,
|
|
438
|
+
})
|
|
439
|
+
.trim();
|
|
440
|
+
_tmuxClientTermCache = term ? normalizeTerminalName(term) : null;
|
|
441
|
+
} catch {
|
|
442
|
+
_tmuxClientTermCache = null;
|
|
443
|
+
}
|
|
444
|
+
return _tmuxClientTermCache;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Detect the outer terminal when running inside tmux.
|
|
449
|
+
* tmux sets TERM_PROGRAM=tmux, but the real terminal is often in
|
|
450
|
+
* the environment of the tmux server or can be inferred.
|
|
451
|
+
*/
|
|
452
|
+
function getOuterTerminal(): string {
|
|
453
|
+
// Environment hints that often survive inside tmux
|
|
454
|
+
if (process.env.LC_TERMINAL === "iTerm2") return "iTerm.app";
|
|
455
|
+
if (process.env.GHOSTTY_RESOURCES_DIR) return "ghostty";
|
|
456
|
+
if (process.env.KITTY_WINDOW_ID || process.env.KITTY_PID) return "kitty";
|
|
457
|
+
if (process.env.WEZTERM_EXECUTABLE || process.env.WEZTERM_CONFIG_DIR || process.env.WEZTERM_CONFIG_FILE) {
|
|
458
|
+
return "WezTerm";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const termProgram = process.env.TERM_PROGRAM ?? "";
|
|
462
|
+
if (termProgram && termProgram !== "tmux" && termProgram !== "screen") {
|
|
463
|
+
return normalizeTerminalName(termProgram);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const tmuxClientTerm = readTmuxClientTerm();
|
|
467
|
+
if (tmuxClientTerm) return tmuxClientTerm;
|
|
468
|
+
|
|
469
|
+
const term = process.env.TERM ?? "";
|
|
470
|
+
if (term) return normalizeTerminalName(term);
|
|
471
|
+
if (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit") return "unknown-modern";
|
|
472
|
+
return termProgram;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function detectImageProtocol(): ImageProtocol {
|
|
476
|
+
const forced = (process.env.FACELIFT_IMAGE_PROTOCOL ?? "").toLowerCase();
|
|
477
|
+
if (forced === "kitty" || forced === "iterm2" || forced === "none") {
|
|
478
|
+
return forced;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const term = getOuterTerminal();
|
|
482
|
+
// Ghostty and Kitty use the Kitty graphics protocol
|
|
483
|
+
if (term === "ghostty" || term === "kitty") return "kitty";
|
|
484
|
+
// iTerm2, WezTerm, Mintty support the iTerm2 protocol
|
|
485
|
+
if (["iTerm.app", "WezTerm", "mintty"].includes(term)) return "iterm2";
|
|
486
|
+
if (process.env.LC_TERMINAL === "iTerm2") return "iterm2";
|
|
487
|
+
return "none";
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function tmuxAllowsPassthrough(): boolean | null {
|
|
491
|
+
if (_tmuxAllowPassthroughOverrideForTests !== undefined) return _tmuxAllowPassthroughOverrideForTests;
|
|
492
|
+
if (!isTmuxSession()) return null;
|
|
493
|
+
if (_tmuxAllowPassthroughCache !== undefined) return _tmuxAllowPassthroughCache;
|
|
494
|
+
try {
|
|
495
|
+
const value = childProcess
|
|
496
|
+
.execFileSync("tmux", ["show-options", "-gv", "allow-passthrough"], {
|
|
497
|
+
encoding: "utf8",
|
|
498
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
499
|
+
timeout: 200,
|
|
500
|
+
})
|
|
501
|
+
.trim()
|
|
502
|
+
.toLowerCase();
|
|
503
|
+
_tmuxAllowPassthroughCache = value === "on" || value === "all";
|
|
504
|
+
} catch {
|
|
505
|
+
_tmuxAllowPassthroughCache = null;
|
|
506
|
+
}
|
|
507
|
+
return _tmuxAllowPassthroughCache;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function getTmuxPassthroughWarning(protocol: ImageProtocol): string | null {
|
|
511
|
+
if (!isTmuxSession() || protocol === "none") return null;
|
|
512
|
+
if (tmuxAllowsPassthrough() === false) {
|
|
513
|
+
return "tmux allow-passthrough is off. Run: tmux set -g allow-passthrough on";
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Wrap escape sequence for tmux passthrough.
|
|
520
|
+
* tmux requires: ESC Ptmux; <escaped-sequence> ESC \
|
|
521
|
+
* Inner ESC chars must be doubled.
|
|
522
|
+
*/
|
|
523
|
+
function tmuxWrap(seq: string): string {
|
|
524
|
+
if (!isTmuxSession()) return seq;
|
|
525
|
+
// Double all ESC chars inside the sequence
|
|
526
|
+
const escaped = seq.split("\x1b").join("\x1b\x1b");
|
|
527
|
+
return `\x1bPtmux;${escaped}\x1b\\`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export const __imageInternals = {
|
|
531
|
+
isTmuxSession,
|
|
532
|
+
getOuterTerminal,
|
|
533
|
+
detectImageProtocol,
|
|
534
|
+
tmuxWrap,
|
|
535
|
+
tmuxAllowsPassthrough,
|
|
536
|
+
getTmuxPassthroughWarning,
|
|
537
|
+
setTmuxClientTermOverrideForTests: (value: string | null | undefined) => {
|
|
538
|
+
_tmuxClientTermOverrideForTests = value;
|
|
539
|
+
},
|
|
540
|
+
setTmuxAllowPassthroughOverrideForTests: (value: boolean | null | undefined) => {
|
|
541
|
+
_tmuxAllowPassthroughOverrideForTests = value;
|
|
542
|
+
},
|
|
543
|
+
resetCachesForTests: () => {
|
|
544
|
+
_tmuxClientTermCache = undefined;
|
|
545
|
+
_tmuxAllowPassthroughCache = undefined;
|
|
546
|
+
_tmuxClientTermOverrideForTests = undefined;
|
|
547
|
+
_tmuxAllowPassthroughOverrideForTests = undefined;
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Compact human-readable duration: `3.3s`, `1m3s`, `2h5m`.
|
|
553
|
+
* Mirrors the style requested by the user for the bash bottom-border label.
|
|
554
|
+
*/
|
|
555
|
+
function formatDuration(ms: number): string {
|
|
556
|
+
const totalSec = Math.max(0, ms) / 1000;
|
|
557
|
+
if (totalSec < 60) return `${totalSec.toFixed(1)}s`;
|
|
558
|
+
const totalMin = Math.floor(totalSec / 60);
|
|
559
|
+
const sec = Math.floor(totalSec % 60);
|
|
560
|
+
if (totalMin < 60) return sec > 0 ? `${totalMin}m${sec}s` : `${totalMin}m`;
|
|
561
|
+
const hours = Math.floor(totalMin / 60);
|
|
562
|
+
const min = totalMin % 60;
|
|
563
|
+
return min > 0 ? `${hours}h${min}m` : `${hours}h`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Get human-readable file size
|
|
568
|
+
*/
|
|
569
|
+
function humanSize(bytes: number): string {
|
|
570
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
571
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
572
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// File-type icons — Nerd Font glyphs (Seti-UI + Devicons, stable in NF v3+)
|
|
577
|
+
//
|
|
578
|
+
// Requires a Nerd Font installed (e.g., JetBrainsMono Nerd Font, FiraCode NF).
|
|
579
|
+
// Fallback: set FACELIFT_ICONS=none to disable icons.
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
const ICONS_MODE = (process.env.FACELIFT_ICONS ?? "nerd").toLowerCase();
|
|
583
|
+
const USE_ICONS = ICONS_MODE !== "none" && ICONS_MODE !== "off";
|
|
584
|
+
|
|
585
|
+
// Nerd Font codepoints + ANSI color per file type
|
|
586
|
+
const NF_DIR = `${FG_BLUE}\ue5ff${RST}`; // folder
|
|
587
|
+
const NF_DEFAULT = `${FG_DIM}\uf15b${RST}`; // generic file
|
|
588
|
+
|
|
589
|
+
const EXT_ICON: Record<string, string> = {
|
|
590
|
+
// TypeScript / JavaScript
|
|
591
|
+
ts: `\x1b[38;2;49;120;198m\ue628${RST}`, // blue
|
|
592
|
+
tsx: `\x1b[38;2;49;120;198m\ue7ba${RST}`, // react blue
|
|
593
|
+
js: `\x1b[38;2;241;224;90m\ue74e${RST}`, // yellow
|
|
594
|
+
jsx: `\x1b[38;2;97;218;251m\ue7ba${RST}`, // react cyan
|
|
595
|
+
mjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
|
|
596
|
+
cjs: `\x1b[38;2;241;224;90m\ue74e${RST}`,
|
|
597
|
+
|
|
598
|
+
// Systems / Backend
|
|
599
|
+
py: `\x1b[38;2;55;118;171m\ue73c${RST}`, // python blue
|
|
600
|
+
rs: `\x1b[38;2;222;165;132m\ue7a8${RST}`, // rust orange
|
|
601
|
+
go: `\x1b[38;2;0;173;216m\ue724${RST}`, // go cyan
|
|
602
|
+
java: `\x1b[38;2;204;62;68m\ue738${RST}`, // java red
|
|
603
|
+
swift: `\x1b[38;2;255;172;77m\ue755${RST}`, // swift orange
|
|
604
|
+
rb: `\x1b[38;2;204;52;45m\ue739${RST}`, // ruby red
|
|
605
|
+
kt: `\x1b[38;2;126;103;200m\ue634${RST}`, // kotlin purple
|
|
606
|
+
c: `\x1b[38;2;85;154;211m\ue61e${RST}`, // c blue
|
|
607
|
+
cpp: `\x1b[38;2;85;154;211m\ue61d${RST}`, // cpp blue
|
|
608
|
+
h: `\x1b[38;2;140;160;185m\ue61e${RST}`, // header muted
|
|
609
|
+
hpp: `\x1b[38;2;140;160;185m\ue61d${RST}`,
|
|
610
|
+
cs: `\x1b[38;2;104;33;122m\ue648${RST}`, // c# purple
|
|
611
|
+
|
|
612
|
+
// Web
|
|
613
|
+
html: `\x1b[38;2;228;77;38m\ue736${RST}`, // html orange
|
|
614
|
+
css: `\x1b[38;2;66;165;245m\ue749${RST}`, // css blue
|
|
615
|
+
scss: `\x1b[38;2;207;100;154m\ue749${RST}`, // scss pink
|
|
616
|
+
less: `\x1b[38;2;66;165;245m\ue749${RST}`,
|
|
617
|
+
vue: `\x1b[38;2;65;184;131m\ue6a0${RST}`, // vue green
|
|
618
|
+
svelte: `\x1b[38;2;255;62;0m\ue697${RST}`, // svelte red-orange
|
|
619
|
+
|
|
620
|
+
// Config / Data
|
|
621
|
+
json: `\x1b[38;2;241;224;90m\ue60b${RST}`, // json yellow
|
|
622
|
+
jsonc: `\x1b[38;2;241;224;90m\ue60b${RST}`,
|
|
623
|
+
yaml: `\x1b[38;2;160;116;196m\ue6a8${RST}`, // yaml purple
|
|
624
|
+
yml: `\x1b[38;2;160;116;196m\ue6a8${RST}`,
|
|
625
|
+
toml: `\x1b[38;2;160;116;196m\ue6b2${RST}`, // toml purple
|
|
626
|
+
xml: `\x1b[38;2;228;77;38m\ue619${RST}`, // xml orange
|
|
627
|
+
sql: `\x1b[38;2;218;218;218m\ue706${RST}`, // sql gray
|
|
628
|
+
|
|
629
|
+
// Markdown / Docs
|
|
630
|
+
md: `\x1b[38;2;66;165;245m\ue73e${RST}`, // markdown blue
|
|
631
|
+
mdx: `\x1b[38;2;66;165;245m\ue73e${RST}`,
|
|
632
|
+
|
|
633
|
+
// Shell / Scripts
|
|
634
|
+
sh: `\x1b[38;2;137;180;130m\ue795${RST}`, // shell green
|
|
635
|
+
bash: `\x1b[38;2;137;180;130m\ue795${RST}`,
|
|
636
|
+
zsh: `\x1b[38;2;137;180;130m\ue795${RST}`,
|
|
637
|
+
fish: `\x1b[38;2;137;180;130m\ue795${RST}`,
|
|
638
|
+
lua: `\x1b[38;2;81;160;207m\ue620${RST}`, // lua blue
|
|
639
|
+
php: `\x1b[38;2;137;147;186m\ue73d${RST}`, // php purple
|
|
640
|
+
dart: `\x1b[38;2;87;182;240m\ue798${RST}`, // dart blue
|
|
641
|
+
|
|
642
|
+
// Images
|
|
643
|
+
png: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
644
|
+
jpg: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
645
|
+
jpeg: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
646
|
+
gif: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
647
|
+
svg: `\x1b[38;2;255;180;50m\uf1c5${RST}`,
|
|
648
|
+
webp: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
649
|
+
ico: `\x1b[38;2;160;116;196m\uf1c5${RST}`,
|
|
650
|
+
|
|
651
|
+
// Misc
|
|
652
|
+
lock: `\x1b[38;2;130;130;130m\uf023${RST}`, // lock gray
|
|
653
|
+
env: `\x1b[38;2;241;224;90m\ue615${RST}`, // env yellow
|
|
654
|
+
graphql: `\x1b[38;2;224;51;144m\ue662${RST}`, // graphql pink
|
|
655
|
+
dockerfile: `\x1b[38;2;56;152;236m\ue7b0${RST}`,
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const NAME_ICON: Record<string, string> = {
|
|
659
|
+
"package.json": `\x1b[38;2;137;180;130m\ue71e${RST}`, // npm green
|
|
660
|
+
"package-lock.json": `\x1b[38;2;130;130;130m\ue71e${RST}`, // npm gray
|
|
661
|
+
"tsconfig.json": `\x1b[38;2;49;120;198m\ue628${RST}`, // ts blue
|
|
662
|
+
"biome.json": `\x1b[38;2;96;165;250m\ue615${RST}`, // config blue
|
|
663
|
+
".gitignore": `\x1b[38;2;222;165;132m\ue702${RST}`, // git orange
|
|
664
|
+
".git": `\x1b[38;2;222;165;132m\ue702${RST}`,
|
|
665
|
+
".env": `\x1b[38;2;241;224;90m\ue615${RST}`, // env yellow
|
|
666
|
+
".envrc": `\x1b[38;2;241;224;90m\ue615${RST}`,
|
|
667
|
+
dockerfile: `\x1b[38;2;56;152;236m\ue7b0${RST}`, // docker blue
|
|
668
|
+
makefile: `\x1b[38;2;130;130;130m\ue615${RST}`, // make gray
|
|
669
|
+
gnumakefile: `\x1b[38;2;130;130;130m\ue615${RST}`,
|
|
670
|
+
"readme.md": `\x1b[38;2;66;165;245m\ue73e${RST}`, // readme blue
|
|
671
|
+
license: `\x1b[38;2;218;218;218m\ue60a${RST}`, // license white
|
|
672
|
+
"cargo.toml": `\x1b[38;2;222;165;132m\ue7a8${RST}`, // rust
|
|
673
|
+
"go.mod": `\x1b[38;2;0;173;216m\ue724${RST}`, // go
|
|
674
|
+
"pyproject.toml": `\x1b[38;2;55;118;171m\ue73c${RST}`, // python
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
function fileIcon(fp: string): string {
|
|
678
|
+
if (!USE_ICONS) return "";
|
|
679
|
+
const base = basename(fp).toLowerCase();
|
|
680
|
+
if (NAME_ICON[base]) return `${NAME_ICON[base]} `;
|
|
681
|
+
const ext = extname(fp).slice(1).toLowerCase();
|
|
682
|
+
return EXT_ICON[ext] ? `${EXT_ICON[ext]} ` : `${NF_DEFAULT} `;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function dirIcon(): string {
|
|
686
|
+
return USE_ICONS ? `${NF_DIR} ` : "";
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// Shiki ANSI cache
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
// Pre-warm
|
|
694
|
+
codeToANSI("", "typescript", THEME).catch(() => {});
|
|
695
|
+
|
|
696
|
+
const _cache = new Map<string, string[]>();
|
|
697
|
+
|
|
698
|
+
function _touch(k: string, v: string[]): string[] {
|
|
699
|
+
_cache.delete(k);
|
|
700
|
+
_cache.set(k, v);
|
|
701
|
+
while (_cache.size > CACHE_LIMIT) {
|
|
702
|
+
const first = _cache.keys().next().value;
|
|
703
|
+
if (first === undefined) break;
|
|
704
|
+
_cache.delete(first);
|
|
705
|
+
}
|
|
706
|
+
return v;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
|
|
710
|
+
if (!code) return [""];
|
|
711
|
+
if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
|
|
712
|
+
|
|
713
|
+
const k = `${THEME}\0${language}\0${code}`;
|
|
714
|
+
const hit = _cache.get(k);
|
|
715
|
+
if (hit) return _touch(k, hit);
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME));
|
|
719
|
+
const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
|
|
720
|
+
return _touch(k, out);
|
|
721
|
+
} catch {
|
|
722
|
+
return code.split("\n");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
// Renderers
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
/** Render syntax-highlighted file content with line numbers. */
|
|
731
|
+
async function renderFileContent(
|
|
732
|
+
content: string,
|
|
733
|
+
filePath: string,
|
|
734
|
+
offset = 1,
|
|
735
|
+
maxLines = MAX_PREVIEW_LINES,
|
|
736
|
+
): Promise<string> {
|
|
737
|
+
const normalizedContent = normalizeLineEndings(content);
|
|
738
|
+
const lines = normalizedContent.split("\n");
|
|
739
|
+
const total = lines.length;
|
|
740
|
+
const show = lines.slice(0, maxLines);
|
|
741
|
+
const lg = lang(filePath);
|
|
742
|
+
const hl = await hlBlock(show.join("\n"), lg);
|
|
743
|
+
|
|
744
|
+
// Reserve 2 cols for the outer frame's `│ ` rail — rendered by frameBodyLines.
|
|
745
|
+
const tw = bodyW();
|
|
746
|
+
const startLine = offset;
|
|
747
|
+
const endLine = startLine + show.length - 1;
|
|
748
|
+
const nw = Math.max(3, String(endLine).length);
|
|
749
|
+
const gw = nw + 3; // num + " │ "
|
|
750
|
+
const cw = Math.max(1, tw - gw);
|
|
751
|
+
|
|
752
|
+
const out: string[] = [];
|
|
753
|
+
|
|
754
|
+
for (let i = 0; i < hl.length; i++) {
|
|
755
|
+
const ln = startLine + i;
|
|
756
|
+
const code = hl[i] ?? show[i] ?? "";
|
|
757
|
+
const display = truncateToWidth(code, cw, `${FG_DIM}›`);
|
|
758
|
+
out.push(`${lnum(ln, nw)} ${FG_RULE}│${RST} ${display}${RST}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (total > maxLines) {
|
|
762
|
+
out.push(`${FG_DIM} … ${total - maxLines} more lines (${total} total)${RST}`);
|
|
763
|
+
}
|
|
764
|
+
return out.join("\n");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Render ls output as a tree view with icons. */
|
|
768
|
+
function renderTree(text: string, _basePath: string): string {
|
|
769
|
+
const lines = text.trim().split("\n").filter(Boolean);
|
|
770
|
+
if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
|
|
771
|
+
|
|
772
|
+
const out: string[] = [];
|
|
773
|
+
const total = lines.length;
|
|
774
|
+
const show = lines.slice(0, MAX_PREVIEW_LINES);
|
|
775
|
+
|
|
776
|
+
for (let i = 0; i < show.length; i++) {
|
|
777
|
+
const entry = show[i].trim();
|
|
778
|
+
const isLast = i === show.length - 1 && total <= MAX_PREVIEW_LINES;
|
|
779
|
+
const prefix = isLast ? "└── " : "├── ";
|
|
780
|
+
const connector = `${FG_RULE}${prefix}${RST}`;
|
|
781
|
+
|
|
782
|
+
// Detect directories (entries ending with /)
|
|
783
|
+
const isDir = entry.endsWith("/");
|
|
784
|
+
const name = isDir ? entry.slice(0, -1) : entry;
|
|
785
|
+
const icon = isDir ? dirIcon() : fileIcon(name);
|
|
786
|
+
const fg = isDir ? FG_BLUE + BOLD : "";
|
|
787
|
+
const reset = isDir ? RST : "";
|
|
788
|
+
|
|
789
|
+
out.push(`${connector}${icon}${fg}${name}${reset}`);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (total > MAX_PREVIEW_LINES) {
|
|
793
|
+
out.push(`${FG_RULE}└── ${RST}${FG_DIM}… ${total - MAX_PREVIEW_LINES} more entries${RST}`);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return out.join("\n");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Render find results grouped by directory with icons. */
|
|
800
|
+
function renderFindResults(text: string): string {
|
|
801
|
+
const lines = text.trim().split("\n").filter(Boolean);
|
|
802
|
+
if (!lines.length) return `${FG_DIM}(no matches)${RST}`;
|
|
803
|
+
|
|
804
|
+
// Group by directory
|
|
805
|
+
const groups = new Map<string, string[]>();
|
|
806
|
+
for (const line of lines) {
|
|
807
|
+
const trimmed = line.trim();
|
|
808
|
+
const dir = dirname(trimmed) || ".";
|
|
809
|
+
const file = basename(trimmed);
|
|
810
|
+
if (!groups.has(dir)) groups.set(dir, []);
|
|
811
|
+
const bucket = groups.get(dir);
|
|
812
|
+
if (bucket) bucket.push(file);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const out: string[] = [];
|
|
816
|
+
let count = 0;
|
|
817
|
+
|
|
818
|
+
for (const [dir, files] of groups) {
|
|
819
|
+
if (count > 0) out.push(""); // blank line between groups
|
|
820
|
+
out.push(`${dirIcon()}${FG_BLUE}${BOLD}${dir}/${RST}`);
|
|
821
|
+
for (let i = 0; i < files.length; i++) {
|
|
822
|
+
if (count >= MAX_PREVIEW_LINES) {
|
|
823
|
+
out.push(` ${FG_DIM}… ${lines.length - count} more files${RST}`);
|
|
824
|
+
return out.join("\n");
|
|
825
|
+
}
|
|
826
|
+
const isLast = i === files.length - 1;
|
|
827
|
+
const prefix = isLast ? "└── " : "├── ";
|
|
828
|
+
const icon = fileIcon(files[i]);
|
|
829
|
+
out.push(` ${FG_RULE}${prefix}${RST}${icon}${files[i]}`);
|
|
830
|
+
count++;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return out.join("\n");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Render grep results with highlighted matches and line numbers. */
|
|
838
|
+
async function renderGrepResults(text: string, pattern: string): Promise<string> {
|
|
839
|
+
const lines = normalizeLineEndings(text).split("\n");
|
|
840
|
+
if (!lines.length || (lines.length === 1 && !lines[0].trim())) return `${FG_DIM}(no matches)${RST}`;
|
|
841
|
+
|
|
842
|
+
const out: string[] = [];
|
|
843
|
+
let currentFile = "";
|
|
844
|
+
let count = 0;
|
|
845
|
+
|
|
846
|
+
// Try to build a regex for highlighting
|
|
847
|
+
let re: RegExp | null = null;
|
|
848
|
+
try {
|
|
849
|
+
re = new RegExp(`(${pattern})`, "gi");
|
|
850
|
+
} catch {
|
|
851
|
+
// invalid regex — skip highlighting
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
for (const line of lines) {
|
|
855
|
+
if (count >= MAX_PREVIEW_LINES) {
|
|
856
|
+
out.push(`${FG_DIM} … more matches${RST}`);
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ripgrep-style: "file:line:content" or "file-line-content" or just "file"
|
|
861
|
+
const fileMatch = line.match(/^(.+?)[:-](\d+)[:-](.*)$/);
|
|
862
|
+
if (fileMatch) {
|
|
863
|
+
const [, file, lineNo, content] = fileMatch;
|
|
864
|
+
if (file !== currentFile) {
|
|
865
|
+
if (currentFile) out.push(""); // blank line between files
|
|
866
|
+
const icon = fileIcon(file);
|
|
867
|
+
out.push(`${icon}${FG_BLUE}${BOLD}${file}${RST}`);
|
|
868
|
+
currentFile = file;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const nw = Math.max(3, lineNo.length);
|
|
872
|
+
let display = content;
|
|
873
|
+
if (re) {
|
|
874
|
+
display = content.replace(re, `${RST}${FG_YELLOW}${BOLD}$1${RST}`);
|
|
875
|
+
}
|
|
876
|
+
out.push(` ${lnum(Number(lineNo), nw)} ${FG_RULE}│${RST} ${display}${RST}`);
|
|
877
|
+
count++;
|
|
878
|
+
} else if (line.trim() === "--") {
|
|
879
|
+
// ripgrep separator
|
|
880
|
+
out.push(` ${FG_DIM} ···${RST}`);
|
|
881
|
+
} else if (line.trim()) {
|
|
882
|
+
out.push(line);
|
|
883
|
+
count++;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return out.join("\n");
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ---------------------------------------------------------------------------
|
|
891
|
+
// SDK tool wiring — shared types + helpers for the wrapped read/bash/ls/find/grep
|
|
892
|
+
// tool definitions registered below.
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
|
|
895
|
+
type ToolTextContent = TextContent;
|
|
896
|
+
type ToolImageContent = ImageContent;
|
|
897
|
+
type ToolContent = TextContent | ImageContent;
|
|
898
|
+
type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
|
|
899
|
+
type TextComponentLike = { setText(value: string): void; getText?: () => string };
|
|
900
|
+
type TextComponentCtor = new (text?: string, x?: number, y?: number) => TextComponentLike;
|
|
901
|
+
type ThemeLike = BgTheme & FgTheme & { bold: (text: string) => string };
|
|
902
|
+
type RenderContextLike<TState extends Record<string, unknown> = Record<string, unknown>> = {
|
|
903
|
+
lastComponent?: TextComponentLike;
|
|
904
|
+
state: TState;
|
|
905
|
+
expanded: boolean;
|
|
906
|
+
isError: boolean;
|
|
907
|
+
/** Streaming/pending flag from pi core's ToolRenderContext. Optional in tests. */
|
|
908
|
+
isPartial?: boolean;
|
|
909
|
+
/** Whether `execute()` has actually started (true once pi calls markExecutionStarted). */
|
|
910
|
+
executionStarted?: boolean;
|
|
911
|
+
invalidate: () => void;
|
|
912
|
+
};
|
|
913
|
+
type SessionContextLike = ExtensionContext;
|
|
914
|
+
type CommandContextLike = ExtensionCommandContext;
|
|
915
|
+
type ToolExecutor<TParams, TDetails = unknown> = (
|
|
916
|
+
toolCallId: string,
|
|
917
|
+
params: TParams,
|
|
918
|
+
signal?: AbortSignal,
|
|
919
|
+
onUpdate?: AgentToolUpdateCallback<TDetails | undefined>,
|
|
920
|
+
ctx?: ExtensionContext,
|
|
921
|
+
) => Promise<ToolResultLike<TDetails>>;
|
|
922
|
+
type ToolFactory<TParams, TDetails = unknown> = (cwd: string) => {
|
|
923
|
+
name?: string;
|
|
924
|
+
description?: string;
|
|
925
|
+
label?: string;
|
|
926
|
+
parameters?: unknown;
|
|
927
|
+
execute: ToolExecutor<TParams, TDetails>;
|
|
928
|
+
};
|
|
929
|
+
type PiFaceliftSdk = {
|
|
930
|
+
createReadToolDefinition?: ToolFactory<ReadToolInput>;
|
|
931
|
+
createReadTool?: ToolFactory<ReadToolInput>;
|
|
932
|
+
createBashToolDefinition?: ToolFactory<BashToolInput>;
|
|
933
|
+
createBashTool?: ToolFactory<BashToolInput>;
|
|
934
|
+
createLsToolDefinition?: ToolFactory<LsToolInput>;
|
|
935
|
+
createLsTool?: ToolFactory<LsToolInput>;
|
|
936
|
+
createFindToolDefinition?: ToolFactory<FindToolInput>;
|
|
937
|
+
createFindTool?: ToolFactory<FindToolInput>;
|
|
938
|
+
createGrepToolDefinition?: ToolFactory<GrepToolInput>;
|
|
939
|
+
createGrepTool?: ToolFactory<GrepToolInput>;
|
|
940
|
+
getAgentDir?: () => string;
|
|
941
|
+
};
|
|
942
|
+
type PiFaceliftApi = {
|
|
943
|
+
registerTool: (tool: unknown) => void;
|
|
944
|
+
registerCommand: (
|
|
945
|
+
name: string,
|
|
946
|
+
command: {
|
|
947
|
+
description?: string;
|
|
948
|
+
handler: (args: string, ctx: CommandContextLike) => Promise<void> | void;
|
|
949
|
+
},
|
|
950
|
+
) => void;
|
|
951
|
+
on: (event: string, handler: (event: unknown, ctx: SessionContextLike) => Promise<void> | void) => void;
|
|
952
|
+
};
|
|
953
|
+
type ReadParams = ReadToolInput;
|
|
954
|
+
type BashParams = BashToolInput;
|
|
955
|
+
type LsParams = LsToolInput;
|
|
956
|
+
type FindParams = FindToolInput;
|
|
957
|
+
type GrepParams = GrepToolInput;
|
|
958
|
+
type ReadRenderState = { _rk?: string; _rt?: string };
|
|
959
|
+
type GrepRenderState = { _gk?: string; _gt?: string };
|
|
960
|
+
type BashRenderState = {
|
|
961
|
+
startedAt?: number;
|
|
962
|
+
endedAt?: number;
|
|
963
|
+
interval?: NodeJS.Timeout;
|
|
964
|
+
};
|
|
965
|
+
type FindResultDetails = { _type: "findResult"; text: string; pattern: string; matchCount: number };
|
|
966
|
+
type GrepResultDetails = { _type: "grepResult"; text: string; pattern: string; matchCount: number };
|
|
967
|
+
type RenderDetails =
|
|
968
|
+
| { _type: "readImage"; filePath: string; data: string; mimeType: string }
|
|
969
|
+
| { _type: "readFile"; filePath: string; content: string; offset: number; lineCount: number }
|
|
970
|
+
| { _type: "bashResult"; text: string; exitCode: number | null; command: string }
|
|
971
|
+
| { _type: "lsResult"; text: string; path: string; entryCount: number }
|
|
972
|
+
| FindResultDetails
|
|
973
|
+
| GrepResultDetails;
|
|
974
|
+
|
|
975
|
+
function isTextContent(content: ToolContent): content is ToolTextContent {
|
|
976
|
+
return content.type === "text";
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function isImageContent(content: ToolContent): content is ToolImageContent {
|
|
980
|
+
return content.type === "image";
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function getTextContent(result: ToolResultLike): string {
|
|
984
|
+
return (
|
|
985
|
+
result.content
|
|
986
|
+
?.filter(isTextContent)
|
|
987
|
+
.map((content) => content.text || "")
|
|
988
|
+
.join("\n") ?? ""
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function setResultDetails<T>(result: ToolResultLike, details: T): void {
|
|
993
|
+
result.details = details;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// ---------------------------------------------------------------------------
|
|
997
|
+
// Extension entry point
|
|
998
|
+
// ---------------------------------------------------------------------------
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Dependencies that can be injected for testing.
|
|
1002
|
+
* In production, omit `deps` — the extension uses require() to load them.
|
|
1003
|
+
*/
|
|
1004
|
+
export interface PiFaceliftDeps {
|
|
1005
|
+
sdk: PiFaceliftSdk;
|
|
1006
|
+
TextComponent: TextComponentCtor;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFaceliftDeps): void {
|
|
1010
|
+
let createReadTool: ToolFactory<ReadToolInput> | undefined;
|
|
1011
|
+
let createBashTool: ToolFactory<BashToolInput> | undefined;
|
|
1012
|
+
let createLsTool: ToolFactory<LsToolInput> | undefined;
|
|
1013
|
+
let createFindTool: ToolFactory<FindToolInput> | undefined;
|
|
1014
|
+
let createGrepTool: ToolFactory<GrepToolInput> | undefined;
|
|
1015
|
+
let TextComponent: TextComponentCtor;
|
|
1016
|
+
|
|
1017
|
+
let sdk: PiFaceliftSdk;
|
|
1018
|
+
|
|
1019
|
+
if (deps) {
|
|
1020
|
+
// Test path: use injected dependencies
|
|
1021
|
+
sdk = deps.sdk;
|
|
1022
|
+
createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
|
|
1023
|
+
createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
|
|
1024
|
+
createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
|
|
1025
|
+
createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
|
|
1026
|
+
createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
|
|
1027
|
+
TextComponent = deps.TextComponent;
|
|
1028
|
+
} else {
|
|
1029
|
+
try {
|
|
1030
|
+
sdk = require("@earendil-works/pi-coding-agent");
|
|
1031
|
+
createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
|
|
1032
|
+
createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
|
|
1033
|
+
createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
|
|
1034
|
+
createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
|
|
1035
|
+
createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
|
|
1036
|
+
TextComponent = require("@earendil-works/pi-tui").Text;
|
|
1037
|
+
} catch {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (!createReadTool || !TextComponent) return;
|
|
1042
|
+
|
|
1043
|
+
const cwd = process.cwd();
|
|
1044
|
+
const home = process.env.HOME ?? "";
|
|
1045
|
+
const sp = (p: string) => shortPath(cwd, home, p);
|
|
1046
|
+
|
|
1047
|
+
const getAgentDir = sdk.getAgentDir;
|
|
1048
|
+
setPrettyTheme(
|
|
1049
|
+
(() => {
|
|
1050
|
+
try {
|
|
1051
|
+
return getAgentDir?.() ?? getDefaultAgentDir();
|
|
1052
|
+
} catch {
|
|
1053
|
+
return getDefaultAgentDir();
|
|
1054
|
+
}
|
|
1055
|
+
})(),
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
// ===================================================================
|
|
1059
|
+
// read — syntax-highlighted file content
|
|
1060
|
+
// ===================================================================
|
|
1061
|
+
|
|
1062
|
+
const origRead = createReadTool(cwd);
|
|
1063
|
+
|
|
1064
|
+
pi.registerTool({
|
|
1065
|
+
...origRead,
|
|
1066
|
+
name: "read",
|
|
1067
|
+
renderShell: "self",
|
|
1068
|
+
|
|
1069
|
+
async execute(
|
|
1070
|
+
tid: string,
|
|
1071
|
+
params: ReadParams,
|
|
1072
|
+
sig: AbortSignal | undefined,
|
|
1073
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
1074
|
+
ctx: ExtensionContext,
|
|
1075
|
+
) {
|
|
1076
|
+
const result = (await origRead.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
1077
|
+
|
|
1078
|
+
const fp = params.path ?? "";
|
|
1079
|
+
const offset = params.offset ?? 1;
|
|
1080
|
+
|
|
1081
|
+
const imageBlock = result.content?.find(isImageContent);
|
|
1082
|
+
if (imageBlock) {
|
|
1083
|
+
setResultDetails(result, {
|
|
1084
|
+
_type: "readImage",
|
|
1085
|
+
filePath: fp,
|
|
1086
|
+
data: imageBlock.data,
|
|
1087
|
+
mimeType: imageBlock.mimeType ?? "image/png",
|
|
1088
|
+
});
|
|
1089
|
+
return result;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const textContent = getTextContent(result);
|
|
1093
|
+
if (textContent && fp) {
|
|
1094
|
+
const normalizedContent = normalizeLineEndings(textContent);
|
|
1095
|
+
const lineCount = normalizedContent.split("\n").length;
|
|
1096
|
+
setResultDetails(result, {
|
|
1097
|
+
_type: "readFile",
|
|
1098
|
+
filePath: fp,
|
|
1099
|
+
content: normalizedContent,
|
|
1100
|
+
offset,
|
|
1101
|
+
lineCount,
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return result;
|
|
1106
|
+
},
|
|
1107
|
+
|
|
1108
|
+
renderCall(args: ReadParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1109
|
+
resolveBaseBackground(theme);
|
|
1110
|
+
const fp = args.path ?? "";
|
|
1111
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1112
|
+
const offset = args.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
|
|
1113
|
+
const limit = args.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
|
|
1114
|
+
const title = `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`;
|
|
1115
|
+
text.setText(frameTop(title, getFrameStatus(ctx)));
|
|
1116
|
+
return text;
|
|
1117
|
+
},
|
|
1118
|
+
|
|
1119
|
+
renderResult(
|
|
1120
|
+
result: ToolResultLike,
|
|
1121
|
+
_opt: ToolRenderResultOptions,
|
|
1122
|
+
theme: ThemeLike,
|
|
1123
|
+
ctx: RenderContextLike<ReadRenderState>,
|
|
1124
|
+
) {
|
|
1125
|
+
resolveBaseBackground(theme);
|
|
1126
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1127
|
+
const status = getFrameStatus(ctx);
|
|
1128
|
+
|
|
1129
|
+
if (ctx.isError) {
|
|
1130
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
1131
|
+
return text;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const d = result.details as RenderDetails | undefined;
|
|
1135
|
+
|
|
1136
|
+
// Image reads keep the original image content so Pi's native TUI renderer
|
|
1137
|
+
// can display it exactly once. pi-facelift only renders metadata here;
|
|
1138
|
+
// rendering another inline image caused duplicate previews.
|
|
1139
|
+
if (d?._type === "readImage") {
|
|
1140
|
+
const byteSize = Math.ceil(((d.data as string).length * 3) / 4);
|
|
1141
|
+
const sizeStr = humanSize(byteSize);
|
|
1142
|
+
const mimeStr = d.mimeType ?? "image";
|
|
1143
|
+
|
|
1144
|
+
text.setText(frameResult(`${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`, status));
|
|
1145
|
+
return text;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (d?._type === "readFile" && d.content) {
|
|
1149
|
+
const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}:${status}`;
|
|
1150
|
+
if (ctx.state._rk !== key) {
|
|
1151
|
+
ctx.state._rk = key;
|
|
1152
|
+
// Initial render: just the frame chrome until shiki resolves.
|
|
1153
|
+
// Once `renderFileContent` returns we re-cache with the highlighted body.
|
|
1154
|
+
ctx.state._rt = frameResult("", status);
|
|
1155
|
+
|
|
1156
|
+
const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
|
|
1157
|
+
renderFileContent(d.content, d.filePath, d.offset, maxShow)
|
|
1158
|
+
.then((rendered: string) => {
|
|
1159
|
+
if (ctx.state._rk !== key) return;
|
|
1160
|
+
ctx.state._rt = frameResult(rendered, status);
|
|
1161
|
+
ctx.invalidate();
|
|
1162
|
+
})
|
|
1163
|
+
.catch(() => {});
|
|
1164
|
+
}
|
|
1165
|
+
text.setText(ctx.state._rt ?? frameResult("", status));
|
|
1166
|
+
return text;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Fallback
|
|
1170
|
+
const fallback = result.content?.[0];
|
|
1171
|
+
const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "read";
|
|
1172
|
+
text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
|
|
1173
|
+
return text;
|
|
1174
|
+
},
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// ===================================================================
|
|
1178
|
+
// bash — colored exit status
|
|
1179
|
+
// ===================================================================
|
|
1180
|
+
|
|
1181
|
+
if (createBashTool) {
|
|
1182
|
+
const origBash = createBashTool(cwd);
|
|
1183
|
+
|
|
1184
|
+
pi.registerTool({
|
|
1185
|
+
...origBash,
|
|
1186
|
+
name: "bash",
|
|
1187
|
+
renderShell: "self",
|
|
1188
|
+
|
|
1189
|
+
async execute(
|
|
1190
|
+
tid: string,
|
|
1191
|
+
params: BashParams,
|
|
1192
|
+
sig: AbortSignal | undefined,
|
|
1193
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
1194
|
+
ctx: ExtensionContext,
|
|
1195
|
+
) {
|
|
1196
|
+
const result = (await origBash.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
1197
|
+
const textContent = getTextContent(result);
|
|
1198
|
+
|
|
1199
|
+
let exitCode: number | null = 0;
|
|
1200
|
+
if (textContent) {
|
|
1201
|
+
const exitMatch = textContent.match(/(?:exit code|exited with|exit status)[:\s]*(\d+)/i);
|
|
1202
|
+
if (exitMatch) exitCode = Number(exitMatch[1]);
|
|
1203
|
+
if (textContent.includes("command not found") || textContent.includes("No such file")) {
|
|
1204
|
+
exitCode = 1;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
setResultDetails(result, {
|
|
1209
|
+
_type: "bashResult",
|
|
1210
|
+
text: textContent ?? "",
|
|
1211
|
+
exitCode,
|
|
1212
|
+
command: params.command ?? "",
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
return result;
|
|
1216
|
+
},
|
|
1217
|
+
|
|
1218
|
+
renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike<BashRenderState>) {
|
|
1219
|
+
resolveBaseBackground(theme);
|
|
1220
|
+
const cmd = args.command ?? "";
|
|
1221
|
+
const t = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1222
|
+
const state = ctx.state;
|
|
1223
|
+
if (ctx.executionStarted && state.startedAt === undefined) {
|
|
1224
|
+
state.startedAt = Date.now();
|
|
1225
|
+
state.endedAt = undefined;
|
|
1226
|
+
}
|
|
1227
|
+
const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
|
|
1228
|
+
const displayCmd = ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
|
|
1229
|
+
const title = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`;
|
|
1230
|
+
t.setText(frameTop(title, getFrameStatus(ctx)));
|
|
1231
|
+
return t;
|
|
1232
|
+
},
|
|
1233
|
+
|
|
1234
|
+
renderResult(
|
|
1235
|
+
result: ToolResultLike,
|
|
1236
|
+
opt: ToolRenderResultOptions,
|
|
1237
|
+
theme: ThemeLike,
|
|
1238
|
+
ctx: RenderContextLike<BashRenderState>,
|
|
1239
|
+
) {
|
|
1240
|
+
resolveBaseBackground(theme);
|
|
1241
|
+
const t = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1242
|
+
const status = getFrameStatus(ctx);
|
|
1243
|
+
const state = ctx.state;
|
|
1244
|
+
|
|
1245
|
+
// Live timer: tick every second while still streaming, freeze on final/error.
|
|
1246
|
+
const stillRunning = !!opt.isPartial && !ctx.isError;
|
|
1247
|
+
if (state.startedAt !== undefined && stillRunning && !state.interval) {
|
|
1248
|
+
state.interval = setInterval(() => ctx.invalidate(), 1000);
|
|
1249
|
+
}
|
|
1250
|
+
if (!stillRunning) {
|
|
1251
|
+
if (state.startedAt !== undefined) state.endedAt ??= Date.now();
|
|
1252
|
+
if (state.interval) {
|
|
1253
|
+
clearInterval(state.interval);
|
|
1254
|
+
state.interval = undefined;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Resolve the body text + status (works for success, error, streaming, fallback).
|
|
1259
|
+
const d = result.details as RenderDetails | undefined;
|
|
1260
|
+
let bodyText = d?._type === "bashResult" ? d.text : (getTextContent(result) ?? "");
|
|
1261
|
+
let exitCode: number | null = d?._type === "bashResult" ? d.exitCode : null;
|
|
1262
|
+
let timedOut = false;
|
|
1263
|
+
let aborted = false;
|
|
1264
|
+
|
|
1265
|
+
// pi's bash appends `Command exited with code N` / `Command timed out…` /
|
|
1266
|
+
// `Command aborted` to the result text on failure. Lift that line into the
|
|
1267
|
+
// bottom-border label so the body stays clean and the summary is consistent.
|
|
1268
|
+
const tail = bodyText.match(
|
|
1269
|
+
/(?:\n\n|^)Command (?:exited with code (\d+)|timed out after \d+ seconds|aborted)\s*$/,
|
|
1270
|
+
);
|
|
1271
|
+
if (tail) {
|
|
1272
|
+
if (tail[1]) exitCode = Number(tail[1]);
|
|
1273
|
+
else if (/timed out/.test(tail[0])) timedOut = true;
|
|
1274
|
+
else if (/aborted/.test(tail[0])) aborted = true;
|
|
1275
|
+
bodyText = bodyText.slice(0, tail.index);
|
|
1276
|
+
}
|
|
1277
|
+
if (exitCode === null && !opt.isPartial && !ctx.isError) exitCode = 0;
|
|
1278
|
+
|
|
1279
|
+
// Build the bottom-border label: `<duration> <icon> exit <N> (<lines>)`.
|
|
1280
|
+
const elapsedMs = state.startedAt !== undefined ? (state.endedAt ?? Date.now()) - state.startedAt : undefined;
|
|
1281
|
+
const durationStr = elapsedMs !== undefined ? `${FG_DIM}${formatDuration(elapsedMs)}${RST}` : "";
|
|
1282
|
+
|
|
1283
|
+
let summary = "";
|
|
1284
|
+
if (!opt.isPartial) {
|
|
1285
|
+
if (timedOut) summary = `${FG_YELLOW}⚡ timed out${RST}`;
|
|
1286
|
+
else if (aborted) summary = `${FG_YELLOW}⚡ aborted${RST}`;
|
|
1287
|
+
else if (exitCode !== null) {
|
|
1288
|
+
const isOk = exitCode === 0;
|
|
1289
|
+
summary = `${isOk ? FG_GREEN : FG_RED}${isOk ? "✓" : "✗"} exit ${exitCode}${RST}`;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const lines = bodyText.length > 0 ? bodyText.split("\n") : [];
|
|
1294
|
+
const lineCount = lines.length;
|
|
1295
|
+
const lineInfo = summary && lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST}` : "";
|
|
1296
|
+
const labelParts = [durationStr, summary].filter(Boolean);
|
|
1297
|
+
const label = labelParts.length ? `${labelParts.join(" ")}${lineInfo}` : "";
|
|
1298
|
+
|
|
1299
|
+
// Render body lines (preview-truncated when collapsed).
|
|
1300
|
+
const maxShow = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
|
|
1301
|
+
const show = lines.slice(0, Math.max(0, maxShow));
|
|
1302
|
+
const out: string[] = [...show];
|
|
1303
|
+
if (lineCount > maxShow) {
|
|
1304
|
+
out.push(`${FG_DIM}… ${lineCount - maxShow} more lines${RST}`);
|
|
1305
|
+
}
|
|
1306
|
+
const body = out.join("\n");
|
|
1307
|
+
|
|
1308
|
+
if (label) {
|
|
1309
|
+
t.setText(frameResultWithBottomLabel(body, label, status));
|
|
1310
|
+
} else {
|
|
1311
|
+
t.setText(frameResult(body, status));
|
|
1312
|
+
}
|
|
1313
|
+
return t;
|
|
1314
|
+
},
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// ===================================================================
|
|
1319
|
+
// ls — tree view with icons
|
|
1320
|
+
// ===================================================================
|
|
1321
|
+
|
|
1322
|
+
if (createLsTool) {
|
|
1323
|
+
const origLs = createLsTool(cwd);
|
|
1324
|
+
|
|
1325
|
+
pi.registerTool({
|
|
1326
|
+
...origLs,
|
|
1327
|
+
name: "ls",
|
|
1328
|
+
renderShell: "self",
|
|
1329
|
+
|
|
1330
|
+
async execute(
|
|
1331
|
+
tid: string,
|
|
1332
|
+
params: LsParams,
|
|
1333
|
+
sig: AbortSignal | undefined,
|
|
1334
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
1335
|
+
ctx: ExtensionContext,
|
|
1336
|
+
) {
|
|
1337
|
+
const result = (await origLs.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
1338
|
+
const textContent = getTextContent(result);
|
|
1339
|
+
const fp = params.path ?? cwd;
|
|
1340
|
+
const entryCount = textContent ? textContent.trim().split("\n").filter(Boolean).length : 0;
|
|
1341
|
+
|
|
1342
|
+
setResultDetails(result, {
|
|
1343
|
+
_type: "lsResult",
|
|
1344
|
+
text: textContent ?? "",
|
|
1345
|
+
path: fp,
|
|
1346
|
+
entryCount,
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
return result;
|
|
1350
|
+
},
|
|
1351
|
+
|
|
1352
|
+
renderCall(args: LsParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1353
|
+
resolveBaseBackground(theme);
|
|
1354
|
+
const fp = args.path ?? ".";
|
|
1355
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1356
|
+
const title = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`;
|
|
1357
|
+
text.setText(frameTop(title, getFrameStatus(ctx)));
|
|
1358
|
+
return text;
|
|
1359
|
+
},
|
|
1360
|
+
|
|
1361
|
+
renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1362
|
+
resolveBaseBackground(theme);
|
|
1363
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1364
|
+
const status = getFrameStatus(ctx);
|
|
1365
|
+
|
|
1366
|
+
if (ctx.isError) {
|
|
1367
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
1368
|
+
return text;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const d = result.details as RenderDetails | undefined;
|
|
1372
|
+
if (d?._type === "lsResult" && d.text) {
|
|
1373
|
+
const tree = renderTree(d.text, d.path);
|
|
1374
|
+
const info = `${FG_DIM}${d.entryCount} entries${RST}`;
|
|
1375
|
+
text.setText(frameResultWithBottomLabel(tree, info, status));
|
|
1376
|
+
return text;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const fallback = result.content?.[0];
|
|
1380
|
+
const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "listed";
|
|
1381
|
+
text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
|
|
1382
|
+
return text;
|
|
1383
|
+
},
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// ===================================================================
|
|
1388
|
+
// find — grouped file list with icons
|
|
1389
|
+
// ===================================================================
|
|
1390
|
+
|
|
1391
|
+
if (createFindTool) {
|
|
1392
|
+
const origFind = createFindTool(cwd);
|
|
1393
|
+
|
|
1394
|
+
pi.registerTool({
|
|
1395
|
+
...origFind,
|
|
1396
|
+
name: "find",
|
|
1397
|
+
renderShell: "self",
|
|
1398
|
+
|
|
1399
|
+
async execute(
|
|
1400
|
+
tid: string,
|
|
1401
|
+
params: FindParams,
|
|
1402
|
+
sig: AbortSignal | undefined,
|
|
1403
|
+
upd: unknown,
|
|
1404
|
+
ctx: ExtensionContext,
|
|
1405
|
+
) {
|
|
1406
|
+
const result = await origFind.execute(tid, params, sig, upd as never, ctx);
|
|
1407
|
+
const textContent = getTextContent(result);
|
|
1408
|
+
const matchCount = textContent ? textContent.trim().split("\n").filter(Boolean).length : 0;
|
|
1409
|
+
|
|
1410
|
+
setResultDetails<FindResultDetails>(result, {
|
|
1411
|
+
_type: "findResult",
|
|
1412
|
+
text: textContent,
|
|
1413
|
+
pattern: params.pattern,
|
|
1414
|
+
matchCount,
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
return result;
|
|
1418
|
+
},
|
|
1419
|
+
|
|
1420
|
+
renderCall(args: FindParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1421
|
+
resolveBaseBackground(theme);
|
|
1422
|
+
const pattern = args.pattern ?? "";
|
|
1423
|
+
const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
|
|
1424
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1425
|
+
const title = `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`;
|
|
1426
|
+
text.setText(frameTop(title, getFrameStatus(ctx)));
|
|
1427
|
+
return text;
|
|
1428
|
+
},
|
|
1429
|
+
|
|
1430
|
+
renderResult(
|
|
1431
|
+
result: ToolResultLike<FindResultDetails>,
|
|
1432
|
+
_opt: ToolRenderResultOptions,
|
|
1433
|
+
theme: ThemeLike,
|
|
1434
|
+
ctx: RenderContextLike,
|
|
1435
|
+
) {
|
|
1436
|
+
resolveBaseBackground(theme);
|
|
1437
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1438
|
+
const status = getFrameStatus(ctx);
|
|
1439
|
+
|
|
1440
|
+
if (ctx.isError) {
|
|
1441
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
1442
|
+
return text;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const d = result.details;
|
|
1446
|
+
if (d?._type === "findResult" && d.text) {
|
|
1447
|
+
const rendered = renderFindResults(d.text);
|
|
1448
|
+
const info = `${FG_DIM}${d.matchCount} files${RST}`;
|
|
1449
|
+
text.setText(frameResultWithBottomLabel(rendered, info, status));
|
|
1450
|
+
return text;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const fallback = result.content?.[0];
|
|
1454
|
+
const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "found";
|
|
1455
|
+
text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
|
|
1456
|
+
return text;
|
|
1457
|
+
},
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ===================================================================
|
|
1462
|
+
// grep — highlighted matches with line numbers
|
|
1463
|
+
// ===================================================================
|
|
1464
|
+
|
|
1465
|
+
if (createGrepTool) {
|
|
1466
|
+
const origGrep = createGrepTool(cwd);
|
|
1467
|
+
|
|
1468
|
+
pi.registerTool({
|
|
1469
|
+
...origGrep,
|
|
1470
|
+
name: "grep",
|
|
1471
|
+
renderShell: "self",
|
|
1472
|
+
|
|
1473
|
+
async execute(
|
|
1474
|
+
tid: string,
|
|
1475
|
+
params: GrepParams,
|
|
1476
|
+
sig: AbortSignal | undefined,
|
|
1477
|
+
upd: unknown,
|
|
1478
|
+
ctx: ExtensionContext,
|
|
1479
|
+
) {
|
|
1480
|
+
const result = await origGrep.execute(tid, params, sig, upd as never, ctx);
|
|
1481
|
+
const textContent = normalizeLineEndings(getTextContent(result));
|
|
1482
|
+
if (result.content) {
|
|
1483
|
+
for (const content of result.content) {
|
|
1484
|
+
if (isTextContent(content)) content.text = normalizeLineEndings(content.text || "");
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
const matchCount = textContent
|
|
1488
|
+
? textContent
|
|
1489
|
+
.trim()
|
|
1490
|
+
.split("\n")
|
|
1491
|
+
.filter((line) => /^.+?[:-]\d+[:-]/.test(line)).length
|
|
1492
|
+
: 0;
|
|
1493
|
+
|
|
1494
|
+
setResultDetails<GrepResultDetails>(result, {
|
|
1495
|
+
_type: "grepResult",
|
|
1496
|
+
text: textContent,
|
|
1497
|
+
pattern: params.pattern,
|
|
1498
|
+
matchCount,
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
return result;
|
|
1502
|
+
},
|
|
1503
|
+
|
|
1504
|
+
renderCall(args: GrepParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1505
|
+
resolveBaseBackground(theme);
|
|
1506
|
+
const pattern = args.pattern ?? "";
|
|
1507
|
+
const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
|
|
1508
|
+
const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
|
|
1509
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1510
|
+
const title = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`;
|
|
1511
|
+
text.setText(frameTop(title, getFrameStatus(ctx)));
|
|
1512
|
+
return text;
|
|
1513
|
+
},
|
|
1514
|
+
|
|
1515
|
+
renderResult(
|
|
1516
|
+
result: ToolResultLike<GrepResultDetails>,
|
|
1517
|
+
_opt: ToolRenderResultOptions,
|
|
1518
|
+
theme: ThemeLike,
|
|
1519
|
+
ctx: RenderContextLike<GrepRenderState>,
|
|
1520
|
+
) {
|
|
1521
|
+
resolveBaseBackground(theme);
|
|
1522
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1523
|
+
const status = getFrameStatus(ctx);
|
|
1524
|
+
|
|
1525
|
+
if (ctx.isError) {
|
|
1526
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
1527
|
+
return text;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const d = result.details;
|
|
1531
|
+
if (d?._type === "grepResult" && d.text) {
|
|
1532
|
+
const key = `grep:${d.pattern}:${d.matchCount}:${termW()}:${status}`;
|
|
1533
|
+
if (ctx.state._gk !== key) {
|
|
1534
|
+
ctx.state._gk = key;
|
|
1535
|
+
const info = `${FG_DIM}${d.matchCount} matches${RST}`;
|
|
1536
|
+
ctx.state._gt = frameResultWithBottomLabel("", info, status);
|
|
1537
|
+
|
|
1538
|
+
renderGrepResults(d.text, d.pattern)
|
|
1539
|
+
.then((rendered: string) => {
|
|
1540
|
+
if (ctx.state._gk !== key) return;
|
|
1541
|
+
ctx.state._gt = frameResultWithBottomLabel(rendered, info, status);
|
|
1542
|
+
ctx.invalidate();
|
|
1543
|
+
})
|
|
1544
|
+
.catch(() => {});
|
|
1545
|
+
}
|
|
1546
|
+
text.setText(
|
|
1547
|
+
ctx.state._gt ?? frameResultWithBottomLabel("", `${FG_DIM}${d.matchCount} matches${RST}`, status),
|
|
1548
|
+
);
|
|
1549
|
+
return text;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const fallback = result.content?.[0];
|
|
1553
|
+
const fallbackText = fallback && isTextContent(fallback) ? fallback.text : "searched";
|
|
1554
|
+
text.setText(frameResult(theme.fg("dim", String(fallbackText).slice(0, 120)), status));
|
|
1555
|
+
return text;
|
|
1556
|
+
},
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
}
|