@heyhuynhgiabuu/pi-diff 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 +205 -0
- package/biome.json +17 -0
- package/package.json +52 -0
- package/src/index.ts +1043 -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,205 @@
|
|
|
1
|
+
# pi-diff
|
|
2
|
+
|
|
3
|
+
A [pi](https://pi.dev) extension that replaces the default `write` and `edit` tool output with **Shiki-powered, syntax-highlighted diffs** — side-by-side split view, unified stacked view, and word-level change emphasis, all rendered directly in your terminal.
|
|
4
|
+
|
|
5
|
+
> **Status:** Early release.
|
|
6
|
+
|
|
7
|
+
<!-- TODO: add screenshot -->
|
|
8
|
+
<!-- <img width="600" alt="pi-diff screenshot" src="https://github.com/heyhuynhgiabuu/pi-diff/raw/main/media/screenshot.png" /> -->
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Syntax-highlighted diffs** — full Shiki grammar highlighting (190+ languages) composited with diff background colors
|
|
13
|
+
- **Split view** — side-by-side comparison for `edit` tool, auto-falls back to unified on narrow terminals
|
|
14
|
+
- **Unified view** — stacked single-column layout for `write` tool overwrites
|
|
15
|
+
- **Word-level emphasis** — changed characters get brighter backgrounds so you see exactly what changed
|
|
16
|
+
- **New file preview** — syntax-highlighted preview when creating files
|
|
17
|
+
- **Adaptive layout** — auto-detects terminal width; wraps intelligently on wide terminals, truncates on narrow ones
|
|
18
|
+
- **LRU cache** — singleton Shiki highlighter with 192-entry cache for fast re-renders
|
|
19
|
+
- **Large diff fallback** — gracefully degrades (skips highlighting, still shows diff structure) for files > 80k chars
|
|
20
|
+
- **Fully customizable** — every color and threshold is overridable via environment variables
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install npm:@heyhuynhgiabuu/pi-diff
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or load directly for development:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi -e ./src/index.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## How It Works
|
|
35
|
+
|
|
36
|
+
pi-diff wraps the built-in `write` and `edit` tools from the pi SDK. When the agent writes or edits a file:
|
|
37
|
+
|
|
38
|
+
1. **Before the write** — reads the existing file content
|
|
39
|
+
2. **Delegates** to the original SDK tool (file is actually written)
|
|
40
|
+
3. **After the write** — computes a structured diff between old and new content
|
|
41
|
+
4. **Renders** the diff with syntax highlighting and word-level emphasis
|
|
42
|
+
|
|
43
|
+
The rendering pipeline:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Old content ──┐
|
|
47
|
+
├── diff (structuredPatch) ── parse ── highlight (Shiki → ANSI)
|
|
48
|
+
New content ──┘ │
|
|
49
|
+
├── inject diff bg
|
|
50
|
+
├── inject word-level bg
|
|
51
|
+
└── wrap/fit to terminal
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Views
|
|
55
|
+
|
|
56
|
+
| View | Used by | Description |
|
|
57
|
+
|------|---------|-------------|
|
|
58
|
+
| **Split** | `edit` tool | Side-by-side with old on left, new on right. Diagonal stripes fill empty slots. Auto-falls back to unified when terminal < 150 cols or > 20% of lines would wrap. |
|
|
59
|
+
| **Unified** | `write` tool | Single column with `+`/`-` gutter. Compact, works at any terminal width. |
|
|
60
|
+
|
|
61
|
+
Both views show:
|
|
62
|
+
- Colored border bars (`▌`) for changed lines
|
|
63
|
+
- Line numbers in the gutter
|
|
64
|
+
- Hunk separators (`··· N unmodified lines ···`)
|
|
65
|
+
- Word-level emphasis on paired add/del lines
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
All settings are controlled via environment variables. Add them to your shell profile or `.envrc`:
|
|
70
|
+
|
|
71
|
+
### Theme
|
|
72
|
+
|
|
73
|
+
| Variable | Default | Description |
|
|
74
|
+
|----------|---------|-------------|
|
|
75
|
+
| `DIFF_THEME` | `github-dark` | Shiki theme name (e.g., `dracula`, `one-dark-pro`, `catppuccin-mocha`) |
|
|
76
|
+
|
|
77
|
+
### Colors
|
|
78
|
+
|
|
79
|
+
Override any diff color with hex `#RRGGBB` format:
|
|
80
|
+
|
|
81
|
+
| Variable | Default | Description |
|
|
82
|
+
|----------|---------|-------------|
|
|
83
|
+
| `DIFF_BG_ADD` | `#162620` | Background for added lines |
|
|
84
|
+
| `DIFF_BG_DEL` | `#2d1919` | Background for removed lines |
|
|
85
|
+
| `DIFF_BG_ADD_HL` | `#234b32` | Word-level emphasis on added text |
|
|
86
|
+
| `DIFF_BG_DEL_HL` | `#502323` | Word-level emphasis on removed text |
|
|
87
|
+
| `DIFF_BG_GUTTER_ADD` | `#12201a` | Gutter background for added lines |
|
|
88
|
+
| `DIFF_BG_GUTTER_DEL` | `#261616` | Gutter background for removed lines |
|
|
89
|
+
| `DIFF_FG_ADD` | `#64b478` | Foreground for `+` signs and add indicators |
|
|
90
|
+
| `DIFF_FG_DEL` | `#c86464` | Foreground for `-` signs and del indicators |
|
|
91
|
+
|
|
92
|
+
### Layout
|
|
93
|
+
|
|
94
|
+
| Variable | Default | Description |
|
|
95
|
+
|----------|---------|-------------|
|
|
96
|
+
| `DIFF_SPLIT_MIN_WIDTH` | `150` | Minimum terminal columns to use split view |
|
|
97
|
+
| `DIFF_SPLIT_MIN_CODE_WIDTH` | `60` | Minimum code columns per side in split view |
|
|
98
|
+
|
|
99
|
+
### Example `.envrc`
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Use a different Shiki theme
|
|
103
|
+
export DIFF_THEME="catppuccin-mocha"
|
|
104
|
+
|
|
105
|
+
# Brighter add backgrounds
|
|
106
|
+
export DIFF_BG_ADD="#1a3a25"
|
|
107
|
+
export DIFF_BG_ADD_HL="#2d6040"
|
|
108
|
+
|
|
109
|
+
# Allow split view on narrower terminals
|
|
110
|
+
export DIFF_SPLIT_MIN_WIDTH=120
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Architecture
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
src/
|
|
117
|
+
└── index.ts # Extension entry point — wraps write/edit tools with diff rendering
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Key internals
|
|
121
|
+
|
|
122
|
+
| Component | Purpose |
|
|
123
|
+
|-----------|---------|
|
|
124
|
+
| `parseDiff()` | Converts old/new content to structured `DiffLine[]` using `diff.structuredPatch` |
|
|
125
|
+
| `hlBlock()` | Shiki ANSI highlighting with LRU cache (192 entries) |
|
|
126
|
+
| `injectBg()` | Composites diff backgrounds into Shiki ANSI output (fg + bg layering) |
|
|
127
|
+
| `wordDiffAnalysis()` | Single-pass word diff → similarity score + character ranges |
|
|
128
|
+
| `renderSplit()` | Side-by-side renderer with diagonal stripe fillers |
|
|
129
|
+
| `renderUnified()` | Stacked single-column renderer |
|
|
130
|
+
| `wrapAnsi()` | ANSI-aware line wrapping with state carry-forward |
|
|
131
|
+
| `shouldUseSplit()` | Heuristic: split vs unified based on terminal width and wrap ratio |
|
|
132
|
+
|
|
133
|
+
### Rendering constants
|
|
134
|
+
|
|
135
|
+
| Constant | Value | Description |
|
|
136
|
+
|----------|-------|-------------|
|
|
137
|
+
| `MAX_PREVIEW_LINES` | 60 | Max lines in edit preview (split view) |
|
|
138
|
+
| `MAX_RENDER_LINES` | 150 | Max lines in write result (unified view) |
|
|
139
|
+
| `MAX_HL_CHARS` | 80,000 | Skip syntax highlighting above this |
|
|
140
|
+
| `CACHE_LIMIT` | 192 | LRU cache entries for highlighted blocks |
|
|
141
|
+
| `WORD_DIFF_MIN_SIM` | 0.15 | Minimum similarity for word-level emphasis |
|
|
142
|
+
|
|
143
|
+
## Exports
|
|
144
|
+
|
|
145
|
+
The extension exports a `__testing` object for unit testing:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { __testing } from "@heyhuynhgiabuu/pi-diff";
|
|
149
|
+
|
|
150
|
+
const { parseDiff, renderSplit, renderUnified, normalizeShikiContrast } = __testing;
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
git clone https://github.com/heyhuynhgiabuu/pi-diff.git
|
|
157
|
+
cd pi-diff
|
|
158
|
+
npm install
|
|
159
|
+
npm run typecheck # TypeScript validation
|
|
160
|
+
npm run lint # Biome linting
|
|
161
|
+
npm test # Run tests
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Load in pi for testing
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# From the pi-diff directory
|
|
168
|
+
pi -e ./src/index.ts
|
|
169
|
+
|
|
170
|
+
# Or install globally
|
|
171
|
+
pi install .
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## How pi Extensions Work
|
|
175
|
+
|
|
176
|
+
pi-diff is a **pi extension** — a TypeScript file that exports a default function receiving the pi API:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
export default function piDiffExtension(pi: any): void {
|
|
180
|
+
// Get SDK tools
|
|
181
|
+
const origWrite = createWriteTool(cwd);
|
|
182
|
+
const origEdit = createEditTool(cwd);
|
|
183
|
+
|
|
184
|
+
// Register enhanced versions
|
|
185
|
+
pi.registerTool({
|
|
186
|
+
...origWrite,
|
|
187
|
+
name: "write",
|
|
188
|
+
execute: async (...) => { /* wrap + diff */ },
|
|
189
|
+
renderCall: (...) => { /* preview */ },
|
|
190
|
+
renderResult: (...) => { /* render diff */ },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Extensions can:
|
|
196
|
+
- **Register tools** — `pi.registerTool(definition)`
|
|
197
|
+
- **Listen to events** — `pi.on("session_start" | "input" | "before_tool_call" | ...)`
|
|
198
|
+
- **Register commands** — `pi.registerCommand("/name", handler)`
|
|
199
|
+
- **Register providers** — `pi.registerProvider("name", config)`
|
|
200
|
+
|
|
201
|
+
See the [pi docs](https://pi.dev) for the full extension API.
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
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,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@heyhuynhgiabuu/pi-diff",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shiki-powered terminal diff renderer for pi — syntax-highlighted, word-level diffs in split and unified views.",
|
|
5
|
+
"author": "huynhgiabuu",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/heyhuynhgiabuu/pi-diff.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/heyhuynhgiabuu/pi-diff#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/heyhuynhgiabuu/pi-diff/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"diff",
|
|
20
|
+
"syntax-highlighting",
|
|
21
|
+
"shiki",
|
|
22
|
+
"terminal"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"diff": "^7.0.0",
|
|
26
|
+
"@shikijs/cli": "^4.0.2"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
30
|
+
"@mariozechner/pi-tui": "*"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/diff": "^7.0.2",
|
|
34
|
+
"@types/node": "^20.0.0",
|
|
35
|
+
"typescript": "^5.0.0",
|
|
36
|
+
"@biomejs/biome": "^2.3.5",
|
|
37
|
+
"vitest": "^4.0.18"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"lint": "biome check src/",
|
|
43
|
+
"lint:fix": "biome check --fix src/",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest"
|
|
46
|
+
},
|
|
47
|
+
"pi": {
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./src/index.ts"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-diff — Shiki-powered terminal diff renderer for pi.
|
|
3
|
+
*
|
|
4
|
+
* @module pi-diff
|
|
5
|
+
* @see https://github.com/heyhuynhgiabuu/pi-diff
|
|
6
|
+
*
|
|
7
|
+
* Architecture (like OpenTUI / delta):
|
|
8
|
+
* 1. Syntax-highlight full code blocks via Shiki → ANSI (fg-only codes)
|
|
9
|
+
* 2. Layer diff background colors underneath (composites at cell level)
|
|
10
|
+
* 3. For word-level changes, inject brighter bg at changed char positions
|
|
11
|
+
* 4. Result: syntax fg + diff bg + word emphasis — all three visible together
|
|
12
|
+
*
|
|
13
|
+
* Views:
|
|
14
|
+
* • Split (side-by-side) — edit tool, auto-falls back to unified on narrow terminals
|
|
15
|
+
* • Unified (stacked) — write tool overwrites
|
|
16
|
+
*
|
|
17
|
+
* Performance:
|
|
18
|
+
* • Singleton Shiki highlighter (managed by @shikijs/cli)
|
|
19
|
+
* • LRU memo cache per highlighted block
|
|
20
|
+
* • Large-diff fallback (skip highlighting, still show diff)
|
|
21
|
+
* • Async rendering with invalidate() for non-blocking preview
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { extname, relative } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { codeToANSI } from "@shikijs/cli";
|
|
28
|
+
import * as Diff from "diff";
|
|
29
|
+
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Config
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
|
|
36
|
+
|
|
37
|
+
function envInt(name: string, fallback: number): number {
|
|
38
|
+
const v = Number.parseInt(process.env[name] ?? "", 10);
|
|
39
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parse env hex color "#RRGGBB" → ANSI 24-bit fg/bg escape, or return fallback. */
|
|
43
|
+
function envFg(name: string, fallback: string): string {
|
|
44
|
+
const hex = process.env[name];
|
|
45
|
+
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
|
|
46
|
+
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
47
|
+
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
48
|
+
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
49
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
50
|
+
}
|
|
51
|
+
function envBg(name: string, fallback: string): string {
|
|
52
|
+
const hex = process.env[name];
|
|
53
|
+
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback;
|
|
54
|
+
const r = Number.parseInt(hex.slice(1, 3), 16);
|
|
55
|
+
const g = Number.parseInt(hex.slice(3, 5), 16);
|
|
56
|
+
const b = Number.parseInt(hex.slice(5, 7), 16);
|
|
57
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Split-view thresholds ---
|
|
61
|
+
// Split is preferred when there's real room. At narrow widths, a clean stacked
|
|
62
|
+
// (unified) view is better than a cramped split with wrapping.
|
|
63
|
+
const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150); // need ≥150 cols for split to breathe
|
|
64
|
+
const SPLIT_MIN_CODE_WIDTH = envInt("DIFF_SPLIT_MIN_CODE_WIDTH", 60); // ≥60 code cols per side
|
|
65
|
+
const SPLIT_MAX_WRAP_RATIO = 0.20; // if >20% lines wrap in split, fall back to stacked
|
|
66
|
+
const SPLIT_MAX_WRAP_LINES = 8; // absolute cap before unified fallback
|
|
67
|
+
|
|
68
|
+
// --- Terminal bounds ---
|
|
69
|
+
const MAX_TERM_WIDTH = 210; // max for 1728px wide display (~205 cols at typical font)
|
|
70
|
+
const DEFAULT_TERM_WIDTH = 200; // safe default for 1728x1117 resolution
|
|
71
|
+
|
|
72
|
+
// --- Rendering limits ---
|
|
73
|
+
const MAX_PREVIEW_LINES = 60; // was 50 — show slightly more context in edit preview
|
|
74
|
+
const MAX_RENDER_LINES = 150; // was 120 — show more of the diff in write tool
|
|
75
|
+
const MAX_HL_CHARS = 80_000; // was 50k — allow syntax hl for larger diffs
|
|
76
|
+
const CACHE_LIMIT = 192; // was 128 — bigger cache for multi-file sessions
|
|
77
|
+
|
|
78
|
+
// --- Word diff ---
|
|
79
|
+
const WORD_DIFF_MIN_SIM = 0.15; // was 0.2 — show word diffs for slightly less similar lines
|
|
80
|
+
|
|
81
|
+
// --- Wrapping ---
|
|
82
|
+
// Adaptive: narrow terminals truncate aggressively, wide terminals allow wrapping.
|
|
83
|
+
// Actual wrap rows are computed per-render via adaptiveWrapRows().
|
|
84
|
+
const MAX_WRAP_ROWS_WIDE = 3; // ≥180 cols
|
|
85
|
+
const MAX_WRAP_ROWS_MED = 2; // 120–179 cols
|
|
86
|
+
const MAX_WRAP_ROWS_NARROW = 1; // <120 cols (truncate, no wrap)
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// ANSI
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const RST = "\x1b[0m";
|
|
93
|
+
const BOLD = "\x1b[1m";
|
|
94
|
+
const DIM = "\x1b[2m";
|
|
95
|
+
|
|
96
|
+
// Subtle diff backgrounds — muted tones to let syntax fg shine through
|
|
97
|
+
// Override via env: DIFF_BG_ADD="#1a3320" etc. (hex "#RRGGBB" format)
|
|
98
|
+
const BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); // muted teal-green
|
|
99
|
+
const BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); // muted brown-red
|
|
100
|
+
const BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word-level emphasis
|
|
101
|
+
const BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
|
|
102
|
+
const BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
|
|
103
|
+
const BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m");
|
|
104
|
+
const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
|
|
105
|
+
const BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
|
|
106
|
+
|
|
107
|
+
// Diff foregrounds — override via env: DIFF_FG_ADD="#50d264" etc.
|
|
108
|
+
const FG_ADD = envFg("DIFF_FG_ADD", "\x1b[38;2;100;180;120m"); // desaturated green
|
|
109
|
+
const FG_DEL = envFg("DIFF_FG_DEL", "\x1b[38;2;200;100;100m"); // desaturated red
|
|
110
|
+
const FG_DIM = "\x1b[38;2;80;80;80m";
|
|
111
|
+
const FG_LNUM = "\x1b[38;2;100;100;100m";
|
|
112
|
+
const FG_RULE = "\x1b[38;2;50;50;50m";
|
|
113
|
+
const FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
|
|
114
|
+
|
|
115
|
+
const FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes on terminal default bg
|
|
116
|
+
|
|
117
|
+
const BORDER_BAR = "▌";
|
|
118
|
+
|
|
119
|
+
/** Generate a dense diagonal stripe fill for empty filler cells.
|
|
120
|
+
* Solid ╱ characters — uniform direction like CSS diagonal hatching. */
|
|
121
|
+
function stripes(w: number, _rowOffset: number): string {
|
|
122
|
+
return FG_STRIPE + "╱".repeat(w) + RST;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const DIVIDER = `${FG_RULE}│${RST}`;
|
|
126
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
127
|
+
const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Theme-aware diff colors
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/** Resolved ANSI colors for diff rendering — theme overrides hardcoded defaults. */
|
|
134
|
+
interface DiffColors {
|
|
135
|
+
fgAdd: string;
|
|
136
|
+
fgDel: string;
|
|
137
|
+
fgCtx: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
|
|
141
|
+
|
|
142
|
+
/** Resolve diff fg colors from theme (if available), falling back to hardcoded ANSI. */
|
|
143
|
+
function resolveDiffColors(theme?: any): DiffColors {
|
|
144
|
+
if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
|
|
145
|
+
try {
|
|
146
|
+
const fgAdd = theme.getFgAnsi("toolDiffAdded") || FG_ADD;
|
|
147
|
+
const fgDel = theme.getFgAnsi("toolDiffRemoved") || FG_DEL;
|
|
148
|
+
const fgCtx = theme.getFgAnsi("toolDiffContext") || FG_DIM;
|
|
149
|
+
return { fgAdd, fgDel, fgCtx };
|
|
150
|
+
} catch {
|
|
151
|
+
return DEFAULT_DIFF_COLORS;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Adaptive helpers
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/** Returns max wrap rows based on current terminal width. Narrow = truncate, wide = allow wrapping. */
|
|
160
|
+
function adaptiveWrapRows(tw?: number): number {
|
|
161
|
+
const w = tw ?? termW();
|
|
162
|
+
if (w >= 180) return MAX_WRAP_ROWS_WIDE;
|
|
163
|
+
if (w >= 120) return MAX_WRAP_ROWS_MED;
|
|
164
|
+
return MAX_WRAP_ROWS_NARROW;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Types
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
interface DiffLine {
|
|
172
|
+
type: "add" | "del" | "ctx" | "sep";
|
|
173
|
+
oldNum: number | null;
|
|
174
|
+
newNum: number | null;
|
|
175
|
+
content: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface ParsedDiff {
|
|
179
|
+
lines: DiffLine[];
|
|
180
|
+
added: number;
|
|
181
|
+
removed: number;
|
|
182
|
+
chars: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Utilities
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
function strip(s: string): string { return s.replace(ANSI_RE, ""); }
|
|
190
|
+
|
|
191
|
+
function tabs(s: string): string { return s.replace(/\t/g, " "); }
|
|
192
|
+
|
|
193
|
+
function termW(): number {
|
|
194
|
+
// Try multiple sources — process.stdout.columns may be undefined in piped/subagent contexts
|
|
195
|
+
const raw = process.stdout.columns
|
|
196
|
+
|| (process.stderr as any).columns
|
|
197
|
+
|| Number.parseInt(process.env.COLUMNS ?? "", 10)
|
|
198
|
+
|| DEFAULT_TERM_WIDTH;
|
|
199
|
+
return Math.max(80, Math.min(raw - 4, MAX_TERM_WIDTH)); // -4 safety margin for pi TUI padding
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Pad/truncate `s` to exactly `w` visible chars. ANSI-aware. */
|
|
203
|
+
function fit(s: string, w: number): string {
|
|
204
|
+
if (w <= 0) return "";
|
|
205
|
+
const plain = strip(s);
|
|
206
|
+
if (plain.length <= w) return s + " ".repeat(w - plain.length);
|
|
207
|
+
// Truncated — show content + dim › indicator
|
|
208
|
+
const showW = w > 2 ? w - 1 : w;
|
|
209
|
+
let vis = 0, i = 0;
|
|
210
|
+
while (i < s.length && vis < showW) {
|
|
211
|
+
if (s[i] === "\x1b") { const e = s.indexOf("m", i); if (e !== -1) { i = e + 1; continue; } }
|
|
212
|
+
vis++; i++;
|
|
213
|
+
}
|
|
214
|
+
return w > 2
|
|
215
|
+
? s.slice(0, i) + RST + FG_DIM + "›" + RST
|
|
216
|
+
: s.slice(0, i) + RST;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
|
|
220
|
+
function ansiState(s: string): string {
|
|
221
|
+
let fg = "", bg = "";
|
|
222
|
+
const re = /\x1b\[([^m]*)m/g;
|
|
223
|
+
let m: RegExpExecArray | null;
|
|
224
|
+
while ((m = re.exec(s)) !== null) {
|
|
225
|
+
const p = m[1];
|
|
226
|
+
if (p === "0") { fg = ""; bg = ""; }
|
|
227
|
+
else if (p === "39") { fg = ""; }
|
|
228
|
+
else if (p.startsWith("38;")) { fg = m[0]; }
|
|
229
|
+
else if (p.startsWith("48;")) { bg = m[0]; }
|
|
230
|
+
}
|
|
231
|
+
return bg + fg;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isLowContrastShikiFg(params: string): boolean {
|
|
235
|
+
if (params === "30" || params === "90") return true;
|
|
236
|
+
if (params === "38;5;0" || params === "38;5;8") return true;
|
|
237
|
+
if (!params.startsWith("38;2;")) return false;
|
|
238
|
+
const parts = params.split(";").map(Number);
|
|
239
|
+
if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
|
|
240
|
+
const [, , r, g, b] = parts;
|
|
241
|
+
const luminance = (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
|
|
242
|
+
return luminance < 72;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeShikiContrast(ansi: string): string {
|
|
246
|
+
return ansi.replace(/\x1b\[([0-9;]*)m/g, (seq, params: string) => (
|
|
247
|
+
isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq
|
|
248
|
+
));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Wrap ANSI-encoded string into rows of `w` visible chars. Max `maxRows` rows; last row truncates with ›. */
|
|
252
|
+
function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = ""): string[] {
|
|
253
|
+
if (w <= 0) return [""];
|
|
254
|
+
const plain = strip(s);
|
|
255
|
+
if (plain.length <= w) {
|
|
256
|
+
const pad = w - plain.length;
|
|
257
|
+
return pad > 0 ? [s + fillBg + " ".repeat(pad) + (fillBg ? RST : "")] : [s];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const rows: string[] = [];
|
|
261
|
+
let row = "", vis = 0, i = 0;
|
|
262
|
+
let onLastRow = false;
|
|
263
|
+
let effW = w;
|
|
264
|
+
|
|
265
|
+
while (i < s.length) {
|
|
266
|
+
// When we reach the last allowed row, reserve 1 char for › indicator
|
|
267
|
+
if (!onLastRow && rows.length >= maxRows - 1) {
|
|
268
|
+
onLastRow = true;
|
|
269
|
+
effW = w > 2 ? w - 1 : w;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Pass through ANSI escapes
|
|
273
|
+
if (s[i] === "\x1b") {
|
|
274
|
+
const end = s.indexOf("m", i);
|
|
275
|
+
if (end !== -1) { row += s.slice(i, end + 1); i = end + 1; continue; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Row full
|
|
279
|
+
if (vis >= effW) {
|
|
280
|
+
if (onLastRow) {
|
|
281
|
+
// Check if remaining string has visible chars
|
|
282
|
+
let hasMore = false;
|
|
283
|
+
for (let j = i; j < s.length; j++) {
|
|
284
|
+
if (s[j] === "\x1b") { const e2 = s.indexOf("m", j); if (e2 !== -1) { j = e2; continue; } }
|
|
285
|
+
hasMore = true; break;
|
|
286
|
+
}
|
|
287
|
+
if (hasMore && w > 2) row += RST + FG_DIM + "›" + RST;
|
|
288
|
+
else row += fillBg + " ".repeat(Math.max(0, w - vis)) + RST;
|
|
289
|
+
rows.push(row);
|
|
290
|
+
return rows;
|
|
291
|
+
}
|
|
292
|
+
// Normal wrap — carry ANSI state forward
|
|
293
|
+
const state = ansiState(row);
|
|
294
|
+
rows.push(row + RST);
|
|
295
|
+
row = state + fillBg;
|
|
296
|
+
vis = 0;
|
|
297
|
+
if (rows.length >= maxRows - 1) { onLastRow = true; effW = w > 2 ? w - 1 : w; }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
row += s[i]; vis++; i++;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Final row, padded
|
|
304
|
+
if (row.length > 0 || rows.length === 0) {
|
|
305
|
+
rows.push(row + fillBg + " ".repeat(Math.max(0, w - vis)) + RST);
|
|
306
|
+
}
|
|
307
|
+
return rows;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function lnum(n: number | null, w: number, fg = FG_LNUM): string {
|
|
311
|
+
if (n === null) return " ".repeat(w);
|
|
312
|
+
const v = String(n);
|
|
313
|
+
return `${fg}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function shortPath(cwd: string, home: string, p: string): string {
|
|
317
|
+
if (!p) return "";
|
|
318
|
+
const r = relative(cwd, p);
|
|
319
|
+
if (!r.startsWith("..") && !r.startsWith("/")) return r;
|
|
320
|
+
return p.replace(home, "~");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function summarize(a: number, d: number): string {
|
|
324
|
+
const p: string[] = [];
|
|
325
|
+
if (a > 0) p.push(`${FG_ADD}+${a}${RST}`);
|
|
326
|
+
if (d > 0) p.push(`${FG_DEL}-${d}${RST}`);
|
|
327
|
+
return p.length ? p.join(" ") : `${FG_DIM}no changes${RST}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function rule(w: number): string {
|
|
331
|
+
return `${FG_RULE}${"─".repeat(w)}${RST}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Decide whether split view is readable for the given terminal width.
|
|
336
|
+
* Prefers split view — side-by-side is always easier to scan.
|
|
337
|
+
* Falls back to unified only when code columns would be too cramped
|
|
338
|
+
* or too many lines would wrap even with adaptive truncation.
|
|
339
|
+
*/
|
|
340
|
+
function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
|
|
341
|
+
if (!diff.lines.length) return false;
|
|
342
|
+
if (tw < SPLIT_MIN_WIDTH) return false;
|
|
343
|
+
|
|
344
|
+
const nw = Math.max(2, String(Math.max(...diff.lines.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
345
|
+
const half = Math.floor((tw - 1) / 2); // -1 for center divider
|
|
346
|
+
const gw = nw + 5; // border + num + sign + sp + │ + sp
|
|
347
|
+
const cw = Math.max(12, half - gw);
|
|
348
|
+
if (cw < SPLIT_MIN_CODE_WIDTH) return false;
|
|
349
|
+
|
|
350
|
+
// Estimate how many lines would need wrapping at this code width
|
|
351
|
+
const vis = diff.lines.slice(0, maxRows);
|
|
352
|
+
let contentLines = 0;
|
|
353
|
+
let wrapCandidates = 0;
|
|
354
|
+
for (const l of vis) {
|
|
355
|
+
if (l.type === "sep") continue;
|
|
356
|
+
contentLines++;
|
|
357
|
+
if (tabs(l.content).length > cw) wrapCandidates++;
|
|
358
|
+
}
|
|
359
|
+
if (contentLines === 0) return true;
|
|
360
|
+
|
|
361
|
+
const wrapRatio = wrapCandidates / contentLines;
|
|
362
|
+
if (wrapCandidates >= SPLIT_MAX_WRAP_LINES) return false;
|
|
363
|
+
if (wrapRatio >= SPLIT_MAX_WRAP_RATIO) return false;
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Language detection
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
const EXT_LANG: Record<string, BundledLanguage> = {
|
|
372
|
+
ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
|
|
373
|
+
mjs: "javascript", cjs: "javascript",
|
|
374
|
+
py: "python", rb: "ruby", rs: "rust", go: "go", java: "java",
|
|
375
|
+
c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
|
|
376
|
+
swift: "swift", kt: "kotlin",
|
|
377
|
+
html: "html", css: "css", scss: "scss",
|
|
378
|
+
json: "json", yaml: "yaml", yml: "yaml", toml: "toml",
|
|
379
|
+
md: "markdown", sql: "sql", sh: "bash", bash: "bash", zsh: "bash",
|
|
380
|
+
lua: "lua", php: "php", dart: "dart", xml: "xml",
|
|
381
|
+
graphql: "graphql", svelte: "svelte", vue: "vue",
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
function lang(fp: string): BundledLanguage | undefined {
|
|
385
|
+
return EXT_LANG[extname(fp).slice(1).toLowerCase()];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// Shiki ANSI cache + pre-warm
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
// Pre-warm the Shiki singleton (loads WASM grammars + theme) so the first
|
|
393
|
+
// diff render doesn't pay the ~200-500ms startup cost.
|
|
394
|
+
codeToANSI("", "typescript", THEME).catch(() => {});
|
|
395
|
+
|
|
396
|
+
const _cache = new Map<string, string[]>();
|
|
397
|
+
|
|
398
|
+
function _touch(k: string, v: string[]): string[] {
|
|
399
|
+
_cache.delete(k); _cache.set(k, v);
|
|
400
|
+
while (_cache.size > CACHE_LIMIT) {
|
|
401
|
+
const first = _cache.keys().next().value;
|
|
402
|
+
if (first === undefined) break;
|
|
403
|
+
_cache.delete(first);
|
|
404
|
+
}
|
|
405
|
+
return v;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
|
|
409
|
+
if (!code) return [""];
|
|
410
|
+
if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
|
|
411
|
+
|
|
412
|
+
const k = `${THEME}\0${language}\0${code}`;
|
|
413
|
+
const hit = _cache.get(k);
|
|
414
|
+
if (hit) return _touch(k, hit);
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME));
|
|
418
|
+
const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
|
|
419
|
+
return _touch(k, out);
|
|
420
|
+
} catch {
|
|
421
|
+
return code.split("\n");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Diff parsing
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff {
|
|
430
|
+
const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctx });
|
|
431
|
+
const lines: DiffLine[] = [];
|
|
432
|
+
let added = 0, removed = 0;
|
|
433
|
+
|
|
434
|
+
for (let hi = 0; hi < patch.hunks.length; hi++) {
|
|
435
|
+
if (hi > 0) {
|
|
436
|
+
const prev = patch.hunks[hi - 1];
|
|
437
|
+
const gap = patch.hunks[hi].oldStart - (prev.oldStart + prev.oldLines);
|
|
438
|
+
lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" });
|
|
439
|
+
}
|
|
440
|
+
const h = patch.hunks[hi];
|
|
441
|
+
let oL = h.oldStart, nL = h.newStart;
|
|
442
|
+
for (const raw of h.lines) {
|
|
443
|
+
if (raw === "\") continue;
|
|
444
|
+
const ch = raw[0], text = raw.slice(1);
|
|
445
|
+
if (ch === "+") { lines.push({ type: "add", oldNum: null, newNum: nL++, content: text }); added++; }
|
|
446
|
+
else if (ch === "-") { lines.push({ type: "del", oldNum: oL++, newNum: null, content: text }); removed++; }
|
|
447
|
+
else { lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text }); }
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return { lines, added, removed, chars: oldContent.length + newContent.length };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// Word diff + bg injection
|
|
455
|
+
//
|
|
456
|
+
// Key insight: Shiki's codeToANSI only emits fg codes (\x1b[38;...m and
|
|
457
|
+
// \x1b[39m). It never sets backgrounds. So we can layer a diff bg underneath
|
|
458
|
+
// and it persists through all fg switches. For word-level emphasis we swap
|
|
459
|
+
// the bg to a brighter shade at changed character positions.
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Combined word diff analysis — single Diff.diffWords() call returns both
|
|
464
|
+
* similarity score and character ranges for emphasis highlighting.
|
|
465
|
+
* Replaces separate wordDiffRanges + wordDiffSimilarity (which called diffWords twice).
|
|
466
|
+
*/
|
|
467
|
+
function wordDiffAnalysis(a: string, b: string): {
|
|
468
|
+
similarity: number;
|
|
469
|
+
oldRanges: Array<[number, number]>;
|
|
470
|
+
newRanges: Array<[number, number]>;
|
|
471
|
+
} {
|
|
472
|
+
if (!a && !b) return { similarity: 1, oldRanges: [], newRanges: [] };
|
|
473
|
+
const parts = Diff.diffWords(a, b);
|
|
474
|
+
const oldRanges: Array<[number, number]> = [];
|
|
475
|
+
const newRanges: Array<[number, number]> = [];
|
|
476
|
+
let oPos = 0, nPos = 0, same = 0;
|
|
477
|
+
for (const p of parts) {
|
|
478
|
+
if (p.removed) { oldRanges.push([oPos, oPos + p.value.length]); oPos += p.value.length; }
|
|
479
|
+
else if (p.added) { newRanges.push([nPos, nPos + p.value.length]); nPos += p.value.length; }
|
|
480
|
+
else { const len = p.value.length; same += len; oPos += len; nPos += len; }
|
|
481
|
+
}
|
|
482
|
+
const maxLen = Math.max(a.length, b.length);
|
|
483
|
+
return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Inject diff background into Shiki ANSI output.
|
|
488
|
+
* `baseBg` on unchanged spans, `hlBg` on changed character ranges.
|
|
489
|
+
* Re-injects bg after any full reset (\x1b[0m).
|
|
490
|
+
*
|
|
491
|
+
* Uses sorted-range pointer scan instead of Set (avoids O(totalChars) Set creation).
|
|
492
|
+
*/
|
|
493
|
+
function injectBg(
|
|
494
|
+
ansiLine: string,
|
|
495
|
+
ranges: Array<[number, number]>,
|
|
496
|
+
baseBg: string,
|
|
497
|
+
hlBg: string,
|
|
498
|
+
): string {
|
|
499
|
+
if (!ranges.length) return baseBg + ansiLine + RST;
|
|
500
|
+
|
|
501
|
+
let out = baseBg;
|
|
502
|
+
let vis = 0;
|
|
503
|
+
let inHL = false;
|
|
504
|
+
let ri = 0; // current range index
|
|
505
|
+
let i = 0;
|
|
506
|
+
|
|
507
|
+
while (i < ansiLine.length) {
|
|
508
|
+
if (ansiLine[i] === "\x1b") {
|
|
509
|
+
const m = ansiLine.indexOf("m", i);
|
|
510
|
+
if (m !== -1) {
|
|
511
|
+
const seq = ansiLine.slice(i, m + 1);
|
|
512
|
+
out += seq;
|
|
513
|
+
// Re-inject bg after full reset
|
|
514
|
+
if (seq === "\x1b[0m") out += inHL ? hlBg : baseBg;
|
|
515
|
+
i = m + 1;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// Advance past exhausted ranges
|
|
520
|
+
while (ri < ranges.length && vis >= ranges[ri][1]) ri++;
|
|
521
|
+
const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1];
|
|
522
|
+
if (want !== inHL) { inHL = want; out += inHL ? hlBg : baseBg; }
|
|
523
|
+
out += ansiLine[i];
|
|
524
|
+
vis++; i++;
|
|
525
|
+
}
|
|
526
|
+
return out + RST;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Simple word diff (no syntax hl) — fallback when Shiki isn't available. */
|
|
530
|
+
function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
|
|
531
|
+
const parts = Diff.diffWords(oldText, newText);
|
|
532
|
+
let o = "", n = "";
|
|
533
|
+
for (const p of parts) {
|
|
534
|
+
if (p.removed) o += `${BG_DEL_W}${p.value}${RST}${BG_DEL}`;
|
|
535
|
+
else if (p.added) n += `${BG_ADD_W}${p.value}${RST}${BG_ADD}`;
|
|
536
|
+
else { o += p.value; n += p.value; }
|
|
537
|
+
}
|
|
538
|
+
return { old: o, new: n };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// Stacked (unified) view — clean single-column layout
|
|
543
|
+
//
|
|
544
|
+
// Modelled after Shiki diff/GitHub stacked view:
|
|
545
|
+
// • Single line-number column (shows old num for del/ctx, new num for add)
|
|
546
|
+
// • Compact gutter: "NNN-│" or "NNN+│" or "NNN │"
|
|
547
|
+
// • Full-width code — no side-by-side cramming
|
|
548
|
+
// • Hunk separators as "··· N unmodified lines ···"
|
|
549
|
+
// • Paired del/add lines adjacent with word-level emphasis
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
async function renderUnified(diff: ParsedDiff, language: BundledLanguage | undefined, max = MAX_RENDER_LINES, dc: DiffColors = DEFAULT_DIFF_COLORS): Promise<string> {
|
|
553
|
+
if (!diff.lines.length) return "";
|
|
554
|
+
|
|
555
|
+
const vis = diff.lines.slice(0, max);
|
|
556
|
+
const tw = termW();
|
|
557
|
+
const nw = Math.max(2, String(Math.max(...vis.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
558
|
+
const gw = nw + 5; // border + num + sign + sp + │ + sp
|
|
559
|
+
const cw = Math.max(20, tw - gw);
|
|
560
|
+
const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
|
|
561
|
+
|
|
562
|
+
// Build separate old/new code blocks for highlighting
|
|
563
|
+
const oldSrc: string[] = [], newSrc: string[] = [];
|
|
564
|
+
for (const l of vis) {
|
|
565
|
+
if (l.type === "ctx" || l.type === "del") oldSrc.push(l.content);
|
|
566
|
+
if (l.type === "ctx" || l.type === "add") newSrc.push(l.content);
|
|
567
|
+
}
|
|
568
|
+
const [oldHL, newHL] = canHL
|
|
569
|
+
? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
|
|
570
|
+
: [oldSrc, newSrc];
|
|
571
|
+
|
|
572
|
+
let oI = 0, nI = 0, idx = 0;
|
|
573
|
+
const out: string[] = [];
|
|
574
|
+
out.push(rule(tw));
|
|
575
|
+
|
|
576
|
+
/** Emit a single stacked row with compact gutter + left border bar. */
|
|
577
|
+
function emitRow(num: number | null, sign: string, gutterBg: string, signFg: string, body: string, bodyBg = ""): void {
|
|
578
|
+
const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
|
|
579
|
+
const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : `${BG_DEFAULT} `;
|
|
580
|
+
const numFg = borderFg || FG_LNUM;
|
|
581
|
+
const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${signFg}${sign}${RST} ${DIVIDER} `;
|
|
582
|
+
const contGutter = `${border}${gutterBg}${" ".repeat(nw + 1)}${RST} ${DIVIDER} `;
|
|
583
|
+
const rows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), bodyBg);
|
|
584
|
+
out.push(`${gutter}${rows[0]}${RST}`);
|
|
585
|
+
for (let r = 1; r < rows.length; r++) out.push(`${contGutter}${rows[r]}${RST}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
while (idx < vis.length) {
|
|
589
|
+
const l = vis[idx];
|
|
590
|
+
|
|
591
|
+
// Hunk separator — collapsed context
|
|
592
|
+
if (l.type === "sep") {
|
|
593
|
+
const gap = l.newNum;
|
|
594
|
+
const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
|
|
595
|
+
const totalW = Math.min(tw, 72);
|
|
596
|
+
const pad = Math.max(0, totalW - label.length - 2);
|
|
597
|
+
const half1 = Math.floor(pad / 2), half2 = pad - half1;
|
|
598
|
+
out.push(`${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${RST}`);
|
|
599
|
+
idx++; continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Context line — dimmed, single line number
|
|
603
|
+
if (l.type === "ctx") {
|
|
604
|
+
const hl = oldHL[oI] ?? l.content;
|
|
605
|
+
emitRow(l.newNum, " ", BG_DEFAULT, dc.fgCtx, `${BG_DEFAULT}${DIM}${hl}`, BG_DEFAULT);
|
|
606
|
+
oI++; nI++; idx++; continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Collect del/add blocks
|
|
610
|
+
const dels: Array<{ l: DiffLine; hl: string }> = [];
|
|
611
|
+
while (idx < vis.length && vis[idx].type === "del") {
|
|
612
|
+
dels.push({ l: vis[idx], hl: oldHL[oI] ?? vis[idx].content });
|
|
613
|
+
oI++; idx++;
|
|
614
|
+
}
|
|
615
|
+
const adds: Array<{ l: DiffLine; hl: string }> = [];
|
|
616
|
+
while (idx < vis.length && vis[idx].type === "add") {
|
|
617
|
+
adds.push({ l: vis[idx], hl: newHL[nI] ?? vis[idx].content });
|
|
618
|
+
nI++; idx++;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 1:1 paired → word diff emphasis
|
|
622
|
+
const isPaired = dels.length === 1 && adds.length === 1;
|
|
623
|
+
const wd = isPaired ? wordDiffAnalysis(dels[0].l.content, adds[0].l.content) : null;
|
|
624
|
+
|
|
625
|
+
if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
|
|
626
|
+
const delBody = injectBg(dels[0].hl, wd.oldRanges, BG_DEL, BG_DEL_W);
|
|
627
|
+
const addBody = injectBg(adds[0].hl, wd.newRanges, BG_ADD, BG_ADD_W);
|
|
628
|
+
emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, delBody, BG_DEL);
|
|
629
|
+
emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, addBody, BG_ADD);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
633
|
+
const pwd = plainWordDiff(dels[0].l.content, adds[0].l.content);
|
|
634
|
+
emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, `${BG_DEL}${pwd.old}`, BG_DEL);
|
|
635
|
+
emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, `${BG_ADD}${pwd.new}`, BG_ADD);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Multi-line blocks — syntax highlighted with diff bg
|
|
640
|
+
for (const d of dels) {
|
|
641
|
+
const body = canHL ? `${BG_DEL}${d.hl}` : `${BG_DEL}${d.l.content}`;
|
|
642
|
+
emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, body, BG_DEL);
|
|
643
|
+
}
|
|
644
|
+
for (const a of adds) {
|
|
645
|
+
const body = canHL ? `${BG_ADD}${a.hl}` : `${BG_ADD}${a.l.content}`;
|
|
646
|
+
emitRow(a.l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, body, BG_ADD);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
out.push(rule(tw));
|
|
651
|
+
if (diff.lines.length > vis.length) {
|
|
652
|
+
out.push(`${FG_DIM} … ${diff.lines.length - vis.length} more lines${RST}`);
|
|
653
|
+
}
|
|
654
|
+
return out.join("\n");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// Split view (auto-fallback to unified when narrow)
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
async function renderSplit(diff: ParsedDiff, language: BundledLanguage | undefined, max = MAX_PREVIEW_LINES, dc: DiffColors = DEFAULT_DIFF_COLORS): Promise<string> {
|
|
662
|
+
const tw = termW();
|
|
663
|
+
if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc);
|
|
664
|
+
if (!diff.lines.length) return "";
|
|
665
|
+
|
|
666
|
+
// Build rows
|
|
667
|
+
type Row = { left: DiffLine | null; right: DiffLine | null };
|
|
668
|
+
const rows: Row[] = [];
|
|
669
|
+
let i = 0;
|
|
670
|
+
while (i < diff.lines.length) {
|
|
671
|
+
const l = diff.lines[i];
|
|
672
|
+
if (l.type === "sep" || l.type === "ctx") { rows.push({ left: l, right: l }); i++; continue; }
|
|
673
|
+
const dels: DiffLine[] = [], adds: DiffLine[] = [];
|
|
674
|
+
while (i < diff.lines.length && diff.lines[i].type === "del") { dels.push(diff.lines[i]); i++; }
|
|
675
|
+
while (i < diff.lines.length && diff.lines[i].type === "add") { adds.push(diff.lines[i]); i++; }
|
|
676
|
+
const n = Math.max(dels.length, adds.length);
|
|
677
|
+
for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const vis = rows.slice(0, max);
|
|
681
|
+
const half = Math.floor((tw - 1) / 2); // -1 for center divider
|
|
682
|
+
const nw = Math.max(2, String(Math.max(...diff.lines.map(l => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
683
|
+
const gw = nw + 5; // border + num + sign + sp + │ + sp
|
|
684
|
+
const cw = Math.max(12, half - gw);
|
|
685
|
+
const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
|
|
686
|
+
|
|
687
|
+
// Build separate code blocks per side
|
|
688
|
+
const leftSrc: string[] = [], rightSrc: string[] = [];
|
|
689
|
+
for (const r of vis) {
|
|
690
|
+
if (r.left && r.left.type !== "sep") leftSrc.push(r.left.content);
|
|
691
|
+
if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content);
|
|
692
|
+
}
|
|
693
|
+
const [leftHL, rightHL] = canHL
|
|
694
|
+
? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
|
|
695
|
+
: [leftSrc, rightSrc];
|
|
696
|
+
|
|
697
|
+
let lI = 0, rI = 0;
|
|
698
|
+
let stripeRow = 0; // tracks row index for diagonal stripe offset
|
|
699
|
+
|
|
700
|
+
// Returns { gutter, contGutter, body } for wrapping composition
|
|
701
|
+
type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] };
|
|
702
|
+
|
|
703
|
+
function half_build(line: DiffLine | null, hl: string, ranges: Array<[number, number]> | null, side: "left" | "right"): HalfResult {
|
|
704
|
+
// Empty filler — diagonal stripes
|
|
705
|
+
if (!line) {
|
|
706
|
+
const gw2 = nw + 2; // number + sign + space before │
|
|
707
|
+
const gPat = FG_STRIPE + "╱".repeat(gw2) + RST;
|
|
708
|
+
const g = ` ${gPat}${FG_RULE}│${RST} `;
|
|
709
|
+
return { gutter: g, contGutter: g, bodyRows: [stripes(cw, stripeRow)] };
|
|
710
|
+
}
|
|
711
|
+
// Hunk separator
|
|
712
|
+
if (line.type === "sep") {
|
|
713
|
+
const gap = line.newNum;
|
|
714
|
+
const label = gap && gap > 0 ? `··· ${gap} lines ···` : "···";
|
|
715
|
+
const g = ` ${FG_DIM}${fit("", nw + 2)}${RST}${FG_RULE}│${RST} `;
|
|
716
|
+
return { gutter: g, contGutter: g, bodyRows: [`${FG_DIM}${fit(label, cw)}${RST}`] };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const isDel = line.type === "del", isAdd = line.type === "add";
|
|
720
|
+
const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_DEFAULT;
|
|
721
|
+
const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_DEFAULT;
|
|
722
|
+
const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
|
|
723
|
+
const sign = isDel ? "-" : isAdd ? "+" : " ";
|
|
724
|
+
const num = isDel ? line.oldNum : isAdd ? line.newNum : (side === "left" ? line.oldNum : line.newNum);
|
|
725
|
+
|
|
726
|
+
// Border bar + colored line numbers for changed lines
|
|
727
|
+
const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
|
|
728
|
+
const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : ` ${BG_DEFAULT}`;
|
|
729
|
+
const numFg = borderFg || FG_LNUM;
|
|
730
|
+
|
|
731
|
+
let body: string;
|
|
732
|
+
if (ranges && ranges.length > 0) {
|
|
733
|
+
body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W);
|
|
734
|
+
} else if (isDel || isAdd) {
|
|
735
|
+
body = `${cBg}${hl}`;
|
|
736
|
+
} else {
|
|
737
|
+
body = `${BG_DEFAULT}${DIM}${hl}`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${sFg}${BOLD}${sign}${RST} ${FG_RULE}│${RST} `;
|
|
741
|
+
const contGutter = `${border}${gBg}${" ".repeat(nw + 1)}${RST} ${FG_RULE}│${RST} `;
|
|
742
|
+
const bodyRows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), cBg);
|
|
743
|
+
return { gutter, contGutter, bodyRows };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const out: string[] = [];
|
|
747
|
+
// Column headers — "old" / "new" positioned above line numbers
|
|
748
|
+
const hdrOld = `${" ".repeat(Math.max(0, nw - 2))}${dc.fgDel}${DIM}old${RST}`;
|
|
749
|
+
const hdrNew = `${" ".repeat(Math.max(0, nw - 2))}${dc.fgAdd}${DIM}new${RST}`;
|
|
750
|
+
out.push(`${hdrOld}${" ".repeat(Math.max(0, half - nw - 1))}${FG_RULE}┊${RST}${hdrNew}`);
|
|
751
|
+
out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
|
|
752
|
+
|
|
753
|
+
for (const r of vis) {
|
|
754
|
+
const leftLine = r.left, rightLine = r.right;
|
|
755
|
+
const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
|
|
756
|
+
const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null;
|
|
757
|
+
|
|
758
|
+
let lResult: HalfResult, rResult: HalfResult;
|
|
759
|
+
|
|
760
|
+
if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
|
|
761
|
+
const lhl = leftHL[lI++] ?? leftLine.content;
|
|
762
|
+
const rhl = rightHL[rI++] ?? rightLine.content;
|
|
763
|
+
lResult = half_build(leftLine, lhl, wd.oldRanges, "left");
|
|
764
|
+
rResult = half_build(rightLine, rhl, wd.newRanges, "right");
|
|
765
|
+
} else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
766
|
+
const pwd = plainWordDiff(leftLine.content, rightLine.content);
|
|
767
|
+
lI++; rI++;
|
|
768
|
+
lResult = half_build(leftLine, pwd.old, null, "left");
|
|
769
|
+
rResult = half_build(rightLine, pwd.new, null, "right");
|
|
770
|
+
} else {
|
|
771
|
+
const lhl = (leftLine && leftLine.type !== "sep") ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
|
|
772
|
+
const rhl = (rightLine && rightLine.type !== "sep") ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
|
|
773
|
+
lResult = half_build(leftLine, lhl, null, "left");
|
|
774
|
+
rResult = half_build(rightLine, rhl, null, "right");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Compose wrapped rows — pad shorter side with striped continuation rows
|
|
778
|
+
const maxRows = Math.max(lResult.bodyRows.length, rResult.bodyRows.length);
|
|
779
|
+
const leftIsEmpty = !r.left;
|
|
780
|
+
const rightIsEmpty = !r.right;
|
|
781
|
+
for (let row = 0; row < maxRows; row++) {
|
|
782
|
+
const lg = row === 0 ? lResult.gutter : lResult.contGutter;
|
|
783
|
+
const rg = row === 0 ? rResult.gutter : rResult.contGutter;
|
|
784
|
+
const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
|
|
785
|
+
const rb = rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
|
|
786
|
+
out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`);
|
|
787
|
+
stripeRow++;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`);
|
|
792
|
+
if (rows.length > vis.length) {
|
|
793
|
+
out.push(`${FG_DIM} … ${rows.length - vis.length} more lines${RST}`);
|
|
794
|
+
}
|
|
795
|
+
return out.join("\n");
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
// Extension
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
export const __testing = {
|
|
803
|
+
normalizeShikiContrast,
|
|
804
|
+
parseDiff,
|
|
805
|
+
renderSplit,
|
|
806
|
+
renderUnified,
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
export default function diffRendererExtension(pi: any): void {
|
|
810
|
+
let createWriteTool: any, createEditTool: any, TextComponent: any;
|
|
811
|
+
try {
|
|
812
|
+
const sdk = require("@mariozechner/pi-coding-agent");
|
|
813
|
+
createWriteTool = sdk.createWriteTool;
|
|
814
|
+
createEditTool = sdk.createEditTool;
|
|
815
|
+
TextComponent = require("@mariozechner/pi-tui").Text;
|
|
816
|
+
} catch { return; }
|
|
817
|
+
if (!createWriteTool || !createEditTool || !TextComponent) return;
|
|
818
|
+
|
|
819
|
+
const cwd = process.cwd();
|
|
820
|
+
const home = process.env.HOME ?? "";
|
|
821
|
+
const sp = (p: string) => shortPath(cwd, home, p);
|
|
822
|
+
|
|
823
|
+
// =======================================================================
|
|
824
|
+
// write
|
|
825
|
+
// =======================================================================
|
|
826
|
+
|
|
827
|
+
const origWrite = createWriteTool(cwd);
|
|
828
|
+
|
|
829
|
+
pi.registerTool({
|
|
830
|
+
...origWrite,
|
|
831
|
+
name: "write",
|
|
832
|
+
|
|
833
|
+
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
834
|
+
const fp = params.path ?? params.file_path ?? "";
|
|
835
|
+
let old: string | null = null;
|
|
836
|
+
try { if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8"); } catch { old = null; }
|
|
837
|
+
|
|
838
|
+
const result = await origWrite.execute(tid, params, sig, upd, ctx);
|
|
839
|
+
const content = params.content ?? "";
|
|
840
|
+
|
|
841
|
+
// Store in details — the only custom field TUI preserves in renderResult
|
|
842
|
+
if (old !== null && old !== content) {
|
|
843
|
+
const diff = parseDiff(old, content);
|
|
844
|
+
const lg = lang(fp);
|
|
845
|
+
(result as any).details = { _type: "diff", summary: summarize(diff.added, diff.removed), diff, language: lg };
|
|
846
|
+
} else if (old === null) {
|
|
847
|
+
const lineCount = content ? content.split("\n").length : 0;
|
|
848
|
+
(result as any).details = { _type: "new", lines: lineCount, content: content ?? "", filePath: fp };
|
|
849
|
+
} else if (old === content) {
|
|
850
|
+
(result as any).details = { _type: "noChange" };
|
|
851
|
+
}
|
|
852
|
+
return result;
|
|
853
|
+
},
|
|
854
|
+
|
|
855
|
+
renderCall(args: any, theme: any, ctx: any) {
|
|
856
|
+
const fp = args?.path ?? args?.file_path ?? "";
|
|
857
|
+
const isNew = !fp || !existsSync(fp);
|
|
858
|
+
const label = isNew ? "create" : "write";
|
|
859
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
860
|
+
const hdr = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
|
|
861
|
+
|
|
862
|
+
// Streaming
|
|
863
|
+
if (args?.content && !ctx.argsComplete) {
|
|
864
|
+
const n = String(args.content).split("\n").length;
|
|
865
|
+
text.setText(`${hdr} ${theme.fg("muted", `(${n} lines…)`)}`);
|
|
866
|
+
return text;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// New file preview with Shiki
|
|
870
|
+
if (args?.content && ctx.argsComplete && isNew) {
|
|
871
|
+
const previewKey = `create:${fp}:${String(args.content).length}`;
|
|
872
|
+
if (ctx.state._previewKey !== previewKey) {
|
|
873
|
+
ctx.state._previewKey = previewKey;
|
|
874
|
+
ctx.state._previewText = hdr;
|
|
875
|
+
const lg = lang(fp);
|
|
876
|
+
hlBlock(args.content, lg).then((lines: string[]) => {
|
|
877
|
+
if (ctx.state._previewKey !== previewKey) return;
|
|
878
|
+
const maxShow = ctx.expanded ? lines.length : 16;
|
|
879
|
+
const preview = lines.slice(0, maxShow).join("\n");
|
|
880
|
+
const rem = lines.length - maxShow;
|
|
881
|
+
let out = `${hdr}\n\n${preview}`;
|
|
882
|
+
if (rem > 0) out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
|
|
883
|
+
ctx.state._previewText = out;
|
|
884
|
+
ctx.invalidate();
|
|
885
|
+
}).catch(() => {});
|
|
886
|
+
}
|
|
887
|
+
text.setText(ctx.state._previewText ?? hdr);
|
|
888
|
+
return text;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
text.setText(hdr);
|
|
892
|
+
return text;
|
|
893
|
+
},
|
|
894
|
+
|
|
895
|
+
renderResult(result: any, _opt: any, theme: any, ctx: any) {
|
|
896
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
897
|
+
if (ctx.isError) {
|
|
898
|
+
const e = result.content?.filter((c: any) => c.type === "text").map((c: any) => c.text || "").join("\n") ?? "Error";
|
|
899
|
+
text.setText(`\n${theme.fg("error", e)}`);
|
|
900
|
+
return text;
|
|
901
|
+
}
|
|
902
|
+
const d = result.details;
|
|
903
|
+
if (d?._type === "diff") {
|
|
904
|
+
const w = termW();
|
|
905
|
+
const key = `wd:${w}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`;
|
|
906
|
+
if (ctx.state._wdk !== key) {
|
|
907
|
+
ctx.state._wdk = key;
|
|
908
|
+
ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
|
|
909
|
+
const dc = resolveDiffColors(theme);
|
|
910
|
+
renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc).then((rendered: string) => {
|
|
911
|
+
if (ctx.state._wdk !== key) return;
|
|
912
|
+
ctx.state._wdt = ` ${d.summary}\n${rendered}`;
|
|
913
|
+
ctx.invalidate();
|
|
914
|
+
}).catch(() => {
|
|
915
|
+
if (ctx.state._wdk !== key) return;
|
|
916
|
+
ctx.state._wdt = ` ${d.summary}`;
|
|
917
|
+
ctx.invalidate();
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
text.setText(ctx.state._wdt ?? ` ${d.summary}`);
|
|
921
|
+
return text;
|
|
922
|
+
}
|
|
923
|
+
if (d?._type === "noChange") {
|
|
924
|
+
text.setText(` ${theme.fg("muted", "✓ no changes")}`);
|
|
925
|
+
return text;
|
|
926
|
+
}
|
|
927
|
+
if (d?._type === "new") {
|
|
928
|
+
const { lines: lineCount, content: rawContent, filePath: fp } = d;
|
|
929
|
+
const pk = `nf:${fp}:${lineCount}`;
|
|
930
|
+
if (ctx.state._nfk !== pk) {
|
|
931
|
+
ctx.state._nfk = pk;
|
|
932
|
+
ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
|
|
933
|
+
const lg = lang(fp);
|
|
934
|
+
if (rawContent) {
|
|
935
|
+
hlBlock(rawContent, lg).then((hlLines: string[]) => {
|
|
936
|
+
if (ctx.state._nfk !== pk) return;
|
|
937
|
+
const maxShow = ctx.expanded ? hlLines.length : 12;
|
|
938
|
+
const preview = hlLines.slice(0, maxShow).join("\n");
|
|
939
|
+
const rem = hlLines.length - maxShow;
|
|
940
|
+
let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`;
|
|
941
|
+
if (rem > 0) out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
|
|
942
|
+
ctx.state._nft = out;
|
|
943
|
+
ctx.invalidate();
|
|
944
|
+
}).catch(() => {});
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
text.setText(ctx.state._nft ?? ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`);
|
|
948
|
+
return text;
|
|
949
|
+
}
|
|
950
|
+
text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "written").slice(0, 120))}`);
|
|
951
|
+
return text;
|
|
952
|
+
},
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// =======================================================================
|
|
956
|
+
// edit
|
|
957
|
+
// =======================================================================
|
|
958
|
+
|
|
959
|
+
const origEdit = createEditTool(cwd);
|
|
960
|
+
|
|
961
|
+
pi.registerTool({
|
|
962
|
+
...origEdit,
|
|
963
|
+
name: "edit",
|
|
964
|
+
|
|
965
|
+
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
966
|
+
const fp = params.path ?? params.file_path ?? "";
|
|
967
|
+
const oldText = params.oldText ?? params.old_text ?? "";
|
|
968
|
+
const newText = params.newText ?? params.new_text ?? "";
|
|
969
|
+
|
|
970
|
+
const result = await origEdit.execute(tid, params, sig, upd, ctx);
|
|
971
|
+
|
|
972
|
+
if (oldText && oldText !== newText) {
|
|
973
|
+
let editLine = 0;
|
|
974
|
+
try {
|
|
975
|
+
if (fp && existsSync(fp)) {
|
|
976
|
+
const f = readFileSync(fp, "utf-8");
|
|
977
|
+
const idx = f.indexOf(newText);
|
|
978
|
+
if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
|
|
979
|
+
}
|
|
980
|
+
} catch { editLine = 0; }
|
|
981
|
+
const diff = parseDiff(oldText, newText);
|
|
982
|
+
(result as any).details = { _type: "editInfo", summary: summarize(diff.added, diff.removed), editLine };
|
|
983
|
+
}
|
|
984
|
+
return result;
|
|
985
|
+
},
|
|
986
|
+
|
|
987
|
+
renderCall(args: any, theme: any, ctx: any) {
|
|
988
|
+
const fp = args?.path ?? args?.file_path ?? "";
|
|
989
|
+
const oldText = args?.oldText ?? args?.old_text ?? "";
|
|
990
|
+
const newText = args?.newText ?? args?.new_text ?? "";
|
|
991
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
992
|
+
const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
|
|
993
|
+
|
|
994
|
+
if (!(ctx.argsComplete && oldText && oldText !== newText)) {
|
|
995
|
+
text.setText(hdr);
|
|
996
|
+
return text;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const pk = JSON.stringify({ fp, oldText, newText, w: termW() });
|
|
1000
|
+
if (ctx.state._pk !== pk) {
|
|
1001
|
+
ctx.state._pk = pk;
|
|
1002
|
+
ctx.state._pt = `${hdr} ${theme.fg("muted", "(rendering…)")}`;
|
|
1003
|
+
const lg = lang(fp);
|
|
1004
|
+
const diff = parseDiff(oldText, newText);
|
|
1005
|
+
const dc = resolveDiffColors(theme);
|
|
1006
|
+
renderSplit(diff, lg, MAX_PREVIEW_LINES, dc).then((rendered) => {
|
|
1007
|
+
if (ctx.state._pk !== pk) return;
|
|
1008
|
+
ctx.state._pt = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`;
|
|
1009
|
+
ctx.invalidate();
|
|
1010
|
+
}).catch(() => {
|
|
1011
|
+
if (ctx.state._pk !== pk) return;
|
|
1012
|
+
// Fallback: plain word diff
|
|
1013
|
+
const diff2 = parseDiff(oldText, newText);
|
|
1014
|
+
ctx.state._pt = `${hdr} ${summarize(diff2.added, diff2.removed)}`;
|
|
1015
|
+
ctx.invalidate();
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
text.setText(ctx.state._pt ?? hdr);
|
|
1020
|
+
return text;
|
|
1021
|
+
},
|
|
1022
|
+
|
|
1023
|
+
renderResult(result: any, _opt: any, theme: any, ctx: any) {
|
|
1024
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1025
|
+
if (ctx.isError) {
|
|
1026
|
+
const e = result.content?.filter((c: any) => c.type === "text").map((c: any) => c.text || "").join("\n") ?? "Error";
|
|
1027
|
+
text.setText(`\n${theme.fg("error", e)}`);
|
|
1028
|
+
return text;
|
|
1029
|
+
}
|
|
1030
|
+
if (result.details?._type === "editInfo") {
|
|
1031
|
+
const { summary: s, editLine } = result.details;
|
|
1032
|
+
const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : "";
|
|
1033
|
+
const content = ` ${s}${loc}`;
|
|
1034
|
+
const vis = content.replace(ANSI_RE, "").length;
|
|
1035
|
+
const pad = Math.max(0, termW() - vis);
|
|
1036
|
+
text.setText(`${content}${" ".repeat(pad)}`);
|
|
1037
|
+
return text;
|
|
1038
|
+
}
|
|
1039
|
+
text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
|
|
1040
|
+
return text;
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
}
|
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
|
+
}
|