claudeup 6.3.2 → 6.4.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 (58) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/cli-live.test.ts +9 -2
  3. package/src/__tests__/footer-hints.test.ts +40 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  5. package/src/__tests__/hook-import-policy.test.ts +90 -0
  6. package/src/__tests__/hook-process.test.ts +256 -0
  7. package/src/__tests__/hook-registration.test.ts +224 -0
  8. package/src/__tests__/manifest.test.ts +134 -0
  9. package/src/__tests__/model-visuals.test.tsx +789 -0
  10. package/src/__tests__/models-adapter.test.ts +317 -0
  11. package/src/__tests__/models-cli.test.ts +173 -0
  12. package/src/__tests__/models-core.test.ts +640 -0
  13. package/src/__tests__/models-manager.test.ts +497 -0
  14. package/src/__tests__/models-screen-state.test.ts +259 -0
  15. package/src/__tests__/profile-materializer.test.ts +46 -0
  16. package/src/__tests__/resolver.test.ts +36 -0
  17. package/src/__tests__/settings-file.test.ts +179 -0
  18. package/src/__tests__/symlink-manager.test.ts +65 -1
  19. package/src/__tests__/tabbar-layout.test.ts +40 -2
  20. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  21. package/src/cli/doctor.ts +90 -0
  22. package/src/cli/hook.ts +129 -0
  23. package/src/cli/models.ts +214 -0
  24. package/src/cli/router.ts +12 -0
  25. package/src/data/gitignore-defaults.ts +4 -0
  26. package/src/data/models-presets.ts +281 -0
  27. package/src/data/predefined-profiles.ts +9 -0
  28. package/src/data/settings-catalog.ts +11 -4
  29. package/src/main.tsx +51 -82
  30. package/src/services/hook-registration.ts +218 -0
  31. package/src/services/manifest.ts +84 -0
  32. package/src/services/models-core.ts +628 -0
  33. package/src/services/models-manager.ts +606 -0
  34. package/src/services/profile-materializer.ts +17 -0
  35. package/src/services/resolver.ts +11 -0
  36. package/src/services/settings-file.ts +69 -0
  37. package/src/services/styles-manager.ts +23 -45
  38. package/src/services/symlink-manager.ts +57 -11
  39. package/src/tui.tsx +112 -0
  40. package/src/types/bun.d.ts +21 -0
  41. package/src/types/index.ts +14 -0
  42. package/src/ui/App.tsx +15 -3
  43. package/src/ui/adapters/modelsAdapter.ts +170 -0
  44. package/src/ui/components/TabBar.tsx +9 -4
  45. package/src/ui/components/layout/FooterHints.tsx +20 -3
  46. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  47. package/src/ui/components/primitives/MetaText.tsx +27 -1
  48. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  49. package/src/ui/renderers/modelVisuals.tsx +853 -0
  50. package/src/ui/renderers/skillRenderers.tsx +13 -3
  51. package/src/ui/renderers/styleRenderers.tsx +7 -3
  52. package/src/ui/screens/ModelsScreen.tsx +478 -0
  53. package/src/ui/screens/StylesScreen.tsx +8 -13
  54. package/src/ui/screens/index.ts +1 -0
  55. package/src/ui/state/reducer.ts +94 -0
  56. package/src/ui/state/types.ts +65 -2
  57. package/src/ui/theme-mode.ts +116 -0
  58. package/src/ui/theme.ts +26 -0
@@ -1,3 +1,4 @@
1
+ import type { ModelsConfig, ModelsStatus } from "../../services/models-core.js";
1
2
  import type { PluginInfo } from "../../services/plugin-manager.js";
2
3
  import type { VersionMismatchInfo } from "../../services/plugin-version-check.js";
3
4
  import type {
@@ -26,7 +27,8 @@ export type Screen =
26
27
  | "skills"
27
28
  | "styles"
28
29
  | "gitignore"
29
- | "alias";
30
+ | "alias"
31
+ | "models";
30
32
 
31
33
  export type Route =
32
34
  | { screen: "plugins" }
@@ -38,7 +40,8 @@ export type Route =
38
40
  | { screen: "skills" }
39
41
  | { screen: "styles" }
40
42
  | { screen: "gitignore" }
41
- | { screen: "alias" };
43
+ | { screen: "alias" }
44
+ | { screen: "models" };
42
45
 
43
46
  // ============================================================================
44
47
  // Async Data Types
@@ -253,6 +256,45 @@ export interface StylesScreenState {
253
256
  isFilling: boolean;
254
257
  }
255
258
 
259
+ /**
260
+ * What the Models screen reads in one go.
261
+ *
262
+ * Both halves are needed and neither implies the other: `status` says whether
263
+ * the routing is in force, and `config` is the routing itself — which the
264
+ * screen needs in full to give a hand-edited config a row of its own.
265
+ */
266
+ export interface ModelsSnapshot {
267
+ status: ModelsStatus;
268
+ /** The validated config, or null when there is none (or it failed validation). */
269
+ config: ModelsConfig | null;
270
+ /** Where `.claude/models.json` is, whether or not it exists. */
271
+ path: string;
272
+ }
273
+
274
+ export interface ModelsScreenState {
275
+ selectedIndex: number;
276
+ searchQuery: string;
277
+ /** Routing status + the config behind it, in one read. */
278
+ data: AsyncData<ModelsSnapshot>;
279
+ /**
280
+ * The status line's current message, or null for the default line.
281
+ *
282
+ * App state for the same reason the Styles screen's is: `Router` swaps the
283
+ * component type on a tab change, so anything held in the screen's own
284
+ * `useState` is destroyed by `0 → 1 → 0`. The message survives until the next
285
+ * action replaces it, so the last thing you did is still on screen.
286
+ */
287
+ status: { text: string; tone: "success" | "error" } | null;
288
+ /**
289
+ * True while an apply or a clear is writing.
290
+ *
291
+ * App state, not screen state, because the screen UNMOUNTS on a tab switch.
292
+ * An apply rewrites the profile, re-materializes it and registers a hook —
293
+ * two of those running at once would interleave writes to the same files.
294
+ */
295
+ isApplying: boolean;
296
+ }
297
+
256
298
  // ============================================================================
257
299
  // App State
258
300
  // ============================================================================
@@ -287,6 +329,7 @@ export interface AppState {
287
329
  profiles: ProfilesScreenState;
288
330
  skills: SkillsScreenState;
289
331
  styles: StylesScreenState;
332
+ models: ModelsScreenState;
290
333
  }
291
334
 
292
335
  // ============================================================================
@@ -412,5 +455,25 @@ export type AppAction =
412
455
  | { type: "STYLES_FILL_END" }
413
456
  | { type: "STYLES_SET_SELECTION"; ids: string[] }
414
457
 
458
+ // Models screen
459
+ | { type: "MODELS_SELECT"; index: number }
460
+ | { type: "MODELS_SET_SEARCH"; query: string }
461
+ // Computed IN the reducer, from current state, for the same reason the
462
+ // STYLES_SEARCH_* pair is: a screen-side `searchQuery + char` reads a value
463
+ // captured at render time, so keystrokes arriving faster than React
464
+ // re-renders all overwrite each other.
465
+ | { type: "MODELS_SEARCH_APPEND"; char: string }
466
+ | { type: "MODELS_SEARCH_BACKSPACE" }
467
+ | { type: "MODELS_DATA_LOADING" }
468
+ | { type: "MODELS_DATA_SUCCESS"; snapshot: ModelsSnapshot }
469
+ | { type: "MODELS_DATA_ERROR"; error: Error }
470
+ | {
471
+ type: "MODELS_STATUS_SET";
472
+ status: { text: string; tone: "success" | "error" };
473
+ }
474
+ | { type: "MODELS_STATUS_CLEAR" }
475
+ | { type: "MODELS_APPLY_START" }
476
+ | { type: "MODELS_APPLY_END" }
477
+
415
478
  // Data refresh - triggers screens to refetch
416
479
  | { type: "DATA_REFRESH_COMPLETE" };
@@ -101,6 +101,122 @@ export const COMPONENT_BADGE = {
101
101
 
102
102
  export type ComponentBadgeKind = keyof typeof COMPONENT_BADGE;
103
103
 
104
+ /**
105
+ * Badge and chart colours for the four Claude models, plus `inherit`.
106
+ *
107
+ * Three strengths, because a badge and a bar cannot share an ink: twenty characters of a
108
+ * colour weigh far more than six, so what reads as a label reads as a slab when it is a
109
+ * chart. `bg`/`fg` are the chip; `bar` is the distribution bar's fill.
110
+ *
111
+ * ## The two pages are built differently, deliberately
112
+ *
113
+ * `COMPONENT_BADGE` above explains why a chip needs two ends at all. This map goes one step
114
+ * further and does not MIRROR them. Cream already carries the light, so a light chip is a
115
+ * pale wash under mid-tone ink and stays quiet. A near-black page carries none, so the same
116
+ * construction turns grey and the screen goes flat — a symmetric palette was built, shown,
117
+ * and rejected on sight as "dark looks like the light theme". A dark chip is therefore a
118
+ * SATURATED MID-TONE under near-white ink: a wash toward black is dark, a wash toward white
119
+ * is the light theme, and neither of those is a colour.
120
+ *
121
+ * ## The dark fills clear a page we cannot see
122
+ *
123
+ * `CONTRAST_REFERENCE_KEYS.dark` is `#1C1C1E`, but a real terminal may be pure black or a
124
+ * slate like One Dark's `#282C34`. MEASURED, the `COMPONENT_BADGE` dark washes sit at
125
+ * 1.00–1.07:1 against that slate — the same luminance as the page — so the chip loses its
126
+ * edge and reads as a hole punched in the row. These fills clear every ground: 3.1–4.3:1 on
127
+ * black, 2.8–2.9 on slate, and they still clear 4.5:1 on cream, so even a mis-detected theme
128
+ * is merely odd rather than unreadable.
129
+ *
130
+ * Hues stay far apart for the same reason the scope squares' do: one or two characters of
131
+ * colour carry no readable hue SHIFT, only a readable hue DIFFERENCE.
132
+ */
133
+ export const MODEL_BADGE = {
134
+ opus: {
135
+ light: { bg: "#D3E7EC", fg: "#0E5F73", bar: "#92CEDC" },
136
+ dark: { bg: "#0E7C94", fg: "#F0FDFF", bar: "#48C8E0" },
137
+ },
138
+ sonnet: {
139
+ light: { bg: "#D6EEDD", fg: "#166534", bar: "#93D9B2" },
140
+ dark: { bg: "#12804F", fg: "#EFFFF6", bar: "#4FD98F" },
141
+ },
142
+ haiku: {
143
+ light: { bg: "#E6E6E0", fg: "#4B5563", bar: "#C9CCD2" },
144
+ dark: { bg: "#697180", fg: "#F8FAFC", bar: "#8A92A2" },
145
+ },
146
+ fable: {
147
+ light: { bg: "#E9DCF5", fg: "#6B21A8", bar: "#C3A6EC" },
148
+ dark: { bg: "#7B4CD6", fg: "#F6F0FF", bar: "#A87BE8" },
149
+ },
150
+ /** Not a model — the absence of a routing decision. Grey on both pages, so it never
151
+ * reads as a fifth choice sitting beside four real ones. */
152
+ inherit: {
153
+ light: { bg: "#EFEFE6", fg: "#8A8A80", bar: "#DEDED2" },
154
+ dark: { bg: "#4A4A54", fg: "#D0D2D8", bar: "#4E4E58" },
155
+ },
156
+ } as const;
157
+
158
+ export type ModelBadgeKind = keyof typeof MODEL_BADGE;
159
+
160
+ export interface ModelInk {
161
+ bg: string;
162
+ fg: string;
163
+ bar: string;
164
+ }
165
+
166
+ /**
167
+ * Chip and bar colours for one model, for the resolved mode.
168
+ *
169
+ * Total by construction: a name this build does not know gets `inherit`'s grey, which reads
170
+ * as "claudeup does not recognise this" rather than as a missing colour. A hand-edited
171
+ * config can name anything.
172
+ */
173
+ export function modelBadge(kind: string): ModelInk {
174
+ const key = (kind in MODEL_BADGE ? kind : "inherit") as ModelBadgeKind;
175
+ return MODEL_BADGE[key][detected];
176
+ }
177
+
178
+ /**
179
+ * The UNLIT track: the `·` behind an effort dot, the `░` behind a bar.
180
+ *
181
+ * Light reuses `SCOPE_OFF_FILL`, which already solves this there. Dark does NOT: at
182
+ * `#3A3A40` the track measures 1.51:1 on the reference page and simply vanishes, so a bar
183
+ * reads as a floating block with no scale and the effort dots lose the denominator that
184
+ * makes them a level rather than a count. `#4A4A52` is 1.94:1 — still recessive, still
185
+ * visible.
186
+ */
187
+ export const TRACK_FILL = {
188
+ light: SCOPE_OFF_FILL.light,
189
+ dark: "#4A4A52",
190
+ } as const;
191
+
192
+ /** Fill for an unlit track segment, for the resolved mode. */
193
+ export function trackFill(): string {
194
+ return TRACK_FILL[detected];
195
+ }
196
+
197
+ /**
198
+ * Effort's colour ramp — TEXT only, never a fill, so a row never paints two blocks.
199
+ *
200
+ * Effort is an ORDINAL, so these are a ramp rather than four unrelated hues. The ramp avoids
201
+ * green and red, which already mean "on" and "failed" elsewhere on the same screen: one
202
+ * colour, one meaning, app-wide. Dark runs brighter than light for the reason `MODEL_BADGE`
203
+ * gives — a dim colour on a dark page is a grey one.
204
+ */
205
+ export const EFFORT_INK = {
206
+ low: { light: "#6B7280", dark: "#9AA0AC" },
207
+ medium: { light: "#0E7490", dark: "#6FD9F0" },
208
+ high: { light: "#B45309", dark: "#F2B25E" },
209
+ xhigh: { light: "#A21CAF", dark: "#E49BFF" },
210
+ } as const;
211
+
212
+ export type EffortInkKind = keyof typeof EFFORT_INK;
213
+
214
+ /** Ink for one effort level, for the resolved mode. Unknown words take the lowest step. */
215
+ export function effortInk(effort: string): string {
216
+ const key = (effort in EFFORT_INK ? effort : "low") as EffortInkKind;
217
+ return EFFORT_INK[key][detected];
218
+ }
219
+
104
220
  /**
105
221
  * The two reference pages, mirrored from `theme.ts`'s `CONTRAST_REFERENCE`.
106
222
  *
package/src/ui/theme.ts CHANGED
@@ -59,6 +59,22 @@ export const brand = {
59
59
  muted: "#6B7280", // 4.53 / 3.52 — de-emphasis, still legible on both
60
60
  /** Ink on OUR badges. We own both sides, so contrast is deterministic. */
61
61
  ink: "#FFFFFF",
62
+ /**
63
+ * De-emphasised ink on the SELECTION fill, and nowhere else.
64
+ *
65
+ * Measured on a screenshot: `muted` (#6B7280) on the selection purple is
66
+ * 1.11:1, `warning` 1.07:1, `danger` 1.11:1 — every meta tone was invisible
67
+ * the moment its row was selected, and `(default)` on the Models screen
68
+ * disappeared entirely. The page-level greys cannot help here: they are
69
+ * chosen to sit between a cream and a near-black terminal, and the selection
70
+ * is neither.
71
+ *
72
+ * 3.64:1 on the selection fill, and 1.48:1 against `ink` — far enough from
73
+ * white to still read as secondary. Like `ink`, it is measured against a fill
74
+ * WE paint rather than against the terminal, so it is exempt from the
75
+ * both-backgrounds rule the accents above obey.
76
+ */
77
+ selectionDim: "#D4D4D8",
62
78
  } as const;
63
79
 
64
80
  /** Any colour a component may hand to a JSX colour prop. */
@@ -88,9 +104,19 @@ export const theme = {
88
104
  info: brand.info,
89
105
  },
90
106
 
107
+ /**
108
+ * The selected row owns the colour channel.
109
+ *
110
+ * A row's normal palette is chosen against the TERMINAL's background; on the
111
+ * selection fill none of it clears even 1.2:1. So a selected row has exactly
112
+ * two inks — `fg` for what it is saying, `dim` for what it is saying quietly —
113
+ * and any tone a row would otherwise carry is surrendered while it is
114
+ * selected. The words survive; the hue does not, and cannot.
115
+ */
91
116
  selection: {
92
117
  bg: brand.accent,
93
118
  fg: brand.ink,
119
+ dim: brand.selectionDim,
94
120
  },
95
121
 
96
122
  /** Blue / green / amber: the widest hue separation available, because these