@pi-unipi/utility 2.6.0 → 2.6.2

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/src/diff/theme.ts DELETED
@@ -1,316 +0,0 @@
1
- /**
2
- * @pi-unipi/utility — Diff Theme System
3
- *
4
- * Color presets, resolution chain, and hex ↔ ANSI conversion for diff rendering.
5
- *
6
- * Resolution chain: env vars → per-color overrides → preset → auto-derive → hardcoded
7
- */
8
-
9
- import { readDiffSettings, type DiffSettings } from "./settings.js";
10
-
11
- // ─── Types ──────────────────────────────────────────────────────────────────────
12
-
13
- /** Diff color configuration */
14
- export interface DiffColors {
15
- /** Background for added lines */
16
- addBg: string;
17
- /** Foreground for added line content */
18
- addFg: string;
19
- /** Background for removed lines */
20
- remBg: string;
21
- /** Foreground for removed line content */
22
- remFg: string;
23
- /** Background for added word-level highlights */
24
- addWordBg: string;
25
- /** Background for removed word-level highlights */
26
- remWordBg: string;
27
- /** Hunk header foreground */
28
- hunkFg: string;
29
- /** Header info foreground */
30
- headerFg: string;
31
- }
32
-
33
- /** Diff theme preset */
34
- export interface DiffPreset {
35
- name: string;
36
- description: string;
37
- colors: DiffColors;
38
- }
39
-
40
- /** An ANSI color code (e.g. "\x1b[38;2;255;0;0m") */
41
- type AnsiColor = string;
42
-
43
- // ─── Built-in Presets ───────────────────────────────────────────────────────────
44
-
45
- const PRESETS: Record<string, DiffPreset> = {
46
- default: {
47
- name: "default",
48
- description: "Classic green/red diff colors",
49
- colors: {
50
- addBg: "#1a3a1a",
51
- addFg: "#b5e8b5",
52
- remBg: "#3a1a1a",
53
- remFg: "#e8b5b5",
54
- addWordBg: "#2d5a2d",
55
- remWordBg: "#5a2d2d",
56
- hunkFg: "#8888ff",
57
- headerFg: "#888888",
58
- },
59
- },
60
- midnight: {
61
- name: "midnight",
62
- description: "Deep blue-tinted diff colors",
63
- colors: {
64
- addBg: "#0a2a3a",
65
- addFg: "#a5d8e8",
66
- remBg: "#3a0a1a",
67
- remFg: "#e8a5c5",
68
- addWordBg: "#1a4a5a",
69
- remWordBg: "#5a1a3a",
70
- hunkFg: "#6688cc",
71
- headerFg: "#666688",
72
- },
73
- },
74
- subtle: {
75
- name: "subtle",
76
- description: "Muted, low-contrast diff colors",
77
- colors: {
78
- addBg: "#1e2e1e",
79
- addFg: "#a0b8a0",
80
- remBg: "#2e1e1e",
81
- remFg: "#b8a0a0",
82
- addWordBg: "#2a3a2a",
83
- remWordBg: "#3a2a2a",
84
- hunkFg: "#7777aa",
85
- headerFg: "#777777",
86
- },
87
- },
88
- neon: {
89
- name: "neon",
90
- description: "High-contrast vivid diff colors",
91
- colors: {
92
- addBg: "#003300",
93
- addFg: "#66ff66",
94
- remBg: "#330000",
95
- remFg: "#ff6666",
96
- addWordBg: "#005500",
97
- remWordBg: "#550000",
98
- hunkFg: "#6666ff",
99
- headerFg: "#999999",
100
- },
101
- },
102
- };
103
-
104
- // ─── Preset Access ──────────────────────────────────────────────────────────────
105
-
106
- /**
107
- * Get a diff preset by name. Falls back to "default" if not found.
108
- */
109
- export function getPreset(name: string): DiffPreset {
110
- return PRESETS[name] ?? PRESETS.default;
111
- }
112
-
113
- /**
114
- * Get all available preset names.
115
- */
116
- export function getPresetNames(): string[] {
117
- return Object.keys(PRESETS);
118
- }
119
-
120
- /**
121
- * Get all presets with their metadata.
122
- */
123
- export function getAllPresets(): DiffPreset[] {
124
- return Object.values(PRESETS);
125
- }
126
-
127
- // ─── Hex ↔ ANSI Conversion ──────────────────────────────────────────────────────
128
-
129
- /**
130
- * Parse a hex color string (#RRGGBB or #RGB) to [r, g, b].
131
- */
132
- export function hexToRgb(hex: string): [number, number, number] {
133
- let h = hex.replace(/^#/, "");
134
- if (h.length === 3) {
135
- h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
136
- }
137
- const r = parseInt(h.substring(0, 2), 16);
138
- const g = parseInt(h.substring(2, 4), 16);
139
- const b = parseInt(h.substring(4, 6), 16);
140
- return [r, g, b];
141
- }
142
-
143
- /**
144
- * Convert [r, g, b] to hex string (#RRGGBB).
145
- */
146
- export function rgbToHex(r: number, g: number, b: number): string {
147
- const toHex = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
148
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
149
- }
150
-
151
- /**
152
- * Convert a hex color to ANSI 24-bit foreground escape.
153
- */
154
- export function hexToFgAnsi(hex: string): AnsiColor {
155
- const [r, g, b] = hexToRgb(hex);
156
- return `\x1b[38;2;${r};${g};${b}m`;
157
- }
158
-
159
- /**
160
- * Convert a hex color to ANSI 24-bit background escape.
161
- */
162
- export function hexToBgAnsi(hex: string): AnsiColor {
163
- const [r, g, b] = hexToRgb(hex);
164
- return `\x1b[48;2;${r};${g};${b}m`;
165
- }
166
-
167
- /**
168
- * Extract hex color from an ANSI 24-bit foreground escape sequence.
169
- * Returns null if not a 24-bit color.
170
- */
171
- export function ansiFgToHex(ansi: string): string | null {
172
- const match = ansi.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
173
- if (!match) return null;
174
- return rgbToHex(parseInt(match[1]), parseInt(match[2]), parseInt(match[3]));
175
- }
176
-
177
- /**
178
- * Extract hex color from an ANSI 24-bit background escape sequence.
179
- * Returns null if not a 24-bit color.
180
- */
181
- export function ansiBgToHex(ansi: string): string | null {
182
- const match = ansi.match(/\x1b\[48;2;(\d+);(\d+);(\d+)m/);
183
- if (!match) return null;
184
- return rgbToHex(parseInt(match[1]), parseInt(match[2]), parseInt(match[3]));
185
- }
186
-
187
- // ─── Color Resolution ───────────────────────────────────────────────────────────
188
-
189
- /**
190
- * Load the diff configuration from settings.
191
- */
192
- export function loadDiffConfig(): DiffSettings {
193
- return readDiffSettings();
194
- }
195
-
196
- /**
197
- * Mix a foreground color with a background color at a given ratio.
198
- * Used for auto-deriving diff backgrounds from pi theme accents.
199
- */
200
- export function mixColors(fg: string, bg: string, ratio: number): string {
201
- const [fr, fg_, fb] = hexToRgb(fg);
202
- const [br, bg_, bb] = hexToRgb(bg);
203
- const r = Math.round(fr * ratio + br * (1 - ratio));
204
- const g = Math.round(fg_ * ratio + bg_ * (1 - ratio));
205
- const b = Math.round(fb * ratio + bb * (1 - ratio));
206
- return rgbToHex(r, g, b);
207
- }
208
-
209
- /**
210
- * Auto-derive diff background colors from a pi theme.
211
- * Mixes accent/success/error colors with a base background.
212
- */
213
- export function autoDeriveBgFromTheme(theme: any): DiffColors | null {
214
- try {
215
- // Try to get theme colors from pi's Theme object
216
- const baseBg = theme?.colors?.customMessageBg || theme?.colors?.background || "#1a1a2e";
217
- const successColor = theme?.colors?.toolSuccess || theme?.colors?.success || "#22c55e";
218
- const errorColor = theme?.colors?.toolError || theme?.colors?.error || "#ef4444";
219
-
220
- return {
221
- addBg: mixColors(successColor, baseBg, 0.15),
222
- addFg: mixColors(successColor, "#ffffff", 0.7),
223
- remBg: mixColors(errorColor, baseBg, 0.15),
224
- remFg: mixColors(errorColor, "#ffffff", 0.7),
225
- addWordBg: mixColors(successColor, baseBg, 0.25),
226
- remWordBg: mixColors(errorColor, baseBg, 0.25),
227
- hunkFg: "#8888ff",
228
- headerFg: "#888888",
229
- };
230
- } catch {
231
- return null;
232
- }
233
- }
234
-
235
- /**
236
- * Check if a value looks like a hex color.
237
- */
238
- function isHexColor(v: string): boolean {
239
- return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v);
240
- }
241
-
242
- /**
243
- * Resolve diff colors using the full chain:
244
- * env vars → per-color overrides → preset → auto-derive → hardcoded
245
- *
246
- * @param theme - Optional pi Theme object for auto-derivation
247
- */
248
- export function resolveDiffColors(theme?: any): DiffColors {
249
- const config = loadDiffConfig();
250
- const preset = getPreset(config.theme);
251
-
252
- // Start with preset colors
253
- let colors = { ...preset.colors };
254
-
255
- // Layer: auto-derive from pi theme (if available)
256
- if (theme) {
257
- const derived = autoDeriveBgFromTheme(theme);
258
- if (derived) {
259
- // Auto-derived colors fill in where preset has defaults
260
- colors = {
261
- addBg: colors.addBg || derived.addBg,
262
- addFg: colors.addFg || derived.addFg,
263
- remBg: colors.remBg || derived.remBg,
264
- remFg: colors.remFg || derived.remFg,
265
- addWordBg: colors.addWordBg || derived.addWordBg,
266
- remWordBg: colors.remWordBg || derived.remWordBg,
267
- hunkFg: colors.hunkFg || derived.hunkFg,
268
- headerFg: colors.headerFg || derived.headerFg,
269
- };
270
- }
271
- }
272
-
273
- // Layer: environment variable overrides
274
- const envMap: Record<string, keyof DiffColors> = {
275
- DIFF_ADD_BG: "addBg",
276
- DIFF_ADD_FG: "addFg",
277
- DIFF_REM_BG: "remBg",
278
- DIFF_REM_FG: "remFg",
279
- DIFF_ADD_WORD_BG: "addWordBg",
280
- DIFF_REM_WORD_BG: "remWordBg",
281
- DIFF_HUNK_FG: "hunkFg",
282
- DIFF_HEADER_FG: "headerFg",
283
- };
284
-
285
- for (const [envKey, colorKey] of Object.entries(envMap)) {
286
- const envVal = process.env[envKey];
287
- if (envVal && isHexColor(envVal)) {
288
- colors[colorKey] = envVal;
289
- }
290
- }
291
-
292
- // Layer: per-color overrides from settings (if we add diffColors to settings later)
293
- // Currently deferred — spec says "Out of Scope"
294
-
295
- return colors;
296
- }
297
-
298
- /**
299
- * Apply the diff palette to create ANSI escape functions.
300
- * Returns an object with helper functions for coloring diff output.
301
- */
302
- export function applyDiffPalette(theme?: any) {
303
- const colors = resolveDiffColors(theme);
304
-
305
- return {
306
- colors,
307
- addBg: (s: string) => `${hexToBgAnsi(colors.addBg)}${s}\x1b[0m`,
308
- addFg: (s: string) => `${hexToFgAnsi(colors.addFg)}${s}\x1b[0m`,
309
- remBg: (s: string) => `${hexToBgAnsi(colors.remBg)}${s}\x1b[0m`,
310
- remFg: (s: string) => `${hexToFgAnsi(colors.remFg)}${s}\x1b[0m`,
311
- addWordBg: (s: string) => `${hexToBgAnsi(colors.addWordBg)}${s}\x1b[0m`,
312
- remWordBg: (s: string) => `${hexToBgAnsi(colors.remWordBg)}${s}\x1b[0m`,
313
- hunkFg: (s: string) => `${hexToFgAnsi(colors.hunkFg)}${s}\x1b[0m`,
314
- headerFg: (s: string) => `${hexToFgAnsi(colors.headerFg)}${s}\x1b[0m`,
315
- };
316
- }
@@ -1,339 +0,0 @@
1
- /**
2
- * @pi-unipi/utility — Diff Tool Wrapper
3
- *
4
- * Wraps the default Pi write/edit tools with Shiki-powered diff rendering.
5
- * When enabled, enhanced tools are registered that:
6
- * 1. Read old content before write
7
- * 2. Delegate to the original tool
8
- * 3. Compute diff
9
- * 4. Store diff in result.details for async rendering
10
- */
11
-
12
- import * as fs from "node:fs";
13
- import * as path from "node:path";
14
- import type { ExtensionAPI, ToolDefinition, AgentToolResult } from "@earendil-works/pi-coding-agent";
15
- import { visibleWidth as piVisibleWidth, truncateToWidth as piTruncateToWidth } from "@earendil-works/pi-tui";
16
- import { readDiffSettings } from "./settings.js";
17
- import { parseDiff } from "./parser.js";
18
- import { resolveDiffColors, applyDiffPalette } from "./theme.js";
19
- import { renderSplit, renderUnified, termW, SPLIT_MIN_WIDTH, truncateToTermWidth } from "./renderer.js";
20
- import { detectLanguageFromPath, hlBlock, MAX_HL_CHARS } from "./highlighter.js";
21
-
22
- // ─── Types ──────────────────────────────────────────────────────────────────────
23
-
24
- /** Extended tool result with diff data */
25
- interface DiffToolDetails {
26
- /** Old file content (null for new files) */
27
- oldContent: string | null;
28
- /** New file content */
29
- newContent: string;
30
- /** Parsed diff */
31
- diff: ReturnType<typeof parseDiff>;
32
- /** File path */
33
- filePath: string;
34
- /** Detected language */
35
- language: string;
36
- }
37
-
38
- /** Edit operation from the edit tool */
39
- export interface EditOperation {
40
- oldText: string;
41
- newText: string;
42
- }
43
-
44
- // ─── Helpers ────────────────────────────────────────────────────────────────────
45
-
46
- /**
47
- * Normalize edit tool input to get the list of edit operations.
48
- * Handles both single-edit and multi-edit parameter formats.
49
- */
50
- export function getEditOperations(input: any): EditOperation[] {
51
- if (Array.isArray(input?.edits)) {
52
- return input.edits.map((e: any) => ({
53
- oldText: e.oldText ?? e.old_text ?? "",
54
- newText: e.newText ?? e.new_text ?? "",
55
- }));
56
- }
57
- if (input?.oldText !== undefined || input?.old_text !== undefined) {
58
- return [{
59
- oldText: input.oldText ?? input.old_text ?? "",
60
- newText: input.newText ?? input.new_text ?? "",
61
- }];
62
- }
63
- return [];
64
- }
65
-
66
- /**
67
- * Summarize edit operations into aggregate diff stats.
68
- */
69
- export function summarizeEditOperations(operations: EditOperation[]): {
70
- totalEdits: number;
71
- totalAdditions: number;
72
- totalDeletions: number;
73
- } {
74
- let totalAdditions = 0;
75
- let totalDeletions = 0;
76
-
77
- for (const op of operations) {
78
- const oldLines = op.oldText.split("\n");
79
- const newLines = op.newText.split("\n");
80
- totalDeletions += oldLines.length;
81
- totalAdditions += newLines.length;
82
- }
83
-
84
- return {
85
- totalEdits: operations.length,
86
- totalAdditions,
87
- totalDeletions,
88
- };
89
- }
90
-
91
- /**
92
- * Read file content safely. Returns null if file doesn't exist.
93
- */
94
- function readFileSafe(filePath: string): string | null {
95
- try {
96
- if (fs.existsSync(filePath)) {
97
- return fs.readFileSync(filePath, "utf-8");
98
- }
99
- } catch {
100
- // Ignore read errors
101
- }
102
- return null;
103
- }
104
-
105
- // ─── Tool Registration ──────────────────────────────────────────────────────────
106
-
107
- /**
108
- * Register the enhanced write tool that wraps the default with diff rendering.
109
- */
110
- export function registerEnhancedWriteTool(pi: ExtensionAPI, cwd: string): void {
111
- // We need to re-register a tool with the same name "write" to override it.
112
- // The approach: register our own tool that reads old content, writes the file,
113
- // computes the diff, and stores it for rendering.
114
-
115
- pi.registerTool({
116
- name: "write",
117
- label: "Write File",
118
- description: "Write content to a file at the given path. Creates parent directories if needed. Shows a syntax-highlighted diff of the changes.",
119
- parameters: {
120
- type: "object",
121
- properties: {
122
- path: { type: "string", description: "Path to the file to write" },
123
- content: { type: "string", description: "Content to write to the file" },
124
- },
125
- required: ["path", "content"],
126
- } as any,
127
- async execute(toolCallId: string, params: any, signal: any, _onUpdate: any, _ctx: any): Promise<any> {
128
- const { path: filePath, content } = params;
129
- const absolutePath = path.resolve(cwd, filePath);
130
- const dir = path.dirname(absolutePath);
131
-
132
- // Read old content before write
133
- const oldContent = readFileSafe(absolutePath);
134
-
135
- // Write the file
136
- if (!fs.existsSync(dir)) {
137
- fs.mkdirSync(dir, { recursive: true });
138
- }
139
- fs.writeFileSync(absolutePath, content, "utf-8");
140
-
141
- // Compute diff
142
- const language = detectLanguageFromPath(filePath);
143
- const diff = parseDiff(oldContent ?? "", content, 3, filePath, filePath);
144
-
145
- return {
146
- content: [
147
- { type: "text", text: `Successfully wrote ${content.length} bytes to ${filePath}` },
148
- ],
149
- details: {
150
- oldContent,
151
- newContent: content,
152
- diff,
153
- filePath,
154
- language,
155
- } as DiffToolDetails,
156
- };
157
- },
158
- renderResult(result: any, _options: any, theme: any): any {
159
- const details = result?.details as DiffToolDetails | undefined;
160
- if (!details || !details.diff || !details.diff.lines || details.diff.lines.length === 0) {
161
- // Error or empty-diff case: render the message from result.content so the
162
- // user sees "Could not find text to replace..." etc. Never return null here
163
- // because Container.render() will crash on null child.
164
- const msg = result?.content?.[0]?.text ?? "";
165
- return {
166
- setText: () => {},
167
- text: msg,
168
- render: (width: number) => (width > 0 ? [msg.slice(0, width)] : [msg]),
169
- } as any;
170
- }
171
-
172
- try {
173
- const dc = resolveDiffColors(theme);
174
- const tw = termW();
175
- const max = 60;
176
-
177
- const rendered: string = tw >= SPLIT_MIN_WIDTH
178
- ? renderSplit(details.diff, details.language, max, dc)
179
- : renderUnified(details.diff, details.language, max, dc);
180
-
181
- // Split into lines and cache for width-aware rendering.
182
- // Each line is already truncated to terminal width by
183
- // truncateToTermWidth() in the renderer, but we also
184
- // respect the width parameter from Box.render().
185
- const cachedLines = rendered.split("\n");
186
-
187
- return {
188
- setText: () => {},
189
- text: rendered,
190
- render: (width: number) => {
191
- // If width is provided, re-truncate lines that
192
- // still exceed it (e.g., inside nested Boxes)
193
- const maxW = width > 0 ? width : tw;
194
- return cachedLines.map((line: string) => {
195
- if (piVisibleWidth(line) > maxW) {
196
- return piTruncateToWidth(line, maxW, "…");
197
- }
198
- return line;
199
- });
200
- },
201
- } as any;
202
- } catch {
203
- const fallback = "(diff rendering failed)";
204
- return {
205
- setText: () => {},
206
- text: fallback,
207
- render: (width: number) => (width > 0 ? [fallback.slice(0, width)] : [fallback]),
208
- } as any;
209
- }
210
- },
211
- });
212
- }
213
-
214
- /**
215
- * Register the enhanced edit tool that wraps the default with diff rendering.
216
- */
217
- export function registerEnhancedEditTool(pi: ExtensionAPI, cwd: string): void {
218
- pi.registerTool({
219
- name: "edit",
220
- label: "Edit File",
221
- description: "Edit a file by replacing text. Shows a syntax-highlighted diff of the changes.",
222
- parameters: {
223
- type: "object",
224
- properties: {
225
- path: { type: "string", description: "Path to the file to edit" },
226
- edits: {
227
- type: "array",
228
- items: {
229
- type: "object",
230
- properties: {
231
- oldText: { type: "string", description: "Text to replace" },
232
- newText: { type: "string", description: "Replacement text" },
233
- },
234
- required: ["oldText", "newText"],
235
- },
236
- description: "Array of edit operations",
237
- },
238
- },
239
- required: ["path", "edits"],
240
- } as any,
241
- async execute(toolCallId: string, params: any, signal: any, _onUpdate: any, _ctx: any): Promise<any> {
242
- const { path: filePath, edits } = params;
243
- const absolutePath = path.resolve(cwd, filePath);
244
-
245
- // Read old content
246
- const oldContent = readFileSafe(absolutePath);
247
- if (oldContent === null) {
248
- return {
249
- content: [{ type: "text", text: `Error: File not found: ${filePath}` }],
250
- details: undefined,
251
- isError: true,
252
- };
253
- }
254
-
255
- // Apply edits
256
- let newContent = oldContent;
257
- const operations = getEditOperations(params);
258
- for (const op of operations) {
259
- const idx = newContent.indexOf(op.oldText);
260
- if (idx === -1) {
261
- return {
262
- content: [{ type: "text", text: `Error: Could not find text to replace in ${filePath}` }],
263
- details: undefined,
264
- isError: true,
265
- };
266
- }
267
- newContent = newContent.substring(0, idx) + op.newText + newContent.substring(idx + op.oldText.length);
268
- }
269
-
270
- // Write the modified content
271
- fs.writeFileSync(absolutePath, newContent, "utf-8");
272
-
273
- // Compute diff
274
- const language = detectLanguageFromPath(filePath);
275
- const diff = parseDiff(oldContent, newContent, 3, filePath, filePath);
276
- const summary = summarizeEditOperations(operations);
277
-
278
- return {
279
- content: [
280
- { type: "text", text: `Successfully edited ${filePath} (${summary.totalEdits} edit${summary.totalEdits !== 1 ? "s" : ""})` },
281
- ],
282
- details: {
283
- oldContent,
284
- newContent,
285
- diff,
286
- filePath,
287
- language,
288
- } as DiffToolDetails,
289
- };
290
- },
291
- renderResult(result: any, _options: any, theme: any): any {
292
- const details = result?.details as DiffToolDetails | undefined;
293
- if (!details || !details.diff || !details.diff.lines || details.diff.lines.length === 0) {
294
- // Error or empty-diff case: render the message from result.content so the
295
- // user sees "Could not find text to replace..." etc. Never return null here
296
- // because Container.render() will crash on null child.
297
- const msg = result?.content?.[0]?.text ?? "";
298
- return {
299
- setText: () => {},
300
- text: msg,
301
- render: (width: number) => (width > 0 ? [msg.slice(0, width)] : [msg]),
302
- } as any;
303
- }
304
-
305
- try {
306
- const dc = resolveDiffColors(theme);
307
- const tw = termW();
308
- const max = 60;
309
-
310
- const rendered: string = tw >= SPLIT_MIN_WIDTH
311
- ? renderSplit(details.diff, details.language, max, dc)
312
- : renderUnified(details.diff, details.language, max, dc);
313
-
314
- const cachedLines = rendered.split("\n");
315
-
316
- return {
317
- setText: () => {},
318
- text: rendered,
319
- render: (width: number) => {
320
- const maxW = width > 0 ? width : tw;
321
- return cachedLines.map((line: string) => {
322
- if (piVisibleWidth(line) > maxW) {
323
- return piTruncateToWidth(line, maxW, "…");
324
- }
325
- return line;
326
- });
327
- },
328
- } as any;
329
- } catch {
330
- const fallback = "(diff rendering failed)";
331
- return {
332
- setText: () => {},
333
- text: fallback,
334
- render: (width: number) => (width > 0 ? [fallback.slice(0, width)] : [fallback]),
335
- } as any;
336
- }
337
- },
338
- });
339
- }