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
@@ -1,4 +1,5 @@
1
1
  import type { AppState, AppAction } from "./types.js";
2
+ import { asyncValue } from "./types.js";
2
3
 
3
4
  export const initialState: AppState = {
4
5
  // Navigation - start on plugins screen
@@ -137,8 +138,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
137
138
  ...state,
138
139
  plugins: {
139
140
  ...state.plugins,
140
- marketplaces: { status: "loading" },
141
- plugins: { status: "loading" },
141
+ // Hand the previous list forward so a reload refreshes in place
142
+ // instead of replacing the screen with "Loading...".
143
+ marketplaces: {
144
+ status: "loading",
145
+ previous: asyncValue(state.plugins.marketplaces),
146
+ },
147
+ plugins: {
148
+ status: "loading",
149
+ previous: asyncValue(state.plugins.plugins),
150
+ },
142
151
  },
143
152
  };
144
153
 
@@ -39,10 +39,25 @@ export type Route =
39
39
 
40
40
  export type AsyncData<T> =
41
41
  | { status: "idle" }
42
- | { status: "loading" }
42
+ /**
43
+ * `previous` carries the last successful value through a reload.
44
+ *
45
+ * Without it a refetch is indistinguishable from a first load, so a screen
46
+ * that already had data blanked itself to "Loading..." every time it
47
+ * remounted — and the router unmounts screens on every tab switch. Optional so
48
+ * that a genuine first load is still just `{ status: "loading" }`.
49
+ */
50
+ | { status: "loading"; previous?: T }
43
51
  | { status: "success"; data: T }
44
52
  | { status: "error"; error: Error };
45
53
 
54
+ /** The value to render: fresh if loaded, else the last good one during a reload. */
55
+ export function asyncValue<T>(d: AsyncData<T>): T | undefined {
56
+ if (d.status === "success") return d.data;
57
+ if (d.status === "loading") return d.previous;
58
+ return undefined;
59
+ }
60
+
46
61
  // ============================================================================
47
62
  // Modal Types
48
63
  // ============================================================================
@@ -0,0 +1,73 @@
1
+ import type { ThemeMode } from "@opentui/core";
2
+
3
+ /**
4
+ * The one place claudeup needs to know whether the terminal is light or dark.
5
+ *
6
+ * Everything else in the palette avoids this question on purpose: accents are
7
+ * chosen to clear 3:1 against both backgrounds, and body text uses the terminal's
8
+ * own foreground. A *disabled row tint* is the exception, because "subtle" is
9
+ * defined relative to the page — a wash that sits gently on cream is a bright
10
+ * band on near-black, and there is no third colour that is quiet on both.
11
+ *
12
+ * ANSI cannot help here either: there is no alpha channel, so the tint cannot be
13
+ * expressed as "the page, 8% toward the foreground". OpenTUI's alpha blending
14
+ * lives only in its 3d module, not the text renderer.
15
+ *
16
+ * So we ask. OpenTUI queries the terminal over OSC and reports `themeMode`; we
17
+ * read it once at startup. **Unknown means no tint** — a terminal that does not
18
+ * answer the query gets today's behaviour rather than a guess that could paint a
19
+ * bright band across half the list.
20
+ */
21
+
22
+ let detected: ThemeMode | null = null;
23
+
24
+ /** Set once at startup from `renderer.themeMode`. */
25
+ export function setThemeMode(mode: ThemeMode | null): void {
26
+ detected = mode;
27
+ }
28
+
29
+ export function getThemeMode(): ThemeMode | null {
30
+ return detected;
31
+ }
32
+
33
+ /** Test seam — reset detection between cases. */
34
+ export function resetThemeMode(): void {
35
+ detected = null;
36
+ }
37
+
38
+ /**
39
+ * Fill for an UNLIT segment of the three-square scope bar.
40
+ *
41
+ * The bar reads as three segments, each lit (in its scope colour) or unlit. Unlit
42
+ * used to be the same mid-grey as body de-emphasis, `#6B7280`, which is dark —
43
+ * so an unlit segment carried the same visual weight as a lit one and the bar
44
+ * read as three similar blobs. Reported directly: "non installed state is dark as
45
+ * well - confusing".
46
+ *
47
+ * An unlit segment should recede toward the page, and "toward the page" is the
48
+ * one thing a fixed colour cannot express: a pale fill that sits quietly on cream
49
+ * is a bright block on near-black. Hence the two values, chosen per detected
50
+ * terminal — this is the only place claudeup asks.
51
+ */
52
+ export const SCOPE_OFF_FILL = {
53
+ /** Just off a light page — a filled but quiet segment. */
54
+ light: "#D9D9CC",
55
+ /** Just off a dark page. */
56
+ dark: "#3A3A40",
57
+ } as const;
58
+
59
+ /**
60
+ * Neutral fallback when the terminal never answered the OSC query. A mid grey is
61
+ * visible on both pages — not as quiet as the tuned values, but never invisible
62
+ * and never a bright block. Failing to a wrong extreme would be worse than
63
+ * failing to something merely unremarkable.
64
+ */
65
+ export const SCOPE_OFF_FALLBACK = "#9CA3AF";
66
+
67
+ /** Fill colour for an unlit scope segment. Always a colour — never undefined,
68
+ * because the segment is a filled block whatever the terminal turns out to be. */
69
+ export function scopeOffFill(): string {
70
+ if (detected === "light") return SCOPE_OFF_FILL.light;
71
+ if (detected === "dark") return SCOPE_OFF_FILL.dark;
72
+ return SCOPE_OFF_FALLBACK;
73
+ }
package/src/ui/theme.ts CHANGED
@@ -1,55 +1,149 @@
1
+ import { RGBA } from "@opentui/core";
2
+
3
+ /**
4
+ * claudeup's palette: our own vivid accents, on the terminal's own canvas.
5
+ *
6
+ * Three rules, and the reason for each:
7
+ *
8
+ * 1. **Backgrounds are never ours.** Surfaces and modals use the terminal's
9
+ * background. Painting an absolute fill boxes the UI into whichever theme we
10
+ * guessed, and it was the original sin here — modals hardcoded #1C1C1E.
11
+ * 2. **Body text is never ours.** Normal text uses the terminal's foreground,
12
+ * which is the only colour guaranteed to clear a text-grade contrast ratio
13
+ * against that terminal's own background. `text` used to be the string
14
+ * `"white"` — OpenTUI resolves that to rgb(255,255,255), not to "the
15
+ * terminal's white" — so on a light theme every installed plugin was drawn
16
+ * white-on-cream and vanished while the *un*installed ones, in grey, stayed
17
+ * readable. The list read exactly backwards.
18
+ * 3. **Accents ARE ours, and vivid.** Deferring accents to ANSI slots 0-15 made
19
+ * everything legible but flattened it: a theme that renders slots 2 and 3 as
20
+ * neighbouring olives leaves user/project/local scope squares indistinguishable,
21
+ * and slot 8 chips melt into the page. Distinction is a product decision, so
22
+ * these are chosen, not inherited.
23
+ *
24
+ * ## How the accents were chosen
25
+ *
26
+ * By measurement, not taste. A single colour CANNOT reach 4.5:1 against both a
27
+ * light and a dark terminal — clearing 4.5:1 on cream needs relative luminance
28
+ * <= ~0.17, clearing it on near-black needs >= ~0.22, and those do not overlap.
29
+ * So the bar is 3:1, WCAG's threshold for UI components and large text, which
30
+ * admits a band of mid-dark saturated colours. Every accent below clears 3:1
31
+ * against BOTH reference backgrounds, verified in theme-adaptive-colors.test.ts —
32
+ * so a colour that only works on dark cannot be added without failing the build.
33
+ *
34
+ * Where we own a badge's background we also own its ink, so `ink` is plain white
35
+ * and its contrast is deterministic rather than a function of the user's theme.
36
+ * That is why badge ink is not slot 0 or the terminal background: on a light
37
+ * theme, terminal-background ink on a yellow chip is unreadable.
38
+ */
39
+
40
+ /** Reference backgrounds every accent is measured against. */
41
+ export const CONTRAST_REFERENCE = {
42
+ light: "#FAFAD2",
43
+ dark: "#1C1C1E",
44
+ } as const;
45
+
46
+ /**
47
+ * The vivid set. Ratios are (on light / on dark), measured — see the test.
48
+ * Hues for the three scopes are deliberately far apart (blue / green / amber)
49
+ * because they appear as single-character squares, where a subtle hue shift is
50
+ * not readable at all.
51
+ */
52
+ export const brand = {
53
+ accent: "#9333EA", // 5.04 / 3.16 — brand purple
54
+ link: "#2563EB", // 4.84 / 3.29
55
+ info: "#0E7490", // 5.02 / 3.18 — teal
56
+ success: "#15803D", // 4.70 / 3.39
57
+ warning: "#B45309", // 4.70 / 3.39 — amber
58
+ danger: "#DC2626", // 4.52 / 3.52
59
+ muted: "#6B7280", // 4.53 / 3.52 — de-emphasis, still legible on both
60
+ /** Ink on OUR badges. We own both sides, so contrast is deterministic. */
61
+ ink: "#FFFFFF",
62
+ } as const;
63
+
64
+ /** Any colour a component may hand to a JSX colour prop. */
65
+ export type UiColor = string | RGBA;
66
+
67
+ /** The terminal's own foreground — the only honest "normal text" colour. */
68
+ const fg = () => RGBA.defaultForeground();
69
+
70
+ /** The terminal's own background — for surfaces, never for ink. */
71
+ const bg = () => RGBA.defaultBackground();
72
+
1
73
  export const theme = {
2
- colors: {
3
- text: "white",
4
- muted: "#666666",
5
- dim: "#333333",
6
- border: "#444444",
7
- link: "#5c9aff",
8
- accent: "#7e57c2",
9
- /** Low-key brand tint for bylines — readable, deliberately not attention-grabbing. */
10
- byline: "#8a7fa8",
11
- success: "green",
12
- warning: "yellow",
13
- danger: "red",
14
- info: "cyan",
15
- },
16
-
17
- selection: {
18
- bg: "magenta",
19
- fg: "white",
20
- },
21
-
22
- scopes: {
23
- user: "cyan",
24
- project: "green",
25
- local: "yellow",
26
- },
27
-
28
- category: {
29
- purple: { bg: "#7e57c2", fg: "white", badgeFg: "#d1c4e9" },
30
- green: { bg: "#2e7d32", fg: "white", badgeFg: "#c8e6c9" },
31
- teal: { bg: "#00695c", fg: "white", badgeFg: "#b2dfdb" },
32
- yellow: { bg: "#8d6e00", fg: "white", badgeFg: "#ffe082" },
33
- gray: { bg: "#455a64", fg: "white", badgeFg: "#cfd8dc" },
34
- red: { bg: "#b71c1c", fg: "white", badgeFg: "#ffcdd2" },
35
- },
36
-
37
- meta: {
38
- muted: "gray",
39
- warning: "yellow",
40
- success: "green",
41
- danger: "red",
42
- },
43
-
44
- hints: {
45
- defaultBg: "white",
46
- primaryBg: "cyan",
47
- dangerBg: "red",
48
- },
49
-
50
- spacing: {
51
- rowIndent: 1,
52
- sectionGap: 1,
53
- panelPadding: 1,
54
- },
74
+ colors: {
75
+ /** Adaptive by rule 2 — never make this one of ours. */
76
+ text: fg(),
77
+ muted: brand.muted,
78
+ /** Same grey as `muted`; a third level should come from layout, not colour. */
79
+ dim: brand.muted,
80
+ border: brand.muted,
81
+ link: brand.link,
82
+ accent: brand.accent,
83
+ /** Low-key brand tint for bylines — readable, deliberately not attention-grabbing. */
84
+ byline: brand.accent,
85
+ success: brand.success,
86
+ warning: brand.warning,
87
+ danger: brand.danger,
88
+ info: brand.info,
89
+ },
90
+
91
+ selection: {
92
+ bg: brand.accent,
93
+ fg: brand.ink,
94
+ },
95
+
96
+ /** Blue / green / amber: the widest hue separation available, because these
97
+ * render as one-character squares where a subtle shift reads as noise. */
98
+ scopes: {
99
+ user: brand.link,
100
+ project: brand.success,
101
+ local: brand.warning,
102
+ },
103
+
104
+ /** Badge colours. `fg` is the ink ON the block; `badgeFg` is the same hue used
105
+ * as ink on the normal background instead. */
106
+ category: {
107
+ purple: { bg: brand.accent, fg: brand.ink, badgeFg: brand.accent },
108
+ green: { bg: brand.success, fg: brand.ink, badgeFg: brand.success },
109
+ teal: { bg: brand.info, fg: brand.ink, badgeFg: brand.info },
110
+ yellow: { bg: brand.warning, fg: brand.ink, badgeFg: brand.warning },
111
+ gray: { bg: brand.muted, fg: brand.ink, badgeFg: brand.muted },
112
+ red: { bg: brand.danger, fg: brand.ink, badgeFg: brand.danger },
113
+ },
114
+
115
+ meta: {
116
+ muted: brand.muted,
117
+ warning: brand.warning,
118
+ success: brand.success,
119
+ danger: brand.danger,
120
+ },
121
+
122
+ /**
123
+ * Footer key chips. A vivid block with white ink, because the point of a chip
124
+ * is to read as a pressable key at a glance — the previous neutral-grey chip
125
+ * melted into a light page. Dark slates were measured and rejected: they clear
126
+ * 3:1 on cream but drop to ~2.2 on a dark terminal, i.e. an invisible block.
127
+ */
128
+ hints: {
129
+ defaultBg: brand.accent,
130
+ primaryBg: brand.info,
131
+ dangerBg: brand.danger,
132
+ fg: brand.ink,
133
+ },
134
+
135
+ /** Panels and modals sit on the terminal's own background and are separated by
136
+ * their border, never by an absolute fill that fights the user's theme. */
137
+ surface: {
138
+ bg: bg(),
139
+ border: brand.muted,
140
+ },
141
+
142
+ spacing: {
143
+ rowIndent: 1,
144
+ sectionGap: 1,
145
+ panelPadding: 1,
146
+ },
55
147
  } as const;
148
+
149
+ export { fg as defaultFg, bg as defaultBg };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * config-dir.ts — where Claude Code's config lives, with a test guard.
3
+ *
4
+ * `CLAUDE_CONFIG_DIR` is Claude Code's own override and must be honoured. It is
5
+ * resolved per call rather than captured at import time, because a module-level
6
+ * `os.homedir()` bakes in the operator's real directory and cannot be overridden
7
+ * afterwards.
8
+ *
9
+ * The test guard
10
+ * --------------
11
+ * Under `bun test`, a missing `CLAUDE_CONFIG_DIR` returns null instead of
12
+ * `~/.claude`. Callers treat null as "no cache, no clone" — an empty, inert state.
13
+ *
14
+ * This is not defensive decoration. Once the catalog cache and the rate-limit
15
+ * cooldown moved to disk, and the catalog resolver gained a `git fetch` fallback,
16
+ * any test that had not isolated its config dir began reading the operator's real
17
+ * cooldown file and fetching from their real marketplace clones. The suite went
18
+ * from 6s to 59s and started failing intermittently, because results depended on
19
+ * whether this particular machine happened to be rate-limited at that moment.
20
+ *
21
+ * A test that forgets to isolate should get nothing, not the developer's live
22
+ * state. Production is unaffected: NODE_ENV is not "test" there.
23
+ */
24
+
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+
28
+ /**
29
+ * The Claude config directory, or null when a test has not chosen one.
30
+ *
31
+ * Callers that must always have a path (writing real user config) should use
32
+ * `requireClaudeConfigDir`. Callers holding regenerable state — caches — should
33
+ * treat null as "cache unavailable" and carry on.
34
+ */
35
+ export function claudeConfigDirOrNull(): string | null {
36
+ const explicit = process.env.CLAUDE_CONFIG_DIR;
37
+ if (explicit) return explicit;
38
+ if (process.env.NODE_ENV === "test") return null;
39
+ return path.join(os.homedir(), ".claude");
40
+ }
41
+
42
+ /** The Claude config directory, falling back to `~/.claude` even under test. */
43
+ export function requireClaudeConfigDir(): string {
44
+ return (
45
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")
46
+ );
47
+ }