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,1089 @@
1
+ import os from "node:os";
2
+ import React, {
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import {
10
+ type CommunityStyleSource,
11
+ resolveCommunityStyle,
12
+ } from "../../data/community-styles.js";
13
+ import { githubFetcher } from "../../services/community-fetcher.js";
14
+ import {
15
+ type CommunityUpstreamStatus,
16
+ acceptPendingUpdate,
17
+ checkAllSources,
18
+ checkSourceForUpdates,
19
+ describeCommunityFailure,
20
+ fetchCommunityStyles,
21
+ readCommunityStatuses,
22
+ } from "../../services/community-styles.js";
23
+ import {
24
+ COMMUNITY_ID_PREFIX,
25
+ type ImportedStyle,
26
+ type StylePreset,
27
+ applyStyles,
28
+ clearStyle,
29
+ communityCacheDirOrNull,
30
+ createTeamStyle,
31
+ loadStyles,
32
+ validateSelection,
33
+ } from "../../services/styles-manager.js";
34
+ import { fillTerminology } from "../../services/terminology-filler.js";
35
+ import { openInDefaultApp } from "../../utils/open-file.js";
36
+ import {
37
+ type StyleBrowserItem,
38
+ buildStyleBrowserItems,
39
+ firstSelectableIndex,
40
+ } from "../adapters/stylesAdapter.js";
41
+ import { EmptyFilterState } from "../components/EmptyFilterState.js";
42
+ import { ScrollableList } from "../components/ScrollableList.js";
43
+ import { ScreenLayout } from "../components/layout/index.js";
44
+ import { useKeyboard } from "../hooks/useKeyboard.js";
45
+ import {
46
+ renderStyleDetail,
47
+ renderStyleRow,
48
+ } from "../renderers/styleRenderers.js";
49
+ import { useApp, useModal } from "../state/AppContext.js";
50
+ import { useDimensions } from "../state/DimensionsContext.js";
51
+ import { theme } from "../theme.js";
52
+
53
+ export function StylesScreen() {
54
+ const { state, dispatch } = useApp();
55
+ const { styles: stylesState } = state;
56
+ const dimensions = useDimensions();
57
+ const modal = useModal();
58
+
59
+ const isSearchActive =
60
+ state.isSearching && state.currentRoute.screen === "styles" && !state.modal;
61
+
62
+ // ── Data ──────────────────────────────────────────────────────────────────
63
+
64
+ const fetchData = useCallback(async () => {
65
+ dispatch({ type: "STYLES_DATA_LOADING" });
66
+ try {
67
+ const snapshot = await loadStyles(state.projectPath);
68
+ dispatch({ type: "STYLES_DATA_SUCCESS", snapshot });
69
+ } catch (error) {
70
+ dispatch({
71
+ type: "STYLES_DATA_ERROR",
72
+ error: error instanceof Error ? error : new Error(String(error)),
73
+ });
74
+ }
75
+ }, [dispatch, state.projectPath]);
76
+
77
+ // dataRefreshVersion is a refetch signal, not a value this effect reads:
78
+ // bumping it is how the app tells every screen its data changed.
79
+ // biome-ignore lint/correctness/useExhaustiveDependencies: signal, not a read
80
+ useEffect(() => {
81
+ fetchData();
82
+ }, [fetchData, state.dataRefreshVersion]);
83
+
84
+ const snapshot =
85
+ stylesState.snapshot.status === "success"
86
+ ? stylesState.snapshot.data
87
+ : null;
88
+
89
+ // ── Community styles ──────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Where fetched styles live. Null only under an unisolated test, where the
93
+ * whole section is inert rather than pointed at the operator's real cache.
94
+ */
95
+ const cacheDir = useMemo(() => communityCacheDirOrNull(os.homedir()), []);
96
+
97
+ /**
98
+ * What the last upstream check found, per coordinate id.
99
+ *
100
+ * Read from the LOCAL store, never from the network. This effect runs on
101
+ * every reload and must stay as offline as `loadStyles` itself — an update
102
+ * check that fired because a panel was drawn would not be an advisory.
103
+ */
104
+ const [upstream, setUpstream] = useState<
105
+ Record<string, CommunityUpstreamStatus>
106
+ >({});
107
+ const refreshUpstream = useCallback(async () => {
108
+ try {
109
+ setUpstream(await readCommunityStatuses());
110
+ } catch {
111
+ // A cache that cannot be read means "not checked", which is already the
112
+ // default and is the safe direction to be wrong in.
113
+ }
114
+ }, []);
115
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-read per snapshot
116
+ useEffect(() => {
117
+ refreshUpstream();
118
+ }, [refreshUpstream, snapshot]);
119
+
120
+ /** The managed copies a check can ask about, with the bytes we hold. */
121
+ const cachedCommunity = useMemo(
122
+ () =>
123
+ (snapshot?.imports ?? [])
124
+ .filter((style) => style.origin === "community" && style.managed)
125
+ // `name` IS the coordinate id for a fetched style — that is the whole
126
+ // point of stamping the coordinate as the file's name.
127
+ .map((style) => ({
128
+ id: style.name,
129
+ sha256: style.community?.sha256 ?? null,
130
+ })),
131
+ [snapshot],
132
+ );
133
+
134
+ // ── Status line (auto-clearing) ───────────────────────────────────────────
135
+
136
+ /**
137
+ * Set the status line.
138
+ *
139
+ * No timer. A message used to erase itself after 5s, which meant the last
140
+ * thing you did was usually gone by the time you looked — and made the line
141
+ * untestable by anything that samples the screen a moment later. It is
142
+ * cleared when the NEXT action starts instead, so the screen always reports
143
+ * the most recent thing that happened.
144
+ */
145
+ const showStatus = useCallback(
146
+ (text: string, tone: "success" | "error" = "success") => {
147
+ dispatch({ type: "STYLES_STATUS_SET", status: { text, tone } });
148
+ },
149
+ [dispatch],
150
+ );
151
+ const statusMsg = stylesState.status;
152
+
153
+ // ── Derived ───────────────────────────────────────────────────────────────
154
+
155
+ const allItems = useMemo(
156
+ () =>
157
+ buildStyleBrowserItems({
158
+ presets: snapshot?.presets ?? [],
159
+ imports: snapshot?.imports ?? [],
160
+ selected: stylesState.selected,
161
+ applied: snapshot?.applied ?? null,
162
+ query: stylesState.searchQuery,
163
+ upstream,
164
+ fetchable: snapshot?.status.fetchable ?? [],
165
+ }),
166
+ [snapshot, stylesState.selected, stylesState.searchQuery, upstream],
167
+ );
168
+
169
+ // Keep the cursor off category headers — they are labels, not rows.
170
+ useEffect(() => {
171
+ const item = allItems[stylesState.selectedIndex];
172
+ if (allItems.length > 0 && (!item || item.kind === "category")) {
173
+ dispatch({
174
+ type: "STYLES_SELECT",
175
+ index: firstSelectableIndex(allItems),
176
+ });
177
+ }
178
+ }, [allItems, stylesState.selectedIndex, dispatch]);
179
+
180
+ const selectedItem: StyleBrowserItem | undefined =
181
+ allItems[stylesState.selectedIndex];
182
+
183
+ /** The pending selection, resolved back to real preset/import objects. */
184
+ const chosen = useMemo(() => {
185
+ const presets = (snapshot?.presets ?? []).filter((preset) =>
186
+ stylesState.selected.has(preset.id),
187
+ );
188
+ const imports = (snapshot?.imports ?? []).filter((style) =>
189
+ stylesState.selected.has(style.id),
190
+ );
191
+ return { presets, imports };
192
+ }, [snapshot, stylesState.selected]);
193
+
194
+ const errors = useMemo(
195
+ () => validateSelection(chosen.presets),
196
+ [chosen.presets],
197
+ );
198
+
199
+ const syncState = snapshot?.status.state;
200
+
201
+ /** True when the pending selection already matches what is live. */
202
+ const isDirty = useMemo(() => {
203
+ const live = new Set([
204
+ ...(snapshot?.applied?.presets ?? []),
205
+ ...(snapshot?.applied?.imports ?? []),
206
+ ]);
207
+ if (live.size !== stylesState.selected.size) return true;
208
+ for (const id of stylesState.selected) if (!live.has(id)) return true;
209
+ return false;
210
+ }, [snapshot, stylesState.selected]);
211
+
212
+ // ── Actions ───────────────────────────────────────────────────────────────
213
+
214
+ const [isFetching, setIsFetching] = useState(false);
215
+
216
+ /**
217
+ * Fetch, or accept a downloaded update. The only thing that reaches GitHub.
218
+ *
219
+ * `f` resolves its target in one order, and each step exists for a reason:
220
+ * an offer is the obvious case; a fetched style with an update waiting means
221
+ * accept, because that is what the row is offering; a fetched style without
222
+ * one means re-fetch. Falling through to the project's un-fetched community
223
+ * styles is what makes the status line's "press f" literally true rather than
224
+ * a suggestion that does nothing unless the cursor happens to be parked in
225
+ * the right section.
226
+ */
227
+ const handleFetch = useCallback(async () => {
228
+ if (isFetching) {
229
+ showStatus("Already talking to GitHub — one request at a time", "error");
230
+ return;
231
+ }
232
+ if (!cacheDir) {
233
+ showStatus(
234
+ "claudeup could not resolve its cache directory — set CLAUDE_CONFIG_DIR",
235
+ "error",
236
+ );
237
+ return;
238
+ }
239
+
240
+ let ids: string[] = [];
241
+ let accept: string | null = null;
242
+
243
+ if (selectedItem?.kind === "offer") {
244
+ ids = [selectedItem.entry.id];
245
+ } else if (
246
+ selectedItem?.kind === "style" &&
247
+ selectedItem.source.kind === "imported" &&
248
+ selectedItem.source.origin === "community"
249
+ ) {
250
+ if (!selectedItem.source.managed) {
251
+ showStatus(
252
+ `${selectedItem.label} is your own copy — claudeup will not overwrite it`,
253
+ "error",
254
+ );
255
+ return;
256
+ }
257
+ if (selectedItem.upstream?.state === "update-available") {
258
+ accept = selectedItem.source.name;
259
+ } else {
260
+ ids = [selectedItem.source.name];
261
+ }
262
+ } else if ((snapshot?.status.fetchable ?? []).length > 0) {
263
+ ids = (snapshot?.status.fetchable ?? []).map((id) =>
264
+ id.startsWith(COMMUNITY_ID_PREFIX)
265
+ ? id.slice(COMMUNITY_ID_PREFIX.length)
266
+ : id,
267
+ );
268
+ } else {
269
+ showStatus(
270
+ "Nothing to fetch here — move to a Community row and press f",
271
+ "error",
272
+ );
273
+ return;
274
+ }
275
+
276
+ setIsFetching(true);
277
+ try {
278
+ if (accept) {
279
+ const result = await acceptPendingUpdate({ cacheDir, id: accept });
280
+ await fetchData();
281
+ await refreshUpstream();
282
+ showStatus(
283
+ result.outcome === "accepted"
284
+ ? `Updated ${accept} — press a to re-apply it`
285
+ : `No downloaded update for ${accept} — press u to check`,
286
+ result.outcome === "accepted" ? "success" : "error",
287
+ );
288
+ return;
289
+ }
290
+
291
+ showStatus(
292
+ `Fetching ${ids.length} style${ids.length === 1 ? "" : "s"} from GitHub…`,
293
+ "success",
294
+ );
295
+ const result = await fetchCommunityStyles({
296
+ ids,
297
+ fetcher: githubFetcher,
298
+ cacheDir,
299
+ });
300
+ await fetchData();
301
+ await refreshUpstream();
302
+
303
+ if (result.failed.length > 0) {
304
+ // The FIRST failure verbatim, not a count. Each kind carries its own
305
+ // diagnosis — a 404 blames our registry, a DNS failure names Tailscale
306
+ // — and "3 styles failed" throws all of that away.
307
+ const [first] = result.failed;
308
+ showStatus(
309
+ describeCommunityFailure(first.failure, first.label),
310
+ "error",
311
+ );
312
+ return;
313
+ }
314
+ if (result.unknown.length > 0) {
315
+ showStatus(
316
+ `claudeup has no registry entry for ${result.unknown.join(", ")} — please report it`,
317
+ "error",
318
+ );
319
+ return;
320
+ }
321
+ showStatus(
322
+ `Fetched ${result.written.length} style${
323
+ result.written.length === 1 ? "" : "s"
324
+ } — read it, then press Space to select and a to apply`,
325
+ );
326
+ } catch (error) {
327
+ showStatus(
328
+ error instanceof Error ? error.message : String(error),
329
+ "error",
330
+ );
331
+ } finally {
332
+ setIsFetching(false);
333
+ }
334
+ }, [
335
+ isFetching,
336
+ cacheDir,
337
+ selectedItem,
338
+ snapshot,
339
+ fetchData,
340
+ refreshUpstream,
341
+ showStatus,
342
+ ]);
343
+
344
+ /**
345
+ * Ask GitHub whether cached styles have moved. Advisory, and explicit only.
346
+ *
347
+ * `all` checks every repo with a cached style — at most one API call each,
348
+ * five today. Nothing it finds is installed: an update lands in `.pending/`
349
+ * and waits for `f`, because silently rewriting the words of a style that is
350
+ * currently applied is the one thing this design will not do.
351
+ */
352
+ const handleCheckUpdates = useCallback(
353
+ async (all: boolean) => {
354
+ if (isFetching) {
355
+ showStatus(
356
+ "Already talking to GitHub — one request at a time",
357
+ "error",
358
+ );
359
+ return;
360
+ }
361
+ if (!cacheDir || cachedCommunity.length === 0) {
362
+ showStatus("No fetched community styles to check", "error");
363
+ return;
364
+ }
365
+
366
+ let cached = cachedCommunity;
367
+ let only: CommunityStyleSource | null = null;
368
+ if (!all) {
369
+ const name =
370
+ selectedItem?.kind === "style" &&
371
+ selectedItem.source.kind === "imported" &&
372
+ selectedItem.source.origin === "community"
373
+ ? selectedItem.source.name
374
+ : null;
375
+ const resolved = name ? resolveCommunityStyle(name) : null;
376
+ if (!resolved) {
377
+ showStatus(
378
+ "Move to a fetched community style and press u, or U to check all",
379
+ "error",
380
+ );
381
+ return;
382
+ }
383
+ only = resolved.source;
384
+ // Every style of this REPO, not just the selected row — one API call
385
+ // answers for all of them, so narrowing it further would pay the same
386
+ // price for less information.
387
+ cached = cachedCommunity.filter(
388
+ (entry) => resolveCommunityStyle(entry.id)?.source.id === only?.id,
389
+ );
390
+ if (cached.length === 0) {
391
+ showStatus(
392
+ `${name} is not a copy claudeup manages — nothing to check`,
393
+ "error",
394
+ );
395
+ return;
396
+ }
397
+ }
398
+
399
+ setIsFetching(true);
400
+ showStatus("Checking GitHub for newer versions…");
401
+ try {
402
+ const statuses = only
403
+ ? (
404
+ await checkSourceForUpdates({
405
+ source: only,
406
+ cached,
407
+ fetcher: githubFetcher,
408
+ cacheDir,
409
+ force: true,
410
+ })
411
+ ).statuses
412
+ : (
413
+ await checkAllSources({
414
+ cached,
415
+ fetcher: githubFetcher,
416
+ cacheDir,
417
+ force: true,
418
+ })
419
+ ).statuses;
420
+
421
+ await refreshUpstream();
422
+ const values = Object.values(statuses);
423
+ const updates = values.filter(
424
+ (s) => s.state === "update-available",
425
+ ).length;
426
+ const unknown = values.filter((s) => s.state === "unknown");
427
+ if (updates > 0) {
428
+ showStatus(
429
+ `${updates} update${updates === 1 ? "" : "s"} downloaded to .pending — press f on a row to accept`,
430
+ );
431
+ } else if (unknown.length === values.length && unknown.length > 0) {
432
+ // Every answer was "we could not look". Say the reason, not a
433
+ // reassuring summary — a rate-limited check must never read as
434
+ // up to date.
435
+ showStatus(`Could not check — ${unknown[0].detail}`, "error");
436
+ } else {
437
+ showStatus(
438
+ `Up to date${unknown.length > 0 ? ` (${unknown.length} could not be checked)` : ""}`,
439
+ );
440
+ }
441
+ } catch (error) {
442
+ showStatus(
443
+ error instanceof Error ? error.message : String(error),
444
+ "error",
445
+ );
446
+ } finally {
447
+ setIsFetching(false);
448
+ }
449
+ },
450
+ [
451
+ isFetching,
452
+ cacheDir,
453
+ cachedCommunity,
454
+ selectedItem,
455
+ refreshUpstream,
456
+ showStatus,
457
+ ],
458
+ );
459
+
460
+ /**
461
+ * Toggle one entry.
462
+ *
463
+ * Verbosity presets are a radio group, not checkboxes: the composition
464
+ * accepts exactly one, and letting a second be ticked would only produce an
465
+ * error at apply time. Swapping is what the user meant, so do that instead
466
+ * of making them untick the old one first.
467
+ */
468
+ const handleToggle = useCallback(() => {
469
+ // Space on an offer FETCHES it. Pressing the ticking key on something that
470
+ // cannot be ticked should do the thing that makes it tickable, not beep.
471
+ if (selectedItem?.kind === "offer") {
472
+ handleFetch();
473
+ return;
474
+ }
475
+ if (!selectedItem || selectedItem.kind !== "style") return;
476
+ const { source } = selectedItem;
477
+
478
+ if (selectedItem.disabled) {
479
+ showStatus(
480
+ `"${source.name}" is a template — fill it in and import it instead`,
481
+ "error",
482
+ );
483
+ return;
484
+ }
485
+
486
+ if (
487
+ source.kind === "preset" &&
488
+ source.axis === "verbosity" &&
489
+ !stylesState.selected.has(source.id)
490
+ ) {
491
+ const others = (snapshot?.presets ?? [])
492
+ .filter((preset) => preset.axis === "verbosity")
493
+ .map((preset) => preset.id);
494
+ const next = [...stylesState.selected].filter(
495
+ (id) => !others.includes(id),
496
+ );
497
+ next.push(source.id);
498
+ dispatch({ type: "STYLES_SET_SELECTION", ids: next });
499
+ return;
500
+ }
501
+
502
+ dispatch({ type: "STYLES_TOGGLE", id: source.id });
503
+ }, [
504
+ selectedItem,
505
+ stylesState.selected,
506
+ snapshot,
507
+ dispatch,
508
+ showStatus,
509
+ handleFetch,
510
+ ]);
511
+
512
+ const [isApplying, setIsApplying] = useState(false);
513
+
514
+ const handleApply = useCallback(async () => {
515
+ if (isApplying) return;
516
+ if (errors.length > 0) {
517
+ showStatus(errors[0], "error");
518
+ return;
519
+ }
520
+ if (chosen.presets.length === 0 && chosen.imports.length === 0) {
521
+ showStatus("Nothing selected — press Space to tick a style", "error");
522
+ return;
523
+ }
524
+
525
+ setIsApplying(true);
526
+ try {
527
+ const result = await applyStyles({
528
+ projectPath: state.projectPath,
529
+ presets: chosen.presets as StylePreset[],
530
+ imports: chosen.imports as ImportedStyle[],
531
+ });
532
+ await fetchData();
533
+ const where = result.profile
534
+ ? `profile "${result.profile}"`
535
+ : "this project";
536
+ // Say plainly when the manifest was NOT updated: without that record a
537
+ // `claudeup install` re-materializes settings.json and drops the style.
538
+ const durability =
539
+ result.profile && !result.recordedInManifest
540
+ ? " — not in profiles.json, so `claudeup install` will drop it"
541
+ : "";
542
+ showStatus(
543
+ `Applied ${result.styleName} to ${where} — commit .claude/style.json to share it${durability}`,
544
+ result.profile && !result.recordedInManifest ? "error" : "success",
545
+ );
546
+ } catch (error) {
547
+ showStatus(
548
+ error instanceof Error ? error.message : String(error),
549
+ "error",
550
+ );
551
+ } finally {
552
+ setIsApplying(false);
553
+ }
554
+ }, [isApplying, errors, chosen, state.projectPath, fetchData, showStatus]);
555
+
556
+ const handleClear = useCallback(async () => {
557
+ try {
558
+ await clearStyle(state.projectPath);
559
+ await fetchData();
560
+ dispatch({ type: "STYLES_SET_SELECTION", ids: [] });
561
+ showStatus("Cleared the active output style");
562
+ } catch (error) {
563
+ showStatus(
564
+ error instanceof Error ? error.message : String(error),
565
+ "error",
566
+ );
567
+ }
568
+ }, [state.projectPath, fetchData, dispatch, showStatus]);
569
+
570
+ const handleReset = useCallback(() => {
571
+ dispatch({
572
+ type: "STYLES_SET_SELECTION",
573
+ ids: [
574
+ ...(snapshot?.applied?.presets ?? []),
575
+ ...(snapshot?.applied?.imports ?? []),
576
+ ],
577
+ });
578
+ showStatus("Selection reset to the live style");
579
+ }, [snapshot, dispatch, showStatus]);
580
+
581
+ /**
582
+ * Start a new team style: scaffold a file in the project's
583
+ * .claude/output-styles/ that the user edits and commits.
584
+ *
585
+ * The file is created rather than opened in an editor — claudeup owns the
586
+ * TTY, and handing it to `$EDITOR` from inside OpenTUI is a good way to
587
+ * leave the terminal wedged. The status line reports the path; `r` reloads.
588
+ */
589
+ const handleNewTeamStyle = useCallback(async () => {
590
+ const name = await modal.input(
591
+ "New team style",
592
+ "Name (committed to .claude/output-styles/)",
593
+ );
594
+ if (name === null) return;
595
+ try {
596
+ const created = await createTeamStyle(state.projectPath, name);
597
+ await fetchData();
598
+ // Project-relative: the status line is one row, and an absolute path
599
+ // under a long temp or worktree prefix is truncated before it reaches
600
+ // the filename — the only part the reader needs.
601
+ const shown = created.path.startsWith(`${state.projectPath}/`)
602
+ ? created.path.slice(state.projectPath.length + 1)
603
+ : created.path;
604
+ // Open it straight away: a scaffold nobody edits is worse than no
605
+ // scaffold, and the whole point of creating one is to write in it.
606
+ let opened = true;
607
+ try {
608
+ await openInDefaultApp(created.path);
609
+ } catch {
610
+ opened = false;
611
+ }
612
+ showStatus(
613
+ created.existed
614
+ ? `${shown} already exists — opened it instead of overwriting`
615
+ : opened
616
+ ? `Created and opened ${shown} — press r after saving`
617
+ : `Created ${shown} — open it to edit, then press r`,
618
+ created.existed ? "error" : "success",
619
+ );
620
+ } catch (error) {
621
+ showStatus(
622
+ error instanceof Error ? error.message : String(error),
623
+ "error",
624
+ );
625
+ }
626
+ }, [modal, state.projectPath, fetchData, showStatus]);
627
+
628
+ /**
629
+ * Open the selected style's file in the system's default application.
630
+ *
631
+ * Presets are excluded on purpose: they live in the installed plugin cache,
632
+ * so an edit there is silently discarded the next time the plugin updates.
633
+ * Saying that is more useful than opening a file whose changes will vanish.
634
+ */
635
+ const handleOpen = useCallback(async () => {
636
+ if (selectedItem?.kind === "offer") {
637
+ showStatus(
638
+ `${selectedItem.label} is not on this machine yet — press f to fetch it`,
639
+ "error",
640
+ );
641
+ return;
642
+ }
643
+ if (!selectedItem || selectedItem.kind !== "style") return;
644
+ const { source } = selectedItem;
645
+
646
+ if (source.kind === "preset") {
647
+ showStatus(
648
+ `${source.name} ships with style@magus and is replaced on update — press n to make a team style instead`,
649
+ "error",
650
+ );
651
+ return;
652
+ }
653
+
654
+ try {
655
+ await openInDefaultApp(source.path);
656
+ showStatus(
657
+ source.origin === "anthropic"
658
+ ? `Opened ${source.name}.md — note re-capturing overwrites it`
659
+ : source.origin === "community"
660
+ ? `Opened ${source.name}.md — a re-fetch overwrites it, so edit a copy`
661
+ : `Opened ${source.name}.md — press r after saving`,
662
+ );
663
+ } catch (error) {
664
+ showStatus(
665
+ error instanceof Error ? error.message : String(error),
666
+ "error",
667
+ );
668
+ }
669
+ }, [selectedItem, showStatus]);
670
+
671
+ const isFilling = stylesState.isFilling;
672
+
673
+ /**
674
+ * Fill a template preset from the codebase by running Claude Code over it.
675
+ *
676
+ * `terminology` ships with an empty vocabulary table that is worthless until
677
+ * someone surveys the project's own words — which is what the subprocess is
678
+ * for. It runs read-only and returns table rows; claudeup writes the file.
679
+ */
680
+ const handleFillTemplate = useCallback(async () => {
681
+ // Both of these used to return silently. A fill runs for minutes, so the
682
+ // natural thing a user does is press t again — and got no acknowledgement
683
+ // that the key had even been received, which reads as the key being dead.
684
+ if (isFilling) {
685
+ showStatus(
686
+ "Already reading the codebase — this takes a few minutes",
687
+ "error",
688
+ );
689
+ return;
690
+ }
691
+ if (!selectedItem || selectedItem.kind !== "style") {
692
+ showStatus("Select a style row first", "error");
693
+ return;
694
+ }
695
+ const { source } = selectedItem;
696
+ if (source.kind !== "preset" || !source.template) {
697
+ showStatus("Only a template preset can be filled in", "error");
698
+ return;
699
+ }
700
+
701
+ dispatch({ type: "STYLES_FILL_START" });
702
+ showStatus(
703
+ `Reading the codebase to fill ${source.name}… this can take a few minutes`,
704
+ "success",
705
+ );
706
+ try {
707
+ const result = await fillTerminology({
708
+ projectPath: state.projectPath,
709
+ templateBody: source.body,
710
+ });
711
+ await fetchData();
712
+ showStatus(
713
+ `Filled ${source.name} with ${result.rows.length} terms — it is now under Team`,
714
+ );
715
+ } catch (error) {
716
+ showStatus(
717
+ error instanceof Error ? error.message : String(error),
718
+ "error",
719
+ );
720
+ } finally {
721
+ dispatch({ type: "STYLES_FILL_END" });
722
+ }
723
+ }, [
724
+ isFilling,
725
+ selectedItem,
726
+ state.projectPath,
727
+ fetchData,
728
+ showStatus,
729
+ dispatch,
730
+ ]);
731
+
732
+ // ── Keyboard ──────────────────────────────────────────────────────────────
733
+
734
+ const move = useCallback(
735
+ (delta: -1 | 1) => {
736
+ let index = stylesState.selectedIndex + delta;
737
+ while (index >= 0 && index < allItems.length) {
738
+ // Offers are landable, only category headers are not. Skipping them
739
+ // would make the Community section unreachable for anyone who has
740
+ // fetched nothing — which is everyone on their first launch.
741
+ if (allItems[index] && allItems[index].kind !== "category") {
742
+ dispatch({ type: "STYLES_SELECT", index });
743
+ return;
744
+ }
745
+ index += delta;
746
+ }
747
+ },
748
+ [stylesState.selectedIndex, allItems, dispatch],
749
+ );
750
+
751
+ useKeyboard((event) => {
752
+ if (state.modal) return;
753
+
754
+ // The previous message belongs to the previous ACTION, so clearing it here
755
+ // covers every action key in one place and replaces the 5-second timer
756
+ // that used to erase messages while they were still being read.
757
+ //
758
+ // Navigation is excluded. A digit or Tab is handled by the global
759
+ // handler, not by this screen — and clearing on it wiped the message on
760
+ // the way OUT of the tab, so a round trip lost it even though the state
761
+ // now survives the unmount.
762
+ const isNavigation =
763
+ /^[1-9]$/.test(event.name ?? "") || event.name === "tab";
764
+ if (stylesState.status && !isNavigation) {
765
+ dispatch({ type: "STYLES_STATUS_CLEAR" });
766
+ }
767
+
768
+ const hasQuery = stylesState.searchQuery.length > 0;
769
+
770
+ if (event.name === "escape") {
771
+ if (hasQuery || isSearchActive) {
772
+ dispatch({ type: "STYLES_SET_SEARCH", query: "" });
773
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
774
+ dispatch({ type: "STYLES_SELECT", index: 0 });
775
+ }
776
+ return;
777
+ }
778
+
779
+ if (event.name === "backspace" || event.name === "delete") {
780
+ if (hasQuery) {
781
+ dispatch({ type: "STYLES_SEARCH_BACKSPACE" });
782
+ // Leaving search mode is driven by what was there BEFORE this
783
+ // keystroke, which is the one thing the closure does know reliably.
784
+ if (stylesState.searchQuery.length <= 1) {
785
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
786
+ }
787
+ }
788
+ return;
789
+ }
790
+
791
+ if (event.name === "up") {
792
+ if (isSearchActive)
793
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
794
+ move(-1);
795
+ return;
796
+ }
797
+ if (event.name === "down") {
798
+ if (isSearchActive)
799
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
800
+ move(1);
801
+ return;
802
+ }
803
+
804
+ if (event.name === "space" || event.name === " ") {
805
+ handleToggle();
806
+ return;
807
+ }
808
+
809
+ if (event.name === "return" || event.name === "enter") {
810
+ if (isSearchActive) {
811
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
812
+ return;
813
+ }
814
+ // Enter opens the style for editing; Space is the ticking key. Enter
815
+ // reads as "go into this thing", and ticking already has a key that
816
+ // means exactly that and nothing else.
817
+ handleOpen();
818
+ return;
819
+ }
820
+
821
+ // While typing a filter, letters are text — not commands.
822
+ if (isSearchActive) {
823
+ if (
824
+ event.name &&
825
+ event.name.length === 1 &&
826
+ !event.ctrl &&
827
+ !event.meta &&
828
+ !/[0-9]/.test(event.name)
829
+ ) {
830
+ dispatch({ type: "STYLES_SEARCH_APPEND", char: event.name });
831
+ }
832
+ return;
833
+ }
834
+
835
+ // Shift+U is tested BEFORE plain u. OpenTUI reports it as
836
+ // `{name: "u", shift: true}`, so a lowercase branch above this one would
837
+ // swallow it — the exact bug `uppercase-keybindings.test.ts` exists for.
838
+ if (event.name === "u" && event.shift) {
839
+ handleCheckUpdates(true);
840
+ } else if (event.name === "u") {
841
+ handleCheckUpdates(false);
842
+ } else if (event.name === "k") {
843
+ move(-1);
844
+ } else if (event.name === "j") {
845
+ move(1);
846
+ } else if (event.name === "a") {
847
+ handleApply();
848
+ } else if (event.name === "c") {
849
+ handleClear();
850
+ } else if (event.name === "x") {
851
+ handleReset();
852
+ } else if (event.name === "n") {
853
+ handleNewTeamStyle();
854
+ } else if (event.name === "e") {
855
+ handleOpen();
856
+ } else if (event.name === "f") {
857
+ handleFetch();
858
+ } else if (event.name === "t") {
859
+ handleFillTemplate();
860
+ } else if (event.name === "r") {
861
+ fetchData();
862
+ } else if (event.name === "/") {
863
+ dispatch({ type: "SET_SEARCHING", isSearching: true });
864
+ }
865
+ });
866
+
867
+ // ── Status line ───────────────────────────────────────────────────────────
868
+
869
+ // Order matters. A transient message wins while it lasts, so pressing a key
870
+ // always acknowledges the press — otherwise the fill banner below would
871
+ // shadow "already reading" and the second press would look ignored again,
872
+ // which is the bug this whole path exists to fix. The banner is the fallback
873
+ // because `statusMsg` is local and dies when the screen unmounts, leaving it
874
+ // the only thing that still reports work in flight after a tab switch.
875
+ const statusContent = statusMsg ? (
876
+ <text fg={theme.colors.text}>
877
+ <span
878
+ fg={
879
+ statusMsg.tone === "success"
880
+ ? theme.colors.success
881
+ : theme.colors.danger
882
+ }
883
+ >
884
+ {statusMsg.text}
885
+ </span>
886
+ </text>
887
+ ) : isFilling ? (
888
+ <text fg={theme.colors.text}>
889
+ <span fg={theme.colors.warning}>
890
+ Reading the codebase to fill a template… this can take a few minutes
891
+ </span>
892
+ </text>
893
+ ) : (
894
+ <text fg={theme.colors.text}>
895
+ <span fg={theme.colors.muted}>Active: </span>
896
+ <span
897
+ fg={
898
+ snapshot?.currentOutputStyle
899
+ ? theme.colors.success
900
+ : theme.colors.muted
901
+ }
902
+ >
903
+ {snapshot?.currentOutputStyle ?? "none"}
904
+ </span>
905
+ {snapshot?.profile ? (
906
+ <span fg={theme.colors.info}>{` │ profile ${snapshot.profile}`}</span>
907
+ ) : null}
908
+ {isApplying ? (
909
+ <span fg={theme.colors.warning}> │ applying…</span>
910
+ ) : isFetching ? (
911
+ <span fg={theme.colors.warning}> │ talking to GitHub…</span>
912
+ ) : errors.length > 0 ? (
913
+ <span fg={theme.colors.danger}> │ selection invalid</span>
914
+ ) : syncState === "not-applied" ? (
915
+ // Checked before the generic dirty flag: both are true after a pull,
916
+ // and "project style changed" says WHY, where "unapplied changes"
917
+ // only says that something differs.
918
+ <span fg={theme.colors.danger}>{` │ ${snapshot?.status.detail}`}</span>
919
+ ) : syncState === "stale" ? (
920
+ <span fg={theme.colors.warning}>{` │ ${snapshot?.status.detail}`}</span>
921
+ ) : isDirty ? (
922
+ <span fg={theme.colors.warning}> │ unapplied changes</span>
923
+ ) : syncState === "in-sync" ? (
924
+ <span fg={theme.colors.success}> │ in sync with project</span>
925
+ ) : null}
926
+ </text>
927
+ );
928
+
929
+ // ── Render ────────────────────────────────────────────────────────────────
930
+
931
+ // ScreenLayout gives the list 49% and the detail 50%, inside a container
932
+ // padded by 1 each side, and the detail panel adds 1 more of left padding.
933
+ const listWidth = Math.max(
934
+ 20,
935
+ Math.floor(dimensions.terminalWidth * 0.49) - 3,
936
+ );
937
+ const detailWidth = Math.max(
938
+ 24,
939
+ Math.floor(dimensions.terminalWidth * 0.5) - 4,
940
+ );
941
+ const query = stylesState.searchQuery.trim();
942
+ const noPresets = snapshot !== null && snapshot.presetsRoot === null;
943
+ // `t` only means anything on a template preset, so it is only advertised
944
+ // there — a footer full of keys that do nothing on the current row teaches
945
+ // the wrong thing.
946
+ const selectedIsTemplate =
947
+ selectedItem?.kind === "style" &&
948
+ selectedItem.source.kind === "preset" &&
949
+ selectedItem.source.template;
950
+ const selectedIsOffer = selectedItem?.kind === "offer";
951
+ const selectedIsCommunity =
952
+ selectedItem?.kind === "style" &&
953
+ selectedItem.source.kind === "imported" &&
954
+ selectedItem.source.origin === "community";
955
+ const selectedHasUpdate =
956
+ selectedItem?.kind === "style" &&
957
+ selectedItem.upstream?.state === "update-available";
958
+
959
+ /**
960
+ * Row-sensitive, and it has to REPLACE rather than append.
961
+ *
962
+ * The tab bar already degrades at about 94 columns, so a footer that only
963
+ * grows pushes itself off the screen on a split pane. `n` and `e` stay bound
964
+ * on a community row; they are simply not the keys that row is about.
965
+ */
966
+ const rowHints = selectedIsOffer
967
+ ? [
968
+ { keys: ["Space"], label: "fetch" },
969
+ { keys: ["a"], label: "apply" },
970
+ ]
971
+ : selectedIsCommunity
972
+ ? [
973
+ { keys: ["Space"], label: "toggle" },
974
+ { keys: ["a"], label: "apply" },
975
+ {
976
+ keys: ["f"],
977
+ label: selectedHasUpdate ? "accept update" : "re-fetch",
978
+ },
979
+ { keys: ["u"], label: "check" },
980
+ ]
981
+ : [
982
+ { keys: ["Space"], label: "toggle" },
983
+ { keys: ["a"], label: "apply" },
984
+ { keys: ["n"], label: "new" },
985
+ { keys: ["e"], label: "edit" },
986
+ ...(selectedIsTemplate
987
+ ? [{ keys: ["t"], label: "fill from codebase" }]
988
+ : []),
989
+ ];
990
+
991
+ return (
992
+ <ScreenLayout
993
+ title="claudeup Styles"
994
+ currentScreen="styles"
995
+ statusLine={statusContent}
996
+ search={
997
+ stylesState.searchQuery || isSearchActive
998
+ ? {
999
+ isActive: isSearchActive,
1000
+ query: stylesState.searchQuery,
1001
+ placeholder: "type to filter",
1002
+ }
1003
+ : undefined
1004
+ }
1005
+ footerHints={
1006
+ isSearchActive
1007
+ ? [
1008
+ { keys: ["type"], label: "filter" },
1009
+ { keys: ["Enter"], label: "done" },
1010
+ { keys: ["Esc"], label: "clear" },
1011
+ ]
1012
+ : [
1013
+ ...rowHints,
1014
+ { keys: ["c"], label: "clear" },
1015
+ { keys: ["/"], label: "filter" },
1016
+ ]
1017
+ }
1018
+ listPanel={
1019
+ <box flexDirection="column">
1020
+ {stylesState.snapshot.status === "loading" && (
1021
+ <box paddingLeft={2}>
1022
+ <text fg={theme.colors.warning}>Reading styles…</text>
1023
+ </box>
1024
+ )}
1025
+
1026
+ {stylesState.snapshot.status === "error" && (
1027
+ <box flexDirection="column" paddingLeft={2} paddingRight={2}>
1028
+ <text fg={theme.colors.danger}>Could not read styles.</text>
1029
+ <box marginTop={1}>
1030
+ <text fg={theme.colors.muted}>
1031
+ {stylesState.snapshot.error.message}
1032
+ </text>
1033
+ </box>
1034
+ </box>
1035
+ )}
1036
+
1037
+ {noPresets && allItems.length === 0 && (
1038
+ <box flexDirection="column" paddingLeft={2} paddingRight={2}>
1039
+ <text fg={theme.colors.warning}>
1040
+ The style plugin is not installed.
1041
+ </text>
1042
+ <box marginTop={1}>
1043
+ <text fg={theme.colors.muted}>
1044
+ Presets come from style@magus. Install it from the
1045
+ </text>
1046
+ <text fg={theme.colors.muted}>
1047
+ Plugins tab, then press r to reload.
1048
+ </text>
1049
+ </box>
1050
+ <box marginTop={1}>
1051
+ <text fg={theme.colors.muted}>
1052
+ Output styles you have written yourself are still
1053
+ </text>
1054
+ <text fg={theme.colors.muted}>
1055
+ listed under Imported, with no plugin needed.
1056
+ </text>
1057
+ </box>
1058
+ </box>
1059
+ )}
1060
+
1061
+ {allItems.length > 0 && (
1062
+ <ScrollableList
1063
+ items={allItems}
1064
+ selectedIndex={stylesState.selectedIndex}
1065
+ renderItem={(item, index, isSelected) =>
1066
+ renderStyleRow(item, index, isSelected, listWidth)
1067
+ }
1068
+ maxHeight={dimensions.listPanelHeight}
1069
+ getKey={(item, index) => `${index}:${item.id}`}
1070
+ />
1071
+ )}
1072
+
1073
+ {query.length > 0 &&
1074
+ allItems.length === 0 &&
1075
+ stylesState.snapshot.status === "success" &&
1076
+ !noPresets && (
1077
+ <EmptyFilterState
1078
+ query={stylesState.searchQuery}
1079
+ entityName="styles"
1080
+ />
1081
+ )}
1082
+ </box>
1083
+ }
1084
+ detailPanel={renderStyleDetail(selectedItem, detailWidth)}
1085
+ />
1086
+ );
1087
+ }
1088
+
1089
+ export default StylesScreen;