pi-cc-extensions 0.8.13 → 0.8.14

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.
@@ -192,7 +192,8 @@ function shortenPath(path: unknown): string {
192
192
  }
193
193
 
194
194
  function oneLine(value: unknown): string {
195
- return sanitizeToolResultText(String(value ?? ""))
195
+ // Preview paths only need a short prefix; skip scanning multi-MB tool outputs.
196
+ return sanitizeToolResultText(String(value ?? ""), 4_096)
196
197
  .replace(/\s+/g, " ")
197
198
  .trim();
198
199
  }
@@ -298,7 +299,8 @@ export function resultPreview(result: any, isPartial = false): string {
298
299
  ? result.content.find((c: any) => c?.type === "text" && typeof c.text === "string")?.text
299
300
  : undefined;
300
301
  if (!text) return isPartial ? "running" : "";
301
- const lines = sanitizeToolResultText(text)
302
+ // Only the first non-empty line / line count is used; bound the scan window.
303
+ const lines = sanitizeToolResultText(text, 8_192)
302
304
  .trim()
303
305
  .split("\n")
304
306
  .filter((line) => line.trim().length > 0);
@@ -213,19 +213,53 @@ export function formatTokens(tokens: number): string {
213
213
  return `${Math.round(tokens / 1_000)}k`;
214
214
  }
215
215
 
216
- function collectParts(ctx: ExtensionCommandContext): ContextPart[] {
217
- const options = ctx.getSystemPromptOptions();
216
+ type SystemPromptOptions = {
217
+ contextFiles?: Array<{ path: string; content: string }>;
218
+ skills?: Array<{
219
+ name: string;
220
+ description?: string;
221
+ filePath?: string;
222
+ disableModelInvocation?: boolean;
223
+ }>;
224
+ selectedTools?: string[];
225
+ toolSnippets?: Record<string, string>;
226
+ promptGuidelines?: string[];
227
+ };
228
+
229
+ type ContextBreakdown = {
230
+ parts: ContextPart[];
231
+ options: SystemPromptOptions;
232
+ systemPrompt: string;
233
+ };
234
+
235
+ /**
236
+ * Single pass over prompt options + session entries. Returns options/systemPrompt
237
+ * so the /context UI does not re-fetch or re-stringify the same sources.
238
+ */
239
+ function collectContextBreakdown(ctx: ExtensionCommandContext): ContextBreakdown {
240
+ const options = (ctx.getSystemPromptOptions?.() ?? {}) as SystemPromptOptions;
241
+ const systemPrompt = typeof ctx.getSystemPrompt === "function" ? ctx.getSystemPrompt() : "";
242
+
218
243
  const contextFileTokens = (options.contextFiles ?? []).reduce(
219
244
  (sum, file) => sum + tokenEstimate(file.content),
220
245
  0,
221
246
  );
222
- const skillTokens = (options.skills ?? []).reduce((sum, skill) => sum + tokenEstimate(skill), 0);
247
+ // Prefer field-level estimates over JSON.stringify(whole skill).
248
+ const skillTokens = (options.skills ?? []).reduce((sum, skill) => {
249
+ if (!skill || typeof skill !== "object") return sum + tokenEstimate(skill);
250
+ return (
251
+ sum +
252
+ tokenEstimate(skill.name) +
253
+ tokenEstimate(skill.description) +
254
+ tokenEstimate(skill.filePath)
255
+ );
256
+ }, 0);
223
257
  const tools = options.selectedTools ?? [];
224
- const toolTokens =
225
- tools.reduce(
226
- (sum, name) => sum + tokenEstimate(name) + tokenEstimate(options.toolSnippets?.[name]),
227
- 0,
228
- ) + tokenEstimate(options.promptGuidelines);
258
+ const snippets = options.toolSnippets;
259
+ let toolTokens = tokenEstimate(options.promptGuidelines);
260
+ for (const name of tools) {
261
+ toolTokens += tokenEstimate(name) + tokenEstimate(snippets?.[name]);
262
+ }
229
263
 
230
264
  let user = 0;
231
265
  let assistant = 0;
@@ -242,7 +276,7 @@ function collectParts(ctx: ExtensionCommandContext): ContextPart[] {
242
276
  }
243
277
  }
244
278
 
245
- const systemTotal = tokenEstimate(ctx.getSystemPrompt());
279
+ const systemTotal = tokenEstimate(systemPrompt);
246
280
  const baseSystem = Math.max(0, systemTotal - contextFileTokens - skillTokens - toolTokens);
247
281
  const parts: ContextPart[] = [
248
282
  { label: "System prompt", tokens: baseSystem, color: "accent" },
@@ -254,7 +288,11 @@ function collectParts(ctx: ExtensionCommandContext): ContextPart[] {
254
288
  { label: "Tool results", tokens: toolResults, color: "dim" },
255
289
  { label: "Compaction summaries", tokens: summaries, color: "success" },
256
290
  ];
257
- return parts.filter((part) => part.tokens > 0);
291
+ return {
292
+ parts: parts.filter((part) => part.tokens > 0),
293
+ options,
294
+ systemPrompt,
295
+ };
258
296
  }
259
297
 
260
298
  export default function contextUsageExtension(pi: ExtensionAPI) {
@@ -263,8 +301,9 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
263
301
  handler: async (_args, ctx) => {
264
302
  const usage = ctx.getContextUsage();
265
303
  const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
266
- const used = usage?.tokens ?? collectParts(ctx).reduce((sum, part) => sum + part.tokens, 0);
267
- const parts = scaleParts(collectParts(ctx), used);
304
+ const breakdown = collectContextBreakdown(ctx);
305
+ const used = usage?.tokens ?? breakdown.parts.reduce((sum, part) => sum + part.tokens, 0);
306
+ const parts = scaleParts(breakdown.parts, used);
268
307
  const free = Math.max(0, contextWindow - used);
269
308
  const allParts = [...parts, { label: "Free space", tokens: free, color: "dim" as const }];
270
309
 
@@ -274,7 +313,7 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
274
313
  return;
275
314
  }
276
315
 
277
- const options = ctx.getSystemPromptOptions();
316
+ const options = breakdown.options;
278
317
  const toolByName = new Map<string, ToolPreviewInfo>(
279
318
  (pi.getAllTools() as ToolPreviewInfo[]).map((tool) => [tool.name, tool] as const),
280
319
  );
@@ -313,7 +352,7 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
313
352
  key: "systemPrompt",
314
353
  label: "System prompt",
315
354
  title: "System Prompt",
316
- content: ctx.getSystemPrompt(),
355
+ content: breakdown.systemPrompt,
317
356
  },
318
357
  {
319
358
  key: "tools",
@@ -66,6 +66,20 @@ type SubagentManager = {
66
66
 
67
67
  // Local subagent record tracking (pi-subagents does not expose a global manager).
68
68
  const liveSubagentRecords = new Map<string, SubagentLiveRecord>();
69
+ /** Cap retained completed/live records so long-lived pi processes cannot grow unbounded. */
70
+ const MAX_LIVE_SUBAGENT_RECORDS = 200;
71
+
72
+ function pruneLiveSubagentRecords(): void {
73
+ while (liveSubagentRecords.size > MAX_LIVE_SUBAGENT_RECORDS) {
74
+ const oldest = liveSubagentRecords.keys().next().value;
75
+ if (oldest === undefined) break;
76
+ liveSubagentRecords.delete(oldest);
77
+ }
78
+ }
79
+
80
+ function clearLiveSubagentRecords(): void {
81
+ liveSubagentRecords.clear();
82
+ }
69
83
 
70
84
  function trackSubagentFromEvent(data: unknown): void {
71
85
  if (!data || typeof data !== "object") return;
@@ -87,6 +101,9 @@ function trackSubagentFromEvent(data: unknown): void {
87
101
  typeof event.endedAt === "number" ? event.endedAt : event.lastUpdate
88
102
  ) as number;
89
103
  }
104
+ // Refresh insertion order so completed-but-still-referenced runs stay recent.
105
+ liveSubagentRecords.delete(runId);
106
+ liveSubagentRecords.set(runId, existing);
90
107
  return;
91
108
  }
92
109
  // Started event — create new record
@@ -100,6 +117,7 @@ function trackSubagentFromEvent(data: unknown): void {
100
117
  cwd,
101
118
  startedAt,
102
119
  });
120
+ pruneLiveSubagentRecords();
103
121
  }
104
122
 
105
123
  function getSubagentManager(): SubagentManager | undefined {
@@ -134,8 +152,14 @@ function formatDate(date: Date): string {
134
152
  }).format(date);
135
153
  }
136
154
 
155
+ /** Stable across keystrokes while getReferences returns the same reference objects. */
156
+ const sessionSearchTextCache = new WeakMap<SessionReference, string>();
157
+ const sessionItemCache = new WeakMap<SessionReference, { cwd: string; item: AutocompleteItem }>();
158
+
137
159
  function sessionSearchText(reference: SessionReference): string {
138
- return [
160
+ const cached = sessionSearchTextCache.get(reference);
161
+ if (cached !== undefined) return cached;
162
+ const text = [
139
163
  reference.info.name,
140
164
  reference.info.firstMessage,
141
165
  reference.info.cwd,
@@ -144,34 +168,65 @@ function sessionSearchText(reference: SessionReference): string {
144
168
  ]
145
169
  .filter(Boolean)
146
170
  .join(" ");
171
+ sessionSearchTextCache.set(reference, text);
172
+ return text;
147
173
  }
148
174
 
149
175
  function sessionItem(reference: SessionReference, currentCwd: string): AutocompleteItem {
176
+ const cached = sessionItemCache.get(reference);
177
+ if (cached && cached.cwd === currentCwd) return cached.item;
178
+
150
179
  const session = reference.info;
151
180
  const workspace = samePath(session.cwd, currentCwd)
152
181
  ? "current workspace"
153
182
  : session.cwd || "unknown workspace";
154
183
  const label = reference.kind === "subagent" ? "[SubAgent]" : "[Session]";
155
- return {
184
+ const item: AutocompleteItem = {
156
185
  value: `${SESSION_REFERENCE_PREFIX}${reference.referenceIds[0]}`,
157
186
  label: `${label} ${sessionTitle(session)}`,
158
187
  description: `${workspace} · ${session.messageCount} messages · ${formatDate(session.modified)}`,
159
188
  };
189
+ sessionItemCache.set(reference, { cwd: currentCwd, item });
190
+ return item;
160
191
  }
161
192
 
162
- function filterSessions(
193
+ /** Sort cache: same array + cwd reuses order (getReferences returns a stable ordered array). */
194
+ const orderedReferencesCache = new WeakMap<
195
+ SessionReference[],
196
+ { cwd: string; ordered: SessionReference[] }
197
+ >();
198
+
199
+ function orderSessionReferences(
163
200
  references: SessionReference[],
164
- query: string,
165
201
  currentCwd: string,
166
- ): AutocompleteItem[] {
167
- if (query.startsWith("session:")) return [];
202
+ ): SessionReference[] {
203
+ const cached = orderedReferencesCache.get(references);
204
+ if (cached && cached.cwd === currentCwd) return cached.ordered;
168
205
 
169
206
  const ordered = [...references].sort((left, right) => {
170
207
  const leftLocal = samePath(left.info.cwd, currentCwd) ? 1 : 0;
171
208
  const rightLocal = samePath(right.info.cwd, currentCwd) ? 1 : 0;
172
209
  return rightLocal - leftLocal || right.info.modified.getTime() - left.info.modified.getTime();
173
210
  });
174
- const matches = query.trim() ? fuzzyFilter(ordered, query, sessionSearchText) : ordered;
211
+ orderedReferencesCache.set(references, { cwd: currentCwd, ordered });
212
+ // Point the ordered array at itself so subsequent keystrokes skip re-sorting.
213
+ orderedReferencesCache.set(ordered, { cwd: currentCwd, ordered });
214
+ return ordered;
215
+ }
216
+
217
+ function filterSessions(
218
+ references: SessionReference[],
219
+ query: string,
220
+ currentCwd: string,
221
+ ): AutocompleteItem[] {
222
+ if (query.startsWith("session:")) return [];
223
+
224
+ const ordered = orderSessionReferences(references, currentCwd);
225
+ const trimmed = query.trim();
226
+ // Empty query: top-N already sorted — no fuzzy scan over the full list.
227
+ const matches = trimmed
228
+ ? fuzzyFilter(ordered, trimmed, sessionSearchText)
229
+ : ordered.slice(0, MAX_SESSION_SUGGESTIONS);
175
230
  return matches
176
231
  .slice(0, MAX_SESSION_SUGGESTIONS)
177
232
  .map((reference) => sessionItem(reference, currentCwd));
@@ -341,6 +396,7 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
341
396
  pi.on("session_start", (_event, ctx) => {
342
397
  const generation = ++sessionGeneration;
343
398
  subagentIds.clear();
399
+ clearLiveSubagentRecords();
344
400
  let loadErrorShown = false;
345
401
  const currentSessionId = ctx.sessionManager.getSessionId();
346
402
  const currentSessionFile = ctx.sessionManager.getSessionFile();
@@ -365,8 +421,27 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
365
421
  return sessionsPromise;
366
422
  };
367
423
 
368
- const getReferences = async (): Promise<SessionReference[]> =>
369
- mergeReferences(await getSessions(), liveSubagentReferences(subagentIds, currentSessionId));
424
+ // Pre-sort once per (sessions list, subagent id set) so @ keystrokes only fuzzy-filter.
425
+ let referencesCache:
426
+ | { sessions: SessionInfo[]; subagentKey: string; ordered: SessionReference[] }
427
+ | undefined;
428
+ const getReferences = async (): Promise<SessionReference[]> => {
429
+ const sessions = await getSessions();
430
+ const subagentKey = [...subagentIds].join("\0");
431
+ if (
432
+ referencesCache &&
433
+ referencesCache.sessions === sessions &&
434
+ referencesCache.subagentKey === subagentKey
435
+ ) {
436
+ return referencesCache.ordered;
437
+ }
438
+ const ordered = orderSessionReferences(
439
+ mergeReferences(sessions, liveSubagentReferences(subagentIds, currentSessionId)),
440
+ ctx.cwd,
441
+ );
442
+ referencesCache = { sessions, subagentKey, ordered };
443
+ return ordered;
444
+ };
370
445
 
371
446
  getAvailableReferences = getReferences;
372
447
  if (ctx.mode === "tui") {
@@ -463,6 +538,7 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
463
538
 
464
539
  pi.on("session_before_switch", () => {
465
540
  subagentIds.clear();
541
+ clearLiveSubagentRecords();
466
542
  getAvailableReferences = undefined;
467
543
  });
468
544
 
@@ -470,6 +546,7 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
470
546
  sessionGeneration++;
471
547
  for (const unsubscribe of unsubscribeSubagentEvents) unsubscribe();
472
548
  subagentIds.clear();
549
+ clearLiveSubagentRecords();
473
550
  getAvailableReferences = undefined;
474
551
  });
475
552
  }
@@ -277,29 +277,40 @@ function resolveLanguageFromPath(rawPath: string | undefined): string | undefine
277
277
  }
278
278
  }
279
279
 
280
+ const MAX_CODE_HIGHLIGHT_CACHE_ENTRIES = 2048;
281
+
280
282
  function createCodeLineHighlighter(language: string | undefined): CodeLineHighlighter {
281
283
  if (!language) {
282
284
  return (line) => sanitizeAnsiForThemedOutput(line);
283
285
  }
284
286
 
287
+ // Insertion-order Map used as a small LRU so large diffs cannot retain every unique line.
285
288
  const cache = new Map<string, string>();
289
+ const remember = (line: string, value: string): string => {
290
+ if (cache.has(line)) cache.delete(line);
291
+ cache.set(line, value);
292
+ if (cache.size > MAX_CODE_HIGHLIGHT_CACHE_ENTRIES) {
293
+ const oldest = cache.keys().next().value;
294
+ if (oldest !== undefined) cache.delete(oldest);
295
+ }
296
+ return value;
297
+ };
286
298
  return (line) => {
287
299
  if (!line) {
288
300
  return line;
289
301
  }
290
302
  const cached = cache.get(line);
291
303
  if (cached !== undefined) {
304
+ // Refresh recency for LRU.
305
+ cache.delete(line);
306
+ cache.set(line, cached);
292
307
  return cached;
293
308
  }
294
309
  try {
295
310
  const highlighted = highlightCode(line, language)[0] ?? line;
296
- const sanitized = sanitizeAnsiForThemedOutput(highlighted);
297
- cache.set(line, sanitized);
298
- return sanitized;
311
+ return remember(line, sanitizeAnsiForThemedOutput(highlighted));
299
312
  } catch {
300
- const sanitizedFallback = sanitizeAnsiForThemedOutput(line);
301
- cache.set(line, sanitizedFallback);
302
- return sanitizedFallback;
313
+ return remember(line, sanitizeAnsiForThemedOutput(line));
303
314
  }
304
315
  };
305
316
  }
@@ -2148,28 +2159,96 @@ function renderDiffSpacerLine(width: number): string {
2148
2159
  return safeWidth > 0 ? " ".repeat(safeWidth) : "";
2149
2160
  }
2150
2161
 
2151
- function applyLineLimit(
2152
- rows: RenderedRow[],
2153
- width: number,
2162
+ function resolveDiffDisplayLimit(
2154
2163
  expanded: boolean,
2155
2164
  maxCollapsedLines: number,
2156
2165
  maxExpandedLines: number,
2157
- totalHunks: number,
2158
- theme: DiffTheme,
2159
- ): string[] {
2166
+ ): number {
2160
2167
  const expandedLimit = Number.isFinite(maxExpandedLines)
2161
2168
  ? maxExpandedLines
2162
2169
  : DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
2163
2170
  const collapsedLimit = Number.isFinite(maxCollapsedLines)
2164
2171
  ? maxCollapsedLines
2165
2172
  : DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines;
2166
- const limit = expanded ? Math.max(0, expandedLimit) : Math.max(1, collapsedLimit);
2167
- if (limit === 0 || rows.length <= limit) {
2173
+ return expanded ? Math.max(0, expandedLimit) : Math.max(1, collapsedLimit);
2174
+ }
2175
+
2176
+ /**
2177
+ * How many source rows/entries to fully highlight+render before applyLineLimit.
2178
+ * Extra headroom covers word-wrap expansion so the display limit can still fill.
2179
+ */
2180
+ function resolveDiffProcessBudget(displayLimit: number, wordWrap: boolean): number {
2181
+ if (!Number.isFinite(displayLimit) || displayLimit <= 0) {
2182
+ return Number.POSITIVE_INFINITY;
2183
+ }
2184
+ return wordWrap ? displayLimit * 2 + 8 : displayLimit + 4;
2185
+ }
2186
+
2187
+ function countDiffLineEntries(entries: readonly ParsedDiffEntry[]): number {
2188
+ let count = 0;
2189
+ for (const entry of entries) {
2190
+ if (entry.kind === "line") count++;
2191
+ }
2192
+ return count;
2193
+ }
2194
+
2195
+ function takeEntriesForLineBudget(
2196
+ entries: ParsedDiffEntry[],
2197
+ maxLineEntries: number,
2198
+ ): { entries: ParsedDiffEntry[]; totalLineEntries: number; processedLineEntries: number } {
2199
+ const totalLineEntries = countDiffLineEntries(entries);
2200
+ if (!Number.isFinite(maxLineEntries) || maxLineEntries >= totalLineEntries) {
2201
+ return { entries, totalLineEntries, processedLineEntries: totalLineEntries };
2202
+ }
2203
+ const limited: ParsedDiffEntry[] = [];
2204
+ let processedLineEntries = 0;
2205
+ for (const entry of entries) {
2206
+ if (entry.kind === "line") {
2207
+ if (processedLineEntries >= maxLineEntries) break;
2208
+ processedLineEntries++;
2209
+ }
2210
+ limited.push(entry);
2211
+ }
2212
+ return { entries: limited, totalLineEntries, processedLineEntries };
2213
+ }
2214
+
2215
+ function takeSplitRowsForBudget(
2216
+ rows: SplitDiffRow[],
2217
+ maxRows: number,
2218
+ ): { rows: SplitDiffRow[]; totalRows: number; processedRows: number } {
2219
+ const totalRows = rows.length;
2220
+ if (!Number.isFinite(maxRows) || maxRows >= totalRows) {
2221
+ return { rows, totalRows, processedRows: totalRows };
2222
+ }
2223
+ const limited = rows.slice(0, maxRows);
2224
+ return { rows: limited, totalRows, processedRows: limited.length };
2225
+ }
2226
+
2227
+ function applyLineLimit(
2228
+ rows: RenderedRow[],
2229
+ width: number,
2230
+ expanded: boolean,
2231
+ maxCollapsedLines: number,
2232
+ maxExpandedLines: number,
2233
+ totalHunks: number,
2234
+ theme: DiffTheme,
2235
+ /**
2236
+ * When the caller only rendered a prefix of the full diff, pass the unprocessed
2237
+ * remainder so the collapse hint still reflects total hidden content.
2238
+ */
2239
+ unprocessedLogicalRows = 0,
2240
+ ): string[] {
2241
+ const limit = resolveDiffDisplayLimit(expanded, maxCollapsedLines, maxExpandedLines);
2242
+ const safeUnprocessed = Math.max(0, unprocessedLogicalRows);
2243
+ if (limit === 0 || (rows.length <= limit && safeUnprocessed === 0)) {
2168
2244
  return rows.map((row) => clampDiffLineToWidth(row.text, width));
2169
2245
  }
2170
2246
 
2171
- const shown = rows.slice(0, limit);
2172
- const remaining = rows.length - shown.length;
2247
+ const shown = rows.length <= limit ? rows : rows.slice(0, limit);
2248
+ const remaining = Math.max(0, rows.length - shown.length) + safeUnprocessed;
2249
+ if (remaining === 0) {
2250
+ return shown.map((row) => clampDiffLineToWidth(row.text, width));
2251
+ }
2173
2252
  const visibleHunks = new Set(
2174
2253
  shown
2175
2254
  .map((row) => row.hunkIndex)
@@ -2371,51 +2450,38 @@ export function renderEditDiffResult(
2371
2450
  }
2372
2451
 
2373
2452
  const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
2374
- const inlineHighlights = buildInlineHighlightMap(splitRows);
2453
+ const displayLimit = resolveDiffDisplayLimit(
2454
+ options.expanded,
2455
+ config.diffCollapsedLines,
2456
+ config.expandedPreviewMaxLines,
2457
+ );
2458
+ const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
2459
+ // Only highlight/render a prefix that can fill the display limit; full-diff
2460
+ // LCS + syntax highlight on thousands of hidden lines is pure waste when collapsed.
2461
+ const entryBudget = takeEntriesForLineBudget(parsed.entries, processBudget);
2462
+ const splitBudget = takeSplitRowsForBudget(splitRows, processBudget);
2463
+ const inlineHighlights = buildInlineHighlightMap(splitBudget.rows);
2464
+ const renderCtx: DiffRenderContext = {
2465
+ width: safeWidth,
2466
+ theme,
2467
+ inlineHighlights,
2468
+ palette,
2469
+ highlightLine,
2470
+ containerBgAnsi,
2471
+ wordWrap,
2472
+ indicatorMode,
2473
+ showHashlineAnchors,
2474
+ };
2375
2475
  const bodyRows =
2376
2476
  mode === "split"
2377
- ? renderSplit(
2378
- splitRows,
2379
- {
2380
- width: safeWidth,
2381
- theme,
2382
- inlineHighlights,
2383
- palette,
2384
- highlightLine,
2385
- containerBgAnsi,
2386
- wordWrap,
2387
- indicatorMode,
2388
- showHashlineAnchors,
2389
- },
2390
- lineNumberWidth,
2391
- )
2477
+ ? renderSplit(splitBudget.rows, renderCtx, lineNumberWidth)
2392
2478
  : mode === "compact"
2393
- ? renderCompact(parsed.entries, {
2394
- width: safeWidth,
2395
- theme,
2396
- inlineHighlights,
2397
- palette,
2398
- highlightLine,
2399
- containerBgAnsi,
2400
- wordWrap,
2401
- indicatorMode,
2402
- showHashlineAnchors,
2403
- })
2404
- : renderUnified(
2405
- parsed.entries,
2406
- {
2407
- width: safeWidth,
2408
- theme,
2409
- inlineHighlights,
2410
- palette,
2411
- highlightLine,
2412
- containerBgAnsi,
2413
- wordWrap,
2414
- indicatorMode,
2415
- showHashlineAnchors,
2416
- },
2417
- lineNumberWidth,
2418
- );
2479
+ ? renderCompact(entryBudget.entries, renderCtx)
2480
+ : renderUnified(entryBudget.entries, renderCtx, lineNumberWidth);
2481
+ const unprocessedLogicalRows =
2482
+ mode === "split"
2483
+ ? Math.max(0, splitBudget.totalRows - splitBudget.processedRows)
2484
+ : Math.max(0, entryBudget.totalLineEntries - entryBudget.processedLineEntries);
2419
2485
  const bodyWithLimit = applyLineLimit(
2420
2486
  bodyRows,
2421
2487
  safeWidth,
@@ -2424,6 +2490,7 @@ export function renderEditDiffResult(
2424
2490
  config.expandedPreviewMaxLines,
2425
2491
  parsed.stats.hunks,
2426
2492
  theme,
2493
+ unprocessedLogicalRows,
2427
2494
  );
2428
2495
  const frame = renderDiffFrameLine(safeWidth, theme);
2429
2496
  const renderedLines =
@@ -2593,6 +2660,8 @@ interface WriteOverwriteGuard {
2593
2660
 
2594
2661
  const MAX_WRITE_OVERWRITE_DIFF_LINES = 4000;
2595
2662
  const MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS = 1_000_000;
2663
+ /** Collapsed overwrite previews refuse larger LCS matrices to avoid TUI freezes. */
2664
+ const MAX_COLLAPSED_WRITE_OVERWRITE_DIFF_MATRIX_CELLS = 200_000;
2596
2665
 
2597
2666
  function buildApproximateWriteStats(
2598
2667
  lineCount: number,
@@ -2631,6 +2700,7 @@ function buildWriteDiffData(entries: ParsedDiffEntry[]): WriteDiffData {
2631
2700
  function resolveWriteOverwriteGuard(
2632
2701
  previousLines: string[],
2633
2702
  nextLines: string[],
2703
+ expanded = true,
2634
2704
  ): WriteOverwriteGuard | undefined {
2635
2705
  const previousLineCount = previousLines.length;
2636
2706
  const nextLineCount = nextLines.length;
@@ -2643,7 +2713,10 @@ function resolveWriteOverwriteGuard(
2643
2713
  if (previousLineCount === 0 || nextLineCount === 0) {
2644
2714
  return undefined;
2645
2715
  }
2646
- return previousLineCount * nextLineCount > MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS
2716
+ const cellLimit = expanded
2717
+ ? MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS
2718
+ : MAX_COLLAPSED_WRITE_OVERWRITE_DIFF_MATRIX_CELLS;
2719
+ return previousLineCount * nextLineCount > cellLimit
2647
2720
  ? { previousLineCount, nextLineCount }
2648
2721
  : undefined;
2649
2722
  }
@@ -2704,9 +2777,6 @@ export function renderWriteDiffResult(
2704
2777
  previousLines.length,
2705
2778
  hasComparablePrevious,
2706
2779
  );
2707
- const overwriteGuard = hasComparablePrevious
2708
- ? resolveWriteOverwriteGuard(previousLines, lines)
2709
- : undefined;
2710
2780
  const palette = resolveDiffPalette(theme);
2711
2781
  // Keep the panel transparent; changed rows and inline spans retain their own highlights.
2712
2782
  const containerBgAnsi = undefined;
@@ -2753,6 +2823,10 @@ export function renderWriteDiffResult(
2753
2823
  theme,
2754
2824
  options.headerLabel,
2755
2825
  );
2826
+ // Guard depends on expanded: collapsed previews use a stricter LCS cell budget.
2827
+ const overwriteGuard = hasComparablePrevious
2828
+ ? resolveWriteOverwriteGuard(previousLines, lines, options.expanded)
2829
+ : undefined;
2756
2830
  if (overwriteGuard) {
2757
2831
  return cache.set(
2758
2832
  safeWidth,
@@ -2787,10 +2861,22 @@ export function renderWriteDiffResult(
2787
2861
  }
2788
2862
 
2789
2863
  const data = getDetailedData();
2864
+ const displayLimit = resolveDiffDisplayLimit(
2865
+ options.expanded,
2866
+ config.diffCollapsedLines,
2867
+ config.expandedPreviewMaxLines,
2868
+ );
2869
+ const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
2870
+ const entryBudget = takeEntriesForLineBudget(data.entries, processBudget);
2871
+ const splitBudget = takeSplitRowsForBudget(data.splitRows, processBudget);
2872
+ const inlineHighlights =
2873
+ splitBudget.processedRows < splitBudget.totalRows
2874
+ ? buildInlineHighlightMap(splitBudget.rows)
2875
+ : data.inlineHighlights;
2790
2876
  const renderCtx: DiffRenderContext = {
2791
2877
  width: safeWidth,
2792
2878
  theme,
2793
- inlineHighlights: data.inlineHighlights,
2879
+ inlineHighlights,
2794
2880
  palette,
2795
2881
  highlightLine,
2796
2882
  containerBgAnsi,
@@ -2799,13 +2885,17 @@ export function renderWriteDiffResult(
2799
2885
  showHashlineAnchors: false,
2800
2886
  };
2801
2887
  const bodyRows: RenderedRow[] =
2802
- data.entries.length === 0
2888
+ entryBudget.entries.length === 0
2803
2889
  ? [{ text: theme.fg("muted", "(empty file)"), hunkIndex: null }]
2804
2890
  : mode === "split"
2805
- ? renderSplit(data.splitRows, renderCtx, data.lineNumberWidth)
2891
+ ? renderSplit(splitBudget.rows, renderCtx, data.lineNumberWidth)
2806
2892
  : mode === "compact"
2807
- ? renderCompact(data.entries, renderCtx)
2808
- : renderUnified(data.entries, renderCtx, data.lineNumberWidth);
2893
+ ? renderCompact(entryBudget.entries, renderCtx)
2894
+ : renderUnified(entryBudget.entries, renderCtx, data.lineNumberWidth);
2895
+ const unprocessedLogicalRows =
2896
+ mode === "split"
2897
+ ? Math.max(0, splitBudget.totalRows - splitBudget.processedRows)
2898
+ : Math.max(0, entryBudget.totalLineEntries - entryBudget.processedLineEntries);
2809
2899
 
2810
2900
  const bodyWithLimit = applyLineLimit(
2811
2901
  bodyRows,
@@ -2815,6 +2905,7 @@ export function renderWriteDiffResult(
2815
2905
  config.expandedPreviewMaxLines,
2816
2906
  data.hunkCount,
2817
2907
  theme,
2908
+ unprocessedLogicalRows,
2818
2909
  );
2819
2910
  const frame = renderDiffFrameLine(safeWidth, theme);
2820
2911
  const renderedLines =