@pi-unipi/utility 0.2.6 → 0.2.7

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/README.md CHANGED
@@ -16,6 +16,8 @@ Comprehensive utility suite for the Pi coding agent — part of the Unipi extens
16
16
  | `/unipi:doctor` | Run diagnostics across all modules |
17
17
  | `/unipi:name-badge` | Toggle name badge overlay (shows session name) |
18
18
  | `/unipi:badge-gen` | Generate session name via LLM and enable badge |
19
+ | `/unipi:util-settings` | **Unified settings** — badge + diff rendering config |
20
+ | `/unipi:badge-settings` | Settings overlay (deprecated alias for `/unipi:util-settings`) |
19
21
 
20
22
  ### Tools
21
23
 
@@ -23,6 +25,8 @@ Comprehensive utility suite for the Pi coding agent — part of the Unipi extens
23
25
  |------|-------------|
24
26
  | `ctx_batch` | Atomic batch execution with rollback support |
25
27
  | `ctx_env` | Environment inspection for debugging |
28
+ | `write` | Write file with **syntax-highlighted diff** (when diff enabled) |
29
+ | `edit` | Edit file with **split/unified diff view** (when diff enabled) |
26
30
 
27
31
  ### Modules (Programmatic API)
28
32
 
@@ -71,6 +75,40 @@ pi install npm:@pi-unipi/unipi
71
75
  The badge is a persistent HUD overlay in the top-right corner showing the current session name.
72
76
  It auto-restores visibility on session restart.
73
77
 
78
+ ### Diff Rendering
79
+
80
+ Shiki-powered, syntax-highlighted diffs for `write` and `edit` tool output. When enabled, the default tools are replaced with enhanced versions that show side-by-side or stacked diffs with syntax highlighting.
81
+
82
+ **Features:**
83
+ - Split view (side-by-side) for `edit` tool, auto-falls back to unified on narrow terminals
84
+ - Unified view (stacked single-column) for `write` tool overwrites
85
+ - 4 color presets: default, midnight, subtle, neon
86
+ - LRU cache (192 entries) for Shiki highlights
87
+ - Large diff fallback (skip highlighting above 80k chars)
88
+ - Environment variable color overrides (`DIFF_ADD_BG`, `DIFF_REM_BG`, etc.)
89
+
90
+ **Configuration:**
91
+
92
+ ```
93
+ /unipi:util-settings # Open unified settings TUI
94
+ ```
95
+
96
+ Or edit `.unipi/config/util-settings.json` directly:
97
+
98
+ ```json
99
+ {
100
+ "diff": {
101
+ "enabled": true,
102
+ "theme": "default",
103
+ "shikiTheme": "github-dark",
104
+ "splitMinWidth": 150
105
+ }
106
+ }
107
+ ```
108
+
109
+ **Diff themes:** default, midnight, subtle, neon
110
+ **Shiki themes:** github-dark, dracula, one-dark-pro, catppuccin-mocha, nord, tokyo-night, and more
111
+
74
112
  ### Batch Execution (Code)
75
113
 
76
114
  ```typescript
@@ -136,10 +174,19 @@ packages/utility/src/
136
174
  ├── display/
137
175
  │ ├── capabilities.ts # Terminal detection
138
176
  │ └── width.ts # Width utilities
177
+ ├── diff/
178
+ │ ├── settings.ts # Unified settings (badge + diff) read/write + migration
179
+ │ ├── theme.ts # Diff color presets, resolution chain, hex ↔ ANSI
180
+ │ ├── parser.ts # Diff parsing (structuredPatch, word diff analysis)
181
+ │ ├── highlighter.ts # Shiki singleton, LRU cache, language detection
182
+ │ ├── renderer.ts # Split/unified renderers, ANSI utilities
183
+ │ └── wrapper.ts # write/edit tool wrapping with diff output
139
184
  ├── tui/
140
185
  │ ├── settings-inspector.ts # Settings overlay model
141
186
  │ ├── name-badge.ts # Name badge overlay component
142
- └── name-badge-state.ts # Name badge state manager
187
+ ├── name-badge-state.ts # Name badge state manager
188
+ │ ├── badge-settings.ts # Badge settings (thin wrapper over diff/settings)
189
+ │ └── util-settings-tui.ts # Unified settings TUI (badge + diff)
143
190
  └── tools/
144
191
  ├── batch.ts # Batch execution
145
192
  └── env.ts # Environment info
@@ -158,8 +205,14 @@ The analytics collector is **privacy-respecting** by design:
158
205
  - `@pi-unipi/core` — Shared constants, events, utilities
159
206
  - `@mariozechner/pi-coding-agent` — Pi extension API
160
207
  - `@sinclair/typebox` — Schema validation (peer dependency)
208
+ - `diff` — Unified diff generation (for diff rendering)
209
+ - `@shikijs/cli` — Shiki syntax highlighting (for diff rendering)
161
210
  - `sqlite3` — Optional, for persistent cache/analytics
162
211
 
212
+ ### Dev Dependencies
213
+
214
+ - `@types/diff` — TypeScript types for the diff library
215
+
163
216
  ## License
164
217
 
165
218
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,12 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@pi-unipi/core": "*"
41
+ "@pi-unipi/core": "*",
42
+ "diff": "^7.0.0",
43
+ "@shikijs/cli": "^4.0.2"
44
+ },
45
+ "devDependencies": {
46
+ "@types/diff": "^7.0.2"
42
47
  },
43
48
  "peerDependencies": {
44
49
  "@mariozechner/pi-coding-agent": "*",
package/src/commands.ts CHANGED
@@ -23,6 +23,7 @@ import { getEnvironmentInfo, formatEnvironmentInfo } from "./tools/env.js";
23
23
  import type { NameBadgeState } from "./tui/name-badge-state.js";
24
24
  import { readBadgeSettings, updateBadgeSetting, formatBadgeSettings } from "./tui/badge-settings.js";
25
25
  import { BadgeSettingsTui } from "./tui/badge-settings-tui.js";
26
+ import { UtilSettingsTui } from "./tui/util-settings-tui.js";
26
27
 
27
28
  /** Send a markdown response via pi.sendMessage */
28
29
  function sendResponse(pi: ExtensionAPI, markdown: string): void {
@@ -97,18 +98,55 @@ export function registerNameBadgeCommands(
97
98
  },
98
99
  });
99
100
 
100
- // ─── /unipi:badge-settings — TUI settings overlay ──────────────────────
101
+ // ─── /unipi:badge-settings — TUI settings overlay (deprecated alias) ──────
101
102
  pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.BADGE_SETTINGS}`, {
102
- description: "Configure badge settings via TUI overlay",
103
+ description: "Configure badge settings via TUI overlay (deprecated — use /unipi:util-settings)",
103
104
  handler: async (_args: string, ctx: ExtensionContext) => {
104
105
  if (!ctx.hasUI) {
105
106
  ctx.ui.notify("Badge settings require an interactive UI.", "warning");
106
107
  return;
107
108
  }
108
109
 
110
+ // Redirect to unified settings
109
111
  ctx.ui.custom(
110
112
  (tui: any, _theme: any, _keybindings: any, done: any) => {
111
- const overlay = new BadgeSettingsTui();
113
+ const overlay = new UtilSettingsTui();
114
+ overlay.onClose = () => done(undefined);
115
+ overlay.requestRender = () => tui.requestRender();
116
+ return {
117
+ render: (w: number) => overlay.render(w),
118
+ invalidate: () => overlay.invalidate(),
119
+ handleInput: (data: string) => {
120
+ overlay.handleInput(data);
121
+ tui.requestRender();
122
+ },
123
+ };
124
+ },
125
+ {
126
+ overlay: true,
127
+ overlayOptions: {
128
+ width: "80%",
129
+ minWidth: 50,
130
+ anchor: "center",
131
+ margin: 2,
132
+ },
133
+ },
134
+ );
135
+ },
136
+ });
137
+
138
+ // ─── /unipi:util-settings — unified settings TUI ──────────────────────
139
+ pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.UTIL_SETTINGS}`, {
140
+ description: "Configure badge and diff settings via unified TUI overlay",
141
+ handler: async (_args: string, ctx: ExtensionContext) => {
142
+ if (!ctx.hasUI) {
143
+ ctx.ui.notify("Settings require an interactive UI.", "warning");
144
+ return;
145
+ }
146
+
147
+ ctx.ui.custom(
148
+ (tui: any, _theme: any, _keybindings: any, done: any) => {
149
+ const overlay = new UtilSettingsTui();
112
150
  overlay.onClose = () => done(undefined);
113
151
  overlay.requestRender = () => tui.requestRender();
114
152
  return {
@@ -0,0 +1,353 @@
1
+ /**
2
+ * @pi-unipi/utility — Shiki Highlighter
3
+ *
4
+ * Singleton Shiki ANSI highlighter with LRU cache, language detection,
5
+ * and contrast normalization.
6
+ */
7
+
8
+ // ─── Constants ──────────────────────────────────────────────────────────────────
9
+
10
+ /** Maximum number of cached highlight results */
11
+ export const CACHE_LIMIT = 192;
12
+
13
+ /** Maximum characters to highlight (skip Shiki above this) */
14
+ export const MAX_HL_CHARS = 80_000;
15
+
16
+ // ─── LRU Cache ──────────────────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Simple LRU cache with string keys.
20
+ * Evicts oldest entries when capacity is reached.
21
+ */
22
+ export class LruCache<V> {
23
+ private map = new Map<string, V>();
24
+ private capacity: number;
25
+
26
+ constructor(capacity: number) {
27
+ this.capacity = capacity;
28
+ }
29
+
30
+ get(key: string): V | undefined {
31
+ const value = this.map.get(key);
32
+ if (value !== undefined) {
33
+ // Move to end (most recently used)
34
+ this.map.delete(key);
35
+ this.map.set(key, value);
36
+ }
37
+ return value;
38
+ }
39
+
40
+ set(key: string, value: V): void {
41
+ if (this.map.has(key)) {
42
+ this.map.delete(key);
43
+ } else if (this.map.size >= this.capacity) {
44
+ // Evict oldest (first entry)
45
+ const firstKey = this.map.keys().next().value;
46
+ if (firstKey !== undefined) {
47
+ this.map.delete(firstKey);
48
+ }
49
+ }
50
+ this.map.set(key, value);
51
+ }
52
+
53
+ has(key: string): boolean {
54
+ return this.map.has(key);
55
+ }
56
+
57
+ get size(): number {
58
+ return this.map.size;
59
+ }
60
+
61
+ clear(): void {
62
+ this.map.clear();
63
+ }
64
+ }
65
+
66
+ // ─── Language Detection ─────────────────────────────────────────────────────────
67
+
68
+ /** File extension → Shiki language mapping */
69
+ export const EXT_LANG: Record<string, string> = {
70
+ ".ts": "typescript",
71
+ ".tsx": "tsx",
72
+ ".js": "javascript",
73
+ ".jsx": "jsx",
74
+ ".mjs": "javascript",
75
+ ".cjs": "javascript",
76
+ ".mts": "typescript",
77
+ ".cts": "typescript",
78
+ ".json": "json",
79
+ ".jsonc": "jsonc",
80
+ ".json5": "json5",
81
+ ".html": "html",
82
+ ".htm": "html",
83
+ ".css": "css",
84
+ ".scss": "scss",
85
+ ".sass": "sass",
86
+ ".less": "less",
87
+ ".md": "markdown",
88
+ ".mdx": "mdx",
89
+ ".py": "python",
90
+ ".rb": "ruby",
91
+ ".go": "go",
92
+ ".rs": "rust",
93
+ ".java": "java",
94
+ ".kt": "kotlin",
95
+ ".kts": "kotlin",
96
+ ".c": "c",
97
+ ".h": "c",
98
+ ".cpp": "cpp",
99
+ ".hpp": "cpp",
100
+ ".cc": "cpp",
101
+ ".cs": "csharp",
102
+ ".swift": "swift",
103
+ ".php": "php",
104
+ ".sql": "sql",
105
+ ".sh": "bash",
106
+ ".bash": "bash",
107
+ ".zsh": "bash",
108
+ ".fish": "fish",
109
+ ".yaml": "yaml",
110
+ ".yml": "yaml",
111
+ ".toml": "toml",
112
+ ".xml": "xml",
113
+ ".svg": "xml",
114
+ ".graphql": "graphql",
115
+ ".gql": "graphql",
116
+ ".vue": "vue",
117
+ ".svelte": "svelte",
118
+ ".astro": "astro",
119
+ ".prisma": "prisma",
120
+ ".dockerfile": "dockerfile",
121
+ ".tf": "hcl",
122
+ ".hcl": "hcl",
123
+ ".lua": "lua",
124
+ ".r": "r",
125
+ ".R": "r",
126
+ ".dart": "dart",
127
+ ".ex": "elixir",
128
+ ".exs": "elixir",
129
+ ".erl": "erlang",
130
+ ".hrl": "erlang",
131
+ ".clj": "clojure",
132
+ ".cljs": "clojure",
133
+ ".hs": "haskell",
134
+ ".elm": "elm",
135
+ ".nim": "nim",
136
+ ".zig": "zig",
137
+ ".v": "v",
138
+ ".jl": "julia",
139
+ ".ml": "ocaml",
140
+ ".mli": "ocaml",
141
+ ".fs": "fsharp",
142
+ ".fsx": "fsharp",
143
+ ".fsi": "fsharp",
144
+ };
145
+
146
+ /**
147
+ * Detect the Shiki language from a file extension.
148
+ * Returns "text" if the extension is unknown.
149
+ */
150
+ export function detectLanguage(extension: string): string {
151
+ const ext = extension.startsWith(".") ? extension.toLowerCase() : `.${extension.toLowerCase()}`;
152
+ return EXT_LANG[ext] ?? "text";
153
+ }
154
+
155
+ /**
156
+ * Detect language from a file path.
157
+ */
158
+ export function detectLanguageFromPath(filePath: string): string {
159
+ const ext = filePath.lastIndexOf(".") >= 0 ? filePath.substring(filePath.lastIndexOf(".")) : "";
160
+ return detectLanguage(ext);
161
+ }
162
+
163
+ // ─── Contrast Normalization ─────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Calculate the relative luminance of a hex color.
167
+ * Used for contrast ratio calculations.
168
+ */
169
+ function relativeLuminance(hex: string): number {
170
+ const h = hex.replace(/^#/, "");
171
+ const r = parseInt(h.substring(0, 2), 16) / 255;
172
+ const g = parseInt(h.substring(2, 4), 16) / 255;
173
+ const b = parseInt(h.substring(4, 6), 16) / 255;
174
+
175
+ const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
176
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
177
+ }
178
+
179
+ /**
180
+ * Calculate contrast ratio between two colors.
181
+ */
182
+ function contrastRatio(fg: string, bg: string): number {
183
+ const l1 = relativeLuminance(fg);
184
+ const l2 = relativeLuminance(bg);
185
+ const lighter = Math.max(l1, l2);
186
+ const darker = Math.min(l1, l2);
187
+ return (lighter + 0.05) / (darker + 0.05);
188
+ }
189
+
190
+ /**
191
+ * Extract the foreground color from an ANSI 24-bit escape sequence.
192
+ * Returns the hex color and the full match for replacement.
193
+ */
194
+ function extractAnsiFg(ansi: string): { hex: string; match: string } | null {
195
+ const match = ansi.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
196
+ if (!match) return null;
197
+ const r = parseInt(match[1]);
198
+ const g = parseInt(match[2]);
199
+ const b = parseInt(match[3]);
200
+ const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
201
+ return { hex, match: match[0] };
202
+ }
203
+
204
+ /**
205
+ * Normalize low-contrast Shiki foregrounds against a dark background.
206
+ *
207
+ * Shiki themes sometimes produce foreground colors with poor contrast
208
+ * against diff backgrounds. This function bumps the brightness of any
209
+ * foreground that falls below the minimum contrast ratio.
210
+ *
211
+ * @param ansi - ANSI string with 24-bit color codes
212
+ * @param bgHex - Background hex color to test against (default: dark bg)
213
+ * @param minRatio - Minimum contrast ratio (default: 3.0)
214
+ */
215
+ export function normalizeShikiContrast(
216
+ ansi: string,
217
+ bgHex: string = "#1a1a2e",
218
+ minRatio: number = 3.0,
219
+ ): string {
220
+ // Find all 24-bit foreground sequences
221
+ const fgRegex = /\x1b\[38;2;\d+;\d+;\d+m/g;
222
+ let result = ansi;
223
+ let match: RegExpExecArray | null;
224
+
225
+ while ((match = fgRegex.exec(ansi)) !== null) {
226
+ const fgInfo = extractAnsiFg(match[0] + ansi.substring(match.index + match[0].length));
227
+ if (!fgInfo) continue;
228
+
229
+ const ratio = contrastRatio(fgInfo.hex, bgHex);
230
+ if (ratio < minRatio) {
231
+ // Brighten the foreground by mixing with white
232
+ const [r, g, b] = [
233
+ parseInt(fgInfo.hex.slice(1, 3), 16),
234
+ parseInt(fgInfo.hex.slice(3, 5), 16),
235
+ parseInt(fgInfo.hex.slice(5, 7), 16),
236
+ ];
237
+ const factor = minRatio / Math.max(ratio, 0.01);
238
+ const nr = Math.min(255, Math.round(r + (255 - r) * Math.min(1, factor * 0.5)));
239
+ const ng = Math.min(255, Math.round(g + (255 - g) * Math.min(1, factor * 0.5)));
240
+ const nb = Math.min(255, Math.round(b + (255 - b) * Math.min(1, factor * 0.5)));
241
+ const newFg = `\x1b[38;2;${nr};${ng};${nb}m`;
242
+ result = result.replace(match[0], newFg);
243
+ }
244
+ }
245
+
246
+ return result;
247
+ }
248
+
249
+ // ─── Shiki Highlighter ──────────────────────────────────────────────────────────
250
+
251
+ /** Shiki highlighter instance (lazy singleton) */
252
+ let shikiHighlighter: any = null;
253
+ let shikiInitPromise: Promise<any> | null = null;
254
+
255
+ /**
256
+ * Initialize the Shiki highlighter (singleton).
257
+ * Returns the highlighter instance.
258
+ */
259
+ export async function getShikiHighlighter(): Promise<any> {
260
+ if (shikiHighlighter) return shikiHighlighter;
261
+ if (shikiInitPromise) return shikiInitPromise;
262
+
263
+ shikiInitPromise = (async () => {
264
+ try {
265
+ const { createHighlighter } = await import("shiki");
266
+ shikiHighlighter = await createHighlighter({
267
+ themes: ["github-dark"],
268
+ langs: [
269
+ "typescript", "javascript", "tsx", "jsx", "json", "jsonc",
270
+ "html", "css", "scss", "markdown", "python", "go", "rust",
271
+ "java", "c", "cpp", "csharp", "ruby", "php", "swift", "kotlin",
272
+ "bash", "yaml", "toml", "xml", "sql", "graphql", "vue", "svelte",
273
+ ],
274
+ });
275
+ return shikiHighlighter;
276
+ } catch (err) {
277
+ // If Shiki fails to load, return null — we'll use plain text
278
+ console.warn("[pi-diff] Shiki highlighter failed to load:", err);
279
+ shikiInitPromise = null;
280
+ return null;
281
+ }
282
+ })();
283
+
284
+ return shikiInitPromise;
285
+ }
286
+
287
+ /**
288
+ * Pre-warm the Shiki highlighter.
289
+ * Call this early in the extension lifecycle to avoid first-render delay.
290
+ */
291
+ export async function preWarmHighlighter(): Promise<void> {
292
+ await getShikiHighlighter();
293
+ }
294
+
295
+ /** LRU cache for highlighted blocks */
296
+ const hlCache = new LruCache<string[]>(CACHE_LIMIT);
297
+
298
+ /**
299
+ * Generate a cache key for a code block.
300
+ */
301
+ function hlCacheKey(code: string, language: string): string {
302
+ // Use first 200 chars + length + language for cache key
303
+ const prefix = code.substring(0, 200);
304
+ return `${language}:${code.length}:${prefix}`;
305
+ }
306
+
307
+ /**
308
+ * Highlight a code block to ANSI using Shiki.
309
+ * Results are cached in an LRU cache (192 entries).
310
+ *
311
+ * @param code - Code to highlight
312
+ * @param language - Shiki language identifier
313
+ * @returns Array of ANSI-highlighted lines, or plain lines if Shiki unavailable
314
+ */
315
+ export async function hlBlock(code: string, language: string): Promise<string[]> {
316
+ // Skip highlighting for very large content
317
+ if (code.length > MAX_HL_CHARS) {
318
+ return code.split("\n");
319
+ }
320
+
321
+ // Check cache
322
+ const key = hlCacheKey(code, language);
323
+ const cached = hlCache.get(key);
324
+ if (cached) return cached;
325
+
326
+ // Get highlighter
327
+ const highlighter = await getShikiHighlighter();
328
+ if (!highlighter) {
329
+ // Shiki not available — return plain text
330
+ return code.split("\n");
331
+ }
332
+
333
+ try {
334
+ // Highlight with Shiki
335
+ const ansi = highlighter.codeToANSI(code, {
336
+ lang: language === "text" ? "text" : language,
337
+ theme: "github-dark",
338
+ });
339
+
340
+ const lines = ansi.split("\n");
341
+
342
+ // Normalize contrast
343
+ const normalized = lines.map((line: string) => normalizeShikiContrast(line));
344
+
345
+ // Cache result
346
+ hlCache.set(key, normalized);
347
+
348
+ return normalized;
349
+ } catch {
350
+ // Fallback on error
351
+ return code.split("\n");
352
+ }
353
+ }