@pi-unipi/utility 0.2.6 → 0.2.8

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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * @pi-unipi/utility — Unified Settings Manager
3
+ *
4
+ * Manages both badge and diff settings in a single `.unipi/config/util-settings.json` file.
5
+ * Migrates from legacy `badge.json` on first read.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+
11
+ /** Diff rendering settings */
12
+ export interface DiffSettings {
13
+ /** Enable Shiki-powered diff rendering for write/edit tools */
14
+ enabled: boolean;
15
+ /** Diff theme preset: "default" | "midnight" | "subtle" | "neon" */
16
+ theme: string;
17
+ /** Shiki syntax theme name */
18
+ shikiTheme: string;
19
+ /** Minimum terminal columns for split view */
20
+ splitMinWidth: number;
21
+ }
22
+
23
+ /** Badge settings (matches existing BadgeSettings interface) */
24
+ export interface BadgeSettingsSection {
25
+ autoGen: boolean;
26
+ badgeEnabled: boolean;
27
+ agentTool: boolean;
28
+ generationModel: string;
29
+ }
30
+
31
+ /** Unified utility settings */
32
+ export interface UtilSettings {
33
+ badge: BadgeSettingsSection;
34
+ diff: DiffSettings;
35
+ }
36
+
37
+ /** Default diff settings */
38
+ const DEFAULT_DIFF_SETTINGS: DiffSettings = {
39
+ enabled: true,
40
+ theme: "default",
41
+ shikiTheme: "github-dark",
42
+ splitMinWidth: 150,
43
+ };
44
+
45
+ /** Default badge settings */
46
+ const DEFAULT_BADGE_SETTINGS: BadgeSettingsSection = {
47
+ autoGen: true,
48
+ badgeEnabled: true,
49
+ agentTool: true,
50
+ generationModel: "inherit",
51
+ };
52
+
53
+ /** Default unified settings */
54
+ const DEFAULT_SETTINGS: UtilSettings = {
55
+ badge: { ...DEFAULT_BADGE_SETTINGS },
56
+ diff: { ...DEFAULT_DIFF_SETTINGS },
57
+ };
58
+
59
+ /** Config file paths */
60
+ const UTIL_SETTINGS_FILE = ".unipi/config/util-settings.json";
61
+ const BADGE_CONFIG_FILE = ".unipi/config/badge.json";
62
+
63
+ /**
64
+ * Get absolute path for a config file relative to cwd.
65
+ */
66
+ function getConfigPath(file: string): string {
67
+ return path.resolve(process.cwd(), file);
68
+ }
69
+
70
+ /**
71
+ * Read badge.json for migration purposes.
72
+ * Returns null if file doesn't exist or is malformed.
73
+ */
74
+ function readLegacyBadgeSettings(): BadgeSettingsSection | null {
75
+ try {
76
+ const configPath = getConfigPath(BADGE_CONFIG_FILE);
77
+ if (!fs.existsSync(configPath)) return null;
78
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
79
+ return {
80
+ autoGen: typeof parsed.autoGen === "boolean" ? parsed.autoGen : DEFAULT_BADGE_SETTINGS.autoGen,
81
+ badgeEnabled: typeof parsed.badgeEnabled === "boolean" ? parsed.badgeEnabled : DEFAULT_BADGE_SETTINGS.badgeEnabled,
82
+ agentTool: typeof parsed.agentTool === "boolean" ? parsed.agentTool : DEFAULT_BADGE_SETTINGS.agentTool,
83
+ generationModel: typeof parsed.generationModel === "string" ? parsed.generationModel : DEFAULT_BADGE_SETTINGS.generationModel,
84
+ };
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Atomic write: write to temp file then rename.
92
+ * Prevents corruption if two instances write simultaneously.
93
+ */
94
+ function atomicWrite(filePath: string, data: string): void {
95
+ const tmpPath = filePath + ".tmp";
96
+ fs.writeFileSync(tmpPath, data, "utf-8");
97
+ fs.renameSync(tmpPath, filePath);
98
+ }
99
+
100
+ /**
101
+ * Read the unified util-settings.json.
102
+ * On first read, migrates from badge.json if it exists.
103
+ * Returns defaults if no config exists.
104
+ */
105
+ export function readUtilSettings(): UtilSettings {
106
+ try {
107
+ const configPath = getConfigPath(UTIL_SETTINGS_FILE);
108
+
109
+ // Check if unified config exists
110
+ if (fs.existsSync(configPath)) {
111
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
112
+ return normalizeSettings(parsed);
113
+ }
114
+
115
+ // Migration: import from badge.json if it exists
116
+ const legacyBadge = readLegacyBadgeSettings();
117
+ if (legacyBadge) {
118
+ const migrated: UtilSettings = {
119
+ badge: legacyBadge,
120
+ diff: { ...DEFAULT_DIFF_SETTINGS },
121
+ };
122
+ writeUtilSettings(migrated);
123
+ return migrated;
124
+ }
125
+
126
+ // No config at all — return defaults (don't write yet)
127
+ return { ...DEFAULT_SETTINGS, badge: { ...DEFAULT_BADGE_SETTINGS }, diff: { ...DEFAULT_DIFF_SETTINGS } };
128
+ } catch {
129
+ return { ...DEFAULT_SETTINGS, badge: { ...DEFAULT_BADGE_SETTINGS }, diff: { ...DEFAULT_DIFF_SETTINGS } };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Write the full unified settings to disk.
135
+ */
136
+ export function writeUtilSettings(settings: UtilSettings): void {
137
+ try {
138
+ const configPath = getConfigPath(UTIL_SETTINGS_FILE);
139
+ const dir = path.dirname(configPath);
140
+ if (!fs.existsSync(dir)) {
141
+ fs.mkdirSync(dir, { recursive: true });
142
+ }
143
+ atomicWrite(configPath, JSON.stringify(settings, null, 2) + "\n");
144
+ } catch {
145
+ // Best effort
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Normalize a parsed JSON object into valid UtilSettings.
151
+ */
152
+ function normalizeSettings(parsed: any): UtilSettings {
153
+ return {
154
+ badge: {
155
+ autoGen: typeof parsed?.badge?.autoGen === "boolean" ? parsed.badge.autoGen : DEFAULT_BADGE_SETTINGS.autoGen,
156
+ badgeEnabled: typeof parsed?.badge?.badgeEnabled === "boolean" ? parsed.badge.badgeEnabled : DEFAULT_BADGE_SETTINGS.badgeEnabled,
157
+ agentTool: typeof parsed?.badge?.agentTool === "boolean" ? parsed.badge.agentTool : DEFAULT_BADGE_SETTINGS.agentTool,
158
+ generationModel: typeof parsed?.badge?.generationModel === "string" ? parsed.badge.generationModel : DEFAULT_BADGE_SETTINGS.generationModel,
159
+ },
160
+ diff: {
161
+ enabled: typeof parsed?.diff?.enabled === "boolean" ? parsed.diff.enabled : DEFAULT_DIFF_SETTINGS.enabled,
162
+ theme: typeof parsed?.diff?.theme === "string" ? parsed.diff.theme : DEFAULT_DIFF_SETTINGS.theme,
163
+ shikiTheme: typeof parsed?.diff?.shikiTheme === "string" ? parsed.diff.shikiTheme : DEFAULT_DIFF_SETTINGS.shikiTheme,
164
+ splitMinWidth: typeof parsed?.diff?.splitMinWidth === "number" ? parsed.diff.splitMinWidth : DEFAULT_DIFF_SETTINGS.splitMinWidth,
165
+ },
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Read only the diff settings section.
171
+ */
172
+ export function readDiffSettings(): DiffSettings {
173
+ return readUtilSettings().diff;
174
+ }
175
+
176
+ /**
177
+ * Write partial diff settings (merged with existing).
178
+ */
179
+ export function writeDiffSettings(partial: Partial<DiffSettings>): void {
180
+ const settings = readUtilSettings();
181
+ settings.diff = { ...settings.diff, ...partial };
182
+ writeUtilSettings(settings);
183
+ }
184
+
185
+ /**
186
+ * Read only the badge settings section.
187
+ */
188
+ export function readBadgeSettingsFromUtil(): BadgeSettingsSection {
189
+ return readUtilSettings().badge;
190
+ }
191
+
192
+ /**
193
+ * Write partial badge settings (merged with existing).
194
+ */
195
+ export function writeBadgeSettingsToUtil(partial: Partial<BadgeSettingsSection>): void {
196
+ const settings = readUtilSettings();
197
+ settings.badge = { ...settings.badge, ...partial };
198
+ writeUtilSettings(settings);
199
+ }
@@ -0,0 +1,319 @@
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
+ /** Hardcoded fallback colors (last resort) */
105
+ const HARDCODED_FALLBACK: DiffColors = PRESETS.default.colors;
106
+
107
+ // ─── Preset Access ──────────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Get a diff preset by name. Falls back to "default" if not found.
111
+ */
112
+ export function getPreset(name: string): DiffPreset {
113
+ return PRESETS[name] ?? PRESETS.default;
114
+ }
115
+
116
+ /**
117
+ * Get all available preset names.
118
+ */
119
+ export function getPresetNames(): string[] {
120
+ return Object.keys(PRESETS);
121
+ }
122
+
123
+ /**
124
+ * Get all presets with their metadata.
125
+ */
126
+ export function getAllPresets(): DiffPreset[] {
127
+ return Object.values(PRESETS);
128
+ }
129
+
130
+ // ─── Hex ↔ ANSI Conversion ──────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Parse a hex color string (#RRGGBB or #RGB) to [r, g, b].
134
+ */
135
+ export function hexToRgb(hex: string): [number, number, number] {
136
+ let h = hex.replace(/^#/, "");
137
+ if (h.length === 3) {
138
+ h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
139
+ }
140
+ const r = parseInt(h.substring(0, 2), 16);
141
+ const g = parseInt(h.substring(2, 4), 16);
142
+ const b = parseInt(h.substring(4, 6), 16);
143
+ return [r, g, b];
144
+ }
145
+
146
+ /**
147
+ * Convert [r, g, b] to hex string (#RRGGBB).
148
+ */
149
+ export function rgbToHex(r: number, g: number, b: number): string {
150
+ const toHex = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
151
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
152
+ }
153
+
154
+ /**
155
+ * Convert a hex color to ANSI 24-bit foreground escape.
156
+ */
157
+ export function hexToFgAnsi(hex: string): AnsiColor {
158
+ const [r, g, b] = hexToRgb(hex);
159
+ return `\x1b[38;2;${r};${g};${b}m`;
160
+ }
161
+
162
+ /**
163
+ * Convert a hex color to ANSI 24-bit background escape.
164
+ */
165
+ export function hexToBgAnsi(hex: string): AnsiColor {
166
+ const [r, g, b] = hexToRgb(hex);
167
+ return `\x1b[48;2;${r};${g};${b}m`;
168
+ }
169
+
170
+ /**
171
+ * Extract hex color from an ANSI 24-bit foreground escape sequence.
172
+ * Returns null if not a 24-bit color.
173
+ */
174
+ export function ansiFgToHex(ansi: string): string | null {
175
+ const match = ansi.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
176
+ if (!match) return null;
177
+ return rgbToHex(parseInt(match[1]), parseInt(match[2]), parseInt(match[3]));
178
+ }
179
+
180
+ /**
181
+ * Extract hex color from an ANSI 24-bit background escape sequence.
182
+ * Returns null if not a 24-bit color.
183
+ */
184
+ export function ansiBgToHex(ansi: string): string | null {
185
+ const match = ansi.match(/\x1b\[48;2;(\d+);(\d+);(\d+)m/);
186
+ if (!match) return null;
187
+ return rgbToHex(parseInt(match[1]), parseInt(match[2]), parseInt(match[3]));
188
+ }
189
+
190
+ // ─── Color Resolution ───────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Load the diff configuration from settings.
194
+ */
195
+ export function loadDiffConfig(): DiffSettings {
196
+ return readDiffSettings();
197
+ }
198
+
199
+ /**
200
+ * Mix a foreground color with a background color at a given ratio.
201
+ * Used for auto-deriving diff backgrounds from pi theme accents.
202
+ */
203
+ export function mixColors(fg: string, bg: string, ratio: number): string {
204
+ const [fr, fg_, fb] = hexToRgb(fg);
205
+ const [br, bg_, bb] = hexToRgb(bg);
206
+ const r = Math.round(fr * ratio + br * (1 - ratio));
207
+ const g = Math.round(fg_ * ratio + bg_ * (1 - ratio));
208
+ const b = Math.round(fb * ratio + bb * (1 - ratio));
209
+ return rgbToHex(r, g, b);
210
+ }
211
+
212
+ /**
213
+ * Auto-derive diff background colors from a pi theme.
214
+ * Mixes accent/success/error colors with a base background.
215
+ */
216
+ export function autoDeriveBgFromTheme(theme: any): DiffColors | null {
217
+ try {
218
+ // Try to get theme colors from pi's Theme object
219
+ const baseBg = theme?.colors?.customMessageBg || theme?.colors?.background || "#1a1a2e";
220
+ const successColor = theme?.colors?.toolSuccess || theme?.colors?.success || "#22c55e";
221
+ const errorColor = theme?.colors?.toolError || theme?.colors?.error || "#ef4444";
222
+
223
+ return {
224
+ addBg: mixColors(successColor, baseBg, 0.15),
225
+ addFg: mixColors(successColor, "#ffffff", 0.7),
226
+ remBg: mixColors(errorColor, baseBg, 0.15),
227
+ remFg: mixColors(errorColor, "#ffffff", 0.7),
228
+ addWordBg: mixColors(successColor, baseBg, 0.25),
229
+ remWordBg: mixColors(errorColor, baseBg, 0.25),
230
+ hunkFg: "#8888ff",
231
+ headerFg: "#888888",
232
+ };
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Check if a value looks like a hex color.
240
+ */
241
+ function isHexColor(v: string): boolean {
242
+ return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v);
243
+ }
244
+
245
+ /**
246
+ * Resolve diff colors using the full chain:
247
+ * env vars → per-color overrides → preset → auto-derive → hardcoded
248
+ *
249
+ * @param theme - Optional pi Theme object for auto-derivation
250
+ */
251
+ export function resolveDiffColors(theme?: any): DiffColors {
252
+ const config = loadDiffConfig();
253
+ const preset = getPreset(config.theme);
254
+
255
+ // Start with preset colors
256
+ let colors = { ...preset.colors };
257
+
258
+ // Layer: auto-derive from pi theme (if available)
259
+ if (theme) {
260
+ const derived = autoDeriveBgFromTheme(theme);
261
+ if (derived) {
262
+ // Auto-derived colors fill in where preset has defaults
263
+ colors = {
264
+ addBg: colors.addBg || derived.addBg,
265
+ addFg: colors.addFg || derived.addFg,
266
+ remBg: colors.remBg || derived.remBg,
267
+ remFg: colors.remFg || derived.remFg,
268
+ addWordBg: colors.addWordBg || derived.addWordBg,
269
+ remWordBg: colors.remWordBg || derived.remWordBg,
270
+ hunkFg: colors.hunkFg || derived.hunkFg,
271
+ headerFg: colors.headerFg || derived.headerFg,
272
+ };
273
+ }
274
+ }
275
+
276
+ // Layer: environment variable overrides
277
+ const envMap: Record<string, keyof DiffColors> = {
278
+ DIFF_ADD_BG: "addBg",
279
+ DIFF_ADD_FG: "addFg",
280
+ DIFF_REM_BG: "remBg",
281
+ DIFF_REM_FG: "remFg",
282
+ DIFF_ADD_WORD_BG: "addWordBg",
283
+ DIFF_REM_WORD_BG: "remWordBg",
284
+ DIFF_HUNK_FG: "hunkFg",
285
+ DIFF_HEADER_FG: "headerFg",
286
+ };
287
+
288
+ for (const [envKey, colorKey] of Object.entries(envMap)) {
289
+ const envVal = process.env[envKey];
290
+ if (envVal && isHexColor(envVal)) {
291
+ colors[colorKey] = envVal;
292
+ }
293
+ }
294
+
295
+ // Layer: per-color overrides from settings (if we add diffColors to settings later)
296
+ // Currently deferred — spec says "Out of Scope"
297
+
298
+ return colors;
299
+ }
300
+
301
+ /**
302
+ * Apply the diff palette to create ANSI escape functions.
303
+ * Returns an object with helper functions for coloring diff output.
304
+ */
305
+ export function applyDiffPalette(theme?: any) {
306
+ const colors = resolveDiffColors(theme);
307
+
308
+ return {
309
+ colors,
310
+ addBg: (s: string) => `${hexToBgAnsi(colors.addBg)}${s}\x1b[0m`,
311
+ addFg: (s: string) => `${hexToFgAnsi(colors.addFg)}${s}\x1b[0m`,
312
+ remBg: (s: string) => `${hexToBgAnsi(colors.remBg)}${s}\x1b[0m`,
313
+ remFg: (s: string) => `${hexToFgAnsi(colors.remFg)}${s}\x1b[0m`,
314
+ addWordBg: (s: string) => `${hexToBgAnsi(colors.addWordBg)}${s}\x1b[0m`,
315
+ remWordBg: (s: string) => `${hexToBgAnsi(colors.remWordBg)}${s}\x1b[0m`,
316
+ hunkFg: (s: string) => `${hexToFgAnsi(colors.hunkFg)}${s}\x1b[0m`,
317
+ headerFg: (s: string) => `${hexToFgAnsi(colors.headerFg)}${s}\x1b[0m`,
318
+ };
319
+ }