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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -2
  3. package/package.json +34 -5
  4. package/src/claude-tools.ts +274 -0
  5. package/src/claude-view.ts +775 -0
  6. package/src/index.ts +566 -0
  7. package/src/pi-runtime.ts +232 -0
  8. package/src/preview.ts +147 -0
  9. package/src/running-effect.ts +29 -0
  10. package/src/skill-block-recollapse.ts +362 -0
  11. package/src/sticky-first-message.ts +1114 -0
  12. package/src/subagent-panel.ts +144 -0
  13. package/src/vendor/ATTRIBUTION.md +26 -0
  14. package/src/vendor/ansi-color.ts +44 -0
  15. package/src/vendor/ansi-text.ts +47 -0
  16. package/src/vendor/diff/ATTRIBUTION.md +28 -0
  17. package/src/vendor/diff/ansi-utils.ts +113 -0
  18. package/src/vendor/diff/diff-component.ts +125 -0
  19. package/src/vendor/diff/diff-edit-render.ts +237 -0
  20. package/src/vendor/diff/diff-header.ts +137 -0
  21. package/src/vendor/diff/diff-highlight.ts +115 -0
  22. package/src/vendor/diff/diff-inline.ts +324 -0
  23. package/src/vendor/diff/diff-layout.ts +762 -0
  24. package/src/vendor/diff/diff-limits.ts +229 -0
  25. package/src/vendor/diff/diff-palette.ts +410 -0
  26. package/src/vendor/diff/diff-parse.ts +535 -0
  27. package/src/vendor/diff/diff-presentation.ts +70 -0
  28. package/src/vendor/diff/diff-renderer.ts +23 -0
  29. package/src/vendor/diff/diff-text.ts +45 -0
  30. package/src/vendor/diff/diff-write-render.ts +572 -0
  31. package/src/vendor/diff/index.ts +126 -0
  32. package/src/vendor/diff/line-width-safety.ts +92 -0
  33. package/src/vendor/diff/shiki-highlight.ts +92 -0
  34. package/src/vendor/diff/write-execution.ts +113 -0
  35. package/src/vendor/diff-config.ts +30 -0
  36. package/src/vendor/show-more-hint.ts +38 -0
  37. package/src/vendor/tool-result-sanitize.ts +24 -0
  38. package/src/working-message.ts +140 -0
@@ -0,0 +1,229 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { DEFAULT_TOOL_DISPLAY_CONFIG, type ToolDisplayConfig } from "../diff-config.ts";
3
+ import { showMoreHintText } from "../show-more-hint.ts";
4
+ import {
5
+ buildCollapsedDiffHintText,
6
+ clampRenderedLineToWidth,
7
+ clampRenderedLinesToWidth,
8
+ } from "./line-width-safety.ts";
9
+ import { stabilizeBackgroundResets, type DiffTheme } from "./diff-palette.ts";
10
+ import { countDiffLineEntries, type ParsedDiffEntry } from "./diff-parse.ts";
11
+ import type { SplitDiffRow } from "./diff-inline.ts";
12
+ import type { RenderedRow } from "./diff-layout.ts";
13
+
14
+ const DIFF_WIDTH_OPS = {
15
+ measure: visibleWidth,
16
+ truncate: (text: string, maxWidth: number): string => truncateToWidth(text, maxWidth, ""),
17
+ };
18
+
19
+ export function clampDiffLineToWidth(text: string, width: number): string {
20
+ return stabilizeBackgroundResets(clampRenderedLineToWidth(text, width, DIFF_WIDTH_OPS));
21
+ }
22
+
23
+ export function clampDiffLinesToWidth(lines: string[], width: number): string[] {
24
+ return clampRenderedLinesToWidth(lines, width, DIFF_WIDTH_OPS).map((line) =>
25
+ stabilizeBackgroundResets(line),
26
+ );
27
+ }
28
+
29
+ function renderDiffSpacerLine(width: number): string {
30
+ const safeWidth = Math.max(0, width);
31
+ return safeWidth > 0 ? " ".repeat(safeWidth) : "";
32
+ }
33
+
34
+ /**
35
+ * Body lines to render before the remainder hint. Expanded diffs are never capped:
36
+ * clicking `click to show more` expands the card to the whole diff.
37
+ */
38
+ export function resolveDiffDisplayLimit(expanded: boolean, maxCollapsedLines: number): number {
39
+ if (expanded) return Number.POSITIVE_INFINITY;
40
+ const collapsedLimit = Number.isFinite(maxCollapsedLines)
41
+ ? maxCollapsedLines
42
+ : DEFAULT_TOOL_DISPLAY_CONFIG.editDiffCollapsedLines;
43
+ // 0 是合法值:折叠态零内容行,仅保留 remainder hint。
44
+ return Math.max(0, collapsedLimit);
45
+ }
46
+
47
+ export function resolveWriteCollapsedLimit(
48
+ config: Pick<ToolDisplayConfig, "writeDiffCollapsedLines">,
49
+ ): number {
50
+ return Math.max(0, config.writeDiffCollapsedLines);
51
+ }
52
+
53
+ /**
54
+ * Split layout prepends two decor rows (the column rule and the old/new label
55
+ * row) to the rendered body; they are layout chrome, not content.
56
+ */
57
+ export const SPLIT_DECOR_ROWS = 2;
58
+
59
+ /**
60
+ * Collapsed previews must surface every changed row. Pure context runs longer than
61
+ * `maxRun` collapse into a single omission row so changes late in the diff survive
62
+ * the collapsed budget; runs short enough to be worth their rows stay verbatim.
63
+ * Expanded rendering never compacts — the full diff stays authoritative.
64
+ */
65
+ export function compactDiffContext(
66
+ entries: readonly ParsedDiffEntry[],
67
+ maxRun = 1,
68
+ keepAdjacent = 1,
69
+ ): ParsedDiffEntry[] {
70
+ const keep = new Array<boolean>(entries.length).fill(false);
71
+ let hasContextRun = false;
72
+ for (let index = 0; index < entries.length; index++) {
73
+ const entry = entries[index];
74
+ if (entry.kind !== "line") {
75
+ keep[index] = true;
76
+ continue;
77
+ }
78
+ if (entry.lineKind !== "context") {
79
+ for (
80
+ let near = Math.max(0, index - keepAdjacent);
81
+ near <= Math.min(entries.length - 1, index + keepAdjacent);
82
+ near++
83
+ ) {
84
+ const neighbour = entries[near];
85
+ if (neighbour?.kind === "line") keep[near] = true;
86
+ }
87
+ }
88
+ }
89
+ const out: ParsedDiffEntry[] = [];
90
+ let pending: ParsedDiffEntry[] = [];
91
+ let pendingHunk = 1;
92
+ const flushPending = () => {
93
+ if (!pending.length) return;
94
+ const last = out[out.length - 1];
95
+ if (last?.kind === "omission") {
96
+ // An upstream omission already elides this gap; drop the pending rows
97
+ // instead of stacking a second ⋮ row right below it.
98
+ pending = [];
99
+ return;
100
+ }
101
+ if (pending.length > maxRun) {
102
+ hasContextRun = true;
103
+ out.push({ kind: "omission", raw: ` ... ${pending.length} lines ...`, hunkIndex: pendingHunk });
104
+ } else out.push(...pending);
105
+ pending = [];
106
+ };
107
+ for (let index = 0; index < entries.length; index++) {
108
+ const entry = entries[index];
109
+ if (keep[index]) {
110
+ flushPending();
111
+ out.push(entry);
112
+ continue;
113
+ }
114
+ if (entry.kind === "line" && entry.lineKind === "context") {
115
+ pending.push(entry);
116
+ pendingHunk = entry.hunkIndex;
117
+ }
118
+ }
119
+ flushPending();
120
+ return hasContextRun || out.length !== entries.length ? out : entries.slice();
121
+ }
122
+
123
+ /**
124
+ * How many source rows/entries to fully highlight+render before applyLineLimit.
125
+ * Extra headroom covers word-wrap expansion so the display limit can still fill.
126
+ */
127
+ export function resolveDiffProcessBudget(displayLimit: number, wordWrap: boolean): number {
128
+ if (!Number.isFinite(displayLimit)) {
129
+ return Number.POSITIVE_INFINITY;
130
+ }
131
+ // 0 = 折叠态零预览:一条源行也不必高亮渲染。
132
+ if (displayLimit <= 0) return 0;
133
+ return wordWrap ? displayLimit * 2 + 8 : displayLimit + 4;
134
+ }
135
+
136
+ export function takeEntriesForLineBudget(
137
+ entries: ParsedDiffEntry[],
138
+ maxLineEntries: number,
139
+ ): { entries: ParsedDiffEntry[]; totalLineEntries: number; processedLineEntries: number } {
140
+ const totalLineEntries = countDiffLineEntries(entries);
141
+ if (!Number.isFinite(maxLineEntries) || maxLineEntries >= totalLineEntries) {
142
+ return { entries, totalLineEntries, processedLineEntries: totalLineEntries };
143
+ }
144
+ const limited: ParsedDiffEntry[] = [];
145
+ let processedLineEntries = 0;
146
+ for (const entry of entries) {
147
+ if (entry.kind === "line") {
148
+ if (processedLineEntries >= maxLineEntries) break;
149
+ processedLineEntries++;
150
+ }
151
+ limited.push(entry);
152
+ }
153
+ return { entries: limited, totalLineEntries, processedLineEntries };
154
+ }
155
+
156
+ export function takeSplitRowsForBudget(
157
+ rows: SplitDiffRow[],
158
+ maxRows: number,
159
+ ): { rows: SplitDiffRow[]; totalRows: number; processedRows: number } {
160
+ const totalRows = rows.length;
161
+ if (!Number.isFinite(maxRows) || maxRows >= totalRows) {
162
+ return { rows, totalRows, processedRows: totalRows };
163
+ }
164
+ const limited = rows.slice(0, maxRows);
165
+ return { rows: limited, totalRows, processedRows: limited.length };
166
+ }
167
+
168
+ export function applyLineLimit(
169
+ rows: RenderedRow[],
170
+ width: number,
171
+ expanded: boolean,
172
+ maxCollapsedLines: number,
173
+ totalHunks: number,
174
+ theme: DiffTheme,
175
+ /**
176
+ * When the caller only rendered a prefix of the full diff, pass the unprocessed
177
+ * remainder so the collapse hint still reflects total hidden content.
178
+ */
179
+ unprocessedLogicalRows = 0,
180
+ hovered = false,
181
+ ): { lines: string[]; hintLine?: string } {
182
+ const limit = resolveDiffDisplayLimit(expanded, maxCollapsedLines);
183
+ const safeUnprocessed = Math.max(0, unprocessedLogicalRows);
184
+ if (rows.length <= limit && safeUnprocessed === 0) {
185
+ return { lines: rows.map((row) => clampDiffLineToWidth(row.text, width)) };
186
+ }
187
+
188
+ const shown = rows.length <= limit ? rows : rows.slice(0, limit);
189
+ const remaining = Math.max(0, rows.length - shown.length) + safeUnprocessed;
190
+ if (remaining === 0) {
191
+ return { lines: shown.map((row) => clampDiffLineToWidth(row.text, width)) };
192
+ }
193
+ const visibleHunks = new Set(
194
+ shown
195
+ .map((row) => row.hunkIndex)
196
+ .filter((hunkIndex): hunkIndex is number => typeof hunkIndex === "number" && hunkIndex > 0),
197
+ );
198
+ const hiddenHunks = Math.max(0, totalHunks - visibleHunks.size);
199
+ const hintText = buildCollapsedDiffHintText(
200
+ {
201
+ remainingLines: remaining,
202
+ hiddenHunks,
203
+ },
204
+ width,
205
+ DIFF_WIDTH_OPS,
206
+ );
207
+
208
+ const clickLabel = showMoreHintText();
209
+ const clickIndex = hintText.lastIndexOf(clickLabel);
210
+ // 展开态不会再截断,剩下的分支必是折叠态提示。
211
+ const styledHint =
212
+ hovered && clickIndex >= 0
213
+ ? theme.fg("muted", hintText.slice(0, clickIndex)) +
214
+ theme.fg("text", clickLabel) +
215
+ theme.fg("muted", hintText.slice(clickIndex + clickLabel.length))
216
+ : theme.fg("muted", hintText);
217
+ // spacer 只在有正文行时才有分隔对象(split 网格 ↔ 提示行);零预算
218
+ // (editDiffCollapsedLines: 0)时 shown 为空,无条件插入会在标题摘要和
219
+ // 提示行之间多出一个孤零零的空行(一南 2026-10-02)。
220
+ const spacer = shown.length ? [renderDiffSpacerLine(width)] : [];
221
+ return {
222
+ lines: [
223
+ ...shown.map((row) => clampDiffLineToWidth(row.text, width)),
224
+ ...spacer,
225
+ clampDiffLineToWidth(styledHint, width),
226
+ ],
227
+ hintLine: hintText,
228
+ };
229
+ }
@@ -0,0 +1,410 @@
1
+ import {
2
+ ANSI_SGR_PATTERN,
3
+ expandSgrReset,
4
+ filterSgrSequences,
5
+ readSgrColorSequence,
6
+ toSgrParams,
7
+ } from "./ansi-utils.ts";
8
+ import { ansi256ToRgb } from "../ansi-color.ts";
9
+ import { fitToWidth } from "./diff-text.ts";
10
+ import { mergeSpans, type DiffSpan } from "./diff-inline.ts";
11
+ import type { DiffLineKind } from "./diff-parse.ts";
12
+
13
+ export interface DiffTheme {
14
+ fg(color: string, text: string): string;
15
+ bg?(color: string, text: string): string;
16
+ bold?(text: string): string;
17
+ getFgAnsi?(color: string): string;
18
+ getBgAnsi?(color: string): string;
19
+ }
20
+
21
+ export interface RgbColor {
22
+ r: number;
23
+ g: number;
24
+ b: number;
25
+ }
26
+
27
+ export interface DiffPalette {
28
+ addRowBgAnsi: string;
29
+ removeRowBgAnsi: string;
30
+ addEmphasisBgAnsi: string;
31
+ removeEmphasisBgAnsi: string;
32
+ }
33
+
34
+ const ADD_ROW_BACKGROUND_MIX_RATIO = 0.12;
35
+ const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
36
+ const ADD_INLINE_EMPHASIS_MIX_RATIO = 0.26;
37
+ const REMOVE_INLINE_EMPHASIS_MIX_RATIO = 0.26;
38
+ const ADDITION_TINT_TARGET: RgbColor = { r: 84, g: 190, b: 118 };
39
+ const DELETION_TINT_TARGET: RgbColor = { r: 232, g: 95, b: 122 };
40
+ export const ANSI_BG_RESET = "\x1b[49m";
41
+
42
+ export function emphasis(theme: DiffTheme, text: string): string {
43
+ return typeof theme.bold === "function" ? theme.bold(text) : text;
44
+ }
45
+
46
+ function sequenceResetsBackground(params: number[]): boolean {
47
+ let index = 0;
48
+ while (index < params.length) {
49
+ const param = params[index] ?? 0;
50
+ if (param === 0 || param === 49) {
51
+ return true;
52
+ }
53
+
54
+ const colorSequence = readSgrColorSequence(params, index);
55
+ index += colorSequence ? colorSequence.length : 1;
56
+ }
57
+
58
+ return false;
59
+ }
60
+
61
+ function stripBackgroundResetParams(params: number[]): number[] {
62
+ const filtered: number[] = [];
63
+
64
+ for (let i = 0; i < params.length; i++) {
65
+ const param = params[i] ?? 0;
66
+
67
+ if (param === 0) {
68
+ filtered.push(...expandSgrReset(param)!);
69
+ continue;
70
+ }
71
+
72
+ if (param === 49) {
73
+ continue;
74
+ }
75
+
76
+ const colorSequence = readSgrColorSequence(params, i);
77
+ if (colorSequence) {
78
+ filtered.push(...colorSequence);
79
+ i += colorSequence.length - 1;
80
+ continue;
81
+ }
82
+
83
+ filtered.push(param);
84
+ }
85
+
86
+ return filtered;
87
+ }
88
+
89
+ export function stabilizeBackgroundResets(text: string): string {
90
+ if (!text) {
91
+ return text;
92
+ }
93
+ return filterSgrSequences(text, stripBackgroundResetParams);
94
+ }
95
+
96
+ export function keepBackgroundAcrossResets(text: string, rowBg: string): string {
97
+ if (!text) {
98
+ return text;
99
+ }
100
+
101
+ return text.replace(ANSI_SGR_PATTERN, (sequence, rawParams: string) => {
102
+ const params = toSgrParams(rawParams);
103
+ if (params.length === 0 || !sequenceResetsBackground(params)) {
104
+ return sequence;
105
+ }
106
+ return `${sequence}${rowBg}`;
107
+ });
108
+ }
109
+
110
+ export function colorizeSegment(
111
+ theme: DiffTheme,
112
+ color: "dim" | "toolDiffAdded" | "toolDiffRemoved",
113
+ text: string,
114
+ rowBg: string | undefined,
115
+ ): string {
116
+ let themedText: string;
117
+ try {
118
+ themedText = theme.fg(color, text);
119
+ } catch {
120
+ themedText = text;
121
+ }
122
+
123
+ if (!rowBg) {
124
+ return themedText;
125
+ }
126
+
127
+ const stableText = keepBackgroundAcrossResets(themedText, rowBg);
128
+ return `${rowBg}${stableText}${rowBg}`;
129
+ }
130
+
131
+ function applyBackgroundToVisualRow(
132
+ text: string,
133
+ width: number,
134
+ rowBgAnsi: string,
135
+ restoreBgAnsi: string,
136
+ ): string {
137
+ if (width <= 0) {
138
+ return "";
139
+ }
140
+
141
+ const fitted = fitToWidth(text, width);
142
+ const withStableBackground = keepBackgroundAcrossResets(fitted, rowBgAnsi);
143
+ return stabilizeBackgroundResets(`${rowBgAnsi}${withStableBackground}${restoreBgAnsi}`);
144
+ }
145
+
146
+ export function applyLineBackgroundToWrappedRows(
147
+ rows: string[],
148
+ width: number,
149
+ rowBgAnsi: string,
150
+ restoreBgAnsi: string,
151
+ ): string[] {
152
+ if (rows.length === 0) {
153
+ return [applyBackgroundToVisualRow("", width, rowBgAnsi, restoreBgAnsi)];
154
+ }
155
+
156
+ return rows.map((row) => applyBackgroundToVisualRow(row, width, rowBgAnsi, restoreBgAnsi));
157
+ }
158
+
159
+ /** utils 版返回 tuple,此处适配为本文件使用的 RgbColor 形状。 */
160
+ function ansi256ToRgbColor(code: number): RgbColor {
161
+ const [r, g, b] = ansi256ToRgb(code);
162
+ return { r, g, b };
163
+ }
164
+
165
+ export function parseAnsiColorCode(ansi: string | undefined): RgbColor | null {
166
+ if (!ansi) {
167
+ return null;
168
+ }
169
+ const rgbMatch = /\x1b\[(?:3|4)8;2;(\d{1,3});(\d{1,3});(\d{1,3})m/.exec(ansi);
170
+ if (rgbMatch) {
171
+ const r = Number.parseInt(rgbMatch[1] ?? "0", 10);
172
+ const g = Number.parseInt(rgbMatch[2] ?? "0", 10);
173
+ const b = Number.parseInt(rgbMatch[3] ?? "0", 10);
174
+ if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
175
+ return {
176
+ r: Math.max(0, Math.min(255, r)),
177
+ g: Math.max(0, Math.min(255, g)),
178
+ b: Math.max(0, Math.min(255, b)),
179
+ };
180
+ }
181
+ }
182
+
183
+ const bitMatch = /\x1b\[(?:3|4)8;5;(\d{1,3})m/.exec(ansi);
184
+ if (bitMatch) {
185
+ const code = Number.parseInt(bitMatch[1] ?? "0", 10);
186
+ if (Number.isFinite(code)) {
187
+ return ansi256ToRgbColor(code);
188
+ }
189
+ }
190
+
191
+ const basicMatch = /\x1b\[(?:3|4)([0-7])m/.exec(ansi);
192
+ if (basicMatch) return ansi256ToRgbColor(Number(basicMatch[1]));
193
+ const brightMatch = /\x1b\[(?:9|10)([0-7])m/.exec(ansi);
194
+ if (brightMatch) return ansi256ToRgbColor(Number(brightMatch[1]) + 8);
195
+ return null;
196
+ }
197
+
198
+ function rgbToBgAnsi(color: RgbColor): string {
199
+ const r = Math.max(0, Math.min(255, Math.round(color.r)));
200
+ const g = Math.max(0, Math.min(255, Math.round(color.g)));
201
+ const b = Math.max(0, Math.min(255, Math.round(color.b)));
202
+ return `\x1b[48;2;${r};${g};${b}m`;
203
+ }
204
+
205
+ function mixRgb(base: RgbColor, tint: RgbColor, ratio: number): RgbColor {
206
+ const clamped = Math.max(0, Math.min(1, ratio));
207
+ return {
208
+ r: base.r * (1 - clamped) + tint.r * clamped,
209
+ g: base.g * (1 - clamped) + tint.g * clamped,
210
+ b: base.b * (1 - clamped) + tint.b * clamped,
211
+ };
212
+ }
213
+
214
+ function extractThemeBackgroundAnsi(text: string): string | undefined {
215
+ if (!text || !text.includes("\x1b[")) {
216
+ return undefined;
217
+ }
218
+
219
+ ANSI_SGR_PATTERN.lastIndex = 0;
220
+ let match: RegExpExecArray | null;
221
+ while ((match = ANSI_SGR_PATTERN.exec(text)) !== null) {
222
+ const parsed = toSgrParams(match[1] ?? "");
223
+ for (let index = 0; index < parsed.length; index += 1) {
224
+ const param = parsed[index] ?? 0;
225
+ if ((param >= 40 && param <= 47) || (param >= 100 && param <= 107)) {
226
+ return `\x1b[${param}m`;
227
+ }
228
+
229
+ const colorSequence = readSgrColorSequence(parsed, index);
230
+ if (colorSequence?.[0] === 48) {
231
+ return `\x1b[${colorSequence.join(";")}m`;
232
+ }
233
+ if (colorSequence) {
234
+ index += colorSequence.length - 1;
235
+ }
236
+ }
237
+ }
238
+
239
+ return undefined;
240
+ }
241
+
242
+ export function readThemeAnsi(
243
+ theme: DiffTheme,
244
+ kind: "fg" | "bg",
245
+ slot: string,
246
+ ): string | undefined {
247
+ try {
248
+ if (kind === "fg" && typeof theme.getFgAnsi === "function") {
249
+ return theme.getFgAnsi(slot);
250
+ }
251
+ if (kind === "bg") {
252
+ if (typeof theme.getBgAnsi === "function") {
253
+ return theme.getBgAnsi(slot);
254
+ }
255
+ if (typeof theme.bg === "function") {
256
+ return extractThemeBackgroundAnsi(theme.bg(slot, " "));
257
+ }
258
+ }
259
+ } catch {
260
+ return undefined;
261
+ }
262
+ return undefined;
263
+ }
264
+
265
+ export function resolveDiffPalette(theme: DiffTheme): DiffPalette {
266
+ const baseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ??
267
+ parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg")) ??
268
+ parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg")) ?? { r: 32, g: 35, b: 42 };
269
+ const addFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffAdded")) ?? {
270
+ r: 88,
271
+ g: 173,
272
+ b: 88,
273
+ };
274
+ const removeFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffRemoved")) ?? {
275
+ r: 196,
276
+ g: 98,
277
+ b: 98,
278
+ };
279
+ const addTint = mixRgb(addFg, ADDITION_TINT_TARGET, 0.35);
280
+ const removeTint = mixRgb(removeFg, DELETION_TINT_TARGET, 0.65);
281
+
282
+ const addRowBg = mixRgb(baseBg, addTint, ADD_ROW_BACKGROUND_MIX_RATIO);
283
+ const removeRowBg = mixRgb(baseBg, removeTint, REMOVE_ROW_BACKGROUND_MIX_RATIO);
284
+ const addEmphasisBg = mixRgb(baseBg, addTint, ADD_INLINE_EMPHASIS_MIX_RATIO);
285
+ const removeEmphasisBg = mixRgb(baseBg, removeTint, REMOVE_INLINE_EMPHASIS_MIX_RATIO);
286
+
287
+ return {
288
+ addRowBgAnsi: rgbToBgAnsi(addRowBg),
289
+ removeRowBgAnsi: rgbToBgAnsi(removeRowBg),
290
+ addEmphasisBgAnsi: rgbToBgAnsi(addEmphasisBg),
291
+ removeEmphasisBgAnsi: rgbToBgAnsi(removeEmphasisBg),
292
+ };
293
+ }
294
+
295
+ export function getLineBackground(
296
+ kind: DiffLineKind,
297
+ palette: DiffPalette,
298
+ emphasis: boolean,
299
+ ): string | undefined {
300
+ if (kind === "add") {
301
+ return emphasis ? palette.addEmphasisBgAnsi : palette.addRowBgAnsi;
302
+ }
303
+ if (kind === "remove") {
304
+ return emphasis ? palette.removeEmphasisBgAnsi : palette.removeRowBgAnsi;
305
+ }
306
+ return undefined;
307
+ }
308
+
309
+ function applyBackgroundToVisibleRange(
310
+ ansiText: string,
311
+ start: number,
312
+ end: number,
313
+ backgroundAnsi: string,
314
+ restoreBackgroundAnsi: string,
315
+ ): string {
316
+ if (!ansiText || start >= end || end <= 0) {
317
+ return ansiText;
318
+ }
319
+
320
+ const rangeStart = Math.max(0, start);
321
+ const rangeEnd = Math.max(rangeStart, end);
322
+ let output = "";
323
+ let visibleIndex = 0;
324
+ let index = 0;
325
+ let inRange = false;
326
+
327
+ while (index < ansiText.length) {
328
+ if (ansiText[index] === "\x1b") {
329
+ const sequenceEnd = ansiText.indexOf("m", index);
330
+ if (sequenceEnd !== -1) {
331
+ output += ansiText.slice(index, sequenceEnd + 1);
332
+ index = sequenceEnd + 1;
333
+ continue;
334
+ }
335
+ }
336
+
337
+ if (visibleIndex === rangeStart && !inRange) {
338
+ output += backgroundAnsi;
339
+ inRange = true;
340
+ }
341
+ if (visibleIndex === rangeEnd && inRange) {
342
+ output += restoreBackgroundAnsi;
343
+ inRange = false;
344
+ }
345
+
346
+ output += ansiText[index] ?? "";
347
+ visibleIndex++;
348
+ index++;
349
+ }
350
+
351
+ if (inRange) {
352
+ output += restoreBackgroundAnsi;
353
+ }
354
+
355
+ return output;
356
+ }
357
+
358
+ export function applyInlineSpanHighlight(
359
+ plainText: string,
360
+ renderedText: string,
361
+ spans: DiffSpan[],
362
+ emphasisBgAnsi: string | undefined,
363
+ rowBgAnsi: string | undefined,
364
+ fallbackBgAnsi: string | undefined,
365
+ ): string {
366
+ if (!renderedText || !plainText || spans.length === 0 || !emphasisBgAnsi) {
367
+ return renderedText;
368
+ }
369
+
370
+ const sorted = mergeSpans(
371
+ spans
372
+ .map((span) => ({
373
+ start: Math.max(0, Math.min(plainText.length, span.start)),
374
+ end: Math.max(0, Math.min(plainText.length, span.end)),
375
+ }))
376
+ .filter((span) => span.end > span.start),
377
+ );
378
+ if (sorted.length === 0) {
379
+ return renderedText;
380
+ }
381
+
382
+ const restoreBackgroundAnsi = rowBgAnsi ?? fallbackBgAnsi ?? ANSI_BG_RESET;
383
+ let highlighted = renderedText;
384
+ for (let index = sorted.length - 1; index >= 0; index--) {
385
+ const span = sorted[index];
386
+ if (!span) {
387
+ continue;
388
+ }
389
+ highlighted = applyBackgroundToVisibleRange(
390
+ highlighted,
391
+ span.start,
392
+ span.end,
393
+ emphasisBgAnsi,
394
+ restoreBackgroundAnsi,
395
+ );
396
+ }
397
+
398
+ return highlighted;
399
+ }
400
+
401
+ export function resolveShikiTheme(theme: DiffTheme): string {
402
+ if (process.env.DIFF_THEME) return process.env.DIFF_THEME;
403
+ const background =
404
+ parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ??
405
+ parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg")) ??
406
+ parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg"));
407
+ if (!background) return "github-dark";
408
+ const luminance = (background.r * 299 + background.g * 587 + background.b * 114) / 1000;
409
+ return luminance >= 128 ? "github-light" : "github-dark";
410
+ }