@herbertgao/pi-extensions 2026.9.18 → 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 (226) hide show
  1. package/README.md +20 -28
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +76 -59
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.md +76 -59
  5. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/preview.webp +0 -0
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +93 -34
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +346 -117
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary/core.ts +6 -36
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary/index.ts +2 -2
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +24 -29
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +330 -360
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/index.ts +56 -108
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/session.ts +97 -15
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/subagent.ts +80 -45
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/flush-docked-bash.ts +36 -0
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-layout.ts +229 -0
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +479 -0
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/startup-header.ts +92 -61
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +75 -40
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +19 -3
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +1080 -229
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +232 -259
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +42 -41
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +8 -2
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +2 -7
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +277 -113
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +15 -6
  28. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/packets.ts +18 -72
  29. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/scroll.ts +61 -26
  30. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/ansi-utils.ts +25 -57
  31. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-component.ts +125 -0
  32. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +227 -0
  33. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-header.ts +136 -0
  34. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-highlight.ts +115 -0
  35. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-inline.ts +324 -0
  36. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-layout.ts +762 -0
  37. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-limits.ts +152 -0
  38. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-palette.ts +410 -0
  39. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-parse.ts +535 -0
  40. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +2 -2
  41. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +21 -3219
  42. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-text.ts +44 -0
  43. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +568 -0
  44. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/index.ts +47 -10
  45. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/line-width-safety.ts +2 -2
  46. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/write-execution.ts +1 -0
  47. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +145 -190
  48. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/mcp-title.ts +39 -0
  49. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/{message-display.ts → tool/message-display.ts} +24 -90
  50. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +426 -0
  51. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +90 -132
  52. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/{show-more-hint.ts → tool/show-more-hint.ts} +10 -0
  53. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/transcript-refresh.ts +8 -26
  54. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/ansi-color.ts +44 -0
  55. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/ansi-text.ts +76 -0
  56. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/component-tree.ts +34 -0
  57. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +28 -0
  58. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/fullscreen-detect.ts +51 -0
  59. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +124 -0
  60. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/sgr-mouse.ts +70 -0
  61. package/node_modules/@herbertgao/pi-cc-extensions/package.json +8 -8
  62. package/node_modules/pi-antigravity/README.md +29 -6
  63. package/node_modules/pi-antigravity/package.json +12 -7
  64. package/node_modules/pi-antigravity/src/auth/accounts.ts +327 -0
  65. package/node_modules/pi-antigravity/src/auth/index.ts +9 -0
  66. package/node_modules/pi-antigravity/src/client/client.ts +1 -1
  67. package/node_modules/pi-antigravity/src/image/image.ts +4 -4
  68. package/node_modules/pi-antigravity/src/index.ts +203 -13
  69. package/node_modules/pi-antigravity/src/search/index.ts +1 -0
  70. package/node_modules/pi-antigravity/src/search/search.ts +325 -0
  71. package/node_modules/pi-antigravity/src/stream/stream.ts +191 -14
  72. package/node_modules/pi-antigravity/src/types/enums.ts +45 -36
  73. package/node_modules/pi-antigravity/src/types/types.ts +5 -12
  74. package/node_modules/pi-antigravity/src/utils/http.ts +7 -2
  75. package/node_modules/pi-antigravity/src/utils/util.ts +31 -3
  76. package/node_modules/pi-typesafe/README.md +2 -0
  77. package/node_modules/pi-typesafe/package.json +2 -1
  78. package/node_modules/pi-web-access/CHANGELOG.md +72 -0
  79. package/node_modules/pi-web-access/README.md +34 -13
  80. package/node_modules/pi-web-access/chrome-cookies.ts +79 -25
  81. package/node_modules/pi-web-access/curator-page.ts +5 -1
  82. package/node_modules/pi-web-access/curator-server.ts +2 -0
  83. package/node_modules/pi-web-access/dist/index.js +1337 -1009
  84. package/node_modules/pi-web-access/exa.ts +20 -52
  85. package/node_modules/pi-web-access/extract.ts +41 -4
  86. package/node_modules/pi-web-access/gemini-search.ts +12 -4
  87. package/node_modules/pi-web-access/index.ts +29 -37
  88. package/node_modules/pi-web-access/openai-search.ts +35 -7
  89. package/node_modules/pi-web-access/package.json +6 -5
  90. package/node_modules/pi-web-access/page-query.ts +2 -2
  91. package/node_modules/pi-web-access/query-rewrite.ts +2 -3
  92. package/node_modules/pi-web-access/ssrf-protection.ts +27 -10
  93. package/node_modules/pi-web-access/storage.ts +48 -77
  94. package/node_modules/pi-web-access/summary-model-scope.ts +6 -77
  95. package/node_modules/pi-web-access/summary-review.ts +3 -4
  96. package/node_modules/pi-web-access/tavily.ts +67 -20
  97. package/node_modules/pi-web-access/tool-activation.ts +53 -33
  98. package/node_modules/pi-web-access/utils.ts +1 -1
  99. package/node_modules/pi-web-access/you.ts +164 -0
  100. package/node_modules/pi-web-access/zai.ts +161 -0
  101. package/package.json +7 -11
  102. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/hero.en.svg +0 -101
  103. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/hero.svg +0 -101
  104. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/welcome.webp +0 -0
  105. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/render-utils.ts +0 -5
  106. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +0 -30
  107. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/write-display-utils.ts +0 -12
  108. package/node_modules/pi-footer/CHANGELOG.md +0 -145
  109. package/node_modules/pi-footer/LICENSE +0 -21
  110. package/node_modules/pi-footer/README.md +0 -354
  111. package/node_modules/pi-footer/package.json +0 -68
  112. package/node_modules/pi-footer/src/cache.ts +0 -106
  113. package/node_modules/pi-footer/src/colors.ts +0 -237
  114. package/node_modules/pi-footer/src/config.ts +0 -202
  115. package/node_modules/pi-footer/src/event-widgets.ts +0 -41
  116. package/node_modules/pi-footer/src/extension-statuses.ts +0 -102
  117. package/node_modules/pi-footer/src/git.ts +0 -135
  118. package/node_modules/pi-footer/src/index.ts +0 -268
  119. package/node_modules/pi-footer/src/metrics.ts +0 -141
  120. package/node_modules/pi-footer/src/presets.ts +0 -383
  121. package/node_modules/pi-footer/src/render.ts +0 -114
  122. package/node_modules/pi-footer/src/separators.ts +0 -52
  123. package/node_modules/pi-footer/src/types.ts +0 -122
  124. package/node_modules/pi-footer/src/ui/color-level-confirm.ts +0 -22
  125. package/node_modules/pi-footer/src/ui/color-options.ts +0 -195
  126. package/node_modules/pi-footer/src/ui/config-lifecycle.ts +0 -61
  127. package/node_modules/pi-footer/src/ui/edit-colors.ts +0 -13
  128. package/node_modules/pi-footer/src/ui/events.ts +0 -35
  129. package/node_modules/pi-footer/src/ui/extension-status-picker.ts +0 -60
  130. package/node_modules/pi-footer/src/ui/extension-statuses.ts +0 -73
  131. package/node_modules/pi-footer/src/ui/fields.ts +0 -252
  132. package/node_modules/pi-footer/src/ui/global-menu.ts +0 -126
  133. package/node_modules/pi-footer/src/ui/helpers.ts +0 -44
  134. package/node_modules/pi-footer/src/ui/layout.ts +0 -17
  135. package/node_modules/pi-footer/src/ui/line-list.ts +0 -19
  136. package/node_modules/pi-footer/src/ui/model.ts +0 -64
  137. package/node_modules/pi-footer/src/ui/navigation.ts +0 -29
  138. package/node_modules/pi-footer/src/ui/option-edit.ts +0 -93
  139. package/node_modules/pi-footer/src/ui/overlay-render.ts +0 -103
  140. package/node_modules/pi-footer/src/ui/screen-context.ts +0 -18
  141. package/node_modules/pi-footer/src/ui/screen-controller.ts +0 -27
  142. package/node_modules/pi-footer/src/ui/screen-render.ts +0 -32
  143. package/node_modules/pi-footer/src/ui/screen-state.ts +0 -20
  144. package/node_modules/pi-footer/src/ui/screen.ts +0 -243
  145. package/node_modules/pi-footer/src/ui/screens/add-widget.ts +0 -92
  146. package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +0 -52
  147. package/node_modules/pi-footer/src/ui/screens/controller.ts +0 -12
  148. package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +0 -68
  149. package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +0 -115
  150. package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +0 -61
  151. package/node_modules/pi-footer/src/ui/screens/global.ts +0 -75
  152. package/node_modules/pi-footer/src/ui/screens/line-list.ts +0 -108
  153. package/node_modules/pi-footer/src/ui/screens/main.ts +0 -99
  154. package/node_modules/pi-footer/src/ui/screens/terminal.ts +0 -121
  155. package/node_modules/pi-footer/src/ui/screens/widget-list.ts +0 -154
  156. package/node_modules/pi-footer/src/ui/terminal-menu.ts +0 -32
  157. package/node_modules/pi-footer/src/ui/theme.ts +0 -48
  158. package/node_modules/pi-footer/src/ui/title-bar.ts +0 -100
  159. package/node_modules/pi-footer/src/ui/widget-actions.ts +0 -76
  160. package/node_modules/pi-footer/src/ui.ts +0 -66
  161. package/node_modules/pi-footer/src/widgets/context.ts +0 -28
  162. package/node_modules/pi-footer/src/widgets/core/active-tools.ts +0 -17
  163. package/node_modules/pi-footer/src/widgets/core/context-window.ts +0 -24
  164. package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +0 -19
  165. package/node_modules/pi-footer/src/widgets/core/cwd.ts +0 -108
  166. package/node_modules/pi-footer/src/widgets/core/event.ts +0 -34
  167. package/node_modules/pi-footer/src/widgets/core/external-status.ts +0 -102
  168. package/node_modules/pi-footer/src/widgets/core/model-provider.ts +0 -20
  169. package/node_modules/pi-footer/src/widgets/core/model.ts +0 -35
  170. package/node_modules/pi-footer/src/widgets/core/provider.ts +0 -17
  171. package/node_modules/pi-footer/src/widgets/core/session-name.ts +0 -17
  172. package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +0 -17
  173. package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +0 -17
  174. package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +0 -17
  175. package/node_modules/pi-footer/src/widgets/git/branch.ts +0 -81
  176. package/node_modules/pi-footer/src/widgets/git/clean.ts +0 -19
  177. package/node_modules/pi-footer/src/widgets/git/deletions.ts +0 -17
  178. package/node_modules/pi-footer/src/widgets/git/diff.ts +0 -38
  179. package/node_modules/pi-footer/src/widgets/git/insertions.ts +0 -17
  180. package/node_modules/pi-footer/src/widgets/git/remote.ts +0 -17
  181. package/node_modules/pi-footer/src/widgets/git/root.ts +0 -17
  182. package/node_modules/pi-footer/src/widgets/git/sha.ts +0 -17
  183. package/node_modules/pi-footer/src/widgets/git/staged.ts +0 -17
  184. package/node_modules/pi-footer/src/widgets/git/status.ts +0 -19
  185. package/node_modules/pi-footer/src/widgets/git/unstaged.ts +0 -17
  186. package/node_modules/pi-footer/src/widgets/git/untracked.ts +0 -17
  187. package/node_modules/pi-footer/src/widgets/instance.ts +0 -118
  188. package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +0 -18
  189. package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +0 -32
  190. package/node_modules/pi-footer/src/widgets/layout/separator.ts +0 -57
  191. package/node_modules/pi-footer/src/widgets/layout/spacer.ts +0 -31
  192. package/node_modules/pi-footer/src/widgets/options.ts +0 -122
  193. package/node_modules/pi-footer/src/widgets/project/runtime.ts +0 -361
  194. package/node_modules/pi-footer/src/widgets/registry.ts +0 -223
  195. package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +0 -17
  196. package/node_modules/pi-footer/src/widgets/session/compactions.ts +0 -17
  197. package/node_modules/pi-footer/src/widgets/session/elapsed.ts +0 -18
  198. package/node_modules/pi-footer/src/widgets/session/last-activity.ts +0 -18
  199. package/node_modules/pi-footer/src/widgets/session/messages.ts +0 -19
  200. package/node_modules/pi-footer/src/widgets/session/session-id.ts +0 -17
  201. package/node_modules/pi-footer/src/widgets/session/session-start.ts +0 -18
  202. package/node_modules/pi-footer/src/widgets/session/tool-results.ts +0 -17
  203. package/node_modules/pi-footer/src/widgets/session/total-messages.ts +0 -19
  204. package/node_modules/pi-footer/src/widgets/session/total-time.ts +0 -18
  205. package/node_modules/pi-footer/src/widgets/session/user-messages.ts +0 -17
  206. package/node_modules/pi-footer/src/widgets/store.ts +0 -26
  207. package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +0 -55
  208. package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +0 -18
  209. package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +0 -18
  210. package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +0 -108
  211. package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +0 -26
  212. package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +0 -29
  213. package/node_modules/pi-footer/src/widgets/tokens/context.ts +0 -29
  214. package/node_modules/pi-footer/src/widgets/tokens/cost.ts +0 -51
  215. package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +0 -25
  216. package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +0 -18
  217. package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +0 -25
  218. package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +0 -18
  219. package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +0 -20
  220. package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +0 -25
  221. package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +0 -18
  222. package/node_modules/pi-footer/src/widgets/types.ts +0 -181
  223. package/node_modules/pi-footer/src/widgets/utils/colors.ts +0 -11
  224. package/node_modules/pi-footer/src/widgets/utils/context.ts +0 -112
  225. package/node_modules/pi-footer/src/widgets/utils/session.ts +0 -19
  226. package/node_modules/pi-footer/src/widgets/utils/token-format.ts +0 -78
@@ -1,3221 +1,23 @@
1
- import {
2
- Text,
3
- truncateToWidth,
4
- visibleWidth,
5
- wrapTextWithAnsi,
6
- type Component,
7
- } from "@earendil-works/pi-tui";
8
- import {
9
- getLanguageFromPath,
10
- highlightCode,
11
- type EditToolDetails,
12
- } from "@earendil-works/pi-coding-agent";
13
- import {
14
- ANSI_SGR_PATTERN,
15
- STYLE_RESET_PARAMS,
16
- filterSgrSequences,
17
- toSgrParams,
18
- readSgrColorSequence,
19
- isFiniteSgrParam,
20
- expandSgrReset,
21
- } from "./ansi-utils.ts";
22
- import {
23
- buildCollapsedDiffHintText,
24
- clampRenderedLineToWidth,
25
- clampRenderedLinesToWidth,
26
- } from "./line-width-safety.ts";
27
- import {
28
- buildDiffSummaryText,
29
- normalizeDiffRenderWidth,
30
- resolveDiffPresentationMode,
31
- type DiffPresentationMode,
32
- } from "./diff-presentation.ts";
33
- import { sanitizeToolResultText } from "../../../utils/tool-result-sanitize.ts";
34
- import { pluralize, sanitizeAnsiForThemedOutput } from "./render-utils.ts";
35
- import { splitWriteContentLines } from "./write-display-utils.ts";
36
- import { MAX_HL_CHARS, shikiHighlightCache } from "./shiki-highlight.ts";
37
- import { showMoreHintText } from "../../show-more-hint.ts";
38
- import {
39
- DEFAULT_TOOL_DISPLAY_CONFIG,
40
- type DiffIndicatorMode,
41
- type ToolDisplayConfig,
42
- } from "./types.ts";
43
-
44
- interface DiffTheme {
45
- fg(color: string, text: string): string;
46
- bg?(color: string, text: string): string;
47
- bold?(text: string): string;
48
- getFgAnsi?(color: string): string;
49
- getBgAnsi?(color: string): string;
50
- }
51
-
52
- type DiffLineKind = "add" | "remove" | "context";
53
- type DiffEntryKind = "line" | "omission" | "meta" | "hunk" | "file";
54
-
55
- export interface DiffLineEntry {
56
- kind: "line";
57
- lineKind: DiffLineKind;
58
- oldLineNumber: number | null;
59
- newLineNumber: number | null;
60
- fallbackLineNumber: string;
61
- content: string;
62
- hashlineAnchorContent?: string;
63
- raw: string;
64
- hunkIndex: number;
65
- }
66
-
67
- export interface DiffOmissionEntry {
68
- kind: "omission";
69
- raw: string;
70
- hunkIndex: number;
71
- }
72
-
73
- interface DiffMetaEntry {
74
- kind: Exclude<DiffEntryKind, "line" | "omission">;
75
- raw: string;
76
- hunkIndex: number;
77
- }
78
-
79
- type ParsedDiffEntry = DiffLineEntry | DiffOmissionEntry | DiffMetaEntry;
80
-
81
- export interface ParsedDiff {
82
- entries: ParsedDiffEntry[];
83
- stats: DiffStats;
84
- }
85
-
86
- interface DiffStats {
87
- added: number;
88
- removed: number;
89
- context: number;
90
- hunks: number;
91
- files: number;
92
- lines: number;
93
- }
94
-
95
- interface RenderedRow {
96
- text: string;
97
- hunkIndex: number | null;
98
- }
99
-
100
- interface SplitDiffRow {
101
- left?: DiffLineEntry;
102
- right?: DiffLineEntry;
103
- omission?: DiffOmissionEntry;
104
- meta?: DiffMetaEntry;
105
- hunkIndex: number | null;
106
- }
107
-
108
- interface DiffSpan {
109
- start: number;
110
- end: number;
111
- }
112
-
113
- interface RgbColor {
114
- r: number;
115
- g: number;
116
- b: number;
117
- }
118
-
119
- interface DiffPalette {
120
- addRowBgAnsi: string;
121
- removeRowBgAnsi: string;
122
- addEmphasisBgAnsi: string;
123
- removeEmphasisBgAnsi: string;
124
- }
125
-
126
- interface DiffRenderOptions {
127
- expanded: boolean;
128
- filePath?: string;
129
- previousContent?: string;
130
- fileExistedBeforeWrite?: boolean;
131
- headerLabel?: string;
132
- /** Live hover state for the collapsed hint row (muted → text on hover). */
133
- isHovered?: () => boolean;
134
- invalidate?: () => void;
135
- }
136
-
137
- type CodeLineHighlighter = (line: string, entry: DiffLineEntry) => string;
138
-
139
- const CANONICAL_LINE_PATTERN = /^([+\- ])(\s*\d+)\|(.*)$/;
140
- const HASHLINE_ANCHOR_LINE_PATTERN = /^([+\- ])(\s*\d+)#([A-Za-z0-9]+| {2}):(.*)$/;
141
- // Pi 仍使用空格分隔的行号格式;仅在无 hunk 头时可安全识别。
142
- const LEGACY_LINE_PATTERN = /^([+\- ])(\s*\d+)\s(.*)$/;
143
- const PI_OMISSION_LINE_PATTERN = /^ {3,}\.\.\.$/;
144
- const HUNK_HEADER_PATTERN = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$/;
145
- const SPLIT_SEPARATOR = " │ ";
146
- const MIN_LINE_NUMBER_WIDTH = 2;
147
- const MIN_SPLIT_COLUMN_WIDTH = 24;
148
- const MAX_INLINE_DIFF_LINE_LENGTH = 700;
149
- const ADD_ROW_BACKGROUND_MIX_RATIO = 0.12;
150
- const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
151
- const ADD_INLINE_EMPHASIS_MIX_RATIO = 0.26;
152
- const REMOVE_INLINE_EMPHASIS_MIX_RATIO = 0.26;
153
- const ADDITION_TINT_TARGET: RgbColor = { r: 84, g: 190, b: 118 };
154
- const DELETION_TINT_TARGET: RgbColor = { r: 232, g: 95, b: 122 };
155
- const ANSI_BG_RESET = "\x1b[49m";
156
- const DIFF_WIDTH_OPS = {
157
- measure: visibleWidth,
158
- truncate: (text: string, maxWidth: number): string => truncateToWidth(text, maxWidth, ""),
159
- };
160
-
161
- function clampDiffLineToWidth(text: string, width: number): string {
162
- return stabilizeBackgroundResets(clampRenderedLineToWidth(text, width, DIFF_WIDTH_OPS));
163
- }
164
-
165
- function clampDiffLinesToWidth(lines: string[], width: number): string[] {
166
- return clampRenderedLinesToWidth(lines, width, DIFF_WIDTH_OPS).map((line) =>
167
- stabilizeBackgroundResets(line),
168
- );
169
- }
170
-
171
- function normalizeCodeWhitespace(text: string): string {
172
- return text.replace(/\t/g, " ");
173
- }
174
-
175
- function emphasis(theme: DiffTheme, text: string): string {
176
- return typeof theme.bold === "function" ? theme.bold(text) : text;
177
- }
178
-
179
- function sequenceResetsBackground(params: number[]): boolean {
180
- let index = 0;
181
- while (index < params.length) {
182
- const param = params[index] ?? 0;
183
- if (param === 0 || param === 49) {
184
- return true;
185
- }
186
-
187
- const colorSequence = readSgrColorSequence(params, index);
188
- index += colorSequence ? colorSequence.length : 1;
189
- }
190
-
191
- return false;
192
- }
193
-
194
- function stripBackgroundResetParams(params: number[]): number[] {
195
- const filtered: number[] = [];
196
-
197
- for (let i = 0; i < params.length; i++) {
198
- const param = params[i] ?? 0;
199
-
200
- if (param === 0) {
201
- filtered.push(...expandSgrReset(param)!);
202
- continue;
203
- }
204
-
205
- if (param === 49) {
206
- continue;
207
- }
208
-
209
- const colorSequence = readSgrColorSequence(params, i);
210
- if (colorSequence) {
211
- filtered.push(...colorSequence);
212
- i += colorSequence.length - 1;
213
- continue;
214
- }
215
-
216
- filtered.push(param);
217
- }
218
-
219
- return filtered;
220
- }
221
-
222
- function stabilizeBackgroundResets(text: string): string {
223
- if (!text) {
224
- return text;
225
- }
226
- return filterSgrSequences(text, stripBackgroundResetParams);
227
- }
228
-
229
- function fitToWidth(text: string, width: number): string {
230
- const trimmed = truncateToWidth(text, width, "");
231
- const gap = Math.max(0, width - visibleWidth(trimmed));
232
- return gap > 0 ? `${trimmed}${" ".repeat(gap)}` : trimmed;
233
- }
234
-
235
- function applyBackgroundToVisualRow(
236
- text: string,
237
- width: number,
238
- rowBgAnsi: string,
239
- restoreBgAnsi: string,
240
- ): string {
241
- if (width <= 0) {
242
- return "";
243
- }
244
-
245
- const fitted = fitToWidth(text, width);
246
- const withStableBackground = keepBackgroundAcrossResets(fitted, rowBgAnsi);
247
- return stabilizeBackgroundResets(`${rowBgAnsi}${withStableBackground}${restoreBgAnsi}`);
248
- }
249
-
250
- function applyLineBackgroundToWrappedRows(
251
- rows: string[],
252
- width: number,
253
- rowBgAnsi: string,
254
- restoreBgAnsi: string,
255
- ): string[] {
256
- if (rows.length === 0) {
257
- return [applyBackgroundToVisualRow("", width, rowBgAnsi, restoreBgAnsi)];
258
- }
259
-
260
- return rows.map((row) => applyBackgroundToVisualRow(row, width, rowBgAnsi, restoreBgAnsi));
261
- }
262
-
263
- function wrapToWidth(text: string, width: number, wordWrap: boolean): string[] {
264
- if (width <= 0) {
265
- return [""];
266
- }
267
-
268
- if (!wordWrap) {
269
- return [fitToWidth(text, width)];
270
- }
271
-
272
- const wrapped = wrapTextWithAnsi(text, width);
273
- if (wrapped.length === 0) {
274
- return [fitToWidth("", width)];
275
- }
276
-
277
- return wrapped.map((line) => fitToWidth(line, width));
278
- }
279
-
280
- function resolveLanguageFromPath(rawPath: string | undefined): string | undefined {
281
- if (!rawPath || !rawPath.trim()) {
282
- return undefined;
283
- }
284
- const normalizedPath = rawPath.replace(/^@/, "").trim();
285
- if (!normalizedPath) {
286
- return undefined;
287
- }
288
- try {
289
- return getLanguageFromPath(normalizedPath);
290
- } catch {
291
- return undefined;
292
- }
293
- }
294
-
295
- function resolveShikiTheme(theme: DiffTheme): string {
296
- if (process.env.DIFF_THEME) return process.env.DIFF_THEME;
297
- const background =
298
- parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ??
299
- parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg")) ??
300
- parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg"));
301
- if (!background) return "github-dark";
302
- const luminance = (background.r * 299 + background.g * 587 + background.b * 114) / 1000;
303
- return luminance >= 128 ? "github-light" : "github-dark";
304
- }
305
-
306
- function cleanCodeLine(line: string): string {
307
- return sanitizeToolResultText(line).replace(/\n/g, "");
308
- }
309
-
310
- export function shouldHighlightCodeBlock(code: string): boolean {
311
- return code.length <= MAX_HL_CHARS;
312
- }
313
-
314
- function createCodeLineHighlighter(
315
- language: string | undefined,
316
- theme: DiffTheme,
317
- entries: readonly ParsedDiffEntry[],
318
- invalidate?: () => void,
319
- ): CodeLineHighlighter {
320
- const codeEntries = entries.filter((entry): entry is DiffLineEntry => entry.kind === "line");
321
- const cleanLines = codeEntries.map((entry) =>
322
- cleanCodeLine(normalizeCodeWhitespace(entry.content)),
323
- );
324
- const code = cleanLines.join("\n");
325
- const shouldHighlight = !!language && shouldHighlightCodeBlock(code);
326
- const fallbackLines = shouldHighlight
327
- ? (() => {
328
- try {
329
- return highlightCode(code, language).map(sanitizeAnsiForThemedOutput);
330
- } catch {
331
- return cleanLines.map(sanitizeAnsiForThemedOutput);
332
- }
333
- })()
334
- : cleanLines.map(sanitizeAnsiForThemedOutput);
335
- const fallbackByEntry = new WeakMap(
336
- codeEntries.map((entry, index) => [entry, fallbackLines[index] ?? cleanLines[index] ?? ""]),
337
- );
338
- if (!shouldHighlight) return (_line, entry) => fallbackByEntry.get(entry) ?? "";
339
-
340
- const shikiTheme = resolveShikiTheme(theme);
341
- let highlightedByEntry: WeakMap<DiffLineEntry, string> | undefined;
342
- const resolveHighlighted = () => {
343
- if (highlightedByEntry) return;
344
- const highlighted = shikiHighlightCache.get(
345
- code,
346
- language,
347
- shikiTheme,
348
- fallbackLines,
349
- invalidate,
350
- );
351
- if (highlighted) {
352
- highlightedByEntry = new WeakMap(
353
- codeEntries.map((entry, index) => [
354
- entry,
355
- sanitizeAnsiForThemedOutput(highlighted[index] ?? fallbackByEntry.get(entry) ?? ""),
356
- ]),
357
- );
358
- }
359
- };
360
- resolveHighlighted();
361
- return (_line, entry) => {
362
- resolveHighlighted();
363
- return highlightedByEntry?.get(entry) ?? fallbackByEntry.get(entry) ?? "";
364
- };
365
- }
366
-
367
- function toParsedDiffLine(
368
- prefix: string,
369
- lineNumber: string,
370
- content: string,
371
- ): {
372
- lineKind: DiffLineKind;
373
- lineNumber: string;
374
- content: string;
375
- } {
376
- const normalizedLineNumber = lineNumber.trim();
377
- if (prefix === "+") {
378
- return { lineKind: "add", lineNumber: normalizedLineNumber, content };
379
- }
380
- if (prefix === "-") {
381
- return { lineKind: "remove", lineNumber: normalizedLineNumber, content };
382
- }
383
- return { lineKind: "context", lineNumber: normalizedLineNumber, content };
384
- }
385
-
386
- function parseCanonicalDiffLine(
387
- line: string,
388
- allowPiFormat: boolean,
389
- ): {
390
- lineKind: DiffLineKind;
391
- lineNumber: string;
392
- content: string;
393
- hashlineAnchorContent?: string;
394
- } | null {
395
- const hashlineAnchorMatch = line.match(HASHLINE_ANCHOR_LINE_PATTERN);
396
- if (hashlineAnchorMatch) {
397
- const lineNumber = hashlineAnchorMatch[2] ?? "";
398
- const hash = hashlineAnchorMatch[3] ?? "";
399
- const content = hashlineAnchorMatch[4] ?? "";
400
- const parsed = toParsedDiffLine(hashlineAnchorMatch[1] ?? " ", lineNumber, content);
401
- return {
402
- ...parsed,
403
- hashlineAnchorContent: `${lineNumber.trim()}#${hash}:${content}`,
404
- };
405
- }
406
-
407
- const canonicalMatch = line.match(CANONICAL_LINE_PATTERN);
408
- const legacyMatch = canonicalMatch || !allowPiFormat ? null : line.match(LEGACY_LINE_PATTERN);
409
- const matched = canonicalMatch ?? legacyMatch;
410
- if (!matched) {
411
- return null;
412
- }
413
-
414
- return toParsedDiffLine(matched[1] ?? " ", matched[2] ?? "", matched[3] ?? "");
415
- }
416
-
417
- function toNumber(value: string | undefined): number | null {
418
- if (!value) {
419
- return null;
420
- }
421
- const parsed = Number.parseInt(value, 10);
422
- return Number.isNaN(parsed) ? null : parsed;
423
- }
424
-
425
- function anchorCanonicalLineCursors(
426
- kind: DiffLineKind,
427
- parsedNumber: number | null,
428
- oldLineCursor: number | null,
429
- newLineCursor: number | null,
430
- lineNumberDelta: number,
431
- ): { oldLineCursor: number | null; newLineCursor: number | null } {
432
- if (parsedNumber === null) {
433
- return { oldLineCursor, newLineCursor };
434
- }
435
-
436
- if (kind === "add") {
437
- return {
438
- oldLineCursor,
439
- newLineCursor: newLineCursor ?? parsedNumber,
440
- };
441
- }
442
-
443
- return {
444
- oldLineCursor: parsedNumber,
445
- newLineCursor: parsedNumber + lineNumberDelta,
446
- };
447
- }
448
-
449
- function classifyMetaLine(raw: string): DiffMetaEntry["kind"] {
450
- if (raw.startsWith("@@")) {
451
- return "hunk";
452
- }
453
- if (
454
- raw.startsWith("diff --git") ||
455
- raw.startsWith("index ") ||
456
- raw.startsWith("--- ") ||
457
- raw.startsWith("+++ ") ||
458
- raw.startsWith("rename from ") ||
459
- raw.startsWith("rename to ") ||
460
- raw.startsWith("new file mode ") ||
461
- raw.startsWith("deleted file mode ")
462
- ) {
463
- return "file";
464
- }
465
- return "meta";
466
- }
467
-
468
- function pushParsedLineEntry(
469
- entries: ParsedDiffEntry[],
470
- lineKind: DiffLineKind,
471
- oldLineNumber: number | null,
472
- newLineNumber: number | null,
473
- fallbackLineNumber: string,
474
- rawLine: string,
475
- hunkIndex: number,
476
- ): void {
477
- entries.push({
478
- kind: "line",
479
- lineKind,
480
- oldLineNumber,
481
- newLineNumber,
482
- fallbackLineNumber,
483
- content: rawLine.slice(1),
484
- raw: rawLine,
485
- hunkIndex,
486
- });
487
- }
488
-
489
- function createMetaEntry(raw: string, hunkIndex: number): DiffMetaEntry {
490
- return {
491
- kind: classifyMetaLine(raw),
492
- raw,
493
- hunkIndex,
494
- };
495
- }
496
-
497
- function ensureImplicitHunk(currentHunk: number): number {
498
- return currentHunk > 0 ? currentHunk : 1;
499
- }
500
-
501
- export function parseDiff(diffText: string): ParsedDiff {
502
- const stats: DiffStats = {
503
- added: 0,
504
- removed: 0,
505
- context: 0,
506
- hunks: 0,
507
- files: 0,
508
- lines: 0,
509
- };
510
- const entries: ParsedDiffEntry[] = [];
511
-
512
- if (!diffText.trim()) {
513
- return { entries, stats };
514
- }
515
-
516
- let hunkIndex = 0;
517
- let oldLineCursor: number | null = null;
518
- let newLineCursor: number | null = null;
519
- let lineNumberDelta = 0;
520
- let hasHunkHeader = false;
521
-
522
- for (const rawLine of diffText.replace(/\r/g, "").split("\n")) {
523
- stats.lines++;
524
-
525
- const hunkMatch = rawLine.match(HUNK_HEADER_PATTERN);
526
- if (hunkMatch) {
527
- hasHunkHeader = true;
528
- hunkIndex++;
529
- stats.hunks = Math.max(stats.hunks, hunkIndex);
530
- oldLineCursor = toNumber(hunkMatch[1]);
531
- newLineCursor = toNumber(hunkMatch[3]);
532
- lineNumberDelta = (newLineCursor ?? 0) - (oldLineCursor ?? 0);
533
- entries.push({ kind: "hunk", raw: rawLine, hunkIndex });
534
- continue;
535
- }
536
-
537
- if (rawLine.startsWith("diff --git ")) {
538
- stats.files++;
539
- oldLineCursor = null;
540
- newLineCursor = null;
541
- lineNumberDelta = 0;
542
- entries.push({ kind: "file", raw: rawLine, hunkIndex });
543
- continue;
544
- }
545
-
546
- if (rawLine.startsWith("--- ") || rawLine.startsWith("+++ ")) {
547
- oldLineCursor = null;
548
- newLineCursor = null;
549
- lineNumberDelta = 0;
550
- }
551
-
552
- // Pi 用空白行号填充省略上下文;它不是源码行,也不推进行号游标。
553
- if (!hasHunkHeader && PI_OMISSION_LINE_PATTERN.test(rawLine)) {
554
- entries.push({ kind: "omission", raw: rawLine, hunkIndex });
555
- continue;
556
- }
557
-
558
- // Pi 无 hunk 头时的数字行号与 unified hunk 中的数字源码文本存在歧义。
559
- const canonical = parseCanonicalDiffLine(rawLine, !hasHunkHeader);
560
- if (canonical) {
561
- hunkIndex = ensureImplicitHunk(hunkIndex);
562
- stats.hunks = Math.max(stats.hunks, hunkIndex);
563
-
564
- const parsedNumber = toNumber(canonical.lineNumber);
565
- const anchoredCursors = anchorCanonicalLineCursors(
566
- canonical.lineKind,
567
- parsedNumber,
568
- oldLineCursor,
569
- newLineCursor,
570
- lineNumberDelta,
571
- );
572
- oldLineCursor = anchoredCursors.oldLineCursor;
573
- newLineCursor = anchoredCursors.newLineCursor;
574
-
575
- const oldLineNumber = canonical.lineKind === "add" ? null : oldLineCursor;
576
- const newLineNumber = canonical.lineKind === "remove" ? null : newLineCursor;
577
-
578
- if (canonical.lineKind === "add") {
579
- stats.added++;
580
- if (newLineCursor !== null) {
581
- newLineCursor++;
582
- }
583
- lineNumberDelta++;
584
- } else if (canonical.lineKind === "remove") {
585
- stats.removed++;
586
- if (oldLineCursor !== null) {
587
- oldLineCursor++;
588
- }
589
- lineNumberDelta--;
590
- } else {
591
- stats.context++;
592
- if (oldLineCursor !== null) {
593
- oldLineCursor++;
594
- }
595
- if (newLineCursor !== null) {
596
- newLineCursor++;
597
- }
598
- }
599
-
600
- entries.push({
601
- kind: "line",
602
- lineKind: canonical.lineKind,
603
- oldLineNumber,
604
- newLineNumber,
605
- fallbackLineNumber: canonical.lineNumber,
606
- content: canonical.content,
607
- hashlineAnchorContent: canonical.hashlineAnchorContent,
608
- raw: rawLine,
609
- hunkIndex,
610
- });
611
- continue;
612
- }
613
-
614
- if (rawLine.startsWith("-") && !rawLine.startsWith("---")) {
615
- hunkIndex = ensureImplicitHunk(hunkIndex);
616
- stats.hunks = Math.max(stats.hunks, hunkIndex);
617
- stats.removed++;
618
- const oldLineNumber = oldLineCursor;
619
- if (oldLineCursor !== null) {
620
- oldLineCursor++;
621
- }
622
- lineNumberDelta--;
623
- pushParsedLineEntry(
624
- entries,
625
- "remove",
626
- oldLineNumber,
627
- null,
628
- oldLineNumber !== null ? `${oldLineNumber}` : "",
629
- rawLine,
630
- hunkIndex,
631
- );
632
- continue;
633
- }
634
-
635
- if (rawLine.startsWith("+") && !rawLine.startsWith("+++")) {
636
- hunkIndex = ensureImplicitHunk(hunkIndex);
637
- stats.hunks = Math.max(stats.hunks, hunkIndex);
638
- stats.added++;
639
- const newLineNumber = newLineCursor;
640
- if (newLineCursor !== null) {
641
- newLineCursor++;
642
- }
643
- lineNumberDelta++;
644
- pushParsedLineEntry(
645
- entries,
646
- "add",
647
- null,
648
- newLineNumber,
649
- newLineNumber !== null ? `${newLineNumber}` : "",
650
- rawLine,
651
- hunkIndex,
652
- );
653
- continue;
654
- }
655
-
656
- if (rawLine.startsWith(" ")) {
657
- hunkIndex = ensureImplicitHunk(hunkIndex);
658
- stats.hunks = Math.max(stats.hunks, hunkIndex);
659
- stats.context++;
660
- const oldLineNumber = oldLineCursor;
661
- const newLineNumber = newLineCursor;
662
- if (oldLineCursor !== null) {
663
- oldLineCursor++;
664
- }
665
- if (newLineCursor !== null) {
666
- newLineCursor++;
667
- }
668
- pushParsedLineEntry(
669
- entries,
670
- "context",
671
- oldLineNumber,
672
- newLineNumber,
673
- oldLineNumber !== null
674
- ? `${oldLineNumber}`
675
- : newLineNumber !== null
676
- ? `${newLineNumber}`
677
- : "",
678
- rawLine,
679
- hunkIndex,
680
- );
681
- continue;
682
- }
683
-
684
- entries.push(createMetaEntry(rawLine, hunkIndex));
685
- }
686
-
687
- if (stats.hunks === 0 && (stats.added > 0 || stats.removed > 0 || stats.context > 0)) {
688
- stats.hunks = 1;
689
- }
690
- if (stats.files === 0) {
691
- const patchStyleFileHeaders = entries.filter(
692
- (entry) => entry.kind === "file" && entry.raw.startsWith("+++ "),
693
- ).length;
694
- if (patchStyleFileHeaders > 0) {
695
- stats.files = patchStyleFileHeaders;
696
- } else if (stats.hunks > 0) {
697
- stats.files = 1;
698
- }
699
- }
700
-
701
- return { entries, stats };
702
- }
703
-
704
- function getHashlineAnchorLabel(entry: DiffLineEntry): string | undefined {
705
- if (!entry.hashlineAnchorContent) {
706
- return undefined;
707
- }
708
- const separatorIndex = entry.hashlineAnchorContent.indexOf(":");
709
- return separatorIndex >= 0
710
- ? entry.hashlineAnchorContent.slice(0, separatorIndex)
711
- : entry.hashlineAnchorContent;
712
- }
713
-
714
- function getLineNumberWidth(entries: ParsedDiffEntry[], showHashlineAnchors = false): number {
715
- let maxWidth = MIN_LINE_NUMBER_WIDTH;
716
-
717
- for (const entry of entries) {
718
- if (entry.kind !== "line") {
719
- continue;
720
- }
721
-
722
- if (showHashlineAnchors) {
723
- const anchorLabel = getHashlineAnchorLabel(entry);
724
- if (anchorLabel) {
725
- maxWidth = Math.max(maxWidth, visibleWidth(anchorLabel));
726
- continue;
727
- }
728
- }
729
-
730
- const candidates = [
731
- entry.oldLineNumber,
732
- entry.newLineNumber,
733
- toNumber(entry.fallbackLineNumber),
734
- ].filter((value): value is number => value !== null);
735
-
736
- for (const candidate of candidates) {
737
- const digits = `${candidate}`.length;
738
- if (digits > maxWidth) {
739
- maxWidth = digits;
740
- }
741
- }
742
- }
743
-
744
- return maxWidth;
745
- }
746
-
747
- function formatLineNumber(value: number | null, fallback: string, width: number): string {
748
- if (value !== null) {
749
- return `${value}`.padStart(width, " ");
750
- }
751
- if (fallback.trim()) {
752
- return fallback.trim().slice(-width).padStart(width, " ");
753
- }
754
- return " ".repeat(width);
755
- }
756
-
757
- function formatLineNumberLabel(
758
- entry: DiffLineEntry,
759
- value: number | null,
760
- fallback: string,
761
- width: number,
762
- showHashlineAnchors: boolean,
763
- ): string {
764
- const anchorLabel = showHashlineAnchors ? getHashlineAnchorLabel(entry) : undefined;
765
- if (anchorLabel) {
766
- return fitToWidth(anchorLabel, width);
767
- }
768
- return formatLineNumber(value, fallback, width);
769
- }
770
-
771
- function formatMetaEntryRows(
772
- entry: DiffMetaEntry,
773
- width: number,
774
- theme: DiffTheme,
775
- wordWrap: boolean,
776
- ): RenderedRow[] {
777
- const normalized = sanitizeAnsiForThemedOutput(normalizeCodeWhitespace(entry.raw));
778
- const lines = wordWrap
779
- ? wrapToWidth(normalized, width, true)
780
- : [truncateToWidth(normalized, width)];
781
-
782
- const mapColor = (line: string): string => {
783
- if (entry.kind === "hunk") {
784
- return stabilizeBackgroundResets(theme.fg("accent", line));
785
- }
786
- if (entry.kind === "file") {
787
- return stabilizeBackgroundResets(theme.fg("muted", line));
788
- }
789
- return stabilizeBackgroundResets(theme.fg("toolDiffContext", line));
790
- };
791
-
792
- return lines.map((line) => ({
793
- text: mapColor(line),
794
- hunkIndex: entry.kind === "file" ? null : entry.hunkIndex || null,
795
- }));
796
- }
797
-
798
- function collectConsecutiveLineEntries(
799
- entries: ParsedDiffEntry[],
800
- startIndex: number,
801
- lineKind: DiffLineKind,
802
- ): { collected: DiffLineEntry[]; nextIndex: number } {
803
- const collected: DiffLineEntry[] = [];
804
- let index = startIndex;
805
- while (index < entries.length) {
806
- const candidate = entries[index];
807
- if (!candidate || candidate.kind !== "line" || candidate.lineKind !== lineKind) {
808
- break;
809
- }
810
- collected.push(candidate);
811
- index++;
812
- }
813
- return { collected, nextIndex: index };
814
- }
815
-
816
- function buildSplitRows(entries: ParsedDiffEntry[]): SplitDiffRow[] {
817
- const rows: SplitDiffRow[] = [];
818
- let index = 0;
819
-
820
- while (index < entries.length) {
821
- const entry = entries[index];
822
- if (!entry) {
823
- break;
824
- }
825
-
826
- if (entry.kind === "omission") {
827
- rows.push({ omission: entry, hunkIndex: entry.hunkIndex || null });
828
- index++;
829
- continue;
830
- }
831
-
832
- if (entry.kind !== "line") {
833
- rows.push({ meta: entry, hunkIndex: entry.hunkIndex || null });
834
- index++;
835
- continue;
836
- }
837
-
838
- if (entry.lineKind === "remove") {
839
- const removedResult = collectConsecutiveLineEntries(entries, index, "remove");
840
- const removed = removedResult.collected;
841
- const addedResult = collectConsecutiveLineEntries(entries, removedResult.nextIndex, "add");
842
- const added = addedResult.collected;
843
- index = addedResult.nextIndex;
844
-
845
- const pairCount = Math.max(removed.length, added.length);
846
- for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
847
- const left = removed[pairIndex];
848
- const right = added[pairIndex];
849
- rows.push({
850
- left,
851
- right,
852
- hunkIndex: left?.hunkIndex ?? right?.hunkIndex ?? null,
853
- });
854
- }
855
- continue;
856
- }
857
-
858
- if (entry.lineKind === "add") {
859
- rows.push({ right: entry, hunkIndex: entry.hunkIndex || null });
860
- index++;
861
- continue;
862
- }
863
-
864
- rows.push({ left: entry, right: entry, hunkIndex: entry.hunkIndex || null });
865
- index++;
866
- }
867
-
868
- return rows;
869
- }
870
-
871
- function getCellLineNumber(line: DiffLineEntry, side: "left" | "right"): number | null {
872
- if (side === "left") {
873
- return line.oldLineNumber ?? (line.lineKind === "context" ? line.newLineNumber : null);
874
- }
875
- return line.newLineNumber ?? (line.lineKind === "context" ? line.oldLineNumber : null);
876
- }
877
-
878
- function tokenizeInlineDiff(input: string): Array<{ value: string; start: number; end: number }> {
879
- if (!input) {
880
- return [];
881
- }
882
-
883
- const tokens: Array<{ value: string; start: number; end: number }> = [];
884
- const pattern = /(\s+|[A-Za-z0-9_]+|[^A-Za-z0-9_\s])/g;
885
- let match: RegExpExecArray | null;
886
-
887
- while ((match = pattern.exec(input)) !== null) {
888
- const value = match[0] ?? "";
889
- if (!value) {
890
- continue;
891
- }
892
- tokens.push({
893
- value,
894
- start: match.index,
895
- end: match.index + value.length,
896
- });
897
- }
898
-
899
- if (tokens.length === 0 && input.length > 0) {
900
- tokens.push({ value: input, start: 0, end: input.length });
901
- }
902
-
903
- return tokens;
904
- }
905
-
906
- function mergeSpans(spans: DiffSpan[]): DiffSpan[] {
907
- if (spans.length <= 1) {
908
- return spans;
909
- }
910
-
911
- const sorted = [...spans].sort((a, b) => a.start - b.start);
912
- const merged: DiffSpan[] = [sorted[0]];
913
-
914
- for (let index = 1; index < sorted.length; index++) {
915
- const current = sorted[index];
916
- const previous = merged[merged.length - 1];
917
- if (!current || !previous) {
918
- continue;
919
- }
920
-
921
- if (current.start <= previous.end) {
922
- previous.end = Math.max(previous.end, current.end);
923
- continue;
924
- }
925
-
926
- merged.push({ ...current });
927
- }
928
-
929
- return merged;
930
- }
931
-
932
- function tokensToDiffSpans(
933
- text: string,
934
- tokens: Array<{ value: string; start: number; end: number }>,
935
- changedIndexes: Set<number>,
936
- ): DiffSpan[] {
937
- if (tokens.length === 0 || changedIndexes.size === 0) {
938
- return [];
939
- }
940
-
941
- const spans: DiffSpan[] = [];
942
- let start: number | null = null;
943
- let end = -1;
944
-
945
- for (let index = 0; index < tokens.length; index++) {
946
- if (!changedIndexes.has(index)) {
947
- if (start !== null && end > start) {
948
- spans.push({ start, end });
949
- start = null;
950
- end = -1;
951
- }
952
- continue;
953
- }
954
-
955
- const token = tokens[index];
956
- if (!token) {
957
- continue;
958
- }
959
-
960
- if (start === null) {
961
- start = token.start;
962
- end = token.end;
963
- } else {
964
- end = token.end;
965
- }
966
- }
967
-
968
- if (start !== null && end > start) {
969
- spans.push({ start, end });
970
- }
971
-
972
- const trimmed: DiffSpan[] = [];
973
- for (const span of spans) {
974
- let spanStart = span.start;
975
- let spanEnd = span.end;
976
-
977
- while (spanStart < spanEnd && /\s/.test(text[spanStart] ?? "")) {
978
- spanStart++;
979
- }
980
- while (spanEnd > spanStart && /\s/.test(text[spanEnd - 1] ?? "")) {
981
- spanEnd--;
982
- }
983
- if (spanEnd > spanStart) {
984
- trimmed.push({ start: spanStart, end: spanEnd });
985
- }
986
- }
987
-
988
- return mergeSpans(trimmed);
989
- }
990
-
991
- function computeInlineDiffSpans(
992
- leftLine: string,
993
- rightLine: string,
994
- ): { left: DiffSpan[]; right: DiffSpan[] } {
995
- if (leftLine === rightLine) {
996
- return { left: [], right: [] };
997
- }
998
- if (
999
- leftLine.length > MAX_INLINE_DIFF_LINE_LENGTH ||
1000
- rightLine.length > MAX_INLINE_DIFF_LINE_LENGTH
1001
- ) {
1002
- return { left: [], right: [] };
1003
- }
1004
-
1005
- const leftTokens = tokenizeInlineDiff(leftLine);
1006
- const rightTokens = tokenizeInlineDiff(rightLine);
1007
- const leftCount = leftTokens.length;
1008
- const rightCount = rightTokens.length;
1009
-
1010
- if (leftCount === 0 || rightCount === 0) {
1011
- return {
1012
- left: leftLine.trim().length > 0 ? [{ start: 0, end: leftLine.length }] : [],
1013
- right: rightLine.trim().length > 0 ? [{ start: 0, end: rightLine.length }] : [],
1014
- };
1015
- }
1016
-
1017
- const table: number[][] = Array.from({ length: leftCount + 1 }, () =>
1018
- Array<number>(rightCount + 1).fill(0),
1019
- );
1020
-
1021
- for (let leftIndex = 1; leftIndex <= leftCount; leftIndex++) {
1022
- const leftToken = leftTokens[leftIndex - 1];
1023
- for (let rightIndex = 1; rightIndex <= rightCount; rightIndex++) {
1024
- const rightToken = rightTokens[rightIndex - 1];
1025
- if (leftToken?.value === rightToken?.value) {
1026
- table[leftIndex][rightIndex] = (table[leftIndex - 1]?.[rightIndex - 1] ?? 0) + 1;
1027
- } else {
1028
- const top = table[leftIndex - 1]?.[rightIndex] ?? 0;
1029
- const side = table[leftIndex]?.[rightIndex - 1] ?? 0;
1030
- table[leftIndex][rightIndex] = Math.max(top, side);
1031
- }
1032
- }
1033
- }
1034
-
1035
- const changedLeft = new Set<number>();
1036
- const changedRight = new Set<number>();
1037
- let leftCursor = leftCount;
1038
- let rightCursor = rightCount;
1039
-
1040
- while (leftCursor > 0 && rightCursor > 0) {
1041
- const leftToken = leftTokens[leftCursor - 1];
1042
- const rightToken = rightTokens[rightCursor - 1];
1043
- if (leftToken?.value === rightToken?.value) {
1044
- leftCursor--;
1045
- rightCursor--;
1046
- continue;
1047
- }
1048
-
1049
- const top = table[leftCursor - 1]?.[rightCursor] ?? 0;
1050
- const side = table[leftCursor]?.[rightCursor - 1] ?? 0;
1051
- if (top >= side) {
1052
- changedLeft.add(leftCursor - 1);
1053
- leftCursor--;
1054
- } else {
1055
- changedRight.add(rightCursor - 1);
1056
- rightCursor--;
1057
- }
1058
- }
1059
-
1060
- while (leftCursor > 0) {
1061
- changedLeft.add(leftCursor - 1);
1062
- leftCursor--;
1063
- }
1064
- while (rightCursor > 0) {
1065
- changedRight.add(rightCursor - 1);
1066
- rightCursor--;
1067
- }
1068
-
1069
- return {
1070
- left: tokensToDiffSpans(leftLine, leftTokens, changedLeft),
1071
- right: tokensToDiffSpans(rightLine, rightTokens, changedRight),
1072
- };
1073
- }
1074
-
1075
- function getCompactLineRenderContent(entry: DiffLineEntry, showHashlineAnchors: boolean): string {
1076
- return showHashlineAnchors && entry.hashlineAnchorContent
1077
- ? entry.hashlineAnchorContent
1078
- : entry.content;
1079
- }
1080
-
1081
- function buildInlineHighlightMap(rows: SplitDiffRow[]): WeakMap<DiffLineEntry, DiffSpan[]> {
1082
- const highlights = new WeakMap<DiffLineEntry, DiffSpan[]>();
1083
-
1084
- for (const row of rows) {
1085
- if (!row.left || !row.right) {
1086
- continue;
1087
- }
1088
- if (row.left.lineKind !== "remove" || row.right.lineKind !== "add") {
1089
- continue;
1090
- }
1091
-
1092
- const leftText = normalizeCodeWhitespace(row.left.content);
1093
- const rightText = normalizeCodeWhitespace(row.right.content);
1094
- const inline = computeInlineDiffSpans(leftText, rightText);
1095
- if (inline.left.length > 0) {
1096
- highlights.set(row.left, inline.left);
1097
- }
1098
- if (inline.right.length > 0) {
1099
- highlights.set(row.right, inline.right);
1100
- }
1101
- }
1102
-
1103
- return highlights;
1104
- }
1105
-
1106
- function ansi256ToRgb(code: number): RgbColor {
1107
- if (code < 0) {
1108
- return { r: 0, g: 0, b: 0 };
1109
- }
1110
- if (code <= 15) {
1111
- const base16: RgbColor[] = [
1112
- { r: 0, g: 0, b: 0 },
1113
- { r: 128, g: 0, b: 0 },
1114
- { r: 0, g: 128, b: 0 },
1115
- { r: 128, g: 128, b: 0 },
1116
- { r: 0, g: 0, b: 128 },
1117
- { r: 128, g: 0, b: 128 },
1118
- { r: 0, g: 128, b: 128 },
1119
- { r: 192, g: 192, b: 192 },
1120
- { r: 128, g: 128, b: 128 },
1121
- { r: 255, g: 0, b: 0 },
1122
- { r: 0, g: 255, b: 0 },
1123
- { r: 255, g: 255, b: 0 },
1124
- { r: 0, g: 0, b: 255 },
1125
- { r: 255, g: 0, b: 255 },
1126
- { r: 0, g: 255, b: 255 },
1127
- { r: 255, g: 255, b: 255 },
1128
- ];
1129
- return base16[code] ?? { r: 255, g: 255, b: 255 };
1130
- }
1131
- if (code >= 232) {
1132
- const value = Math.max(0, Math.min(255, 8 + (code - 232) * 10));
1133
- return { r: value, g: value, b: value };
1134
- }
1135
-
1136
- const cube = code - 16;
1137
- const levels = [0, 95, 135, 175, 215, 255];
1138
- const blue = cube % 6;
1139
- const green = Math.floor(cube / 6) % 6;
1140
- const red = Math.floor(cube / 36) % 6;
1141
- return {
1142
- r: levels[red] ?? 0,
1143
- g: levels[green] ?? 0,
1144
- b: levels[blue] ?? 0,
1145
- };
1146
- }
1147
-
1148
- function parseAnsiColorCode(ansi: string | undefined): RgbColor | null {
1149
- if (!ansi) {
1150
- return null;
1151
- }
1152
- const rgbMatch = /\x1b\[(?:3|4)8;2;(\d{1,3});(\d{1,3});(\d{1,3})m/.exec(ansi);
1153
- if (rgbMatch) {
1154
- const r = Number.parseInt(rgbMatch[1] ?? "0", 10);
1155
- const g = Number.parseInt(rgbMatch[2] ?? "0", 10);
1156
- const b = Number.parseInt(rgbMatch[3] ?? "0", 10);
1157
- if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
1158
- return {
1159
- r: Math.max(0, Math.min(255, r)),
1160
- g: Math.max(0, Math.min(255, g)),
1161
- b: Math.max(0, Math.min(255, b)),
1162
- };
1163
- }
1164
- }
1165
-
1166
- const bitMatch = /\x1b\[(?:3|4)8;5;(\d{1,3})m/.exec(ansi);
1167
- if (bitMatch) {
1168
- const code = Number.parseInt(bitMatch[1] ?? "0", 10);
1169
- if (Number.isFinite(code)) {
1170
- return ansi256ToRgb(code);
1171
- }
1172
- }
1173
-
1174
- const basicMatch = /\x1b\[(?:3|4)([0-7])m/.exec(ansi);
1175
- if (basicMatch) return ansi256ToRgb(Number(basicMatch[1]));
1176
- const brightMatch = /\x1b\[(?:9|10)([0-7])m/.exec(ansi);
1177
- if (brightMatch) return ansi256ToRgb(Number(brightMatch[1]) + 8);
1178
- return null;
1179
- }
1180
-
1181
- function rgbToBgAnsi(color: RgbColor): string {
1182
- const r = Math.max(0, Math.min(255, Math.round(color.r)));
1183
- const g = Math.max(0, Math.min(255, Math.round(color.g)));
1184
- const b = Math.max(0, Math.min(255, Math.round(color.b)));
1185
- return `\x1b[48;2;${r};${g};${b}m`;
1186
- }
1187
-
1188
- function mixRgb(base: RgbColor, tint: RgbColor, ratio: number): RgbColor {
1189
- const clamped = Math.max(0, Math.min(1, ratio));
1190
- return {
1191
- r: base.r * (1 - clamped) + tint.r * clamped,
1192
- g: base.g * (1 - clamped) + tint.g * clamped,
1193
- b: base.b * (1 - clamped) + tint.b * clamped,
1194
- };
1195
- }
1196
-
1197
- function extractThemeBackgroundAnsi(text: string): string | undefined {
1198
- if (!text || !text.includes("\x1b[")) {
1199
- return undefined;
1200
- }
1201
-
1202
- ANSI_SGR_PATTERN.lastIndex = 0;
1203
- let match: RegExpExecArray | null;
1204
- while ((match = ANSI_SGR_PATTERN.exec(text)) !== null) {
1205
- const parsed = toSgrParams(match[1] ?? "");
1206
- for (let index = 0; index < parsed.length; index += 1) {
1207
- const param = parsed[index] ?? 0;
1208
- if ((param >= 40 && param <= 47) || (param >= 100 && param <= 107)) {
1209
- return `\x1b[${param}m`;
1210
- }
1211
-
1212
- const colorSequence = readSgrColorSequence(parsed, index);
1213
- if (colorSequence?.[0] === 48) {
1214
- return `\x1b[${colorSequence.join(";")}m`;
1215
- }
1216
- if (colorSequence) {
1217
- index += colorSequence.length - 1;
1218
- }
1219
- }
1220
- }
1221
-
1222
- return undefined;
1223
- }
1224
-
1225
- function readThemeAnsi(theme: DiffTheme, kind: "fg" | "bg", slot: string): string | undefined {
1226
- try {
1227
- if (kind === "fg" && typeof theme.getFgAnsi === "function") {
1228
- return theme.getFgAnsi(slot);
1229
- }
1230
- if (kind === "bg") {
1231
- if (typeof theme.getBgAnsi === "function") {
1232
- return theme.getBgAnsi(slot);
1233
- }
1234
- if (typeof theme.bg === "function") {
1235
- return extractThemeBackgroundAnsi(theme.bg(slot, " "));
1236
- }
1237
- }
1238
- } catch {
1239
- return undefined;
1240
- }
1241
- return undefined;
1242
- }
1243
-
1244
- function resolveDiffPalette(theme: DiffTheme): DiffPalette {
1245
- const baseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ??
1246
- parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg")) ??
1247
- parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg")) ?? { r: 32, g: 35, b: 42 };
1248
- const addFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffAdded")) ?? {
1249
- r: 88,
1250
- g: 173,
1251
- b: 88,
1252
- };
1253
- const removeFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffRemoved")) ?? {
1254
- r: 196,
1255
- g: 98,
1256
- b: 98,
1257
- };
1258
- const addTint = mixRgb(addFg, ADDITION_TINT_TARGET, 0.35);
1259
- const removeTint = mixRgb(removeFg, DELETION_TINT_TARGET, 0.65);
1260
-
1261
- const addRowBg = mixRgb(baseBg, addTint, ADD_ROW_BACKGROUND_MIX_RATIO);
1262
- const removeRowBg = mixRgb(baseBg, removeTint, REMOVE_ROW_BACKGROUND_MIX_RATIO);
1263
- const addEmphasisBg = mixRgb(baseBg, addTint, ADD_INLINE_EMPHASIS_MIX_RATIO);
1264
- const removeEmphasisBg = mixRgb(baseBg, removeTint, REMOVE_INLINE_EMPHASIS_MIX_RATIO);
1265
-
1266
- return {
1267
- addRowBgAnsi: rgbToBgAnsi(addRowBg),
1268
- removeRowBgAnsi: rgbToBgAnsi(removeRowBg),
1269
- addEmphasisBgAnsi: rgbToBgAnsi(addEmphasisBg),
1270
- removeEmphasisBgAnsi: rgbToBgAnsi(removeEmphasisBg),
1271
- };
1272
- }
1273
-
1274
- function getLineBackground(
1275
- kind: DiffLineKind,
1276
- palette: DiffPalette,
1277
- emphasis: boolean,
1278
- ): string | undefined {
1279
- if (kind === "add") {
1280
- return emphasis ? palette.addEmphasisBgAnsi : palette.addRowBgAnsi;
1281
- }
1282
- if (kind === "remove") {
1283
- return emphasis ? palette.removeEmphasisBgAnsi : palette.removeRowBgAnsi;
1284
- }
1285
- return undefined;
1286
- }
1287
-
1288
- function applyBackgroundToVisibleRange(
1289
- ansiText: string,
1290
- start: number,
1291
- end: number,
1292
- backgroundAnsi: string,
1293
- restoreBackgroundAnsi: string,
1294
- ): string {
1295
- if (!ansiText || start >= end || end <= 0) {
1296
- return ansiText;
1297
- }
1298
-
1299
- const rangeStart = Math.max(0, start);
1300
- const rangeEnd = Math.max(rangeStart, end);
1301
- let output = "";
1302
- let visibleIndex = 0;
1303
- let index = 0;
1304
- let inRange = false;
1305
-
1306
- while (index < ansiText.length) {
1307
- if (ansiText[index] === "\x1b") {
1308
- const sequenceEnd = ansiText.indexOf("m", index);
1309
- if (sequenceEnd !== -1) {
1310
- output += ansiText.slice(index, sequenceEnd + 1);
1311
- index = sequenceEnd + 1;
1312
- continue;
1313
- }
1314
- }
1315
-
1316
- if (visibleIndex === rangeStart && !inRange) {
1317
- output += backgroundAnsi;
1318
- inRange = true;
1319
- }
1320
- if (visibleIndex === rangeEnd && inRange) {
1321
- output += restoreBackgroundAnsi;
1322
- inRange = false;
1323
- }
1324
-
1325
- output += ansiText[index] ?? "";
1326
- visibleIndex++;
1327
- index++;
1328
- }
1329
-
1330
- if (inRange) {
1331
- output += restoreBackgroundAnsi;
1332
- }
1333
-
1334
- return output;
1335
- }
1336
-
1337
- function applyInlineSpanHighlight(
1338
- plainText: string,
1339
- renderedText: string,
1340
- spans: DiffSpan[],
1341
- emphasisBgAnsi: string | undefined,
1342
- rowBgAnsi: string | undefined,
1343
- fallbackBgAnsi: string | undefined,
1344
- ): string {
1345
- if (!renderedText || !plainText || spans.length === 0 || !emphasisBgAnsi) {
1346
- return renderedText;
1347
- }
1348
-
1349
- const sorted = mergeSpans(
1350
- spans
1351
- .map((span) => ({
1352
- start: Math.max(0, Math.min(plainText.length, span.start)),
1353
- end: Math.max(0, Math.min(plainText.length, span.end)),
1354
- }))
1355
- .filter((span) => span.end > span.start),
1356
- );
1357
- if (sorted.length === 0) {
1358
- return renderedText;
1359
- }
1360
-
1361
- const restoreBackgroundAnsi = rowBgAnsi ?? fallbackBgAnsi ?? ANSI_BG_RESET;
1362
- let highlighted = renderedText;
1363
- for (let index = sorted.length - 1; index >= 0; index--) {
1364
- const span = sorted[index];
1365
- if (!span) {
1366
- continue;
1367
- }
1368
- highlighted = applyBackgroundToVisibleRange(
1369
- highlighted,
1370
- span.start,
1371
- span.end,
1372
- emphasisBgAnsi,
1373
- restoreBackgroundAnsi,
1374
- );
1375
- }
1376
-
1377
- return highlighted;
1378
- }
1379
-
1380
- function resolveDiffIndicatorMode(
1381
- config: Partial<Pick<ToolDisplayConfig, "diffIndicatorMode">>,
1382
- ): DiffIndicatorMode {
1383
- return config.diffIndicatorMode ?? DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode;
1384
- }
1385
-
1386
- /** Snapshot or live getter — panel changes must apply on the next paint. */
1387
- export type DisplayConfigInput = ToolDisplayConfig | (() => ToolDisplayConfig);
1388
-
1389
- const RICH_DIFF_COMPONENT = Symbol.for("pi.ccstyle.rich-diff-component");
1390
-
1391
- export function isRichDiffComponent(value: unknown): boolean {
1392
- return Boolean(
1393
- value && typeof value === "object" && (value as any)[RICH_DIFF_COMPONENT] === true,
1394
- );
1395
- }
1396
-
1397
- function resolveLiveDisplayConfig(input: DisplayConfigInput): ToolDisplayConfig {
1398
- return typeof input === "function" ? input() : input;
1399
- }
1400
-
1401
- /** Cache key fragment so indicator/wrap/limits invalidate without host recreate. */
1402
- function displayConfigCacheKey(config: ToolDisplayConfig): string {
1403
- return [
1404
- config.diffViewMode,
1405
- config.diffIndicatorMode,
1406
- String(config.diffSplitMinWidth),
1407
- String(config.editDiffCollapsedLines),
1408
- String(config.writeDiffCollapsedLines),
1409
- config.diffWordWrap ? "1" : "0",
1410
- String(config.expandedPreviewMaxLines),
1411
- ].join(":");
1412
- }
1413
-
1414
- function resolveIndicatorGlyph(
1415
- kind: DiffLineKind,
1416
- indicatorMode: DiffIndicatorMode,
1417
- continuation: boolean,
1418
- ): string {
1419
- if (kind === "context") {
1420
- return " ";
1421
- }
1422
-
1423
- switch (indicatorMode) {
1424
- case "bars":
1425
- return "▌";
1426
- case "classic":
1427
- if (continuation) {
1428
- return " ";
1429
- }
1430
- return kind === "add" ? "+" : "-";
1431
- case "none":
1432
- default:
1433
- return " ";
1434
- }
1435
- }
1436
-
1437
- function colorizeSegment(
1438
- theme: DiffTheme,
1439
- color: "dim" | "toolDiffAdded" | "toolDiffRemoved",
1440
- text: string,
1441
- rowBg: string | undefined,
1442
- ): string {
1443
- let themedText: string;
1444
- try {
1445
- themedText = theme.fg(color, text);
1446
- } catch {
1447
- themedText = text;
1448
- }
1449
-
1450
- if (!rowBg) {
1451
- return themedText;
1452
- }
1453
-
1454
- const stableText = keepBackgroundAcrossResets(themedText, rowBg);
1455
- return `${rowBg}${stableText}${rowBg}`;
1456
- }
1457
-
1458
- function keepBackgroundAcrossResets(text: string, rowBg: string): string {
1459
- if (!text) {
1460
- return text;
1461
- }
1462
-
1463
- return text.replace(ANSI_SGR_PATTERN, (sequence, rawParams: string) => {
1464
- const params = toSgrParams(rawParams);
1465
- if (params.length === 0 || !sequenceResetsBackground(params)) {
1466
- return sequence;
1467
- }
1468
- return `${sequence}${rowBg}`;
1469
- });
1470
- }
1471
-
1472
- function renderChangeMarker(
1473
- kind: DiffLineKind,
1474
- theme: DiffTheme,
1475
- rowBg: string | undefined,
1476
- indicatorMode: DiffIndicatorMode,
1477
- continuation = false,
1478
- ): string {
1479
- const glyph = resolveIndicatorGlyph(kind, indicatorMode, continuation);
1480
- if (glyph === " ") {
1481
- return rowBg ? `${rowBg} ${rowBg}` : " ";
1482
- }
1483
- if (kind === "add") {
1484
- return colorizeSegment(theme, "toolDiffAdded", glyph, rowBg);
1485
- }
1486
- if (kind === "remove") {
1487
- return colorizeSegment(theme, "toolDiffRemoved", glyph, rowBg);
1488
- }
1489
- return colorizeSegment(theme, "dim", glyph, rowBg);
1490
- }
1491
-
1492
- function usesHashlineGutter(showHashlineAnchors: boolean): boolean {
1493
- return showHashlineAnchors;
1494
- }
1495
-
1496
- function getHashlineGutterMarkerWidth(_indicatorMode: DiffIndicatorMode): number {
1497
- return 0;
1498
- }
1499
-
1500
- function getLineDividerPlainWidth(
1501
- indicatorMode: DiffIndicatorMode,
1502
- hashlineGutter = false,
1503
- ): number {
1504
- if (hashlineGutter) {
1505
- return 2;
1506
- }
1507
- return indicatorMode === "classic" ? 1 : 2;
1508
- }
1509
-
1510
- function renderCodeDivider(
1511
- theme: DiffTheme,
1512
- rowBg: string | undefined,
1513
- indicatorMode: DiffIndicatorMode,
1514
- hashlineGutter = false,
1515
- ): string {
1516
- return colorizeSegment(
1517
- theme,
1518
- "dim",
1519
- hashlineGutter || indicatorMode !== "classic" ? "│ " : "│",
1520
- rowBg,
1521
- );
1522
- }
1523
-
1524
- function getLineNumberColor(kind: DiffLineKind): "dim" | "toolDiffAdded" | "toolDiffRemoved" {
1525
- if (kind === "add") {
1526
- return "toolDiffAdded";
1527
- }
1528
- if (kind === "remove") {
1529
- return "toolDiffRemoved";
1530
- }
1531
- return "dim";
1532
- }
1533
-
1534
- function renderLineNumberSegment(
1535
- kind: DiffLineKind,
1536
- lineNumber: string,
1537
- theme: DiffTheme,
1538
- rowBg: string | undefined,
1539
- ): string {
1540
- return colorizeSegment(theme, getLineNumberColor(kind), lineNumber, rowBg);
1541
- }
1542
-
1543
- function getLinePrefixPlainWidth(
1544
- lineNumberWidth: number,
1545
- indicatorMode: DiffIndicatorMode,
1546
- hashlineGutter = false,
1547
- ): number {
1548
- if (hashlineGutter) {
1549
- return getHashlineGutterMarkerWidth(indicatorMode) + lineNumberWidth;
1550
- }
1551
- return indicatorMode === "bars"
1552
- ? visibleWidth(`▌ ${" ".repeat(lineNumberWidth)} `)
1553
- : visibleWidth(`${" ".repeat(lineNumberWidth)} `);
1554
- }
1555
-
1556
- function getLineContentIndicatorPrefixPlainWidth(indicatorMode: DiffIndicatorMode): number {
1557
- return indicatorMode === "classic" ? 2 : 0;
1558
- }
1559
-
1560
- function renderClassicContentPrefix(
1561
- kind: DiffLineKind,
1562
- theme: DiffTheme,
1563
- rowBg: string | undefined,
1564
- continuation = false,
1565
- ): string {
1566
- if (kind === "context" || continuation) {
1567
- return rowBg ? `${rowBg} ${rowBg}` : " ";
1568
- }
1569
-
1570
- const glyph = kind === "add" ? "+" : "-";
1571
- const glyphColor = kind === "add" ? "toolDiffAdded" : "toolDiffRemoved";
1572
- const spacer = rowBg ? `${rowBg} ` : " ";
1573
- return `${colorizeSegment(theme, glyphColor, glyph, rowBg)}${spacer}`;
1574
- }
1575
-
1576
- function renderLinePrefix(
1577
- kind: DiffLineKind,
1578
- lineNumber: string,
1579
- theme: DiffTheme,
1580
- rowBg: string | undefined,
1581
- indicatorMode: DiffIndicatorMode,
1582
- continuation = false,
1583
- hashlineGutter = false,
1584
- ): string {
1585
- const number = renderLineNumberSegment(kind, lineNumber, theme, rowBg);
1586
- if (hashlineGutter) {
1587
- return number;
1588
- }
1589
- const spacer = rowBg ? `${rowBg} ` : " ";
1590
- if (indicatorMode !== "bars") {
1591
- return `${number}${spacer}`;
1592
- }
1593
- const marker = renderChangeMarker(kind, theme, rowBg, indicatorMode, continuation);
1594
- return `${marker}${spacer}${number}${spacer}`;
1595
- }
1596
-
1597
- function renderLineContinuationPrefix(
1598
- kind: DiffLineKind,
1599
- lineNumberWidth: number,
1600
- rowBg: string | undefined,
1601
- theme: DiffTheme,
1602
- indicatorMode: DiffIndicatorMode,
1603
- hashlineGutter = false,
1604
- ): string {
1605
- const blankLineNumber = " ".repeat(lineNumberWidth);
1606
- return renderLinePrefix(kind, blankLineNumber, theme, rowBg, indicatorMode, true, hashlineGutter);
1607
- }
1608
-
1609
- function renderLineContentIndicatorPrefix(
1610
- kind: DiffLineKind,
1611
- theme: DiffTheme,
1612
- rowBg: string | undefined,
1613
- indicatorMode: DiffIndicatorMode,
1614
- continuation = false,
1615
- ): string {
1616
- return indicatorMode === "classic"
1617
- ? renderClassicContentPrefix(kind, theme, rowBg, continuation)
1618
- : "";
1619
- }
1620
-
1621
- function renderCompactLinePrefix(
1622
- kind: DiffLineKind,
1623
- theme: DiffTheme,
1624
- rowBg: string | undefined,
1625
- indicatorMode: DiffIndicatorMode,
1626
- continuation = false,
1627
- ): string {
1628
- const marker = renderChangeMarker(kind, theme, rowBg, indicatorMode, continuation);
1629
- const spacer = rowBg ? `${rowBg} ` : " ";
1630
- return `${marker}${spacer}`;
1631
- }
1632
-
1633
- interface LineCellRenderParams {
1634
- kind: DiffLineKind;
1635
- code: string;
1636
- width: number;
1637
- rowBg: string | undefined;
1638
- restoreBgAnsi: string | undefined;
1639
- theme: DiffTheme;
1640
- wordWrap: boolean;
1641
- indicatorMode: DiffIndicatorMode;
1642
- }
1643
-
1644
- function renderWrappedRowsWithOptionalBackground(
1645
- wrappedCodeLines: string[],
1646
- buildRow: (index: number, wrappedCodeLine: string) => string,
1647
- width: number,
1648
- rowBg: string | undefined,
1649
- restoreBgAnsi: string | undefined,
1650
- ): string[] {
1651
- if (!rowBg) {
1652
- return wrappedCodeLines.map((wrappedCodeLine, index) =>
1653
- stabilizeBackgroundResets(buildRow(index, wrappedCodeLine)),
1654
- );
1655
- }
1656
- const safeRestoreBgAnsi = restoreBgAnsi ?? rowBg ?? ANSI_BG_RESET;
1657
- const visualRows = wrappedCodeLines.map((wrappedCodeLine, index) =>
1658
- buildRow(index, wrappedCodeLine),
1659
- );
1660
- return applyLineBackgroundToWrappedRows(visualRows, width, rowBg, safeRestoreBgAnsi);
1661
- }
1662
-
1663
- function computeLineCellCodeWidth(
1664
- width: number,
1665
- lineNumberWidth: number,
1666
- indicatorMode: DiffIndicatorMode,
1667
- hashlineGutter: boolean,
1668
- ): number {
1669
- const prefixPlainWidth = getLinePrefixPlainWidth(lineNumberWidth, indicatorMode, hashlineGutter);
1670
- const dividerPlainWidth = getLineDividerPlainWidth(indicatorMode, hashlineGutter);
1671
- const contentIndicatorWidth = hashlineGutter
1672
- ? 0
1673
- : getLineContentIndicatorPrefixPlainWidth(indicatorMode);
1674
- return Math.max(0, width - prefixPlainWidth - dividerPlainWidth - contentIndicatorWidth);
1675
- }
1676
-
1677
- function buildLineCellParams(
1678
- kind: DiffLineKind,
1679
- code: string,
1680
- width: number,
1681
- rowBg: string | undefined,
1682
- restoreBgAnsi: string | undefined,
1683
- theme: DiffTheme,
1684
- wordWrap: boolean,
1685
- indicatorMode: DiffIndicatorMode,
1686
- ): LineCellRenderParams {
1687
- return { kind, code, width, rowBg, restoreBgAnsi, theme, wordWrap, indicatorMode };
1688
- }
1689
-
1690
- function renderCompactLineCell({
1691
- kind,
1692
- code,
1693
- width,
1694
- rowBg,
1695
- restoreBgAnsi,
1696
- theme,
1697
- wordWrap,
1698
- indicatorMode,
1699
- }: LineCellRenderParams): string[] {
1700
- if (width <= 0) {
1701
- return [""];
1702
- }
1703
-
1704
- const prefix = renderCompactLinePrefix(kind, theme, undefined, indicatorMode);
1705
- const continuationPrefix = renderCompactLinePrefix(kind, theme, undefined, indicatorMode, true);
1706
- const prefixPlainWidth = 2;
1707
- const codeWidth = Math.max(0, width - prefixPlainWidth);
1708
- const wrappedCodeLines = wrapToWidth(code, codeWidth, wordWrap);
1709
- return renderWrappedRowsWithOptionalBackground(
1710
- wrappedCodeLines,
1711
- (index, line) => `${index === 0 ? prefix : continuationPrefix}${line}`,
1712
- width,
1713
- rowBg,
1714
- restoreBgAnsi,
1715
- );
1716
- }
1717
-
1718
- function renderLineCell(
1719
- { kind, code, width, rowBg, restoreBgAnsi, theme, wordWrap, indicatorMode }: LineCellRenderParams,
1720
- lineNumber: string,
1721
- hashlineGutter = false,
1722
- ): string[] {
1723
- if (width <= 0) {
1724
- return [""];
1725
- }
1726
-
1727
- const codeWidth = computeLineCellCodeWidth(
1728
- width,
1729
- lineNumber.length,
1730
- indicatorMode,
1731
- hashlineGutter,
1732
- );
1733
- const prefix = renderLinePrefix(
1734
- kind,
1735
- lineNumber,
1736
- theme,
1737
- undefined,
1738
- indicatorMode,
1739
- false,
1740
- hashlineGutter,
1741
- );
1742
- const continuationPrefix = renderLineContinuationPrefix(
1743
- kind,
1744
- lineNumber.length,
1745
- undefined,
1746
- theme,
1747
- indicatorMode,
1748
- hashlineGutter,
1749
- );
1750
- const divider = renderCodeDivider(theme, undefined, indicatorMode, hashlineGutter);
1751
- const firstContentPrefix = hashlineGutter
1752
- ? ""
1753
- : renderLineContentIndicatorPrefix(kind, theme, undefined, indicatorMode);
1754
- const continuationContentPrefix = hashlineGutter
1755
- ? ""
1756
- : renderLineContentIndicatorPrefix(kind, theme, undefined, indicatorMode, true);
1757
- const wrappedCodeLines = wrapToWidth(code, codeWidth, wordWrap);
1758
- return renderWrappedRowsWithOptionalBackground(
1759
- wrappedCodeLines,
1760
- (index, line) => {
1761
- const linePrefix = index === 0 ? prefix : continuationPrefix;
1762
- const contentPrefix = index === 0 ? firstContentPrefix : continuationContentPrefix;
1763
- return `${linePrefix}${divider}${contentPrefix}${line}`;
1764
- },
1765
- width,
1766
- rowBg,
1767
- restoreBgAnsi,
1768
- );
1769
- }
1770
-
1771
- function highlightDiffLine(
1772
- codeText: string,
1773
- entry: DiffLineEntry,
1774
- inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
1775
- palette: DiffPalette,
1776
- highlightLine: CodeLineHighlighter,
1777
- containerBgAnsi: string | undefined,
1778
- ): { highlighted: string; rowBg: string | undefined } {
1779
- const syntaxHighlighted = highlightLine(codeText, entry);
1780
- const rowBg = getLineBackground(entry.lineKind, palette, false);
1781
- const emphasisBg = getLineBackground(entry.lineKind, palette, true);
1782
- const inlineSpans = inlineHighlights.get(entry) ?? [];
1783
- const highlighted = applyInlineSpanHighlight(
1784
- codeText,
1785
- syntaxHighlighted,
1786
- inlineSpans,
1787
- emphasisBg,
1788
- rowBg,
1789
- containerBgAnsi,
1790
- );
1791
- return { highlighted, rowBg };
1792
- }
1793
-
1794
- function pushDiffLineRows(rows: RenderedRow[], lines: string[], entry: DiffLineEntry): void {
1795
- rows.push(
1796
- ...lines.map((text) => ({
1797
- text,
1798
- hunkIndex: entry.hunkIndex || null,
1799
- })),
1800
- );
1801
- }
1802
-
1803
- interface DiffRenderContext {
1804
- width: number;
1805
- theme: DiffTheme;
1806
- inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>;
1807
- palette: DiffPalette;
1808
- highlightLine: CodeLineHighlighter;
1809
- containerBgAnsi: string | undefined;
1810
- wordWrap: boolean;
1811
- indicatorMode: DiffIndicatorMode;
1812
- showHashlineAnchors: boolean;
1813
- }
1814
-
1815
- const OMISSION_GLYPH = "⋮";
1816
-
1817
- function createOmissionRenderLine(entry: DiffOmissionEntry, compact = false): DiffLineEntry {
1818
- return {
1819
- kind: "line",
1820
- lineKind: "context",
1821
- oldLineNumber: null,
1822
- newLineNumber: null,
1823
- fallbackLineNumber: compact ? "" : OMISSION_GLYPH,
1824
- content: compact ? OMISSION_GLYPH : "",
1825
- raw: entry.raw,
1826
- hunkIndex: entry.hunkIndex,
1827
- };
1828
- }
1829
-
1830
- function materializeOmissionEntries(
1831
- entries: ParsedDiffEntry[],
1832
- compact = false,
1833
- ): (DiffLineEntry | DiffMetaEntry)[] {
1834
- return entries.map((entry) =>
1835
- entry.kind === "omission" ? createOmissionRenderLine(entry, compact) : entry,
1836
- );
1837
- }
1838
-
1839
- function omitTerminalOmissions(entries: ParsedDiffEntry[]): ParsedDiffEntry[] {
1840
- let lastLineIndex = -1;
1841
- for (let index = entries.length - 1; index >= 0; index--) {
1842
- if (entries[index]?.kind === "line") {
1843
- lastLineIndex = index;
1844
- break;
1845
- }
1846
- }
1847
-
1848
- return entries.filter((entry, index) => entry.kind !== "omission" || index < lastLineIndex);
1849
- }
1850
-
1851
- function processDiffEntries(
1852
- entries: (DiffLineEntry | DiffMetaEntry)[],
1853
- ctx: DiffRenderContext,
1854
- processLine: (entry: DiffLineEntry) => string[],
1855
- ): RenderedRow[] {
1856
- const { width, theme, wordWrap } = ctx;
1857
- const rows: RenderedRow[] = [];
1858
- for (const entry of entries) {
1859
- if (entry.kind !== "line") {
1860
- rows.push(...formatMetaEntryRows(entry, width, theme, wordWrap));
1861
- continue;
1862
- }
1863
- pushDiffLineRows(rows, processLine(entry), entry);
1864
- }
1865
- return rows;
1866
- }
1867
-
1868
- function renderUnified(
1869
- entries: ParsedDiffEntry[],
1870
- ctx: DiffRenderContext,
1871
- lineNumberWidth: number,
1872
- ): RenderedRow[] {
1873
- return processDiffEntries(materializeOmissionEntries(entries), ctx, (entry) => {
1874
- const lineNumber =
1875
- entry.lineKind === "add"
1876
- ? formatLineNumberLabel(
1877
- entry,
1878
- entry.newLineNumber,
1879
- entry.fallbackLineNumber,
1880
- lineNumberWidth,
1881
- ctx.showHashlineAnchors,
1882
- )
1883
- : formatLineNumberLabel(
1884
- entry,
1885
- entry.oldLineNumber,
1886
- entry.fallbackLineNumber,
1887
- lineNumberWidth,
1888
- ctx.showHashlineAnchors,
1889
- );
1890
- const codeText = normalizeCodeWhitespace(entry.content);
1891
- const { highlighted, rowBg } = highlightDiffLine(
1892
- codeText,
1893
- entry,
1894
- ctx.inlineHighlights,
1895
- ctx.palette,
1896
- ctx.highlightLine,
1897
- ctx.containerBgAnsi,
1898
- );
1899
- return renderLineCell(
1900
- buildLineCellParams(
1901
- entry.lineKind,
1902
- highlighted,
1903
- ctx.width,
1904
- rowBg,
1905
- ctx.containerBgAnsi,
1906
- ctx.theme,
1907
- ctx.wordWrap,
1908
- ctx.indicatorMode,
1909
- ),
1910
- lineNumber,
1911
- usesHashlineGutter(ctx.showHashlineAnchors),
1912
- );
1913
- });
1914
- }
1915
-
1916
- function toUnifiedFallbackRows(
1917
- rows: SplitDiffRow[],
1918
- ctx: DiffRenderContext,
1919
- lineNumberWidth: number,
1920
- ): RenderedRow[] {
1921
- const flattened: ParsedDiffEntry[] = [];
1922
- for (const row of rows) {
1923
- if (row.omission) {
1924
- flattened.push(row.omission);
1925
- continue;
1926
- }
1927
- if (row.meta) {
1928
- flattened.push(row.meta);
1929
- continue;
1930
- }
1931
- if (row.left) {
1932
- flattened.push(row.left);
1933
- }
1934
- if (row.right && row.right !== row.left) {
1935
- flattened.push(row.right);
1936
- }
1937
- }
1938
- return renderUnified(flattened, ctx, lineNumberWidth);
1939
- }
1940
-
1941
- function renderCompact(entries: ParsedDiffEntry[], ctx: DiffRenderContext): RenderedRow[] {
1942
- return processDiffEntries(materializeOmissionEntries(entries, true), ctx, (entry) => {
1943
- const codeText = normalizeCodeWhitespace(
1944
- getCompactLineRenderContent(entry, ctx.showHashlineAnchors),
1945
- );
1946
- const { highlighted, rowBg } = highlightDiffLine(
1947
- codeText,
1948
- entry,
1949
- ctx.inlineHighlights,
1950
- ctx.palette,
1951
- ctx.highlightLine,
1952
- ctx.containerBgAnsi,
1953
- );
1954
- return renderCompactLineCell(
1955
- buildLineCellParams(
1956
- entry.lineKind,
1957
- highlighted,
1958
- ctx.width,
1959
- rowBg,
1960
- ctx.containerBgAnsi,
1961
- ctx.theme,
1962
- ctx.wordWrap,
1963
- ctx.indicatorMode,
1964
- ),
1965
- );
1966
- });
1967
- }
1968
-
1969
- function renderSplitBlankCell(
1970
- columnWidth: number,
1971
- lineNumberWidth: number,
1972
- theme: DiffTheme,
1973
- indicatorMode: DiffIndicatorMode,
1974
- hashlineGutter = false,
1975
- ): string {
1976
- const codeWidth = computeLineCellCodeWidth(
1977
- columnWidth,
1978
- lineNumberWidth,
1979
- indicatorMode,
1980
- hashlineGutter,
1981
- );
1982
- const prefix = renderLinePrefix(
1983
- "context",
1984
- " ".repeat(lineNumberWidth),
1985
- theme,
1986
- undefined,
1987
- indicatorMode,
1988
- true,
1989
- hashlineGutter,
1990
- );
1991
- const divider = renderCodeDivider(theme, undefined, indicatorMode, hashlineGutter);
1992
- const contentPrefix = hashlineGutter
1993
- ? ""
1994
- : renderLineContentIndicatorPrefix("context", theme, undefined, indicatorMode, true);
1995
- return stabilizeBackgroundResets(`${prefix}${divider}${contentPrefix}${" ".repeat(codeWidth)}`);
1996
- }
1997
-
1998
- function renderSplitCell(
1999
- line: DiffLineEntry | undefined,
2000
- side: "left" | "right",
2001
- columnWidth: number,
2002
- lineNumberWidth: number,
2003
- theme: DiffTheme,
2004
- inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
2005
- palette: DiffPalette,
2006
- highlightLine: CodeLineHighlighter,
2007
- containerBgAnsi: string | undefined,
2008
- wordWrap: boolean,
2009
- indicatorMode: DiffIndicatorMode,
2010
- showHashlineAnchors: boolean,
2011
- ): string[] {
2012
- const hashlineGutter = usesHashlineGutter(showHashlineAnchors);
2013
- if (!line) {
2014
- return [
2015
- renderSplitBlankCell(columnWidth, lineNumberWidth, theme, indicatorMode, hashlineGutter),
2016
- ];
2017
- }
2018
-
2019
- const lineNumber = formatLineNumberLabel(
2020
- line,
2021
- getCellLineNumber(line, side),
2022
- line.fallbackLineNumber,
2023
- lineNumberWidth,
2024
- showHashlineAnchors,
2025
- );
2026
- const codeText = normalizeCodeWhitespace(line.content);
2027
- const { highlighted, rowBg } = highlightDiffLine(
2028
- codeText,
2029
- line,
2030
- inlineHighlights,
2031
- palette,
2032
- highlightLine,
2033
- containerBgAnsi,
2034
- );
2035
- return renderLineCell(
2036
- buildLineCellParams(
2037
- line.lineKind,
2038
- highlighted,
2039
- columnWidth,
2040
- rowBg,
2041
- containerBgAnsi,
2042
- theme,
2043
- wordWrap,
2044
- indicatorMode,
2045
- ),
2046
- lineNumber,
2047
- hashlineGutter,
2048
- );
2049
- }
2050
-
2051
- function renderSplitDivider(
2052
- theme: DiffTheme,
2053
- containerBgAnsi: string | undefined,
2054
- separatorText: string = SPLIT_SEPARATOR,
2055
- ): string {
2056
- const dimAnsi = readThemeAnsi(theme, "fg", "dim");
2057
- if (!containerBgAnsi) {
2058
- return stabilizeBackgroundResets(theme.fg("dim", separatorText));
2059
- }
2060
- if (!dimAnsi) {
2061
- return stabilizeBackgroundResets(
2062
- `${containerBgAnsi}${theme.fg("dim", separatorText)}${containerBgAnsi}`,
2063
- );
2064
- }
2065
- return stabilizeBackgroundResets(
2066
- `${containerBgAnsi}${dimAnsi}${separatorText}\x1b[39m${containerBgAnsi}`,
2067
- );
2068
- }
2069
-
2070
- function renderSplitTopBorderCell(
2071
- columnWidth: number,
2072
- lineNumberWidth: number,
2073
- theme: DiffTheme,
2074
- indicatorMode: DiffIndicatorMode,
2075
- hashlineGutter = false,
2076
- ): string {
2077
- const safeColumnWidth = Math.max(1, columnWidth);
2078
- const chars = "─".repeat(safeColumnWidth).split("");
2079
- const dividerIndex = getLinePrefixPlainWidth(lineNumberWidth, indicatorMode, hashlineGutter);
2080
- if (dividerIndex >= 0 && dividerIndex < chars.length) {
2081
- chars[dividerIndex] = "┬";
2082
- }
2083
- return stabilizeBackgroundResets(theme.fg("dim", chars.join("")));
2084
- }
2085
-
2086
- function renderSplitHeaderCell(
2087
- label: string,
2088
- columnWidth: number,
2089
- lineNumberWidth: number,
2090
- theme: DiffTheme,
2091
- indicatorMode: DiffIndicatorMode,
2092
- hashlineGutter = false,
2093
- ): string {
2094
- const markerPad = hashlineGutter
2095
- ? " ".repeat(getHashlineGutterMarkerWidth(indicatorMode))
2096
- : indicatorMode === "bars"
2097
- ? " "
2098
- : "";
2099
- const lineNumberLabel = fitToWidth(label, lineNumberWidth);
2100
- const lineNumberSpacer = hashlineGutter ? "" : " ";
2101
- const divider = hashlineGutter || indicatorMode !== "classic" ? "│ " : "│";
2102
- const prefix = `${theme.fg("dim", markerPad)}${theme.fg("muted", lineNumberLabel)}${theme.fg("dim", lineNumberSpacer)}${theme.fg("dim", divider)}`;
2103
- const prefixWidth = visibleWidth(`${markerPad}${lineNumberLabel}${lineNumberSpacer}${divider}`);
2104
- const contentIndicatorWidth = hashlineGutter
2105
- ? 0
2106
- : getLineContentIndicatorPrefixPlainWidth(indicatorMode);
2107
- const codeWidth = Math.max(0, columnWidth - prefixWidth - contentIndicatorWidth);
2108
- const contentPad = !hashlineGutter && indicatorMode === "classic" ? " " : "";
2109
- return stabilizeBackgroundResets(`${prefix}${contentPad}${" ".repeat(codeWidth)}`);
2110
- }
2111
-
2112
- function canRenderSplitLayout(width: number): boolean {
2113
- const separatorWidth = visibleWidth(SPLIT_SEPARATOR);
2114
- const minimumSplitWidth = MIN_SPLIT_COLUMN_WIDTH * 2 + separatorWidth;
2115
- return width >= minimumSplitWidth;
2116
- }
2117
-
2118
- function renderSplit(
2119
- rows: SplitDiffRow[],
2120
- ctx: DiffRenderContext,
2121
- lineNumberWidth: number,
2122
- ): RenderedRow[] {
2123
- const {
2124
- width,
2125
- theme,
2126
- inlineHighlights,
2127
- palette,
2128
- highlightLine,
2129
- containerBgAnsi,
2130
- wordWrap,
2131
- indicatorMode,
2132
- showHashlineAnchors,
2133
- } = ctx;
2134
- if (!canRenderSplitLayout(width)) {
2135
- return toUnifiedFallbackRows(rows, ctx, lineNumberWidth);
2136
- }
2137
-
2138
- const separatorWidth = visibleWidth(SPLIT_SEPARATOR);
2139
- const leftWidth = Math.max(MIN_SPLIT_COLUMN_WIDTH, Math.floor((width - separatorWidth) / 2));
2140
- const rightWidth = Math.max(MIN_SPLIT_COLUMN_WIDTH, width - separatorWidth - leftWidth);
2141
- const splitLineNumberWidth = Math.max(3, lineNumberWidth);
2142
- const hashlineGutter = usesHashlineGutter(showHashlineAnchors);
2143
- const separator = renderSplitDivider(theme, containerBgAnsi);
2144
- const topSeparator = renderSplitDivider(theme, containerBgAnsi, "─┬─");
2145
- const output: RenderedRow[] = [];
2146
- output.push({
2147
- text: `${renderSplitTopBorderCell(leftWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}${topSeparator}${renderSplitTopBorderCell(rightWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}`,
2148
- hunkIndex: null,
2149
- });
2150
- output.push({
2151
- text: `${renderSplitHeaderCell("old", leftWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}${separator}${renderSplitHeaderCell("new", rightWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}`,
2152
- hunkIndex: null,
2153
- });
2154
-
2155
- for (const row of rows) {
2156
- if (row.meta) {
2157
- output.push(...formatMetaEntryRows(row.meta, width, theme, wordWrap));
2158
- continue;
2159
- }
2160
-
2161
- const omissionLine = row.omission ? createOmissionRenderLine(row.omission) : undefined;
2162
- const leftCells = renderSplitCell(
2163
- omissionLine ?? row.left,
2164
- "left",
2165
- leftWidth,
2166
- splitLineNumberWidth,
2167
- theme,
2168
- inlineHighlights,
2169
- palette,
2170
- highlightLine,
2171
- containerBgAnsi,
2172
- wordWrap,
2173
- indicatorMode,
2174
- showHashlineAnchors,
2175
- );
2176
- const rightCells = renderSplitCell(
2177
- omissionLine ?? row.right,
2178
- "right",
2179
- rightWidth,
2180
- splitLineNumberWidth,
2181
- theme,
2182
- inlineHighlights,
2183
- palette,
2184
- highlightLine,
2185
- containerBgAnsi,
2186
- wordWrap,
2187
- indicatorMode,
2188
- showHashlineAnchors,
2189
- );
2190
-
2191
- const rowCount = Math.max(leftCells.length, rightCells.length);
2192
- for (let index = 0; index < rowCount; index++) {
2193
- const leftCell =
2194
- leftCells[index] ??
2195
- renderSplitBlankCell(leftWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter);
2196
- const rightCell =
2197
- rightCells[index] ??
2198
- renderSplitBlankCell(
2199
- rightWidth,
2200
- splitLineNumberWidth,
2201
- theme,
2202
- indicatorMode,
2203
- hashlineGutter,
2204
- );
2205
- output.push({ text: `${leftCell}${separator}${rightCell}`, hunkIndex: row.hunkIndex });
2206
- }
2207
- }
2208
-
2209
- return output;
2210
- }
2211
-
2212
- function renderDiffStatBar(stats: DiffStats, width: number, theme: DiffTheme): string | null {
2213
- const totalChanges = stats.added + stats.removed;
2214
- if (totalChanges === 0 || width < 20) {
2215
- return null;
2216
- }
2217
-
2218
- const barSlots = Math.max(8, Math.min(24, Math.floor(width / 12)));
2219
- let addedSlots = Math.max(
2220
- 0,
2221
- Math.min(barSlots, Math.round((stats.added / totalChanges) * barSlots)),
2222
- );
2223
- if (stats.added > 0 && addedSlots === 0) {
2224
- addedSlots = 1;
2225
- }
2226
- if (stats.removed > 0 && addedSlots >= barSlots) {
2227
- addedSlots = barSlots - 1;
2228
- }
2229
- const removedSlots = Math.max(0, barSlots - addedSlots);
2230
-
2231
- const addedBar = addedSlots > 0 ? theme.fg("toolDiffAdded", "━".repeat(addedSlots)) : "";
2232
- const removedBar = removedSlots > 0 ? theme.fg("toolDiffRemoved", "━".repeat(removedSlots)) : "";
2233
- return stabilizeBackgroundResets(
2234
- `${theme.fg("dim", "[")}${addedBar}${removedBar}${theme.fg("dim", "]")}`,
2235
- );
2236
- }
2237
-
2238
- function buildDiffSummaryBasePieces(stats: DiffStats, theme: DiffTheme): string[] {
2239
- return [
2240
- theme.fg("toolOutput", `↳ ${emphasis(theme, "diff")}`),
2241
- theme.fg("toolDiffAdded", `+${stats.added}`),
2242
- theme.fg("toolDiffRemoved", `-${stats.removed}`),
2243
- ];
2244
- }
2245
-
2246
- function renderHeaderRows(
2247
- stats: DiffStats,
2248
- mode: Exclude<DiffPresentationMode, "summary">,
2249
- width: number,
2250
- theme: DiffTheme,
2251
- ): RenderedRow[] {
2252
- if (mode === "compact") {
2253
- const summary = buildDiffSummaryBasePieces(stats, theme).join(" ");
2254
- return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
2255
- }
2256
-
2257
- const summaryPieces = [...buildDiffSummaryBasePieces(stats, theme), theme.fg("muted", mode)];
2258
-
2259
- const summary = summaryPieces.join(mode === "split" ? " " : theme.fg("muted", " • "));
2260
- const meter = renderDiffStatBar(stats, width, theme);
2261
- if (!meter) {
2262
- return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
2263
- }
2264
-
2265
- const meterSeparator = " ";
2266
- const meterWidth = visibleWidth(meterSeparator) + visibleWidth(meter);
2267
- if (meterWidth >= width) {
2268
- return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
2269
- }
2270
-
2271
- const summaryWidth = Math.max(0, width - meterWidth);
2272
- const fittedSummary = truncateToWidth(summary, summaryWidth);
2273
- return [
2274
- {
2275
- text: stabilizeBackgroundResets(`${fittedSummary}${meterSeparator}${meter}`),
2276
- hunkIndex: null,
2277
- },
2278
- ];
2279
- }
2280
-
2281
- function renderDiffFrameLine(width: number, theme: DiffTheme): string {
2282
- const frameWidth = Math.max(0, width);
2283
- if (frameWidth === 0) {
2284
- return "";
2285
- }
2286
- return stabilizeBackgroundResets(theme.fg("dim", "─".repeat(frameWidth)));
2287
- }
2288
-
2289
- function renderDiffSpacerLine(width: number): string {
2290
- const safeWidth = Math.max(0, width);
2291
- return safeWidth > 0 ? " ".repeat(safeWidth) : "";
2292
- }
2293
-
2294
- function resolveDiffDisplayLimit(
2295
- expanded: boolean,
2296
- maxCollapsedLines: number,
2297
- maxExpandedLines: number,
2298
- ): number {
2299
- const expandedLimit = Number.isFinite(maxExpandedLines)
2300
- ? maxExpandedLines
2301
- : DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
2302
- const collapsedLimit = Number.isFinite(maxCollapsedLines)
2303
- ? maxCollapsedLines
2304
- : DEFAULT_TOOL_DISPLAY_CONFIG.editDiffCollapsedLines;
2305
- return expanded ? Math.max(0, expandedLimit) : Math.max(1, collapsedLimit);
2306
- }
2307
-
2308
- /** Write collapsed body limit. Missing/NaN falls back to edit's limit. */
2309
- function resolveWriteCollapsedLimit(config: ToolDisplayConfig): number {
2310
- const value = config.writeDiffCollapsedLines;
2311
- if (Number.isFinite(value)) {
2312
- return Math.max(0, value);
2313
- }
2314
- const fallback = Number.isFinite(config.editDiffCollapsedLines)
2315
- ? config.editDiffCollapsedLines
2316
- : DEFAULT_TOOL_DISPLAY_CONFIG.writeDiffCollapsedLines;
2317
- return Math.max(0, fallback);
2318
- }
2319
-
2320
1
  /**
2321
- * How many source rows/entries to fully highlight+render before applyLineLimit.
2322
- * Extra headroom covers word-wrap expansion so the display limit can still fill.
2
+ * Public entry surface for the diff renderer.
3
+ *
4
+ * Implementation is split across focused modules by concern:
5
+ * - diff-parse.ts : diff string → line/meta/hunk/file entries + stats
6
+ * - diff-inline.ts : inline token diff (LCS spans) + split-row pairing
7
+ * - diff-palette.ts : theme/color/SGR (row backgrounds, inline emphasis)
8
+ * - diff-highlight.ts : syntax highlighting (sync + shiki)
9
+ * - diff-layout.ts : unified / split / compact layout
10
+ * - diff-header.ts : file/hunk headers + stat/summary/frame lines
11
+ * - diff-limits.ts : collapse/expand limits + width clamping
12
+ * - diff-component.ts : shared component cache / live-config plumbing
13
+ * - diff-edit-render.ts : edit diff result rendering + stats
14
+ * - diff-write-render.ts : write diff result rendering + stats
15
+ *
16
+ * This file re-exports the stable public symbols so external importers
17
+ * (diff/index.ts, compact-mode.ts, tests) keep working unchanged.
2323
18
  */
2324
- function resolveDiffProcessBudget(displayLimit: number, wordWrap: boolean): number {
2325
- if (!Number.isFinite(displayLimit) || displayLimit <= 0) {
2326
- return Number.POSITIVE_INFINITY;
2327
- }
2328
- return wordWrap ? displayLimit * 2 + 8 : displayLimit + 4;
2329
- }
2330
-
2331
- function countDiffLineEntries(entries: readonly ParsedDiffEntry[]): number {
2332
- let count = 0;
2333
- for (const entry of entries) {
2334
- if (entry.kind === "line") count++;
2335
- }
2336
- return count;
2337
- }
2338
-
2339
- function takeEntriesForLineBudget(
2340
- entries: ParsedDiffEntry[],
2341
- maxLineEntries: number,
2342
- ): { entries: ParsedDiffEntry[]; totalLineEntries: number; processedLineEntries: number } {
2343
- const totalLineEntries = countDiffLineEntries(entries);
2344
- if (!Number.isFinite(maxLineEntries) || maxLineEntries >= totalLineEntries) {
2345
- return { entries, totalLineEntries, processedLineEntries: totalLineEntries };
2346
- }
2347
- const limited: ParsedDiffEntry[] = [];
2348
- let processedLineEntries = 0;
2349
- for (const entry of entries) {
2350
- if (entry.kind === "line") {
2351
- if (processedLineEntries >= maxLineEntries) break;
2352
- processedLineEntries++;
2353
- }
2354
- limited.push(entry);
2355
- }
2356
- return { entries: limited, totalLineEntries, processedLineEntries };
2357
- }
2358
-
2359
- function takeSplitRowsForBudget(
2360
- rows: SplitDiffRow[],
2361
- maxRows: number,
2362
- ): { rows: SplitDiffRow[]; totalRows: number; processedRows: number } {
2363
- const totalRows = rows.length;
2364
- if (!Number.isFinite(maxRows) || maxRows >= totalRows) {
2365
- return { rows, totalRows, processedRows: totalRows };
2366
- }
2367
- const limited = rows.slice(0, maxRows);
2368
- return { rows: limited, totalRows, processedRows: limited.length };
2369
- }
2370
-
2371
- function applyLineLimit(
2372
- rows: RenderedRow[],
2373
- width: number,
2374
- expanded: boolean,
2375
- maxCollapsedLines: number,
2376
- maxExpandedLines: number,
2377
- totalHunks: number,
2378
- theme: DiffTheme,
2379
- /**
2380
- * When the caller only rendered a prefix of the full diff, pass the unprocessed
2381
- * remainder so the collapse hint still reflects total hidden content.
2382
- */
2383
- unprocessedLogicalRows = 0,
2384
- hovered = false,
2385
- ): string[] {
2386
- const limit = resolveDiffDisplayLimit(expanded, maxCollapsedLines, maxExpandedLines);
2387
- const safeUnprocessed = Math.max(0, unprocessedLogicalRows);
2388
- if (limit === 0 || (rows.length <= limit && safeUnprocessed === 0)) {
2389
- return rows.map((row) => clampDiffLineToWidth(row.text, width));
2390
- }
2391
-
2392
- const shown = rows.length <= limit ? rows : rows.slice(0, limit);
2393
- const remaining = Math.max(0, rows.length - shown.length) + safeUnprocessed;
2394
- if (remaining === 0) {
2395
- return shown.map((row) => clampDiffLineToWidth(row.text, width));
2396
- }
2397
- const visibleHunks = new Set(
2398
- shown
2399
- .map((row) => row.hunkIndex)
2400
- .filter((hunkIndex): hunkIndex is number => typeof hunkIndex === "number" && hunkIndex > 0),
2401
- );
2402
- const hiddenHunks = Math.max(0, totalHunks - visibleHunks.size);
2403
- const hintText = buildCollapsedDiffHintText(
2404
- {
2405
- remainingLines: remaining,
2406
- hiddenHunks,
2407
- },
2408
- width,
2409
- DIFF_WIDTH_OPS,
2410
- );
2411
-
2412
- const clickLabel = showMoreHintText();
2413
- const clickIndex = hintText.lastIndexOf(clickLabel);
2414
- const styledHint =
2415
- hovered && !expanded && clickIndex >= 0
2416
- ? theme.fg("muted", hintText.slice(0, clickIndex)) +
2417
- theme.fg("text", clickLabel) +
2418
- theme.fg("muted", hintText.slice(clickIndex + clickLabel.length))
2419
- : theme.fg(expanded ? "warning" : "muted", hintText);
2420
- return [
2421
- ...shown.map((row) => clampDiffLineToWidth(row.text, width)),
2422
- renderDiffSpacerLine(width),
2423
- clampDiffLineToWidth(styledHint, width),
2424
- ];
2425
- }
2426
-
2427
- function collectDiffStats(
2428
- entries: ParsedDiffEntry[],
2429
- fallbackHunks = 0,
2430
- fallbackFiles = 0,
2431
- ): DiffStats {
2432
- const stats: DiffStats = {
2433
- added: 0,
2434
- removed: 0,
2435
- context: 0,
2436
- hunks: fallbackHunks,
2437
- files: fallbackFiles,
2438
- lines: entries.length,
2439
- };
2440
-
2441
- const hunkIndexes = new Set<number>();
2442
- let explicitFileCount = 0;
2443
-
2444
- for (const entry of entries) {
2445
- if (entry.kind === "line") {
2446
- if (entry.lineKind === "add") {
2447
- stats.added++;
2448
- } else if (entry.lineKind === "remove") {
2449
- stats.removed++;
2450
- } else {
2451
- stats.context++;
2452
- }
2453
- if (entry.hunkIndex > 0) {
2454
- hunkIndexes.add(entry.hunkIndex);
2455
- }
2456
- continue;
2457
- }
2458
-
2459
- if (entry.kind === "hunk" && entry.hunkIndex > 0) {
2460
- hunkIndexes.add(entry.hunkIndex);
2461
- }
2462
- if (entry.kind === "file") {
2463
- explicitFileCount++;
2464
- }
2465
- }
2466
-
2467
- if (hunkIndexes.size > 0) {
2468
- stats.hunks = Math.max(stats.hunks, hunkIndexes.size);
2469
- }
2470
- if (explicitFileCount > 0) {
2471
- stats.files = Math.max(stats.files, explicitFileCount);
2472
- } else if (entries.length > 0) {
2473
- stats.files = Math.max(stats.files, 1);
2474
- }
2475
- if (stats.hunks === 0 && entries.some((entry) => entry.kind === "line")) {
2476
- stats.hunks = 1;
2477
- }
2478
-
2479
- return stats;
2480
- }
2481
-
2482
- function renderSingleDiffRow(
2483
- text: string,
2484
- color: string,
2485
- width: number,
2486
- theme: DiffTheme,
2487
- ): string[] {
2488
- if (width <= 0) {
2489
- return [""];
2490
- }
2491
- return [clampDiffLineToWidth(stabilizeBackgroundResets(theme.fg(color, text)), width)];
2492
- }
2493
-
2494
- function safeGetDiff(details: unknown): string {
2495
- if (!details || typeof details !== "object") {
2496
- return "";
2497
- }
2498
- const typed = details as Partial<EditToolDetails>;
2499
- return typeof typed.diff === "string" ? typed.diff : "";
2500
- }
2501
-
2502
- function createDiffRenderCache() {
2503
- let cachedWidth: number | undefined;
2504
- let cachedExpanded: boolean | undefined;
2505
- let cachedMode: DiffPresentationMode | undefined;
2506
- let cachedConfigKey: string | undefined;
2507
- let cachedHovered: boolean | undefined;
2508
- let cachedLines: string[] | undefined;
2509
-
2510
- return {
2511
- get(
2512
- width: number,
2513
- expanded: boolean,
2514
- mode: DiffPresentationMode,
2515
- configKey: string,
2516
- hovered: boolean,
2517
- ): string[] | undefined {
2518
- if (
2519
- cachedLines &&
2520
- cachedWidth === width &&
2521
- cachedExpanded === expanded &&
2522
- cachedMode === mode &&
2523
- cachedConfigKey === configKey &&
2524
- cachedHovered === hovered
2525
- ) {
2526
- return cachedLines;
2527
- }
2528
- return undefined;
2529
- },
2530
- set(
2531
- width: number,
2532
- expanded: boolean,
2533
- mode: DiffPresentationMode,
2534
- configKey: string,
2535
- hovered: boolean,
2536
- lines: string[],
2537
- ): string[] {
2538
- cachedWidth = width;
2539
- cachedExpanded = expanded;
2540
- cachedMode = mode;
2541
- cachedConfigKey = configKey;
2542
- cachedHovered = hovered;
2543
- cachedLines = lines;
2544
- return lines;
2545
- },
2546
- invalidate(): void {
2547
- cachedWidth = undefined;
2548
- cachedExpanded = undefined;
2549
- cachedMode = undefined;
2550
- cachedConfigKey = undefined;
2551
- cachedHovered = undefined;
2552
- cachedLines = undefined;
2553
- },
2554
- };
2555
- }
2556
-
2557
- /**
2558
- * edit 变更行统计(compact 单行 `(+A -D)` 用):复用现有 parser,diff 文件头不计入。
2559
- * 无 diff 或解析失败时返回 undefined,不把未知状态误报为零变更。
2560
- */
2561
- export function countEditDiffStats(
2562
- details: unknown,
2563
- ): { added: number; removed: number } | undefined {
2564
- const diffText = sanitizeToolResultText(safeGetDiff(details));
2565
- if (!diffText.trim()) return undefined;
2566
- try {
2567
- const parsed = parseDiff(diffText);
2568
- return { added: parsed.stats.added, removed: parsed.stats.removed };
2569
- } catch {
2570
- return undefined;
2571
- }
2572
- }
2573
-
2574
- /**
2575
- * write 变更行统计(compact 单行用):复用当前 write diff 算法。
2576
- * 新文件 = 全部新内容为 added;覆盖已有文件用 LCS 精确统计。
2577
- * content 非字符串、元数据缺失或超过矩阵预算时返回 undefined(不显示计数)。
2578
- */
2579
- export function countWriteDiffStats(
2580
- content: string | undefined,
2581
- previousContent: string | undefined,
2582
- fileExistedBeforeWrite: boolean | undefined,
2583
- ): { added: number; removed: number } | undefined {
2584
- if (typeof content !== "string" || fileExistedBeforeWrite === undefined) return undefined;
2585
- const lines = splitWriteContentLines(sanitizeToolResultText(content));
2586
- if (fileExistedBeforeWrite === false) return { added: lines.length, removed: 0 };
2587
- if (typeof previousContent !== "string") return undefined;
2588
- const previousLines = splitWriteContentLines(sanitizeToolResultText(previousContent));
2589
- if (previousLines.length === 0 || lines.length === 0) {
2590
- return { added: lines.length, removed: previousLines.length };
2591
- }
2592
- if (previousLines.length * lines.length > MAX_COLLAPSED_WRITE_OVERWRITE_DIFF_MATRIX_CELLS) {
2593
- return undefined;
2594
- }
2595
- let added = 0;
2596
- let removed = 0;
2597
- for (const operation of buildWriteDiffOperations(previousLines, lines)) {
2598
- if (operation.kind === "add") added++;
2599
- else if (operation.kind === "remove") removed++;
2600
- }
2601
- return { added, removed };
2602
- }
2603
-
2604
- export function renderEditDiffResult(
2605
- details: unknown,
2606
- options: DiffRenderOptions,
2607
- config: DisplayConfigInput,
2608
- theme: DiffTheme,
2609
- fallbackText: string,
2610
- ): Component {
2611
- const diffText = sanitizeToolResultText(safeGetDiff(details));
2612
- const safeFallbackText = sanitizeToolResultText(fallbackText);
2613
- if (!diffText.trim()) {
2614
- if (!safeFallbackText.trim()) {
2615
- return new Text(theme.fg("muted", "↳ edit completed (no diff payload)"), 0, 0);
2616
- }
2617
- return new Text(theme.fg("toolOutput", safeFallbackText), 0, 0);
2618
- }
2619
-
2620
- let parsed: ParsedDiff;
2621
- try {
2622
- parsed = parseDiff(diffText);
2623
- } catch (error) {
2624
- const message = error instanceof Error ? error.message : String(error);
2625
- return new Text(theme.fg("warning", `↳ unable to render diff: ${message}`), 0, 0);
2626
- }
2627
-
2628
- if (parsed.entries.length === 0) {
2629
- return new Text(theme.fg("muted", "↳ no diff data"), 0, 0);
2630
- }
2631
-
2632
- const renderEntries = omitTerminalOmissions(parsed.entries);
2633
- const splitRows = buildSplitRows(renderEntries);
2634
- const showHashlineAnchors =
2635
- options.expanded === true &&
2636
- renderEntries.some((entry) => entry.kind === "line" && !!entry.hashlineAnchorContent);
2637
- const lineNumberWidth = getLineNumberWidth(renderEntries, showHashlineAnchors);
2638
- const palette = resolveDiffPalette(theme);
2639
- // Rich diffs use ccstyle's self shell. Keep the panel transparent so the
2640
- // separator cannot leak toolSuccessBg across the entire new column.
2641
- const containerBgAnsi = undefined;
2642
- const language = resolveLanguageFromPath(options.filePath);
2643
- const cache = createDiffRenderCache();
2644
- const highlightLine = createCodeLineHighlighter(language, theme, renderEntries, () => {
2645
- cache.invalidate();
2646
- options.invalidate?.();
2647
- });
2648
-
2649
- return {
2650
- [RICH_DIFF_COMPONENT]: true,
2651
- render(width: number): string[] {
2652
- // Live config: panel can change indicator/wrap/limits after this component is created.
2653
- const live = resolveLiveDisplayConfig(config);
2654
- const wordWrap = live.diffWordWrap;
2655
- const indicatorMode = resolveDiffIndicatorMode(live);
2656
- const configKey = displayConfigCacheKey(live);
2657
- const safeWidth = normalizeDiffRenderWidth(width);
2658
- const mode = resolveDiffPresentationMode(live, safeWidth, canRenderSplitLayout(safeWidth));
2659
- const hovered = options.isHovered?.() ?? false;
2660
- const cached = cache.get(safeWidth, options.expanded, mode, configKey, hovered);
2661
- if (cached) {
2662
- return cached;
2663
- }
2664
-
2665
- if (mode === "summary") {
2666
- return cache.set(
2667
- safeWidth,
2668
- options.expanded,
2669
- mode,
2670
- configKey,
2671
- hovered,
2672
- clampDiffLinesToWidth(
2673
- renderSingleDiffRow(
2674
- buildDiffSummaryText(parsed.stats, safeWidth),
2675
- "toolOutput",
2676
- safeWidth,
2677
- theme,
2678
- ),
2679
- safeWidth,
2680
- ),
2681
- );
2682
- }
2683
-
2684
- const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
2685
- const displayLimit = resolveDiffDisplayLimit(
2686
- options.expanded,
2687
- live.editDiffCollapsedLines,
2688
- live.expandedPreviewMaxLines,
2689
- );
2690
- const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
2691
- // Only highlight/render a prefix that can fill the display limit; full-diff
2692
- // LCS + syntax highlight on thousands of hidden lines is pure waste when collapsed.
2693
- const entryBudget = takeEntriesForLineBudget(renderEntries, processBudget);
2694
- const splitBudget = takeSplitRowsForBudget(splitRows, processBudget);
2695
- const inlineHighlights = buildInlineHighlightMap(splitBudget.rows);
2696
- const renderCtx: DiffRenderContext = {
2697
- width: safeWidth,
2698
- theme,
2699
- inlineHighlights,
2700
- palette,
2701
- highlightLine,
2702
- containerBgAnsi,
2703
- wordWrap,
2704
- indicatorMode,
2705
- showHashlineAnchors,
2706
- };
2707
- const bodyRows =
2708
- mode === "split"
2709
- ? renderSplit(splitBudget.rows, renderCtx, lineNumberWidth)
2710
- : mode === "compact"
2711
- ? renderCompact(entryBudget.entries, renderCtx)
2712
- : renderUnified(entryBudget.entries, renderCtx, lineNumberWidth);
2713
- const unprocessedLogicalRows =
2714
- mode === "split"
2715
- ? Math.max(0, splitBudget.totalRows - splitBudget.processedRows)
2716
- : Math.max(0, entryBudget.totalLineEntries - entryBudget.processedLineEntries);
2717
- const bodyWithLimit = applyLineLimit(
2718
- bodyRows,
2719
- safeWidth,
2720
- options.expanded,
2721
- live.editDiffCollapsedLines,
2722
- live.expandedPreviewMaxLines,
2723
- parsed.stats.hunks,
2724
- theme,
2725
- unprocessedLogicalRows,
2726
- hovered,
2727
- );
2728
- const frame = renderDiffFrameLine(safeWidth, theme);
2729
- const renderedLines =
2730
- mode === "unified"
2731
- ? [...headerRows.map((row) => row.text), frame, ...bodyWithLimit, frame]
2732
- : [...headerRows.map((row) => row.text), ...bodyWithLimit];
2733
-
2734
- const clampedLines = clampDiffLinesToWidth(renderedLines, safeWidth);
2735
- return cache.set(safeWidth, options.expanded, mode, configKey, hovered, clampedLines);
2736
- },
2737
- invalidate: cache.invalidate,
2738
- } as Component;
2739
- }
2740
-
2741
- function renderWriteHeader(
2742
- wasOverwrite: boolean,
2743
- width: number,
2744
- theme: DiffTheme,
2745
- headerLabel?: string,
2746
- ): string {
2747
- const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
2748
- return stabilizeBackgroundResets(
2749
- truncateToWidth(theme.fg("toolOutput", `↳ ${emphasis(theme, actionLabel)}`), width),
2750
- );
2751
- }
2752
-
2753
- type WriteDiffOperationKind = "context" | "remove" | "add";
2754
-
2755
- interface WriteDiffOperation {
2756
- kind: WriteDiffOperationKind;
2757
- content: string;
2758
- }
2759
-
2760
- function buildWriteDiffOperations(oldLines: string[], newLines: string[]): WriteDiffOperation[] {
2761
- const oldLength = oldLines.length;
2762
- const newLength = newLines.length;
2763
- const table: number[][] = Array.from({ length: oldLength + 1 }, () =>
2764
- Array<number>(newLength + 1).fill(0),
2765
- );
2766
-
2767
- for (let oldIndex = 1; oldIndex <= oldLength; oldIndex++) {
2768
- for (let newIndex = 1; newIndex <= newLength; newIndex++) {
2769
- if ((oldLines[oldIndex - 1] ?? "") === (newLines[newIndex - 1] ?? "")) {
2770
- table[oldIndex]![newIndex] = (table[oldIndex - 1]?.[newIndex - 1] ?? 0) + 1;
2771
- continue;
2772
- }
2773
- const top = table[oldIndex - 1]?.[newIndex] ?? 0;
2774
- const left = table[oldIndex]?.[newIndex - 1] ?? 0;
2775
- table[oldIndex]![newIndex] = Math.max(top, left);
2776
- }
2777
- }
2778
-
2779
- const operations: WriteDiffOperation[] = [];
2780
- let oldCursor = oldLength;
2781
- let newCursor = newLength;
2782
-
2783
- while (oldCursor > 0 || newCursor > 0) {
2784
- const oldLine = oldCursor > 0 ? (oldLines[oldCursor - 1] ?? "") : undefined;
2785
- const newLine = newCursor > 0 ? (newLines[newCursor - 1] ?? "") : undefined;
2786
-
2787
- if (oldCursor > 0 && newCursor > 0 && oldLine === newLine) {
2788
- operations.push({ kind: "context", content: oldLine ?? "" });
2789
- oldCursor--;
2790
- newCursor--;
2791
- continue;
2792
- }
2793
-
2794
- const top = oldCursor > 0 ? (table[oldCursor - 1]?.[newCursor] ?? 0) : -1;
2795
- const left = newCursor > 0 ? (table[oldCursor]?.[newCursor - 1] ?? 0) : -1;
2796
-
2797
- if (newCursor > 0 && left >= top) {
2798
- operations.push({ kind: "add", content: newLine ?? "" });
2799
- newCursor--;
2800
- continue;
2801
- }
2802
-
2803
- if (oldCursor > 0) {
2804
- operations.push({ kind: "remove", content: oldLine ?? "" });
2805
- oldCursor--;
2806
- }
2807
- }
2808
-
2809
- operations.reverse();
2810
- return operations;
2811
- }
2812
-
2813
- function buildWriteEntries(lines: string[]): ParsedDiffEntry[] {
2814
- return lines.map((line, index) => ({
2815
- kind: "line",
2816
- lineKind: "add",
2817
- oldLineNumber: null,
2818
- newLineNumber: index + 1,
2819
- fallbackLineNumber: `${index + 1}`,
2820
- content: line,
2821
- raw: `+${line}`,
2822
- hunkIndex: 1,
2823
- }));
2824
- }
2825
-
2826
- function buildWriteOverwriteEntries(oldLines: string[], newLines: string[]): ParsedDiffEntry[] {
2827
- const operations = buildWriteDiffOperations(oldLines, newLines);
2828
- const entries: ParsedDiffEntry[] = [];
2829
- let oldLineNumber = 1;
2830
- let newLineNumber = 1;
2831
-
2832
- for (const operation of operations) {
2833
- if (operation.kind === "context") {
2834
- entries.push({
2835
- kind: "line",
2836
- lineKind: "context",
2837
- oldLineNumber,
2838
- newLineNumber,
2839
- fallbackLineNumber: `${newLineNumber}`,
2840
- content: operation.content,
2841
- raw: ` ${operation.content}`,
2842
- hunkIndex: 1,
2843
- });
2844
- oldLineNumber++;
2845
- newLineNumber++;
2846
- continue;
2847
- }
2848
-
2849
- if (operation.kind === "remove") {
2850
- entries.push({
2851
- kind: "line",
2852
- lineKind: "remove",
2853
- oldLineNumber,
2854
- newLineNumber: null,
2855
- fallbackLineNumber: `${oldLineNumber}`,
2856
- content: operation.content,
2857
- raw: `-${operation.content}`,
2858
- hunkIndex: 1,
2859
- });
2860
- oldLineNumber++;
2861
- continue;
2862
- }
2863
-
2864
- entries.push({
2865
- kind: "line",
2866
- lineKind: "add",
2867
- oldLineNumber: null,
2868
- newLineNumber,
2869
- fallbackLineNumber: `${newLineNumber}`,
2870
- content: operation.content,
2871
- raw: `+${operation.content}`,
2872
- hunkIndex: 1,
2873
- });
2874
- newLineNumber++;
2875
- }
2876
-
2877
- return entries;
2878
- }
2879
-
2880
- interface WriteDiffData {
2881
- entries: ParsedDiffEntry[];
2882
- splitRows: SplitDiffRow[];
2883
- inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>;
2884
- lineNumberWidth: number;
2885
- stats: DiffStats;
2886
- hunkCount: number;
2887
- }
2888
-
2889
- interface WriteOverwriteGuard {
2890
- previousLineCount: number;
2891
- nextLineCount: number;
2892
- }
2893
-
2894
- const MAX_WRITE_OVERWRITE_DIFF_LINES = 4000;
2895
- const MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS = 1_000_000;
2896
- /** Collapsed overwrite previews refuse larger LCS matrices to avoid TUI freezes. */
2897
- const MAX_COLLAPSED_WRITE_OVERWRITE_DIFF_MATRIX_CELLS = 200_000;
2898
-
2899
- function buildApproximateWriteStats(
2900
- lineCount: number,
2901
- previousLineCount: number,
2902
- hasComparablePrevious: boolean,
2903
- ): DiffStats {
2904
- const removed = hasComparablePrevious ? previousLineCount : 0;
2905
- const added = lineCount;
2906
- const hasContent = lineCount > 0 || removed > 0;
2907
- return {
2908
- added,
2909
- removed,
2910
- context: 0,
2911
- hunks: hasContent ? 1 : 0,
2912
- files: 1,
2913
- lines: added + removed,
2914
- };
2915
- }
2916
-
2917
- function buildWriteDiffData(entries: ParsedDiffEntry[]): WriteDiffData {
2918
- const splitRows = buildSplitRows(entries);
2919
- const inlineHighlights = buildInlineHighlightMap(splitRows);
2920
- const lineNumberWidth = getLineNumberWidth(entries);
2921
- const hunkCount = entries.length > 0 ? 1 : 0;
2922
- const stats = collectDiffStats(entries, hunkCount, 1);
2923
- return {
2924
- entries,
2925
- splitRows,
2926
- inlineHighlights,
2927
- lineNumberWidth,
2928
- stats,
2929
- hunkCount,
2930
- };
2931
- }
2932
-
2933
- function resolveWriteOverwriteGuard(
2934
- previousLines: string[],
2935
- nextLines: string[],
2936
- expanded = true,
2937
- ): WriteOverwriteGuard | undefined {
2938
- const previousLineCount = previousLines.length;
2939
- const nextLineCount = nextLines.length;
2940
- if (
2941
- previousLineCount > MAX_WRITE_OVERWRITE_DIFF_LINES ||
2942
- nextLineCount > MAX_WRITE_OVERWRITE_DIFF_LINES
2943
- ) {
2944
- return { previousLineCount, nextLineCount };
2945
- }
2946
- if (previousLineCount === 0 || nextLineCount === 0) {
2947
- return undefined;
2948
- }
2949
- const cellLimit = expanded
2950
- ? MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS
2951
- : MAX_COLLAPSED_WRITE_OVERWRITE_DIFF_MATRIX_CELLS;
2952
- return previousLineCount * nextLineCount > cellLimit
2953
- ? { previousLineCount, nextLineCount }
2954
- : undefined;
2955
- }
2956
-
2957
- function buildWriteOverwriteGuardText(guard: WriteOverwriteGuard, width: number): string {
2958
- const safeWidth = normalizeDiffRenderWidth(width);
2959
- if (safeWidth === 0) {
2960
- return "";
2961
- }
2962
-
2963
- const candidates = [
2964
- `↳ overwrite diff omitted (${guard.previousLineCount} → ${guard.nextLineCount} lines)`,
2965
- `↳ overwrite diff omitted (${guard.previousLineCount}→${guard.nextLineCount})`,
2966
- "↳ overwrite diff omitted",
2967
- "diff omitted",
2968
- "…",
2969
- ];
2970
- for (const candidate of candidates) {
2971
- if (visibleWidth(candidate) <= safeWidth) {
2972
- return candidate;
2973
- }
2974
- }
2975
- return truncateToWidth(candidates[candidates.length - 1] ?? "", safeWidth, "");
2976
- }
2977
-
2978
- function renderWriteOverwriteGuardRows(
2979
- guard: WriteOverwriteGuard,
2980
- width: number,
2981
- theme: DiffTheme,
2982
- ): string[] {
2983
- return renderSingleDiffRow(buildWriteOverwriteGuardText(guard, width), "warning", width, theme);
2984
- }
2985
-
2986
- /** Folded write with a zero body limit: `↳ created • click to show more`. */
2987
- function renderWriteCollapsedHintLine(
2988
- wasOverwrite: boolean,
2989
- width: number,
2990
- theme: DiffTheme,
2991
- hovered: boolean,
2992
- headerLabel?: string,
2993
- ): string[] {
2994
- const safeWidth = normalizeDiffRenderWidth(width);
2995
- if (safeWidth === 0) {
2996
- return [""];
2997
- }
2998
- const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
2999
- const clickLabel = showMoreHintText();
3000
- const candidates = [`↳ ${actionLabel} • ${clickLabel}`, `↳ ${actionLabel}`, actionLabel, "…"];
3001
- let text = candidates[candidates.length - 1] ?? "…";
3002
- for (const candidate of candidates) {
3003
- if (visibleWidth(candidate) <= safeWidth) {
3004
- text = candidate;
3005
- break;
3006
- }
3007
- }
3008
- if (visibleWidth(text) > safeWidth) {
3009
- text = truncateToWidth(text, safeWidth, "");
3010
- }
3011
- const clickIndex = text.lastIndexOf(clickLabel);
3012
- const styled =
3013
- hovered && clickIndex >= 0
3014
- ? theme.fg("muted", text.slice(0, clickIndex)) +
3015
- theme.fg("text", clickLabel) +
3016
- theme.fg("muted", text.slice(clickIndex + clickLabel.length))
3017
- : theme.fg("muted", text);
3018
- return [clampDiffLineToWidth(styled, safeWidth)];
3019
- }
3020
-
3021
- export function renderWriteDiffResult(
3022
- content: string | undefined,
3023
- options: DiffRenderOptions,
3024
- config: DisplayConfigInput,
3025
- theme: DiffTheme,
3026
- fallbackText: string,
3027
- ): Component {
3028
- const safeFallbackText = sanitizeToolResultText(fallbackText);
3029
- if (typeof content !== "string") {
3030
- if (!safeFallbackText.trim()) {
3031
- return new Text(theme.fg("muted", "↳ write completed"), 0, 0);
3032
- }
3033
- return new Text(theme.fg("toolOutput", safeFallbackText), 0, 0);
3034
- }
3035
-
3036
- const filePath = options.filePath?.trim() || "(unknown path)";
3037
- const lines = splitWriteContentLines(sanitizeToolResultText(content));
3038
- const previousLines =
3039
- typeof options.previousContent === "string"
3040
- ? splitWriteContentLines(sanitizeToolResultText(options.previousContent))
3041
- : [];
3042
- const hasComparablePrevious =
3043
- options.fileExistedBeforeWrite === true && typeof options.previousContent === "string";
3044
- const approximateStats = buildApproximateWriteStats(
3045
- lines.length,
3046
- previousLines.length,
3047
- hasComparablePrevious,
3048
- );
3049
- const palette = resolveDiffPalette(theme);
3050
- // Keep the panel transparent; changed rows and inline spans retain their own highlights.
3051
- const containerBgAnsi = undefined;
3052
- const language = resolveLanguageFromPath(filePath);
3053
- const cache = createDiffRenderCache();
3054
- let highlightLine: CodeLineHighlighter | undefined;
3055
- let detailedData: WriteDiffData | undefined;
3056
-
3057
- function getDetailedData(): WriteDiffData {
3058
- if (detailedData) {
3059
- return detailedData;
3060
- }
3061
- const entries = hasComparablePrevious
3062
- ? buildWriteOverwriteEntries(previousLines, lines)
3063
- : buildWriteEntries(lines);
3064
- detailedData = buildWriteDiffData(entries);
3065
- highlightLine = createCodeLineHighlighter(language, theme, entries, () => {
3066
- cache.invalidate();
3067
- options.invalidate?.();
3068
- });
3069
- return detailedData;
3070
- }
3071
-
3072
- return {
3073
- [RICH_DIFF_COMPONENT]: true,
3074
- render(width: number): string[] {
3075
- // Live config: panel can change indicator/wrap/limits after this component is created.
3076
- const live = resolveLiveDisplayConfig(config);
3077
- const wordWrap = live.diffWordWrap;
3078
- const indicatorMode = resolveDiffIndicatorMode(live);
3079
- const configKey = displayConfigCacheKey(live);
3080
- const safeWidth = normalizeDiffRenderWidth(width);
3081
- const resolvedMode = resolveDiffPresentationMode(
3082
- live,
3083
- safeWidth,
3084
- canRenderSplitLayout(safeWidth),
3085
- );
3086
- const mode: DiffPresentationMode = hasComparablePrevious
3087
- ? resolvedMode
3088
- : resolvedMode === "split"
3089
- ? "unified"
3090
- : resolvedMode;
3091
- const hovered = options.isHovered?.() ?? false;
3092
- const cached = cache.get(safeWidth, options.expanded, mode, configKey, hovered);
3093
- if (cached) {
3094
- return cached;
3095
- }
3096
-
3097
- const writeCollapsedLimit = resolveWriteCollapsedLimit(live);
3098
- if (!options.expanded && writeCollapsedLimit === 0) {
3099
- return cache.set(
3100
- safeWidth,
3101
- options.expanded,
3102
- mode,
3103
- configKey,
3104
- hovered,
3105
- clampDiffLinesToWidth(
3106
- renderWriteCollapsedHintLine(
3107
- options.fileExistedBeforeWrite === true,
3108
- safeWidth,
3109
- theme,
3110
- hovered,
3111
- options.headerLabel,
3112
- ),
3113
- safeWidth,
3114
- ),
3115
- );
3116
- }
3117
-
3118
- const header = renderWriteHeader(
3119
- options.fileExistedBeforeWrite === true,
3120
- safeWidth,
3121
- theme,
3122
- options.headerLabel,
3123
- );
3124
- // Guard depends on expanded: collapsed previews use a stricter LCS cell budget.
3125
- const overwriteGuard = hasComparablePrevious
3126
- ? resolveWriteOverwriteGuard(previousLines, lines, options.expanded)
3127
- : undefined;
3128
- if (overwriteGuard) {
3129
- return cache.set(
3130
- safeWidth,
3131
- options.expanded,
3132
- mode,
3133
- configKey,
3134
- hovered,
3135
- clampDiffLinesToWidth(
3136
- [header, ...renderWriteOverwriteGuardRows(overwriteGuard, safeWidth, theme)],
3137
- safeWidth,
3138
- ),
3139
- );
3140
- }
3141
-
3142
- if (mode === "summary") {
3143
- const summaryRows =
3144
- approximateStats.lines === 0
3145
- ? [header]
3146
- : [
3147
- header,
3148
- ...renderSingleDiffRow(
3149
- buildDiffSummaryText(approximateStats, safeWidth),
3150
- "toolOutput",
3151
- safeWidth,
3152
- theme,
3153
- ),
3154
- ];
3155
- return cache.set(
3156
- safeWidth,
3157
- options.expanded,
3158
- mode,
3159
- configKey,
3160
- hovered,
3161
- clampDiffLinesToWidth(summaryRows, safeWidth),
3162
- );
3163
- }
3164
-
3165
- const data = getDetailedData();
3166
- const displayLimit = resolveDiffDisplayLimit(
3167
- options.expanded,
3168
- writeCollapsedLimit,
3169
- live.expandedPreviewMaxLines,
3170
- );
3171
- const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
3172
- const entryBudget = takeEntriesForLineBudget(data.entries, processBudget);
3173
- const splitBudget = takeSplitRowsForBudget(data.splitRows, processBudget);
3174
- const inlineHighlights =
3175
- splitBudget.processedRows < splitBudget.totalRows
3176
- ? buildInlineHighlightMap(splitBudget.rows)
3177
- : data.inlineHighlights;
3178
- const renderCtx: DiffRenderContext = {
3179
- width: safeWidth,
3180
- theme,
3181
- inlineHighlights,
3182
- palette,
3183
- highlightLine: highlightLine!,
3184
- containerBgAnsi,
3185
- wordWrap,
3186
- indicatorMode,
3187
- showHashlineAnchors: false,
3188
- };
3189
- const bodyRows: RenderedRow[] =
3190
- entryBudget.entries.length === 0
3191
- ? [{ text: theme.fg("muted", "(empty file)"), hunkIndex: null }]
3192
- : mode === "split"
3193
- ? renderSplit(splitBudget.rows, renderCtx, data.lineNumberWidth)
3194
- : mode === "compact"
3195
- ? renderCompact(entryBudget.entries, renderCtx)
3196
- : renderUnified(entryBudget.entries, renderCtx, data.lineNumberWidth);
3197
- const unprocessedLogicalRows =
3198
- mode === "split"
3199
- ? Math.max(0, splitBudget.totalRows - splitBudget.processedRows)
3200
- : Math.max(0, entryBudget.totalLineEntries - entryBudget.processedLineEntries);
3201
-
3202
- const bodyWithLimit = applyLineLimit(
3203
- bodyRows,
3204
- safeWidth,
3205
- options.expanded,
3206
- writeCollapsedLimit,
3207
- live.expandedPreviewMaxLines,
3208
- data.hunkCount,
3209
- theme,
3210
- unprocessedLogicalRows,
3211
- hovered,
3212
- );
3213
- const frame = renderDiffFrameLine(safeWidth, theme);
3214
- const renderedLines =
3215
- mode === "unified" ? [header, frame, ...bodyWithLimit, frame] : [header, ...bodyWithLimit];
3216
- const finalLines = clampDiffLinesToWidth(renderedLines, safeWidth);
3217
- return cache.set(safeWidth, options.expanded, mode, configKey, hovered, finalLines);
3218
- },
3219
- invalidate: cache.invalidate,
3220
- } as Component;
3221
- }
19
+ export { shouldHighlightCodeBlock } from "./diff-highlight.ts";
20
+ export type { DisplayConfigInput } from "./diff-component.ts";
21
+ export { isRichDiffComponent } from "./diff-component.ts";
22
+ export { countEditDiffStats, renderEditDiffResult } from "./diff-edit-render.ts";
23
+ export { countWriteDiffStats, renderWriteDiffResult } from "./diff-write-render.ts";