@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/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
+ }