@luan.sh/pi-libtui 0.3.8

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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,504 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { TOOL_SURFACE_BACKGROUND } from "../background-surface.ts";
3
+ import type { TuiBackgroundToken, TuiColor, TuiForegroundColor, TuiForegroundPaint, TuiTheme } from "../color/theme.ts";
4
+ import { sanitizeTuiText } from "../content/terminal-text.ts";
5
+ import { icon } from "../decoration/glyphs.ts";
6
+ import type { SyntaxHighlightSpan } from "../syntax.ts";
7
+ import type { UnifiedDiffLine, UnifiedDiffLineKind, UnifiedDiffModel } from "./model.ts";
8
+ import { diffSyntax } from "./pierre-syntax.ts";
9
+
10
+ /** Explicit bounded row policy selected by a tool-owned view mode. */
11
+ export interface UnifiedDiffViewport {
12
+ readonly maxRows: number;
13
+ readonly selection?: "head" | "tail" | "head-tail";
14
+ }
15
+
16
+ export interface RenderUnifiedDiffOptions {
17
+ readonly width: number;
18
+ readonly theme: TuiTheme;
19
+ readonly viewport?: UnifiedDiffViewport;
20
+ /** Absolute render escape hatch, applied after wrapping. */
21
+ readonly maxRenderedRows?: number;
22
+ /** Paint the renderer-owned omission row with its semantic hover variant. */
23
+ readonly omissionRowHovered?: boolean;
24
+ /** Background for context, metadata, and hunk rows. Defaults to the shared tool surface. */
25
+ readonly surface?: TuiBackgroundToken;
26
+ }
27
+
28
+ export interface UnifiedDiffRenderResult {
29
+ readonly lines: readonly string[];
30
+ readonly omittedRows: number;
31
+ readonly truncated: boolean;
32
+ /** Rendered row containing the omission fold, when the result is truncated. */
33
+ readonly omissionRow?: number;
34
+ }
35
+
36
+ const DEFAULT_MAX_ROWS = 20_000;
37
+ const DEFAULT_VIEWPORT_ROWS = 120;
38
+ const DEFAULT_MAX_RENDERED_ROWS = 4_000;
39
+ const RENDER_CACHE_LIMIT = 16;
40
+ const renderCache = new Map<string, UnifiedDiffRenderResult>();
41
+ const themeIds = new WeakMap<TuiTheme, number>();
42
+ let nextThemeId = 1;
43
+
44
+ /** Render a parsed diff with full-row semantic paint and bounded work. */
45
+ export function renderUnifiedDiff(model: UnifiedDiffModel, options: RenderUnifiedDiffOptions): UnifiedDiffRenderResult {
46
+ const width = Math.max(1, Math.floor(options.width));
47
+ const viewport = normalizeDiffViewport(options.viewport);
48
+ const rowBudget = viewport.maxRows;
49
+ const maxRenderedRows = positiveInteger(options.maxRenderedRows, DEFAULT_MAX_RENDERED_ROWS);
50
+ const syntax = diffSyntax(model);
51
+ const cacheKey = `${model.revision}:${syntax.highlighted}:${themeId(options.theme)}:${width}:${viewport.selection}:${rowBudget}:${maxRenderedRows}:${options.surface ?? TOOL_SURFACE_BACKGROUND}:${options.omissionRowHovered === true}`;
52
+ const cached = renderCache.get(cacheKey);
53
+ if (cached) return touch(renderCache, cacheKey, cached);
54
+
55
+ const logical = logicalRows(model);
56
+ const selected = selectRows(logical, rowBudget, viewport.selection);
57
+ const numberWidth = Math.max(1, digitWidth(model));
58
+ const rendered: string[] = [];
59
+ let omissionRow: number | undefined;
60
+ let hardLimited = false;
61
+ for (let index = 0; index < selected.rows.length && rendered.length < maxRenderedRows; index += 1) {
62
+ const row = selected.rows[index]!;
63
+ if (row.kind === "fold") {
64
+ omissionRow ??= rendered.length;
65
+ rendered.push(renderFoldRow(row, options, width, numberWidth));
66
+ continue;
67
+ }
68
+ const remainingRows = maxRenderedRows - rendered.length;
69
+ const remainingSelectedRows = selected.rows.length - index - 1;
70
+ // Preserve one terminal row for every selected source row after this one.
71
+ // Otherwise one wrapped line can consume the omission control and tail.
72
+ const rowLimit = Math.max(1, remainingRows - remainingSelectedRows);
73
+ const physicalLines = renderLogicalRow(
74
+ row,
75
+ syntax.lines,
76
+ width,
77
+ numberWidth,
78
+ options.theme,
79
+ options.surface ?? TOOL_SURFACE_BACKGROUND,
80
+ rowLimit,
81
+ );
82
+ if (row.kind === "line" && row.line.text.length > rowLimit * width * 4) hardLimited = true;
83
+ for (const [physicalIndex, line] of physicalLines.entries()) {
84
+ if (rendered.length >= maxRenderedRows) {
85
+ hardLimited = true;
86
+ break;
87
+ }
88
+ rendered.push(line);
89
+ if (
90
+ rendered.length >= maxRenderedRows &&
91
+ (physicalIndex < physicalLines.length - 1 || index < selected.rows.length - 1)
92
+ )
93
+ hardLimited = true;
94
+ }
95
+ }
96
+ if (hardLimited && omissionRow === undefined && rendered.length > 0) {
97
+ omissionRow = rendered.length - 1;
98
+ rendered[omissionRow] = renderFoldRow(
99
+ { kind: "fold", count: 1, context: "… wrapped diff omitted …" },
100
+ options,
101
+ width,
102
+ numberWidth,
103
+ );
104
+ }
105
+ const hardOmitted = hardLimited ? 1 : Math.max(0, selected.estimatedRenderedRows - rendered.length);
106
+ const omittedRows = selected.omittedRows + hardOmitted;
107
+ const result: UnifiedDiffRenderResult = Object.freeze({
108
+ lines: Object.freeze(rendered),
109
+ omittedRows,
110
+ truncated: model.truncated || omittedRows > 0,
111
+ omissionRow,
112
+ });
113
+ return boundedSet(renderCache, cacheKey, result, RENDER_CACHE_LIMIT);
114
+ }
115
+
116
+ function renderFoldRow(
117
+ row: Extract<LogicalRow, { kind: "fold" }>,
118
+ options: RenderUnifiedDiffOptions,
119
+ width: number,
120
+ numberWidth: number,
121
+ ): string {
122
+ const hovered = options.omissionRowHovered === true;
123
+ const gutterBackground = hovered ? "diff.hunkGutterHover" : "diff.hunkGutter";
124
+ const sample = row.sample
125
+ ? ` ${options.theme.fg(
126
+ row.sample.kind === "added"
127
+ ? { hue: "green", shade: 3 }
128
+ : row.sample.kind === "removed"
129
+ ? { hue: "red", shade: 3 }
130
+ : "text.secondary",
131
+ row.sample.text,
132
+ )}`
133
+ : "";
134
+ return paintDiffRow(options.theme, {
135
+ background: hovered ? "diff.hunkHover" : "diff.hunk",
136
+ foreground: options.theme.contrastBackground(options.theme.color(hovered ? "diff.hunkHover" : "diff.hunk")),
137
+ gutter: gutterIcon(icon("fold-unfold")),
138
+ content: `${row.context}${sample}`,
139
+ width,
140
+ numberWidth,
141
+ gutterBackground,
142
+ gutterForeground: "text.muted",
143
+ gutterColor: options.theme.contrastBackground(options.theme.color(gutterBackground)),
144
+ });
145
+ }
146
+
147
+ type LogicalRow =
148
+ | { readonly kind: "header"; readonly text: string; readonly ref: string; readonly path?: string }
149
+ | {
150
+ readonly kind: "hunk";
151
+ readonly text: string;
152
+ readonly ref: string;
153
+ readonly path?: string;
154
+ }
155
+ | { readonly kind: "line"; readonly line: UnifiedDiffLine; readonly path?: string }
156
+ /** A folded context span. It is not a hunk; the label is nearby diff context. */
157
+ | {
158
+ readonly kind: "fold";
159
+ readonly count: number;
160
+ readonly context: string;
161
+ readonly sample?: { readonly kind: UnifiedDiffLineKind; readonly text: string };
162
+ };
163
+
164
+ function logicalRows(model: UnifiedDiffModel): LogicalRow[] {
165
+ const rows: LogicalRow[] = model.preamble.map((text, index) => ({ kind: "header", text, ref: `p${index}` }));
166
+ for (const file of model.files) {
167
+ const path = file.newPath ?? file.oldPath;
168
+ for (const [index, text] of file.headerLines.entries()) {
169
+ if (isNullFileHeader(text)) continue;
170
+ rows.push({ kind: "header", text, ref: `${file.ref}:m${index}`, path });
171
+ }
172
+ for (const hunk of file.hunks) {
173
+ if (hunk.header.trim()) rows.push({ kind: "hunk", text: hunk.header, ref: hunk.ref, path });
174
+ for (const line of hunk.lines) rows.push({ kind: "line", line, path });
175
+ }
176
+ }
177
+ return rows;
178
+ }
179
+
180
+ function isNullFileHeader(text: string): boolean {
181
+ return /^(?:---|\+\+\+)\s+\/dev\/null(?:\s|$)/u.test(text);
182
+ }
183
+
184
+ function selectRows(
185
+ rows: readonly LogicalRow[],
186
+ budget: number,
187
+ selection: Required<UnifiedDiffViewport>["selection"],
188
+ ): { rows: readonly LogicalRow[]; omittedRows: number; estimatedRenderedRows: number } {
189
+ if (rows.length <= budget) return { rows, omittedRows: 0, estimatedRenderedRows: rows.length };
190
+ const omittedRows = rows.length - budget;
191
+ if (selection === "tail") {
192
+ return {
193
+ rows: [foldRow(rows, 0, rows.length - budget), ...rows.slice(-budget)],
194
+ omittedRows,
195
+ estimatedRenderedRows: budget + 1,
196
+ };
197
+ }
198
+ if (selection === "head") {
199
+ return {
200
+ rows: [...rows.slice(0, budget), foldRow(rows, budget, rows.length)],
201
+ omittedRows,
202
+ estimatedRenderedRows: budget + 1,
203
+ };
204
+ }
205
+ const head = Math.ceil(budget / 2);
206
+ const tail = Math.floor(budget / 2);
207
+ return {
208
+ rows: [...rows.slice(0, head), foldRow(rows, head, rows.length - tail), ...rows.slice(rows.length - tail)],
209
+ omittedRows,
210
+ estimatedRenderedRows: budget + 1,
211
+ };
212
+ }
213
+
214
+ function foldRow(rows: readonly LogicalRow[], start: number, end: number): Extract<LogicalRow, { kind: "fold" }> {
215
+ const omittedHunk = rows
216
+ .slice(start, end)
217
+ .find((row): row is Extract<LogicalRow, { kind: "hunk" }> => row.kind === "hunk");
218
+ const precedingHunk = omittedHunk
219
+ ? undefined
220
+ : [...rows.slice(0, start)]
221
+ .reverse()
222
+ .find((row): row is Extract<LogicalRow, { kind: "hunk" }> => row.kind === "hunk");
223
+ const followingHunk =
224
+ omittedHunk || precedingHunk
225
+ ? undefined
226
+ : rows.slice(end).find((row): row is Extract<LogicalRow, { kind: "hunk" }> => row.kind === "hunk");
227
+ const contextHeader = omittedHunk?.text ?? precedingHunk?.text ?? followingHunk?.text;
228
+ const contextLine = rows
229
+ .slice(start, end)
230
+ .find((row): row is Extract<LogicalRow, { kind: "line" }> => row.kind === "line")?.line;
231
+ const count = Math.max(0, end - start);
232
+ return {
233
+ kind: "fold",
234
+ count,
235
+ context: contextHeader ?? `… ${count} rows omitted …`,
236
+ sample: contextLine ? { kind: contextLine.kind, text: contextLine.text } : undefined,
237
+ };
238
+ }
239
+
240
+ function normalizeDiffViewport(viewport: UnifiedDiffViewport | undefined): Required<UnifiedDiffViewport> {
241
+ const requested = viewport?.maxRows ?? DEFAULT_VIEWPORT_ROWS;
242
+ return {
243
+ maxRows: Number.isFinite(requested)
244
+ ? Math.min(DEFAULT_MAX_ROWS, Math.max(1, Math.floor(requested)))
245
+ : DEFAULT_VIEWPORT_ROWS,
246
+ selection: viewport?.selection ?? "head-tail",
247
+ };
248
+ }
249
+
250
+ function renderLogicalRow(
251
+ row: Exclude<LogicalRow, { kind: "fold" }>,
252
+ syntax: ReadonlyMap<string, readonly SyntaxHighlightSpan[]>,
253
+ width: number,
254
+ numberWidth: number,
255
+ theme: TuiTheme,
256
+ surface: TuiBackgroundToken,
257
+ maxRows: number,
258
+ ): string[] {
259
+ if (row.kind === "header") return [paintFullRow(theme, surface, "text.secondary", row.text, width)];
260
+ if (row.kind === "hunk") {
261
+ return [
262
+ paintDiffRow(theme, {
263
+ background: "diff.hunk",
264
+ foreground: theme.contrastBackground(theme.color("diff.hunk")),
265
+ gutter: gutterIcon(icon("diff-hunk")),
266
+ content: row.text,
267
+ width,
268
+ numberWidth,
269
+ gutterBackground: "diff.hunkGutter",
270
+ gutterForeground: "text.muted",
271
+ gutterColor: theme.contrastBackground(theme.color("diff.hunkGutter")),
272
+ }),
273
+ ];
274
+ }
275
+ const line = row.line;
276
+ const lineNumber = line.kind === "removed" ? line.oldLine : (line.newLine ?? line.oldLine);
277
+ const rail = line.kind === "added" ? "┃" : line.kind === "removed" ? "┋" : line.kind === "malformed" ? "!" : " ";
278
+ const gutter = `${rail} ${formatLineNumber(lineNumber, numberWidth)} `;
279
+ const contentWidth = Math.max(1, width - visibleWidth(gutter));
280
+ const wrapped = wrapPlain(line.text, contentWidth, maxRows);
281
+ const background: TuiBackgroundToken =
282
+ line.kind === "added" ? "diff.added" : line.kind === "removed" ? "diff.removed" : surface;
283
+ const gutterBackground: TuiBackgroundToken =
284
+ line.kind === "added" ? "diff.addedGutter" : line.kind === "removed" ? "diff.removedGutter" : surface;
285
+ const foreground =
286
+ line.kind === "added"
287
+ ? "positive"
288
+ : line.kind === "removed"
289
+ ? "negative"
290
+ : line.kind === "malformed"
291
+ ? "warning"
292
+ : "text.primary";
293
+ const highlighted = syntax.get(line.ref);
294
+ // Line numbers share the changed-row background, so the positive/negative
295
+ // foregrounds used by the code and rail can disappear on light themes.
296
+ // Keep the rails semantic, but use the theme's primary text for the number
297
+ // lane so it remains readable against both dark and light diff surfaces.
298
+ const lineNumberColor: TuiForegroundColor = line.kind === "context" ? "text.muted" : "text.primary";
299
+ let sourceOffset = 0;
300
+ return wrapped.map((text, wrappedIndex) => {
301
+ const railColor: TuiForegroundColor =
302
+ line.kind === "added"
303
+ ? "positive"
304
+ : line.kind === "removed"
305
+ ? "negative"
306
+ : line.kind === "malformed"
307
+ ? "warning"
308
+ : "text.muted";
309
+ const prefix =
310
+ wrappedIndex === 0
311
+ ? `${theme.fg(railColor, rail)} ${theme.fg(lineNumberColor, formatLineNumber(lineNumber, numberWidth))} `
312
+ : `${theme.fg(railColor, rail)} ${" ".repeat(numberWidth)} `;
313
+ const spans = styleSyntaxSpans(
314
+ text,
315
+ sliceSyntaxSpans(highlighted, sourceOffset, sourceOffset + text.length),
316
+ theme,
317
+ foreground,
318
+ background,
319
+ line.kind,
320
+ );
321
+ sourceOffset += text.length;
322
+ return paintDiffRow(theme, {
323
+ background,
324
+ gutterBackground,
325
+ foreground,
326
+ gutter: prefix,
327
+ content: spans,
328
+ width,
329
+ numberWidth,
330
+ });
331
+ });
332
+ }
333
+
334
+ function styleSyntaxSpans(
335
+ text: string,
336
+ spans: readonly SyntaxHighlightSpan[] | undefined,
337
+ theme: TuiTheme,
338
+ fallback: TuiForegroundColor,
339
+ lineBackground: TuiBackgroundToken,
340
+ kind: UnifiedDiffLineKind,
341
+ ): string {
342
+ if (!spans || spans.length === 0) return theme.fg(fallback, text);
343
+ const sanitized = spans.map((span) => ({ ...span, text: sanitizeTuiText(span.text) }));
344
+ if (sanitized.map((span) => span.text).join("") !== text) return theme.fg(fallback, text);
345
+ let output = "";
346
+ for (const span of sanitized) {
347
+ const painted = theme.fg(span.foreground ?? fallback, span.text);
348
+ if (span.emphasized && (kind === "added" || kind === "removed")) {
349
+ const emphasis = kind === "added" ? "diff.addedEmphasis" : "diff.removedEmphasis";
350
+ output += `${theme.bgAnsi(emphasis)}${painted}${theme.bgAnsi(lineBackground)}`;
351
+ } else output += painted;
352
+ }
353
+ return output;
354
+ }
355
+
356
+ function sliceSyntaxSpans(
357
+ spans: readonly SyntaxHighlightSpan[] | undefined,
358
+ start: number,
359
+ end: number,
360
+ ): readonly SyntaxHighlightSpan[] | undefined {
361
+ if (!spans) return undefined;
362
+ const sliced: SyntaxHighlightSpan[] = [];
363
+ let offset = 0;
364
+ for (const span of spans) {
365
+ const spanStart = offset;
366
+ const spanEnd = spanStart + span.text.length;
367
+ if (spanStart < end && spanEnd > start) {
368
+ sliced.push({
369
+ ...span,
370
+ text: span.text.slice(Math.max(0, start - spanStart), Math.min(span.text.length, end - spanStart)),
371
+ });
372
+ }
373
+ offset = spanEnd;
374
+ if (offset >= end) break;
375
+ }
376
+ return sliced;
377
+ }
378
+
379
+ function paintFullRow(
380
+ theme: TuiTheme,
381
+ background: TuiBackgroundToken,
382
+ foreground: TuiForegroundColor,
383
+ content: string,
384
+ width: number,
385
+ ): string {
386
+ const clipped = truncateToWidth(content, width, "");
387
+ const padding = " ".repeat(Math.max(0, width - visibleWidth(clipped)));
388
+ return `${theme.bgAnsi(background)}${theme.fgAnsi(foreground)}${clipped}${padding}\x1b[0m`;
389
+ }
390
+
391
+ function gutterIcon(glyph: string): string {
392
+ // Hunk rows reserve the same leading cells as changed-line numbers, but do
393
+ // not claim an add/remove rail. The glyph therefore starts in the leading
394
+ // number column, aligned with the first digit of adjacent rows.
395
+ return ` ${glyph}`;
396
+ }
397
+
398
+ interface DiffRowPaintOptions {
399
+ readonly background: TuiBackgroundToken;
400
+ readonly gutterBackground: TuiBackgroundToken;
401
+ readonly foreground: TuiForegroundPaint;
402
+ readonly gutter: string;
403
+ readonly content: string;
404
+ readonly width: number;
405
+ readonly numberWidth: number;
406
+ readonly gutterForeground?: TuiForegroundColor;
407
+ readonly gutterColor?: TuiColor;
408
+ }
409
+
410
+ function paintDiffRow(theme: TuiTheme, options: DiffRowPaintOptions): string {
411
+ const {
412
+ background,
413
+ gutterBackground,
414
+ foreground,
415
+ gutter,
416
+ content,
417
+ width,
418
+ numberWidth,
419
+ gutterForeground,
420
+ gutterColor,
421
+ } = options;
422
+ const boundedWidth = Math.max(0, Math.floor(width));
423
+ if (boundedWidth === 0) return "";
424
+ const gutterWidth = Math.min(Math.max(1, numberWidth + 3), boundedWidth);
425
+ const clippedGutter = truncateToWidth(gutter, gutterWidth, "");
426
+ const gutterPadding = " ".repeat(Math.max(0, gutterWidth - visibleWidth(clippedGutter)));
427
+ const contentWidth = Math.max(0, boundedWidth - gutterWidth);
428
+ const clippedContent = truncateToWidth(content, contentWidth, "");
429
+ const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(clippedContent)));
430
+ const gutterText = `${clippedGutter}${gutterPadding}`;
431
+ const paintedGutter = gutterColor
432
+ ? theme.fg(gutterColor, gutterText)
433
+ : gutterForeground
434
+ ? `${theme.fgAnsi(gutterForeground)}${gutterText}`
435
+ : gutterText;
436
+ return `${theme.bgAnsi(gutterBackground)}${paintedGutter}${theme.bgAnsi(background)}${theme.fgAnsi(foreground)}${clippedContent}${padding}\x1b[0m`;
437
+ }
438
+
439
+ function wrapPlain(text: string, width: number, maxRows: number): string[] {
440
+ const clean = sanitizeTuiText(text)
441
+ .replaceAll("\t", " ")
442
+ .slice(0, Math.max(1, width) * Math.max(1, maxRows) * 4);
443
+ if (clean.length === 0) return [""];
444
+ const rows: string[] = [];
445
+ let remaining = clean;
446
+ while (remaining.length > 0 && rows.length < maxRows) {
447
+ const part = truncateToWidth(remaining, width, "");
448
+ if (part.length === 0) {
449
+ // A wide glyph cannot fit a one-column viewport. Drop it rather than loop forever.
450
+ remaining = [...remaining].slice(1).join("");
451
+ continue;
452
+ }
453
+ rows.push(part);
454
+ remaining = remaining.slice(part.length);
455
+ }
456
+ return rows.length === 0 ? [""] : rows;
457
+ }
458
+
459
+ function digitWidth(model: UnifiedDiffModel): number {
460
+ let maximum = 0;
461
+ for (const file of model.files) {
462
+ for (const hunk of file.hunks) {
463
+ maximum = Math.max(
464
+ maximum,
465
+ hunk.oldStart === undefined ? 0 : hunk.oldStart + Math.max(0, (hunk.oldCount ?? 1) - 1),
466
+ hunk.newStart === undefined ? 0 : hunk.newStart + Math.max(0, (hunk.newCount ?? 1) - 1),
467
+ );
468
+ for (const line of hunk.lines) maximum = Math.max(maximum, line.oldLine ?? 0, line.newLine ?? 0);
469
+ }
470
+ }
471
+ return String(maximum).length;
472
+ }
473
+
474
+ function formatLineNumber(value: number | undefined, width: number): string {
475
+ return value === undefined ? " ".repeat(width) : String(value).padStart(width);
476
+ }
477
+
478
+ function positiveInteger(value: number | undefined, fallback: number): number {
479
+ return value === undefined || !Number.isFinite(value) ? fallback : Math.max(1, Math.floor(value));
480
+ }
481
+
482
+ function themeId(theme: TuiTheme): number {
483
+ const existing = themeIds.get(theme);
484
+ if (existing !== undefined) return existing;
485
+ const id = nextThemeId++;
486
+ themeIds.set(theme, id);
487
+ return id;
488
+ }
489
+
490
+ function touch<K, V>(cache: Map<K, V>, key: K, value: V): V {
491
+ cache.delete(key);
492
+ cache.set(key, value);
493
+ return value;
494
+ }
495
+
496
+ function boundedSet<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): V {
497
+ cache.set(key, value);
498
+ while (cache.size > limit) {
499
+ const oldest = cache.keys().next().value as K | undefined;
500
+ if (oldest === undefined) break;
501
+ cache.delete(oldest);
502
+ }
503
+ return value;
504
+ }