claudeup 4.36.0 → 4.38.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 (47) hide show
  1. package/package.json +4 -4
  2. package/scripts/verify-community-registry.ts +272 -0
  3. package/src/__tests__/catalog-cache-store.test.ts +271 -0
  4. package/src/__tests__/catalog-notice.test.ts +155 -0
  5. package/src/__tests__/community-fetch.test.ts +545 -0
  6. package/src/__tests__/community-registry.test.ts +269 -0
  7. package/src/__tests__/community-staleness.test.ts +722 -0
  8. package/src/__tests__/github-budget.test.ts +200 -0
  9. package/src/__tests__/open-file.test.ts +59 -0
  10. package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
  11. package/src/__tests__/style-wrap.test.ts +220 -0
  12. package/src/__tests__/styles-manager.test.ts +1124 -0
  13. package/src/__tests__/styles-origins.test.ts +416 -0
  14. package/src/__tests__/styles-screen-state.test.ts +460 -0
  15. package/src/__tests__/styles-status-line.test.ts +72 -0
  16. package/src/__tests__/styles-sync.test.ts +452 -0
  17. package/src/__tests__/tabbar-layout.test.ts +62 -0
  18. package/src/__tests__/terminology-filler.test.ts +214 -0
  19. package/src/data/community-styles.ts +521 -0
  20. package/src/main.tsx +15 -0
  21. package/src/services/catalog-cache-store.ts +312 -0
  22. package/src/services/community-fetcher.ts +90 -0
  23. package/src/services/community-styles.ts +1194 -0
  24. package/src/services/github-budget.ts +274 -0
  25. package/src/services/marketplace-catalog-git.ts +170 -0
  26. package/src/services/marketplace-catalog.ts +95 -0
  27. package/src/services/marketplace-fetcher.ts +310 -87
  28. package/src/services/plugin-manager.ts +103 -92
  29. package/src/services/styles-manager.ts +1400 -0
  30. package/src/services/terminology-filler.ts +266 -0
  31. package/src/ui/App.tsx +15 -3
  32. package/src/ui/adapters/catalogNotice.ts +122 -0
  33. package/src/ui/adapters/stylesAdapter.ts +403 -0
  34. package/src/ui/components/TabBar.tsx +43 -9
  35. package/src/ui/components/layout/ScreenLayout.tsx +19 -2
  36. package/src/ui/components/primitives/ActionHints.tsx +4 -1
  37. package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
  38. package/src/ui/registry.ts +6 -0
  39. package/src/ui/renderers/pluginRenderers.tsx +39 -1
  40. package/src/ui/renderers/styleRenderers.tsx +809 -0
  41. package/src/ui/screens/PluginsScreen.tsx +138 -29
  42. package/src/ui/screens/StylesScreen.tsx +1089 -0
  43. package/src/ui/screens/index.ts +1 -0
  44. package/src/ui/state/reducer.ts +124 -3
  45. package/src/ui/state/types.ts +76 -3
  46. package/src/utils/config-dir.ts +47 -0
  47. package/src/utils/open-file.ts +84 -0
@@ -0,0 +1,809 @@
1
+ import type React from "react";
2
+ import type { CommunityUpstreamStatus } from "../../services/community-styles.js";
3
+ import type {
4
+ StyleBrowserItem,
5
+ StyleCategoryItem,
6
+ StyleEntryItem,
7
+ StyleOfferItem,
8
+ } from "../adapters/stylesAdapter.js";
9
+ import {
10
+ ActionHints,
11
+ DetailSection,
12
+ KeyValueLine,
13
+ ListCategoryRow,
14
+ MetaText,
15
+ SelectableRow,
16
+ } from "../components/primitives/index.js";
17
+ import type { ItemRenderer } from "../registry.js";
18
+ import { type UiColor, theme } from "../theme.js";
19
+
20
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
21
+
22
+ const MAX_NAME_LEN = 22;
23
+
24
+ function truncate(text: string, max: number): string {
25
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
26
+ }
27
+
28
+ /**
29
+ * The checkbox glyph.
30
+ *
31
+ * `applied` (what the generated file records) and `checked` (the pending
32
+ * selection) are distinct on purpose: the whole point of a staged multi-select
33
+ * is that you can see, before pressing apply, how the pending set differs from
34
+ * what is live. A single glyph for both would hide exactly that.
35
+ */
36
+ function checkbox(item: StyleEntryItem): { glyph: string; tone: string } {
37
+ if (item.disabled) return { glyph: "[-]", tone: theme.colors.muted };
38
+ if (item.checked && item.applied)
39
+ return { glyph: "[x]", tone: theme.colors.success };
40
+ if (item.checked) return { glyph: "[+]", tone: theme.colors.warning };
41
+ if (item.applied) return { glyph: "[~]", tone: theme.colors.danger };
42
+ return { glyph: "[ ]", tone: theme.colors.muted };
43
+ }
44
+
45
+ /**
46
+ * The glyph for a registry entry whose text is not here yet.
47
+ *
48
+ * Never a `[ ]`. An unchecked checkbox says "tick me to include this", and an
49
+ * offer cannot be included — there are no words behind it until it is fetched.
50
+ * The arrow says "available", which is the true statement.
51
+ */
52
+ const OFFER_GLYPH = "[↓]";
53
+
54
+ /** "3h ago", for a check timestamp. Coarse on purpose — precision implies rigour. */
55
+ function ago(then: number, now: number): string {
56
+ const minutes = Math.max(0, Math.round((now - then) / 60_000));
57
+ if (minutes < 1) return "just now";
58
+ if (minutes < 60) return `${minutes}m ago`;
59
+ const hours = Math.round(minutes / 60);
60
+ if (hours < 24) return `${hours}h ago`;
61
+ return `${Math.round(hours / 24)}d ago`;
62
+ }
63
+
64
+ /**
65
+ * The Upstream line, and the one rule it must never break.
66
+ *
67
+ * `unknown` renders as "not checked" plus the reason, NEVER as anything a reader
68
+ * could mistake for currency. A rate-limited check, an offline box and a check
69
+ * that has simply aged past its window all land here, and each of them is the
70
+ * absence of evidence rather than evidence of freshness.
71
+ */
72
+ export function upstreamLine(
73
+ status: CommunityUpstreamStatus | undefined,
74
+ now: number,
75
+ ): { text: string; tone: string } {
76
+ if (!status) {
77
+ return { text: "not checked — press u", tone: theme.colors.muted };
78
+ }
79
+ const when = status.checkedAt
80
+ ? ` (checked ${ago(status.checkedAt, now)})`
81
+ : "";
82
+ if (status.state === "up-to-date") {
83
+ return { text: `up to date${when}`, tone: theme.colors.success };
84
+ }
85
+ if (status.state === "update-available") {
86
+ return { text: status.detail, tone: theme.colors.warning };
87
+ }
88
+ return { text: `not checked — ${status.detail}`, tone: theme.colors.muted };
89
+ }
90
+
91
+ /** A line that must be reproduced exactly: fenced code, or a markdown table. */
92
+ function isVerbatim(line: string): boolean {
93
+ return line.trimStart().startsWith("|");
94
+ }
95
+
96
+ /** A line that opens a new block rather than continuing the previous one. */
97
+ function startsBlock(line: string): boolean {
98
+ return /^\s*(?:[-*+]\s|#{1,6}\s|\d+\.\s|>\s?)/.test(line);
99
+ }
100
+
101
+ /**
102
+ * Group hard-wrapped source lines back into logical blocks.
103
+ *
104
+ * Style presets are authored as markdown wrapped at ~76 columns. Re-wrapping
105
+ * those lines individually to a ~50-column panel leaves every second line a
106
+ * ragged fragment, because a 40-character continuation line already fits and is
107
+ * emitted untouched. Joining a block's lines before re-wrapping is what makes
108
+ * the output look like a paragraph instead of shredded source.
109
+ *
110
+ * Fenced code and table rows are passed through untouched — reflowing a table
111
+ * destroys it, and there is no width at which a mangled table is better than a
112
+ * clipped one.
113
+ */
114
+ export function toBlocks(text: string): string[] {
115
+ const blocks: string[] = [];
116
+ let current = "";
117
+ let inFence = false;
118
+
119
+ const flush = () => {
120
+ if (current) blocks.push(current);
121
+ current = "";
122
+ };
123
+
124
+ for (const raw of text.split("\n")) {
125
+ const line = raw.replace(/\s+$/, "");
126
+
127
+ if (line.trimStart().startsWith("```")) {
128
+ flush();
129
+ blocks.push(line);
130
+ inFence = !inFence;
131
+ continue;
132
+ }
133
+ if (inFence || isVerbatim(line)) {
134
+ flush();
135
+ blocks.push(line);
136
+ continue;
137
+ }
138
+ if (!line.trim()) {
139
+ flush();
140
+ blocks.push("");
141
+ continue;
142
+ }
143
+ if (startsBlock(line)) {
144
+ flush();
145
+ current = line;
146
+ // A heading is a block of exactly one line; anything after it starts
147
+ // fresh rather than being folded into the heading text.
148
+ if (/^\s*#{1,6}\s/.test(line)) flush();
149
+ continue;
150
+ }
151
+ current = current ? `${current} ${line.trim()}` : line;
152
+ }
153
+ flush();
154
+
155
+ return blocks;
156
+ }
157
+
158
+ /**
159
+ * Render a style body as fixed-width lines: reflow, then wrap.
160
+ *
161
+ * OpenTUI does not fold text, so a line longer than the panel is clipped rather
162
+ * than wrapped — which for a rules list means silently losing the end of every
163
+ * rule.
164
+ */
165
+ export function formatBody(text: string, width: number): string[] {
166
+ const out: string[] = [];
167
+ for (const block of toBlocks(text)) {
168
+ if (!block) {
169
+ out.push("");
170
+ continue;
171
+ }
172
+ if (isVerbatim(block) || block.trimStart().startsWith("```")) {
173
+ out.push(block);
174
+ continue;
175
+ }
176
+ out.push(...wrapText(block, width));
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * Wrap one logical block to `width` columns, hanging-indenting continuations
183
+ * under the block's marker.
184
+ */
185
+ export function wrapText(text: string, width: number): string[] {
186
+ const limit = Math.max(20, width);
187
+ const out: string[] = [];
188
+
189
+ for (const rawLine of text.split("\n")) {
190
+ const line = rawLine.replace(/\s+$/, "");
191
+ if (line.length <= limit) {
192
+ out.push(line);
193
+ continue;
194
+ }
195
+
196
+ // Split the line into its leading marker (indentation plus an optional
197
+ // bullet) and the content after it. The marker is emitted ONCE and
198
+ // continuation lines get a blank hanging indent of the same width — so a
199
+ // bullet stays a bullet instead of being repeated on every wrapped row.
200
+ const match = /^(\s*(?:[-*]\s+)?)(.*)$/.exec(line);
201
+ const marker = match?.[1] ?? "";
202
+ const content = match?.[2] ?? line;
203
+ const hanging = " ".repeat(marker.length);
204
+
205
+ let prefix = marker;
206
+ let current = "";
207
+ for (const word of content.split(/\s+/).filter(Boolean)) {
208
+ const candidate = current ? `${current} ${word}` : word;
209
+ // Measure the rendered row — prefix included — against the real column
210
+ // width, not against a budget that drifts with how much came before.
211
+ if (current && prefix.length + candidate.length > limit) {
212
+ out.push(prefix + current);
213
+ prefix = hanging;
214
+ current = word;
215
+ } else {
216
+ current = candidate;
217
+ }
218
+ }
219
+ if (current) out.push(prefix + current);
220
+ }
221
+
222
+ return out;
223
+ }
224
+
225
+ // ─── Category renderer ────────────────────────────────────────────────────────
226
+
227
+ const CATEGORY_BLURB: Record<string, string> = {
228
+ anthropic:
229
+ "Claude Code's own built-in styles. Their text ships inside the binary, so these are snapshots captured to disk — re-capture them after a Claude Code upgrade or they drift from what the harness actually does.",
230
+ verbosity:
231
+ "How much the answer says. Exactly one applies — two contradict each other and cancel out.",
232
+ modifier:
233
+ "Independent rules layered on top of the verbosity preset. Combine as many as you like.",
234
+ team: "This project's own styles, in .claude/output-styles/. They live in the repository, so they commit with the project and everyone working on it gets the same one. Press n to start a new one.",
235
+ personal:
236
+ "Styles in ~/.claude/output-styles/ that are not captured built-ins. They exist only on this machine — move one into the project to share it with the team.",
237
+ community:
238
+ "Styles written by other people, fetched from their GitHub repos when you ask. claudeup stores no copy of the text — pressing f downloads the current version and stamps where it came from. Third-party writing becomes part of the system prompt when applied, so read it before you tick it.",
239
+ };
240
+
241
+ /**
242
+ * Why the two names a user would look for first are absent.
243
+ *
244
+ * Without this the omission reads as an oversight: caveman and humanizer are the
245
+ * highest-starred entries in this space by two orders of magnitude, and they
246
+ * ship zero output-style files. A registry entry for either would 404 on its
247
+ * first fetch.
248
+ */
249
+ const COMMUNITY_ABSENCE_NOTE =
250
+ "caveman and humanizer are skills, not output styles — they ship no style files, so they cannot appear here.";
251
+
252
+ /**
253
+ * A paragraph, pre-wrapped to the panel.
254
+ *
255
+ * The same reason `formatBody` exists: OpenTUI does not fold, so a line longer
256
+ * than the panel is clipped rather than wrapped, and every one of these blocks
257
+ * is longer than a half-width detail panel. Pre-wrapping is safe either way —
258
+ * short lines render identically whatever the renderer does with long ones.
259
+ */
260
+ function WrappedText({
261
+ text,
262
+ width,
263
+ tone,
264
+ }: {
265
+ text: string;
266
+ width?: number;
267
+ /** `UiColor`, not `string`: the terminal's own foreground is an RGBA. */
268
+ tone?: UiColor;
269
+ }): React.ReactNode {
270
+ return (
271
+ <box flexDirection="column">
272
+ {wrapText(text, width ?? 48).map((line, index) => (
273
+ <text
274
+ key={`${index}:${line.slice(0, 12)}`}
275
+ fg={tone ?? theme.colors.muted}
276
+ >
277
+ {line || " "}
278
+ </text>
279
+ ))}
280
+ </box>
281
+ );
282
+ }
283
+
284
+ const categoryRenderer: ItemRenderer<StyleCategoryItem> = {
285
+ renderRow: ({ item, isSelected }) => (
286
+ <ListCategoryRow
287
+ selected={isSelected}
288
+ title={item.title}
289
+ count={item.count}
290
+ badge={item.badge}
291
+ tone={item.tone}
292
+ // These are section labels, not collapsible groups — the cursor skips
293
+ // them entirely, so a ▶ would advertise an interaction that isn't there.
294
+ expandable={false}
295
+ />
296
+ ),
297
+
298
+ renderDetail: ({ item, width }) => (
299
+ <box flexDirection="column">
300
+ <text fg={theme.colors.accent}>
301
+ <strong>{item.title}</strong>
302
+ </text>
303
+ <box marginTop={1}>
304
+ <WrappedText
305
+ text={CATEGORY_BLURB[item.categoryKey] ?? ""}
306
+ width={width}
307
+ />
308
+ </box>
309
+ {item.categoryKey === "community" ? (
310
+ <box marginTop={1}>
311
+ <WrappedText text={COMMUNITY_ABSENCE_NOTE} width={width} />
312
+ </box>
313
+ ) : null}
314
+ </box>
315
+ ),
316
+ };
317
+
318
+ // ─── Style renderer ───────────────────────────────────────────────────────────
319
+
320
+ const styleRenderer: ItemRenderer<StyleEntryItem> = {
321
+ renderRow: ({ item, isSelected, width }) => {
322
+ const { source } = item;
323
+ const { glyph, tone } = checkbox(item);
324
+ const summary =
325
+ source.kind === "preset" ? source.summary : source.description;
326
+ // The label the adapter chose: Anthropic's own name for a captured
327
+ // built-in, the on-disk name for everything else.
328
+ const shown = item.label;
329
+ // Budget: indent(1) + checkbox(3) + space + name + 2 + summary.
330
+ const nameWidth = Math.min(MAX_NAME_LEN, shown.length);
331
+ const summaryRoom = Math.max(0, (width ?? 48) - nameWidth - 9);
332
+
333
+ return (
334
+ <SelectableRow selected={isSelected} indent={1}>
335
+ <span fg={isSelected ? theme.selection.fg : tone}>{glyph}</span>
336
+ <span> </span>
337
+ <span
338
+ fg={
339
+ isSelected
340
+ ? theme.selection.fg
341
+ : item.disabled
342
+ ? theme.colors.muted
343
+ : theme.colors.text
344
+ }
345
+ >
346
+ {truncate(shown, MAX_NAME_LEN).padEnd(nameWidth)}
347
+ </span>
348
+ {/* ASCII on purpose. U+26A0 carries emoji presentation, so terminals
349
+ draw it two cells wide while the layout measures it as one — the
350
+ row's next character was overprinted. */}
351
+ {item.conflictsWith ? <MetaText text=" (!)" tone="danger" /> : null}
352
+ {/* A pending update has to be visible from the LIST. Nobody opens the
353
+ detail panel on a style they are happy with, which is exactly the
354
+ style an update lands on. */}
355
+ {item.upstream?.state === "update-available" ? (
356
+ <MetaText text=" (new)" tone="warning" />
357
+ ) : null}
358
+ {summaryRoom > 6 ? (
359
+ <span fg={isSelected ? theme.selection.fg : theme.colors.muted}>
360
+ {` ${truncate(summary, summaryRoom)}`}
361
+ </span>
362
+ ) : null}
363
+ </SelectableRow>
364
+ );
365
+ },
366
+
367
+ renderDetail: ({ item, width }) => {
368
+ const { source } = item;
369
+ const summary =
370
+ source.kind === "preset" ? source.summary : source.description;
371
+ const bodyLines = formatBody(source.body, width ?? 48);
372
+
373
+ const originLabel =
374
+ source.kind === "imported"
375
+ ? source.origin === "anthropic"
376
+ ? "Anthropic official"
377
+ : source.origin === "community"
378
+ ? "community style (fetched from GitHub)"
379
+ : source.origin === "team"
380
+ ? "team style (commits with the project)"
381
+ : "personal style (this machine only)"
382
+ : null;
383
+
384
+ const provenance = source.kind === "imported" ? source.community : null;
385
+ const upstream = upstreamLine(item.upstream, Date.now());
386
+
387
+ return (
388
+ <box flexDirection="column">
389
+ <text fg={theme.colors.accent}>
390
+ <strong>{item.label}</strong>
391
+ </text>
392
+
393
+ {summary ? (
394
+ <box marginTop={1}>
395
+ <text fg={theme.colors.text}>{summary}</text>
396
+ </box>
397
+ ) : null}
398
+
399
+ <box flexDirection="column" marginTop={1}>
400
+ <KeyValueLine
401
+ label="Kind"
402
+ value={
403
+ <span fg={theme.colors.info}>
404
+ {source.kind === "preset"
405
+ ? source.axis === "verbosity"
406
+ ? "verbosity preset"
407
+ : "modifier preset"
408
+ : (originLabel ?? "")}
409
+ </span>
410
+ }
411
+ />
412
+ {provenance ? (
413
+ <KeyValueLine
414
+ label="Source"
415
+ value={
416
+ <span fg={theme.colors.text}>
417
+ {provenance.path
418
+ ? `${provenance.source} · ${provenance.path}`
419
+ : provenance.source}
420
+ </span>
421
+ }
422
+ />
423
+ ) : null}
424
+ {provenance ? (
425
+ <KeyValueLine
426
+ label="Ref"
427
+ value={
428
+ <span fg={theme.colors.muted}>
429
+ {provenance.commit
430
+ ? `${provenance.ref ?? "HEAD"}, pinned at ${provenance.commit}`
431
+ : // An unresolved pin is stated, not hidden. It is the
432
+ // difference between "this copy is reproducible" and
433
+ // "these are the bytes we happened to get".
434
+ `${provenance.ref ?? "HEAD"}, no commit pin recorded`}
435
+ </span>
436
+ }
437
+ />
438
+ ) : null}
439
+ {provenance?.fetched ? (
440
+ <KeyValueLine
441
+ label="Fetched"
442
+ value={<span fg={theme.colors.text}>{provenance.fetched}</span>}
443
+ />
444
+ ) : null}
445
+ {provenance ? (
446
+ <KeyValueLine
447
+ label="Licence"
448
+ value={
449
+ provenance.licence ? (
450
+ <span fg={theme.colors.text}>
451
+ {provenance.author
452
+ ? `${provenance.licence} · ${provenance.author}`
453
+ : provenance.licence}
454
+ </span>
455
+ ) : (
456
+ // Warning colour, and the words say what is missing rather
457
+ // than naming a licence that does not exist. NOASSERTION or
458
+ // no licence file means no grant of redistribution rights.
459
+ <span fg={theme.colors.warning}>
460
+ {provenance.author
461
+ ? `no licence granted · ${provenance.author}`
462
+ : "no licence granted"}
463
+ </span>
464
+ )
465
+ }
466
+ />
467
+ ) : null}
468
+ {provenance ? (
469
+ <KeyValueLine
470
+ label="Upstream"
471
+ value={<span fg={upstream.tone}>{upstream.text}</span>}
472
+ />
473
+ ) : null}
474
+ {provenance ? (
475
+ <KeyValueLine
476
+ label="Managed"
477
+ value={
478
+ <span fg={theme.colors.muted}>
479
+ {source.kind === "imported" && source.managed
480
+ ? "yes — claudeup's copy, re-fetchable"
481
+ : "no — this copy is yours, claudeup leaves it alone"}
482
+ </span>
483
+ }
484
+ />
485
+ ) : null}
486
+ {source.kind === "preset" && source.displayName !== source.name ? (
487
+ <KeyValueLine
488
+ label="Preset"
489
+ value={<span fg={theme.colors.muted}>{source.name}</span>}
490
+ />
491
+ ) : null}
492
+ {source.kind === "imported" && source.updatedAt ? (
493
+ <KeyValueLine
494
+ label="Updated"
495
+ value={<span fg={theme.colors.text}>{source.updatedAt}</span>}
496
+ />
497
+ ) : null}
498
+ {source.kind === "imported" && source.capturedFrom ? (
499
+ // The Claude Code version this snapshot came from. Shown because
500
+ // a built-in's real text lives in the binary: once the harness
501
+ // moves on, this file is a copy of what it USED to say.
502
+ <KeyValueLine
503
+ label="Captured"
504
+ value={
505
+ <span fg={theme.colors.muted}>
506
+ {`from Claude Code ${source.capturedFrom}`}
507
+ </span>
508
+ }
509
+ />
510
+ ) : null}
511
+ {source.kind === "imported" && source.displayName !== source.name ? (
512
+ <KeyValueLine
513
+ label="File"
514
+ value={<span fg={theme.colors.muted}>{`${source.name}.md`}</span>}
515
+ />
516
+ ) : null}
517
+ <KeyValueLine
518
+ label="Selected"
519
+ value={
520
+ <span
521
+ fg={item.checked ? theme.colors.success : theme.colors.muted}
522
+ >
523
+ {item.checked ? "yes" : "no"}
524
+ </span>
525
+ }
526
+ />
527
+ <KeyValueLine
528
+ label="Live"
529
+ value={
530
+ <span
531
+ fg={item.applied ? theme.colors.success : theme.colors.muted}
532
+ >
533
+ {item.applied ? "yes — in the active style" : "no"}
534
+ </span>
535
+ }
536
+ />
537
+ {source.kind === "preset" && source.conflicts.length > 0 ? (
538
+ <KeyValueLine
539
+ label="Conflicts"
540
+ value={
541
+ <span fg={theme.colors.warning}>
542
+ {source.conflicts.join(", ")}
543
+ </span>
544
+ }
545
+ />
546
+ ) : null}
547
+ </box>
548
+
549
+ {item.conflictsWith ? (
550
+ <box marginTop={1}>
551
+ <text fg={theme.colors.danger}>
552
+ {`Conflict: cancels out with "${item.conflictsWith}", which is also selected. Apply will refuse until one is unticked.`}
553
+ </text>
554
+ </box>
555
+ ) : null}
556
+
557
+ {item.disabled ? (
558
+ <box marginTop={1}>
559
+ <text fg={theme.colors.warning}>
560
+ This is a template — its table is empty until it is filled from
561
+ this codebase. Press t to have Claude Code survey the project and
562
+ fill it in; the result lands under Team, where it commits with the
563
+ repository.
564
+ </text>
565
+ </box>
566
+ ) : null}
567
+
568
+ {/* Stated plainly, and never softened. We validate the coordinate, the
569
+ content type, the size and the frontmatter; we do not and cannot
570
+ validate the body, and a panel that implied otherwise would be
571
+ worse than one that admits it. `(!)` is ASCII for the reason given
572
+ on the row glyph above. */}
573
+ {provenance ? (
574
+ <box marginTop={1}>
575
+ <WrappedText
576
+ width={width}
577
+ tone={theme.colors.warning}
578
+ text="(!) Third-party text. Applying it puts these words in the system prompt. A re-fetch overwrites this file, so edit a copy, not this one."
579
+ />
580
+ </box>
581
+ ) : null}
582
+
583
+ <DetailSection title="Rules">
584
+ <box flexDirection="column">
585
+ {bodyLines.map((line, index) => (
586
+ <text
587
+ key={`${index}:${line.slice(0, 12)}`}
588
+ fg={
589
+ line.startsWith("#") ? theme.colors.info : theme.colors.text
590
+ }
591
+ >
592
+ {line || " "}
593
+ </text>
594
+ ))}
595
+ </box>
596
+ </DetailSection>
597
+
598
+ <DetailSection title="Source">
599
+ <text fg={theme.colors.muted}>{source.path}</text>
600
+ </DetailSection>
601
+
602
+ <ActionHints
603
+ hints={[
604
+ { key: "Space", label: "toggle", tone: "primary" },
605
+ { key: "a", label: "apply selection to this project" },
606
+ ...(item.disabled
607
+ ? [{ key: "t", label: "fill it in from this codebase" }]
608
+ : source.kind === "imported"
609
+ ? [{ key: "e", label: "open in your default editor" }]
610
+ : []),
611
+ ...(provenance
612
+ ? [
613
+ {
614
+ key: "f",
615
+ label:
616
+ item.upstream?.state === "update-available"
617
+ ? "accept the downloaded update"
618
+ : "re-fetch the current version",
619
+ },
620
+ { key: "u", label: "check this repo for updates" },
621
+ ]
622
+ : []),
623
+ { key: "n", label: "new team style" },
624
+ {
625
+ key: "c",
626
+ label: "clear the active style",
627
+ tone: "danger" as const,
628
+ },
629
+ ]}
630
+ />
631
+ </box>
632
+ );
633
+ },
634
+ };
635
+
636
+ // ─── Offer renderer ───────────────────────────────────────────────────────────
637
+
638
+ /**
639
+ * A registry entry nobody has fetched.
640
+ *
641
+ * Landable but not tickable. It renders in the info colour rather than as a
642
+ * checkbox because there is nothing on this machine to tick yet — the row is an
643
+ * offer, and the honest thing for it to advertise is the key that makes it real.
644
+ */
645
+ const offerRenderer: ItemRenderer<StyleOfferItem> = {
646
+ renderRow: ({ item, isSelected, width }) => {
647
+ const nameWidth = Math.min(MAX_NAME_LEN, item.label.length);
648
+ const summaryRoom = Math.max(0, (width ?? 48) - nameWidth - 9);
649
+
650
+ return (
651
+ <SelectableRow selected={isSelected} indent={1}>
652
+ <span fg={isSelected ? theme.selection.fg : theme.colors.info}>
653
+ {OFFER_GLYPH}
654
+ </span>
655
+ <span> </span>
656
+ <span fg={isSelected ? theme.selection.fg : theme.colors.muted}>
657
+ {truncate(item.label, MAX_NAME_LEN).padEnd(nameWidth)}
658
+ </span>
659
+ {/* The project asked for this one and this machine does not have it.
660
+ Worth flagging on the row: it is the difference between "you could
661
+ have this" and "your project is waiting for this". */}
662
+ {item.declared ? <MetaText text=" (needed)" tone="warning" /> : null}
663
+ {summaryRoom > 6 ? (
664
+ <span fg={isSelected ? theme.selection.fg : theme.colors.muted}>
665
+ {` ${truncate(item.entry.summary, summaryRoom)}`}
666
+ </span>
667
+ ) : null}
668
+ </SelectableRow>
669
+ );
670
+ },
671
+
672
+ renderDetail: ({ item, width }) => {
673
+ const { entry, source } = item;
674
+ return (
675
+ <box flexDirection="column">
676
+ <text fg={theme.colors.accent}>
677
+ <strong>{entry.displayName}</strong>
678
+ </text>
679
+
680
+ <box marginTop={1}>
681
+ <WrappedText
682
+ text={entry.summary}
683
+ width={width}
684
+ tone={theme.colors.text}
685
+ />
686
+ </box>
687
+
688
+ <box flexDirection="column" marginTop={1}>
689
+ <KeyValueLine
690
+ label="Kind"
691
+ value={
692
+ <span fg={theme.colors.info}>not fetched yet — an offer</span>
693
+ }
694
+ />
695
+ <KeyValueLine
696
+ label="Source"
697
+ value={
698
+ <span fg={theme.colors.text}>
699
+ {`${source.repo} · ${entry.path}`}
700
+ </span>
701
+ }
702
+ />
703
+ <KeyValueLine
704
+ label="Licence"
705
+ value={
706
+ source.licence ? (
707
+ <span fg={theme.colors.text}>
708
+ {`${source.licence} · ${source.author}`}
709
+ </span>
710
+ ) : (
711
+ <span fg={theme.colors.warning}>
712
+ {`no licence granted · ${source.author}`}
713
+ </span>
714
+ )
715
+ }
716
+ />
717
+ <KeyValueLine
718
+ label="Repo"
719
+ value={
720
+ // No star count, deliberately. In this category stars measure
721
+ // the wrong thing: the best-known names in the space ship no
722
+ // style files at all, so a number here would imply a ranking
723
+ // across entries that cannot honour it. The push date is the
724
+ // signal that actually helps — it says whether anyone still
725
+ // maintains this.
726
+ <span fg={theme.colors.muted}>
727
+ {`last pushed ${source.pushedAt}`}
728
+ </span>
729
+ }
730
+ />
731
+ {item.declared ? (
732
+ <KeyValueLine
733
+ label="Needed"
734
+ value={
735
+ <span fg={theme.colors.warning}>
736
+ yes — this project's committed style asks for it
737
+ </span>
738
+ }
739
+ />
740
+ ) : null}
741
+ </box>
742
+
743
+ <box marginTop={1}>
744
+ <WrappedText
745
+ width={width}
746
+ text="Press Space or f to fetch — nothing is downloaded until you do. claudeup ships the coordinates for this style, never its text."
747
+ />
748
+ </box>
749
+
750
+ <box marginTop={1}>
751
+ <WrappedText
752
+ width={width}
753
+ tone={theme.colors.warning}
754
+ text="(!) Third-party text. Once fetched, applying it puts someone else's words in the system prompt. Read it first."
755
+ />
756
+ </box>
757
+
758
+ <DetailSection title="Source">
759
+ <text fg={theme.colors.muted}>{source.homepage}</text>
760
+ </DetailSection>
761
+
762
+ <ActionHints
763
+ hints={[
764
+ { key: "Space", label: "fetch it", tone: "primary" },
765
+ { key: "f", label: "fetch it" },
766
+ { key: "/", label: "filter the list" },
767
+ ]}
768
+ />
769
+ </box>
770
+ );
771
+ },
772
+ };
773
+
774
+ // ─── Dispatch ─────────────────────────────────────────────────────────────────
775
+
776
+ export function renderStyleRow(
777
+ item: StyleBrowserItem,
778
+ _index: number,
779
+ isSelected: boolean,
780
+ width?: number,
781
+ ): React.ReactNode {
782
+ if (item.kind === "category") {
783
+ return categoryRenderer.renderRow({ item, isSelected, width });
784
+ }
785
+ if (item.kind === "offer") {
786
+ return offerRenderer.renderRow({ item, isSelected, width });
787
+ }
788
+ return styleRenderer.renderRow({ item, isSelected, width });
789
+ }
790
+
791
+ export function renderStyleDetail(
792
+ item: StyleBrowserItem | undefined,
793
+ width?: number,
794
+ ): React.ReactNode {
795
+ if (!item) {
796
+ return (
797
+ <box flexDirection="column">
798
+ <text fg={theme.colors.muted}>No style selected.</text>
799
+ </box>
800
+ );
801
+ }
802
+ if (item.kind === "category") {
803
+ return categoryRenderer.renderDetail({ item, width });
804
+ }
805
+ if (item.kind === "offer") {
806
+ return offerRenderer.renderDetail({ item, width });
807
+ }
808
+ return styleRenderer.renderDetail({ item, width });
809
+ }