claudeup 4.35.1 → 4.37.0

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 (72) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/catalog-cache-store.test.ts +271 -0
  3. package/src/__tests__/catalog-notice.test.ts +155 -0
  4. package/src/__tests__/github-budget.test.ts +200 -0
  5. package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
  6. package/src/__tests__/scope-squares.test.tsx +165 -0
  7. package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
  8. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  9. package/src/main.tsx +21 -5
  10. package/src/opentui.d.ts +21 -12
  11. package/src/services/catalog-cache-store.ts +218 -0
  12. package/src/services/github-budget.ts +274 -0
  13. package/src/services/marketplace-catalog-git.ts +170 -0
  14. package/src/services/marketplace-catalog.ts +95 -0
  15. package/src/services/marketplace-fetcher.ts +310 -87
  16. package/src/services/plugin-manager.ts +103 -92
  17. package/src/ui/App.tsx +19 -12
  18. package/src/ui/adapters/catalogNotice.ts +122 -0
  19. package/src/ui/adapters/pluginsAdapter.ts +174 -168
  20. package/src/ui/adapters/settingsAdapter.ts +119 -116
  21. package/src/ui/adapters/skillsAdapter.ts +203 -196
  22. package/src/ui/components/CategoryHeader.tsx +9 -8
  23. package/src/ui/components/EmptyFilterState.tsx +10 -5
  24. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  25. package/src/ui/components/ScopeIndicator.tsx +10 -6
  26. package/src/ui/components/ScrollableList.tsx +3 -2
  27. package/src/ui/components/SearchInput.tsx +2 -1
  28. package/src/ui/components/StyledText.tsx +5 -4
  29. package/src/ui/components/TabBar.tsx +4 -3
  30. package/src/ui/components/layout/FooterHints.tsx +37 -30
  31. package/src/ui/components/layout/Panel.tsx +6 -5
  32. package/src/ui/components/layout/ProgressBar.tsx +7 -6
  33. package/src/ui/components/layout/ScopeTabs.tsx +6 -5
  34. package/src/ui/components/layout/ScreenLayout.tsx +46 -26
  35. package/src/ui/components/layout/index.ts +3 -3
  36. package/src/ui/components/modals/ConfirmModal.tsx +12 -11
  37. package/src/ui/components/modals/InputModal.tsx +14 -6
  38. package/src/ui/components/modals/LoadingModal.tsx +6 -5
  39. package/src/ui/components/modals/MessageModal.tsx +9 -8
  40. package/src/ui/components/modals/SelectModal.tsx +11 -7
  41. package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
  42. package/src/ui/components/primitives/ActionHints.tsx +26 -26
  43. package/src/ui/components/primitives/DetailSection.tsx +13 -12
  44. package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
  45. package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
  46. package/src/ui/components/primitives/MetaText.tsx +3 -3
  47. package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
  48. package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
  49. package/src/ui/components/primitives/SelectableRow.tsx +22 -16
  50. package/src/ui/hooks/useGitignoreModal.ts +78 -74
  51. package/src/ui/registry.ts +11 -11
  52. package/src/ui/renderers/cliToolRenderers.tsx +260 -203
  53. package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
  54. package/src/ui/renderers/mcpRenderers.tsx +121 -117
  55. package/src/ui/renderers/pluginRenderers.tsx +566 -471
  56. package/src/ui/renderers/profileRenderers.tsx +346 -300
  57. package/src/ui/renderers/settingsRenderers.tsx +183 -176
  58. package/src/ui/renderers/skillRenderers.tsx +410 -326
  59. package/src/ui/screens/AliasScreen.tsx +1336 -1309
  60. package/src/ui/screens/CliToolsScreen.tsx +92 -40
  61. package/src/ui/screens/EnvVarsScreen.tsx +19 -13
  62. package/src/ui/screens/GitignoreScreen.tsx +510 -493
  63. package/src/ui/screens/McpRegistryScreen.tsx +28 -21
  64. package/src/ui/screens/McpScreen.tsx +12 -3
  65. package/src/ui/screens/PluginsScreen.tsx +152 -33
  66. package/src/ui/screens/ProfilesScreen.tsx +39 -23
  67. package/src/ui/screens/SkillsScreen.tsx +832 -688
  68. package/src/ui/state/reducer.ts +11 -2
  69. package/src/ui/state/types.ts +16 -1
  70. package/src/ui/theme-mode.ts +73 -0
  71. package/src/ui/theme.ts +147 -53
  72. package/src/utils/config-dir.ts +47 -0
@@ -0,0 +1,307 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { RGBA } from "@opentui/core";
4
+ import fs from "fs-extra";
5
+ import { CONTRAST_REFERENCE, brand, theme } from "../ui/theme.js";
6
+
7
+ /**
8
+ * claudeup uses its own vivid accents on the terminal's own canvas.
9
+ *
10
+ * The bug this file exists for: `theme.colors.text` was the string `"white"`,
11
+ * which OpenTUI resolves to rgb(255,255,255) — not "the terminal's white". On a
12
+ * light theme every installed plugin was drawn white-on-cream and vanished,
13
+ * while the *un*installed ones, drawn in grey, stayed readable. The list read
14
+ * exactly backwards.
15
+ *
16
+ * The first fix pushed everything onto ANSI slots 0-15. That made it legible and
17
+ * flattened it: a theme rendering slots 2 and 3 as neighbouring olives leaves the
18
+ * scope squares indistinguishable, and slot-8 chips melt into a light page. So
19
+ * accents are now chosen by us — which means WE are responsible for their
20
+ * contrast, and these tests are how that responsibility is discharged.
21
+ *
22
+ * Invariants:
23
+ * 1. Backgrounds and body text stay the terminal's (it is the only colour that
24
+ * can clear a text-grade ratio against that terminal's own background).
25
+ * 2. Every accent clears 3:1 against BOTH reference backgrounds.
26
+ * 3. Ink on our own badges clears 4.5:1 against that badge.
27
+ * 4. The three scope colours are far enough apart in hue to read as squares.
28
+ * 5. No component invents a colour outside the palette.
29
+ */
30
+
31
+ // ─── Contrast maths (WCAG 2.1 relative luminance) ─────────────────────────────
32
+
33
+ function relativeLuminance(hex: string): number {
34
+ const n = hex.replace("#", "");
35
+ const channels = [0, 2, 4].map(
36
+ (i) => Number.parseInt(n.slice(i, i + 2), 16) / 255,
37
+ );
38
+ const linear = channels.map((c) =>
39
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
40
+ );
41
+ return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
42
+ }
43
+
44
+ function contrastRatio(a: string, b: string): number {
45
+ const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort(
46
+ (x, y) => y - x,
47
+ );
48
+ return (hi + 0.05) / (lo + 0.05);
49
+ }
50
+
51
+ /**
52
+ * Perceptual distance (CIE76 ΔE) in L*a*b*.
53
+ *
54
+ * Contrast ratio is the wrong tool for "are these two swatches telling apart":
55
+ * it only compares luminance, and the scope colours are deliberately close in
56
+ * luminance — that is precisely what lets each of them clear 3:1 against both a
57
+ * light and a dark background. Measured, they sit at ratios of 1.00-1.03 to each
58
+ * other while being obviously different colours. ΔE sees the hue difference that
59
+ * a luminance ratio is blind to.
60
+ */
61
+ function deltaE(aHex: string, bHex: string): number {
62
+ const toLab = (hex: string): [number, number, number] => {
63
+ const n = hex.replace("#", "");
64
+ const [r, g, b] = [0, 2, 4].map((i) => {
65
+ const c = Number.parseInt(n.slice(i, i + 2), 16) / 255;
66
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
67
+ });
68
+ // sRGB -> XYZ (D65), then XYZ -> Lab
69
+ const x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
70
+ const y = r * 0.2126 + g * 0.7152 + b * 0.0722;
71
+ const z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
72
+ const f = (t: number) =>
73
+ t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
74
+ const [fx, fy, fz] = [f(x), f(y), f(z)];
75
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
76
+ };
77
+ const [l1, a1, b1] = toLab(aHex);
78
+ const [l2, a2, b2] = toLab(bHex);
79
+ return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
80
+ }
81
+
82
+ /** WCAG threshold for UI components and large text. 4.5 is unreachable against
83
+ * both a light and a dark background at once — the luminance bands do not overlap. */
84
+ const UI_CONTRAST = 3;
85
+ const INK_CONTRAST = 4.5;
86
+
87
+ describe("contrast maths", () => {
88
+ // Guard the ruler before measuring with it: a broken luminance function would
89
+ // make every assertion below meaningless in whichever direction it was broken.
90
+ test("known reference ratios are correct", () => {
91
+ expect(contrastRatio("#FFFFFF", "#000000")).toBeCloseTo(21, 1);
92
+ expect(contrastRatio("#000000", "#FFFFFF")).toBeCloseTo(21, 1);
93
+ expect(contrastRatio("#777777", "#777777")).toBeCloseTo(1, 5);
94
+ });
95
+ });
96
+
97
+ // ─── The palette ──────────────────────────────────────────────────────────────
98
+
99
+ const ACCENTS = Object.entries(brand).filter(([name]) => name !== "ink");
100
+
101
+ describe("accents are legible on light AND dark terminals", () => {
102
+ test("the palette is non-empty (guards the loops below)", () => {
103
+ expect(ACCENTS.length).toBeGreaterThanOrEqual(6);
104
+ });
105
+
106
+ for (const [name, hex] of ACCENTS) {
107
+ test(`${name} clears ${UI_CONTRAST}:1 on both backgrounds`, () => {
108
+ const onLight = contrastRatio(hex, CONTRAST_REFERENCE.light);
109
+ const onDark = contrastRatio(hex, CONTRAST_REFERENCE.dark);
110
+ // Reported together so a failure names the side that broke.
111
+ expect({
112
+ name,
113
+ onLight: onLight >= UI_CONTRAST,
114
+ onDark: onDark >= UI_CONTRAST,
115
+ }).toEqual({ name, onLight: true, onDark: true });
116
+ });
117
+ }
118
+
119
+ test("badge ink clears 4.5:1 on every badge colour we paint", () => {
120
+ const failures: string[] = [];
121
+ for (const [tone, colors] of Object.entries(theme.category)) {
122
+ const r = contrastRatio(brand.ink, colors.bg as string);
123
+ if (r < INK_CONTRAST) failures.push(`category.${tone} ${r.toFixed(2)}`);
124
+ }
125
+ for (const key of ["defaultBg", "primaryBg", "dangerBg"] as const) {
126
+ const r = contrastRatio(brand.ink, theme.hints[key]);
127
+ if (r < INK_CONTRAST) failures.push(`hints.${key} ${r.toFixed(2)}`);
128
+ }
129
+ const sel = contrastRatio(theme.selection.fg, theme.selection.bg);
130
+ if (sel < INK_CONTRAST) failures.push(`selection ${sel.toFixed(2)}`);
131
+ expect(failures).toEqual([]);
132
+ });
133
+
134
+ test("the three scope colours are perceptually far apart", () => {
135
+ // They render as single-character squares, where a subtle shift is not
136
+ // readable at all. Deferring to ANSI put project and local on slots 2 and 3,
137
+ // which one real theme drew as two near-identical olives — the reason this
138
+ // rule exists. ΔE > 25 is comfortably past "different colour" (~2.3 is the
139
+ // just-noticeable difference); the bar is set high because these are tiny.
140
+ const scopes = Object.entries(theme.scopes) as [string, string][];
141
+ const tooClose: string[] = [];
142
+ for (let i = 0; i < scopes.length; i++) {
143
+ for (let j = i + 1; j < scopes.length; j++) {
144
+ const d = deltaE(scopes[i][1], scopes[j][1]);
145
+ if (d < 25)
146
+ tooClose.push(`${scopes[i][0]}~${scopes[j][0]} ΔE=${d.toFixed(1)}`);
147
+ }
148
+ }
149
+ expect(tooClose).toEqual([]);
150
+ expect(new Set(scopes.map(([, hex]) => hex)).size).toBe(scopes.length);
151
+ });
152
+
153
+ test("ΔE agrees that identical colours are identical (guards the metric)", () => {
154
+ expect(deltaE(brand.success, brand.success)).toBeCloseTo(0, 5);
155
+ expect(deltaE("#000000", "#FFFFFF")).toBeGreaterThan(90);
156
+ });
157
+ });
158
+
159
+ describe("the canvas stays the terminal's", () => {
160
+ test("normal text is the terminal's own foreground", () => {
161
+ // The original regression: this was `"white"`.
162
+ expect(theme.colors.text).toBeInstanceOf(RGBA);
163
+ expect((theme.colors.text as RGBA).intent).toBe("default");
164
+ });
165
+
166
+ test("surfaces use the terminal background, never an absolute fill", () => {
167
+ expect(theme.surface.bg).toBeInstanceOf(RGBA);
168
+ expect((theme.surface.bg as RGBA).intent).toBe("default");
169
+ });
170
+ });
171
+
172
+ // ─── Source guard ─────────────────────────────────────────────────────────────
173
+
174
+ const UI_DIR = path.join(import.meta.dir, "..", "ui");
175
+ /** The palette layer. Colour literals are these files' whole job. */
176
+ const PALETTE_FILES = new Set([
177
+ path.join(UI_DIR, "theme.ts"),
178
+ path.join(UI_DIR, "theme-mode.ts"),
179
+ ]);
180
+
181
+ async function uiSourceFiles(dir: string): Promise<string[]> {
182
+ const out: string[] = [];
183
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
184
+ const full = path.join(dir, entry.name);
185
+ if (entry.isDirectory()) out.push(...(await uiSourceFiles(full)));
186
+ else if (/\.tsx?$/.test(entry.name) && !PALETTE_FILES.has(full))
187
+ out.push(full);
188
+ }
189
+ return out;
190
+ }
191
+
192
+ const CSS_NAMES =
193
+ "white|black|gray|grey|red|green|yellow|blue|cyan|magenta|orange|purple";
194
+
195
+ /** A line that is purely a comment. Prose describing a colour is not a defect. */
196
+ function isComment(line: string): boolean {
197
+ const t = line.trim();
198
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
199
+ }
200
+
201
+ describe("components draw only from the palette", () => {
202
+ test("no colour literal outside theme.ts", async () => {
203
+ const files = await uiSourceFiles(UI_DIR);
204
+ expect(files.length).toBeGreaterThan(10); // guard: the walk found real files
205
+
206
+ const offenders: string[] = [];
207
+ for (const file of files) {
208
+ const text = await fs.readFile(file, "utf8");
209
+ text.split("\n").forEach((line, i) => {
210
+ if (isComment(line)) return;
211
+ const hex = /#[0-9a-fA-F]{6}\b/.test(line);
212
+ const named = new RegExp(
213
+ `\\b(fg|bg|backgroundColor|borderColor|titleColor|textColor|color)\\s*[=:]\\s*"(${CSS_NAMES})"`,
214
+ ).test(line);
215
+ if (hex || named) {
216
+ offenders.push(
217
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
218
+ );
219
+ }
220
+ });
221
+ }
222
+ expect(offenders).toEqual([]);
223
+ });
224
+
225
+ test("no <text> element renders without naming a colour", async () => {
226
+ // OpenTUI's fallback for an unstyled cell is truecolor white, NOT the
227
+ // terminal's foreground. Measured on the real TUI: after the palette was
228
+ // first fixed, `tmux capture-pane -e` still showed 139 truecolor-white runs,
229
+ // and the one carrying a visible glyph was a bare `<text>Plugins: 17</text>`.
230
+ // A correct palette cannot save an element that never asks for a colour.
231
+ const files = await uiSourceFiles(UI_DIR);
232
+ const offenders: string[] = [];
233
+ for (const file of files) {
234
+ const text = await fs.readFile(file, "utf8");
235
+ text.split("\n").forEach((line, i) => {
236
+ if (isComment(line)) return;
237
+ if (/<text>/.test(line)) {
238
+ offenders.push(
239
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
240
+ );
241
+ }
242
+ });
243
+ }
244
+ expect(offenders).toEqual([]);
245
+ });
246
+
247
+ test("no element falls back to `undefined` for a colour", async () => {
248
+ // `fg={selected ? x : undefined}` reads as "leave it alone", but OpenTUI's
249
+ // fallback for an unstyled cell is truecolor white — so every unselected
250
+ // SelectableRow rendered white-on-cream and vanished on a light theme. Same
251
+ // root cause as the original bug, reached by omission rather than by a wrong
252
+ // literal, which is why the `<text>` guard alone did not catch it.
253
+ const files = await uiSourceFiles(UI_DIR);
254
+ const offenders: string[] = [];
255
+ for (const file of files) {
256
+ const text = await fs.readFile(file, "utf8");
257
+ text.split("\n").forEach((line, i) => {
258
+ if (isComment(line)) return;
259
+ if (/\bfg=\{[^}]*\bundefined\b/.test(line)) {
260
+ offenders.push(
261
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
262
+ );
263
+ }
264
+ });
265
+ }
266
+ // `bg` legitimately falls back to the terminal page — that is how a row sits
267
+ // on the terminal's own background — so only `fg` is checked. The pattern is
268
+ // anchored to fg itself: filtering whole lines flagged
269
+ // `fg={x} bg={cond ? y : undefined}`, which is correct code.
270
+ expect(offenders).toEqual([]);
271
+ });
272
+
273
+ test("a background we paint never carries the terminal's ink", async () => {
274
+ // `theme.colors.text` is the TERMINAL's foreground. It is the right choice on
275
+ // the terminal's own page and the wrong one on a block we painted: on a light
276
+ // theme it is dark, so `bg={accent} fg={colors.text}` renders dark-on-purple.
277
+ // Reported from the real UI as the top tab bar showing "black on purple".
278
+ // It was not one tab — 16 sites shared it, across tabs, badges and chips.
279
+ //
280
+ // The rule: if we own the background, we own the ink (theme.hints.fg).
281
+ const files = await uiSourceFiles(UI_DIR);
282
+ const offenders: string[] = [];
283
+ for (const file of files) {
284
+ const text = await fs.readFile(file, "utf8");
285
+ text.split("\n").forEach((line, i) => {
286
+ if (isComment(line)) return;
287
+ if (/\bbg=\{[^}]+\}\s+fg=\{theme\.colors\.text\}/.test(line)) {
288
+ offenders.push(
289
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
290
+ );
291
+ }
292
+ });
293
+ }
294
+ expect(offenders).toEqual([]);
295
+ });
296
+
297
+ test("the comment filter does not swallow a real offender", () => {
298
+ // The guards skip comment lines, because a doc comment that merely mentions
299
+ // `<text>` or a hex value is not a defect — one legitimately did, and failed
300
+ // the build. A filter that silently hid real code would be worse than the
301
+ // false positive it replaced, so pin both directions.
302
+ expect(isComment(" // <text>foo</text>")).toBe(true);
303
+ expect(isComment(' * fg="white" in a docblock')).toBe(true);
304
+ expect(isComment(" <text>real</text>")).toBe(false);
305
+ expect(isComment(' <span fg="white">real</span>')).toBe(false);
306
+ });
307
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { parseKeypress } from "@opentui/core";
4
+ import fs from "fs-extra";
5
+
6
+ /**
7
+ * A shifted key never arrives as an uppercase `name`.
8
+ *
9
+ * OpenTUI reports Shift+U as `{name: "u", shift: true, sequence: "U"}`, so
10
+ * `event.name === "U"` is unreachable. That is not merely dead code: in
11
+ * PluginsScreen the dead branch sat BELOW `event.name === "u"`, so pressing
12
+ * Shift+U — advertised in the footer as "update" — fell through to the plain "u"
13
+ * branch and installed the plugin at user scope instead. Reported from the real
14
+ * UI: "U instead of update install user scope".
15
+ *
16
+ * The same shape was in McpRegistryScreen (`"R"` for refresh), where nothing
17
+ * shadowed it, so refresh silently did nothing at all.
18
+ */
19
+
20
+ const UI_DIR = path.join(import.meta.dir, "..", "ui");
21
+
22
+ async function uiSourceFiles(dir: string): Promise<string[]> {
23
+ const out: string[] = [];
24
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
25
+ const full = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) out.push(...(await uiSourceFiles(full)));
27
+ else if (/\.tsx?$/.test(entry.name)) out.push(full);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ const isComment = (line: string) => {
33
+ const t = line.trim();
34
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
35
+ };
36
+
37
+ describe("shifted keys parse to a lowercase name plus a flag", () => {
38
+ // Pin the platform behaviour the rule depends on. If OpenTUI ever changed
39
+ // this, the guard below would be enforcing a rule that no longer holds.
40
+ test("Shift+letter keeps the unshifted name and sets shift", () => {
41
+ for (const [raw, name] of [
42
+ ["U", "u"],
43
+ ["A", "a"],
44
+ ["R", "r"],
45
+ ] as const) {
46
+ const k = parseKeypress(raw);
47
+ expect({ raw, name: k?.name, shift: k?.shift }).toEqual({
48
+ raw,
49
+ name,
50
+ shift: true,
51
+ });
52
+ }
53
+ });
54
+
55
+ test("an unshifted letter reports shift false", () => {
56
+ const k = parseKeypress("u");
57
+ expect({ name: k?.name, shift: k?.shift }).toEqual({
58
+ name: "u",
59
+ shift: false,
60
+ });
61
+ });
62
+ });
63
+
64
+ describe("no binding compares name to an uppercase letter", () => {
65
+ test("uppercase name comparisons are absent from the UI", async () => {
66
+ const files = await uiSourceFiles(UI_DIR);
67
+ expect(files.length).toBeGreaterThan(10); // guard: the walk found real files
68
+
69
+ const offenders: string[] = [];
70
+ for (const file of files) {
71
+ const text = await fs.readFile(file, "utf8");
72
+ text.split("\n").forEach((line, i) => {
73
+ if (isComment(line)) return;
74
+ if (/\bname\s*===\s*"[A-Z]"/.test(line)) {
75
+ offenders.push(
76
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
77
+ );
78
+ }
79
+ });
80
+ }
81
+ expect(offenders).toEqual([]);
82
+ });
83
+
84
+ test("an uppercase binding is reachable only via the shift flag", async () => {
85
+ // The positive half: Shift+U must actually route to update. Asserting only
86
+ // the absence of `=== "U"` would also pass if someone deleted the binding.
87
+ const src = await fs.readFile(
88
+ path.join(UI_DIR, "screens", "PluginsScreen.tsx"),
89
+ "utf8",
90
+ );
91
+ const update = src.indexOf('event.name === "u" && event.shift');
92
+ const userScope = src.indexOf(
93
+ 'event.name === "u") handleScopeToggle("user")',
94
+ );
95
+ expect(update).toBeGreaterThan(-1);
96
+ expect(userScope).toBeGreaterThan(-1);
97
+ // Order matters: the shifted branch has to be tested first, or the plain
98
+ // "u" branch swallows it again — which is exactly the original bug.
99
+ expect(update).toBeLessThan(userScope);
100
+ });
101
+ });
package/src/main.tsx CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { createCliRenderer } from "@opentui/core";
3
+ import { RGBA, createCliRenderer } from "@opentui/core";
4
4
  import { createRoot } from "@opentui/react";
5
- import { App } from "./ui/App.js";
6
- import { route } from "./cli/router.js";
7
5
  // Static import so `bun build --compile` embeds package.json into the binary;
8
6
  // a dynamic require("../package.json") is not resolvable inside the bunfs root.
9
7
  import pkg from "../package.json";
8
+ import { route } from "./cli/router.js";
9
+ import { App } from "./ui/App.js";
10
+ import { setThemeMode } from "./ui/theme-mode.js";
10
11
 
11
12
  export const VERSION = (pkg as { version: string }).version;
12
13
 
@@ -24,8 +25,23 @@ async function main(): Promise<void> {
24
25
  process.exit(outcome.exitCode ?? 0);
25
26
  }
26
27
 
27
- // Create OpenTUI renderer (handles alternate screen buffer automatically)
28
- const renderer = await createCliRenderer();
28
+ // Create OpenTUI renderer (handles alternate screen buffer automatically).
29
+ //
30
+ // The background is the terminal's own, not a colour of ours: painting an
31
+ // absolute fill would box the UI into whatever theme we guessed. OpenTUI's
32
+ // built-in default for unstyled cells is truecolor white, which is why every
33
+ // element now names an adaptive colour explicitly — see src/ui/theme.ts.
34
+ const renderer = await createCliRenderer({
35
+ backgroundColor: RGBA.defaultBackground(),
36
+ });
37
+
38
+ // Ask the terminal whether it is light or dark, once. Only the disabled-row
39
+ // tint needs this — see src/ui/theme-mode.ts for why that one case cannot be
40
+ // solved the way the rest of the palette is. Bounded wait: a terminal that
41
+ // ignores the OSC query must not delay first paint, and `null` simply means
42
+ // no tint.
43
+ setThemeMode(await renderer.waitForThemeMode(250).catch(() => null));
44
+
29
45
  const root = createRoot(renderer);
30
46
 
31
47
  // Cleanup function to restore terminal
package/src/opentui.d.ts CHANGED
@@ -8,6 +8,15 @@
8
8
  */
9
9
 
10
10
  import type { ReactNode } from "react";
11
+ import type { RGBA } from "@opentui/core";
12
+
13
+ /**
14
+ * What OpenTUI actually accepts for a colour: a CSS string OR an RGBA, which is
15
+ * how adaptive colours are expressed (RGBA.defaultForeground(), RGBA.fromIndex()).
16
+ * These declarations previously said `string`, which made the terminal-resolved
17
+ * forms a type error and quietly pushed every call site towards absolute hex.
18
+ */
19
+ type ColorProp = string | RGBA;
11
20
 
12
21
  declare global {
13
22
  namespace JSX {
@@ -28,7 +37,7 @@ declare global {
28
37
  u: { children?: ReactNode };
29
38
  dim: { children?: ReactNode };
30
39
  a: { href?: string; children?: ReactNode };
31
- span: { fg?: string; bg?: string; children?: ReactNode };
40
+ span: { fg?: ColorProp; bg?: ColorProp; children?: ReactNode };
32
41
  }
33
42
  }
34
43
  }
@@ -37,12 +46,12 @@ interface BaseBoxProps {
37
46
  // Borders
38
47
  border?: boolean;
39
48
  borderStyle?: "single" | "double" | "rounded" | "bold";
40
- borderColor?: string;
49
+ borderColor?: ColorProp;
41
50
  title?: string;
42
51
  titleAlignment?: "left" | "center" | "right";
43
52
 
44
53
  // Colors
45
- backgroundColor?: string;
54
+ backgroundColor?: ColorProp;
46
55
 
47
56
  // Layout (Flexbox)
48
57
  flexDirection?: "row" | "column";
@@ -94,8 +103,8 @@ interface BoxProps extends BaseBoxProps {
94
103
 
95
104
  interface TextProps {
96
105
  content?: string;
97
- fg?: string;
98
- bg?: string;
106
+ fg?: ColorProp;
107
+ bg?: ColorProp;
99
108
  selectable?: boolean;
100
109
  children?: ReactNode;
101
110
  }
@@ -108,10 +117,10 @@ interface InputProps {
108
117
  placeholder?: string;
109
118
  focused?: boolean;
110
119
  width?: number;
111
- backgroundColor?: string;
112
- textColor?: string;
113
- cursorColor?: string;
114
- focusedBackgroundColor?: string;
120
+ backgroundColor?: ColorProp;
121
+ textColor?: ColorProp;
122
+ cursorColor?: ColorProp;
123
+ focusedBackgroundColor?: ColorProp;
115
124
  }
116
125
 
117
126
  interface SelectOption {
@@ -150,8 +159,8 @@ interface ScrollboxProps {
150
159
  scrollbarOptions?: {
151
160
  showArrows?: boolean;
152
161
  trackOptions?: {
153
- foregroundColor?: string;
154
- backgroundColor?: string;
162
+ foregroundColor?: ColorProp;
163
+ backgroundColor?: ColorProp;
155
164
  };
156
165
  };
157
166
  };
@@ -161,7 +170,7 @@ interface ScrollboxProps {
161
170
  interface AsciiFontProps {
162
171
  text: string;
163
172
  font?: "tiny" | "block" | "slick" | "shade";
164
- color?: string;
173
+ color?: ColorProp;
165
174
  }
166
175
 
167
176
  interface CodeProps {