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