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.
- package/extensions/ask-user-question/state/build-questionnaire.ts +14 -2
- package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +7 -0
- package/extensions/claude-code-style.ts +947 -72
- package/extensions/compact-style.ts +4 -2
- package/extensions/context.ts +53 -14
- package/extensions/session-reference.ts +86 -9
- package/extensions/tool-diff/diff-renderer.ts +158 -67
- package/extensions/tool-diff/index.ts +11 -4
- package/extensions/tool-diff/types.ts +7 -1
- package/extensions/tool-result-sanitize.ts +10 -3
- package/package.json +1 -1
|
@@ -192,7 +192,8 @@ function shortenPath(path: unknown): string {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
function oneLine(value: unknown): string {
|
|
195
|
-
|
|
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
|
-
|
|
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);
|
package/extensions/context.ts
CHANGED
|
@@ -213,19 +213,53 @@ export function formatTokens(tokens: number): string {
|
|
|
213
213
|
return `${Math.round(tokens / 1_000)}k`;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(
|
|
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
|
|
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
|
|
267
|
-
const
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
297
|
-
cache.set(line, sanitized);
|
|
298
|
-
return sanitized;
|
|
311
|
+
return remember(line, sanitizeAnsiForThemedOutput(highlighted));
|
|
299
312
|
} catch {
|
|
300
|
-
|
|
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
|
|
2152
|
-
rows: RenderedRow[],
|
|
2153
|
-
width: number,
|
|
2162
|
+
function resolveDiffDisplayLimit(
|
|
2154
2163
|
expanded: boolean,
|
|
2155
2164
|
maxCollapsedLines: number,
|
|
2156
2165
|
maxExpandedLines: number,
|
|
2157
|
-
|
|
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
|
-
|
|
2167
|
-
|
|
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
|
|
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(
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2888
|
+
entryBudget.entries.length === 0
|
|
2803
2889
|
? [{ text: theme.fg("muted", "(empty file)"), hunkIndex: null }]
|
|
2804
2890
|
: mode === "split"
|
|
2805
|
-
? renderSplit(
|
|
2891
|
+
? renderSplit(splitBudget.rows, renderCtx, data.lineNumberWidth)
|
|
2806
2892
|
: mode === "compact"
|
|
2807
|
-
? renderCompact(
|
|
2808
|
-
: renderUnified(
|
|
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 =
|