claudeup 4.35.1 → 4.37.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/src/__tests__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/__tests__/scope-squares.test.tsx +165 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/main.tsx +21 -5
- package/src/opentui.d.ts +21 -12
- package/src/services/catalog-cache-store.ts +218 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/marketplace-catalog-git.ts +170 -0
- package/src/services/marketplace-catalog.ts +95 -0
- package/src/services/marketplace-fetcher.ts +310 -87
- package/src/services/plugin-manager.ts +103 -92
- package/src/ui/App.tsx +19 -12
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/pluginsAdapter.ts +174 -168
- package/src/ui/adapters/settingsAdapter.ts +119 -116
- package/src/ui/adapters/skillsAdapter.ts +203 -196
- package/src/ui/components/CategoryHeader.tsx +9 -8
- package/src/ui/components/EmptyFilterState.tsx +10 -5
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +10 -6
- package/src/ui/components/ScrollableList.tsx +3 -2
- package/src/ui/components/SearchInput.tsx +2 -1
- package/src/ui/components/StyledText.tsx +5 -4
- package/src/ui/components/TabBar.tsx +4 -3
- package/src/ui/components/layout/FooterHints.tsx +37 -30
- package/src/ui/components/layout/Panel.tsx +6 -5
- package/src/ui/components/layout/ProgressBar.tsx +7 -6
- package/src/ui/components/layout/ScopeTabs.tsx +6 -5
- package/src/ui/components/layout/ScreenLayout.tsx +46 -26
- package/src/ui/components/layout/index.ts +3 -3
- package/src/ui/components/modals/ConfirmModal.tsx +12 -11
- package/src/ui/components/modals/InputModal.tsx +14 -6
- package/src/ui/components/modals/LoadingModal.tsx +6 -5
- package/src/ui/components/modals/MessageModal.tsx +9 -8
- package/src/ui/components/modals/SelectModal.tsx +11 -7
- package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
- package/src/ui/components/primitives/ActionHints.tsx +26 -26
- package/src/ui/components/primitives/DetailSection.tsx +13 -12
- package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
- package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
- package/src/ui/components/primitives/MetaText.tsx +3 -3
- package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
- package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
- package/src/ui/components/primitives/SelectableRow.tsx +22 -16
- package/src/ui/hooks/useGitignoreModal.ts +78 -74
- package/src/ui/registry.ts +11 -11
- package/src/ui/renderers/cliToolRenderers.tsx +260 -203
- package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
- package/src/ui/renderers/mcpRenderers.tsx +121 -117
- package/src/ui/renderers/pluginRenderers.tsx +566 -471
- package/src/ui/renderers/profileRenderers.tsx +346 -300
- package/src/ui/renderers/settingsRenderers.tsx +183 -176
- package/src/ui/renderers/skillRenderers.tsx +410 -326
- package/src/ui/screens/AliasScreen.tsx +1336 -1309
- package/src/ui/screens/CliToolsScreen.tsx +92 -40
- package/src/ui/screens/EnvVarsScreen.tsx +19 -13
- package/src/ui/screens/GitignoreScreen.tsx +510 -493
- package/src/ui/screens/McpRegistryScreen.tsx +28 -21
- package/src/ui/screens/McpScreen.tsx +12 -3
- package/src/ui/screens/PluginsScreen.tsx +152 -33
- package/src/ui/screens/ProfilesScreen.tsx +39 -23
- package/src/ui/screens/SkillsScreen.tsx +832 -688
- package/src/ui/state/reducer.ts +11 -2
- package/src/ui/state/types.ts +16 -1
- package/src/ui/theme-mode.ts +73 -0
- package/src/ui/theme.ts +147 -53
- package/src/utils/config-dir.ts +47 -0
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import { execSync } from "node:child_process";
|
|
4
2
|
import {
|
|
5
3
|
getConfiguredMarketplaces,
|
|
6
4
|
getEnabledPlugins,
|
|
5
|
+
getGlobalClaudeDir,
|
|
7
6
|
readSettings,
|
|
8
7
|
writeSettings,
|
|
9
8
|
getGlobalConfiguredMarketplaces,
|
|
@@ -37,13 +36,22 @@ import {
|
|
|
37
36
|
} from "../utils/string-utils.js";
|
|
38
37
|
import {
|
|
39
38
|
clearMarketplaceCache as clearFetcherCache,
|
|
39
|
+
clearPersistedCatalogs,
|
|
40
40
|
fetchMarketplacePlugins,
|
|
41
|
+
getMarketplaceFetchFailures,
|
|
42
|
+
isAuthoritative,
|
|
41
43
|
resolveMarketplacePlugins,
|
|
44
|
+
type CatalogSource,
|
|
45
|
+
type MarketplaceFetchFailure,
|
|
42
46
|
type MarketplacePlugin,
|
|
43
47
|
} from "./marketplace-fetcher.js";
|
|
44
48
|
export {
|
|
45
49
|
fetchMarketplacePlugins,
|
|
50
|
+
getMarketplaceFetchFailures,
|
|
51
|
+
isAuthoritative,
|
|
46
52
|
resolveMarketplacePlugins,
|
|
53
|
+
type CatalogSource,
|
|
54
|
+
type MarketplaceFetchFailure,
|
|
47
55
|
type MarketplacePlugin,
|
|
48
56
|
};
|
|
49
57
|
// Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
|
|
@@ -110,6 +118,29 @@ export interface PluginInfo {
|
|
|
110
118
|
* Never set on the very first run, when there is no baseline to compare to.
|
|
111
119
|
*/
|
|
112
120
|
recentlyInstalled?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Which catalog supplied `version` — i.e. the number this plugin's installed
|
|
123
|
+
* version was compared against.
|
|
124
|
+
*
|
|
125
|
+
* `remote` (HTTP) and `remote-git` (a `git fetch`ed remote ref) are both
|
|
126
|
+
* authoritative — each is upstream's own catalog file. `local-clone` is the
|
|
127
|
+
* checked-out working tree, which is deliberately pinned by
|
|
128
|
+
* `autoUpdate: false`, so its versions can be arbitrarily old.
|
|
129
|
+
*/
|
|
130
|
+
catalogSource?: CatalogSource;
|
|
131
|
+
/**
|
|
132
|
+
* Set when `hasUpdate: false` could not actually be verified — the catalog
|
|
133
|
+
* fetch failed and the comparison ran against the pinned clone.
|
|
134
|
+
*
|
|
135
|
+
* This exists because the absence of an update was indistinguishable from a
|
|
136
|
+
* successful check. A rate-limited fetch returned `[]`, the stale clone filled
|
|
137
|
+
* in, its version compared equal to the installed one, and the UI reported
|
|
138
|
+
* "up to date" — the failure rendered as a clean bill of health. Anything that
|
|
139
|
+
* tells the user they are current must check this flag first.
|
|
140
|
+
*/
|
|
141
|
+
updateCheckFailed?: boolean;
|
|
142
|
+
/** Why the check failed, for display. Set with `updateCheckFailed`. */
|
|
143
|
+
updateCheckFailure?: MarketplaceFetchFailure;
|
|
113
144
|
}
|
|
114
145
|
|
|
115
146
|
/**
|
|
@@ -241,16 +272,30 @@ export async function getAvailablePlugins(
|
|
|
241
272
|
// Fetch local marketplace caches up front so we can detect stale caches
|
|
242
273
|
let localMarketplaces = await getLocalMarketplaces();
|
|
243
274
|
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
275
|
+
// Resolve every marketplace concurrently. These are independent HTTP calls
|
|
276
|
+
// with a 10s timeout each; awaited in sequence, one unreachable marketplace
|
|
277
|
+
// added its full timeout to the "Loading..." the user stares at. Measured on
|
|
278
|
+
// six marketplaces with four unreachable: 44.9s sequential.
|
|
279
|
+
const resolutions = await Promise.all(
|
|
280
|
+
[...marketplaceNames].map(async (mpName) => {
|
|
281
|
+
const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
|
|
282
|
+
if (!marketplace) return null;
|
|
283
|
+
return {
|
|
284
|
+
mpName,
|
|
285
|
+
marketplace,
|
|
286
|
+
resolution: await resolveMarketplacePlugins(
|
|
287
|
+
mpName,
|
|
288
|
+
marketplace.source.repo,
|
|
289
|
+
localMarketplaces,
|
|
290
|
+
),
|
|
291
|
+
};
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
248
294
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
);
|
|
295
|
+
for (const entry of resolutions) {
|
|
296
|
+
if (!entry) continue;
|
|
297
|
+
const { mpName, marketplace, resolution } = entry;
|
|
298
|
+
const marketplacePlugins = resolution.plugins;
|
|
254
299
|
|
|
255
300
|
// Auto-sync local cache if remote has plugins the local cache doesn't
|
|
256
301
|
localMarketplaces = await autoSyncIfStale(
|
|
@@ -259,6 +304,13 @@ export async function getAvailablePlugins(
|
|
|
259
304
|
localMarketplaces,
|
|
260
305
|
);
|
|
261
306
|
|
|
307
|
+
// The catalog that answered was not the authoritative one, so every
|
|
308
|
+
// version compare below is a guess, not a check.
|
|
309
|
+
// `remote-git` counts as verified: it is upstream's own catalog file, read
|
|
310
|
+
// through a git remote ref rather than over HTTP. Only the pinned working
|
|
311
|
+
// tree (`local-clone`) is untrustworthy for versions.
|
|
312
|
+
const unverified = !isAuthoritative(resolution.source);
|
|
313
|
+
|
|
262
314
|
for (const plugin of marketplacePlugins) {
|
|
263
315
|
const pluginId = `${plugin.name}@${mpName}`;
|
|
264
316
|
const installedVersion = installedVersions[pluginId];
|
|
@@ -279,6 +331,9 @@ export async function getAvailablePlugins(
|
|
|
279
331
|
installedVersion && plugin.version
|
|
280
332
|
? compareVersions(plugin.version, installedVersion) > 0
|
|
281
333
|
: false,
|
|
334
|
+
catalogSource: resolution.source,
|
|
335
|
+
updateCheckFailed: unverified,
|
|
336
|
+
updateCheckFailure: resolution.failure,
|
|
282
337
|
...scopeStatus,
|
|
283
338
|
category: plugin.category,
|
|
284
339
|
author: plugin.author,
|
|
@@ -438,16 +493,30 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
438
493
|
// Fetch local marketplace caches up front so we can detect stale caches
|
|
439
494
|
let localMarketplaces = await getLocalMarketplaces();
|
|
440
495
|
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
496
|
+
// Resolve every marketplace concurrently. These are independent HTTP calls
|
|
497
|
+
// with a 10s timeout each; awaited in sequence, one unreachable marketplace
|
|
498
|
+
// added its full timeout to the "Loading..." the user stares at. Measured on
|
|
499
|
+
// six marketplaces with four unreachable: 44.9s sequential.
|
|
500
|
+
const resolutions = await Promise.all(
|
|
501
|
+
[...marketplaceNames].map(async (mpName) => {
|
|
502
|
+
const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
|
|
503
|
+
if (!marketplace) return null;
|
|
504
|
+
return {
|
|
505
|
+
mpName,
|
|
506
|
+
marketplace,
|
|
507
|
+
resolution: await resolveMarketplacePlugins(
|
|
508
|
+
mpName,
|
|
509
|
+
marketplace.source.repo,
|
|
510
|
+
localMarketplaces,
|
|
511
|
+
),
|
|
512
|
+
};
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
445
515
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
);
|
|
516
|
+
for (const entry of resolutions) {
|
|
517
|
+
if (!entry) continue;
|
|
518
|
+
const { mpName, marketplace, resolution } = entry;
|
|
519
|
+
const marketplacePlugins = resolution.plugins;
|
|
451
520
|
|
|
452
521
|
// Auto-sync local cache if remote has plugins the local cache doesn't
|
|
453
522
|
localMarketplaces = await autoSyncIfStale(
|
|
@@ -456,6 +525,13 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
456
525
|
localMarketplaces,
|
|
457
526
|
);
|
|
458
527
|
|
|
528
|
+
// The catalog that answered was not the authoritative one, so every
|
|
529
|
+
// version compare below is a guess, not a check.
|
|
530
|
+
// `remote-git` counts as verified: it is upstream's own catalog file, read
|
|
531
|
+
// through a git remote ref rather than over HTTP. Only the pinned working
|
|
532
|
+
// tree (`local-clone`) is untrustworthy for versions.
|
|
533
|
+
const unverified = !isAuthoritative(resolution.source);
|
|
534
|
+
|
|
459
535
|
for (const plugin of marketplacePlugins) {
|
|
460
536
|
const pluginId = `${plugin.name}@${mpName}`;
|
|
461
537
|
const installedVersion = installedVersions[pluginId];
|
|
@@ -476,6 +552,9 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
476
552
|
installedVersion && plugin.version
|
|
477
553
|
? compareVersions(plugin.version, installedVersion) > 0
|
|
478
554
|
: false,
|
|
555
|
+
catalogSource: resolution.source,
|
|
556
|
+
updateCheckFailed: unverified,
|
|
557
|
+
updateCheckFailure: resolution.failure,
|
|
479
558
|
...scopeStatus,
|
|
480
559
|
category: plugin.category,
|
|
481
560
|
author: plugin.author,
|
|
@@ -798,8 +877,11 @@ export async function refreshAllMarketplaces(
|
|
|
798
877
|
): Promise<RefreshAndRepairResult> {
|
|
799
878
|
onProgress?.({ current: 1, total: 1, name: "Clearing cache..." });
|
|
800
879
|
|
|
801
|
-
// Clear all caches to force fresh data
|
|
880
|
+
// Clear all caches to force fresh data. The on-disk catalog cache is awaited
|
|
881
|
+
// rather than fired and forgotten, so the refetch that follows cannot read the
|
|
882
|
+
// entries this is discarding.
|
|
802
883
|
clearMarketplaceCache();
|
|
884
|
+
await clearPersistedCatalogs();
|
|
803
885
|
|
|
804
886
|
// Auto-repair plugin.json files with missing agents/commands/skills
|
|
805
887
|
const repairResults = await repairAllMarketplaces();
|
|
@@ -810,76 +892,5 @@ export async function refreshAllMarketplaces(
|
|
|
810
892
|
};
|
|
811
893
|
}
|
|
812
894
|
|
|
813
|
-
export interface MarketplaceUpdateInfo {
|
|
814
|
-
name: string;
|
|
815
|
-
hasUpdate: boolean;
|
|
816
|
-
latestCommit?: string;
|
|
817
|
-
currentCommit?: string;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* Check if marketplaces have updates available on GitHub
|
|
822
|
-
* Compares local git HEAD with remote HEAD
|
|
823
|
-
*/
|
|
824
|
-
export async function checkMarketplaceUpdates(): Promise<
|
|
825
|
-
MarketplaceUpdateInfo[]
|
|
826
|
-
> {
|
|
827
|
-
const results: MarketplaceUpdateInfo[] = [];
|
|
828
|
-
const localMarketplaces = await getLocalMarketplaces();
|
|
829
|
-
|
|
830
|
-
for (const [name, marketplace] of localMarketplaces) {
|
|
831
|
-
if (!marketplace.gitRepo) continue;
|
|
832
|
-
|
|
833
|
-
try {
|
|
834
|
-
const marketplacePath = path.join(
|
|
835
|
-
os.homedir(),
|
|
836
|
-
".claude",
|
|
837
|
-
"plugins",
|
|
838
|
-
"marketplaces",
|
|
839
|
-
name,
|
|
840
|
-
);
|
|
841
|
-
|
|
842
|
-
// Get current HEAD from local repo
|
|
843
|
-
let currentHead: string;
|
|
844
|
-
try {
|
|
845
|
-
currentHead = execSync("git rev-parse HEAD", {
|
|
846
|
-
cwd: marketplacePath,
|
|
847
|
-
encoding: "utf-8",
|
|
848
|
-
timeout: 5000,
|
|
849
|
-
}).trim();
|
|
850
|
-
} catch {
|
|
851
|
-
continue; // Skip if can't get HEAD
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
// Fetch latest commit from GitHub API
|
|
855
|
-
const apiUrl = `https://api.github.com/repos/${marketplace.gitRepo}/commits/main`;
|
|
856
|
-
const response = await fetch(apiUrl, {
|
|
857
|
-
signal: AbortSignal.timeout(10000),
|
|
858
|
-
headers: {
|
|
859
|
-
Accept: "application/vnd.github.v3+json",
|
|
860
|
-
},
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
if (response.ok) {
|
|
864
|
-
const data = (await response.json()) as { sha: string };
|
|
865
|
-
const latestCommit = data.sha;
|
|
866
|
-
|
|
867
|
-
results.push({
|
|
868
|
-
name,
|
|
869
|
-
hasUpdate: currentHead !== latestCommit,
|
|
870
|
-
currentCommit: currentHead.substring(0, 7),
|
|
871
|
-
latestCommit: latestCommit.substring(0, 7),
|
|
872
|
-
});
|
|
873
|
-
} else {
|
|
874
|
-
results.push({ name, hasUpdate: false });
|
|
875
|
-
}
|
|
876
|
-
} catch {
|
|
877
|
-
results.push({ name, hasUpdate: false });
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
return results;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
895
|
// Re-export types for consumers
|
|
885
896
|
export type { ProgressCallback };
|
package/src/ui/App.tsx
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from "../services/version-check.js";
|
|
50
50
|
import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
|
|
51
51
|
import { ProgressBar } from "./components/layout/ProgressBar.js";
|
|
52
|
+
import { theme } from "./theme.js";
|
|
52
53
|
|
|
53
54
|
export const VERSION = getCurrentVersion();
|
|
54
55
|
|
|
@@ -238,15 +239,15 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
|
|
|
238
239
|
|
|
239
240
|
return (
|
|
240
241
|
<box paddingLeft={1} paddingRight={1}>
|
|
241
|
-
<text bg=
|
|
242
|
+
<text bg={theme.colors.warning} fg={theme.hints.fg}>
|
|
242
243
|
<strong> UPDATE </strong>
|
|
243
244
|
</text>
|
|
244
|
-
<text fg=
|
|
245
|
+
<text fg={theme.colors.warning}>
|
|
245
246
|
{" "}
|
|
246
247
|
v{result.currentVersion} → v{result.latestVersion}
|
|
247
248
|
</text>
|
|
248
|
-
<text fg=
|
|
249
|
-
<text fg=
|
|
249
|
+
<text fg={theme.colors.muted}> Run: </text>
|
|
250
|
+
<text fg={theme.colors.info}>claudeup update</text>
|
|
250
251
|
</box>
|
|
251
252
|
);
|
|
252
253
|
}
|
|
@@ -299,12 +300,12 @@ function AppContentInner({
|
|
|
299
300
|
{updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
|
|
300
301
|
{recoveryReport && (
|
|
301
302
|
<box paddingLeft={1} paddingRight={1}>
|
|
302
|
-
<text fg=
|
|
303
|
+
<text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
|
|
303
304
|
</box>
|
|
304
305
|
)}
|
|
305
306
|
{showDebug && (
|
|
306
307
|
<box paddingLeft={1} paddingRight={1}>
|
|
307
|
-
<text fg=
|
|
308
|
+
<text fg={theme.colors.muted}>
|
|
308
309
|
DEBUG: {dimensions.terminalWidth}x{dimensions.terminalHeight} |
|
|
309
310
|
content={dimensions.contentHeight} | screen=
|
|
310
311
|
{state.currentRoute.screen}
|
|
@@ -340,7 +341,9 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
340
341
|
const [showDebug, setShowDebug] = useState(false);
|
|
341
342
|
const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
|
|
342
343
|
const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
|
|
343
|
-
const [mismatchData, setMismatchData] = useState<
|
|
344
|
+
const [mismatchData, setMismatchData] = useState<
|
|
345
|
+
VersionMismatchInfo[] | null
|
|
346
|
+
>(null);
|
|
344
347
|
const mismatchModal = useMismatchModal();
|
|
345
348
|
const [gitignoreData, setGitignoreData] = useState<{
|
|
346
349
|
violationCount: number;
|
|
@@ -472,8 +475,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
472
475
|
const result = await checkGitignore(process.cwd());
|
|
473
476
|
if (result.violationCount > 0) {
|
|
474
477
|
const s = await loadGitignoreState(process.cwd());
|
|
475
|
-
const safeCount = s.violations.filter(
|
|
476
|
-
|
|
478
|
+
const safeCount = s.violations.filter(
|
|
479
|
+
(v) => v.severity === "safe",
|
|
480
|
+
).length;
|
|
481
|
+
setGitignoreData({
|
|
482
|
+
violationCount: result.violationCount,
|
|
483
|
+
safeCount,
|
|
484
|
+
});
|
|
477
485
|
}
|
|
478
486
|
} catch {
|
|
479
487
|
// non-fatal
|
|
@@ -491,8 +499,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
491
499
|
}, [dispatch]);
|
|
492
500
|
|
|
493
501
|
// Count transient banners for dimension calculation
|
|
494
|
-
const transientBannerCount =
|
|
495
|
-
recoveryReport ? 1 : 0;
|
|
502
|
+
const transientBannerCount = recoveryReport ? 1 : 0;
|
|
496
503
|
|
|
497
504
|
return (
|
|
498
505
|
<DimensionsProvider
|
|
@@ -507,7 +514,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
507
514
|
updateInfo={updateInfo}
|
|
508
515
|
onExit={onExit}
|
|
509
516
|
recoveryReport={recoveryReport}
|
|
510
|
-
|
|
517
|
+
/>
|
|
511
518
|
</DimensionsProvider>
|
|
512
519
|
);
|
|
513
520
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalogNotice.ts — the text of the Plugins screen's warning line.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from PluginsScreen for the same reason as pluginsAdapter: so it can
|
|
5
|
+
* be tested without importing a React/OpenTUI screen. That is not merely tidier —
|
|
6
|
+
* importing the screen into a test pulls the renderer into the shared test process
|
|
7
|
+
* and broke fifteen unrelated tests.
|
|
8
|
+
*
|
|
9
|
+
* What this does NOT warn about, deliberately
|
|
10
|
+
* -------------------------------------------
|
|
11
|
+
* `autoUpdate: false` and a marketplace clone sitting behind its remote are both
|
|
12
|
+
* NORMAL here. Plugins are not auto-updated by policy; the UI's job is to show
|
|
13
|
+
* that an update exists, not to advance the install source. An earlier version of
|
|
14
|
+
* this banner reported both as problems and suggested
|
|
15
|
+
* `claude plugin marketplace update`, which would have defeated the policy it was
|
|
16
|
+
* complaining about. A warning that fires on intended behaviour trains the user to
|
|
17
|
+
* ignore the line.
|
|
18
|
+
*
|
|
19
|
+
* The one thing worth warning about is an update check that did not happen —
|
|
20
|
+
* because the alternative is claiming a plugin is current when nobody looked.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { formatWait } from "../../services/github-budget.js";
|
|
24
|
+
import type { MarketplaceFetchFailure } from "../../services/marketplace-fetcher.js";
|
|
25
|
+
|
|
26
|
+
export interface CatalogNoticeInput {
|
|
27
|
+
/** Catalogs that could not be read from any authoritative source. */
|
|
28
|
+
failures: MarketplaceFetchFailure[];
|
|
29
|
+
/** Installed plugins whose version could not be verified against upstream. */
|
|
30
|
+
unverifiedPlugins: number;
|
|
31
|
+
/** Terminal columns available for the line. */
|
|
32
|
+
width: number;
|
|
33
|
+
/** For the countdown; injected so tests are not clock-dependent. */
|
|
34
|
+
now?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One line naming why some versions on screen could not be verified — or `null`
|
|
39
|
+
* when everything was actually checked.
|
|
40
|
+
*
|
|
41
|
+
* Returns `null` when there is nothing wrong: a banner that is always present is
|
|
42
|
+
* a banner nobody reads.
|
|
43
|
+
*
|
|
44
|
+
* Segments are added in priority order and dropped from the end when they do not
|
|
45
|
+
* fit `width`. Listing every failing marketplace overflowed an 80-column pane and
|
|
46
|
+
* took the retry hint with it — a warning truncated mid-sentence is barely better
|
|
47
|
+
* than no warning.
|
|
48
|
+
*/
|
|
49
|
+
export function buildCatalogNoticeText({
|
|
50
|
+
failures,
|
|
51
|
+
unverifiedPlugins,
|
|
52
|
+
width,
|
|
53
|
+
now = Date.now(),
|
|
54
|
+
}: CatalogNoticeInput): string | null {
|
|
55
|
+
if (failures.length === 0) return null;
|
|
56
|
+
|
|
57
|
+
const segments: string[] = [];
|
|
58
|
+
|
|
59
|
+
// Lead with the reason, not the names: "rate limit" tells the user to wait,
|
|
60
|
+
// "timed out" tells them to check the network. That distinction is the whole
|
|
61
|
+
// value of classifying the failure, and it costs few characters.
|
|
62
|
+
const kinds = new Set(failures.map((f) => f.kind));
|
|
63
|
+
const rateLimited = kinds.has("rate-limited");
|
|
64
|
+
const reason = rateLimited
|
|
65
|
+
? "GitHub rate limit"
|
|
66
|
+
: kinds.has("timeout")
|
|
67
|
+
? "GitHub timed out"
|
|
68
|
+
: (failures[0]?.detail ?? "fetch failed");
|
|
69
|
+
|
|
70
|
+
const noun = failures.length === 1 ? "catalog" : "catalogs";
|
|
71
|
+
// Naming them only pays off when there are one or two; past that the count
|
|
72
|
+
// carries the same information in a fraction of the width.
|
|
73
|
+
const named =
|
|
74
|
+
failures.length <= 2
|
|
75
|
+
? `: ${failures.map((f) => f.marketplace).join(", ")}`
|
|
76
|
+
: "";
|
|
77
|
+
segments.push(`${failures.length} ${noun} unchecked (${reason})${named}`);
|
|
78
|
+
|
|
79
|
+
if (unverifiedPlugins > 0) {
|
|
80
|
+
segments.push(
|
|
81
|
+
`${unverifiedPlugins} version${unverifiedPlugins === 1 ? "" : "s"} unverified`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The countdown replaces the bare retry hint when we know when the window
|
|
86
|
+
// reopens. Answering "when can I try again" is the point: "try later" leaves
|
|
87
|
+
// the user to guess, and guessing wrong spends another request against the
|
|
88
|
+
// limit. Soonest retry across all failures, since that is when anything at all
|
|
89
|
+
// becomes possible again.
|
|
90
|
+
const retryAt = failures
|
|
91
|
+
.map((f) => f.retryAt)
|
|
92
|
+
.filter((t): t is number => typeof t === "number" && t > now)
|
|
93
|
+
.sort((a, b) => a - b)[0];
|
|
94
|
+
|
|
95
|
+
const hint = retryAt
|
|
96
|
+
? ` · retrying in ${formatWait(retryAt - now)}`
|
|
97
|
+
: " · r to retry";
|
|
98
|
+
|
|
99
|
+
// Column budget. `⚠` renders double-width in most terminals while
|
|
100
|
+
// `String.length` counts it as one, so one column is held back for it.
|
|
101
|
+
const maxColumns = Math.max(1, width - 1);
|
|
102
|
+
|
|
103
|
+
// The retry affordance is reserved out of the budget rather than appended: it
|
|
104
|
+
// is the only action the line offers, so it is the last thing that may be cut.
|
|
105
|
+
// No floor on what remains — a floor is what let a 40-column pane overflow by
|
|
106
|
+
// two, which is the whole failure mode this budget exists to prevent.
|
|
107
|
+
const messageBudget = maxColumns - hint.length;
|
|
108
|
+
|
|
109
|
+
let line = `⚠ ${segments[0]}`;
|
|
110
|
+
for (const segment of segments.slice(1)) {
|
|
111
|
+
const candidate = `${line} · ${segment}`;
|
|
112
|
+
if (candidate.length > messageBudget) break;
|
|
113
|
+
line = candidate;
|
|
114
|
+
}
|
|
115
|
+
if (line.length > messageBudget) {
|
|
116
|
+
line = messageBudget > 1 ? `${line.slice(0, messageBudget - 1)}…` : "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Absurdly narrow pane: the hint alone does not fit. Truncate it rather than
|
|
120
|
+
// return a line that overflows and corrupts the layout.
|
|
121
|
+
return `${line}${hint}`.slice(0, maxColumns);
|
|
122
|
+
}
|