claudeup 4.37.0 → 4.38.1

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 (33) hide show
  1. package/package.json +4 -4
  2. package/scripts/verify-community-registry.ts +272 -0
  3. package/src/__tests__/community-fetch.test.ts +545 -0
  4. package/src/__tests__/community-registry.test.ts +269 -0
  5. package/src/__tests__/community-staleness.test.ts +722 -0
  6. package/src/__tests__/open-file.test.ts +59 -0
  7. package/src/__tests__/style-wrap.test.ts +220 -0
  8. package/src/__tests__/styles-manager.test.ts +1124 -0
  9. package/src/__tests__/styles-origins.test.ts +416 -0
  10. package/src/__tests__/styles-screen-state.test.ts +460 -0
  11. package/src/__tests__/styles-status-line.test.ts +72 -0
  12. package/src/__tests__/styles-sync.test.ts +452 -0
  13. package/src/__tests__/tabbar-layout.test.ts +62 -0
  14. package/src/__tests__/terminology-filler.test.ts +214 -0
  15. package/src/data/community-styles.ts +531 -0
  16. package/src/main.tsx +15 -0
  17. package/src/services/catalog-cache-store.ts +101 -7
  18. package/src/services/community-fetcher.ts +90 -0
  19. package/src/services/community-styles.ts +1194 -0
  20. package/src/services/styles-manager.ts +1400 -0
  21. package/src/services/terminology-filler.ts +266 -0
  22. package/src/ui/App.tsx +15 -3
  23. package/src/ui/adapters/stylesAdapter.ts +403 -0
  24. package/src/ui/components/TabBar.tsx +43 -9
  25. package/src/ui/components/primitives/ActionHints.tsx +4 -1
  26. package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
  27. package/src/ui/registry.ts +6 -0
  28. package/src/ui/renderers/styleRenderers.tsx +809 -0
  29. package/src/ui/screens/StylesScreen.tsx +1089 -0
  30. package/src/ui/screens/index.ts +1 -0
  31. package/src/ui/state/reducer.ts +113 -1
  32. package/src/ui/state/types.ts +60 -2
  33. package/src/utils/open-file.ts +84 -0
@@ -0,0 +1,403 @@
1
+ import {
2
+ COMMUNITY_STYLES,
3
+ type CommunityStyle,
4
+ type CommunityStyleSource,
5
+ findCommunitySource,
6
+ } from "../../data/community-styles.js";
7
+ import type { CommunityUpstreamStatus } from "../../services/community-styles.js";
8
+ import type {
9
+ ImportedStyle,
10
+ StylePreset,
11
+ StyleSource,
12
+ } from "../../services/styles-manager.js";
13
+
14
+ // ─── Item types ───────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * List sections, in display order.
18
+ *
19
+ * Anthropic's official styles lead because they are the familiar starting
20
+ * point — someone who already knows Explanatory or Learning should find it
21
+ * first, then discover the presets that refine it. Team comes after the
22
+ * presets: it is the section a project fills in for itself.
23
+ *
24
+ * Community sorts LAST, and deliberately not second where "styles someone else
25
+ * wrote" would group it with Anthropic's. Two reasons decide it. It is the only
26
+ * section whose contents can change from the network, so keeping it at the
27
+ * bottom means the offline-authored sections never shift position underneath a
28
+ * user. And empty sections are omitted, so inserting it at position two would
29
+ * push Anthropic's familiar row down the screen for every existing user to buy
30
+ * discovery for a section most of them will not use.
31
+ */
32
+ export type StyleCategoryKey =
33
+ | "anthropic"
34
+ | "verbosity"
35
+ | "modifier"
36
+ | "team"
37
+ | "personal"
38
+ | "community";
39
+
40
+ export interface StyleCategoryItem {
41
+ id: string;
42
+ kind: "category";
43
+ label: string;
44
+ title: string;
45
+ categoryKey: StyleCategoryKey;
46
+ count: number;
47
+ tone: "purple" | "green" | "teal" | "yellow" | "gray" | "red";
48
+ badge?: string;
49
+ }
50
+
51
+ export interface StyleEntryItem {
52
+ id: string;
53
+ kind: "style";
54
+ label: string;
55
+ source: StyleSource;
56
+ /** In the pending selection (what apply would write). */
57
+ checked: boolean;
58
+ /** In the selection the generated style file records (what is live). */
59
+ applied: boolean;
60
+ /**
61
+ * Name of an already-selected style this one cancels out with, or null.
62
+ * Verbosity presets never report one — they behave as a radio group, so
63
+ * selecting a second replaces the first rather than colliding with it.
64
+ */
65
+ conflictsWith: string | null;
66
+ /** Template presets ship an empty table and cannot be applied directly. */
67
+ disabled: boolean;
68
+ /** For a fetched community style: what the last upstream check found. */
69
+ upstream?: CommunityUpstreamStatus;
70
+ }
71
+
72
+ /**
73
+ * A registry entry whose text is NOT on this machine yet.
74
+ *
75
+ * Not an `ImportedStyle`, and that is the point. `ImportedStyle` means "text
76
+ * that exists here"; a synthetic one carrying an empty body would be tickable,
77
+ * composable, and would hash to a lie. Keeping the type honest costs one extra
78
+ * item kind and buys a list where every tickable row has real words behind it.
79
+ */
80
+ export interface StyleOfferItem {
81
+ id: string;
82
+ kind: "offer";
83
+ label: string;
84
+ entry: CommunityStyle;
85
+ source: CommunityStyleSource;
86
+ /** True when the project's committed declaration asks for this one. */
87
+ declared: boolean;
88
+ }
89
+
90
+ export type StyleBrowserItem =
91
+ | StyleCategoryItem
92
+ | StyleEntryItem
93
+ | StyleOfferItem;
94
+
95
+ // ─── Adapter ──────────────────────────────────────────────────────────────────
96
+
97
+ export interface BuildStyleBrowserItemsArgs {
98
+ presets: StylePreset[];
99
+ imports: ImportedStyle[];
100
+ /** Pending selection, by `source.id`. */
101
+ selected: Set<string>;
102
+ /** Selection recorded in the generated style file (null if never applied). */
103
+ applied: { presets: string[]; imports: string[] } | null;
104
+ query: string;
105
+ /**
106
+ * Registry entries to offer. Defaults to the shipped registry, which is a
107
+ * compile-time constant, so building the list still costs no I/O whatsoever.
108
+ */
109
+ registry?: CommunityStyle[];
110
+ /** Last upstream check per coordinate id. Read from the local store only. */
111
+ upstream?: Record<string, CommunityUpstreamStatus>;
112
+ /** `community:` ids the declaration asks for and this machine lacks. */
113
+ fetchable?: string[];
114
+ }
115
+
116
+ function matches(source: StyleSource, lowerQuery: string): boolean {
117
+ if (!lowerQuery) return true;
118
+ const summary =
119
+ source.kind === "preset" ? source.summary : source.description;
120
+ // Filter on the displayed name too. A captured built-in shows as
121
+ // "Explanatory" and a titled preset as its full standard name; typing what
122
+ // you can see has to find it.
123
+ const shown = source.displayName;
124
+ return (
125
+ source.name.toLowerCase().includes(lowerQuery) ||
126
+ shown.toLowerCase().includes(lowerQuery) ||
127
+ summary.toLowerCase().includes(lowerQuery)
128
+ );
129
+ }
130
+
131
+ /** Same rule for an offer, over the fields an offer actually has. */
132
+ function offerMatches(entry: CommunityStyle, lowerQuery: string): boolean {
133
+ if (!lowerQuery) return true;
134
+ return (
135
+ entry.id.toLowerCase().includes(lowerQuery) ||
136
+ entry.displayName.toLowerCase().includes(lowerQuery) ||
137
+ entry.summary.toLowerCase().includes(lowerQuery)
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Which already-selected preset cancels `preset` out.
143
+ *
144
+ * `conflicts` is declared on one side of a pair in the shipped presets but the
145
+ * relationship is symmetric, so both directions are checked — otherwise the
146
+ * warning appears or not depending on which of the two the user ticked first.
147
+ */
148
+ function findConflict(
149
+ preset: StylePreset,
150
+ selectedPresets: StylePreset[],
151
+ ): string | null {
152
+ for (const other of selectedPresets) {
153
+ if (other.name === preset.name) continue;
154
+ if (
155
+ preset.conflicts.includes(other.name) ||
156
+ other.conflicts.includes(preset.name)
157
+ ) {
158
+ return other.name;
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Build the flat list for the Styles screen.
166
+ *
167
+ * Section order is Anthropic official, verbosity (exactly one), modifiers (any
168
+ * number), the project's own styles, this machine's, and finally other people's.
169
+ * Category rows are non-selectable headers — the screen's key handler skips
170
+ * them, and an empty section is omitted entirely rather than shown with a zero
171
+ * count.
172
+ */
173
+ export function buildStyleBrowserItems({
174
+ presets,
175
+ imports,
176
+ selected,
177
+ applied,
178
+ query,
179
+ registry = COMMUNITY_STYLES,
180
+ upstream = {},
181
+ fetchable = [],
182
+ }: BuildStyleBrowserItemsArgs): StyleBrowserItem[] {
183
+ const lowerQuery = query.trim().toLowerCase();
184
+ const items: StyleBrowserItem[] = [];
185
+
186
+ const appliedPresets = new Set(applied?.presets ?? []);
187
+ const appliedImports = new Set(applied?.imports ?? []);
188
+ const selectedPresets = presets.filter((preset) => selected.has(preset.id));
189
+
190
+ const verbosity = presets.filter((preset) => preset.axis === "verbosity");
191
+ const modifiers = presets.filter((preset) => preset.axis === "modifier");
192
+
193
+ const pushPresets = (
194
+ group: StylePreset[],
195
+ categoryKey: StyleCategoryKey,
196
+ title: string,
197
+ tone: StyleCategoryItem["tone"],
198
+ badge: string,
199
+ ) => {
200
+ const filtered = group.filter((preset) => matches(preset, lowerQuery));
201
+ if (filtered.length === 0) return;
202
+ items.push({
203
+ id: `cat:${categoryKey}`,
204
+ kind: "category",
205
+ label: title,
206
+ title,
207
+ categoryKey,
208
+ count: filtered.length,
209
+ tone,
210
+ badge,
211
+ });
212
+ for (const preset of filtered) {
213
+ items.push({
214
+ id: preset.id,
215
+ kind: "style",
216
+ label: preset.displayName,
217
+ source: preset,
218
+ checked: selected.has(preset.id),
219
+ applied: appliedPresets.has(preset.name),
220
+ conflictsWith:
221
+ preset.axis === "verbosity"
222
+ ? null
223
+ : findConflict(preset, selectedPresets),
224
+ disabled: preset.template,
225
+ });
226
+ }
227
+ };
228
+
229
+ const pushImports = (
230
+ origin: ImportedStyle["origin"],
231
+ categoryKey: StyleCategoryKey,
232
+ title: string,
233
+ tone: StyleCategoryItem["tone"],
234
+ badge: string,
235
+ ) => {
236
+ const filtered = imports.filter(
237
+ (style) => style.origin === origin && matches(style, lowerQuery),
238
+ );
239
+ if (filtered.length === 0) return;
240
+ items.push({
241
+ id: `cat:${categoryKey}`,
242
+ kind: "category",
243
+ label: title,
244
+ title,
245
+ categoryKey,
246
+ count: filtered.length,
247
+ tone,
248
+ badge,
249
+ });
250
+ for (const style of filtered) {
251
+ items.push({
252
+ id: style.id,
253
+ kind: "style",
254
+ // The displayed name, not the on-disk slug — "Explanatory", not
255
+ // "builtin-explanatory". `id` still carries the real one.
256
+ label: style.displayName,
257
+ source: style,
258
+ checked: selected.has(style.id),
259
+ applied: appliedImports.has(style.id),
260
+ conflictsWith: null,
261
+ disabled: false,
262
+ });
263
+ }
264
+ };
265
+
266
+ /**
267
+ * ONE stable order: registry order, by source then display name, whether a
268
+ * style is fetched or still an offer.
269
+ *
270
+ * Grouping fetched ones first was worse in use. Fetching moved the row out
271
+ * of the offers group and up into the fetched group, so everything below it
272
+ * shifted by one and the cursor — which tracks an index — ended up on a
273
+ * different style than the one just acted on. Position now says WHERE a
274
+ * style is in the registry and nothing else; whether it is on disk is the
275
+ * checkbox's job, which is the thing that can change without moving.
276
+ *
277
+ * Never ordered by popularity: the best-known names in this space ship no
278
+ * style files at all, so any such ranking would be one the registry cannot
279
+ * honour.
280
+ */
281
+ const pushCommunity = () => {
282
+ const declared = new Set(fetchable);
283
+ const fetched = imports.filter((style) => style.origin === "community");
284
+ // A community style's `name` IS its registry coordinate id, which is what
285
+ // lets one row hold either state without moving.
286
+ const onDisk = new Map(fetched.map((style) => [style.name, style]));
287
+
288
+ // Walk the registry in its own order and emit whichever state each entry
289
+ // is in. A fetched entry keeps the slot its offer occupied.
290
+ const rows = registry
291
+ .filter((entry) => !entry.retired || onDisk.has(entry.id))
292
+ .filter(
293
+ (entry) =>
294
+ findCommunitySource(entry.sourceId)?.retired !== true ||
295
+ onDisk.has(entry.id),
296
+ )
297
+ .filter((entry) => {
298
+ const style = onDisk.get(entry.id);
299
+ // Filter on whichever representation is showing, so typing a query
300
+ // cannot make a row vanish just because it changed state.
301
+ return style
302
+ ? matches(style, lowerQuery)
303
+ : offerMatches(entry, lowerQuery);
304
+ })
305
+ .sort(
306
+ (a, b) =>
307
+ a.sourceId.localeCompare(b.sourceId) ||
308
+ a.displayName.localeCompare(b.displayName),
309
+ );
310
+
311
+ // Anything on disk the registry no longer lists — a retired entry pulled
312
+ // before it was dropped. It still belongs to the user, so it is shown,
313
+ // after the registry rows so it cannot shift them.
314
+ const listed = new Set(rows.map((entry) => entry.id));
315
+ const orphans = fetched
316
+ .filter((style) => !listed.has(style.name))
317
+ .filter((style) => matches(style, lowerQuery))
318
+ .sort((a, b) => a.name.localeCompare(b.name));
319
+
320
+ if (rows.length === 0 && orphans.length === 0) return;
321
+
322
+ items.push({
323
+ id: "cat:community",
324
+ kind: "category",
325
+ label: "Community",
326
+ title: "Community",
327
+ categoryKey: "community",
328
+ count: rows.length + orphans.length,
329
+ // Yellow, shared with Anthropic captures: both sections are text
330
+ // captured from somewhere else onto this disk, which is the right
331
+ // pairing. Red is the only unused tone and it means danger here — a
332
+ // normal, working section must not wear it.
333
+ tone: "yellow",
334
+ badge: "from GitHub",
335
+ });
336
+
337
+ const pushFetched = (style: ImportedStyle) => {
338
+ items.push({
339
+ id: style.id,
340
+ kind: "style",
341
+ label: style.displayName,
342
+ source: style,
343
+ checked: selected.has(style.id),
344
+ applied: appliedImports.has(style.id),
345
+ conflictsWith: null,
346
+ disabled: false,
347
+ upstream: upstream[style.name],
348
+ });
349
+ };
350
+
351
+ for (const entry of rows) {
352
+ const style = onDisk.get(entry.id);
353
+ if (style) {
354
+ pushFetched(style);
355
+ continue;
356
+ }
357
+ const source = findCommunitySource(entry.sourceId);
358
+ if (!source) continue;
359
+ items.push({
360
+ id: `offer:${entry.id}`,
361
+ kind: "offer",
362
+ label: entry.displayName,
363
+ entry,
364
+ source,
365
+ declared: declared.has(`community:${entry.id}`),
366
+ });
367
+ }
368
+
369
+ for (const style of orphans) pushFetched(style);
370
+ };
371
+
372
+ // Badges are kept short deliberately: the row already carries a title and a
373
+ // count, and a long badge wraps onto a second line on a narrow pane, which
374
+ // costs a whole row and breaks the scan down the left column. The full
375
+ // explanation lives in the detail panel, where there is room for it.
376
+ pushImports(
377
+ "anthropic",
378
+ "anthropic",
379
+ "Anthropic official",
380
+ "yellow",
381
+ "captured",
382
+ );
383
+ pushPresets(verbosity, "verbosity", "Verbosity", "purple", "pick one");
384
+ pushPresets(modifiers, "modifier", "Modifiers", "teal", "combine freely");
385
+ pushImports("team", "team", "Team", "green", "in the repo");
386
+ pushImports("personal", "personal", "Personal", "gray", "this machine");
387
+ pushCommunity();
388
+
389
+ return items;
390
+ }
391
+
392
+ /**
393
+ * Index of the first row the user can actually land on, or 0 if none.
394
+ *
395
+ * `kind !== "category"` rather than `kind === "style"`: an offer is landable, it
396
+ * simply cannot be ticked. A cursor that skipped offers would make the whole
397
+ * section unreachable for a user who has fetched nothing — which is every user
398
+ * on their first launch.
399
+ */
400
+ export function firstSelectableIndex(items: StyleBrowserItem[]): number {
401
+ const index = items.findIndex((item) => item.kind !== "category");
402
+ return index >= 0 ? index : 0;
403
+ }
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { useDimensions } from "../state/DimensionsContext.js";
2
3
  import type { Screen } from "../state/types.js";
3
4
  import { theme } from "../theme.js";
4
5
 
@@ -8,7 +9,7 @@ interface Tab {
8
9
  screen: Screen;
9
10
  }
10
11
 
11
- const TABS: Tab[] = [
12
+ export const TABS: Tab[] = [
12
13
  { key: "1", label: "Plugins", screen: "plugins" },
13
14
  { key: "2", label: "Skills", screen: "skills" },
14
15
  { key: "3", label: "MCP", screen: "mcp" },
@@ -17,18 +18,57 @@ const TABS: Tab[] = [
17
18
  { key: "6", label: "CLI", screen: "cli-tools" },
18
19
  { key: "7", label: "Git State", screen: "gitignore" },
19
20
  { key: "8", label: "Alias", screen: "alias" },
21
+ { key: "9", label: "Styles", screen: "styles" },
20
22
  ];
21
23
 
24
+ /** Columns one tab occupies: the text plus its one-space padding each side. */
25
+ function cellWidth(text: string): number {
26
+ return text.length + 2;
27
+ }
28
+
29
+ /** Columns the whole bar occupies: every cell plus the separators between. */
30
+ export function barWidth(texts: string[]): number {
31
+ if (texts.length === 0) return 0;
32
+ return texts.reduce((n, t) => n + cellWidth(t), 0) + (texts.length - 1);
33
+ }
34
+
35
+ /**
36
+ * Choose what each tab shows for the space available.
37
+ *
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
41
+ * the bar honest: the number is what you press, and the one tab whose name
42
+ * matters is the one you are on.
43
+ */
44
+ export function layoutTabs(
45
+ tabs: Tab[],
46
+ currentScreen: Screen,
47
+ available: number,
48
+ ): string[] {
49
+ const full = tabs.map((tab) => `${tab.key}:${tab.label}`);
50
+ if (barWidth(full) <= available) return full;
51
+ return tabs.map((tab) =>
52
+ tab.screen === currentScreen ? `${tab.key}:${tab.label}` : tab.key,
53
+ );
54
+ }
55
+
22
56
  interface TabBarProps {
23
57
  currentScreen: Screen;
24
58
  }
25
59
 
26
60
  export function TabBar({ currentScreen }: TabBarProps) {
61
+ const dimensions = useDimensions();
62
+ // ScreenLayout pads the bar by 1 each side, inside a container padded by 1.
63
+ const available = Math.max(10, dimensions.terminalWidth - 4);
64
+ const texts = layoutTabs(TABS, currentScreen, available);
65
+
27
66
  return (
28
67
  <box flexDirection="row" gap={0}>
29
68
  {TABS.map((tab, index) => {
30
69
  const isSelected = tab.screen === currentScreen;
31
70
  const isLast = index === TABS.length - 1;
71
+ const text = texts[index];
32
72
 
33
73
  return (
34
74
  <box key={tab.key} flexDirection="row">
@@ -36,18 +76,12 @@ export function TabBar({ currentScreen }: TabBarProps) {
36
76
  {isSelected ? (
37
77
  <box>
38
78
  <text bg={theme.colors.accent} fg={theme.hints.fg}>
39
- <strong>
40
- {" "}
41
- {tab.key}:{tab.label}{" "}
42
- </strong>
79
+ <strong> {text} </strong>
43
80
  </text>
44
81
  </box>
45
82
  ) : (
46
83
  <box>
47
- <text fg={theme.colors.muted}>
48
- {" "}
49
- {tab.key}:{tab.label}{" "}
50
- </text>
84
+ <text fg={theme.colors.muted}> {text} </text>
51
85
  </box>
52
86
  )}
53
87
  {/* Separator */}
@@ -19,7 +19,10 @@ export function ActionHints({ hints }: ActionHintsProps) {
19
19
  return (
20
20
  <box flexDirection="column" marginTop={1}>
21
21
  {hints.map((hint) => (
22
- <box key={`${hint.key}:${hint.label}`}>
22
+ // A box with no flexDirection lays out as a column here, which put
23
+ // every key chip on its own line above its label. The chip and what
24
+ // it does are one unit — the footer hints already render that way.
25
+ <box key={`${hint.key}:${hint.label}`} flexDirection="row">
23
26
  <text
24
27
  bg={
25
28
  hint.tone === "danger"
@@ -9,6 +9,13 @@ interface ListCategoryRowProps {
9
9
  badge?: string;
10
10
  tone?: keyof typeof theme.category;
11
11
  selected: boolean;
12
+ /**
13
+ * Whether the row can be opened and closed. Default true, which is what
14
+ * every collapsible list wants. Set false for a heading that is purely a
15
+ * label — a ▶ on a row that does not respond to Enter reads as a control
16
+ * that is broken rather than as a section title.
17
+ */
18
+ expandable?: boolean;
12
19
  }
13
20
 
14
21
  export function ListCategoryRow({
@@ -18,9 +25,11 @@ export function ListCategoryRow({
18
25
  badge,
19
26
  tone = "gray",
20
27
  selected,
28
+ expandable = true,
21
29
  }: ListCategoryRowProps) {
22
30
  const colors = theme.category[tone];
23
- const label = `${expanded ? "▼" : "▶"} ${title}${count !== undefined ? ` (${count})` : ""}`;
31
+ const marker = expandable ? (expanded ? "▼ " : "▶ ") : "";
32
+ const label = `${marker}${title}${count !== undefined ? ` (${count})` : ""}`;
24
33
 
25
34
  return (
26
35
  <SelectableRow selected={selected}>
@@ -8,6 +8,12 @@ export interface RowRenderProps<T> {
8
8
 
9
9
  export interface DetailRenderProps<T> {
10
10
  item: T;
11
+ /**
12
+ * Usable columns in the detail panel. Optional — renderers that only emit
13
+ * short lines can ignore it. Renderers that print wrapped body text need it,
14
+ * because OpenTUI does not reflow: an unwrapped line is clipped, not folded.
15
+ */
16
+ width?: number;
11
17
  }
12
18
 
13
19
  export interface Hint {