@pi-archimedes/diff 0.6.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/diff",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,126 @@
1
+ /** ANSI escape codes, constants, and interfaces. */
2
+
3
+ import { deriveBgFromTheme } from "./colors.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // ANSI escape codes
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const RST = "\x1b[0m";
10
+ export const BOLD = "\x1b[1m";
11
+ export const DIM = "\x1b[2m";
12
+
13
+ // Diff foregrounds — hardcoded fallbacks
14
+ export const FG_ADD = "\x1b[38;2;100;180;120m"; // desaturated green
15
+ export const FG_DEL = "\x1b[38;2;200;100;100m"; // desaturated red
16
+ export const FG_DIM = "\x1b[38;2;80;80;80m";
17
+ export const FG_LNUM = "\x1b[38;2;100;100;100m";
18
+ export const FG_RULE = "\x1b[38;2;50;50;50m";
19
+ export const FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
20
+ export const FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes
21
+
22
+ export const BORDER_BAR = "▌";
23
+ const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // DiffBg — instance-scoped background colors derived from a theme
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** All background colors needed for diff rendering, derived from a theme. */
30
+ export interface DiffBg {
31
+ bgAdd: string;
32
+ bgDel: string;
33
+ bgAddW: string; // word-level emphasis
34
+ bgDelW: string;
35
+ bgGutterAdd: string;
36
+ bgGutterDel: string;
37
+ bgEmpty: string;
38
+ bgBase: string; // tool box base bg
39
+ rst: string; // reset + base bg
40
+ divider: string;
41
+ }
42
+
43
+ /** Hardcoded fallback backgrounds. */
44
+ export const DEFAULT_DIFF_BG: DiffBg = {
45
+ bgAdd: "\x1b[48;2;22;38;32m",
46
+ bgDel: "\x1b[48;2;45;25;25m",
47
+ bgAddW: "\x1b[48;2;35;75;50m",
48
+ bgDelW: "\x1b[48;2;80;35;35m",
49
+ bgGutterAdd: "\x1b[48;2;18;32;26m",
50
+ bgGutterDel: "\x1b[48;2;38;22;22m",
51
+ bgEmpty: "\x1b[48;2;18;18;18m",
52
+ bgBase: BG_DEFAULT,
53
+ rst: "\x1b[0m",
54
+ divider: `${FG_RULE}│\x1b[0m`,
55
+ };
56
+
57
+ // Theme cache key state — lives here since it's used by resolveDiffColors.
58
+ let _lastThemeKey: string | undefined;
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Regex patterns
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const ESC_RE = "\u001b";
65
+ export const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
66
+ export const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
67
+ export const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // DiffColors — foreground colors for diff signs
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export interface DiffColors {
74
+ fgAdd: string;
75
+ fgDel: string;
76
+ fgCtx: string;
77
+ }
78
+
79
+ export const DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Theme-aware color resolution
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Reset auto-derived colors — call when theme changes. */
86
+ export function resetDiffColors(): void {
87
+ _lastThemeKey = undefined;
88
+ }
89
+
90
+ export function themeCacheKey(theme?: any): string {
91
+ if (!theme?.fg) return "no-theme";
92
+ const fgKeys = [
93
+ "toolTitle", "accent", "muted", "success", "error",
94
+ "toolDiffAdded", "toolDiffRemoved", "toolDiffContext",
95
+ ];
96
+ const bgKeys = ["toolSuccessBg", "toolErrorBg"];
97
+ const parts: string[] = [];
98
+ for (const key of fgKeys) {
99
+ try { parts.push(theme.fg(key, key)); } catch { parts.push(key); }
100
+ }
101
+ for (const key of bgKeys) {
102
+ try { parts.push(theme.bg ? theme.bg(key, key) : key); } catch { parts.push(key); }
103
+ }
104
+ return parts.join("|");
105
+ }
106
+
107
+ export { deriveBgFromTheme };
108
+
109
+ export function resolveDiffColors(theme?: any): DiffColors {
110
+ const themeKey = themeCacheKey(theme);
111
+
112
+ // Re-derive theme key cache (bg colors are now passed explicitly via DiffBg)
113
+ if (themeKey !== _lastThemeKey) {
114
+ _lastThemeKey = themeKey;
115
+ }
116
+ if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
117
+ try {
118
+ return {
119
+ fgAdd: theme.getFgAnsi("toolDiffAdded") || FG_ADD,
120
+ fgDel: theme.getFgAnsi("toolDiffRemoved") || FG_DEL,
121
+ fgCtx: theme.getFgAnsi("toolDiffContext") || FG_DIM,
122
+ };
123
+ } catch {
124
+ return DEFAULT_DIFF_COLORS;
125
+ }
126
+ }
@@ -0,0 +1,80 @@
1
+ /** Diff background color derivation from theme. */
2
+
3
+ import type { DiffBg } from "./codes.js";
4
+ import { DEFAULT_DIFF_BG, FG_RULE } from "./codes.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Color helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** Parse 24-bit ANSI color code → RGB. Works for both fg and bg escapes. */
11
+ function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
12
+ const esc = "\x1b";
13
+ const m = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
14
+ return m ? { r: +m[1]!, g: +m[2]!, b: +m[3]! } : null;
15
+ }
16
+
17
+ /** Mix an accent color into a base color at the given intensity (0.0–1.0). */
18
+ function mixBg(
19
+ base: { r: number; g: number; b: number },
20
+ accent: { r: number; g: number; b: number },
21
+ intensity: number,
22
+ ): string {
23
+ const r = Math.round(base.r + (accent.r - base.r) * intensity);
24
+ const g = Math.round(base.g + (accent.g - base.g) * intensity);
25
+ const b = Math.round(base.b + (accent.b - base.b) * intensity);
26
+ return `\x1b[48;2;${r};${g};${b}m`;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Theme-aware diff colors
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Auto-derive diff background colors from the pi theme's fg diff colors. */
34
+ export function deriveBgFromTheme(theme: any): DiffBg {
35
+ if (!theme?.getFgAnsi) return { ...DEFAULT_DIFF_BG };
36
+ try {
37
+ const fgAdd = theme.getFgAnsi("toolDiffAdded");
38
+ const fgDel = theme.getFgAnsi("toolDiffRemoved");
39
+ const addRgb = parseAnsiRgb(fgAdd);
40
+ const delRgb = parseAnsiRgb(fgDel);
41
+ if (!addRgb || !delRgb) return { ...DEFAULT_DIFF_BG };
42
+
43
+ let addBase = { r: 0, g: 0, b: 0 };
44
+ let delBase = addBase;
45
+ let bgBase = "\x1b[49m"; // BG_DEFAULT
46
+
47
+ if (theme.getBgAnsi) {
48
+ try {
49
+ const successBgAnsi = theme.getBgAnsi("toolSuccessBg");
50
+ const successParsed = parseAnsiRgb(successBgAnsi);
51
+ if (successParsed) {
52
+ addBase = successParsed;
53
+ delBase = successParsed;
54
+ bgBase = successBgAnsi;
55
+ }
56
+ } catch { /* no toolSuccessBg */ }
57
+
58
+ try {
59
+ const errorParsed = parseAnsiRgb(theme.getBgAnsi("toolErrorBg"));
60
+ if (errorParsed) delBase = errorParsed;
61
+ } catch { /* no toolErrorBg */ }
62
+ }
63
+
64
+ const rst = bgBase === "\x1b[49m" ? "\x1b[0m" : `\x1b[0m${bgBase}`;
65
+ return {
66
+ bgAdd: mixBg(addBase, addRgb, 0.08),
67
+ bgDel: mixBg(delBase, delRgb, 0.1),
68
+ bgAddW: mixBg(addBase, addRgb, 0.2),
69
+ bgDelW: mixBg(delBase, delRgb, 0.22),
70
+ bgGutterAdd: mixBg(addBase, addRgb, 0.05),
71
+ bgGutterDel: mixBg(delBase, delRgb, 0.06),
72
+ bgEmpty: bgBase,
73
+ bgBase,
74
+ rst,
75
+ divider: `${FG_RULE}│${rst}`,
76
+ };
77
+ } catch {
78
+ return { ...DEFAULT_DIFF_BG };
79
+ }
80
+ }
@@ -0,0 +1,20 @@
1
+ /** ANSI utilities — barrel re-export. */
2
+
3
+ export {
4
+ RST, BOLD, DIM,
5
+ FG_ADD, FG_DEL, FG_DIM, FG_LNUM, FG_RULE, FG_SAFE_MUTED, FG_STRIPE,
6
+ BORDER_BAR,
7
+ DEFAULT_DIFF_BG,
8
+ ANSI_RE, ANSI_CAPTURE_RE, ANSI_PARAM_CAPTURE_RE,
9
+ DEFAULT_DIFF_COLORS,
10
+ resetDiffColors, themeCacheKey, resolveDiffColors,
11
+ deriveBgFromTheme,
12
+ type DiffBg,
13
+ type DiffColors,
14
+ } from "./codes.js";
15
+
16
+ export {
17
+ strip, tabs, fit, ansiState,
18
+ isLowContrastShikiFg, normalizeShikiContrast,
19
+ stripes, lnum, rule, shortPath, summarize,
20
+ } from "./manip.js";
@@ -0,0 +1,113 @@
1
+ /** ANSI string manipulation utilities. */
2
+
3
+ import { relative } from "node:path";
4
+ import * as C from "./codes.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // ANSI manipulation
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const stripSgr = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
11
+
12
+ /** Strip all ANSI escape codes from a string. */
13
+ export const strip = stripSgr;
14
+
15
+ /** Replace tabs with 2 spaces. */
16
+ export function tabs(s: string): string {
17
+ return s.replace(/\t/g, " ");
18
+ }
19
+
20
+ /** Pad/truncate `s` to exactly `w` visible chars. ANSI-aware. */
21
+ export function fit(s: string, w: number): string {
22
+ if (w <= 0) return "";
23
+ const plain = strip(s);
24
+ if (plain.length <= w) return s + " ".repeat(w - plain.length);
25
+ const showW = w > 2 ? w - 1 : w;
26
+ let vis = 0,
27
+ i = 0;
28
+ while (i < s.length && vis < showW) {
29
+ if (s[i] === "\x1b") {
30
+ const e = s.indexOf("m", i);
31
+ if (e !== -1) {
32
+ i = e + 1;
33
+ continue;
34
+ }
35
+ }
36
+ vis++;
37
+ i++;
38
+ }
39
+ return w > 2 ? `${s.slice(0, i)}${C.RST}${C.FG_DIM}›${C.RST}` : `${s.slice(0, i)}${C.RST}`;
40
+ }
41
+
42
+ /** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
43
+ export function ansiState(s: string): string {
44
+ let fg = "",
45
+ bg = "";
46
+ for (const match of s.matchAll(C.ANSI_CAPTURE_RE)) {
47
+ const p = match[1] ?? "";
48
+ const seq = match[0] ?? "";
49
+ if (p === "0") {
50
+ fg = "";
51
+ bg = "";
52
+ } else if (p === "39") {
53
+ fg = "";
54
+ } else if (p.startsWith("38;")) {
55
+ fg = seq;
56
+ } else if (p.startsWith("48;")) {
57
+ bg = seq;
58
+ }
59
+ }
60
+ return bg + fg;
61
+ }
62
+
63
+ /** Check if a Shiki fg code is too dark to read. */
64
+ export function isLowContrastShikiFg(params: string): boolean {
65
+ if (params === "30" || params === "90") return true;
66
+ if (params === "38;5;0" || params === "38;5;8") return true;
67
+ if (!params.startsWith("38;2;")) return false;
68
+ const parts = params.split(";").map(Number);
69
+ if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
70
+ const [, , r, g, b] = parts as [number, number, number, number, number];
71
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
72
+ return luminance < 72;
73
+ }
74
+
75
+ /** Normalize Shiki ANSI output to boost low-contrast fg codes. */
76
+ export function normalizeShikiContrast(ansi: string): string {
77
+ return ansi.replace(C.ANSI_PARAM_CAPTURE_RE, (seq, params: string) =>
78
+ isLowContrastShikiFg(params) ? C.FG_SAFE_MUTED : seq,
79
+ );
80
+ }
81
+
82
+ /** Generate a dense diagonal stripe fill for empty filler cells. */
83
+ export function stripes(dbg: C.DiffBg, w: number, _rowOffset: number): string {
84
+ return dbg.bgBase + C.FG_STRIPE + "╱".repeat(w) + dbg.rst;
85
+ }
86
+
87
+ /** Format a line number, right-padded to width `w`. */
88
+ export function lnum(n: number | null, w: number, fg = C.FG_LNUM): string {
89
+ if (n === null) return " ".repeat(w);
90
+ const v = String(n);
91
+ return `${fg}${" ".repeat(Math.max(0, w - v.length))}${v}${C.RST}`;
92
+ }
93
+
94
+ /** Horizontal rule line. */
95
+ export function rule(dbg: C.DiffBg, w: number): string {
96
+ return `${dbg.bgBase}${C.FG_RULE}${"─".repeat(w)}${dbg.rst}`;
97
+ }
98
+
99
+ /** Shorten a file path relative to cwd or home. */
100
+ export function shortPath(cwd: string, home: string, p: string): string {
101
+ if (!p) return "";
102
+ const r = relative(cwd, p);
103
+ if (!r.startsWith("..") && !r.startsWith("/")) return r;
104
+ return p.replace(home, "~");
105
+ }
106
+
107
+ /** Summarize added/removed counts as colored `+N -M` string. */
108
+ export function summarize(a: number, d: number): string {
109
+ const p: string[] = [];
110
+ if (a > 0) p.push(`${C.FG_ADD}+${a}${C.RST}`);
111
+ if (d > 0) p.push(`${C.FG_DEL}-${d}${C.RST}`);
112
+ return p.length ? p.join(" ") : `${C.FG_DIM}no changes${C.RST}`;
113
+ }
package/src/core/diff.ts CHANGED
@@ -15,6 +15,10 @@ export interface ParsedDiff {
15
15
  }
16
16
 
17
17
  export function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff {
18
+ // Early return for identical content — skip expensive diff computation
19
+ if (oldContent === newContent) {
20
+ return { lines: [], added: 0, removed: 0, chars: 0 };
21
+ }
18
22
  const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctx });
19
23
  const lines: DiffLine[] = [];
20
24
  let added = 0;
@@ -0,0 +1,164 @@
1
+ /** DiffComponent — proper pi-tui Component for diff rendering. */
2
+
3
+ import type { BundledLanguage } from "shiki";
4
+ import { Box, type Component } from "@earendil-works/pi-tui";
5
+ import type { Theme } from "@earendil-works/pi-coding-agent";
6
+ import * as Ansi from "./ansi/index.js";
7
+ import type { DiffBg, DiffColors } from "./ansi/index.js";
8
+ import { DEFAULT_DIFF_COLORS, DEFAULT_DIFF_BG, deriveBgFromTheme } from "./ansi/index.js";
9
+ import { renderSplitLines, MAX_RENDER_LINES } from "./render/index.js";
10
+ import type { ParsedDiff } from "./core/diff.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // DiffComponent
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * A pi-tui Component that renders a diff with Shiki syntax highlighting.
18
+ *
19
+ * Rendering is lazy: `render(width)` returns an array of lines computed from
20
+ * the diff data. Shiki highlighting is cached (LRU), so repeated renders at
21
+ * different widths are cheap after the first call.
22
+ *
23
+ * The component wraps its output in a `Box` with the theme-derived base
24
+ * background, so it is visually self-contained regardless of the tool shell.
25
+ *
26
+ * The first render may show a placeholder if Shiki hasn't warmed up yet.
27
+ * Subsequent renders (triggered by `invalidate()` + TUI re-render cycle)
28
+ * show the full diff.
29
+ *
30
+ * Usage in a tool renderResult:
31
+ *
32
+ * ```ts
33
+ * renderResult(result, options, theme, context) {
34
+ * const d = result.details;
35
+ * if (d?._type === "diff") {
36
+ * const comp = context.lastComponent ?? new DiffComponent(d.diff, d.language, theme);
37
+ * return comp;
38
+ * }
39
+ * }
40
+ * ```
41
+ */
42
+ export class DiffComponent implements Component {
43
+ private diff: ParsedDiff;
44
+ private language: BundledLanguage | undefined;
45
+ private theme: Theme;
46
+ private maxLines: number;
47
+
48
+ /** Box wrapper with base background — makes the diff self-contained. */
49
+ private shell: Box;
50
+
51
+ /** Pending render promise — reused across width changes. */
52
+ private _renderPromise: Promise<string[]> | null = null;
53
+ /** Resolved raw diff lines (without box wrapping). */
54
+ private _rawLines: string[] | null = null;
55
+ /** Width at which output was last computed. */
56
+ private _cachedWidth: number | undefined;
57
+ /** Final cached output (box-wrapped). */
58
+ private _cachedLines: string[] | undefined;
59
+
60
+ /** Theme cache key — invalidates derived colors on theme change. */
61
+ private _themeKey: string | undefined;
62
+
63
+ constructor(
64
+ diff: ParsedDiff,
65
+ language: BundledLanguage | undefined,
66
+ theme: Theme,
67
+ maxLines: number = MAX_RENDER_LINES,
68
+ ) {
69
+ this.diff = diff;
70
+ this.language = language;
71
+ this.theme = theme;
72
+ this.maxLines = maxLines;
73
+
74
+ // Box provides base background so the diff is self-contained.
75
+ // Background function is re-derived on each render via _deriveBg().
76
+ this.shell = new Box(0, 0, (s: string) => this._deriveBg().bgBase + s + this._deriveBg().rst);
77
+ }
78
+
79
+ /** Re-derive colors from the current theme — handles theme changes. */
80
+ private _deriveColors(): { dc: DiffColors; dbg: DiffBg } {
81
+ const themeKey = Ansi.themeCacheKey(this.theme);
82
+ if (themeKey === this._themeKey && this._cachedDc && this._cachedDbg) {
83
+ return { dc: this._cachedDc, dbg: this._cachedDbg };
84
+ }
85
+ this._themeKey = themeKey;
86
+ this._cachedDc = Ansi.resolveDiffColors(this.theme);
87
+ this._cachedDbg = deriveBgFromTheme(this.theme);
88
+ return { dc: this._cachedDc, dbg: this._cachedDbg };
89
+ }
90
+
91
+ private _cachedDc: DiffColors | undefined;
92
+ private _cachedDbg: DiffBg | undefined;
93
+
94
+ private _deriveBg(): DiffBg {
95
+ return this._deriveColors().dbg;
96
+ }
97
+
98
+ render(width: number): string[] {
99
+ // Use cached output if width unchanged
100
+ if (this._cachedLines && this._cachedWidth === width) {
101
+ return this._cachedLines;
102
+ }
103
+
104
+ // If we have resolved raw lines, wrap them in the box
105
+ if (this._rawLines) {
106
+ this._updateShell(this._rawLines);
107
+ const lines = this.shell.render(width);
108
+ this._cachedWidth = width;
109
+ this._cachedLines = lines;
110
+ return lines;
111
+ }
112
+
113
+ // If we have a pending promise, we haven't resolved yet — show placeholder
114
+ if (this._renderPromise) {
115
+ this._updateShell([Ansi.FG_DIM + " rendering diff…" + Ansi.RST]);
116
+ return this.shell.render(width);
117
+ }
118
+
119
+ // Start async render for this width
120
+ const { dc, dbg } = this._deriveColors();
121
+ const promise = renderSplitLines(
122
+ this.diff,
123
+ this.language,
124
+ width,
125
+ this.maxLines,
126
+ dc,
127
+ dbg,
128
+ );
129
+ this._renderPromise = promise;
130
+
131
+ // Store resolved lines
132
+ promise.then((lines) => {
133
+ this._rawLines = lines;
134
+ this._renderPromise = null;
135
+ this.invalidate();
136
+ }).catch(() => {
137
+ this._rawLines = [Ansi.FG_DIM + " diff render failed" + Ansi.RST];
138
+ this._renderPromise = null;
139
+ this.invalidate();
140
+ });
141
+
142
+ // Show placeholder while waiting
143
+ this._updateShell([Ansi.FG_DIM + " rendering diff…" + Ansi.RST]);
144
+ return this.shell.render(width);
145
+ }
146
+
147
+ private _updateShell(lines: string[]): void {
148
+ this.shell.clear();
149
+ // Join lines into a single text block; each line is a row.
150
+ const textComponent = new (class implements Component {
151
+ render(_w: number): string[] { return lines; }
152
+ invalidate(): void {}
153
+ })();
154
+ this.shell.addChild(textComponent);
155
+ }
156
+
157
+ invalidate(): void {
158
+ this._cachedWidth = undefined;
159
+ this._cachedLines = undefined;
160
+ this._themeKey = undefined; // Force re-derive on theme change
161
+ this._cachedDc = undefined;
162
+ this._cachedDbg = undefined;
163
+ }
164
+ }