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
@@ -0,0 +1,170 @@
1
+ import {
2
+ BUILT_IN_PRESETS,
3
+ DEFAULT_PRESET,
4
+ presetLabel,
5
+ } from "../../data/models-presets.js";
6
+ import {
7
+ GRADES,
8
+ type ModelsConfig,
9
+ type ModelsState,
10
+ type ModelsStatus,
11
+ } from "../../services/models-core.js";
12
+
13
+ // ─── Item types ───────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * The one header row: what is routed right now.
17
+ *
18
+ * Not selectable — the cursor skips it, the same way the Styles screen skips a
19
+ * category label. It carries only what the ROW draws; the drift lines and
20
+ * warnings behind the counts live on the `ModelsStatus` the detail renderer is
21
+ * handed, so they are not stored twice and cannot disagree.
22
+ */
23
+ export interface ModelsStatusItem {
24
+ id: string;
25
+ kind: "status";
26
+ label: string;
27
+ state: ModelsState;
28
+ /** The preset the project's config names, or null when there is none. */
29
+ preset: string | null;
30
+ driftCount: number;
31
+ warningCount: number;
32
+ }
33
+
34
+ export interface ModelsPresetItem {
35
+ id: string;
36
+ kind: "preset";
37
+ label: string;
38
+ config: ModelsConfig;
39
+ /** The routing this project's `models.json` names. */
40
+ active: boolean;
41
+ /** What `claudeup models use` picks when nothing else is chosen. */
42
+ isDefault: boolean;
43
+ /** Not one of the built-ins — this project's own, hand-edited config. */
44
+ custom: boolean;
45
+ }
46
+
47
+ export type ModelsBrowserItem = ModelsStatusItem | ModelsPresetItem;
48
+
49
+ // ─── Adapter ──────────────────────────────────────────────────────────────────
50
+
51
+ export interface BuildModelsItemsArgs {
52
+ /** What `readModelsStatus` reported, or null before the first read lands. */
53
+ status: ModelsStatus | null;
54
+ /** The validated config, or null when there is none (or it failed validation). */
55
+ config: ModelsConfig | null;
56
+ query: string;
57
+ /**
58
+ * Presets to offer. Defaults to the shipped set, which is a compile-time
59
+ * constant — so building the list still costs no I/O whatsoever.
60
+ */
61
+ presets?: ModelsConfig[];
62
+ }
63
+
64
+ /**
65
+ * Does `config` answer to the filter?
66
+ *
67
+ * Matched over the preset name AND the models it names, because "sonnet" is the
68
+ * thing a user actually looks for — the preset names encode which model leads,
69
+ * not which models appear.
70
+ */
71
+ function matches(config: ModelsConfig, lowerQuery: string): boolean {
72
+ if (!lowerQuery) return true;
73
+ // Both names are searchable: the row SHOWS "Opus with Fable help", and a user who knows
74
+ // the command types "fable-advisor". Matching only one of them makes the other look like
75
+ // a missing preset.
76
+ const haystack = [
77
+ config.preset,
78
+ presetLabel(config.preset),
79
+ config.main.model,
80
+ ...GRADES.map((grade) => config.grades[grade].model),
81
+ ]
82
+ .join(" ")
83
+ .toLowerCase();
84
+ return haystack.includes(lowerQuery);
85
+ }
86
+
87
+ /**
88
+ * Build the flat list for the Models screen.
89
+ *
90
+ * One status header, then one row per preset in the order they ship. Nothing is
91
+ * grouped: there are four of them and a grade table each, so sections would cost
92
+ * rows and buy no navigation.
93
+ *
94
+ * The header is emitted only when at least one preset row survives the filter. A
95
+ * header with nothing under it reads as a result, and would leave the empty
96
+ * state — the one thing that says how to clear the filter — unreachable.
97
+ */
98
+ export function buildModelsItems({
99
+ status,
100
+ config,
101
+ query,
102
+ presets = BUILT_IN_PRESETS,
103
+ }: BuildModelsItemsArgs): ModelsBrowserItem[] {
104
+ if (!status) return [];
105
+
106
+ const lowerQuery = query.trim().toLowerCase();
107
+ const activeName = config?.preset ?? null;
108
+ const builtInNames = new Set(presets.map((preset) => preset.preset));
109
+
110
+ const rows: ModelsPresetItem[] = presets
111
+ .filter((preset) => matches(preset, lowerQuery))
112
+ .map((preset) => ({
113
+ id: `preset:${preset.preset}`,
114
+ kind: "preset" as const,
115
+ label: presetLabel(preset.preset),
116
+ config: preset,
117
+ active: preset.preset === activeName,
118
+ isDefault: preset.preset === DEFAULT_PRESET,
119
+ custom: false,
120
+ }));
121
+
122
+ // A project that hand-edited its models.json names a preset no built-in
123
+ // carries. It is the routing actually in force, so it gets a row of its own —
124
+ // otherwise the list shows four presets with none of them marked, which reads
125
+ // as "routing is off" when it is emphatically on.
126
+ if (
127
+ config &&
128
+ activeName &&
129
+ !builtInNames.has(activeName) &&
130
+ matches(config, lowerQuery)
131
+ ) {
132
+ rows.push({
133
+ id: `preset:${activeName}`,
134
+ kind: "preset",
135
+ label: presetLabel(activeName),
136
+ config,
137
+ active: true,
138
+ isDefault: false,
139
+ custom: true,
140
+ });
141
+ }
142
+
143
+ if (rows.length === 0) return [];
144
+
145
+ const header: ModelsStatusItem = {
146
+ id: "status",
147
+ kind: "status",
148
+ label: status.preset
149
+ ? `Model tiers: ${status.state} · ${presetLabel(status.preset)}`
150
+ : `Model tiers: ${status.state}`,
151
+ state: status.state,
152
+ preset: status.preset,
153
+ driftCount: status.drift.length,
154
+ warningCount: status.warnings.length,
155
+ };
156
+
157
+ return [header, ...rows];
158
+ }
159
+
160
+ /**
161
+ * Index of the first row the user can actually land on, or 0 if none.
162
+ *
163
+ * The status row is a header, not a choice: every key this screen binds acts on
164
+ * a preset, so parking the cursor there would make `a` and Enter do nothing on
165
+ * the row the screen opens with.
166
+ */
167
+ export function firstSelectableIndex(items: ModelsBrowserItem[]): number {
168
+ const index = items.findIndex((item) => item.kind !== "status");
169
+ return index >= 0 ? index : 0;
170
+ }
@@ -19,6 +19,11 @@ export const TABS: Tab[] = [
19
19
  { key: "7", label: "Git State", screen: "gitignore" },
20
20
  { key: "8", label: "Alias", screen: "alias" },
21
21
  { key: "9", label: "Styles", screen: "styles" },
22
+ // Last, and keyed "0" because the digits ran out. The number row reads
23
+ // 1…9 then 0, so a tenth tab keyed 0 sits under the finger that follows 9 —
24
+ // which is the only reason the order here and the order on the keyboard
25
+ // agree.
26
+ { key: "0", label: "Models", screen: "models" },
22
27
  ];
23
28
 
24
29
  /** Columns one tab occupies: the text plus its one-space padding each side. */
@@ -35,11 +40,11 @@ export function barWidth(texts: string[]): number {
35
40
  /**
36
41
  * Choose what each tab shows for the space available.
37
42
  *
38
- * The full bar is ~99 columns at nine tabs, so on a 94-column pane it used to
39
- * overflow and OpenTUI clipped each cell — producing a row of truncated stubs
40
- * ("1:", "4:", "7:Git ") that named nothing. Dropping the inactive labels keeps
43
+ * The full bar is ~110 columns at ten tabs, so on a standard 100-column window
44
+ * it overflows and OpenTUI clips each cell — producing a row of truncated stubs
45
+ * ("1:", "4:", "7:Git ") that names nothing. Dropping the inactive labels keeps
41
46
  * the bar honest: the number is what you press, and the one tab whose name
42
- * matters is the one you are on.
47
+ * matters is the one you are on. Compacted, ten tabs need ~46 columns.
43
48
  */
44
49
  export function layoutTabs(
45
50
  tabs: Tab[],
@@ -3,11 +3,28 @@ import { theme } from "../../theme.js";
3
3
 
4
4
  /** One footer hint: a key (or key group) and the action it performs. */
5
5
  export interface FooterHint {
6
- /** Key(s) for this action, e.g. ["↑", "↓"] or ["U"]. Joined for display. */
6
+ /** Key(s) for this action, e.g. ["↑", "↓"] or ["U"]. Joined by `joinKeys`. */
7
7
  keys: string[];
8
8
  label: string;
9
9
  }
10
10
 
11
+ /**
12
+ * Render a key group as one chip's text.
13
+ *
14
+ * Single-character keys butt together, because `↑↓` is how an arrow pair is written
15
+ * everywhere and a separator there would be noise. Anything longer needs a gap, or the
16
+ * names run into each other and read as one key that does not exist — MEASURED on screen:
17
+ * `["Enter", "a"]` rendered as `Entera`, and the layout's own scroll hint
18
+ * `["^U/^D", "PgUp/Dn"]` rendered as `^U/^DPgUp/Dn` on every screen in the app.
19
+ *
20
+ * A space rather than a slash, because several key names already contain one.
21
+ */
22
+ export function joinKeys(keys: string[]): string {
23
+ return keys.every((k) => [...k].length === 1)
24
+ ? keys.join("")
25
+ : keys.join(" ");
26
+ }
27
+
11
28
  // The shared chip palette, not a local one. This file used to define its own
12
29
  // (`theme.colors.dim` block + terminal-foreground ink), which is why the footer
13
30
  // stayed a low-contrast grey-on-grey strip after the palette was made vivid —
@@ -31,7 +48,7 @@ export function FooterHints({
31
48
  const nodes: React.ReactNode[] = [];
32
49
  hints.forEach((hint, i) => {
33
50
  // A hint is identified by its key chord plus label, not its position.
34
- const id = `${hint.keys.join("")}:${hint.label}`;
51
+ const id = `${joinKeys(hint.keys)}:${hint.label}`;
35
52
  if (i > 0) {
36
53
  nodes.push(
37
54
  <span key={`gap-${id}`} fg={LABEL_FG}>
@@ -41,7 +58,7 @@ export function FooterHints({
41
58
  }
42
59
  nodes.push(
43
60
  <span key={`k-${id}`} bg={KEY_BG} fg={KEY_FG}>
44
- {` ${hint.keys.join("")} `}
61
+ {` ${joinKeys(hint.keys)} `}
45
62
  </span>,
46
63
  );
47
64
  nodes.push(
@@ -9,6 +9,70 @@ import { theme } from "../../theme.js";
9
9
  import { TabBar } from "../TabBar.js";
10
10
  import { type FooterHint, FooterHints } from "./FooterHints.js";
11
11
 
12
+ /**
13
+ * Panel content: a node, or a function handed the panel's usable columns.
14
+ *
15
+ * The function form exists because the geometry below — the app frame's side
16
+ * padding, the 49/50 split, the panel padding and the scrollbar column — is
17
+ * ScreenLayout's, and a screen that re-derives it from `terminalWidth` is
18
+ * guessing at constants it cannot see. Both screens that wrap body text did
19
+ * exactly that, with the same hand-copied formula, and both were wrong.
20
+ *
21
+ * `React.ReactNode` does not include functions, so the union is unambiguous.
22
+ */
23
+ export type PanelContent =
24
+ | React.ReactNode
25
+ | ((width: number) => React.ReactNode);
26
+
27
+ /**
28
+ * The app frame (App.tsx) pads the screen one column each side before
29
+ * ScreenLayout ever renders, so every percentage below resolves against
30
+ * `terminalWidth - 2`.
31
+ */
32
+ const APP_SIDE_PADDING = 2;
33
+ const LIST_FRACTION = 0.49;
34
+ const DETAIL_FRACTION = 0.5;
35
+ /** The list panel's own `paddingRight`. */
36
+ const LIST_CHROME = 1;
37
+ /** The detail panel's `paddingLeft`, plus the scrollbox's scrollbar column. */
38
+ const DETAIL_CHROME = 2;
39
+
40
+ /** Columns a panel's content can actually use, at this terminal width. */
41
+ function panelWidth(
42
+ terminalWidth: number,
43
+ fraction: number,
44
+ chrome: number,
45
+ ): number {
46
+ const inner = Math.max(1, terminalWidth - APP_SIDE_PADDING);
47
+ return Math.max(8, Math.floor(inner * fraction) - chrome);
48
+ }
49
+
50
+ /**
51
+ * Usable columns in the list panel.
52
+ *
53
+ * MEASURED against real renders at 70…200 columns, not derived from the JSX:
54
+ * Yoga rounds a fractional percentage to whole cells, so the arithmetic only
55
+ * matches the pixels if you check. This is exact at 8 of the 12 widths sampled
56
+ * and one column short at the other four, which is the safe direction — a
57
+ * budget one column under folds nothing, a budget one column over paints into
58
+ * the separator.
59
+ */
60
+ export function listPanelWidth(terminalWidth: number): number {
61
+ return panelWidth(terminalWidth, LIST_FRACTION, LIST_CHROME);
62
+ }
63
+
64
+ /**
65
+ * Usable columns in the detail panel, scrollbar excluded.
66
+ *
67
+ * Exact at 11 of the 12 widths sampled, one column short at the twelfth. The
68
+ * formula each screen used to carry instead was `floor(width * 0.5) - 4`: it
69
+ * forgot the app frame's padding, counted the scrollbar twice, and then floored
70
+ * at 24 — so below about 56 columns it asked for MORE room than the panel has.
71
+ */
72
+ export function detailPanelWidth(terminalWidth: number): number {
73
+ return panelWidth(terminalWidth, DETAIL_FRACTION, DETAIL_CHROME);
74
+ }
75
+
12
76
  interface ScreenLayoutProps {
13
77
  /** Screen title (e.g., "claudeup Plugins") */
14
78
  title: string;
@@ -40,10 +104,10 @@ interface ScreenLayoutProps {
40
104
  * (the standard look). A string or ReactNode is still accepted for
41
105
  * free-form footers. */
42
106
  footerHints: FooterHint[] | string | React.ReactNode;
43
- /** Left panel content */
44
- listPanel: React.ReactNode;
45
- /** Right panel content (detail view) */
46
- detailPanel: React.ReactNode;
107
+ /** Left panel content, or a function given the list panel's usable columns. */
108
+ listPanel: PanelContent;
109
+ /** Right panel content, or a function given the detail panel's usable columns. */
110
+ detailPanel: PanelContent;
47
111
  /**
48
112
  * Identity of the detail content, e.g. the selected row's id. When it
49
113
  * changes, the detail pane scrolls back to the top — a reading position
@@ -77,6 +141,15 @@ export function ScreenLayout({
77
141
  const panelHeight = Math.max(5, dimensions.contentHeight - fixedHeight);
78
142
  const lineWidth = Math.max(10, dimensions.terminalWidth - 4);
79
143
 
144
+ const listContent =
145
+ typeof listPanel === "function"
146
+ ? listPanel(listPanelWidth(dimensions.terminalWidth))
147
+ : listPanel;
148
+ const detailContent =
149
+ typeof detailPanel === "function"
150
+ ? detailPanel(detailPanelWidth(dimensions.terminalWidth))
151
+ : detailPanel;
152
+
80
153
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
81
154
 
82
155
  // The detail pane has no cursor, so it needs its own scroll keys: arrows
@@ -177,14 +250,21 @@ export function ScreenLayout({
177
250
 
178
251
  {/* Main content area */}
179
252
  <box flexDirection="row" height={panelHeight}>
180
- {/* List panel */}
253
+ {/* List panel. `overflow="hidden"` is the safety net, not the plan:
254
+ the box has a fixed height, so content taller than it does NOT
255
+ scroll and does NOT clip on its own — Yoga shrinks the children
256
+ and they composite on top of each other. MEASURED at 80×24:
257
+ "fable-advisor" and "fable-lead" rendered as "fable-leadical".
258
+ A screen that can overflow should budget its own rows; this
259
+ stops the failure being unreadable when one does not. */}
181
260
  <box
182
261
  flexDirection="column"
183
262
  width="49%"
184
263
  height={panelHeight}
185
264
  paddingRight={1}
265
+ overflow="hidden"
186
266
  >
187
- {listPanel}
267
+ {listContent}
188
268
  </box>
189
269
 
190
270
  {/* Vertical separator */}
@@ -208,7 +288,7 @@ export function ScreenLayout({
208
288
  },
209
289
  }}
210
290
  >
211
- <box flexDirection="column">{detailPanel}</box>
291
+ <box flexDirection="column">{detailContent}</box>
212
292
  </scrollbox>
213
293
  </box>
214
294
  </box>
@@ -4,11 +4,37 @@ import { theme } from "../../theme.js";
4
4
  interface MetaTextProps {
5
5
  text: string;
6
6
  tone?: "muted" | "warning" | "success" | "danger";
7
+ /**
8
+ * True when this sits inside a SELECTED row. Not cosmetic.
9
+ *
10
+ * Every `theme.meta` tone is chosen against the terminal's own background,
11
+ * and none of them survives the selection fill: measured on a screenshot,
12
+ * `muted` is 1.11:1 on it, `warning` 1.07:1, `danger` 1.11:1. So a selected
13
+ * row's `(default)`, `(new)`, `(needed)` and version strings were drawn in a
14
+ * colour indistinguishable from the purple behind them.
15
+ *
16
+ * On a selected row the tone's HUE is surrendered — the selection owns the
17
+ * colour channel there — and only the emphasis survives: `fg` for a tone
18
+ * that was saying something, `dim` for one that was only muttering. The
19
+ * label is words, so its meaning does not depend on the hue.
20
+ */
21
+ selected?: boolean;
7
22
  }
8
23
 
9
24
  /**
10
25
  * Subdued text for versions, stars, status indicators.
11
26
  */
12
- export function MetaText({ text, tone = "muted" }: MetaTextProps) {
27
+ export function MetaText({
28
+ text,
29
+ tone = "muted",
30
+ selected = false,
31
+ }: MetaTextProps) {
32
+ if (selected) {
33
+ return (
34
+ <span fg={tone === "muted" ? theme.selection.dim : theme.selection.fg}>
35
+ {text}
36
+ </span>
37
+ );
38
+ }
13
39
  return <span fg={theme.meta[tone]}>{text}</span>;
14
40
  }