@pi-unipi/utility 0.2.7 → 0.2.9

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
@@ -1,94 +1,68 @@
1
1
  # @pi-unipi/utility
2
2
 
3
- Comprehensive utility suite for the Pi coding agent part of the Unipi extension suite.
3
+ Environment info, diagnostics, cleanup, name badge, and diff rendering. The grab-bag package for maintaining your development environment and making tool output readable.
4
4
 
5
- ## Features
5
+ The diff rendering is the standout feature — Shiki-powered syntax-highlighted diffs for `write` and `edit` tool output. Side-by-side view for edits, unified view for writes, with color presets and auto-fallback on narrow terminals.
6
6
 
7
- ### Commands
7
+ ## Commands
8
8
 
9
9
  | Command | Description |
10
10
  |---------|-------------|
11
- | `/unipi:continue` | Continue agent without polluting context |
12
- | `/unipi:reload` | Explain how to reload extensions |
13
- | `/unipi:status` | Show status of all unipi modules |
14
- | `/unipi:cleanup` | Clean stale DBs, temp files, old sessions |
15
11
  | `/unipi:env` | Show environment info (Node, Pi, OS, paths) |
16
12
  | `/unipi:doctor` | Run diagnostics across all modules |
17
- | `/unipi:name-badge` | Toggle name badge overlay (shows session name) |
13
+ | `/unipi:status` | Show status of all unipi modules |
14
+ | `/unipi:cleanup` | Clean stale DBs, temp files, old sessions |
15
+ | `/unipi:reload` | Explain how to reload extensions |
16
+ | `/unipi:name-badge` | Toggle name badge overlay |
18
17
  | `/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`) |
18
+ | `/unipi:util-settings` | Unified settings for badge and diff rendering |
21
19
 
22
- ### Tools
20
+ ### Examples
23
21
 
24
- | Tool | Description |
25
- |------|-------------|
26
- | `ctx_batch` | Atomic batch execution with rollback support |
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) |
22
+ ```
23
+ /unipi:env # Show environment
24
+ /unipi:doctor # Run diagnostics
25
+ /unipi:cleanup # Clean stale files
26
+ /unipi:cleanup --dry-run # Preview what would be cleaned
27
+ /unipi:name-badge # Toggle the session name badge
28
+ /unipi:badge-gen # Generate a session name via LLM
29
+ ```
30
30
 
31
- ### Modules (Programmatic API)
31
+ ## Special Triggers
32
32
 
33
- | Module | Path | Description |
34
- |--------|------|-------------|
35
- | **ProcessLifecycle** | `lifecycle/process` | Parent PID polling, orphan detection, signal handlers, cleanup registry |
36
- | **cleanupStale** | `lifecycle/cleanup` | Stale DB/temp/session/cache cleanup with dry-run support |
37
- | **TTLCache** | `cache/ttl-cache` | Memory or SQLite-backed TTL cache with auto-expiration |
38
- | **AnalyticsCollector** | `analytics/collector` | Privacy-respecting event collection with daily rollup |
39
- | **runDiagnostics** | `diagnostics/engine` | Cross-module health checks with plugin architecture |
40
- | **detectCapabilities** | `display/capabilities` | Terminal feature detection (color, Nerd Font, unicode) |
41
- | **Width Utilities** | `display/width` | ANSI-aware clamp, wrap, collapse, pad, center |
42
- | **SettingsInspector** | `tui/settings-inspector` | Reusable settings overlay data model |
43
-
44
- ## Installation
45
-
46
- ```bash
47
- pi install npm:@pi-unipi/utility
48
- ```
33
+ Utility registers with the info-screen dashboard, showing module status and diagnostic results. The footer subscribes to utility events for its extension status segment.
49
34
 
50
- Or install the full Unipi suite:
35
+ The diff rendering feature wraps Pi's built-in `write` and `edit` tools. When enabled, these tools show syntax-highlighted diffs instead of plain output. This is a transparent replacement — the agent doesn't need to know about it.
51
36
 
52
- ```bash
53
- pi install npm:@pi-unipi/unipi
54
- ```
37
+ ## Agent Tools
55
38
 
56
- ## Usage
39
+ | Tool | Description |
40
+ |------|-------------|
41
+ | `ctx_batch` | Atomic batch execution with rollback support |
42
+ | `ctx_env` | Environment inspection for debugging |
43
+ | `write` | Write file with syntax-highlighted diff (when diff enabled) |
44
+ | `edit` | Edit file with split/unified diff view (when diff enabled) |
57
45
 
58
- ### Commands
46
+ ### Batch Execution
59
47
 
60
- ```
61
- /unipi:continue # Resume agent cleanly
62
- /unipi:cleanup # Clean stale files
63
- /unipi:cleanup --dry-run # Preview what would be cleaned
64
- /unipi:env # Show environment
65
- /unipi:doctor # Run diagnostics
66
- ```
48
+ ```typescript
49
+ import { BatchBuilder } from "@pi-unipi/utility/tools/batch";
67
50
 
68
- ### Name Badge
51
+ const report = await new BatchBuilder()
52
+ .addCommand("search", { query: "refactor" })
53
+ .addTool("memory_search", { query: "patterns" })
54
+ .withOptions({ failFast: true, commandTimeoutMs: 30000 })
55
+ .execute(myExecutor);
69
56
 
70
- ```
71
- /unipi:name-badge # Toggle the session name badge on/off
72
- /unipi:badge-gen # Generate a session name via LLM
57
+ if (!report.success) {
58
+ console.log("Failed:", report.results.find(r => !r.success)?.error);
59
+ }
73
60
  ```
74
61
 
75
- The badge is a persistent HUD overlay in the top-right corner showing the current session name.
76
- It auto-restores visibility on session restart.
62
+ ## Configurables
77
63
 
78
64
  ### Diff Rendering
79
65
 
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
66
  ```
93
67
  /unipi:util-settings # Open unified settings TUI
94
68
  ```
@@ -106,26 +80,38 @@ Or edit `.unipi/config/util-settings.json` directly:
106
80
  }
107
81
  ```
108
82
 
109
- **Diff themes:** default, midnight, subtle, neon
110
- **Shiki themes:** github-dark, dracula, one-dark-pro, catppuccin-mocha, nord, tokyo-night, and more
83
+ | Setting | Default | Options |
84
+ |---------|---------|---------|
85
+ | `enabled` | true | true/false |
86
+ | `theme` | "default" | default, midnight, subtle, neon |
87
+ | `shikiTheme` | "github-dark" | github-dark, dracula, one-dark-pro, catppuccin-mocha, nord, tokyo-night |
88
+ | `splitMinWidth` | 150 | Minimum terminal width for split view |
111
89
 
112
- ### Batch Execution (Code)
90
+ Environment variable overrides: `DIFF_ADD_BG`, `DIFF_REM_BG`, etc.
113
91
 
114
- ```typescript
115
- import { BatchBuilder } from "@pi-unipi/utility/tools/batch";
92
+ Features:
93
+ - Split view (side-by-side) for `edit`, auto-falls back to unified on narrow terminals
94
+ - Unified view (stacked) for `write` overwrites
95
+ - LRU cache (192 entries) for Shiki highlights
96
+ - Large diff fallback (skip highlighting above 80k chars)
116
97
 
117
- const report = await new BatchBuilder()
118
- .addCommand("search", { query: "refactor" })
119
- .addTool("memory_search", { query: "patterns" })
120
- .withOptions({ failFast: true, commandTimeoutMs: 30000 })
121
- .execute(myExecutor);
98
+ ### Name Badge
122
99
 
123
- if (!report.success) {
124
- console.log("Failed:", report.results.find(r => !r.success)?.error);
125
- }
126
- ```
100
+ The badge is a persistent HUD overlay in the top-right corner showing the current session name. It auto-restores visibility on session restart.
101
+
102
+ ## Programmatic API
103
+
104
+ | Module | Path | Description |
105
+ |--------|------|-------------|
106
+ | ProcessLifecycle | `lifecycle/process` | Parent PID polling, orphan detection, signal handlers |
107
+ | cleanupStale | `lifecycle/cleanup` | Stale DB/temp/session/cache cleanup with dry-run |
108
+ | TTLCache | `cache/ttl-cache` | Memory or SQLite-backed TTL cache |
109
+ | AnalyticsCollector | `analytics/collector` | Privacy-respecting event collection with daily rollup |
110
+ | runDiagnostics | `diagnostics/engine` | Cross-module health checks with plugin architecture |
111
+ | detectCapabilities | `display/capabilities` | Terminal feature detection (color, Nerd Font, unicode) |
112
+ | Width Utilities | `display/width` | ANSI-aware clamp, wrap, collapse, pad, center |
127
113
 
128
- ### TTL Cache (Code)
114
+ ### TTL Cache
129
115
 
130
116
  ```typescript
131
117
  import { TTLCache } from "@pi-unipi/utility/cache/ttl-cache";
@@ -135,16 +121,7 @@ await cache.set("key", { data: "value" });
135
121
  const value = await cache.get("key");
136
122
  ```
137
123
 
138
- ### Diagnostics (Code)
139
-
140
- ```typescript
141
- import { runDiagnostics, formatDiagnosticsReport } from "@pi-unipi/utility/diagnostics/engine";
142
-
143
- const report = await runDiagnostics();
144
- console.log(formatDiagnosticsReport(report));
145
- ```
146
-
147
- ### Terminal Capabilities (Code)
124
+ ### Terminal Capabilities
148
125
 
149
126
  ```typescript
150
127
  import { detectCapabilities, getIcon } from "@pi-unipi/utility/display/capabilities";
@@ -154,64 +131,13 @@ console.log("Nerd Font:", caps.nerdFont);
154
131
  console.log(getIcon("󰘳", "[OK]")); // Uses Nerd Font if available
155
132
  ```
156
133
 
157
- ## Architecture
158
-
159
- ```
160
- packages/utility/src/
161
- ├── index.ts # Extension entry point
162
- ├── commands.ts # Command registration
163
- ├── types.ts # Shared types
164
- ├── info-screen.ts # Info-screen integration
165
- ├── lifecycle/
166
- │ ├── process.ts # Process lifecycle manager
167
- │ └── cleanup.ts # Stale cleanup utility
168
- ├── cache/
169
- │ └── ttl-cache.ts # TTL cache (memory + SQLite)
170
- ├── analytics/
171
- │ └── collector.ts # Event collection + rollup
172
- ├── diagnostics/
173
- │ └── engine.ts # Health check engine
174
- ├── display/
175
- │ ├── capabilities.ts # Terminal detection
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
184
- ├── tui/
185
- │ ├── settings-inspector.ts # Settings overlay model
186
- │ ├── name-badge.ts # Name badge overlay component
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)
190
- └── tools/
191
- ├── batch.ts # Batch execution
192
- └── env.ts # Environment info
193
- ```
194
-
195
134
  ## Privacy
196
135
 
197
- The analytics collector is **privacy-respecting** by design:
198
- - No file contents are recorded
136
+ The analytics collector is privacy-respecting:
137
+ - No file contents recorded
199
138
  - No sensitive data (API keys, tokens, passwords) — redacted automatically
200
139
  - Strings truncated to 500 characters
201
- - All data stays local (in-memory by default, optional SQLite)
202
-
203
- ## Dependencies
204
-
205
- - `@pi-unipi/core` — Shared constants, events, utilities
206
- - `@mariozechner/pi-coding-agent` — Pi extension API
207
- - `@sinclair/typebox` — Schema validation (peer dependency)
208
- - `diff` — Unified diff generation (for diff rendering)
209
- - `@shikijs/cli` — Shiki syntax highlighting (for diff rendering)
210
- - `sqlite3` — Optional, for persistent cache/analytics
211
-
212
- ### Dev Dependencies
213
-
214
- - `@types/diff` — TypeScript types for the diff library
140
+ - All data stays local
215
141
 
216
142
  ## License
217
143
 
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.9",
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
  });