@yinan_chen/pi-claude-code-ui 0.0.0-stage → 0.2.1

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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -2
  3. package/package.json +34 -5
  4. package/src/claude-tools.ts +274 -0
  5. package/src/claude-view.ts +775 -0
  6. package/src/index.ts +566 -0
  7. package/src/pi-runtime.ts +232 -0
  8. package/src/preview.ts +147 -0
  9. package/src/running-effect.ts +29 -0
  10. package/src/skill-block-recollapse.ts +362 -0
  11. package/src/sticky-first-message.ts +1114 -0
  12. package/src/subagent-panel.ts +144 -0
  13. package/src/vendor/ATTRIBUTION.md +26 -0
  14. package/src/vendor/ansi-color.ts +44 -0
  15. package/src/vendor/ansi-text.ts +47 -0
  16. package/src/vendor/diff/ATTRIBUTION.md +28 -0
  17. package/src/vendor/diff/ansi-utils.ts +113 -0
  18. package/src/vendor/diff/diff-component.ts +125 -0
  19. package/src/vendor/diff/diff-edit-render.ts +237 -0
  20. package/src/vendor/diff/diff-header.ts +137 -0
  21. package/src/vendor/diff/diff-highlight.ts +115 -0
  22. package/src/vendor/diff/diff-inline.ts +324 -0
  23. package/src/vendor/diff/diff-layout.ts +762 -0
  24. package/src/vendor/diff/diff-limits.ts +229 -0
  25. package/src/vendor/diff/diff-palette.ts +410 -0
  26. package/src/vendor/diff/diff-parse.ts +535 -0
  27. package/src/vendor/diff/diff-presentation.ts +70 -0
  28. package/src/vendor/diff/diff-renderer.ts +23 -0
  29. package/src/vendor/diff/diff-text.ts +45 -0
  30. package/src/vendor/diff/diff-write-render.ts +572 -0
  31. package/src/vendor/diff/index.ts +126 -0
  32. package/src/vendor/diff/line-width-safety.ts +92 -0
  33. package/src/vendor/diff/shiki-highlight.ts +92 -0
  34. package/src/vendor/diff/write-execution.ts +113 -0
  35. package/src/vendor/diff-config.ts +30 -0
  36. package/src/vendor/show-more-hint.ts +38 -0
  37. package/src/vendor/tool-result-sanitize.ts +24 -0
  38. package/src/working-message.ts +140 -0
@@ -0,0 +1,572 @@
1
+ import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
+ import { sanitizeToolResultText } from "../tool-result-sanitize.ts";
3
+ import { showMoreHintText } from "../show-more-hint.ts";
4
+ import { splitWriteContentLines } from "./diff-text.ts";
5
+ import {
6
+ collectDiffStats,
7
+ getLineNumberWidth,
8
+ type DiffLineEntry,
9
+ type DiffStats,
10
+ type ParsedDiffEntry,
11
+ } from "./diff-parse.ts";
12
+ import {
13
+ buildInlineHighlightMap,
14
+ buildSplitRows,
15
+ type DiffSpan,
16
+ type SplitDiffRow,
17
+ } from "./diff-inline.ts";
18
+ import { resolveDiffPalette, type DiffTheme } from "./diff-palette.ts";
19
+ import {
20
+ createCodeLineHighlighter,
21
+ resolveLanguageFromPath,
22
+ type CodeLineHighlighter,
23
+ } from "./diff-highlight.ts";
24
+ import {
25
+ canRenderSplitLayout,
26
+ renderCompact,
27
+ renderSplit,
28
+ renderUnified,
29
+ type DiffRenderContext,
30
+ type RenderedRow,
31
+ } from "./diff-layout.ts";
32
+ import { renderDiffFrameLine, renderSingleDiffRow, renderWriteHeader } from "./diff-header.ts";
33
+ import {
34
+ applyLineLimit,
35
+ clampDiffLineToWidth,
36
+ clampDiffLinesToWidth,
37
+ resolveDiffDisplayLimit,
38
+ resolveDiffProcessBudget,
39
+ resolveWriteCollapsedLimit,
40
+ takeEntriesForLineBudget,
41
+ takeSplitRowsForBudget,
42
+ SPLIT_DECOR_ROWS,
43
+ } from "./diff-limits.ts";
44
+ import {
45
+ RICH_DIFF_COMPONENT,
46
+ createDiffRenderCache,
47
+ displayConfigCacheKey,
48
+ resolveDiffIndicatorMode,
49
+ resolveLiveDisplayConfig,
50
+ normalizeCollapsedHintLine,
51
+ type DiffRenderOptions,
52
+ type DisplayConfigInput,
53
+ } from "./diff-component.ts";
54
+ import {
55
+ buildDiffSummaryText,
56
+ normalizeDiffRenderWidth,
57
+ resolveDiffPresentationMode,
58
+ type DiffPresentationMode,
59
+ } from "./diff-presentation.ts";
60
+
61
+ type WriteDiffOperationKind = "context" | "remove" | "add";
62
+
63
+ interface WriteDiffOperation {
64
+ kind: WriteDiffOperationKind;
65
+ content: string;
66
+ }
67
+
68
+ function buildWriteDiffOperations(oldLines: string[], newLines: string[]): WriteDiffOperation[] {
69
+ const oldLength = oldLines.length;
70
+ const newLength = newLines.length;
71
+ const table: number[][] = Array.from({ length: oldLength + 1 }, () =>
72
+ Array<number>(newLength + 1).fill(0),
73
+ );
74
+
75
+ for (let oldIndex = 1; oldIndex <= oldLength; oldIndex++) {
76
+ for (let newIndex = 1; newIndex <= newLength; newIndex++) {
77
+ if ((oldLines[oldIndex - 1] ?? "") === (newLines[newIndex - 1] ?? "")) {
78
+ table[oldIndex]![newIndex] = (table[oldIndex - 1]?.[newIndex - 1] ?? 0) + 1;
79
+ continue;
80
+ }
81
+ const top = table[oldIndex - 1]?.[newIndex] ?? 0;
82
+ const left = table[oldIndex]?.[newIndex - 1] ?? 0;
83
+ table[oldIndex]![newIndex] = Math.max(top, left);
84
+ }
85
+ }
86
+
87
+ const operations: WriteDiffOperation[] = [];
88
+ let oldCursor = oldLength;
89
+ let newCursor = newLength;
90
+
91
+ while (oldCursor > 0 || newCursor > 0) {
92
+ const oldLine = oldCursor > 0 ? (oldLines[oldCursor - 1] ?? "") : undefined;
93
+ const newLine = newCursor > 0 ? (newLines[newCursor - 1] ?? "") : undefined;
94
+
95
+ if (oldCursor > 0 && newCursor > 0 && oldLine === newLine) {
96
+ operations.push({ kind: "context", content: oldLine ?? "" });
97
+ oldCursor--;
98
+ newCursor--;
99
+ continue;
100
+ }
101
+
102
+ const top = oldCursor > 0 ? (table[oldCursor - 1]?.[newCursor] ?? 0) : -1;
103
+ const left = newCursor > 0 ? (table[oldCursor]?.[newCursor - 1] ?? 0) : -1;
104
+
105
+ if (newCursor > 0 && left >= top) {
106
+ operations.push({ kind: "add", content: newLine ?? "" });
107
+ newCursor--;
108
+ continue;
109
+ }
110
+
111
+ if (oldCursor > 0) {
112
+ operations.push({ kind: "remove", content: oldLine ?? "" });
113
+ oldCursor--;
114
+ }
115
+ }
116
+
117
+ operations.reverse();
118
+ return operations;
119
+ }
120
+
121
+ function buildWriteEntries(lines: string[]): ParsedDiffEntry[] {
122
+ return lines.map((line, index) => ({
123
+ kind: "line",
124
+ lineKind: "add",
125
+ oldLineNumber: null,
126
+ newLineNumber: index + 1,
127
+ fallbackLineNumber: `${index + 1}`,
128
+ content: line,
129
+ raw: `+${line}`,
130
+ hunkIndex: 1,
131
+ }));
132
+ }
133
+
134
+ function buildWriteOverwriteEntries(oldLines: string[], newLines: string[]): ParsedDiffEntry[] {
135
+ const operations = buildWriteDiffOperations(oldLines, newLines);
136
+ const entries: ParsedDiffEntry[] = [];
137
+ let oldLineNumber = 1;
138
+ let newLineNumber = 1;
139
+
140
+ for (const operation of operations) {
141
+ if (operation.kind === "context") {
142
+ entries.push({
143
+ kind: "line",
144
+ lineKind: "context",
145
+ oldLineNumber,
146
+ newLineNumber,
147
+ fallbackLineNumber: `${newLineNumber}`,
148
+ content: operation.content,
149
+ raw: ` ${operation.content}`,
150
+ hunkIndex: 1,
151
+ });
152
+ oldLineNumber++;
153
+ newLineNumber++;
154
+ continue;
155
+ }
156
+
157
+ if (operation.kind === "remove") {
158
+ entries.push({
159
+ kind: "line",
160
+ lineKind: "remove",
161
+ oldLineNumber,
162
+ newLineNumber: null,
163
+ fallbackLineNumber: `${oldLineNumber}`,
164
+ content: operation.content,
165
+ raw: `-${operation.content}`,
166
+ hunkIndex: 1,
167
+ });
168
+ oldLineNumber++;
169
+ continue;
170
+ }
171
+
172
+ entries.push({
173
+ kind: "line",
174
+ lineKind: "add",
175
+ oldLineNumber: null,
176
+ newLineNumber,
177
+ fallbackLineNumber: `${newLineNumber}`,
178
+ content: operation.content,
179
+ raw: `+${operation.content}`,
180
+ hunkIndex: 1,
181
+ });
182
+ newLineNumber++;
183
+ }
184
+
185
+ return entries;
186
+ }
187
+
188
+ interface WriteDiffData {
189
+ entries: ParsedDiffEntry[];
190
+ splitRows: SplitDiffRow[];
191
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>;
192
+ lineNumberWidth: number;
193
+ stats: DiffStats;
194
+ hunkCount: number;
195
+ }
196
+
197
+ interface WriteOverwriteGuard {
198
+ previousLineCount: number;
199
+ nextLineCount: number;
200
+ }
201
+
202
+ const MAX_WRITE_OVERWRITE_DIFF_LINES = 4000;
203
+ const MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS = 1_000_000;
204
+ /** Collapsed overwrite previews refuse larger LCS matrices to avoid TUI freezes. */
205
+ const MAX_COLLAPSED_WRITE_OVERWRITE_DIFF_MATRIX_CELLS = 200_000;
206
+
207
+ function buildApproximateWriteStats(
208
+ lineCount: number,
209
+ previousLineCount: number,
210
+ hasComparablePrevious: boolean,
211
+ ): DiffStats {
212
+ const removed = hasComparablePrevious ? previousLineCount : 0;
213
+ const added = lineCount;
214
+ const hasContent = lineCount > 0 || removed > 0;
215
+ return {
216
+ added,
217
+ removed,
218
+ context: 0,
219
+ hunks: hasContent ? 1 : 0,
220
+ files: 1,
221
+ lines: added + removed,
222
+ };
223
+ }
224
+
225
+ function buildWriteDiffData(entries: ParsedDiffEntry[]): WriteDiffData {
226
+ const splitRows = buildSplitRows(entries);
227
+ const inlineHighlights = buildInlineHighlightMap(splitRows);
228
+ const lineNumberWidth = getLineNumberWidth(entries);
229
+ const hunkCount = entries.length > 0 ? 1 : 0;
230
+ const stats = collectDiffStats(entries, hunkCount, 1);
231
+ return {
232
+ entries,
233
+ splitRows,
234
+ inlineHighlights,
235
+ lineNumberWidth,
236
+ stats,
237
+ hunkCount,
238
+ };
239
+ }
240
+
241
+ function resolveWriteOverwriteGuard(
242
+ previousLines: string[],
243
+ nextLines: string[],
244
+ expanded = true,
245
+ ): WriteOverwriteGuard | undefined {
246
+ const previousLineCount = previousLines.length;
247
+ const nextLineCount = nextLines.length;
248
+ if (
249
+ previousLineCount > MAX_WRITE_OVERWRITE_DIFF_LINES ||
250
+ nextLineCount > MAX_WRITE_OVERWRITE_DIFF_LINES
251
+ ) {
252
+ return { previousLineCount, nextLineCount };
253
+ }
254
+ if (previousLineCount === 0 || nextLineCount === 0) {
255
+ return undefined;
256
+ }
257
+ const cellLimit = expanded
258
+ ? MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS
259
+ : MAX_COLLAPSED_WRITE_OVERWRITE_DIFF_MATRIX_CELLS;
260
+ return previousLineCount * nextLineCount > cellLimit
261
+ ? { previousLineCount, nextLineCount }
262
+ : undefined;
263
+ }
264
+
265
+ function buildWriteOverwriteGuardText(guard: WriteOverwriteGuard, width: number): string {
266
+ const safeWidth = normalizeDiffRenderWidth(width);
267
+ if (safeWidth === 0) {
268
+ return "";
269
+ }
270
+
271
+ const candidates = [
272
+ `↳ overwrite diff omitted (${guard.previousLineCount} → ${guard.nextLineCount} lines)`,
273
+ `↳ overwrite diff omitted (${guard.previousLineCount}→${guard.nextLineCount})`,
274
+ "↳ overwrite diff omitted",
275
+ "diff omitted",
276
+ "…",
277
+ ];
278
+ for (const candidate of candidates) {
279
+ if (visibleWidth(candidate) <= safeWidth) {
280
+ return candidate;
281
+ }
282
+ }
283
+ return truncateToWidth(candidates[candidates.length - 1] ?? "", safeWidth, "");
284
+ }
285
+
286
+ function renderWriteOverwriteGuardRows(
287
+ guard: WriteOverwriteGuard,
288
+ width: number,
289
+ theme: DiffTheme,
290
+ ): string[] {
291
+ return renderSingleDiffRow(buildWriteOverwriteGuardText(guard, width), "warning", width, theme);
292
+ }
293
+
294
+ /** Folded write with `writeDiffCollapsedLines: 0`: `↳ created • click to show more`. */
295
+ function renderWriteCollapsedHintLine(
296
+ wasOverwrite: boolean,
297
+ width: number,
298
+ theme: DiffTheme,
299
+ hovered: boolean,
300
+ headerLabel?: string,
301
+ ): string[] {
302
+ const safeWidth = normalizeDiffRenderWidth(width);
303
+ if (safeWidth === 0) {
304
+ return [""];
305
+ }
306
+ const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
307
+ const clickLabel = showMoreHintText();
308
+ const candidates = [`↳ ${actionLabel} • ${clickLabel}`, `↳ ${actionLabel}`, actionLabel, "…"];
309
+ let text = candidates[candidates.length - 1] ?? "…";
310
+ for (const candidate of candidates) {
311
+ if (visibleWidth(candidate) <= safeWidth) {
312
+ text = candidate;
313
+ break;
314
+ }
315
+ }
316
+ if (visibleWidth(text) > safeWidth) {
317
+ text = truncateToWidth(text, safeWidth, "");
318
+ }
319
+ const clickIndex = text.lastIndexOf(clickLabel);
320
+ const styled =
321
+ hovered && clickIndex >= 0
322
+ ? theme.fg("muted", text.slice(0, clickIndex)) +
323
+ theme.fg("text", clickLabel) +
324
+ theme.fg("muted", text.slice(clickIndex + clickLabel.length))
325
+ : theme.fg("muted", text);
326
+ return [clampDiffLineToWidth(styled, safeWidth)];
327
+ }
328
+
329
+ /**
330
+ * write 变更行统计(compact 单行用):复用当前 write diff 算法。
331
+ * 新文件 = 全部新内容为 added;覆盖已有文件用 LCS 精确统计。
332
+ * content 非字符串、元数据缺失或超过矩阵预算时返回 undefined(不显示计数)。
333
+ */
334
+ export function countWriteDiffStats(
335
+ content: string | undefined,
336
+ previousContent: string | undefined,
337
+ fileExistedBeforeWrite: boolean | undefined,
338
+ ): { added: number; removed: number } | undefined {
339
+ if (typeof content !== "string" || fileExistedBeforeWrite === undefined) return undefined;
340
+ const lines = splitWriteContentLines(sanitizeToolResultText(content));
341
+ if (fileExistedBeforeWrite === false) return { added: lines.length, removed: 0 };
342
+ if (typeof previousContent !== "string") return undefined;
343
+ const previousLines = splitWriteContentLines(sanitizeToolResultText(previousContent));
344
+ if (previousLines.length === 0 || lines.length === 0) {
345
+ return { added: lines.length, removed: previousLines.length };
346
+ }
347
+ if (previousLines.length * lines.length > MAX_COLLAPSED_WRITE_OVERWRITE_DIFF_MATRIX_CELLS) {
348
+ return undefined;
349
+ }
350
+ let added = 0;
351
+ let removed = 0;
352
+ for (const operation of buildWriteDiffOperations(previousLines, lines)) {
353
+ if (operation.kind === "add") added++;
354
+ else if (operation.kind === "remove") removed++;
355
+ }
356
+ return { added, removed };
357
+ }
358
+
359
+ export function renderWriteDiffResult(
360
+ content: string | undefined,
361
+ options: DiffRenderOptions,
362
+ config: DisplayConfigInput,
363
+ theme: DiffTheme,
364
+ fallbackText: string,
365
+ ): Component {
366
+ const safeFallbackText = sanitizeToolResultText(fallbackText);
367
+ if (typeof content !== "string") {
368
+ if (!safeFallbackText.trim()) {
369
+ return new Text(theme.fg("muted", "↳ write completed"), 0, 0);
370
+ }
371
+ return new Text(theme.fg("toolOutput", safeFallbackText), 0, 0);
372
+ }
373
+
374
+ const filePath = options.filePath?.trim() || "(unknown path)";
375
+ const lines = splitWriteContentLines(sanitizeToolResultText(content));
376
+ const previousLines =
377
+ typeof options.previousContent === "string"
378
+ ? splitWriteContentLines(sanitizeToolResultText(options.previousContent))
379
+ : [];
380
+ const hasComparablePrevious =
381
+ options.fileExistedBeforeWrite === true && typeof options.previousContent === "string";
382
+ const approximateStats = buildApproximateWriteStats(
383
+ lines.length,
384
+ previousLines.length,
385
+ hasComparablePrevious,
386
+ );
387
+ const palette = resolveDiffPalette(theme);
388
+ // Keep the panel transparent; changed rows and inline spans retain their own highlights.
389
+ const containerBgAnsi = undefined;
390
+ const language = resolveLanguageFromPath(filePath);
391
+ const cache = createDiffRenderCache();
392
+ let highlightLine: CodeLineHighlighter | undefined;
393
+ let detailedData: WriteDiffData | undefined;
394
+
395
+ function getDetailedData(): WriteDiffData {
396
+ if (detailedData) {
397
+ return detailedData;
398
+ }
399
+ const entries = hasComparablePrevious
400
+ ? buildWriteOverwriteEntries(previousLines, lines)
401
+ : buildWriteEntries(lines);
402
+ detailedData = buildWriteDiffData(entries);
403
+ highlightLine = createCodeLineHighlighter(language, theme, entries, () => {
404
+ cache.invalidate();
405
+ options.invalidate?.();
406
+ });
407
+ return detailedData;
408
+ }
409
+
410
+ return {
411
+ [RICH_DIFF_COMPONENT]: true,
412
+ /** 鼠标层只在真正的 remainder 行上提供展开入口。 */
413
+ isCollapsedHintLine(plainLine: string): boolean {
414
+ const hint = cache.getHintLine();
415
+ return hint !== undefined && normalizeCollapsedHintLine(plainLine) === hint;
416
+ },
417
+ render(width: number): string[] {
418
+ // Live config: panel can change indicator/wrap/limits after this component is created.
419
+ const live = resolveLiveDisplayConfig(config);
420
+ const wordWrap = live.diffWordWrap;
421
+ const indicatorMode = resolveDiffIndicatorMode(live);
422
+ const configKey = displayConfigCacheKey(live);
423
+ const safeWidth = normalizeDiffRenderWidth(width);
424
+ const resolvedMode = resolveDiffPresentationMode(
425
+ live,
426
+ safeWidth,
427
+ canRenderSplitLayout(safeWidth),
428
+ );
429
+ const mode: DiffPresentationMode = hasComparablePrevious
430
+ ? resolvedMode
431
+ : resolvedMode === "split"
432
+ ? "unified"
433
+ : resolvedMode;
434
+ const hovered = options.isHovered?.() ?? false;
435
+ const cached = cache.get(safeWidth, options.expanded, mode, configKey, hovered);
436
+ if (cached) {
437
+ return cached;
438
+ }
439
+
440
+ const writeCollapsedLimit = resolveWriteCollapsedLimit(live);
441
+ const statsOnlyCollapsed = !options.expanded && writeCollapsedLimit === 0;
442
+ if (statsOnlyCollapsed) {
443
+ const hintLines = renderWriteCollapsedHintLine(
444
+ options.fileExistedBeforeWrite === true,
445
+ safeWidth,
446
+ theme,
447
+ hovered,
448
+ options.headerLabel,
449
+ );
450
+ return cache.set(
451
+ safeWidth,
452
+ options.expanded,
453
+ mode,
454
+ configKey,
455
+ hovered,
456
+ clampDiffLinesToWidth(hintLines, safeWidth),
457
+ normalizeCollapsedHintLine(hintLines[0]),
458
+ );
459
+ }
460
+
461
+ const header = renderWriteHeader(
462
+ options.fileExistedBeforeWrite === true,
463
+ safeWidth,
464
+ theme,
465
+ options.headerLabel,
466
+ );
467
+ // Guard depends on expanded: collapsed previews use a stricter LCS cell budget.
468
+ const overwriteGuard = hasComparablePrevious
469
+ ? resolveWriteOverwriteGuard(previousLines, lines, options.expanded)
470
+ : undefined;
471
+ if (overwriteGuard) {
472
+ return cache.set(
473
+ safeWidth,
474
+ options.expanded,
475
+ mode,
476
+ configKey,
477
+ hovered,
478
+ clampDiffLinesToWidth(
479
+ [header, ...renderWriteOverwriteGuardRows(overwriteGuard, safeWidth, theme)],
480
+ safeWidth,
481
+ ),
482
+ );
483
+ }
484
+
485
+ if (mode === "summary") {
486
+ const summaryRows =
487
+ approximateStats.lines === 0
488
+ ? [header]
489
+ : [
490
+ header,
491
+ ...renderSingleDiffRow(
492
+ buildDiffSummaryText(approximateStats, safeWidth),
493
+ "toolOutput",
494
+ safeWidth,
495
+ theme,
496
+ ),
497
+ ];
498
+ return cache.set(
499
+ safeWidth,
500
+ options.expanded,
501
+ mode,
502
+ configKey,
503
+ hovered,
504
+ clampDiffLinesToWidth(summaryRows, safeWidth),
505
+ );
506
+ }
507
+
508
+ const data = getDetailedData();
509
+ const displayLimit = resolveDiffDisplayLimit(options.expanded, writeCollapsedLimit);
510
+ const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
511
+ const entryBudget = takeEntriesForLineBudget(data.entries, processBudget);
512
+ const splitBudget = takeSplitRowsForBudget(data.splitRows, processBudget);
513
+ const inlineHighlights =
514
+ splitBudget.processedRows < splitBudget.totalRows
515
+ ? buildInlineHighlightMap(splitBudget.rows)
516
+ : data.inlineHighlights;
517
+ const renderCtx: DiffRenderContext = {
518
+ width: safeWidth,
519
+ theme,
520
+ inlineHighlights,
521
+ palette,
522
+ highlightLine: highlightLine!,
523
+ containerBgAnsi,
524
+ wordWrap,
525
+ indicatorMode,
526
+ showHashlineAnchors: false,
527
+ };
528
+ const bodyRows: RenderedRow[] =
529
+ entryBudget.entries.length === 0
530
+ ? [{ text: theme.fg("muted", "(empty file)"), hunkIndex: null }]
531
+ : mode === "split"
532
+ ? renderSplit(splitBudget.rows, renderCtx, data.lineNumberWidth)
533
+ : mode === "compact"
534
+ ? renderCompact(entryBudget.entries, renderCtx)
535
+ : renderUnified(entryBudget.entries, renderCtx, data.lineNumberWidth);
536
+ const unprocessedLogicalRows =
537
+ mode === "split"
538
+ ? Math.max(0, splitBudget.totalRows - splitBudget.processedRows)
539
+ : Math.max(0, entryBudget.totalLineEntries - entryBudget.processedLineEntries);
540
+
541
+ // Split decor rows (column rule + old/new labels) must not consume the
542
+ // collapsed body budget: the limit is meant for content rows.
543
+ const collapsedBodyLimit = mode === "split" && writeCollapsedLimit > 0 ? writeCollapsedLimit + SPLIT_DECOR_ROWS : writeCollapsedLimit;
544
+ const bodyWithLimit = applyLineLimit(
545
+ bodyRows,
546
+ safeWidth,
547
+ options.expanded,
548
+ collapsedBodyLimit,
549
+ data.hunkCount,
550
+ theme,
551
+ unprocessedLogicalRows,
552
+ hovered,
553
+ );
554
+ const frame = renderDiffFrameLine(safeWidth, theme);
555
+ const renderedLines =
556
+ mode === "unified"
557
+ ? [header, frame, ...bodyWithLimit.lines, frame]
558
+ : [header, ...bodyWithLimit.lines];
559
+ const finalLines = clampDiffLinesToWidth(renderedLines, safeWidth);
560
+ return cache.set(
561
+ safeWidth,
562
+ options.expanded,
563
+ mode,
564
+ configKey,
565
+ hovered,
566
+ finalLines,
567
+ normalizeCollapsedHintLine(bodyWithLimit.hintLine),
568
+ );
569
+ },
570
+ invalidate: cache.invalidate,
571
+ } as Component;
572
+ }
@@ -0,0 +1,126 @@
1
+ import { createWriteToolDefinition, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import {
4
+ renderEditDiffResult,
5
+ renderWriteDiffResult,
6
+ type DisplayConfigInput,
7
+ } from "./diff-renderer.ts";
8
+ import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../diff-config.ts";
9
+ import { executeWriteWithMetadata, WriteExecutionMetadataStore } from "./write-execution.ts";
10
+
11
+ function resultText(result: any): string {
12
+ const blocks = Array.isArray(result?.content) ? result.content : [];
13
+ return blocks
14
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
15
+ .map((block: any) => block.text)
16
+ .join("\n");
17
+ }
18
+
19
+ function unavailableComponent(reason: string, theme: any) {
20
+ return {
21
+ render(width: number): string[] {
22
+ return [
23
+ truncateToWidth(
24
+ theme.fg("warning", `↳ diff unavailable: ${reason}`),
25
+ Math.max(0, width),
26
+ "",
27
+ ),
28
+ ];
29
+ },
30
+ invalidate() {},
31
+ };
32
+ }
33
+
34
+ export function renderRichToolResult(
35
+ toolName: string,
36
+ result: any,
37
+ options: any,
38
+ theme: any,
39
+ context: any,
40
+ writeMetadata: WriteExecutionMetadataStore,
41
+ /** Plain snapshot or live getter — getter lets /ccstyle panel changes repaint existing diffs. */
42
+ displayConfig: DisplayConfigInput = DEFAULT_TOOL_DISPLAY_CONFIG,
43
+ ): any | undefined {
44
+ if (options?.isPartial || options?.isError || context?.isError) return undefined;
45
+ const expanded = options?.expanded === true || context?.expanded === true;
46
+ const filePath = context?.args?.file_path ?? context?.args?.path;
47
+ if (toolName === "edit") {
48
+ return renderEditDiffResult(
49
+ result?.details,
50
+ {
51
+ expanded,
52
+ filePath,
53
+ isHovered: options?.isHovered,
54
+ invalidate: () => context?.invalidate?.(),
55
+ },
56
+ displayConfig,
57
+ theme,
58
+ resultText(result),
59
+ );
60
+ }
61
+ if (toolName !== "write") return undefined;
62
+
63
+ const metadata = writeMetadata.get(context?.toolCallId);
64
+ if (!metadata) {
65
+ return unavailableComponent("execution metadata is unavailable", theme);
66
+ }
67
+ if (metadata.diffUnavailableReason) {
68
+ return unavailableComponent(metadata.diffUnavailableReason, theme);
69
+ }
70
+ return renderWriteDiffResult(
71
+ typeof context?.args?.content === "string" ? context.args.content : undefined,
72
+ {
73
+ expanded,
74
+ filePath,
75
+ previousContent: metadata.previousContent,
76
+ fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
77
+ isHovered: options?.isHovered,
78
+ invalidate: () => context?.invalidate?.(),
79
+ },
80
+ displayConfig,
81
+ theme,
82
+ resultText(result),
83
+ );
84
+ }
85
+
86
+ function hasExternalWriteOwner(pi: ExtensionAPI): boolean {
87
+ try {
88
+ const tools = pi.getAllTools();
89
+ const write = tools.find((tool: any) => tool?.name === "write") as any;
90
+ const source = write?.sourceInfo?.source;
91
+ return Boolean(write && typeof source === "string" && source !== "builtin");
92
+ } catch {
93
+ // getAllTools is unavailable before the extension runtime is bound.
94
+ return false;
95
+ }
96
+ }
97
+
98
+ export function installWriteOverride(
99
+ pi: ExtensionAPI,
100
+ store = new WriteExecutionMetadataStore(),
101
+ ): WriteExecutionMetadataStore {
102
+ if (typeof (pi as any).registerTool !== "function" || hasExternalWriteOwner(pi)) return store;
103
+ const nativeWrite = createWriteToolDefinition(process.cwd()) as any;
104
+ pi.registerTool({
105
+ ...nativeWrite,
106
+ async execute(
107
+ toolCallId: string,
108
+ params: { path: string; content: string },
109
+ signal: AbortSignal | undefined,
110
+ _onUpdate: unknown,
111
+ ctx: { cwd: string },
112
+ ) {
113
+ return executeWriteWithMetadata(store, toolCallId, params, signal, ctx.cwd);
114
+ },
115
+ });
116
+ return store;
117
+ }
118
+
119
+ export {
120
+ DEFAULT_TOOL_DISPLAY_CONFIG,
121
+ type ToolDisplayConfig,
122
+ type DiffViewMode,
123
+ type DiffIndicatorMode,
124
+ } from "../diff-config.ts";
125
+ export type { DisplayConfigInput } from "./diff-renderer.ts";
126
+ export { WriteExecutionMetadataStore } from "./write-execution.ts";