@heyhuynhgiabuu/pi-pretty 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 huynhgiabuu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # pi-pretty
2
+
3
+ A [pi](https://pi.dev) extension that enhances built-in tool output with **syntax highlighting**, **file-type icons**, **tree views**, and **colored status indicators** — all rendered directly in your terminal.
4
+
5
+ > **Status:** Early release.
6
+
7
+ > Companion to [@heyhuynhgiabuu/pi-diff](https://github.com/heyhuynhgiabuu/pi-diff) which handles `write`/`edit` diffs.
8
+
9
+ ## Features
10
+
11
+ | Tool | Enhancement |
12
+ |------|-------------|
13
+ | **read** | Syntax-highlighted file content with line numbers (190+ languages via Shiki) |
14
+ | **bash** | Colored exit status (`✓ exit 0` / `✗ exit 1`), line count |
15
+ | **ls** | Tree-view directory listing with file-type icons (📁🟦🐍🦀…) |
16
+ | **find** | Grouped results by directory with file-type icons |
17
+ | **grep** | Highlighted pattern matches with file headers and line numbers |
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pi install npm:@heyhuynhgiabuu/pi-pretty
23
+ ```
24
+
25
+ Or load directly for development:
26
+
27
+ ```bash
28
+ pi -e ./src/index.ts
29
+ ```
30
+
31
+ ## How It Works
32
+
33
+ pi-pretty wraps the built-in SDK tools (`createReadTool`, `createBashTool`, `createLsTool`, `createFindTool`, `createGrepTool`). For each tool:
34
+
35
+ 1. **Delegates** to the original `execute()` — no behavior changes
36
+ 2. **Attaches metadata** to `result.details` for custom rendering
37
+ 3. **Renders** enhanced output via `renderCall` / `renderResult`
38
+
39
+ The agent sees the same tool results. Only the TUI display changes.
40
+
41
+ ## Configuration
42
+
43
+ All settings via environment variables. Add to your shell profile or `.envrc`:
44
+
45
+ ### Theme
46
+
47
+ | Variable | Default | Description |
48
+ |----------|---------|-------------|
49
+ | `PRETTY_THEME` | `github-dark` | Shiki theme for syntax highlighting |
50
+
51
+ ### Limits
52
+
53
+ | Variable | Default | Description |
54
+ |----------|---------|-------------|
55
+ | `PRETTY_MAX_HL_CHARS` | `80000` | Skip syntax highlighting above this |
56
+ | `PRETTY_MAX_PREVIEW_LINES` | `80` | Max lines shown in rendered output |
57
+ | `PRETTY_CACHE_LIMIT` | `128` | LRU cache entries for highlighted blocks |
58
+
59
+ ### Example `.envrc`
60
+
61
+ ```bash
62
+ export PRETTY_THEME="catppuccin-mocha"
63
+ export PRETTY_MAX_PREVIEW_LINES=120
64
+ ```
65
+
66
+ ## Tool Details
67
+
68
+ ### `read` — Syntax Highlighting
69
+
70
+ When the agent reads a file, pi-pretty renders it with:
71
+ - Shiki syntax highlighting (190+ languages, auto-detected from extension)
72
+ - Line numbers in a left gutter
73
+ - Long lines truncated with `›` indicator
74
+ - Respects `offset` and `limit` parameters
75
+
76
+ ### `bash` — Exit Status
77
+
78
+ Bash command results show:
79
+ - `✓ exit 0` in green for success
80
+ - `✗ exit 1` in red for failure
81
+ - `⚡ killed` in yellow for terminated processes
82
+ - Line count for multi-line output
83
+
84
+ ### `ls` — Tree View
85
+
86
+ Directory listings rendered as:
87
+ ```
88
+ 3 entries
89
+ ├── 📁 src
90
+ ├── 📦 package.json
91
+ └── 📖 README.md
92
+ ```
93
+
94
+ File-type icons auto-detected from extension and filename.
95
+
96
+ ### `find` — Grouped Results
97
+
98
+ Find results grouped by directory:
99
+ ```
100
+ 5 files
101
+ 📁 src/
102
+ ├── 🟦 index.ts
103
+ └── 🟦 utils.ts
104
+ 📁 test/
105
+ ├── 🟦 index.test.ts
106
+ └── 🟦 utils.test.ts
107
+ ```
108
+
109
+ ### `grep` — Highlighted Matches
110
+
111
+ Grep results with file headers and matched text highlighted:
112
+ ```
113
+ 3 matches
114
+ 🟦 src/index.ts
115
+ 12 │ const result = await createReadTool(cwd);
116
+ 45 │ export function createReadTool(path: string) {
117
+
118
+ 🟦 src/utils.ts
119
+ 8 │ import { createReadTool } from "./index";
120
+ ```
121
+
122
+ ## File-Type Icons
123
+
124
+ | Icon | Extensions |
125
+ |------|-----------|
126
+ | 🟦 | `.ts`, `.tsx`, `tsconfig.json` |
127
+ | 🟨 | `.js`, `.jsx`, `.mjs`, `.cjs` |
128
+ | 🐍 | `.py`, `pyproject.toml` |
129
+ | 🦀 | `.rs`, `Cargo.toml` |
130
+ | 🔵 | `.go`, `go.mod` |
131
+ | ☕ | `.java` |
132
+ | 🍊 | `.swift` |
133
+ | 💎 | `.rb` |
134
+ | 🌐 | `.html` |
135
+ | 🎨 | `.css`, `.scss`, `.less` |
136
+ | 📋 | `.json`, `.yaml`, `.toml` |
137
+ | 📝 | `.md`, `.mdx` |
138
+ | 🐚 | `.sh`, `.bash`, `.zsh` |
139
+ | 🖼️ | `.png`, `.jpg`, `.svg`, `.webp` |
140
+ | 📦 | `package.json` |
141
+ | 🐳 | `Dockerfile` |
142
+ | 🔐 | `.env`, `.envrc` |
143
+ | 📖 | `README.md` |
144
+ | ⚖️ | `LICENSE` |
145
+
146
+ ## Architecture
147
+
148
+ ```
149
+ src/
150
+ └── index.ts # Extension entry — wraps read/bash/ls/find/grep with pretty rendering
151
+ ```
152
+
153
+ ### Key internals
154
+
155
+ | Component | Purpose |
156
+ |-----------|---------|
157
+ | `hlBlock()` | Shiki ANSI highlighting with LRU cache |
158
+ | `renderFileContent()` | Line-numbered syntax-highlighted file display |
159
+ | `renderBashOutput()` | Colored exit status + stderr detection |
160
+ | `renderTree()` | Tree-view with connectors and file icons |
161
+ | `renderFindResults()` | Directory-grouped file list with icons |
162
+ | `renderGrepResults()` | Pattern-highlighted matches with file headers |
163
+ | `fileIcon()` | Extension → emoji icon mapper |
164
+ | `lang()` | Extension → Shiki language mapper |
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ git clone https://github.com/heyhuynhgiabuu/pi-pretty.git
170
+ cd pi-pretty
171
+ npm install
172
+ npm run typecheck
173
+ npm run lint
174
+ npm test
175
+ ```
176
+
177
+ ### Load in pi for testing
178
+
179
+ ```bash
180
+ pi -e ./src/index.ts
181
+ ```
182
+
183
+ ## Related
184
+
185
+ - [@heyhuynhgiabuu/pi-diff](https://github.com/heyhuynhgiabuu/pi-diff) — Syntax-highlighted diffs for `write`/`edit` tools
186
+
187
+ ## License
188
+
189
+ MIT — [huynhgiabuu](https://github.com/heyhuynhgiabuu)
package/biome.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
3
+ "organizeImports": {
4
+ "enabled": true
5
+ },
6
+ "linter": {
7
+ "enabled": true,
8
+ "rules": {
9
+ "recommended": true
10
+ }
11
+ },
12
+ "formatter": {
13
+ "enabled": true,
14
+ "indentStyle": "tab",
15
+ "lineWidth": 120
16
+ }
17
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@heyhuynhgiabuu/pi-pretty",
3
+ "version": "0.1.0",
4
+ "description": "Pretty terminal output for pi — syntax-highlighted file reads, colored bash output, tree-view directory listings, and more.",
5
+ "author": "huynhgiabuu",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/heyhuynhgiabuu/pi-pretty.git"
10
+ },
11
+ "homepage": "https://github.com/heyhuynhgiabuu/pi-pretty#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/heyhuynhgiabuu/pi-pretty/issues"
14
+ },
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi",
18
+ "pi-extension",
19
+ "syntax-highlighting",
20
+ "shiki",
21
+ "terminal",
22
+ "pretty-print"
23
+ ],
24
+ "dependencies": {
25
+ "@shikijs/cli": "^4.0.2"
26
+ },
27
+ "peerDependencies": {
28
+ "@mariozechner/pi-coding-agent": "*",
29
+ "@mariozechner/pi-tui": "*"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^20.0.0",
33
+ "typescript": "^5.0.0",
34
+ "@biomejs/biome": "^2.3.5",
35
+ "vitest": "^4.0.18"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "typecheck": "tsc --noEmit",
40
+ "lint": "biome check src/",
41
+ "lint:fix": "biome check --fix src/",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest"
44
+ },
45
+ "pi": {
46
+ "extensions": [
47
+ "./src/index.ts"
48
+ ]
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,890 @@
1
+ /**
2
+ * pi-pretty — Pretty terminal output for pi built-in tools.
3
+ *
4
+ * @module pi-pretty
5
+ * @see https://github.com/heyhuynhgiabuu/pi-pretty
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 { existsSync, statSync } from "node:fs";
27
+ import { basename, dirname, extname, relative } from "node:path";
28
+
29
+ import { codeToANSI } from "@shikijs/cli";
30
+ import type { BundledLanguage, BundledTheme } from "shiki";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Config
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const THEME: BundledTheme =
37
+ (process.env.PRETTY_THEME as BundledTheme | undefined) ?? "github-dark";
38
+
39
+ function envInt(name: string, fallback: number): number {
40
+ const v = Number.parseInt(process.env[name] ?? "", 10);
41
+ return Number.isFinite(v) && v > 0 ? v : fallback;
42
+ }
43
+
44
+ const MAX_HL_CHARS = envInt("PRETTY_MAX_HL_CHARS", 80_000);
45
+ const MAX_PREVIEW_LINES = envInt("PRETTY_MAX_PREVIEW_LINES", 80);
46
+ const CACHE_LIMIT = envInt("PRETTY_CACHE_LIMIT", 128);
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // ANSI
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const RST = "\x1b[0m";
53
+ const BOLD = "\x1b[1m";
54
+ const DIM = "\x1b[2m";
55
+ const ITALIC = "\x1b[3m";
56
+
57
+ const FG_LNUM = "\x1b[38;2;100;100;100m";
58
+ const FG_DIM = "\x1b[38;2;80;80;80m";
59
+ const FG_RULE = "\x1b[38;2;50;50;50m";
60
+ const FG_GREEN = "\x1b[38;2;100;180;120m";
61
+ const FG_RED = "\x1b[38;2;200;100;100m";
62
+ const FG_YELLOW = "\x1b[38;2;220;180;80m";
63
+ const FG_BLUE = "\x1b[38;2;100;140;220m";
64
+ const FG_CYAN = "\x1b[38;2;80;190;190m";
65
+ const FG_MUTED = "\x1b[38;2;139;148;158m";
66
+ const FG_ORANGE = "\x1b[38;2;220;140;60m";
67
+ const FG_PURPLE = "\x1b[38;2;170;120;200m";
68
+
69
+ const BG_STDERR = "\x1b[48;2;40;25;25m";
70
+
71
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Low-contrast fix (same as pi-diff)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ function isLowContrastShikiFg(params: string): boolean {
78
+ if (params === "30" || params === "90") return true;
79
+ if (params === "38;5;0" || params === "38;5;8") return true;
80
+ if (!params.startsWith("38;2;")) return false;
81
+ const parts = params.split(";").map(Number);
82
+ if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n)))
83
+ return false;
84
+ const [, , r, g, b] = parts;
85
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
86
+ return luminance < 72;
87
+ }
88
+
89
+ function normalizeShikiContrast(ansi: string): string {
90
+ return ansi.replace(
91
+ /\x1b\[([0-9;]*)m/g,
92
+ (seq, params: string) =>
93
+ isLowContrastShikiFg(params) ? FG_MUTED : seq,
94
+ );
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Utilities
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function strip(s: string): string {
102
+ return s.replace(ANSI_RE, "");
103
+ }
104
+
105
+ function termW(): number {
106
+ const raw =
107
+ process.stdout.columns ||
108
+ (process.stderr as any).columns ||
109
+ Number.parseInt(process.env.COLUMNS ?? "", 10) ||
110
+ 200;
111
+ return Math.max(80, Math.min(raw - 4, 210));
112
+ }
113
+
114
+ function shortPath(cwd: string, home: string, p: string): string {
115
+ if (!p) return "";
116
+ const r = relative(cwd, p);
117
+ if (!r.startsWith("..") && !r.startsWith("/")) return r;
118
+ return p.replace(home, "~");
119
+ }
120
+
121
+ function rule(w: number): string {
122
+ return `${FG_RULE}${"─".repeat(w)}${RST}`;
123
+ }
124
+
125
+ function lnum(n: number, w: number): string {
126
+ const v = String(n);
127
+ return `${FG_LNUM}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Language detection
132
+ // ---------------------------------------------------------------------------
133
+
134
+ const EXT_LANG: Record<string, BundledLanguage> = {
135
+ ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
136
+ mjs: "javascript", cjs: "javascript",
137
+ py: "python", rb: "ruby", rs: "rust", go: "go", java: "java",
138
+ c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
139
+ swift: "swift", kt: "kotlin",
140
+ html: "html", css: "css", scss: "scss", less: "css",
141
+ json: "json", jsonc: "jsonc", yaml: "yaml", yml: "yaml", toml: "toml",
142
+ md: "markdown", mdx: "mdx", sql: "sql", sh: "bash", bash: "bash", zsh: "bash",
143
+ lua: "lua", php: "php", dart: "dart", xml: "xml",
144
+ graphql: "graphql", svelte: "svelte", vue: "vue",
145
+ dockerfile: "dockerfile", makefile: "make",
146
+ zig: "zig", nim: "nim", elixir: "elixir", ex: "elixir",
147
+ erb: "erb", hbs: "handlebars",
148
+ };
149
+
150
+ function lang(fp: string): BundledLanguage | undefined {
151
+ const base = basename(fp).toLowerCase();
152
+ if (base === "dockerfile") return "dockerfile";
153
+ if (base === "makefile" || base === "gnumakefile") return "make";
154
+ if (base === ".envrc" || base === ".env") return "bash";
155
+ return EXT_LANG[extname(fp).slice(1).toLowerCase()];
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // File-type icons
160
+ // ---------------------------------------------------------------------------
161
+
162
+ const ICON_DIR = "📁";
163
+ const ICON_DEFAULT = " ";
164
+
165
+ const EXT_ICON: Record<string, string> = {
166
+ ts: "🟦", tsx: "🟦", js: "🟨", jsx: "🟨", mjs: "🟨", cjs: "🟨",
167
+ py: "🐍", rb: "💎", rs: "🦀", go: "🔵", java: "☕",
168
+ c: "🔧", cpp: "🔧", h: "🔧", hpp: "🔧", cs: "🟪",
169
+ swift: "🍊", kt: "🟣",
170
+ html: "🌐", css: "🎨", scss: "🎨", less: "🎨",
171
+ json: "📋", yaml: "📋", yml: "📋", toml: "📋",
172
+ md: "📝", mdx: "📝",
173
+ sql: "🗃️", sh: "🐚", bash: "🐚", zsh: "🐚",
174
+ png: "🖼️", jpg: "🖼️", jpeg: "🖼️", gif: "🖼️", svg: "🖼️", webp: "🖼️",
175
+ lock: "🔒", env: "🔐",
176
+ };
177
+
178
+ const NAME_ICON: Record<string, string> = {
179
+ "package.json": "📦", "package-lock.json": "📦",
180
+ "tsconfig.json": "🟦", "biome.json": "🧹",
181
+ ".gitignore": "🙈", ".env": "🔐", ".envrc": "🔐",
182
+ dockerfile: "🐳", makefile: "🔧", gnumakefile: "🔧",
183
+ "readme.md": "📖", "license": "⚖️",
184
+ "cargo.toml": "🦀", "go.mod": "🔵", "pyproject.toml": "🐍",
185
+ };
186
+
187
+ function fileIcon(fp: string): string {
188
+ const base = basename(fp).toLowerCase();
189
+ if (NAME_ICON[base]) return NAME_ICON[base];
190
+ const ext = extname(fp).slice(1).toLowerCase();
191
+ return EXT_ICON[ext] ?? ICON_DEFAULT;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Shiki ANSI cache
196
+ // ---------------------------------------------------------------------------
197
+
198
+ // Pre-warm
199
+ codeToANSI("", "typescript", THEME).catch(() => {});
200
+
201
+ const _cache = new Map<string, string[]>();
202
+
203
+ function _touch(k: string, v: string[]): string[] {
204
+ _cache.delete(k);
205
+ _cache.set(k, v);
206
+ while (_cache.size > CACHE_LIMIT) {
207
+ const first = _cache.keys().next().value;
208
+ if (first === undefined) break;
209
+ _cache.delete(first);
210
+ }
211
+ return v;
212
+ }
213
+
214
+ async function hlBlock(
215
+ code: string,
216
+ language: BundledLanguage | undefined,
217
+ ): Promise<string[]> {
218
+ if (!code) return [""];
219
+ if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
220
+
221
+ const k = `${THEME}\0${language}\0${code}`;
222
+ const hit = _cache.get(k);
223
+ if (hit) return _touch(k, hit);
224
+
225
+ try {
226
+ const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME));
227
+ const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
228
+ return _touch(k, out);
229
+ } catch {
230
+ return code.split("\n");
231
+ }
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Renderers
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /** Render syntax-highlighted file content with line numbers. */
239
+ async function renderFileContent(
240
+ content: string,
241
+ filePath: string,
242
+ offset = 1,
243
+ maxLines = MAX_PREVIEW_LINES,
244
+ ): Promise<string> {
245
+ const lines = content.split("\n");
246
+ const total = lines.length;
247
+ const show = lines.slice(0, maxLines);
248
+ const lg = lang(filePath);
249
+ const hl = await hlBlock(show.join("\n"), lg);
250
+
251
+ const tw = termW();
252
+ const startLine = offset;
253
+ const endLine = startLine + show.length - 1;
254
+ const nw = Math.max(3, String(endLine).length);
255
+ const gw = nw + 3; // num + " │ "
256
+ const cw = Math.max(20, tw - gw);
257
+
258
+ const out: string[] = [];
259
+ out.push(rule(tw));
260
+
261
+ for (let i = 0; i < hl.length; i++) {
262
+ const ln = startLine + i;
263
+ const code = hl[i] ?? show[i] ?? "";
264
+ const plain = strip(code);
265
+ // Truncate if wider than available
266
+ let display = code;
267
+ if (plain.length > cw) {
268
+ let vis = 0;
269
+ let j = 0;
270
+ while (j < code.length && vis < cw - 1) {
271
+ if (code[j] === "\x1b") {
272
+ const e = code.indexOf("m", j);
273
+ if (e !== -1) {
274
+ j = e + 1;
275
+ continue;
276
+ }
277
+ }
278
+ vis++;
279
+ j++;
280
+ }
281
+ display = code.slice(0, j) + RST + FG_DIM + "›" + RST;
282
+ }
283
+ out.push(`${lnum(ln, nw)} ${FG_RULE}│${RST} ${display}${RST}`);
284
+ }
285
+
286
+ out.push(rule(tw));
287
+ if (total > maxLines) {
288
+ out.push(
289
+ `${FG_DIM} … ${total - maxLines} more lines (${total} total)${RST}`,
290
+ );
291
+ }
292
+ return out.join("\n");
293
+ }
294
+
295
+ /** Render bash output with colored exit code and stderr highlighting. */
296
+ function renderBashOutput(
297
+ text: string,
298
+ exitCode: number | null,
299
+ ): { summary: string; body: string } {
300
+ const isOk = exitCode === 0;
301
+ const statusFg = isOk ? FG_GREEN : FG_RED;
302
+ const statusIcon = isOk ? "✓" : "✗";
303
+ const codeStr =
304
+ exitCode !== null
305
+ ? `${statusFg}${statusIcon} exit ${exitCode}${RST}`
306
+ : `${FG_YELLOW}⚡ killed${RST}`;
307
+
308
+ const lines = text.split("\n");
309
+ const maxShow = MAX_PREVIEW_LINES;
310
+ const show = lines.slice(0, maxShow);
311
+ const remaining = lines.length - maxShow;
312
+
313
+ let body = show.join("\n");
314
+ if (remaining > 0) {
315
+ body += `\n${FG_DIM} … ${remaining} more lines${RST}`;
316
+ }
317
+
318
+ return { summary: codeStr, body };
319
+ }
320
+
321
+ /** Render ls output as a tree view with icons. */
322
+ function renderTree(text: string, basePath: string): string {
323
+ const lines = text.trim().split("\n").filter(Boolean);
324
+ if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
325
+
326
+ const out: string[] = [];
327
+ const total = lines.length;
328
+ const show = lines.slice(0, MAX_PREVIEW_LINES);
329
+
330
+ for (let i = 0; i < show.length; i++) {
331
+ const entry = show[i].trim();
332
+ const isLast = i === show.length - 1 && total <= MAX_PREVIEW_LINES;
333
+ const prefix = isLast ? "└── " : "├── ";
334
+ const connector = `${FG_RULE}${prefix}${RST}`;
335
+
336
+ // Detect directories (entries ending with /)
337
+ const isDir = entry.endsWith("/");
338
+ const name = isDir ? entry.slice(0, -1) : entry;
339
+ const icon = isDir ? ICON_DIR : fileIcon(name);
340
+ const fg = isDir ? FG_BLUE + BOLD : "";
341
+ const reset = isDir ? RST : "";
342
+
343
+ out.push(`${connector}${icon} ${fg}${name}${reset}`);
344
+ }
345
+
346
+ if (total > MAX_PREVIEW_LINES) {
347
+ out.push(
348
+ `${FG_RULE}└── ${RST}${FG_DIM}… ${total - MAX_PREVIEW_LINES} more entries${RST}`,
349
+ );
350
+ }
351
+
352
+ return out.join("\n");
353
+ }
354
+
355
+ /** Render find results grouped by directory with icons. */
356
+ function renderFindResults(text: string): string {
357
+ const lines = text.trim().split("\n").filter(Boolean);
358
+ if (!lines.length) return `${FG_DIM}(no matches)${RST}`;
359
+
360
+ // Group by directory
361
+ const groups = new Map<string, string[]>();
362
+ for (const line of lines) {
363
+ const trimmed = line.trim();
364
+ const dir = dirname(trimmed) || ".";
365
+ const file = basename(trimmed);
366
+ if (!groups.has(dir)) groups.set(dir, []);
367
+ groups.get(dir)!.push(file);
368
+ }
369
+
370
+ const out: string[] = [];
371
+ let count = 0;
372
+
373
+ for (const [dir, files] of groups) {
374
+ if (count > 0) out.push(""); // blank line between groups
375
+ out.push(`${ICON_DIR} ${FG_BLUE}${BOLD}${dir}/${RST}`);
376
+ for (let i = 0; i < files.length; i++) {
377
+ if (count >= MAX_PREVIEW_LINES) {
378
+ out.push(
379
+ ` ${FG_DIM}… ${lines.length - count} more files${RST}`,
380
+ );
381
+ return out.join("\n");
382
+ }
383
+ const isLast = i === files.length - 1;
384
+ const prefix = isLast ? "└── " : "├── ";
385
+ const icon = fileIcon(files[i]);
386
+ out.push(` ${FG_RULE}${prefix}${RST}${icon} ${files[i]}`);
387
+ count++;
388
+ }
389
+ }
390
+
391
+ return out.join("\n");
392
+ }
393
+
394
+ /** Render grep results with highlighted matches and line numbers. */
395
+ async function renderGrepResults(
396
+ text: string,
397
+ pattern: string,
398
+ ): Promise<string> {
399
+ const lines = text.split("\n");
400
+ if (!lines.length || (lines.length === 1 && !lines[0].trim()))
401
+ return `${FG_DIM}(no matches)${RST}`;
402
+
403
+ const tw = termW();
404
+ const out: string[] = [];
405
+ let currentFile = "";
406
+ let count = 0;
407
+
408
+ // Try to build a regex for highlighting
409
+ let re: RegExp | null = null;
410
+ try {
411
+ re = new RegExp(`(${pattern})`, "gi");
412
+ } catch {
413
+ // invalid regex — skip highlighting
414
+ }
415
+
416
+ for (const line of lines) {
417
+ if (count >= MAX_PREVIEW_LINES) {
418
+ out.push(`${FG_DIM} … more matches${RST}`);
419
+ break;
420
+ }
421
+
422
+ // ripgrep-style: "file:line:content" or "file-line-content" or just "file"
423
+ const fileMatch = line.match(/^(.+?)[:\-](\d+)[:\-](.*)$/);
424
+ if (fileMatch) {
425
+ const [, file, lineNo, content] = fileMatch;
426
+ if (file !== currentFile) {
427
+ if (currentFile) out.push(""); // blank line between files
428
+ const icon = fileIcon(file);
429
+ out.push(`${icon} ${FG_BLUE}${BOLD}${file}${RST}`);
430
+ currentFile = file;
431
+ }
432
+
433
+ const nw = Math.max(3, lineNo.length);
434
+ let display = content;
435
+ if (re) {
436
+ display = content.replace(
437
+ re,
438
+ `${RST}${FG_YELLOW}${BOLD}$1${RST}`,
439
+ );
440
+ }
441
+ out.push(` ${lnum(Number(lineNo), nw)} ${FG_RULE}│${RST} ${display}${RST}`);
442
+ count++;
443
+ } else if (line.trim() === "--") {
444
+ // ripgrep separator
445
+ out.push(` ${FG_DIM} ···${RST}`);
446
+ } else if (line.trim()) {
447
+ out.push(line);
448
+ count++;
449
+ }
450
+ }
451
+
452
+ return out.join("\n");
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Extension entry point
457
+ // ---------------------------------------------------------------------------
458
+
459
+ export default function piPrettyExtension(pi: any): void {
460
+ let createReadTool: any;
461
+ let createBashTool: any;
462
+ let createLsTool: any;
463
+ let createFindTool: any;
464
+ let createGrepTool: any;
465
+ let TextComponent: any;
466
+
467
+ try {
468
+ const sdk = require("@mariozechner/pi-coding-agent");
469
+ createReadTool = sdk.createReadTool;
470
+ createBashTool = sdk.createBashTool;
471
+ createLsTool = sdk.createLsTool;
472
+ createFindTool = sdk.createFindTool;
473
+ createGrepTool = sdk.createGrepTool;
474
+ TextComponent = require("@mariozechner/pi-tui").Text;
475
+ } catch {
476
+ return;
477
+ }
478
+ if (!createReadTool || !TextComponent) return;
479
+
480
+ const cwd = process.cwd();
481
+ const home = process.env.HOME ?? "";
482
+ const sp = (p: string) => shortPath(cwd, home, p);
483
+
484
+ // ===================================================================
485
+ // read — syntax-highlighted file content
486
+ // ===================================================================
487
+
488
+ const origRead = createReadTool(cwd);
489
+
490
+ pi.registerTool({
491
+ ...origRead,
492
+ name: "read",
493
+
494
+ async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
495
+ const result = await origRead.execute(tid, params, sig, upd, ctx);
496
+
497
+ const fp = params.path ?? "";
498
+ const offset = params.offset ?? 1;
499
+
500
+ // Extract text content for rendering
501
+ const textContent = result.content
502
+ ?.filter((c: any) => c.type === "text")
503
+ .map((c: any) => c.text || "")
504
+ .join("\n");
505
+
506
+ if (textContent && fp) {
507
+ const lineCount = textContent.split("\n").length;
508
+ (result as any).details = {
509
+ _type: "readFile",
510
+ filePath: fp,
511
+ content: textContent,
512
+ offset,
513
+ lineCount,
514
+ };
515
+ }
516
+
517
+ return result;
518
+ },
519
+
520
+ renderCall(args: any, theme: any, ctx: any) {
521
+ const fp = args?.path ?? "";
522
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
523
+ const offset = args?.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
524
+ const limit = args?.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
525
+ text.setText(
526
+ `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`,
527
+ );
528
+ return text;
529
+ },
530
+
531
+ renderResult(result: any, _opt: any, theme: any, ctx: any) {
532
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
533
+
534
+ if (ctx.isError) {
535
+ const e = result.content
536
+ ?.filter((c: any) => c.type === "text")
537
+ .map((c: any) => c.text || "")
538
+ .join("\n") ?? "Error";
539
+ text.setText(`\n${theme.fg("error", e)}`);
540
+ return text;
541
+ }
542
+
543
+ const d = result.details;
544
+ if (d?._type === "readFile" && d.content) {
545
+ const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}`;
546
+ if (ctx.state._rk !== key) {
547
+ ctx.state._rk = key;
548
+ const info = `${FG_DIM}${d.lineCount} lines${RST}`;
549
+ ctx.state._rt = ` ${info}`;
550
+
551
+ const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
552
+ renderFileContent(d.content, d.filePath, d.offset, maxShow)
553
+ .then((rendered: string) => {
554
+ if (ctx.state._rk !== key) return;
555
+ ctx.state._rt = ` ${info}\n${rendered}`;
556
+ ctx.invalidate();
557
+ })
558
+ .catch(() => {});
559
+ }
560
+ text.setText(ctx.state._rt ?? ` ${FG_DIM}${d.lineCount} lines${RST}`);
561
+ return text;
562
+ }
563
+
564
+ // Fallback
565
+ const fallback = result.content?.[0]?.text ?? "read";
566
+ text.setText(` ${theme.fg("dim", String(fallback).slice(0, 120))}`);
567
+ return text;
568
+ },
569
+ });
570
+
571
+ // ===================================================================
572
+ // bash — colored exit status
573
+ // ===================================================================
574
+
575
+ if (createBashTool) {
576
+ const origBash = createBashTool(cwd);
577
+
578
+ pi.registerTool({
579
+ ...origBash,
580
+ name: "bash",
581
+
582
+ async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
583
+ const result = await origBash.execute(tid, params, sig, upd, ctx);
584
+
585
+ const textContent = result.content
586
+ ?.filter((c: any) => c.type === "text")
587
+ .map((c: any) => c.text || "")
588
+ .join("\n");
589
+
590
+ // Try to extract exit code from the output
591
+ let exitCode: number | null = 0;
592
+ if (textContent) {
593
+ const exitMatch = textContent.match(
594
+ /(?:exit code|exited with|exit status)[:\s]*(\d+)/i,
595
+ );
596
+ if (exitMatch) exitCode = Number(exitMatch[1]);
597
+ // Check for common error indicators
598
+ if (
599
+ textContent.includes("command not found") ||
600
+ textContent.includes("No such file")
601
+ ) {
602
+ exitCode = 1;
603
+ }
604
+ }
605
+
606
+ (result as any).details = {
607
+ _type: "bashResult",
608
+ text: textContent ?? "",
609
+ exitCode,
610
+ command: params.command ?? "",
611
+ };
612
+
613
+ return result;
614
+ },
615
+
616
+ renderCall(args: any, theme: any, ctx: any) {
617
+ const cmd = args?.command ?? "";
618
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
619
+ const timeout = args?.timeout
620
+ ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}`
621
+ : "";
622
+ text.setText(
623
+ `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", cmd.length > 80 ? cmd.slice(0, 77) + "…" : cmd)}${timeout}`,
624
+ );
625
+ return text;
626
+ },
627
+
628
+ renderResult(result: any, _opt: any, theme: any, ctx: any) {
629
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
630
+
631
+ if (ctx.isError) {
632
+ const e = result.content
633
+ ?.filter((c: any) => c.type === "text")
634
+ .map((c: any) => c.text || "")
635
+ .join("\n") ?? "Error";
636
+ text.setText(`\n${theme.fg("error", e)}`);
637
+ return text;
638
+ }
639
+
640
+ const d = result.details;
641
+ if (d?._type === "bashResult") {
642
+ const { summary } = renderBashOutput(d.text, d.exitCode);
643
+ const lines = d.text.split("\n").length;
644
+ const lineInfo = lines > 1 ? ` ${FG_DIM}(${lines} lines)${RST}` : "";
645
+ text.setText(` ${summary}${lineInfo}`);
646
+ return text;
647
+ }
648
+
649
+ const fallback = result.content?.[0]?.text ?? "done";
650
+ text.setText(
651
+ ` ${theme.fg("dim", String(fallback).slice(0, 120))}`,
652
+ );
653
+ return text;
654
+ },
655
+ });
656
+ }
657
+
658
+ // ===================================================================
659
+ // ls — tree view with icons
660
+ // ===================================================================
661
+
662
+ if (createLsTool) {
663
+ const origLs = createLsTool(cwd);
664
+
665
+ pi.registerTool({
666
+ ...origLs,
667
+ name: "ls",
668
+
669
+ async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
670
+ const result = await origLs.execute(tid, params, sig, upd, ctx);
671
+
672
+ const textContent = result.content
673
+ ?.filter((c: any) => c.type === "text")
674
+ .map((c: any) => c.text || "")
675
+ .join("\n");
676
+
677
+ const fp = params.path ?? cwd;
678
+ const entryCount = textContent
679
+ ? textContent.trim().split("\n").filter(Boolean).length
680
+ : 0;
681
+
682
+ (result as any).details = {
683
+ _type: "lsResult",
684
+ text: textContent ?? "",
685
+ path: fp,
686
+ entryCount,
687
+ };
688
+
689
+ return result;
690
+ },
691
+
692
+ renderCall(args: any, theme: any, ctx: any) {
693
+ const fp = args?.path ?? ".";
694
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
695
+ text.setText(
696
+ `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`,
697
+ );
698
+ return text;
699
+ },
700
+
701
+ renderResult(result: any, _opt: any, theme: any, ctx: any) {
702
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
703
+
704
+ if (ctx.isError) {
705
+ const e = result.content
706
+ ?.filter((c: any) => c.type === "text")
707
+ .map((c: any) => c.text || "")
708
+ .join("\n") ?? "Error";
709
+ text.setText(`\n${theme.fg("error", e)}`);
710
+ return text;
711
+ }
712
+
713
+ const d = result.details;
714
+ if (d?._type === "lsResult" && d.text) {
715
+ const tree = renderTree(d.text, d.path);
716
+ const info = `${FG_DIM}${d.entryCount} entries${RST}`;
717
+ text.setText(` ${info}\n${tree}`);
718
+ return text;
719
+ }
720
+
721
+ const fallback = result.content?.[0]?.text ?? "listed";
722
+ text.setText(
723
+ ` ${theme.fg("dim", String(fallback).slice(0, 120))}`,
724
+ );
725
+ return text;
726
+ },
727
+ });
728
+ }
729
+
730
+ // ===================================================================
731
+ // find — grouped file list with icons
732
+ // ===================================================================
733
+
734
+ if (createFindTool) {
735
+ const origFind = createFindTool(cwd);
736
+
737
+ pi.registerTool({
738
+ ...origFind,
739
+ name: "find",
740
+
741
+ async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
742
+ const result = await origFind.execute(tid, params, sig, upd, ctx);
743
+
744
+ const textContent = result.content
745
+ ?.filter((c: any) => c.type === "text")
746
+ .map((c: any) => c.text || "")
747
+ .join("\n");
748
+
749
+ const matchCount = textContent
750
+ ? textContent.trim().split("\n").filter(Boolean).length
751
+ : 0;
752
+
753
+ (result as any).details = {
754
+ _type: "findResult",
755
+ text: textContent ?? "",
756
+ pattern: params.pattern ?? "",
757
+ matchCount,
758
+ };
759
+
760
+ return result;
761
+ },
762
+
763
+ renderCall(args: any, theme: any, ctx: any) {
764
+ const pattern = args?.pattern ?? "";
765
+ const path = args?.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
766
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
767
+ text.setText(
768
+ `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`,
769
+ );
770
+ return text;
771
+ },
772
+
773
+ renderResult(result: any, _opt: any, theme: any, ctx: any) {
774
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
775
+
776
+ if (ctx.isError) {
777
+ const e = result.content
778
+ ?.filter((c: any) => c.type === "text")
779
+ .map((c: any) => c.text || "")
780
+ .join("\n") ?? "Error";
781
+ text.setText(`\n${theme.fg("error", e)}`);
782
+ return text;
783
+ }
784
+
785
+ const d = result.details;
786
+ if (d?._type === "findResult" && d.text) {
787
+ const rendered = renderFindResults(d.text);
788
+ const info = `${FG_DIM}${d.matchCount} files${RST}`;
789
+ text.setText(` ${info}\n${rendered}`);
790
+ return text;
791
+ }
792
+
793
+ const fallback = result.content?.[0]?.text ?? "found";
794
+ text.setText(
795
+ ` ${theme.fg("dim", String(fallback).slice(0, 120))}`,
796
+ );
797
+ return text;
798
+ },
799
+ });
800
+ }
801
+
802
+ // ===================================================================
803
+ // grep — highlighted matches with line numbers
804
+ // ===================================================================
805
+
806
+ if (createGrepTool) {
807
+ const origGrep = createGrepTool(cwd);
808
+
809
+ pi.registerTool({
810
+ ...origGrep,
811
+ name: "grep",
812
+
813
+ async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
814
+ const result = await origGrep.execute(tid, params, sig, upd, ctx);
815
+
816
+ const textContent = result.content
817
+ ?.filter((c: any) => c.type === "text")
818
+ .map((c: any) => c.text || "")
819
+ .join("\n");
820
+
821
+ const matchCount = textContent
822
+ ? textContent
823
+ .trim()
824
+ .split("\n")
825
+ .filter((l: string) => l.match(/^.+?[:\-]\d+[:\-]/))
826
+ .length
827
+ : 0;
828
+
829
+ (result as any).details = {
830
+ _type: "grepResult",
831
+ text: textContent ?? "",
832
+ pattern: params.pattern ?? "",
833
+ matchCount,
834
+ };
835
+
836
+ return result;
837
+ },
838
+
839
+ renderCall(args: any, theme: any, ctx: any) {
840
+ const pattern = args?.pattern ?? "";
841
+ const path = args?.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
842
+ const glob = args?.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
843
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
844
+ text.setText(
845
+ `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`,
846
+ );
847
+ return text;
848
+ },
849
+
850
+ renderResult(result: any, _opt: any, theme: any, ctx: any) {
851
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
852
+
853
+ if (ctx.isError) {
854
+ const e = result.content
855
+ ?.filter((c: any) => c.type === "text")
856
+ .map((c: any) => c.text || "")
857
+ .join("\n") ?? "Error";
858
+ text.setText(`\n${theme.fg("error", e)}`);
859
+ return text;
860
+ }
861
+
862
+ const d = result.details;
863
+ if (d?._type === "grepResult" && d.text) {
864
+ const key = `grep:${d.pattern}:${d.matchCount}:${termW()}`;
865
+ if (ctx.state._gk !== key) {
866
+ ctx.state._gk = key;
867
+ const info = `${FG_DIM}${d.matchCount} matches${RST}`;
868
+ ctx.state._gt = ` ${info}`;
869
+
870
+ renderGrepResults(d.text, d.pattern)
871
+ .then((rendered: string) => {
872
+ if (ctx.state._gk !== key) return;
873
+ ctx.state._gt = ` ${info}\n${rendered}`;
874
+ ctx.invalidate();
875
+ })
876
+ .catch(() => {});
877
+ }
878
+ text.setText(ctx.state._gt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`);
879
+ return text;
880
+ }
881
+
882
+ const fallback = result.content?.[0]?.text ?? "searched";
883
+ text.setText(
884
+ ` ${theme.fg("dim", String(fallback).slice(0, 120))}`,
885
+ );
886
+ return text;
887
+ },
888
+ });
889
+ }
890
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "lib": ["ES2022"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true
16
+ },
17
+ "include": ["src/**/*.ts"],
18
+ "exclude": ["node_modules", "dist", "test"]
19
+ }