claudeup 4.37.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.
- package/package.json +4 -4
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +722 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +521 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +101 -7
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +113 -1
- package/src/ui/state/types.ts +60 -2
- 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
|
+
}
|