@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,531 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { type MeasuredTerminalColors, terminalColorsRegistry } from "../terminal-colors.ts";
3
+ import {
4
+ color256Index,
5
+ generateColor256,
6
+ gray256Index,
7
+ type RgbColor,
8
+ rgb,
9
+ swatch,
10
+ type TuiSwatch,
11
+ xtermColor,
12
+ yiqLuminance,
13
+ } from "./palette.ts";
14
+ import { type ColorValue, createColorResolver } from "./resolver.ts";
15
+
16
+ export type { TuiHue, TuiShade, TuiSwatch } from "./palette.ts";
17
+
18
+ export type TuiForegroundToken =
19
+ | "text.primary"
20
+ | "text.secondary"
21
+ | "text.muted"
22
+ | "border"
23
+ | "heading"
24
+ | "cursor.idleText"
25
+ | "cursor.selectedText"
26
+ | "accent"
27
+ | "info"
28
+ | "positive"
29
+ | "highlight"
30
+ | "warning"
31
+ | "negative";
32
+
33
+ export type TuiBackgroundToken =
34
+ | "surface.base"
35
+ | "surface.editor"
36
+ | "surface.accent"
37
+ | "surface.raised"
38
+ | "surface.inset"
39
+ | "surface.cursor"
40
+ | "surface.selected"
41
+ | "surface.hover"
42
+ | "cursor.idle"
43
+ | "cursor.selected"
44
+ | "action.neutral"
45
+ | "action.positive"
46
+ | "action.warning"
47
+ | "action.negative"
48
+ | "badge.neutral"
49
+ | "badge.positive"
50
+ | "badge.warning"
51
+ | "badge.negative"
52
+ | "diff.hunk"
53
+ | "diff.hunkGutter"
54
+ | "diff.hunkHover"
55
+ | "diff.hunkGutterHover"
56
+ | "diff.added"
57
+ | "diff.removed"
58
+ | "diff.addedGutter"
59
+ | "diff.removedGutter"
60
+ | "diff.addedEmphasis"
61
+ | "diff.removedEmphasis";
62
+
63
+ export type TuiForegroundColor = TuiForegroundToken | TuiSwatch;
64
+
65
+ const TUI_COLOR_REF: unique symbol = Symbol("pi-libtui-color-ref");
66
+
67
+ /** Opaque color produced by a TuiTheme. Callers can pass it back but cannot forge palette indexes. */
68
+ export interface TuiColor {
69
+ readonly [TUI_COLOR_REF]: ColorRef;
70
+ }
71
+
72
+ export type TuiForegroundPaint = TuiForegroundColor | TuiColor;
73
+ export type TuiBackgroundPaint = TuiBackgroundToken | TuiColor;
74
+
75
+ /** The component-facing semantic color facade. */
76
+ export interface TuiTheme {
77
+ color(token: TuiForegroundColor | TuiBackgroundToken): TuiColor;
78
+ mixForeground(from: TuiForegroundPaint, to: TuiForegroundPaint, amount: number): TuiColor;
79
+ adjustForegroundBrightness(paint: TuiForegroundPaint, amount: number): TuiColor;
80
+ fg(paint: TuiForegroundPaint, text: string): string;
81
+ bg(paint: TuiBackgroundPaint, text: string): string;
82
+ fgAnsi(paint: TuiForegroundPaint): string;
83
+ bgAnsi(paint: TuiBackgroundPaint): string;
84
+ contrastBackground(color: TuiColor): TuiColor;
85
+ strongestForegroundContrast(base: TuiForegroundPaint, candidates: readonly TuiForegroundPaint[]): TuiColor;
86
+ }
87
+
88
+ /** Complete Pi theme document derived from a host theme with shifted surfaces for an adjacent TUI. */
89
+ export interface TuiThemeVariation {
90
+ readonly $schema: string;
91
+ readonly name: string;
92
+ readonly colors: Readonly<Record<string, string>>;
93
+ }
94
+
95
+ /** Light/dark appearance derived from the surface behind the active Pi theme. */
96
+ export type TuiThemeAppearance = "dark" | "light";
97
+
98
+ type SemanticToken = TuiForegroundToken | TuiBackgroundToken;
99
+ type PiThemeBackground = Parameters<Theme["bg"]>[0];
100
+ type ColorRef = SemanticToken | ColorValue;
101
+ type ColorResolver = ReturnType<typeof createColorResolver>;
102
+
103
+ interface ThemeAliases {
104
+ readonly secondary?: RgbColor;
105
+ readonly muted?: RgbColor;
106
+ readonly border?: RgbColor;
107
+ readonly heading?: RgbColor;
108
+ readonly raised?: RgbColor;
109
+ readonly editor?: RgbColor;
110
+ readonly selected?: RgbColor;
111
+ readonly accent?: RgbColor;
112
+ }
113
+
114
+ interface CachedTheme {
115
+ readonly fingerprint: string;
116
+ readonly measurements: MeasuredTerminalColors | undefined;
117
+ readonly facade: TuiTheme;
118
+ readonly resolver: ColorResolver;
119
+ }
120
+
121
+ const themeCache = new WeakMap<Theme, CachedTheme>();
122
+
123
+ export function tuiTheme(theme: Theme): TuiTheme {
124
+ return ensureCachedTheme(theme).facade;
125
+ }
126
+
127
+ /** Resolve the active Pi surface appearance for adjacent applications that cannot query the outer terminal. */
128
+ export function tuiThemeAppearance(theme: Theme): TuiThemeAppearance {
129
+ const background =
130
+ terminalColorsRegistry().current()?.defaultBackground ??
131
+ hostBackgroundColor(theme, "toolPendingBg") ??
132
+ rgb(24, 24, 24);
133
+ return yiqLuminance(background) > 127 ? "light" : "dark";
134
+ }
135
+
136
+ const VARIATION_FOREGROUND_TOKENS = [
137
+ "accent",
138
+ "border",
139
+ "borderAccent",
140
+ "borderMuted",
141
+ "success",
142
+ "error",
143
+ "warning",
144
+ "muted",
145
+ "dim",
146
+ "text",
147
+ "thinkingText",
148
+ "searchMatchText",
149
+ "userMessageText",
150
+ "customMessageText",
151
+ "customMessageLabel",
152
+ "toolTitle",
153
+ "toolOutput",
154
+ "mdHeading",
155
+ "mdLink",
156
+ "mdLinkUrl",
157
+ "mdCode",
158
+ "mdCodeBlock",
159
+ "mdCodeBlockBorder",
160
+ "mdQuote",
161
+ "mdQuoteBorder",
162
+ "mdHr",
163
+ "mdListBullet",
164
+ "toolDiffAdded",
165
+ "toolDiffRemoved",
166
+ "toolDiffContext",
167
+ "syntaxComment",
168
+ "syntaxKeyword",
169
+ "syntaxFunction",
170
+ "syntaxVariable",
171
+ "syntaxString",
172
+ "syntaxNumber",
173
+ "syntaxType",
174
+ "syntaxOperator",
175
+ "syntaxPunctuation",
176
+ "thinkingOff",
177
+ "thinkingMinimal",
178
+ "thinkingLow",
179
+ "thinkingMedium",
180
+ "thinkingHigh",
181
+ "thinkingXhigh",
182
+ "thinkingMax",
183
+ "bashMode",
184
+ ] as const satisfies readonly ThemeColor[];
185
+
186
+ const VARIATION_BACKGROUND_TOKENS = [
187
+ "selectedBg",
188
+ "scrollbarThumb",
189
+ "searchMatchBg",
190
+ "userMessageBg",
191
+ "customMessageBg",
192
+ "toolPendingBg",
193
+ "toolSuccessBg",
194
+ "toolErrorBg",
195
+ ] as const satisfies readonly PiThemeBackground[];
196
+
197
+ /** Derive a complete non-persisted Pi theme that remains related to, but distinct from, its host. */
198
+ export function createTuiThemeVariation(theme: Theme, name: string): TuiThemeVariation {
199
+ const colors: Record<string, string> = {};
200
+ for (const token of VARIATION_FOREGROUND_TOKENS) colors[token] = cssColor(hostForegroundColor(theme, token));
201
+ for (const token of VARIATION_BACKGROUND_TOKENS) colors[token] = cssColor(hostBackgroundColor(theme, token));
202
+
203
+ const selected = colors.selectedBg ?? "";
204
+ const inset = colors.toolPendingBg ?? selected;
205
+ colors.selectedBg = inset;
206
+ colors.scrollbarThumb = inset;
207
+ colors.searchMatchBg = inset;
208
+ colors.userMessageBg = inset;
209
+ colors.toolPendingBg = selected;
210
+ colors.accent = colors.mdHeading || colors.accent || "";
211
+ colors.borderAccent = colors.accent;
212
+ colors.customMessageLabel = colors.accent;
213
+ colors.toolTitle = colors.accent;
214
+
215
+ return {
216
+ $schema:
217
+ "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
218
+ name,
219
+ colors,
220
+ };
221
+ }
222
+
223
+ function ensureCachedTheme(theme: Theme): CachedTheme {
224
+ const measurements = terminalColorsRegistry().current();
225
+ const fingerprint = themeFingerprint(theme);
226
+ const cached = themeCache.get(theme);
227
+ if (cached && cached.measurements === measurements && cached.fingerprint === fingerprint) return cached;
228
+ const created = createTuiTheme(theme, measurements);
229
+ const entry = { fingerprint, measurements, ...created };
230
+ themeCache.set(theme, entry);
231
+ return entry;
232
+ }
233
+
234
+ function createTuiTheme(
235
+ theme: Theme,
236
+ measurements: MeasuredTerminalColors | undefined,
237
+ ): { facade: TuiTheme; resolver: ColorResolver } {
238
+ let resolver: ColorResolver;
239
+ let aliases: ThemeAliases = { editor: hostBackgroundColor(theme, "customMessageBg") };
240
+ if (theme.name === "harmonious" && measurements?.indexedPalette === "generated") {
241
+ const resolvedPalette = measurements.ansiBase16
242
+ ? generateColor256(
243
+ measurements.ansiBase16,
244
+ measurements.defaultBackground ?? measurements.ansiBase16[0]!,
245
+ measurements.defaultForeground ?? measurements.ansiBase16[15]!,
246
+ )
247
+ : undefined;
248
+ resolver = createColorResolver({ resolvedPalette, output: "direct-indexed" });
249
+ } else if (theme.name === "harmonious" && measurements?.ansiBase16) {
250
+ const background = measurements.defaultBackground ?? measurements.ansiBase16[0]!;
251
+ const foreground = measurements.defaultForeground ?? measurements.ansiBase16[15]!;
252
+ const resolvedPalette = generateColor256(measurements.ansiBase16, background, foreground);
253
+ resolver = createColorResolver({
254
+ resolvedPalette,
255
+ output: theme.getColorMode?.() === "256color" ? "quantized-indexed" : "truecolor",
256
+ });
257
+ } else {
258
+ const background =
259
+ hostBackgroundColor(theme, "toolPendingBg") ?? measurements?.defaultBackground ?? rgb(24, 24, 24);
260
+ const foreground =
261
+ hostForegroundColor(theme, "text") ?? measurements?.defaultForeground ?? contrastingText(background);
262
+ const anchors = [
263
+ background,
264
+ hostForegroundColor(theme, "error") ?? rgb(204, 4, 3),
265
+ hostForegroundColor(theme, "success") ?? rgb(25, 203, 0),
266
+ hostForegroundColor(theme, "warning") ?? rgb(206, 203, 0),
267
+ hostForegroundColor(theme, "accent") ?? rgb(13, 115, 204),
268
+ hostForegroundColor(theme, "syntaxKeyword") ?? rgb(203, 30, 209),
269
+ hostForegroundColor(theme, "mdLink") ?? rgb(13, 205, 205),
270
+ foreground,
271
+ ] as const;
272
+ // Keep the measured theme anchors as the complete ANSI base. Extended colors
273
+ // are generated from these anchors; no post-hoc RGB brightening is applied.
274
+ const base16 = [...anchors, ...anchors];
275
+ const resolvedPalette = generateColor256(base16, background, foreground);
276
+ resolver = createColorResolver({
277
+ resolvedPalette,
278
+ output: theme.getColorMode?.() === "256color" ? "quantized-indexed" : "truecolor",
279
+ });
280
+ aliases = {
281
+ ...aliases,
282
+ secondary: hostForegroundColor(theme, "muted"),
283
+ muted: hostForegroundColor(theme, "dim"),
284
+ border: hostForegroundColor(theme, "border"),
285
+ heading: hostForegroundColor(theme, "mdHeading"),
286
+ raised: hostBackgroundColor(theme, "toolPendingBg"),
287
+ selected: hostBackgroundColor(theme, "selectedBg"),
288
+ accent: hostForegroundColor(theme, "accent"),
289
+ };
290
+ }
291
+ return { facade: buildTheme(resolver, aliases), resolver };
292
+ }
293
+
294
+ function buildTheme(resolver: ColorResolver, aliases: ThemeAliases): TuiTheme {
295
+ const background = color256Index(0, 0, 0);
296
+ const foreground = color256Index(5, 5, 5);
297
+ const selected = aliases.selected ?? swatch("blue", 1);
298
+ const selectedCursor = color256Index(5, 3, 0);
299
+ const lightSurface = yiqLuminance(resolver.rgb(background)) > yiqLuminance(resolver.rgb(foreground));
300
+ const editorBase = aliases.editor ?? aliases.raised ?? gray256Index(1);
301
+ const editorRgb = resolver.rgb(editorBase);
302
+ const editorSurface =
303
+ yiqLuminance(editorRgb) > 127 ? editorBase : rgb(editorRgb.r * 0.78, editorRgb.g * 0.78, editorRgb.b * 0.78);
304
+ const backgroundRgb = resolver.rgb(background);
305
+ const accentRgb = resolver.rgb(aliases.accent ?? swatch("blue", 5));
306
+ const accentSurface = rgb(
307
+ Math.round(backgroundRgb.r * 0.82 + accentRgb.r * 0.18),
308
+ Math.round(backgroundRgb.g * 0.82 + accentRgb.g * 0.18),
309
+ Math.round(backgroundRgb.b * 0.82 + accentRgb.b * 0.18),
310
+ );
311
+ const added = lightSurface ? swatch("green", 2) : swatch("green", 1);
312
+ const removed = lightSurface ? swatch("red", 2) : swatch("red", 1);
313
+ const addedGutter = lightSurface ? swatch("green", 1) : swatch("green", 2);
314
+ const removedGutter = lightSurface ? swatch("red", 1) : swatch("red", 2);
315
+ const semantic = {
316
+ "text.primary": foreground,
317
+ "text.secondary": aliases.secondary ?? gray256Index(12),
318
+ "text.muted": aliases.muted ?? gray256Index(8),
319
+ border: aliases.border ?? color256Index(1, 2, 3),
320
+ heading: aliases.heading ?? color256Index(5, 3, 0),
321
+ "cursor.idleText": background,
322
+ "cursor.selectedText": readableForeground(selectedCursor, background, foreground, resolver),
323
+ accent: aliases.accent ?? swatch("blue", 5),
324
+ info: swatch("cyan", 5),
325
+ positive: swatch("green", 5),
326
+ highlight: color256Index(5, 5, 1),
327
+ warning: swatch("yellow", 5),
328
+ negative: swatch("red", 5),
329
+ "surface.base": background,
330
+ "surface.editor": editorSurface,
331
+ "surface.accent": accentSurface,
332
+ "surface.raised": aliases.raised ?? gray256Index(1),
333
+ "surface.inset": background,
334
+ "surface.cursor": color256Index(0, 1, 2),
335
+ "surface.selected": selected,
336
+ "surface.hover": swatch("blue", 2),
337
+ "cursor.idle": selectedCursor,
338
+ "cursor.selected": selectedCursor,
339
+ "action.neutral": selected,
340
+ "action.positive": swatch("green", 1),
341
+ "action.warning": swatch("yellow", 1),
342
+ "action.negative": swatch("red", 1),
343
+ "badge.neutral": aliases.raised ?? gray256Index(2),
344
+ "badge.positive": swatch("green", 1),
345
+ "badge.warning": swatch("yellow", 1),
346
+ "badge.negative": swatch("red", 1),
347
+ // Hunk headers are structural separators, not selected rows. Keep the
348
+ // narrow fold cell one blue step brighter than the full header surface.
349
+ "diff.hunk": swatch("blue", 1),
350
+ "diff.hunkGutter": swatch("blue", 2),
351
+ "diff.hunkHover": swatch("blue", 2),
352
+ "diff.hunkGutterHover": swatch("blue", 3),
353
+ "diff.added": added,
354
+ "diff.removed": removed,
355
+ "diff.addedGutter": addedGutter,
356
+ "diff.removedGutter": removedGutter,
357
+ "diff.addedEmphasis": swatch("green", 3),
358
+ "diff.removedEmphasis": swatch("red", 3),
359
+ } as const satisfies Record<SemanticToken, ColorValue>;
360
+ const publicColors = new Map<ColorRef, TuiColor>();
361
+ const publicColor = (ref: ColorRef): TuiColor => {
362
+ const cached = publicColors.get(ref);
363
+ if (cached) return cached;
364
+ const color = createColor(ref);
365
+ publicColors.set(ref, color);
366
+ return color;
367
+ };
368
+ const refForPaint = (paint: TuiForegroundPaint | TuiBackgroundPaint): ColorRef => {
369
+ if (typeof paint === "string") return paint;
370
+ if ("hue" in paint) return swatch(paint.hue, paint.shade);
371
+ return requireColorRef(paint);
372
+ };
373
+ const resolve = (ref: ColorRef): ColorValue => {
374
+ if (typeof ref !== "string") return ref;
375
+ const value = semantic[ref];
376
+ if (value === undefined) throw new Error(`Unknown pi-libtui semantic color: ${ref}`);
377
+ return value;
378
+ };
379
+ const ansi = (ref: ColorRef, background: boolean): string => resolver.ansi(resolve(ref), background);
380
+ const paint = (ref: ColorRef, text: string, backgroundColor: boolean): string =>
381
+ `${ansi(ref, backgroundColor)}${text}${backgroundColor ? "\x1b[49m" : "\x1b[39m"}`;
382
+ const facade: TuiTheme = {
383
+ color: (color) => publicColor(typeof color === "string" ? color : swatch(color.hue, color.shade)),
384
+ mixForeground(from, to, amount) {
385
+ const left = resolver.rgb(resolve(refForPaint(from)));
386
+ const right = resolver.rgb(resolve(refForPaint(to)));
387
+ const position = Math.max(0, Math.min(1, Number.isFinite(amount) ? amount : 0));
388
+ return createColor(
389
+ rgb(
390
+ left.r + (right.r - left.r) * position,
391
+ left.g + (right.g - left.g) * position,
392
+ left.b + (right.b - left.b) * position,
393
+ ),
394
+ );
395
+ },
396
+ adjustForegroundBrightness(paint, amount) {
397
+ const source = resolver.rgb(resolve(refForPaint(paint)));
398
+ const position = Math.max(-1, Math.min(1, Number.isFinite(amount) ? amount : 0));
399
+ const adjust =
400
+ position < 0
401
+ ? (channel: number) => channel * (1 + position)
402
+ : (channel: number) => channel + (255 - channel) * position;
403
+ return createColor(rgb(adjust(source.r), adjust(source.g), adjust(source.b)));
404
+ },
405
+ fg(paintColor, text) {
406
+ return paint(refForPaint(paintColor), text, false);
407
+ },
408
+ bg(paintColor, text) {
409
+ return paint(refForPaint(paintColor), text, true);
410
+ },
411
+ fgAnsi(paintColor) {
412
+ return ansi(refForPaint(paintColor), false);
413
+ },
414
+ bgAnsi(paintColor) {
415
+ return ansi(refForPaint(paintColor), true);
416
+ },
417
+ contrastBackground(color) {
418
+ return createColor(resolver.contrast(resolve(requireColorRef(color))));
419
+ },
420
+ strongestForegroundContrast(base, candidates) {
421
+ const baseValue = resolve(refForPaint(base));
422
+ let strongest = baseValue;
423
+ let ratio = 1;
424
+ for (const candidate of candidates) {
425
+ const value = resolve(refForPaint(candidate));
426
+ const next = resolver.contrastRatio(baseValue, value);
427
+ if (next <= ratio) continue;
428
+ strongest = value;
429
+ ratio = next;
430
+ }
431
+ return createColor(strongest);
432
+ },
433
+ };
434
+ return Object.freeze(facade);
435
+ }
436
+
437
+ /** Parse an external SGR background at an ANSI boundary. Internal colors never round-trip through ANSI. */
438
+ export function parseBackgroundAnsi(ansi: string): RgbColor | undefined {
439
+ return parseAnsiColor(ansi, "background");
440
+ }
441
+
442
+ /** Derive a contrast color for content embedded in a caller-supplied ANSI background. */
443
+ export function contrastBackgroundColor(theme: Theme, surroundingBackgroundAnsi: string): TuiColor {
444
+ const resolver = ensureCachedTheme(theme).resolver;
445
+ const background =
446
+ parseBackgroundAnsi(surroundingBackgroundAnsi) ??
447
+ terminalColorsRegistry().current()?.defaultBackground ??
448
+ hostBackgroundColor(theme, "toolPendingBg") ??
449
+ rgb(24, 24, 24);
450
+ return createColor(resolver.contrast(background));
451
+ }
452
+
453
+ function createColor(ref: ColorRef): TuiColor {
454
+ return Object.freeze({ [TUI_COLOR_REF]: ref });
455
+ }
456
+
457
+ function requireColorRef(color: TuiColor): ColorRef {
458
+ const ref = color[TUI_COLOR_REF];
459
+ if (ref === undefined) throw new TypeError("Color must come from a pi-libtui TuiTheme");
460
+ return ref;
461
+ }
462
+
463
+ function readableForeground(
464
+ background: ColorValue,
465
+ left: ColorValue,
466
+ right: ColorValue,
467
+ resolver: ColorResolver,
468
+ ): ColorValue {
469
+ const source = resolver.rgb(background);
470
+ const distance = (candidate: ColorValue) => Math.abs(yiqLuminance(source) - yiqLuminance(resolver.rgb(candidate)));
471
+ return distance(left) >= distance(right) ? left : right;
472
+ }
473
+
474
+ function hostForegroundColor(theme: Theme, token: ThemeColor): RgbColor | undefined {
475
+ return typeof theme.getFgAnsi === "function" ? parseAnsiColor(theme.getFgAnsi(token), "foreground") : undefined;
476
+ }
477
+
478
+ function hostBackgroundColor(theme: Theme, token: PiThemeBackground): RgbColor | undefined {
479
+ return typeof theme.getBgAnsi === "function" ? parseAnsiColor(theme.getBgAnsi(token), "background") : undefined;
480
+ }
481
+
482
+ function parseAnsiColor(ansi: string, kind: "foreground" | "background"): RgbColor | undefined {
483
+ const prefix = kind === "foreground" ? 38 : 48;
484
+ const truecolor = new RegExp(`\\x1b\\[${prefix};2;(\\d+);(\\d+);(\\d+)m`, "u").exec(ansi);
485
+ if (truecolor) return rgb(Number(truecolor[1]), Number(truecolor[2]), Number(truecolor[3]));
486
+ const indexed = new RegExp(`\\x1b\\[${prefix};5;(\\d+)m`, "u").exec(ansi);
487
+ if (indexed) return xtermColor(Number(indexed[1]));
488
+ const basic = new RegExp(kind === "foreground" ? "\\x1b\\[(3[0-7]|9[0-7])m" : "\\x1b\\[(4[0-7]|10[0-7])m", "u").exec(
489
+ ansi,
490
+ );
491
+ if (!basic) return undefined;
492
+ const code = Number(basic[1]);
493
+ const index = code >= 100 ? code - 92 : code >= 90 ? code - 82 : code >= 40 ? code - 40 : code - 30;
494
+ return xtermColor(index);
495
+ }
496
+
497
+ function cssColor(color: RgbColor | undefined): string {
498
+ if (!color) return "";
499
+ const channel = (value: number) =>
500
+ Math.max(0, Math.min(255, Math.round(value)))
501
+ .toString(16)
502
+ .padStart(2, "0");
503
+ return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
504
+ }
505
+
506
+ function themeFingerprint(theme: Theme): string {
507
+ const colors = [
508
+ "error",
509
+ "success",
510
+ "warning",
511
+ "accent",
512
+ "border",
513
+ "mdHeading",
514
+ "syntaxKeyword",
515
+ "mdLink",
516
+ "text",
517
+ "muted",
518
+ "dim",
519
+ ] as const satisfies readonly ThemeColor[];
520
+ const foregrounds = typeof theme.getFgAnsi === "function" ? colors.map((color) => theme.getFgAnsi(color)) : [];
521
+ const backgrounds =
522
+ typeof theme.getBgAnsi === "function"
523
+ ? [theme.getBgAnsi("toolPendingBg"), theme.getBgAnsi("customMessageBg"), theme.getBgAnsi("selectedBg")]
524
+ : [];
525
+ const colorMode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "truecolor";
526
+ return [theme.name ?? "", colorMode, ...backgrounds, ...foregrounds].join("|");
527
+ }
528
+
529
+ function contrastingText(background: RgbColor): RgbColor {
530
+ return yiqLuminance(background) > 127 ? rgb(24, 24, 24) : rgb(232, 232, 232);
531
+ }