@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 +21 -0
- package/README.md +189 -0
- package/biome.json +17 -0
- package/package.json +50 -0
- package/src/index.ts +890 -0
- package/tsconfig.json +19 -0
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
|
+
}
|