@herbertgao/pi-extensions 2026.9.19 → 2026.9.20

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 (186) hide show
  1. package/README.md +1 -8
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +76 -59
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +76 -59
  4. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/preview.webp +0 -0
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +93 -34
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +346 -117
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary/core.ts +6 -36
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary/index.ts +2 -2
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +24 -29
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +330 -360
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/index.ts +56 -108
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/session.ts +97 -15
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/subagent.ts +80 -45
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/flush-docked-bash.ts +36 -0
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-layout.ts +229 -0
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +479 -0
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/startup-header.ts +92 -61
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +58 -38
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +19 -3
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +1080 -229
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +232 -259
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +38 -48
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +8 -2
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +2 -7
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +277 -113
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +15 -6
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/packets.ts +18 -72
  28. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/scroll.ts +61 -26
  29. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/ansi-utils.ts +25 -57
  30. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-component.ts +125 -0
  31. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +227 -0
  32. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-header.ts +136 -0
  33. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-highlight.ts +115 -0
  34. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-inline.ts +324 -0
  35. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-layout.ts +762 -0
  36. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-limits.ts +152 -0
  37. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-palette.ts +410 -0
  38. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-parse.ts +535 -0
  39. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +2 -2
  40. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +21 -3219
  41. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-text.ts +44 -0
  42. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +568 -0
  43. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/index.ts +19 -18
  44. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/line-width-safety.ts +2 -2
  45. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/write-execution.ts +1 -0
  46. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +142 -192
  47. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/mcp-title.ts +39 -0
  48. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/{message-display.ts → tool/message-display.ts} +24 -90
  49. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +426 -0
  50. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +90 -132
  51. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/{show-more-hint.ts → tool/show-more-hint.ts} +10 -0
  52. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/transcript-refresh.ts +8 -26
  53. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/ansi-color.ts +44 -0
  54. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/ansi-text.ts +76 -0
  55. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/component-tree.ts +34 -0
  56. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +28 -0
  57. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/fullscreen-detect.ts +51 -0
  58. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +124 -0
  59. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/sgr-mouse.ts +70 -0
  60. package/node_modules/@herbertgao/pi-cc-extensions/package.json +7 -7
  61. package/package.json +3 -7
  62. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/hero.en.svg +0 -101
  63. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/hero.svg +0 -101
  64. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/welcome.webp +0 -0
  65. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/render-utils.ts +0 -5
  66. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +0 -30
  67. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/write-display-utils.ts +0 -12
  68. package/node_modules/pi-footer/CHANGELOG.md +0 -145
  69. package/node_modules/pi-footer/LICENSE +0 -21
  70. package/node_modules/pi-footer/README.md +0 -354
  71. package/node_modules/pi-footer/package.json +0 -68
  72. package/node_modules/pi-footer/src/cache.ts +0 -106
  73. package/node_modules/pi-footer/src/colors.ts +0 -237
  74. package/node_modules/pi-footer/src/config.ts +0 -202
  75. package/node_modules/pi-footer/src/event-widgets.ts +0 -41
  76. package/node_modules/pi-footer/src/extension-statuses.ts +0 -102
  77. package/node_modules/pi-footer/src/git.ts +0 -135
  78. package/node_modules/pi-footer/src/index.ts +0 -268
  79. package/node_modules/pi-footer/src/metrics.ts +0 -141
  80. package/node_modules/pi-footer/src/presets.ts +0 -383
  81. package/node_modules/pi-footer/src/render.ts +0 -114
  82. package/node_modules/pi-footer/src/separators.ts +0 -52
  83. package/node_modules/pi-footer/src/types.ts +0 -122
  84. package/node_modules/pi-footer/src/ui/color-level-confirm.ts +0 -22
  85. package/node_modules/pi-footer/src/ui/color-options.ts +0 -195
  86. package/node_modules/pi-footer/src/ui/config-lifecycle.ts +0 -61
  87. package/node_modules/pi-footer/src/ui/edit-colors.ts +0 -13
  88. package/node_modules/pi-footer/src/ui/events.ts +0 -35
  89. package/node_modules/pi-footer/src/ui/extension-status-picker.ts +0 -60
  90. package/node_modules/pi-footer/src/ui/extension-statuses.ts +0 -73
  91. package/node_modules/pi-footer/src/ui/fields.ts +0 -252
  92. package/node_modules/pi-footer/src/ui/global-menu.ts +0 -126
  93. package/node_modules/pi-footer/src/ui/helpers.ts +0 -44
  94. package/node_modules/pi-footer/src/ui/layout.ts +0 -17
  95. package/node_modules/pi-footer/src/ui/line-list.ts +0 -19
  96. package/node_modules/pi-footer/src/ui/model.ts +0 -64
  97. package/node_modules/pi-footer/src/ui/navigation.ts +0 -29
  98. package/node_modules/pi-footer/src/ui/option-edit.ts +0 -93
  99. package/node_modules/pi-footer/src/ui/overlay-render.ts +0 -103
  100. package/node_modules/pi-footer/src/ui/screen-context.ts +0 -18
  101. package/node_modules/pi-footer/src/ui/screen-controller.ts +0 -27
  102. package/node_modules/pi-footer/src/ui/screen-render.ts +0 -32
  103. package/node_modules/pi-footer/src/ui/screen-state.ts +0 -20
  104. package/node_modules/pi-footer/src/ui/screen.ts +0 -243
  105. package/node_modules/pi-footer/src/ui/screens/add-widget.ts +0 -92
  106. package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +0 -52
  107. package/node_modules/pi-footer/src/ui/screens/controller.ts +0 -12
  108. package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +0 -68
  109. package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +0 -115
  110. package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +0 -61
  111. package/node_modules/pi-footer/src/ui/screens/global.ts +0 -75
  112. package/node_modules/pi-footer/src/ui/screens/line-list.ts +0 -108
  113. package/node_modules/pi-footer/src/ui/screens/main.ts +0 -99
  114. package/node_modules/pi-footer/src/ui/screens/terminal.ts +0 -121
  115. package/node_modules/pi-footer/src/ui/screens/widget-list.ts +0 -154
  116. package/node_modules/pi-footer/src/ui/terminal-menu.ts +0 -32
  117. package/node_modules/pi-footer/src/ui/theme.ts +0 -48
  118. package/node_modules/pi-footer/src/ui/title-bar.ts +0 -100
  119. package/node_modules/pi-footer/src/ui/widget-actions.ts +0 -76
  120. package/node_modules/pi-footer/src/ui.ts +0 -66
  121. package/node_modules/pi-footer/src/widgets/context.ts +0 -28
  122. package/node_modules/pi-footer/src/widgets/core/active-tools.ts +0 -17
  123. package/node_modules/pi-footer/src/widgets/core/context-window.ts +0 -24
  124. package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +0 -19
  125. package/node_modules/pi-footer/src/widgets/core/cwd.ts +0 -108
  126. package/node_modules/pi-footer/src/widgets/core/event.ts +0 -34
  127. package/node_modules/pi-footer/src/widgets/core/external-status.ts +0 -102
  128. package/node_modules/pi-footer/src/widgets/core/model-provider.ts +0 -20
  129. package/node_modules/pi-footer/src/widgets/core/model.ts +0 -35
  130. package/node_modules/pi-footer/src/widgets/core/provider.ts +0 -17
  131. package/node_modules/pi-footer/src/widgets/core/session-name.ts +0 -17
  132. package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +0 -17
  133. package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +0 -17
  134. package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +0 -17
  135. package/node_modules/pi-footer/src/widgets/git/branch.ts +0 -81
  136. package/node_modules/pi-footer/src/widgets/git/clean.ts +0 -19
  137. package/node_modules/pi-footer/src/widgets/git/deletions.ts +0 -17
  138. package/node_modules/pi-footer/src/widgets/git/diff.ts +0 -38
  139. package/node_modules/pi-footer/src/widgets/git/insertions.ts +0 -17
  140. package/node_modules/pi-footer/src/widgets/git/remote.ts +0 -17
  141. package/node_modules/pi-footer/src/widgets/git/root.ts +0 -17
  142. package/node_modules/pi-footer/src/widgets/git/sha.ts +0 -17
  143. package/node_modules/pi-footer/src/widgets/git/staged.ts +0 -17
  144. package/node_modules/pi-footer/src/widgets/git/status.ts +0 -19
  145. package/node_modules/pi-footer/src/widgets/git/unstaged.ts +0 -17
  146. package/node_modules/pi-footer/src/widgets/git/untracked.ts +0 -17
  147. package/node_modules/pi-footer/src/widgets/instance.ts +0 -118
  148. package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +0 -18
  149. package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +0 -32
  150. package/node_modules/pi-footer/src/widgets/layout/separator.ts +0 -57
  151. package/node_modules/pi-footer/src/widgets/layout/spacer.ts +0 -31
  152. package/node_modules/pi-footer/src/widgets/options.ts +0 -122
  153. package/node_modules/pi-footer/src/widgets/project/runtime.ts +0 -361
  154. package/node_modules/pi-footer/src/widgets/registry.ts +0 -223
  155. package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +0 -17
  156. package/node_modules/pi-footer/src/widgets/session/compactions.ts +0 -17
  157. package/node_modules/pi-footer/src/widgets/session/elapsed.ts +0 -18
  158. package/node_modules/pi-footer/src/widgets/session/last-activity.ts +0 -18
  159. package/node_modules/pi-footer/src/widgets/session/messages.ts +0 -19
  160. package/node_modules/pi-footer/src/widgets/session/session-id.ts +0 -17
  161. package/node_modules/pi-footer/src/widgets/session/session-start.ts +0 -18
  162. package/node_modules/pi-footer/src/widgets/session/tool-results.ts +0 -17
  163. package/node_modules/pi-footer/src/widgets/session/total-messages.ts +0 -19
  164. package/node_modules/pi-footer/src/widgets/session/total-time.ts +0 -18
  165. package/node_modules/pi-footer/src/widgets/session/user-messages.ts +0 -17
  166. package/node_modules/pi-footer/src/widgets/store.ts +0 -26
  167. package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +0 -55
  168. package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +0 -18
  169. package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +0 -18
  170. package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +0 -108
  171. package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +0 -26
  172. package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +0 -29
  173. package/node_modules/pi-footer/src/widgets/tokens/context.ts +0 -29
  174. package/node_modules/pi-footer/src/widgets/tokens/cost.ts +0 -51
  175. package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +0 -25
  176. package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +0 -18
  177. package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +0 -25
  178. package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +0 -18
  179. package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +0 -20
  180. package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +0 -25
  181. package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +0 -18
  182. package/node_modules/pi-footer/src/widgets/types.ts +0 -181
  183. package/node_modules/pi-footer/src/widgets/utils/colors.ts +0 -11
  184. package/node_modules/pi-footer/src/widgets/utils/context.ts +0 -112
  185. package/node_modules/pi-footer/src/widgets/utils/session.ts +0 -19
  186. package/node_modules/pi-footer/src/widgets/utils/token-format.ts +0 -78
@@ -0,0 +1,227 @@
1
+ import { Text, type Component } from "@earendil-works/pi-tui";
2
+ import type { EditToolDetails } from "@earendil-works/pi-coding-agent";
3
+ import { sanitizeToolResultText } from "../../../utils/tool-result-sanitize.ts";
4
+ import {
5
+ getLineNumberWidth,
6
+ parseDiff,
7
+ type ParsedDiff,
8
+ type ParsedDiffEntry,
9
+ } from "./diff-parse.ts";
10
+ import { buildInlineHighlightMap, buildSplitRows } from "./diff-inline.ts";
11
+ import { resolveDiffPalette, type DiffTheme } from "./diff-palette.ts";
12
+ import { createCodeLineHighlighter, resolveLanguageFromPath } from "./diff-highlight.ts";
13
+ import {
14
+ canRenderSplitLayout,
15
+ renderCompact,
16
+ renderSplit,
17
+ renderUnified,
18
+ type DiffRenderContext,
19
+ } from "./diff-layout.ts";
20
+ import { renderDiffFrameLine, renderHeaderRows, renderSingleDiffRow } from "./diff-header.ts";
21
+ import {
22
+ applyLineLimit,
23
+ clampDiffLinesToWidth,
24
+ resolveDiffDisplayLimit,
25
+ resolveDiffProcessBudget,
26
+ takeEntriesForLineBudget,
27
+ takeSplitRowsForBudget,
28
+ } from "./diff-limits.ts";
29
+ import {
30
+ RICH_DIFF_COMPONENT,
31
+ createDiffRenderCache,
32
+ displayConfigCacheKey,
33
+ resolveDiffIndicatorMode,
34
+ resolveLiveDisplayConfig,
35
+ normalizeCollapsedHintLine,
36
+ type DiffRenderOptions,
37
+ type DisplayConfigInput,
38
+ } from "./diff-component.ts";
39
+ import {
40
+ buildDiffSummaryText,
41
+ normalizeDiffRenderWidth,
42
+ resolveDiffPresentationMode,
43
+ } from "./diff-presentation.ts";
44
+
45
+ function safeGetDiff(details: unknown): string {
46
+ if (!details || typeof details !== "object") {
47
+ return "";
48
+ }
49
+ const typed = details as Partial<EditToolDetails>;
50
+ return typeof typed.diff === "string" ? typed.diff : "";
51
+ }
52
+
53
+ // Pi appends an omission marker after final context to say the file continues.
54
+ // It does not separate visible diff regions, so keep only omissions before a rendered line.
55
+ function omitTerminalOmissions(entries: ParsedDiffEntry[]): ParsedDiffEntry[] {
56
+ let lastLineIndex = -1;
57
+ for (let index = entries.length - 1; index >= 0; index--) {
58
+ if (entries[index]?.kind === "line") {
59
+ lastLineIndex = index;
60
+ break;
61
+ }
62
+ }
63
+
64
+ return entries.filter((entry, index) => entry.kind !== "omission" || index < lastLineIndex);
65
+ }
66
+
67
+ /**
68
+ * edit 变更行统计(compact 单行 `(+A -D)` 用):复用现有 parser,diff 文件头不计入。
69
+ * 无 diff 或解析失败时返回 undefined,不把未知状态误报为零变更。
70
+ */
71
+ export function countEditDiffStats(
72
+ details: unknown,
73
+ ): { added: number; removed: number } | undefined {
74
+ const diffText = sanitizeToolResultText(safeGetDiff(details));
75
+ if (!diffText.trim()) return undefined;
76
+ try {
77
+ const parsed = parseDiff(diffText);
78
+ return { added: parsed.stats.added, removed: parsed.stats.removed };
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ export function renderEditDiffResult(
85
+ details: unknown,
86
+ options: DiffRenderOptions,
87
+ config: DisplayConfigInput,
88
+ theme: DiffTheme,
89
+ fallbackText: string,
90
+ ): Component {
91
+ const diffText = sanitizeToolResultText(safeGetDiff(details));
92
+ const safeFallbackText = sanitizeToolResultText(fallbackText);
93
+ if (!diffText.trim()) {
94
+ if (!safeFallbackText.trim()) {
95
+ return new Text(theme.fg("muted", "↳ edit completed (no diff payload)"), 0, 0);
96
+ }
97
+ return new Text(theme.fg("toolOutput", safeFallbackText), 0, 0);
98
+ }
99
+
100
+ let parsed: ParsedDiff;
101
+ try {
102
+ parsed = parseDiff(diffText);
103
+ } catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ return new Text(theme.fg("warning", `↳ unable to render diff: ${message}`), 0, 0);
106
+ }
107
+
108
+ const renderEntries = omitTerminalOmissions(parsed.entries);
109
+ if (renderEntries.length === 0) {
110
+ return new Text(theme.fg("muted", "↳ no diff data"), 0, 0);
111
+ }
112
+
113
+ const splitRows = buildSplitRows(renderEntries);
114
+ const showHashlineAnchors =
115
+ options.expanded === true &&
116
+ renderEntries.some((entry) => entry.kind === "line" && !!entry.hashlineAnchorContent);
117
+ const lineNumberWidth = getLineNumberWidth(renderEntries, showHashlineAnchors);
118
+ const palette = resolveDiffPalette(theme);
119
+ // Rich diffs use ccstyle's self shell. Keep the panel transparent so the
120
+ // separator cannot leak toolSuccessBg across the entire new column.
121
+ const containerBgAnsi = undefined;
122
+ const language = resolveLanguageFromPath(options.filePath);
123
+ const cache = createDiffRenderCache();
124
+ const highlightLine = createCodeLineHighlighter(language, theme, renderEntries, () => {
125
+ cache.invalidate();
126
+ options.invalidate?.();
127
+ });
128
+
129
+ return {
130
+ [RICH_DIFF_COMPONENT]: true,
131
+ /** 鼠标层只在真正的 remainder 行上提供展开入口。 */
132
+ isCollapsedHintLine(plainLine: string): boolean {
133
+ const hint = cache.getHintLine();
134
+ return hint !== undefined && normalizeCollapsedHintLine(plainLine) === hint;
135
+ },
136
+ render(width: number): string[] {
137
+ // Live config: panel can change indicator/wrap/limits after this component is created.
138
+ const live = resolveLiveDisplayConfig(config);
139
+ const wordWrap = live.diffWordWrap;
140
+ const indicatorMode = resolveDiffIndicatorMode(live);
141
+ const configKey = displayConfigCacheKey(live);
142
+ const safeWidth = normalizeDiffRenderWidth(width);
143
+ const mode = resolveDiffPresentationMode(live, safeWidth, canRenderSplitLayout(safeWidth));
144
+ const hovered = options.isHovered?.() ?? false;
145
+ const cached = cache.get(safeWidth, options.expanded, mode, configKey, hovered);
146
+ if (cached) {
147
+ return cached;
148
+ }
149
+
150
+ if (mode === "summary") {
151
+ return cache.set(
152
+ safeWidth,
153
+ options.expanded,
154
+ mode,
155
+ configKey,
156
+ hovered,
157
+ clampDiffLinesToWidth(
158
+ renderSingleDiffRow(
159
+ buildDiffSummaryText(parsed.stats, safeWidth),
160
+ "toolOutput",
161
+ safeWidth,
162
+ theme,
163
+ ),
164
+ safeWidth,
165
+ ),
166
+ );
167
+ }
168
+
169
+ const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
170
+ const displayLimit = resolveDiffDisplayLimit(options.expanded, live.editDiffCollapsedLines);
171
+ const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
172
+ // Only highlight/render a prefix that can fill the display limit; full-diff
173
+ // LCS + syntax highlight on thousands of hidden lines is pure waste when collapsed.
174
+ const entryBudget = takeEntriesForLineBudget(renderEntries, processBudget);
175
+ const splitBudget = takeSplitRowsForBudget(splitRows, processBudget);
176
+ const inlineHighlights = buildInlineHighlightMap(splitBudget.rows);
177
+ const renderCtx: DiffRenderContext = {
178
+ width: safeWidth,
179
+ theme,
180
+ inlineHighlights,
181
+ palette,
182
+ highlightLine,
183
+ containerBgAnsi,
184
+ wordWrap,
185
+ indicatorMode,
186
+ showHashlineAnchors,
187
+ };
188
+ const bodyRows =
189
+ mode === "split"
190
+ ? renderSplit(splitBudget.rows, renderCtx, lineNumberWidth)
191
+ : mode === "compact"
192
+ ? renderCompact(entryBudget.entries, renderCtx)
193
+ : renderUnified(entryBudget.entries, renderCtx, lineNumberWidth);
194
+ const unprocessedLogicalRows =
195
+ mode === "split"
196
+ ? Math.max(0, splitBudget.totalRows - splitBudget.processedRows)
197
+ : Math.max(0, entryBudget.totalLineEntries - entryBudget.processedLineEntries);
198
+ const bodyWithLimit = applyLineLimit(
199
+ bodyRows,
200
+ safeWidth,
201
+ options.expanded,
202
+ live.editDiffCollapsedLines,
203
+ parsed.stats.hunks,
204
+ theme,
205
+ unprocessedLogicalRows,
206
+ hovered,
207
+ );
208
+ const frame = renderDiffFrameLine(safeWidth, theme);
209
+ const renderedLines =
210
+ mode === "unified"
211
+ ? [...headerRows.map((row) => row.text), frame, ...bodyWithLimit.lines, frame]
212
+ : [...headerRows.map((row) => row.text), ...bodyWithLimit.lines];
213
+
214
+ const clampedLines = clampDiffLinesToWidth(renderedLines, safeWidth);
215
+ return cache.set(
216
+ safeWidth,
217
+ options.expanded,
218
+ mode,
219
+ configKey,
220
+ hovered,
221
+ clampedLines,
222
+ normalizeCollapsedHintLine(bodyWithLimit.hintLine),
223
+ );
224
+ },
225
+ invalidate: cache.invalidate,
226
+ } as Component;
227
+ }
@@ -0,0 +1,136 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { sanitizeAnsiForThemedOutput } from "./ansi-utils.ts";
3
+ import { normalizeCodeWhitespace, wrapToWidth } from "./diff-text.ts";
4
+ import { emphasis, stabilizeBackgroundResets, type DiffTheme } from "./diff-palette.ts";
5
+ import { clampDiffLineToWidth } from "./diff-limits.ts";
6
+ import type { DiffMetaEntry, DiffStats } from "./diff-parse.ts";
7
+ import type { DiffPresentationMode } from "./diff-presentation.ts";
8
+ import type { RenderedRow } from "./diff-layout.ts";
9
+
10
+ export function formatMetaEntryRows(
11
+ entry: DiffMetaEntry,
12
+ width: number,
13
+ theme: DiffTheme,
14
+ wordWrap: boolean,
15
+ ): RenderedRow[] {
16
+ const normalized = sanitizeAnsiForThemedOutput(normalizeCodeWhitespace(entry.raw));
17
+ const lines = wordWrap
18
+ ? wrapToWidth(normalized, width, true)
19
+ : [truncateToWidth(normalized, width)];
20
+
21
+ const mapColor = (line: string): string => {
22
+ if (entry.kind === "hunk") {
23
+ return stabilizeBackgroundResets(theme.fg("accent", line));
24
+ }
25
+ if (entry.kind === "file") {
26
+ return stabilizeBackgroundResets(theme.fg("muted", line));
27
+ }
28
+ return stabilizeBackgroundResets(theme.fg("toolDiffContext", line));
29
+ };
30
+
31
+ return lines.map((line) => ({
32
+ text: mapColor(line),
33
+ hunkIndex: entry.kind === "file" ? null : entry.hunkIndex || null,
34
+ }));
35
+ }
36
+
37
+ function renderDiffStatBar(stats: DiffStats, width: number, theme: DiffTheme): string | null {
38
+ const totalChanges = stats.added + stats.removed;
39
+ if (totalChanges === 0 || width < 20) {
40
+ return null;
41
+ }
42
+
43
+ const barSlots = Math.max(8, Math.min(24, Math.floor(width / 12)));
44
+ let addedSlots = Math.max(
45
+ 0,
46
+ Math.min(barSlots, Math.round((stats.added / totalChanges) * barSlots)),
47
+ );
48
+ if (stats.added > 0 && addedSlots === 0) {
49
+ addedSlots = 1;
50
+ }
51
+ if (stats.removed > 0 && addedSlots >= barSlots) {
52
+ addedSlots = barSlots - 1;
53
+ }
54
+ const removedSlots = Math.max(0, barSlots - addedSlots);
55
+
56
+ const addedBar = addedSlots > 0 ? theme.fg("toolDiffAdded", "━".repeat(addedSlots)) : "";
57
+ const removedBar = removedSlots > 0 ? theme.fg("toolDiffRemoved", "━".repeat(removedSlots)) : "";
58
+ return stabilizeBackgroundResets(
59
+ `${theme.fg("dim", "[")}${addedBar}${removedBar}${theme.fg("dim", "]")}`,
60
+ );
61
+ }
62
+
63
+ function buildDiffSummaryBasePieces(stats: DiffStats, theme: DiffTheme): string[] {
64
+ return [
65
+ theme.fg("toolOutput", `↳ ${emphasis(theme, "diff")}`),
66
+ theme.fg("toolDiffAdded", `+${stats.added}`),
67
+ theme.fg("toolDiffRemoved", `-${stats.removed}`),
68
+ ];
69
+ }
70
+
71
+ export function renderHeaderRows(
72
+ stats: DiffStats,
73
+ mode: Exclude<DiffPresentationMode, "summary">,
74
+ width: number,
75
+ theme: DiffTheme,
76
+ ): RenderedRow[] {
77
+ if (mode === "compact") {
78
+ const summary = buildDiffSummaryBasePieces(stats, theme).join(" ");
79
+ return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
80
+ }
81
+
82
+ const summaryPieces = [...buildDiffSummaryBasePieces(stats, theme), theme.fg("muted", mode)];
83
+
84
+ const summary = summaryPieces.join(mode === "split" ? " " : theme.fg("muted", " • "));
85
+ const meter = renderDiffStatBar(stats, width, theme);
86
+ if (!meter) {
87
+ return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
88
+ }
89
+
90
+ const meterSeparator = " ";
91
+ const meterWidth = visibleWidth(meterSeparator) + visibleWidth(meter);
92
+ if (meterWidth >= width) {
93
+ return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
94
+ }
95
+
96
+ const summaryWidth = Math.max(0, width - meterWidth);
97
+ const fittedSummary = truncateToWidth(summary, summaryWidth);
98
+ return [
99
+ {
100
+ text: stabilizeBackgroundResets(`${fittedSummary}${meterSeparator}${meter}`),
101
+ hunkIndex: null,
102
+ },
103
+ ];
104
+ }
105
+
106
+ export function renderDiffFrameLine(width: number, theme: DiffTheme): string {
107
+ const frameWidth = Math.max(0, width);
108
+ if (frameWidth === 0) {
109
+ return "";
110
+ }
111
+ return stabilizeBackgroundResets(theme.fg("dim", "─".repeat(frameWidth)));
112
+ }
113
+
114
+ export function renderSingleDiffRow(
115
+ text: string,
116
+ color: string,
117
+ width: number,
118
+ theme: DiffTheme,
119
+ ): string[] {
120
+ if (width <= 0) {
121
+ return [""];
122
+ }
123
+ return [clampDiffLineToWidth(stabilizeBackgroundResets(theme.fg(color, text)), width)];
124
+ }
125
+
126
+ export function renderWriteHeader(
127
+ wasOverwrite: boolean,
128
+ width: number,
129
+ theme: DiffTheme,
130
+ headerLabel?: string,
131
+ ): string {
132
+ const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
133
+ return stabilizeBackgroundResets(
134
+ truncateToWidth(theme.fg("toolOutput", `↳ ${emphasis(theme, actionLabel)}`), width),
135
+ );
136
+ }
@@ -0,0 +1,115 @@
1
+ import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
2
+ import { sanitizeAnsiForThemedOutput } from "./ansi-utils.ts";
3
+ import { sanitizeToolResultText } from "../../../utils/tool-result-sanitize.ts";
4
+ import { MAX_HL_CHARS, shikiHighlightCache } from "./shiki-highlight.ts";
5
+ import { normalizeCodeWhitespace } from "./diff-text.ts";
6
+ import {
7
+ applyInlineSpanHighlight,
8
+ getLineBackground,
9
+ resolveShikiTheme,
10
+ type DiffPalette,
11
+ type DiffTheme,
12
+ } from "./diff-palette.ts";
13
+ import type { DiffLineEntry, ParsedDiffEntry } from "./diff-parse.ts";
14
+ import type { DiffSpan } from "./diff-inline.ts";
15
+
16
+ export type CodeLineHighlighter = (line: string, entry: DiffLineEntry) => string;
17
+
18
+ function cleanCodeLine(line: string): string {
19
+ return sanitizeToolResultText(line).replace(/\n/g, "");
20
+ }
21
+
22
+ export function shouldHighlightCodeBlock(code: string): boolean {
23
+ return code.length <= MAX_HL_CHARS;
24
+ }
25
+
26
+ export function resolveLanguageFromPath(rawPath: string | undefined): string | undefined {
27
+ if (!rawPath || !rawPath.trim()) {
28
+ return undefined;
29
+ }
30
+ const normalizedPath = rawPath.replace(/^@/, "").trim();
31
+ if (!normalizedPath) {
32
+ return undefined;
33
+ }
34
+ try {
35
+ return getLanguageFromPath(normalizedPath);
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ export function createCodeLineHighlighter(
42
+ language: string | undefined,
43
+ theme: DiffTheme,
44
+ entries: readonly ParsedDiffEntry[],
45
+ invalidate?: () => void,
46
+ ): CodeLineHighlighter {
47
+ const codeEntries = entries.filter((entry): entry is DiffLineEntry => entry.kind === "line");
48
+ const cleanLines = codeEntries.map((entry) =>
49
+ cleanCodeLine(normalizeCodeWhitespace(entry.content)),
50
+ );
51
+ const code = cleanLines.join("\n");
52
+ const shouldHighlight = !!language && shouldHighlightCodeBlock(code);
53
+ const fallbackLines = shouldHighlight
54
+ ? (() => {
55
+ try {
56
+ return highlightCode(code, language).map(sanitizeAnsiForThemedOutput);
57
+ } catch {
58
+ return cleanLines.map(sanitizeAnsiForThemedOutput);
59
+ }
60
+ })()
61
+ : cleanLines.map(sanitizeAnsiForThemedOutput);
62
+ const fallbackByEntry = new WeakMap(
63
+ codeEntries.map((entry, index) => [entry, fallbackLines[index] ?? cleanLines[index] ?? ""]),
64
+ );
65
+ if (!shouldHighlight) return (_line, entry) => fallbackByEntry.get(entry) ?? "";
66
+
67
+ const shikiTheme = resolveShikiTheme(theme);
68
+ let highlightedByEntry: WeakMap<DiffLineEntry, string> | undefined;
69
+ const resolveHighlighted = () => {
70
+ if (highlightedByEntry) return;
71
+ const highlighted = shikiHighlightCache.get(
72
+ code,
73
+ language,
74
+ shikiTheme,
75
+ fallbackLines,
76
+ invalidate,
77
+ );
78
+ if (highlighted) {
79
+ highlightedByEntry = new WeakMap(
80
+ codeEntries.map((entry, index) => [
81
+ entry,
82
+ sanitizeAnsiForThemedOutput(highlighted[index] ?? fallbackByEntry.get(entry) ?? ""),
83
+ ]),
84
+ );
85
+ }
86
+ };
87
+ resolveHighlighted();
88
+ return (_line, entry) => {
89
+ resolveHighlighted();
90
+ return highlightedByEntry?.get(entry) ?? fallbackByEntry.get(entry) ?? "";
91
+ };
92
+ }
93
+
94
+ export function highlightDiffLine(
95
+ codeText: string,
96
+ entry: DiffLineEntry,
97
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
98
+ palette: DiffPalette,
99
+ highlightLine: CodeLineHighlighter,
100
+ containerBgAnsi: string | undefined,
101
+ ): { highlighted: string; rowBg: string | undefined } {
102
+ const syntaxHighlighted = highlightLine(codeText, entry);
103
+ const rowBg = getLineBackground(entry.lineKind, palette, false);
104
+ const emphasisBg = getLineBackground(entry.lineKind, palette, true);
105
+ const inlineSpans = inlineHighlights.get(entry) ?? [];
106
+ const highlighted = applyInlineSpanHighlight(
107
+ codeText,
108
+ syntaxHighlighted,
109
+ inlineSpans,
110
+ emphasisBg,
111
+ rowBg,
112
+ containerBgAnsi,
113
+ );
114
+ return { highlighted, rowBg };
115
+ }