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.
- package/package.json +4 -4
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +722 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +521 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +312 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -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/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/layout/ScreenLayout.tsx +19 -2
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/pluginRenderers.tsx +39 -1
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/PluginsScreen.tsx +138 -29
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +124 -3
- package/src/ui/state/types.ts +76 -3
- package/src/utils/config-dir.ts +47 -0
- package/src/utils/open-file.ts +84 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fill the `terminology` template preset from the project's own codebase.
|
|
3
|
+
*
|
|
4
|
+
* `terminology` ships as a template: a vocabulary table with a placeholder row,
|
|
5
|
+
* useless until someone fills it with THIS project's words. Doing that by hand
|
|
6
|
+
* means reading the codebase and noticing where two names describe one concept
|
|
7
|
+
* — exactly the sort of survey a coding agent is good at.
|
|
8
|
+
*
|
|
9
|
+
* ## Why Claude only returns rows
|
|
10
|
+
*
|
|
11
|
+
* The subprocess is asked for table rows and nothing else; claudeup does the
|
|
12
|
+
* file writing. Two reasons, both practical:
|
|
13
|
+
*
|
|
14
|
+
* 1. **No write permission is needed.** `claude -p` runs with read-only tools,
|
|
15
|
+
* so the worst case of a bad run is a useless answer, never a modified
|
|
16
|
+
* repository.
|
|
17
|
+
* 2. **The template's fixed rules cannot be lost.** Everything below the table
|
|
18
|
+
* is substantive guidance that must survive verbatim. Asking a model to
|
|
19
|
+
* reproduce a whole file invites it to paraphrase; asking for six table
|
|
20
|
+
* rows does not.
|
|
21
|
+
*
|
|
22
|
+
* The TUI stays on screen throughout: the child's stdio is piped, never
|
|
23
|
+
* inherited, so it cannot take the terminal away from OpenTUI the way
|
|
24
|
+
* `runClaude` deliberately does for CLI use.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import fs from "fs-extra";
|
|
30
|
+
import { which } from "../utils/command-utils.js";
|
|
31
|
+
|
|
32
|
+
export interface TerminologyRow {
|
|
33
|
+
use: string;
|
|
34
|
+
not: string;
|
|
35
|
+
because: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The placeholder row the shipped template carries. */
|
|
39
|
+
const PLACEHOLDER = /^\|.*filled during.*\|.*$/im;
|
|
40
|
+
|
|
41
|
+
/** Table rows are asked for in this exact shape, so the parse can be strict. */
|
|
42
|
+
export function parseTerminologyRows(stdout: string): TerminologyRow[] {
|
|
43
|
+
const rows: TerminologyRow[] = [];
|
|
44
|
+
for (const raw of stdout.split("\n")) {
|
|
45
|
+
const line = raw.trim();
|
|
46
|
+
if (!line.startsWith("|")) continue;
|
|
47
|
+
|
|
48
|
+
// The trailing pipe is optional. Both are valid markdown and a model
|
|
49
|
+
// asked for `| a | b | c |` will sometimes emit it without the closer —
|
|
50
|
+
// discarding that row would silently lose a real term.
|
|
51
|
+
const cells = line
|
|
52
|
+
.slice(1)
|
|
53
|
+
.replace(/\|$/, "")
|
|
54
|
+
.split("|")
|
|
55
|
+
.map((cell) => cell.trim());
|
|
56
|
+
if (cells.length !== 3) continue;
|
|
57
|
+
|
|
58
|
+
// A model that ignores "no header row" tends to emit the header and the
|
|
59
|
+
// `|---|---|---|` separator anyway. Both are noise, not data.
|
|
60
|
+
if (cells.every((cell) => /^:?-{2,}:?$/.test(cell))) continue;
|
|
61
|
+
if (cells[0].toLowerCase() === "use" && cells[1].toLowerCase() === "not") {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!cells[0] || !cells[1]) continue;
|
|
65
|
+
|
|
66
|
+
rows.push({ use: cells[0], not: cells[1], because: cells[2] });
|
|
67
|
+
}
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Put the rows into the template, replacing its placeholder.
|
|
73
|
+
*
|
|
74
|
+
* Everything else in the body — the heading, the opening statement, and the
|
|
75
|
+
* rules below the table — is copied through untouched.
|
|
76
|
+
*/
|
|
77
|
+
export function fillTemplate(
|
|
78
|
+
templateBody: string,
|
|
79
|
+
rows: TerminologyRow[],
|
|
80
|
+
): string {
|
|
81
|
+
if (rows.length === 0) {
|
|
82
|
+
throw new Error("No usable table rows were produced.");
|
|
83
|
+
}
|
|
84
|
+
const rendered = rows
|
|
85
|
+
.map((row) => `| ${row.use} | ${row.not} | ${row.because} |`)
|
|
86
|
+
.join("\n");
|
|
87
|
+
|
|
88
|
+
if (PLACEHOLDER.test(templateBody)) {
|
|
89
|
+
return templateBody.replace(PLACEHOLDER, rendered);
|
|
90
|
+
}
|
|
91
|
+
// The template changed shape upstream. Appending after the separator is
|
|
92
|
+
// still better than silently returning a table with no rows.
|
|
93
|
+
return templateBody.replace(/^(\|\s*-{2,}.*\|)\s*$/im, `$1\n${rendered}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The complete output-style file for the filled terminology preset. */
|
|
97
|
+
export function terminologyStyleFile(body: string): string {
|
|
98
|
+
return [
|
|
99
|
+
"---",
|
|
100
|
+
"name: terminology",
|
|
101
|
+
'description: "Project vocabulary — one name per concept"',
|
|
102
|
+
// Same reasoning as everywhere else: a communication style must not
|
|
103
|
+
// switch off Claude Code's coding rules.
|
|
104
|
+
"keep-coding-instructions: true",
|
|
105
|
+
'generated-by: "claudeup Styles tab, filled from the codebase by Claude Code."',
|
|
106
|
+
"---",
|
|
107
|
+
"",
|
|
108
|
+
body.trim(),
|
|
109
|
+
"",
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildPrompt(templateBody: string): string {
|
|
114
|
+
return [
|
|
115
|
+
"You are filling in one table for a project's communication-style guide.",
|
|
116
|
+
"",
|
|
117
|
+
"Read enough of this codebase to identify its domain vocabulary: the nouns",
|
|
118
|
+
"that name its core concepts, and the places where two different words are",
|
|
119
|
+
"used for the same thing.",
|
|
120
|
+
"",
|
|
121
|
+
"Output ONLY markdown table rows, one per line, in exactly this form:",
|
|
122
|
+
"",
|
|
123
|
+
"| use | not | because |",
|
|
124
|
+
"",
|
|
125
|
+
"Rules:",
|
|
126
|
+
"- No header row, no `|---|` separator, no code fence, no commentary.",
|
|
127
|
+
"- Between 5 and 15 rows.",
|
|
128
|
+
// Bounded on purpose: without a budget this wanders a large repository
|
|
129
|
+
// for many minutes, and the marginal term found on the 80th file is not
|
|
130
|
+
// worth the wait.
|
|
131
|
+
"- Budget yourself: read at most ~25 files. Prefer README, the main",
|
|
132
|
+
" entry points, type definitions and directory names over exhaustive",
|
|
133
|
+
" coverage — the vocabulary shows up there first.",
|
|
134
|
+
"- Every row must come from THIS codebase. Do not invent generic examples.",
|
|
135
|
+
"- `use` is the name the project should standardise on.",
|
|
136
|
+
"- `not` is a synonym actually present in the repo, or one a newcomer would",
|
|
137
|
+
" plausibly reach for.",
|
|
138
|
+
"- `because` is one clause, lower case, no trailing full stop.",
|
|
139
|
+
"",
|
|
140
|
+
"For context, this is the guide the table goes into:",
|
|
141
|
+
"",
|
|
142
|
+
templateBody,
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* How long to let the survey run before giving up.
|
|
148
|
+
*
|
|
149
|
+
* Generous, because reading a large repository legitimately takes minutes — but
|
|
150
|
+
* bounded, because without it a wedged subprocess leaves the screen showing
|
|
151
|
+
* "reading the codebase" forever with no way back.
|
|
152
|
+
*/
|
|
153
|
+
export const FILL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
154
|
+
|
|
155
|
+
export interface FillTerminologyArgs {
|
|
156
|
+
projectPath: string;
|
|
157
|
+
/** Body of the shipped `terminology` preset, table placeholder included. */
|
|
158
|
+
templateBody: string;
|
|
159
|
+
/** Injectable for tests. Returns the subprocess's stdout. */
|
|
160
|
+
run?: (prompt: string, projectPath: string) => Promise<string>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface FillTerminologyResult {
|
|
164
|
+
path: string;
|
|
165
|
+
rows: TerminologyRow[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Run Claude Code over the project and write the filled preset.
|
|
170
|
+
*
|
|
171
|
+
* Written to the PROJECT's output-styles directory, so the vocabulary it
|
|
172
|
+
* discovers commits with the repository — a project's terminology is a team
|
|
173
|
+
* fact, not one person's setting.
|
|
174
|
+
*/
|
|
175
|
+
export async function fillTerminology({
|
|
176
|
+
projectPath,
|
|
177
|
+
templateBody,
|
|
178
|
+
run = runClaudeHeadless,
|
|
179
|
+
}: FillTerminologyArgs): Promise<FillTerminologyResult> {
|
|
180
|
+
const stdout = await run(buildPrompt(templateBody), projectPath);
|
|
181
|
+
const rows = parseTerminologyRows(stdout);
|
|
182
|
+
if (rows.length === 0) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
"Claude Code returned no usable table rows — nothing was written.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const file = path.join(
|
|
189
|
+
projectPath,
|
|
190
|
+
".claude",
|
|
191
|
+
"output-styles",
|
|
192
|
+
"terminology.md",
|
|
193
|
+
);
|
|
194
|
+
await fs.ensureDir(path.dirname(file));
|
|
195
|
+
await fs.writeFile(
|
|
196
|
+
file,
|
|
197
|
+
terminologyStyleFile(fillTemplate(templateBody, rows)),
|
|
198
|
+
"utf8",
|
|
199
|
+
);
|
|
200
|
+
return { path: file, rows };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `claude -p` with read-only tools, stdio piped.
|
|
205
|
+
*
|
|
206
|
+
* `--allowedTools` is the point: the subprocess can look at the repository and
|
|
207
|
+
* cannot change it, so a bad run costs time and nothing else.
|
|
208
|
+
*/
|
|
209
|
+
async function runClaudeHeadless(
|
|
210
|
+
prompt: string,
|
|
211
|
+
projectPath: string,
|
|
212
|
+
): Promise<string> {
|
|
213
|
+
const claudePath = await which("claude");
|
|
214
|
+
if (!claudePath) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
"claude CLI not found in PATH. Install it with: npm install -g @anthropic-ai/claude-code",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const child = spawn(
|
|
222
|
+
claudePath,
|
|
223
|
+
["-p", prompt, "--allowedTools", "Read,Grep,Glob"],
|
|
224
|
+
{
|
|
225
|
+
cwd: projectPath,
|
|
226
|
+
// Piped, never inherited — inheriting would hand our TTY to the
|
|
227
|
+
// child and tear the TUI apart mid-render.
|
|
228
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
let out = "";
|
|
233
|
+
let err = "";
|
|
234
|
+
child.stdout.on("data", (chunk) => {
|
|
235
|
+
out += String(chunk);
|
|
236
|
+
});
|
|
237
|
+
child.stderr.on("data", (chunk) => {
|
|
238
|
+
err += String(chunk);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const timer = setTimeout(() => {
|
|
242
|
+
child.kill("SIGTERM");
|
|
243
|
+
reject(
|
|
244
|
+
new Error(
|
|
245
|
+
`Gave up after ${Math.round(FILL_TIMEOUT_MS / 60000)} minutes — nothing was written. Try again, or fill the table by hand.`,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
}, FILL_TIMEOUT_MS);
|
|
249
|
+
|
|
250
|
+
child.once("error", (error) => {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
reject(error);
|
|
253
|
+
});
|
|
254
|
+
child.once("close", (code) => {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
if (code === 0) resolve(out);
|
|
257
|
+
else {
|
|
258
|
+
reject(
|
|
259
|
+
new Error(
|
|
260
|
+
`claude exited ${code}${err.trim() ? `: ${err.trim().split("\n")[0]}` : ""}`,
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
CliToolsScreen,
|
|
21
21
|
ProfilesScreen,
|
|
22
22
|
SkillsScreen,
|
|
23
|
+
StylesScreen,
|
|
23
24
|
GitignoreScreen,
|
|
24
25
|
AliasScreen,
|
|
25
26
|
} from "./screens/index.js";
|
|
@@ -76,6 +77,8 @@ function Router() {
|
|
|
76
77
|
return <ProfilesScreen />;
|
|
77
78
|
case "skills":
|
|
78
79
|
return <SkillsScreen />;
|
|
80
|
+
case "styles":
|
|
81
|
+
return <StylesScreen />;
|
|
79
82
|
case "gitignore":
|
|
80
83
|
return <GitignoreScreen />;
|
|
81
84
|
case "alias":
|
|
@@ -130,7 +133,7 @@ function GlobalKeyHandler({
|
|
|
130
133
|
// Don't handle keys when modal is open or searching
|
|
131
134
|
if (state.modal || state.isSearching) return;
|
|
132
135
|
|
|
133
|
-
// Global navigation shortcuts (1-
|
|
136
|
+
// Global navigation shortcuts (1-9) - include mcp-registry as it's a sub-screen of mcp
|
|
134
137
|
const isTopLevel = [
|
|
135
138
|
"plugins",
|
|
136
139
|
"mcp",
|
|
@@ -139,6 +142,7 @@ function GlobalKeyHandler({
|
|
|
139
142
|
"cli-tools",
|
|
140
143
|
"profiles",
|
|
141
144
|
"skills",
|
|
145
|
+
"styles",
|
|
142
146
|
"gitignore",
|
|
143
147
|
"alias",
|
|
144
148
|
].includes(state.currentRoute.screen);
|
|
@@ -152,6 +156,7 @@ function GlobalKeyHandler({
|
|
|
152
156
|
else if (input === "6") navigateToScreen("cli-tools");
|
|
153
157
|
else if (input === "7") navigateToScreen("gitignore");
|
|
154
158
|
else if (input === "8") navigateToScreen("alias");
|
|
159
|
+
else if (input === "9") navigateToScreen("styles");
|
|
155
160
|
|
|
156
161
|
// Tab navigation cycling
|
|
157
162
|
if (key.tab) {
|
|
@@ -164,6 +169,7 @@ function GlobalKeyHandler({
|
|
|
164
169
|
"cli-tools",
|
|
165
170
|
"gitignore",
|
|
166
171
|
"alias",
|
|
172
|
+
"styles",
|
|
167
173
|
];
|
|
168
174
|
const currentIndex = screens.indexOf(
|
|
169
175
|
state.currentRoute.screen as Screen,
|
|
@@ -213,7 +219,7 @@ function GlobalKeyHandler({
|
|
|
213
219
|
Quick Navigation
|
|
214
220
|
1 Plugins 4 Settings 7 Git State
|
|
215
221
|
2 Skills 5 Profiles 8 Alias
|
|
216
|
-
3 MCP Servers 6 CLI Tools
|
|
222
|
+
3 MCP Servers 6 CLI Tools 9 Styles
|
|
217
223
|
|
|
218
224
|
Plugin Actions
|
|
219
225
|
u Update d Uninstall
|
|
@@ -221,7 +227,13 @@ Plugin Actions
|
|
|
221
227
|
|
|
222
228
|
MCP Servers
|
|
223
229
|
/ Search local + remote
|
|
224
|
-
r Browse MCP registry
|
|
230
|
+
r Browse MCP registry
|
|
231
|
+
|
|
232
|
+
Styles
|
|
233
|
+
Space Tick / untick a style
|
|
234
|
+
a Apply the selection to this project
|
|
235
|
+
x Reset the selection to what is live
|
|
236
|
+
c Clear the active output style`,
|
|
225
237
|
"info",
|
|
226
238
|
);
|
|
227
239
|
}
|
|
@@ -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
|
+
}
|