@pi-unipi/utility 0.2.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
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",
@@ -273,9 +273,8 @@ export async function getShikiHighlighter(): Promise<any> {
273
273
  ],
274
274
  });
275
275
  return shikiHighlighter;
276
- } catch (err) {
276
+ } catch {
277
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
278
  shikiInitPromise = null;
280
279
  return null;
281
280
  }
@@ -3,12 +3,17 @@
3
3
  *
4
4
  * ANSI diff rendering: split (side-by-side) and unified (stacked) views.
5
5
  * Includes ANSI utilities, background injection, and adaptive wrapping.
6
+ *
7
+ * IMPORTANT: All output lines must be truncated to terminal width
8
+ * to prevent TUI crashes. Use fit() or truncateToTermWidth()
9
+ * before pushing lines.
6
10
  */
7
11
 
8
12
  import type { ParsedDiff, DiffLine } from "./parser.js";
9
13
  import type { DiffColors } from "./theme.js";
10
14
  import { hexToBgAnsi, hexToFgAnsi } from "./theme.js";
11
15
  import { hlBlock, detectLanguage } from "./highlighter.js";
16
+ import { truncateToWidth as piTruncateToWidth, visibleWidth as piVisibleWidth } from "@mariozechner/pi-tui";
12
17
 
13
18
  // ─── Constants ──────────────────────────────────────────────────────────────────
14
19
 
@@ -36,64 +41,21 @@ export function strip(s: string): string {
36
41
 
37
42
  /**
38
43
  * Get the visible width of a string (excluding ANSI escapes).
39
- * Handles CJK characters (width 2) and emoji.
44
+ * Delegates to pi-tui's ANSI-aware implementation for correctness.
40
45
  */
41
46
  export function visibleWidth(s: string): number {
42
- const stripped = strip(s);
43
- let width = 0;
44
- for (const char of stripped) {
45
- const code = char.codePointAt(0)!;
46
- // CJK Unified Ideographs, CJK Compatibility, etc.
47
- if (
48
- (code >= 0x4e00 && code <= 0x9fff) ||
49
- (code >= 0x3000 && code <= 0x30ff) ||
50
- (code >= 0xff00 && code <= 0xffef) ||
51
- (code >= 0xf900 && code <= 0xfaff) ||
52
- (code >= 0x2e80 && code <= 0x2eff) ||
53
- (code >= 0x3400 && code <= 0x4dbf) ||
54
- (code >= 0x20000 && code <= 0x2a6df)
55
- ) {
56
- width += 2;
57
- } else if (code > 0xffff) {
58
- // Surrogate pairs / emoji — typically width 2
59
- width += 2;
60
- } else {
61
- width += 1;
62
- }
63
- }
64
- return width;
47
+ return piVisibleWidth(s);
65
48
  }
66
49
 
67
50
  /**
68
51
  * Fit a string to a target width, padding or truncating as needed.
69
- * Preserves ANSI state across truncation.
52
+ * Uses pi-tui's ANSI-aware truncation for correctness.
70
53
  */
71
54
  export function fit(s: string, targetWidth: number): string {
72
55
  const vw = visibleWidth(s);
73
56
  if (vw === targetWidth) return s;
74
57
  if (vw > targetWidth) {
75
- // Truncate find the cut point
76
- let width = 0;
77
- let i = 0;
78
- const stripped = strip(s);
79
- for (; i < stripped.length && width < targetWidth; i++) {
80
- const code = stripped.codePointAt(i)!;
81
- width += (code >= 0x4e00 && code <= 0x9fff) || code > 0xffff ? 2 : 1;
82
- }
83
- // Find the corresponding position in the original (with ANSI) string
84
- let strippedIdx = 0;
85
- let origIdx = 0;
86
- while (origIdx < s.length && strippedIdx < i) {
87
- if (s[origIdx] === "\x1b") {
88
- // Skip ANSI sequence
89
- while (origIdx < s.length && s[origIdx] !== "m") origIdx++;
90
- origIdx++;
91
- } else {
92
- strippedIdx++;
93
- origIdx++;
94
- }
95
- }
96
- return s.substring(0, origIdx) + "\x1b[0m";
58
+ return piTruncateToWidth(s, targetWidth, "");
97
59
  }
98
60
  // Pad
99
61
  return s + " ".repeat(targetWidth - vw);
@@ -198,6 +160,17 @@ export function termW(): number {
198
160
  }
199
161
  }
200
162
 
163
+ /**
164
+ * Truncate a line to terminal width using pi-tui's ANSI-aware truncation.
165
+ * Prevents TUI crash when rendered lines exceed terminal width.
166
+ * Accounts for rendering overhead from Box nesting (~6 chars).
167
+ */
168
+ export function truncateToTermWidth(line: string): string {
169
+ const maxW = Math.max(20, termW() - 6);
170
+ if (piVisibleWidth(line) <= maxW) return line;
171
+ return piTruncateToWidth(line, maxW, "…");
172
+ }
173
+
201
174
  // ─── Background Injection ───────────────────────────────────────────────────────
202
175
 
203
176
  /**
@@ -313,16 +286,16 @@ export function renderUnified(
313
286
 
314
287
  switch (line.type) {
315
288
  case "hunk":
316
- lines.push(`${hunkFg}${line.content}${reset}`);
289
+ lines.push(truncateToTermWidth(`${hunkFg}${line.content}${reset}`));
317
290
  break;
318
291
  case "add":
319
- lines.push(`${addBg}${addFg}+${reset}${addBg} ${lnum(null, 4)} ${lnum(line.newLine, 4)} │ ${line.content}${reset}`);
292
+ lines.push(truncateToTermWidth(`${addBg}${addFg}+${reset}${addBg} ${lnum(null, 4)} ${lnum(line.newLine, 4)} │ ${line.content}${reset}`));
320
293
  break;
321
294
  case "remove":
322
- lines.push(`${remBg}${remFg}-${reset}${remBg} ${lnum(line.oldLine, 4)} ${lnum(null, 4)} │ ${line.content}${reset}`);
295
+ lines.push(truncateToTermWidth(`${remBg}${remFg}-${reset}${remBg} ${lnum(line.oldLine, 4)} ${lnum(null, 4)} │ ${line.content}${reset}`));
323
296
  break;
324
297
  case "context":
325
- lines.push(` ${headerFg}${lnum(line.oldLine, 4)} ${lnum(line.newLine, 4)}${reset} │ ${line.content}`);
298
+ lines.push(truncateToTermWidth(` ${headerFg}${lnum(line.oldLine, 4)} ${lnum(line.newLine, 4)}${reset} │ ${line.content}`));
326
299
  break;
327
300
  }
328
301
  }
@@ -411,7 +384,7 @@ export function renderSplit(
411
384
  for (let i = 0; i < leftLines.length; i++) {
412
385
  const left = leftLines[i];
413
386
  const right = rightLines[i];
414
- lines.push(`${left} │ ${right}`);
387
+ lines.push(truncateToTermWidth(`${left} │ ${right}`));
415
388
  }
416
389
 
417
390
  if (truncated) {
@@ -12,10 +12,11 @@
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import type { ExtensionAPI, ToolDefinition, AgentToolResult } from "@mariozechner/pi-coding-agent";
15
+ import { visibleWidth as piVisibleWidth, truncateToWidth as piTruncateToWidth } from "@mariozechner/pi-tui";
15
16
  import { readDiffSettings } from "./settings.js";
16
17
  import { parseDiff } from "./parser.js";
17
18
  import { resolveDiffColors, applyDiffPalette } from "./theme.js";
18
- import { renderSplit, renderUnified, termW, SPLIT_MIN_WIDTH } from "./renderer.js";
19
+ import { renderSplit, renderUnified, termW, SPLIT_MIN_WIDTH, truncateToTermWidth } from "./renderer.js";
19
20
  import { detectLanguageFromPath, hlBlock, MAX_HL_CHARS } from "./highlighter.js";
20
21
 
21
22
  // ─── Types ──────────────────────────────────────────────────────────────────────
@@ -157,7 +158,15 @@ export function registerEnhancedWriteTool(pi: ExtensionAPI, cwd: string): void {
157
158
  renderResult(result: any, _options: any, theme: any): any {
158
159
  const details = result?.details as DiffToolDetails | undefined;
159
160
  if (!details || !details.diff || !details.diff.lines || details.diff.lines.length === 0) {
160
- return null as any;
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;
161
170
  }
162
171
 
163
172
  try {
@@ -169,14 +178,34 @@ export function registerEnhancedWriteTool(pi: ExtensionAPI, cwd: string): void {
169
178
  ? renderSplit(details.diff, details.language, max, dc)
170
179
  : renderUnified(details.diff, details.language, max, dc);
171
180
 
172
- // Return a simple component-like object with text
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
+
173
187
  return {
174
188
  setText: () => {},
175
189
  text: rendered,
176
- render: () => rendered.split("\n"),
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
+ },
177
201
  } as any;
178
202
  } catch {
179
- return null as any;
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;
180
209
  }
181
210
  },
182
211
  });
@@ -262,7 +291,15 @@ export function registerEnhancedEditTool(pi: ExtensionAPI, cwd: string): void {
262
291
  renderResult(result: any, _options: any, theme: any): any {
263
292
  const details = result?.details as DiffToolDetails | undefined;
264
293
  if (!details || !details.diff || !details.diff.lines || details.diff.lines.length === 0) {
265
- return null as any;
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;
266
303
  }
267
304
 
268
305
  try {
@@ -274,13 +311,28 @@ export function registerEnhancedEditTool(pi: ExtensionAPI, cwd: string): void {
274
311
  ? renderSplit(details.diff, details.language, max, dc)
275
312
  : renderUnified(details.diff, details.language, max, dc);
276
313
 
314
+ const cachedLines = rendered.split("\n");
315
+
277
316
  return {
278
317
  setText: () => {},
279
318
  text: rendered,
280
- render: () => rendered.split("\n"),
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
+ },
281
328
  } as any;
282
329
  } catch {
283
- return null as any;
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;
284
336
  }
285
337
  },
286
338
  });