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