claudeup 4.41.0 → 4.42.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.
@@ -1,75 +1,83 @@
1
- import React, { useEffect, useCallback, useMemo, useState } from "react";
2
- import { useApp, useModal, useProgress } from "../state/AppContext.js";
3
- import { asyncValue } from "../state/types.js";
4
- import { useDimensions } from "../state/DimensionsContext.js";
5
- import { useKeyboard } from "../hooks/useKeyboard.js";
6
- import { ScreenLayout } from "../components/layout/index.js";
7
- import { ScrollableList } from "../components/ScrollableList.js";
8
- import { EmptyFilterState } from "../components/EmptyFilterState.js";
9
- import { fuzzyFilter } from "../../utils/fuzzy-search.js";
1
+ import type React from "react";
2
+ import { useCallback, useEffect, useMemo, useState } from "react";
10
3
  import {
11
4
  defaultMarketplaces,
12
5
  getAllMarketplaces,
13
6
  } from "../../data/marketplaces.js";
14
- import { clearContentDriftCache } from "../../services/content-drift.js";
15
- import {
16
- diffVersions,
17
- loadSeenVersions,
18
- saveSeenVersions,
19
- } from "../../services/version-snapshot.js";
20
7
  import {
21
- getAvailablePlugins,
22
- getMarketplaceFetchFailures,
23
- refreshAllMarketplaces,
24
- clearMarketplaceCache,
25
- getLocalMarketplacesInfo,
26
- isInstalledInScope,
27
- resolveScopeAction,
28
- saveInstalledPluginVersion,
29
- type MarketplaceFetchFailure,
30
- type PluginInfo,
31
- type ScopeStatus,
32
- } from "../../services/plugin-manager.js";
8
+ type PluginScope,
9
+ addMarketplace as cliAddMarketplace,
10
+ installPlugin as cliInstallPlugin,
11
+ repairPlugin as cliRepairPlugin,
12
+ uninstallPlugin as cliUninstallPlugin,
13
+ updatePlugin as cliUpdatePlugin,
14
+ } from "../../services/claude-cli.js";
33
15
  import {
34
- setMcpEnvVar,
35
16
  getMcpEnvVars,
36
17
  readSettings,
37
18
  saveGlobalInstalledPluginVersion,
38
19
  saveLocalInstalledPluginVersion,
20
+ setMcpEnvVar,
39
21
  } from "../../services/claude-settings.js";
40
- import { saveProfile } from "../../services/profiles.js";
22
+ import { clearContentDriftCache } from "../../services/content-drift.js";
41
23
  import {
42
- addMarketplace as cliAddMarketplace,
43
- installPlugin as cliInstallPlugin,
44
- repairPlugin as cliRepairPlugin,
45
- uninstallPlugin as cliUninstallPlugin,
46
- updatePlugin as cliUpdatePlugin,
47
- type PluginScope,
48
- } from "../../services/claude-cli.js";
24
+ type MarketplaceFetchFailure,
25
+ type PluginInfo,
26
+ type ScopeStatus,
27
+ clearMarketplaceCache,
28
+ getAvailablePlugins,
29
+ getLocalMarketplacesInfo,
30
+ getMarketplaceFetchFailures,
31
+ isInstalledInScope,
32
+ refreshAllMarketplaces,
33
+ resolveScopeAction,
34
+ saveInstalledPluginVersion,
35
+ } from "../../services/plugin-manager.js";
49
36
  import {
50
37
  getPluginEnvRequirements,
51
38
  getPluginSourcePath,
52
39
  } from "../../services/plugin-mcp-config.js";
53
40
  import {
54
- getPluginSetupFromSource,
55
41
  checkMissingDeps,
42
+ getPluginSetupFromSource,
56
43
  installPluginDeps,
57
44
  } from "../../services/plugin-setup.js";
58
45
  import {
46
+ type VersionMismatchInfo,
59
47
  checkPluginVersionMismatches,
60
48
  checkSinglePluginMismatch,
61
- type VersionMismatchInfo,
62
49
  } from "../../services/plugin-version-check.js";
63
- import { useMismatchModal } from "../hooks/useMismatchModal.js";
50
+ import { saveProfile } from "../../services/profiles.js";
51
+ import {
52
+ fetchPluginContents,
53
+ fetchPluginSkillDetail,
54
+ } from "../../services/skills-manager.js";
55
+ import {
56
+ diffVersions,
57
+ loadSeenVersions,
58
+ saveSeenVersions,
59
+ } from "../../services/version-snapshot.js";
60
+ import { fuzzyFilter } from "../../utils/fuzzy-search.js";
61
+ import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
64
62
  import {
65
- buildPluginBrowserItems,
66
63
  type PluginBrowserItem,
64
+ type PluginComponentItem,
65
+ type PluginPluginItem,
66
+ buildPluginBrowserItems,
67
+ pluginContentsRepo,
67
68
  } from "../adapters/pluginsAdapter.js";
69
+ import { EmptyFilterState } from "../components/EmptyFilterState.js";
70
+ import { ScrollableList } from "../components/ScrollableList.js";
71
+ import { ScreenLayout } from "../components/layout/index.js";
72
+ import { useKeyboard } from "../hooks/useKeyboard.js";
73
+ import { useMismatchModal } from "../hooks/useMismatchModal.js";
68
74
  import {
69
- renderPluginRow,
70
75
  renderPluginDetail,
76
+ renderPluginRow,
71
77
  } from "../renderers/pluginRenderers.js";
72
- import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
78
+ import { useApp, useModal, useProgress } from "../state/AppContext.js";
79
+ import { useDimensions } from "../state/DimensionsContext.js";
80
+ import { asyncValue } from "../state/types.js";
73
81
  import { theme } from "../theme.js";
74
82
 
75
83
  /** Wraps the notice text in the warning-coloured row ScreenLayout renders. */
@@ -124,9 +132,7 @@ export function PluginsScreen() {
124
132
  // answered is a non-event for the user, and reporting it would fire a
125
133
  // warning about intended, working behaviour.
126
134
  const unresolved = new Set(
127
- pluginData
128
- .filter((p) => p.updateCheckFailed)
129
- .map((p) => p.marketplace),
135
+ pluginData.filter((p) => p.updateCheckFailed).map((p) => p.marketplace),
130
136
  );
131
137
  setCatalogFailures(
132
138
  getMarketplaceFetchFailures().filter((f) =>
@@ -228,8 +234,16 @@ export function PluginsScreen() {
228
234
  marketplaces: shownMarketplaces,
229
235
  plugins: shownPlugins,
230
236
  collapsedMarketplaces: pluginsState.collapsedMarketplaces,
237
+ expandedPlugins: pluginsState.expandedPlugins,
238
+ pluginContents: pluginsState.pluginContents,
231
239
  });
232
- }, [shownMarketplaces, shownPlugins, pluginsState.collapsedMarketplaces]);
240
+ }, [
241
+ shownMarketplaces,
242
+ shownPlugins,
243
+ pluginsState.collapsedMarketplaces,
244
+ pluginsState.expandedPlugins,
245
+ pluginsState.pluginContents,
246
+ ]);
233
247
 
234
248
  // Filter items by search query
235
249
  const filteredItems = useMemo(() => {
@@ -273,7 +287,13 @@ export function PluginsScreen() {
273
287
  } else if (item.kind === "plugin" && currentCatIncluded) {
274
288
  if (matchedPluginIds.has(item.id)) {
275
289
  const matched = fuzzyResults.find((r) => r.item.id === item.id);
276
- result.push({ ...item, matches: matched?.matches });
290
+ // Skill rows are not in the filtered result, so an expanded arrow
291
+ // here would point at nothing.
292
+ result.push({
293
+ ...item,
294
+ matches: matched?.matches,
295
+ isExpanded: false,
296
+ });
277
297
  }
278
298
  }
279
299
  }
@@ -283,6 +303,101 @@ export function PluginsScreen() {
283
303
 
284
304
  const selectableItems = useMemo(() => filteredItems, [filteredItems]);
285
305
 
306
+ /**
307
+ * Read the file behind whichever component row the cursor has settled on.
308
+ *
309
+ * Debounced, and that is not cosmetic: each read is one GitHub API call, an
310
+ * unauthenticated client gets 60 an hour, and holding ↓ through the 286
311
+ * skills of a large plugin would otherwise fire a call per keypress and
312
+ * exhaust the budget for every other marketplace on screen. 200ms is below
313
+ * the point a deliberate move feels laggy and above key-repeat.
314
+ *
315
+ * MCP servers are skipped: their whole definition is the JSON already read
316
+ * during the contents listing, so there is no document to fetch.
317
+ */
318
+ const selectedRow = selectableItems[pluginsState.selectedIndex];
319
+ const selectedComponent =
320
+ selectedRow?.kind === "component" && selectedRow.component.kind !== "mcp"
321
+ ? (selectedRow as PluginComponentItem)
322
+ : undefined;
323
+ const detailKey = selectedComponent?.detailKey;
324
+ const detailRepo = selectedComponent?.repo;
325
+ const detailPath = selectedComponent?.component.repoPath;
326
+ const haveDetail = detailKey
327
+ ? pluginsState.skillDetails.has(detailKey)
328
+ : true;
329
+
330
+ useEffect(() => {
331
+ if (!detailKey || !detailRepo || !detailPath) return;
332
+ if (haveDetail) return;
333
+
334
+ const timer = setTimeout(() => {
335
+ dispatch({ type: "PLUGINS_SKILL_DETAIL_LOADING", key: detailKey });
336
+ fetchPluginSkillDetail(detailRepo, detailPath)
337
+ .then((detail) =>
338
+ dispatch({
339
+ type: "PLUGINS_SKILL_DETAIL_SUCCESS",
340
+ key: detailKey,
341
+ detail,
342
+ }),
343
+ )
344
+ .catch((error: unknown) =>
345
+ dispatch({
346
+ type: "PLUGINS_SKILL_DETAIL_ERROR",
347
+ key: detailKey,
348
+ error: error instanceof Error ? error.message : String(error),
349
+ }),
350
+ );
351
+ }, 200);
352
+
353
+ return () => clearTimeout(timer);
354
+ }, [dispatch, haveDetail, detailKey, detailPath, detailRepo]);
355
+
356
+ /**
357
+ * Open or close a plugin's contents, fetching them the first time.
358
+ *
359
+ * The listing is kept per plugin id for the life of the session rather than
360
+ * refetched on each open: it costs a GitHub Tree API call, and an
361
+ * unauthenticated client gets 60 an hour across every marketplace on screen.
362
+ */
363
+ const togglePluginContents = useCallback(
364
+ (item: PluginPluginItem) => {
365
+ const pluginId = item.plugin.id;
366
+ dispatch({ type: "PLUGINS_TOGGLE_PLUGIN_CONTENTS", pluginId });
367
+
368
+ if (item.isExpanded) return;
369
+ // A previous *success* is kept for the session. A previous failure is
370
+ // not: the usual cause is the GitHub hourly limit, which clears on its
371
+ // own, and caching the failure made a temporary condition look permanent
372
+ // with no way back except reloading the whole screen.
373
+ if (pluginsState.pluginContents.get(pluginId)?.status === "success") {
374
+ return;
375
+ }
376
+
377
+ const marketplace = shownMarketplaces?.find(
378
+ (m) => m.name === item.plugin.marketplace,
379
+ );
380
+ const repo = marketplace
381
+ ? pluginContentsRepo(item.plugin, marketplace)
382
+ : undefined;
383
+ if (!repo) return;
384
+
385
+ dispatch({ type: "PLUGINS_CONTENTS_LOADING", pluginId });
386
+ fetchPluginContents(repo, item.plugin.source, item.plugin.marketplace)
387
+ .then((components) =>
388
+ dispatch({ type: "PLUGINS_CONTENTS_SUCCESS", pluginId, components }),
389
+ )
390
+ .catch((error: unknown) =>
391
+ dispatch({
392
+ type: "PLUGINS_CONTENTS_ERROR",
393
+ pluginId,
394
+ error: error instanceof Error ? error.message : String(error),
395
+ }),
396
+ );
397
+ },
398
+ [dispatch, pluginsState.pluginContents, shownMarketplaces],
399
+ );
400
+
286
401
  // Keyboard handling
287
402
  useKeyboard((event) => {
288
403
  if (state.modal) return;
@@ -342,11 +457,10 @@ export function PluginsScreen() {
342
457
  }
343
458
 
344
459
  if (
345
- (event.name === "left" ||
346
- event.name === "right" ||
347
- event.name === "<" ||
348
- event.name === ">") &&
349
- selectableItems[pluginsState.selectedIndex]?.kind === "category"
460
+ event.name === "left" ||
461
+ event.name === "right" ||
462
+ event.name === "<" ||
463
+ event.name === ">"
350
464
  ) {
351
465
  const item = selectableItems[pluginsState.selectedIndex];
352
466
  if (item?.kind === "category") {
@@ -354,8 +468,12 @@ export function PluginsScreen() {
354
468
  type: "PLUGINS_TOGGLE_MARKETPLACE",
355
469
  name: item.marketplace.name,
356
470
  });
471
+ return;
472
+ }
473
+ if (item?.kind === "plugin" && item.isExpandable) {
474
+ togglePluginContents(item);
475
+ return;
357
476
  }
358
- return;
359
477
  }
360
478
 
361
479
  if (isSearchActive) {
@@ -1257,9 +1375,19 @@ export function PluginsScreen() {
1257
1375
  )}
1258
1376
  </box>
1259
1377
  }
1378
+ // A skill's panel now holds its whole SKILL.md, which is long enough to
1379
+ // scroll. Without this the pane kept the previous row's scroll offset,
1380
+ // so moving to the next skill landed you in the middle of a document
1381
+ // you had not started reading.
1382
+ detailKey={selectedItem?.id}
1260
1383
  detailPanel={renderPluginDetail(
1261
1384
  selectedItem,
1262
1385
  pluginsState.collapsedMarketplaces,
1386
+ pluginsState.pluginContents,
1387
+ pluginsState.skillDetails,
1388
+ // ScreenLayout gives the detail pane 50% of the terminal, then takes
1389
+ // a column for its left padding and one for the scrollbar.
1390
+ Math.max(24, Math.floor(dimensions.terminalWidth / 2) - 3),
1263
1391
  )}
1264
1392
  />
1265
1393
  );
@@ -21,6 +21,9 @@ export const initialState: AppState = {
21
21
  plugins: {
22
22
  scope: "project",
23
23
  collapsedMarketplaces: new Set(),
24
+ expandedPlugins: new Set(),
25
+ pluginContents: new Map(),
26
+ skillDetails: new Map(),
24
27
  selectedIndex: 0,
25
28
  searchQuery: "",
26
29
  marketplaces: { status: "idle" },
@@ -123,6 +126,59 @@ export function appReducer(state: AppState, action: AppAction): AppState {
123
126
  };
124
127
  }
125
128
 
129
+ case "PLUGINS_TOGGLE_PLUGIN_CONTENTS": {
130
+ const expanded = new Set(state.plugins.expandedPlugins);
131
+ if (expanded.has(action.pluginId)) {
132
+ expanded.delete(action.pluginId);
133
+ } else {
134
+ expanded.add(action.pluginId);
135
+ }
136
+ return {
137
+ ...state,
138
+ plugins: { ...state.plugins, expandedPlugins: expanded },
139
+ };
140
+ }
141
+
142
+ case "PLUGINS_CONTENTS_LOADING":
143
+ case "PLUGINS_CONTENTS_SUCCESS":
144
+ case "PLUGINS_CONTENTS_ERROR": {
145
+ const pluginContents = new Map(state.plugins.pluginContents);
146
+ if (action.type === "PLUGINS_CONTENTS_LOADING") {
147
+ pluginContents.set(action.pluginId, { status: "loading" });
148
+ } else if (action.type === "PLUGINS_CONTENTS_SUCCESS") {
149
+ pluginContents.set(action.pluginId, {
150
+ status: "success",
151
+ components: action.components,
152
+ });
153
+ } else {
154
+ pluginContents.set(action.pluginId, {
155
+ status: "error",
156
+ error: action.error,
157
+ });
158
+ }
159
+ return { ...state, plugins: { ...state.plugins, pluginContents } };
160
+ }
161
+
162
+ case "PLUGINS_SKILL_DETAIL_LOADING":
163
+ case "PLUGINS_SKILL_DETAIL_SUCCESS":
164
+ case "PLUGINS_SKILL_DETAIL_ERROR": {
165
+ const skillDetails = new Map(state.plugins.skillDetails);
166
+ if (action.type === "PLUGINS_SKILL_DETAIL_LOADING") {
167
+ skillDetails.set(action.key, { status: "loading" });
168
+ } else if (action.type === "PLUGINS_SKILL_DETAIL_SUCCESS") {
169
+ skillDetails.set(action.key, {
170
+ status: "success",
171
+ detail: action.detail,
172
+ });
173
+ } else {
174
+ skillDetails.set(action.key, {
175
+ status: "error",
176
+ error: action.error,
177
+ });
178
+ }
179
+ return { ...state, plugins: { ...state.plugins, skillDetails } };
180
+ }
181
+
126
182
  case "PLUGINS_SELECT":
127
183
  return {
128
184
  ...state,
@@ -1,5 +1,9 @@
1
1
  import type { PluginInfo } from "../../services/plugin-manager.js";
2
2
  import type { VersionMismatchInfo } from "../../services/plugin-version-check.js";
3
+ import type {
4
+ PluginComponent,
5
+ PluginSkillDetail,
6
+ } from "../../services/skills-manager.js";
3
7
  import type { StylesSnapshot } from "../../services/styles-manager.js";
4
8
  import type {
5
9
  Marketplace,
@@ -128,9 +132,32 @@ export interface ProgressState {
128
132
  // Screen State Types
129
133
  // ============================================================================
130
134
 
135
+ /** Read-only listing of what one plugin ships, fetched on demand. */
136
+ export interface PluginContentsEntry {
137
+ status: "loading" | "success" | "error";
138
+ components?: PluginComponent[];
139
+ error?: string;
140
+ }
141
+
142
+ /** One skill's SKILL.md, fetched when the cursor settles on its row. */
143
+ export interface PluginSkillDetailEntry {
144
+ status: "loading" | "success" | "error";
145
+ detail?: PluginSkillDetail;
146
+ error?: string;
147
+ }
148
+
131
149
  export interface PluginsScreenState {
132
150
  scope: "project" | "global";
133
151
  collapsedMarketplaces: Set<string>;
152
+ /**
153
+ * Plugin ids whose contents list is open. Opposite default to
154
+ * `collapsedMarketplaces`: listing a plugin's contents costs an HTTP call,
155
+ * so it stays shut until asked for.
156
+ */
157
+ expandedPlugins: Set<string>;
158
+ pluginContents: Map<string, PluginContentsEntry>;
159
+ /** SKILL.md content, keyed `<repo>/<repoPath>`. */
160
+ skillDetails: Map<string, PluginSkillDetailEntry>;
134
161
  selectedIndex: number;
135
162
  searchQuery: string;
136
163
  marketplaces: AsyncData<Marketplace[]>;
@@ -274,6 +301,21 @@ export type AppAction =
274
301
  | { type: "PLUGINS_SET_SCOPE"; scope: "project" | "global" }
275
302
  | { type: "PLUGINS_TOGGLE_SCOPE" }
276
303
  | { type: "PLUGINS_TOGGLE_MARKETPLACE"; name: string }
304
+ | { type: "PLUGINS_TOGGLE_PLUGIN_CONTENTS"; pluginId: string }
305
+ | { type: "PLUGINS_CONTENTS_LOADING"; pluginId: string }
306
+ | {
307
+ type: "PLUGINS_CONTENTS_SUCCESS";
308
+ pluginId: string;
309
+ components: PluginComponent[];
310
+ }
311
+ | { type: "PLUGINS_CONTENTS_ERROR"; pluginId: string; error: string }
312
+ | { type: "PLUGINS_SKILL_DETAIL_LOADING"; key: string }
313
+ | {
314
+ type: "PLUGINS_SKILL_DETAIL_SUCCESS";
315
+ key: string;
316
+ detail: PluginSkillDetail;
317
+ }
318
+ | { type: "PLUGINS_SKILL_DETAIL_ERROR"; key: string; error: string }
277
319
  | { type: "PLUGINS_SELECT"; index: number }
278
320
  | { type: "PLUGINS_SET_SEARCH"; query: string }
279
321
  | { type: "PLUGINS_DATA_LOADING" }
@@ -71,3 +71,70 @@ export function scopeOffFill(): string {
71
71
  if (detected === "dark") return SCOPE_OFF_FILL.dark;
72
72
  return SCOPE_OFF_FALLBACK;
73
73
  }
74
+
75
+ /**
76
+ * Badge colours for the four plugin component kinds.
77
+ *
78
+ * These are chips, not accents: they sit on hundreds of rows at once, so the
79
+ * fill has to be a *wash* of the hue rather than the hue itself. That runs into
80
+ * the same wall `SCOPE_OFF_FILL` documents above — a wash is defined relative to
81
+ * the page, ANSI has no alpha, so the two ends are picked by hand.
82
+ *
83
+ * Hues are deliberately far apart (purple / teal / blue / amber) for the reason
84
+ * the scope squares are: one character of colour carries no readable hue shift,
85
+ * only a readable hue *difference*.
86
+ *
87
+ * The glyph colour is set per mode too. Reusing the vivid accent over a dark
88
+ * wash would drop it below the contrast the palette guarantees against the
89
+ * page — the accent's measured ratio is against `CONTRAST_REFERENCE`, not
90
+ * against another tint laid on top of it.
91
+ */
92
+ export const COMPONENT_BADGE = {
93
+ skill: {
94
+ light: { bg: "#E9DCF5", fg: "#6B21A8" },
95
+ dark: { bg: "#2E2140", fg: "#C4A5F0" },
96
+ },
97
+ command: {
98
+ light: { bg: "#D3E7EC", fg: "#0E5F73" },
99
+ dark: { bg: "#123037", fg: "#7DD3E0" },
100
+ },
101
+ agent: {
102
+ light: { bg: "#DCE4F7", fg: "#1D4ED8" },
103
+ dark: { bg: "#1B2740", fg: "#A5C0F5" },
104
+ },
105
+ mcp: {
106
+ light: { bg: "#F0E2CE", fg: "#92400E" },
107
+ dark: { bg: "#3A2A15", fg: "#E5B877" },
108
+ },
109
+ } as const;
110
+
111
+ export type ComponentBadgeKind = keyof typeof COMPONENT_BADGE;
112
+
113
+ /**
114
+ * The two reference pages, mirrored from `theme.ts`'s `CONTRAST_REFERENCE`.
115
+ *
116
+ * Duplicated deliberately: `theme.ts` imports opentui's RGBA, which drags the
117
+ * renderer into anything that touches it. This module is pure data by design so
118
+ * the colour tests can run without a terminal. A test asserts the two agree.
119
+ */
120
+ export const CONTRAST_REFERENCE_KEYS = {
121
+ light: "#FAFAD2",
122
+ dark: "#1C1C1E",
123
+ } as const;
124
+
125
+ /**
126
+ * Chip colours for one component kind, or `null` when the terminal never
127
+ * answered the OSC query.
128
+ *
129
+ * Null means *no chip* — the caller falls back to a bare glyph. That follows the
130
+ * rule at the top of this file: an undetected terminal gets today's behaviour
131
+ * rather than a guess, and here the wrong guess paints a wash of the wrong end
132
+ * behind every row in the list.
133
+ */
134
+ export function componentBadge(
135
+ kind: ComponentBadgeKind,
136
+ ): { bg: string; fg: string } | null {
137
+ if (detected === "light") return COMPONENT_BADGE[kind].light;
138
+ if (detected === "dark") return COMPONENT_BADGE[kind].dark;
139
+ return null;
140
+ }