@proofofwork-agency/toolpin 0.2.3

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 (61) hide show
  1. package/CONTRIBUTING.md +117 -0
  2. package/LICENSE +183 -0
  3. package/README.md +323 -0
  4. package/SECURITY.md +61 -0
  5. package/action.yml +134 -0
  6. package/dist/canonicalJson.js +38 -0
  7. package/dist/capabilities.js +139 -0
  8. package/dist/ci.js +26 -0
  9. package/dist/cli.js +1843 -0
  10. package/dist/clientSupport.js +76 -0
  11. package/dist/codexToml.js +213 -0
  12. package/dist/config.js +337 -0
  13. package/dist/constants.js +3 -0
  14. package/dist/continueYaml.js +76 -0
  15. package/dist/doctor.js +163 -0
  16. package/dist/install.js +191 -0
  17. package/dist/installed.js +405 -0
  18. package/dist/integrity.js +14 -0
  19. package/dist/inventory.js +169 -0
  20. package/dist/packageIntegrity.js +153 -0
  21. package/dist/plan.js +595 -0
  22. package/dist/policy.js +310 -0
  23. package/dist/registry.js +1610 -0
  24. package/dist/runtimeAdvisory.js +80 -0
  25. package/dist/safeFetch.js +157 -0
  26. package/dist/sarif.js +162 -0
  27. package/dist/scan.js +113 -0
  28. package/dist/search.js +44 -0
  29. package/dist/secrets.js +165 -0
  30. package/dist/signing.js +146 -0
  31. package/dist/tester.js +240 -0
  32. package/dist/trust.js +528 -0
  33. package/dist/tui/app.js +1731 -0
  34. package/dist/tui/command.js +50 -0
  35. package/dist/tui/configSnippet.js +11 -0
  36. package/dist/tui/constants.js +37 -0
  37. package/dist/tui/format.js +31 -0
  38. package/dist/tui/installedState.js +23 -0
  39. package/dist/tui/layout.js +65 -0
  40. package/dist/tui/selectors.js +282 -0
  41. package/dist/tui/types.js +1 -0
  42. package/dist/tui/ui/trust.js +77 -0
  43. package/dist/tui/views/installed.js +82 -0
  44. package/dist/tui/views/panels.js +637 -0
  45. package/dist/tui.js +12 -0
  46. package/dist/types.js +1 -0
  47. package/dist/verificationTrust.js +103 -0
  48. package/dist/verify.js +537 -0
  49. package/dist/version.js +1 -0
  50. package/dist/versions.js +127 -0
  51. package/docs/assets/readme/terminal-demo.svg +174 -0
  52. package/docs/assets/readme/tui-browse-overview.jpg +0 -0
  53. package/docs/assets/readme/tui-config-preview.jpg +0 -0
  54. package/docs/assets/readme/tui-help.jpg +0 -0
  55. package/docs/assets/readme/tui-installed-inventory.jpg +0 -0
  56. package/docs/how-to/catch-drift-in-ci.md +189 -0
  57. package/docs/how-to/custom-registries.md +156 -0
  58. package/docs/how-to/toolpin-curated-registry.md +153 -0
  59. package/package.json +76 -0
  60. package/registry/README.md +92 -0
  61. package/registry/v0/servers +115 -0
@@ -0,0 +1,1731 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useReducer, useRef, useState } from "react";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
6
+ import { exportClientConfig } from "../config.js";
7
+ import { doctorLockfile } from "../doctor.js";
8
+ import { installServerConfig, removeServerConfig } from "../install.js";
9
+ import { adoptInstalledServer, testInstalledServer, updateAllInstalledServers, updateInstalledServer } from "../installed.js";
10
+ import { buildInstallPlan, lockKey, readLockfile, removeLockfileEntry, verifyAgainstLockfile, writeLockfile } from "../plan.js";
11
+ import { enforcePolicy } from "../policy.js";
12
+ import { compareRegistrySources, dedupeRegistryEntries, fetchRegistryResult, latestOnly, listRegistrySourceStatuses, normalizeEntries, readCache, REGISTRY_SOURCES, refreshCache as refreshRegistryCache, updateRegistrySourceEnabled } from "../registry.js";
13
+ import { localHttpRuntimeAdvisory } from "../runtimeAdvisory.js";
14
+ import { testServer } from "../tester.js";
15
+ import { commandLineFor, commandRequiresServer } from "./command.js";
16
+ import { DEFAULT_RESULT_LIMIT, ERR, SERVER_VIEWS, TUI_COMMANDS, } from "./constants.js";
17
+ import { formatClientConfigSnippet } from "./configSnippet.js";
18
+ import { clamp, safeFileName, truncate, unique } from "./format.js";
19
+ import { buildTuiHitZones, hitTestTui } from "./layout.js";
20
+ import { buildTuiVersionInfo, browseSearchResults, browseSortLabel, cacheCoverage, commandLogForView, directInstallClientsForServerScope, filterByEnabledSources, installClientChoicesForServerScope, installClientLabel, initialInstallVersionIndex, nextClientForServerScope, nextBrowseSortMode, nextResultLimit, nextSource, persistentRefreshOptions, pruneVersionSelections, scopeLabel, selectedClientsForScope, selectedInstallClientsForServerScope, selectedServerVersion, switchView, } from "./selectors.js";
21
+ import { knownVersions } from "../versions.js";
22
+ import { installedId, installedViewReducer, loadInstalledServerStates } from "./installedState.js";
23
+ import { ActivityStrip, Centered, ChromeHeader, CommandPalette, DeleteConfirmModal, Footer, HelpView, InstallWizard, ModeLine, OperationModal, OptionList, PromptBar, SelectedServerPanel, SourcesView, } from "./views/panels.js";
24
+ import { InstalledServerDetails, InstalledServersView } from "./views/installed.js";
25
+ import { trustRiskTone } from "./ui/trust.js";
26
+ export function MpmTui() {
27
+ const { exit } = useApp();
28
+ const { stdout } = useStdout();
29
+ const { width, height } = useTerminalSize(stdout);
30
+ const [state, setState] = useState(() => ({
31
+ entries: [],
32
+ registrySources: REGISTRY_SOURCES,
33
+ servers: [],
34
+ query: "",
35
+ commandQuery: "",
36
+ commandSelected: 0,
37
+ sourceSelected: 0,
38
+ selected: 0,
39
+ versionSelections: {},
40
+ installedVersionSelections: {},
41
+ view: "discover",
42
+ inputMode: "normal",
43
+ dataMode: "cache",
44
+ sourceMode: "all",
45
+ browseLayout: "flat",
46
+ browseVersionMode: "latest",
47
+ browseSortMode: "source-first",
48
+ resultLimit: DEFAULT_RESULT_LIMIT,
49
+ client: "claude",
50
+ installScope: "project",
51
+ loading: true,
52
+ installing: false,
53
+ testing: false,
54
+ checking: false,
55
+ }));
56
+ const [installed, dispatchInstalled] = useReducer(installedViewReducer, {
57
+ rows: [],
58
+ selected: 0,
59
+ scope: "all",
60
+ loading: true,
61
+ });
62
+ const [installedTests, setInstalledTests] = useState({});
63
+ const [installedRuntimeAdvisories, setInstalledRuntimeAdvisories] = useState({});
64
+ const pendingInstalledRuntimeAdvisoryIds = useRef(new Set());
65
+ useEffect(() => {
66
+ void loadData("cache");
67
+ }, []);
68
+ useEffect(() => {
69
+ if (!process.stdout.isTTY)
70
+ return;
71
+ process.stdout.write("\x1b[?1000h\x1b[?1006h");
72
+ return () => {
73
+ process.stdout.write("\x1b[?1000l\x1b[?1006l");
74
+ };
75
+ }, []);
76
+ const allResults = useMemo(() => browseSearchResults(state.servers, state.query, state.browseVersionMode, state.browseSortMode), [state.servers, state.query, state.browseVersionMode, state.browseSortMode]);
77
+ const results = useMemo(() => allResults.slice(0, state.resultLimit), [allResults, state.resultLimit]);
78
+ const browseLayout = state.browseLayout === "category" && !hasCategoryMetadata(results) ? "project" : state.browseLayout;
79
+ const selectedIndex = clamp(state.selected, 0, Math.max(0, results.length - 1));
80
+ const selectedResult = results[selectedIndex];
81
+ const selectedServer = selectedResult?.server
82
+ ? selectedServerVersion(state.servers, selectedResult.server, state.versionSelections[selectedResult.server.name])
83
+ : undefined;
84
+ const selectedVersionInfo = selectedServer
85
+ ? buildTuiVersionInfo(state.servers, selectedServer.name, selectedServer.version, state.lockfile, state.client, state.installScope)
86
+ : undefined;
87
+ const commandResults = useMemo(() => {
88
+ const query = state.commandQuery.trim().toLowerCase();
89
+ if (!query)
90
+ return TUI_COMMANDS;
91
+ return TUI_COMMANDS.filter((command) => {
92
+ const haystack = `${command.id} ${command.label} ${command.description}`.toLowerCase();
93
+ return haystack.includes(query);
94
+ });
95
+ }, [state.commandQuery]);
96
+ const selectedCommandIndex = clamp(state.commandSelected, 0, Math.max(0, commandResults.length - 1));
97
+ const selectedCommand = commandResults[selectedCommandIndex];
98
+ const selectedInstalled = installed.rows[installed.selected];
99
+ const selectedInstalledTargetVersion = selectedInstalled ? state.installedVersionSelections[selectedInstalled.id] : undefined;
100
+ const selectedInstalledTarget = selectedInstalled
101
+ ? installedTargetServer(selectedInstalled, selectedInstalledTargetVersion)
102
+ : undefined;
103
+ const selectedInstalledRuntimeAdvisory = selectedInstalled
104
+ ? installedRuntimeAdvisories[selectedInstalled.id] ?? undefined
105
+ : undefined;
106
+ useEffect(() => {
107
+ if (!selectedInstalled)
108
+ return;
109
+ if (Object.hasOwn(installedRuntimeAdvisories, selectedInstalled.id))
110
+ return;
111
+ if (pendingInstalledRuntimeAdvisoryIds.current.has(selectedInstalled.id))
112
+ return;
113
+ const row = selectedInstalled;
114
+ pendingInstalledRuntimeAdvisoryIds.current.add(row.id);
115
+ void localHttpRuntimeAdvisory(row.serverName, row.client, row.scope)
116
+ .then((advisory) => {
117
+ setInstalledRuntimeAdvisories((prev) => ({
118
+ ...prev,
119
+ [row.id]: advisory ?? null,
120
+ }));
121
+ })
122
+ .catch(() => {
123
+ setInstalledRuntimeAdvisories((prev) => ({
124
+ ...prev,
125
+ [row.id]: null,
126
+ }));
127
+ })
128
+ .finally(() => {
129
+ pendingInstalledRuntimeAdvisoryIds.current.delete(row.id);
130
+ });
131
+ }, [selectedInstalled, installedRuntimeAdvisories]);
132
+ async function loadData(mode, query = state.query, sourceMode = state.sourceMode) {
133
+ setState((prev) => ({ ...prev, loading: true, error: undefined, dataMode: mode }));
134
+ try {
135
+ const registrySources = await listRegistrySourceStatuses().catch(() => REGISTRY_SOURCES);
136
+ let nextRegistrySources = registrySources;
137
+ let nextCommandLog = undefined;
138
+ const entries = mode === "live"
139
+ ? await fetchRegistryResult({ maxPages: 4, search: query || undefined, source: sourceMode }).then((fetched) => {
140
+ nextRegistrySources = registrySourcesWithFetchResult(registrySources, fetched);
141
+ return dedupeRegistryEntries(fetched.entries);
142
+ })
143
+ : await readCache().then((cached) => {
144
+ const coverage = cacheCoverage(cached, sourceMode, registrySources);
145
+ if (!coverage.covered) {
146
+ nextCommandLog = {
147
+ title: "ingest",
148
+ command: "toolpin ingest",
149
+ ok: false,
150
+ lines: [
151
+ `cache coverage incomplete for ${coverage.missing.join(", ")}`,
152
+ "Press r to refresh enabled sources into .toolpin/registry-cache.json.",
153
+ ],
154
+ };
155
+ }
156
+ return cached;
157
+ }).catch(async () => {
158
+ const fetched = await refreshRegistryCache({ maxPages: 3, source: sourceMode });
159
+ nextRegistrySources = registrySourcesWithFetchResult(registrySources, fetched);
160
+ return fetched.entries;
161
+ });
162
+ const servers = normalizeEntries(entries);
163
+ const visibleServers = filterByEnabledSources(servers, sourceMode, nextRegistrySources);
164
+ const lockfile = await readLockfile("mcp-lock.json").catch(() => undefined);
165
+ void refreshInstalledRows(servers, lockfile);
166
+ setState((prev) => ({
167
+ ...prev,
168
+ entries,
169
+ registrySources: nextRegistrySources,
170
+ servers: visibleServers,
171
+ lockfile,
172
+ selected: 0,
173
+ testResult: undefined,
174
+ versionSelections: pruneVersionSelections(prev.versionSelections, servers),
175
+ resultLimit: DEFAULT_RESULT_LIMIT,
176
+ loading: false,
177
+ error: undefined,
178
+ dataMode: mode,
179
+ sourceMode,
180
+ lastAction: mode === "live" ? `loaded live ${sourceMode}` : `loaded cache ${sourceMode}`,
181
+ commandLog: nextCommandLog ?? prev.commandLog,
182
+ }));
183
+ }
184
+ catch (error) {
185
+ setState((prev) => ({
186
+ ...prev,
187
+ loading: false,
188
+ error: error instanceof Error ? error.message : String(error),
189
+ }));
190
+ }
191
+ }
192
+ async function refreshCache(source = state.sourceMode) {
193
+ setState((prev) => ({ ...prev, loading: true, error: undefined }));
194
+ try {
195
+ const fetched = await refreshRegistryCache(persistentRefreshOptions(source));
196
+ const entries = await readCache().catch(() => fetched.entries);
197
+ const registrySources = await listRegistrySourceStatuses().catch(() => REGISTRY_SOURCES);
198
+ const servers = normalizeEntries(entries);
199
+ const visibleServers = filterByEnabledSources(servers, source, registrySources);
200
+ const lockfile = await readLockfile("mcp-lock.json").catch(() => undefined);
201
+ const ingestLog = fetched.lastError ? {
202
+ title: "ingest",
203
+ command: "toolpin ingest",
204
+ ok: fetched.entries.length > 0,
205
+ lines: [`cached ${fetched.entries.length} ${source} entries`, fetched.lastError],
206
+ } : undefined;
207
+ void refreshInstalledRows(servers, lockfile);
208
+ setState((prev) => ({
209
+ ...prev,
210
+ entries,
211
+ registrySources,
212
+ servers: visibleServers,
213
+ lockfile,
214
+ selected: 0,
215
+ testResult: undefined,
216
+ versionSelections: pruneVersionSelections(prev.versionSelections, servers),
217
+ resultLimit: DEFAULT_RESULT_LIMIT,
218
+ loading: false,
219
+ error: undefined,
220
+ dataMode: "cache",
221
+ sourceMode: source,
222
+ lastAction: `ingested ${fetched.entries.length} ${source} versions`,
223
+ commandLog: ingestLog ?? prev.commandLog,
224
+ }));
225
+ }
226
+ catch (error) {
227
+ setState((prev) => ({ ...prev, loading: false, error: error instanceof Error ? error.message : String(error) }));
228
+ }
229
+ }
230
+ async function toggleSelectedSource() {
231
+ const source = sourceRows(state.registrySources)[state.sourceSelected];
232
+ if (!source)
233
+ return;
234
+ if (source.pinned && source.enabled) {
235
+ setState((prev) => ({
236
+ ...prev,
237
+ error: undefined,
238
+ lastAction: `${source.id} is pinned`,
239
+ commandLog: {
240
+ title: "sources",
241
+ command: `toolpin registry disable ${source.id}`,
242
+ ok: false,
243
+ lines: [`${source.id} is pinned and cannot be disabled.`],
244
+ },
245
+ }));
246
+ return;
247
+ }
248
+ setState((prev) => ({ ...prev, loading: true, error: undefined }));
249
+ try {
250
+ await updateRegistrySourceEnabled(source.id, !source.enabled);
251
+ const registrySources = await listRegistrySourceStatuses().catch(() => REGISTRY_SOURCES);
252
+ const entries = await readCache().catch(() => state.entries);
253
+ const servers = normalizeEntries(entries);
254
+ const visibleServers = filterByEnabledSources(servers, "all", registrySources);
255
+ setState((prev) => ({
256
+ ...prev,
257
+ entries,
258
+ registrySources,
259
+ servers: visibleServers,
260
+ sourceMode: "all",
261
+ selected: 0,
262
+ loading: false,
263
+ error: undefined,
264
+ lastAction: `${source.enabled ? "disabled" : "enabled"} ${source.id}`,
265
+ commandLog: {
266
+ title: "sources",
267
+ command: `toolpin registry ${source.enabled ? "disable" : "enable"} ${source.id}`,
268
+ ok: true,
269
+ lines: [`${source.id} ${source.enabled ? "disabled" : "enabled"}`, "Browse now uses enabled sources."],
270
+ },
271
+ }));
272
+ }
273
+ catch (error) {
274
+ setState((prev) => ({ ...prev, loading: false, error: error instanceof Error ? error.message : String(error) }));
275
+ }
276
+ }
277
+ async function writeSelectedLock() {
278
+ if (!selectedServer)
279
+ return;
280
+ try {
281
+ let lockfile;
282
+ const clients = selectedInstallClientsForServerScope(state.client, state.installScope, selectedServer);
283
+ if (!clients.length)
284
+ throw new Error(`${selectedServer.name}@${selectedServer.version} is not directly installable for ${state.client} in ${scopeLabel(state.installScope)}.`);
285
+ for (const client of clients) {
286
+ lockfile = await writeLockfile(buildInstallPlan(selectedServer, client, { scope: state.installScope }), "mcp-lock.json", lockKey(selectedServer.name, client));
287
+ }
288
+ setState((prev) => ({ ...prev, lockfile, lastAction: `locked ${selectedServer.name} for ${state.client}` }));
289
+ }
290
+ catch (error) {
291
+ setState((prev) => ({ ...prev, error: error instanceof Error ? error.message : String(error) }));
292
+ }
293
+ }
294
+ async function refreshInstalledRows(servers = state.servers, lockfile = state.lockfile, scope = installed.scope, tests = installedTests) {
295
+ dispatchInstalled({ type: "loading" });
296
+ try {
297
+ const rows = await loadInstalledServerStates({ servers, lockfile, scope, tests });
298
+ dispatchInstalled({ type: "loaded", rows });
299
+ }
300
+ catch (error) {
301
+ dispatchInstalled({ type: "loaded", rows: [] });
302
+ setState((prev) => ({ ...prev, error: error instanceof Error ? error.message : String(error) }));
303
+ }
304
+ }
305
+ async function saveSelectedConfig() {
306
+ if (!selectedServer)
307
+ return;
308
+ try {
309
+ await mkdir(".toolpin", { recursive: true });
310
+ const files = [];
311
+ const clients = selectedInstallClientsForServerScope(state.client, state.installScope, selectedServer);
312
+ if (!clients.length)
313
+ throw new Error(`${selectedServer.name}@${selectedServer.version} is not directly installable for ${state.client} in ${scopeLabel(state.installScope)}.`);
314
+ for (const client of clients) {
315
+ const exported = exportClientConfig(selectedServer, client);
316
+ const formatted = formatClientConfigSnippet(client, exported.config);
317
+ const file = path.join(".toolpin", `${safeFileName(selectedServer.name)}.${client}.${formatted.extension}`);
318
+ await writeFile(file, formatted.content, "utf8");
319
+ files.push(file);
320
+ }
321
+ setState((prev) => ({ ...prev, lastAction: `saved ${files.length} config snippet(s)` }));
322
+ }
323
+ catch (error) {
324
+ setState((prev) => ({ ...prev, error: error instanceof Error ? error.message : String(error) }));
325
+ }
326
+ }
327
+ async function installSelected(opts) {
328
+ const server = opts?.server ?? selectedServer;
329
+ if (!server) {
330
+ setState((prev) => ({
331
+ ...prev,
332
+ commandLog: {
333
+ title: "install",
334
+ command: commandLineFor("install", state, selectedServer),
335
+ ok: false,
336
+ lines: ["Select a server before installing."],
337
+ },
338
+ }));
339
+ return;
340
+ }
341
+ const scope = opts?.scope ?? state.installScope;
342
+ const requestedClient = opts?.client ?? state.client;
343
+ const targetClients = opts?.clients ?? selectedInstallClientsForServerScope(requestedClient, scope, server);
344
+ const clientLabel = opts?.clientLabel ?? installClientLabel(opts?.client ?? state.client, targetClients);
345
+ const command = commandLineFor("install", { ...state, client: opts?.client ?? state.client, installScope: scope }, server);
346
+ if (!targetClients.length) {
347
+ setState((prev) => ({
348
+ ...prev,
349
+ error: `${server.name}@${server.version} is not directly installable for ${requestedClient} in ${scopeLabel(scope)}`,
350
+ commandLog: { title: "install", command, ok: false, lines: [`No direct ToolPin install target for ${requestedClient} in ${scopeLabel(scope)}.`] },
351
+ }));
352
+ return;
353
+ }
354
+ setState((prev) => ({
355
+ ...prev,
356
+ view: "plan",
357
+ installing: true,
358
+ error: undefined,
359
+ commandLog: {
360
+ title: "install",
361
+ command,
362
+ ok: true,
363
+ lines: [
364
+ `starting install for ${server.name}`,
365
+ `target: ${clientLabel} / ${scopeLabel(scope)}`,
366
+ "checking policy and lock drift...",
367
+ ],
368
+ },
369
+ lastAction: `installing ${server.name}`,
370
+ }));
371
+ try {
372
+ const files = [];
373
+ let lockfile;
374
+ const plans = targetClients.map((client) => buildInstallPlan(server, client, { scope }));
375
+ const policyViolations = [];
376
+ for (const plan of plans) {
377
+ const policy = await enforcePolicy(plan);
378
+ if (!policy.ok)
379
+ policyViolations.push(`${policy.key}: ${policy.issues.map((issue) => issue.message).join("; ")}`);
380
+ }
381
+ if (policyViolations.length) {
382
+ throw new Error(`policy refused install: ${policyViolations.join(" | ")}`);
383
+ }
384
+ const mismatches = [];
385
+ for (const plan of plans) {
386
+ const verification = await verifyAgainstLockfile(plan, "mcp-lock.json");
387
+ if (!verification.ok)
388
+ mismatches.push(`${verification.key}: ${verification.messages.join("; ")}`);
389
+ }
390
+ if (mismatches.length) {
391
+ throw new Error(`lock drift: ${mismatches.join(" | ")}. Press w to update the lock after review.`);
392
+ }
393
+ setState((prev) => ({
394
+ ...prev,
395
+ commandLog: {
396
+ title: "install",
397
+ command,
398
+ ok: true,
399
+ lines: [
400
+ `policy and lock checks passed for ${targetClients.length} client(s)`,
401
+ `writing ${scopeLabel(scope)} config and mcp-lock.json...`,
402
+ ],
403
+ },
404
+ }));
405
+ for (const [index, client] of targetClients.entries()) {
406
+ const result = await installServerConfig(server, client, scope);
407
+ lockfile = await writeLockfile(plans[index], "mcp-lock.json", lockKey(server.name, client));
408
+ files.push(result.file);
409
+ }
410
+ setState((prev) => ({
411
+ ...prev,
412
+ lockfile,
413
+ installing: false,
414
+ installFlow: prev.installFlow ? { ...prev.installFlow, step: "complete", selected: 0 } : undefined,
415
+ lastAction: `installed ${server.name} -> ${unique(files).join(", ")}`,
416
+ commandLog: {
417
+ title: "install",
418
+ command,
419
+ ok: true,
420
+ lines: [
421
+ `installed ${server.name}@${server.version} for ${clientLabel}`,
422
+ `scope: ${scopeLabel(scope)}`,
423
+ `${unique(files).length === 1 ? "path" : "paths"}: ${unique(files).join(", ")}`,
424
+ "updated mcp-lock.json",
425
+ ],
426
+ },
427
+ }));
428
+ await refreshInstalledRows(state.servers, lockfile);
429
+ }
430
+ catch (error) {
431
+ const message = error instanceof Error ? error.message : String(error);
432
+ setState((prev) => ({
433
+ ...prev,
434
+ installing: false,
435
+ installFlow: prev.installFlow ? { ...prev.installFlow, step: "failed", selected: 0 } : undefined,
436
+ error: message,
437
+ commandLog: {
438
+ title: "install",
439
+ command,
440
+ ok: false,
441
+ lines: [message],
442
+ },
443
+ }));
444
+ }
445
+ }
446
+ function beginInstallFlow() {
447
+ const baseServer = selectedServer ?? selectedResult?.server;
448
+ if (!baseServer) {
449
+ setState((prev) => ({
450
+ ...prev,
451
+ commandLog: {
452
+ title: "install",
453
+ command: commandLineFor("install", state, selectedServer),
454
+ ok: false,
455
+ lines: ["Select a server before installing."],
456
+ },
457
+ }));
458
+ return;
459
+ }
460
+ const versions = installVersionServers(baseServer);
461
+ const selectedVersionIndex = initialInstallVersionIndex(versions, baseServer.version);
462
+ const server = versions[selectedVersionIndex] ?? versions[0] ?? baseServer;
463
+ setState((prev) => ({
464
+ ...prev,
465
+ installFlow: {
466
+ step: versions.length > 1 ? "version" : "scope",
467
+ server,
468
+ versions,
469
+ preferredClient: prev.client,
470
+ selected: versions.length > 1 ? selectedVersionIndex : prev.installScope === "global" ? 1 : 0,
471
+ },
472
+ inputMode: "normal",
473
+ lastAction: versions.length > 1 ? `install ${server.name}: choose version` : `install ${server.name}: choose scope`,
474
+ }));
475
+ }
476
+ function installVersionServers(server) {
477
+ const versions = knownVersions(state.servers, server.name);
478
+ const versionServers = versions
479
+ .map((entry) => state.servers.find((candidate) => candidate.name === server.name
480
+ && candidate.version === entry.version
481
+ && candidate.registrySource === entry.source) ?? state.servers.find((candidate) => candidate.name === server.name && candidate.version === entry.version))
482
+ .filter((candidate) => Boolean(candidate));
483
+ return versionServers.length ? versionServers : [server];
484
+ }
485
+ async function removeSelected() {
486
+ if (!selectedServer)
487
+ return;
488
+ try {
489
+ await readLockfile("mcp-lock.json");
490
+ const results = [];
491
+ for (const client of selectedClientsForScope(state.client, state.installScope)) {
492
+ const configResult = await removeServerConfig(selectedServer.name, client, state.installScope);
493
+ const lockResult = await removeLockfileEntry(selectedServer.name, client, "mcp-lock.json");
494
+ results.push(`${client}:config=${configResult.action},lock=${lockResult.removed ? "removed" : "missing"}`);
495
+ }
496
+ const lockfile = await readLockfile("mcp-lock.json").catch(() => undefined);
497
+ setState((prev) => ({
498
+ ...prev,
499
+ lockfile,
500
+ pendingRemove: undefined,
501
+ lastAction: `removed ${selectedServer.name} (${results.join("; ")})`,
502
+ }));
503
+ }
504
+ catch (error) {
505
+ setState((prev) => ({ ...prev, pendingRemove: undefined, error: error instanceof Error ? error.message : String(error) }));
506
+ }
507
+ }
508
+ function requestRemoveConfirmation() {
509
+ if (!selectedServer)
510
+ return;
511
+ const pending = state.pendingRemove;
512
+ if (pending?.serverName === selectedServer.name && pending.client === state.client && pending.scope === state.installScope) {
513
+ void removeSelected();
514
+ return;
515
+ }
516
+ setState((prev) => ({
517
+ ...prev,
518
+ pendingRemove: {
519
+ serverName: selectedServer.name,
520
+ client: state.client,
521
+ scope: state.installScope,
522
+ },
523
+ lastAction: `press x again to remove ${selectedServer.name} from ${state.client} ${state.installScope}`,
524
+ }));
525
+ }
526
+ function requestInstalledRemoveConfirmation(row) {
527
+ if (!row)
528
+ return;
529
+ const requested = { serverName: row.serverName, client: row.client, scope: row.scope };
530
+ setState((prev) => ({
531
+ ...prev,
532
+ deleteConfirm: {
533
+ source: "installed",
534
+ ...requested,
535
+ selected: "no",
536
+ },
537
+ pendingRemove: undefined,
538
+ lastAction: `confirm delete ${row.serverName}`,
539
+ }));
540
+ void localHttpRuntimeAdvisory(row.serverName, row.client, row.scope)
541
+ .then((advisory) => {
542
+ if (!advisory)
543
+ return;
544
+ setState((prev) => {
545
+ const confirm = prev.deleteConfirm;
546
+ if (!confirm
547
+ || confirm.serverName !== requested.serverName
548
+ || confirm.client !== requested.client
549
+ || confirm.scope !== requested.scope) {
550
+ return prev;
551
+ }
552
+ return {
553
+ ...prev,
554
+ deleteConfirm: {
555
+ ...confirm,
556
+ runtimeAdvisory: advisory.message,
557
+ },
558
+ };
559
+ });
560
+ })
561
+ .catch(() => undefined);
562
+ }
563
+ async function testSelected() {
564
+ if (!selectedServer) {
565
+ setState((prev) => ({
566
+ ...prev,
567
+ commandLog: {
568
+ title: "test",
569
+ command: commandLineFor("test", state, selectedServer),
570
+ ok: false,
571
+ lines: ["Select a server before testing."],
572
+ },
573
+ }));
574
+ return;
575
+ }
576
+ const command = commandLineFor("test", state, selectedServer);
577
+ setState((prev) => ({
578
+ ...prev,
579
+ view: "details",
580
+ testing: true,
581
+ error: undefined,
582
+ testResult: undefined,
583
+ commandLog: {
584
+ title: "test",
585
+ command,
586
+ ok: true,
587
+ lines: [
588
+ `connecting to ${selectedServer.name}`,
589
+ "some MCP tests require tokens or credentials to succeed",
590
+ "running MCP initialize handshake and tools/list...",
591
+ ],
592
+ },
593
+ lastAction: `testing ${selectedServer.name}`,
594
+ }));
595
+ try {
596
+ const result = await testServer(selectedServer, 15000);
597
+ setState((prev) => ({
598
+ ...prev,
599
+ testing: false,
600
+ testResult: result,
601
+ commandLog: {
602
+ title: "test",
603
+ command,
604
+ ok: result.ok,
605
+ lines: [
606
+ result.message,
607
+ `target: ${result.target}`,
608
+ `duration: ${result.durationMs}ms`,
609
+ ...result.tools.slice(0, 4).map((tool) => `tool ${tool.name}${tool.description ? ` - ${tool.description}` : ""}`),
610
+ ],
611
+ },
612
+ lastAction: result.ok ? `test passed: ${result.tools.length} tool(s)` : `test failed: ${result.message}`,
613
+ }));
614
+ }
615
+ catch (error) {
616
+ const message = error instanceof Error ? error.message : String(error);
617
+ setState((prev) => ({
618
+ ...prev,
619
+ testing: false,
620
+ commandLog: {
621
+ title: "test",
622
+ command,
623
+ ok: false,
624
+ lines: [message],
625
+ },
626
+ lastAction: `test failed: ${message}`,
627
+ }));
628
+ }
629
+ }
630
+ async function updateInstalled(row) {
631
+ if (!row)
632
+ return;
633
+ const targetVersion = state.installedVersionSelections[row.id];
634
+ const updateServer = installedTargetServer(row, targetVersion);
635
+ const hasExplicitTarget = Boolean(targetVersion && updateServer);
636
+ if ((!row.canUpdate && !hasExplicitTarget) || !updateServer || (row.lifecycleAction === "none" && !hasExplicitTarget)) {
637
+ const lines = row.locked
638
+ ? [
639
+ `${row.serverName} is already registry-backed and locked for ${row.client}.`,
640
+ row.latestVersion && row.lockedVersion === row.latestVersion
641
+ ? `locked version ${row.lockedVersion} is current.`
642
+ : "No newer installable registry version is loaded for this lock entry.",
643
+ "Refresh registry data or switch source to all/live if you want to check for updates.",
644
+ ]
645
+ : [
646
+ `No installable registry match is loaded for ${row.serverName}.`,
647
+ "Load live registry data or search the registry first, then retry the lifecycle action.",
648
+ ];
649
+ setState((prev) => ({
650
+ ...prev,
651
+ commandLog: {
652
+ title: "update",
653
+ command: row.locked
654
+ ? `toolpin update ${row.serverName} --client ${row.client} --scope ${row.scope}`
655
+ : `toolpin adopt ${row.serverName} --client ${row.client} --scope ${row.scope}`,
656
+ ok: false,
657
+ lines,
658
+ },
659
+ }));
660
+ return;
661
+ }
662
+ const command = row.lifecycleAction === "update"
663
+ ? `toolpin update ${row.serverName} --client ${row.client} --scope ${row.scope}${targetVersion ? ` --version ${targetVersion}` : ""}`
664
+ : `toolpin adopt ${row.serverName} --client ${row.client} --scope ${row.scope}`;
665
+ setState((prev) => ({
666
+ ...prev,
667
+ installing: true,
668
+ error: undefined,
669
+ commandLog: {
670
+ title: row.lifecycleAction,
671
+ command,
672
+ ok: true,
673
+ lines: [
674
+ row.lifecycleAction === "update" ? `updating locked ${row.serverName}` : `adopting unlocked ${row.serverName}`,
675
+ row.lifecycleAction === "update"
676
+ ? "resolving locked registry entry and updating config + mcp-lock.json"
677
+ : "resolving installed alias in registry, making it registry-backed, and locking it",
678
+ row.serverName !== updateServer.name ? `will replace installed alias ${row.serverName} with ${updateServer.name}` : `registry entry ${updateServer.name}`,
679
+ `version ${row.lockedVersion ?? "unlocked"} -> ${updateServer.version}${targetVersion ? " (explicit)" : ""}`,
680
+ ],
681
+ },
682
+ lastAction: row.lifecycleAction === "update" ? `updating ${row.serverName}` : `adopting ${row.serverName}`,
683
+ }));
684
+ try {
685
+ const result = row.lifecycleAction === "update"
686
+ ? await updateInstalledServer({
687
+ serverName: row.serverName,
688
+ client: row.client,
689
+ scope: row.scope,
690
+ servers: state.servers,
691
+ version: targetVersion,
692
+ })
693
+ : await adoptInstalledServer({
694
+ installedName: row.serverName,
695
+ client: row.client,
696
+ scope: row.scope,
697
+ servers: state.servers,
698
+ });
699
+ const lockfile = await readLockfile("mcp-lock.json").catch(() => undefined);
700
+ setState((prev) => ({
701
+ ...prev,
702
+ lockfile,
703
+ installing: false,
704
+ lastAction: result.action === "update" ? `updated ${result.targetName}` : `adopted ${result.targetName}`,
705
+ commandLog: {
706
+ title: result.action,
707
+ command,
708
+ ok: true,
709
+ lines: [
710
+ ...result.planned,
711
+ result.removedAlias ? `removed old alias: ${result.removedAlias.action} ${result.removedAlias.file}` : "no alias removal needed",
712
+ result.config ? `config: ${result.config.action} ${result.config.file}` : "config write skipped",
713
+ result.lockfileWritten ? "mcp-lock.json updated with registry-backed entry" : "mcp-lock.json unchanged",
714
+ ],
715
+ },
716
+ }));
717
+ await refreshInstalledRows(state.servers, lockfile);
718
+ }
719
+ catch (error) {
720
+ const message = error instanceof Error ? error.message : String(error);
721
+ setState((prev) => ({
722
+ ...prev,
723
+ installing: false,
724
+ error: message,
725
+ commandLog: {
726
+ title: row.lifecycleAction,
727
+ command,
728
+ ok: false,
729
+ lines: [message],
730
+ },
731
+ }));
732
+ }
733
+ }
734
+ async function updateAllInstalled() {
735
+ const command = `toolpin update --all --scope ${installed.scope}`;
736
+ setState((prev) => ({
737
+ ...prev,
738
+ installing: true,
739
+ error: undefined,
740
+ commandLog: {
741
+ title: "update",
742
+ command,
743
+ ok: true,
744
+ lines: ["checking locked installed servers for safe updates..."],
745
+ },
746
+ }));
747
+ try {
748
+ const result = await updateAllInstalledServers({
749
+ scope: installed.scope,
750
+ client: "all",
751
+ servers: state.servers,
752
+ });
753
+ const lockfile = await readLockfile("mcp-lock.json").catch(() => undefined);
754
+ setState((prev) => ({
755
+ ...prev,
756
+ lockfile,
757
+ installing: false,
758
+ commandLog: {
759
+ title: "update",
760
+ command,
761
+ ok: true,
762
+ lines: [
763
+ `updated ${result.updated.length} locked registry-backed server(s)`,
764
+ `skipped ${result.skippedAdoptable.length} unlocked adoptable server(s); use u to adopt and lock them`,
765
+ ...result.updated.slice(0, 4).map((entry) => `${entry.serverName} -> ${entry.targetName}@${entry.toVersion}`),
766
+ ],
767
+ },
768
+ lastAction: `updated ${result.updated.length} installed server(s)`,
769
+ }));
770
+ await refreshInstalledRows(state.servers, lockfile);
771
+ }
772
+ catch (error) {
773
+ const message = error instanceof Error ? error.message : String(error);
774
+ setState((prev) => ({
775
+ ...prev,
776
+ installing: false,
777
+ error: message,
778
+ commandLog: { title: "update", command, ok: false, lines: [message] },
779
+ }));
780
+ }
781
+ }
782
+ async function removeInstalledTarget(target) {
783
+ try {
784
+ const runtimeAdvisory = await localHttpRuntimeAdvisory(target.serverName, target.client, target.scope).catch(() => undefined);
785
+ const configResult = await removeServerConfig(target.serverName, target.client, target.scope);
786
+ const lockResult = await removeLockfileEntry(target.serverName, target.client, "mcp-lock.json");
787
+ const lockfile = lockResult.lockfile;
788
+ const lines = [`config ${configResult.action}: ${configResult.file}`, `lock ${lockResult.removed ? "removed" : "missing"}`];
789
+ if (runtimeAdvisory && configResult.action === "removed")
790
+ lines.push(`runtime ${runtimeAdvisory.message}`);
791
+ setState((prev) => ({
792
+ ...prev,
793
+ lockfile,
794
+ deleteConfirm: undefined,
795
+ commandLog: {
796
+ title: "remove",
797
+ command: `toolpin remove ${target.serverName} --client ${target.client} --scope ${target.scope}`,
798
+ ok: true,
799
+ lines,
800
+ },
801
+ lastAction: `removed ${target.serverName} from ${target.client} ${target.scope}`,
802
+ }));
803
+ await refreshInstalledRows(state.servers, lockfile);
804
+ }
805
+ catch (error) {
806
+ setState((prev) => ({ ...prev, deleteConfirm: undefined, error: error instanceof Error ? error.message : String(error) }));
807
+ }
808
+ }
809
+ async function testInstalled(row) {
810
+ if (!row) {
811
+ setState((prev) => ({
812
+ ...prev,
813
+ commandLog: {
814
+ title: "test",
815
+ command: "toolpin test-installed",
816
+ ok: false,
817
+ lines: ["Select an installed server first."],
818
+ },
819
+ }));
820
+ return;
821
+ }
822
+ const command = `toolpin test-installed ${row.serverName} --client ${row.client} --scope ${row.scope}`;
823
+ setState((prev) => ({
824
+ ...prev,
825
+ testing: true,
826
+ error: undefined,
827
+ commandLog: {
828
+ title: "test",
829
+ command,
830
+ ok: true,
831
+ lines: [
832
+ `testing installed ${row.serverName}`,
833
+ `using installed config from ${row.file}`,
834
+ "running MCP initialize handshake and tools/list...",
835
+ ],
836
+ },
837
+ }));
838
+ try {
839
+ const result = await testInstalledServer({ serverName: row.serverName, client: row.client, scope: row.scope, timeoutMs: 15000 });
840
+ const tests = { ...installedTests, [installedId(row.serverName, row.client, row.scope)]: result };
841
+ setInstalledTests(tests);
842
+ setState((prev) => ({
843
+ ...prev,
844
+ testing: false,
845
+ commandLog: {
846
+ title: "test",
847
+ command,
848
+ ok: result.ok,
849
+ lines: [result.message, `target: ${result.target}`, `duration: ${result.durationMs}ms`],
850
+ },
851
+ lastAction: result.ok ? `installed test passed: ${row.serverName}` : `installed test failed: ${row.serverName}`,
852
+ }));
853
+ await refreshInstalledRows(state.servers, state.lockfile, installed.scope, tests);
854
+ }
855
+ catch (error) {
856
+ const message = error instanceof Error ? error.message : String(error);
857
+ setState((prev) => ({
858
+ ...prev,
859
+ testing: false,
860
+ commandLog: { title: "test", command, ok: false, lines: [message] },
861
+ }));
862
+ }
863
+ }
864
+ async function runInstalledDoctor() {
865
+ setState((prev) => ({
866
+ ...prev,
867
+ checking: true,
868
+ commandLog: {
869
+ title: "doctor",
870
+ command: `toolpin doctor --scope ${installed.scope}`,
871
+ ok: true,
872
+ lines: ["checking installed config entries against mcp-lock.json..."],
873
+ },
874
+ lastAction: "checking installed drift state",
875
+ }));
876
+ try {
877
+ const report = await doctorLockfile("mcp-lock.json", installed.scope);
878
+ await refreshInstalledRows();
879
+ setState((prev) => ({
880
+ ...prev,
881
+ checking: false,
882
+ commandLog: {
883
+ title: "doctor",
884
+ command: `toolpin doctor --scope ${installed.scope}`,
885
+ ok: report.ok,
886
+ lines: report.ok
887
+ ? [`checked ${report.checked} locked entrie(s)`, "no lock/config drift found"]
888
+ : [
889
+ `checked ${report.checked} locked entrie(s)`,
890
+ `${report.issues.length} issue(s) found`,
891
+ ...report.issues.slice(0, 6).map((issue) => `${issue.kind}: ${issue.client}/${issue.serverName} ${issue.scope ?? "all"} - ${issue.message}`),
892
+ ],
893
+ },
894
+ lastAction: report.ok ? "no installed drift found" : `doctor found ${report.issues.length} issue(s)`,
895
+ }));
896
+ }
897
+ catch (error) {
898
+ const message = error instanceof Error ? error.message : String(error);
899
+ setState((prev) => ({
900
+ ...prev,
901
+ checking: false,
902
+ commandLog: {
903
+ title: "doctor",
904
+ command: `toolpin doctor --scope ${installed.scope}`,
905
+ ok: false,
906
+ lines: [message],
907
+ },
908
+ lastAction: `doctor failed: ${message}`,
909
+ }));
910
+ }
911
+ }
912
+ async function executeCommand(commandId) {
913
+ const commandLine = commandLineFor(commandId, state, selectedServer);
914
+ setState((prev) => ({
915
+ ...prev,
916
+ inputMode: "normal",
917
+ commandQuery: "",
918
+ commandSelected: 0,
919
+ commandLog: {
920
+ title: commandId,
921
+ command: commandLine,
922
+ ok: true,
923
+ lines: ["running command-equivalent action..."],
924
+ },
925
+ }));
926
+ if (commandRequiresServer(commandId) && !selectedServer) {
927
+ setState((prev) => ({
928
+ ...prev,
929
+ commandLog: {
930
+ title: commandId,
931
+ command: commandLine,
932
+ ok: false,
933
+ lines: ["Select a server first."],
934
+ },
935
+ }));
936
+ return;
937
+ }
938
+ switch (commandId) {
939
+ case "ingest":
940
+ await refreshCache();
941
+ break;
942
+ case "installed":
943
+ setState((prev) => ({ ...prev, view: "installed", commandLog: { title: "installed", command: commandLine, ok: true, lines: [`${installed.rows.length} installed server entrie(s) loaded`] } }));
944
+ await refreshInstalledRows();
945
+ break;
946
+ case "sources":
947
+ setState((prev) => ({
948
+ ...prev,
949
+ view: "sources",
950
+ commandLog: {
951
+ title: "sources",
952
+ command: commandLine,
953
+ ok: true,
954
+ lines: [
955
+ `${prev.registrySources.filter((source) => source.enabled).length} connected source(s)`,
956
+ `active source: ${prev.sourceMode}`,
957
+ ],
958
+ },
959
+ }));
960
+ break;
961
+ case "search":
962
+ setState((prev) => ({ ...prev, inputMode: "search", view: "discover", commandLog: undefined }));
963
+ break;
964
+ case "more-results":
965
+ showMoreResults();
966
+ break;
967
+ case "reset-view":
968
+ resetViewDefaults();
969
+ break;
970
+ case "info":
971
+ setState((prev) => ({
972
+ ...prev,
973
+ view: "details",
974
+ commandLog: {
975
+ title: "info",
976
+ command: commandLine,
977
+ ok: true,
978
+ lines: selectedServer ? [
979
+ `${selectedServer.name}@${selectedServer.version}`,
980
+ selectedServer.title,
981
+ selectedServer.description || "No description declared.",
982
+ ] : [],
983
+ },
984
+ }));
985
+ break;
986
+ case "audit":
987
+ setState((prev) => ({
988
+ ...prev,
989
+ view: "details",
990
+ commandLog: {
991
+ title: "audit",
992
+ command: commandLine,
993
+ ok: true,
994
+ lines: selectedResult ? [
995
+ `trust tier: ${trustRiskTone(selectedResult.trust).label}`,
996
+ `metadata completeness: ${selectedResult.trust.score}`,
997
+ `badges: ${selectedResult.trust.badges.join(", ") || "none"}`,
998
+ ...selectedResult.trust.issues.slice(0, 4).map((issue) => `${issue.severity}: ${issue.message}`),
999
+ ] : [],
1000
+ },
1001
+ }));
1002
+ break;
1003
+ case "plan":
1004
+ setState((prev) => ({ ...prev, view: "plan" }));
1005
+ break;
1006
+ case "install":
1007
+ beginInstallFlow();
1008
+ break;
1009
+ case "remove":
1010
+ await removeSelected();
1011
+ break;
1012
+ case "doctor": {
1013
+ setState((prev) => ({
1014
+ ...prev,
1015
+ checking: true,
1016
+ commandLog: {
1017
+ title: "doctor",
1018
+ command: commandLine,
1019
+ ok: true,
1020
+ lines: [`checking ${state.installScope} config against mcp-lock.json...`],
1021
+ },
1022
+ }));
1023
+ try {
1024
+ const report = await doctorLockfile("mcp-lock.json", state.installScope);
1025
+ setState((prev) => ({
1026
+ ...prev,
1027
+ checking: false,
1028
+ commandLog: {
1029
+ title: "doctor",
1030
+ command: commandLine,
1031
+ ok: report.ok,
1032
+ lines: report.ok
1033
+ ? [`${report.checked} locked server/client entrie(s) match ${state.installScope} config.`]
1034
+ : report.issues.slice(0, 5).map((issue) => `${issue.kind} ${issue.key}: ${issue.message}`),
1035
+ },
1036
+ }));
1037
+ }
1038
+ catch (error) {
1039
+ setState((prev) => ({
1040
+ ...prev,
1041
+ checking: false,
1042
+ commandLog: {
1043
+ title: "doctor",
1044
+ command: commandLine,
1045
+ ok: false,
1046
+ lines: [error instanceof Error ? error.message : String(error)],
1047
+ },
1048
+ }));
1049
+ }
1050
+ break;
1051
+ }
1052
+ case "test":
1053
+ await testSelected();
1054
+ break;
1055
+ case "ci":
1056
+ setState((prev) => ({
1057
+ ...prev,
1058
+ commandLog: {
1059
+ title: "ci",
1060
+ command: commandLine,
1061
+ ok: true,
1062
+ lines: ["Run this command in a shell for live registry drift checks."],
1063
+ },
1064
+ }));
1065
+ break;
1066
+ case "lock":
1067
+ await writeSelectedLock();
1068
+ break;
1069
+ case "export-config":
1070
+ await saveSelectedConfig();
1071
+ break;
1072
+ case "tui":
1073
+ setState((prev) => ({
1074
+ ...prev,
1075
+ commandLog: { title: "tui", command: commandLine, ok: true, lines: ["You are already in the TUI."] },
1076
+ }));
1077
+ break;
1078
+ case "help":
1079
+ setState((prev) => ({ ...prev, view: "help" }));
1080
+ break;
1081
+ }
1082
+ }
1083
+ function showMoreResults() {
1084
+ setState((prev) => {
1085
+ const nextLimit = nextResultLimit(prev.resultLimit, allResults.length);
1086
+ const atAllMatches = prev.resultLimit >= allResults.length;
1087
+ return {
1088
+ ...prev,
1089
+ resultLimit: nextLimit,
1090
+ commandLog: {
1091
+ title: "results",
1092
+ command: "toolpin tui",
1093
+ ok: true,
1094
+ lines: [
1095
+ atAllMatches ? `already showing all ${allResults.length} matches` : `showing ${Math.min(nextLimit, allResults.length)} / ${allResults.length} matches`,
1096
+ "Use / to edit the search, g to change source, r to refresh listings.",
1097
+ ],
1098
+ },
1099
+ lastAction: atAllMatches ? `showing all ${allResults.length} matches` : `showing ${Math.min(nextLimit, allResults.length)} matches`,
1100
+ };
1101
+ });
1102
+ }
1103
+ function resetViewDefaults() {
1104
+ setState((prev) => ({
1105
+ ...prev,
1106
+ query: "",
1107
+ commandQuery: "",
1108
+ commandSelected: 0,
1109
+ selected: 0,
1110
+ view: "discover",
1111
+ inputMode: "normal",
1112
+ sourceMode: "all",
1113
+ browseVersionMode: "latest",
1114
+ browseSortMode: "source-first",
1115
+ resultLimit: DEFAULT_RESULT_LIMIT,
1116
+ client: "claude",
1117
+ installScope: "project",
1118
+ versionSelections: {},
1119
+ pendingRemove: undefined,
1120
+ deleteConfirm: undefined,
1121
+ commandLog: {
1122
+ title: "reset",
1123
+ command: "toolpin tui",
1124
+ ok: true,
1125
+ lines: ["reset search, source, sort, result count, client, and scope to defaults"],
1126
+ },
1127
+ lastAction: "reset TUI defaults",
1128
+ }));
1129
+ }
1130
+ useInput((input, key) => {
1131
+ const mouse = parseMouse(input);
1132
+ if (mouse?.pressed && handleMouseClick(mouse.x, mouse.y)) {
1133
+ return;
1134
+ }
1135
+ if (key.ctrl && input === "c") {
1136
+ exit();
1137
+ return;
1138
+ }
1139
+ if (state.deleteConfirm) {
1140
+ const confirm = state.deleteConfirm;
1141
+ if (key.escape || input === "n" || input === "N") {
1142
+ setState((prev) => ({ ...prev, deleteConfirm: undefined, lastAction: "delete cancelled" }));
1143
+ return;
1144
+ }
1145
+ if (input === "y" || input === "Y") {
1146
+ setState((prev) => ({ ...prev, deleteConfirm: undefined, lastAction: `deleting ${confirm.serverName}` }));
1147
+ void removeInstalledTarget(confirm);
1148
+ return;
1149
+ }
1150
+ if (key.leftArrow || key.rightArrow || input === "h" || input === "l" || input === "j" || input === "k") {
1151
+ setState((prev) => (prev.deleteConfirm
1152
+ ? {
1153
+ ...prev,
1154
+ deleteConfirm: {
1155
+ ...prev.deleteConfirm,
1156
+ selected: prev.deleteConfirm.selected === "no" ? "yes" : "no",
1157
+ },
1158
+ }
1159
+ : prev));
1160
+ return;
1161
+ }
1162
+ if (key.return) {
1163
+ setState((prev) => ({ ...prev, deleteConfirm: undefined, lastAction: confirm.selected === "yes" ? `deleting ${confirm.serverName}` : "delete cancelled" }));
1164
+ if (confirm.selected === "yes")
1165
+ void removeInstalledTarget(confirm);
1166
+ return;
1167
+ }
1168
+ return;
1169
+ }
1170
+ if (state.installFlow) {
1171
+ const flow = state.installFlow;
1172
+ if (key.escape) {
1173
+ if (flow.step === "installing")
1174
+ return;
1175
+ setState((prev) => ({ ...prev, installFlow: undefined, lastAction: "install cancelled" }));
1176
+ return;
1177
+ }
1178
+ const clientChoices = installClientChoicesForServerScope(flow.scope ?? "project", flow.preferredClient, flow.server);
1179
+ const optionCount = flow.step === "version" ? flow.versions.length : flow.step === "scope" ? 2 : flow.step === "client" ? clientChoices.length : 1;
1180
+ if (key.upArrow || input === "k") {
1181
+ setState((prev) => (prev.installFlow ? { ...prev, installFlow: { ...prev.installFlow, selected: Math.max(0, prev.installFlow.selected - 1) } } : prev));
1182
+ return;
1183
+ }
1184
+ if (key.downArrow || input === "j") {
1185
+ setState((prev) => (prev.installFlow ? { ...prev, installFlow: { ...prev.installFlow, selected: Math.min(optionCount - 1, prev.installFlow.selected + 1) } } : prev));
1186
+ return;
1187
+ }
1188
+ if (key.return) {
1189
+ if (flow.step === "version") {
1190
+ const server = flow.versions[Math.min(flow.selected, flow.versions.length - 1)] ?? flow.server;
1191
+ setState((prev) => (prev.installFlow
1192
+ ? {
1193
+ ...prev,
1194
+ versionSelections: { ...prev.versionSelections, [server.name]: server.version },
1195
+ installFlow: {
1196
+ ...prev.installFlow,
1197
+ step: "scope",
1198
+ server,
1199
+ selected: prev.installScope === "global" ? 1 : 0,
1200
+ },
1201
+ lastAction: `install ${server.name}@${server.version}: choose scope`,
1202
+ }
1203
+ : prev));
1204
+ }
1205
+ else if (flow.step === "scope") {
1206
+ const scope = flow.selected === 1 ? "global" : "project";
1207
+ setState((prev) => (prev.installFlow ? { ...prev, installFlow: { ...prev.installFlow, step: "client", scope, selected: 0 } } : prev));
1208
+ }
1209
+ else if (flow.step === "client") {
1210
+ const scope = flow.scope ?? "project";
1211
+ const client = clientChoices[Math.min(flow.selected, clientChoices.length - 1)] ?? directInstallClientsForServerScope(scope, flow.server)[0] ?? "codex";
1212
+ const clients = client === "all" ? selectedInstallClientsForServerScope("all", scope, flow.server) : selectedInstallClientsForServerScope(client, scope, flow.server);
1213
+ const clientLabel = installClientLabel(client, clients);
1214
+ setState((prev) => (prev.installFlow
1215
+ ? { ...prev, installFlow: { ...prev.installFlow, step: "installing", selected: 0 }, client, installScope: scope }
1216
+ : { ...prev, client, installScope: scope }));
1217
+ void installSelected({ server: flow.server, client, clients, scope, clientLabel });
1218
+ }
1219
+ else if (flow.step === "complete" || flow.step === "failed") {
1220
+ setState((prev) => ({ ...prev, installFlow: undefined }));
1221
+ }
1222
+ return;
1223
+ }
1224
+ return;
1225
+ }
1226
+ if (state.inputMode === "search") {
1227
+ if (key.escape) {
1228
+ setState((prev) => ({
1229
+ ...prev,
1230
+ inputMode: "normal",
1231
+ query: "",
1232
+ selected: 0,
1233
+ view: "discover",
1234
+ pendingRemove: undefined,
1235
+ testResult: undefined,
1236
+ commandLog: undefined,
1237
+ }));
1238
+ return;
1239
+ }
1240
+ if (key.return) {
1241
+ setState((prev) => ({ ...prev, inputMode: "normal", selected: 0, view: "discover", pendingRemove: undefined, testResult: undefined, commandLog: undefined }));
1242
+ if (state.dataMode === "live")
1243
+ void loadData("live", state.query);
1244
+ return;
1245
+ }
1246
+ if (key.backspace || key.delete) {
1247
+ setState((prev) => ({ ...prev, query: prev.query.slice(0, -1), selected: 0, pendingRemove: undefined, testResult: undefined, commandLog: undefined }));
1248
+ return;
1249
+ }
1250
+ if (input && !key.ctrl && !key.meta) {
1251
+ setState((prev) => ({ ...prev, query: prev.query + input, selected: 0, pendingRemove: undefined, testResult: undefined, commandLog: undefined }));
1252
+ }
1253
+ return;
1254
+ }
1255
+ if (state.inputMode === "command") {
1256
+ if (key.escape) {
1257
+ setState((prev) => ({ ...prev, inputMode: "normal", commandQuery: "", commandSelected: 0 }));
1258
+ return;
1259
+ }
1260
+ if (key.return) {
1261
+ if (selectedCommand)
1262
+ void executeCommand(selectedCommand.id);
1263
+ return;
1264
+ }
1265
+ if (key.upArrow || input === "k") {
1266
+ setState((prev) => ({ ...prev, commandSelected: Math.max(0, prev.commandSelected - 1) }));
1267
+ return;
1268
+ }
1269
+ if (key.downArrow || input === "j") {
1270
+ setState((prev) => ({ ...prev, commandSelected: Math.min(Math.max(0, commandResults.length - 1), prev.commandSelected + 1) }));
1271
+ return;
1272
+ }
1273
+ if (key.backspace || key.delete) {
1274
+ setState((prev) => ({ ...prev, commandQuery: prev.commandQuery.slice(0, -1), commandSelected: 0 }));
1275
+ return;
1276
+ }
1277
+ if (input && !key.ctrl && !key.meta) {
1278
+ setState((prev) => ({ ...prev, commandQuery: prev.commandQuery + input, commandSelected: 0 }));
1279
+ }
1280
+ return;
1281
+ }
1282
+ if (state.view === "help") {
1283
+ if (key.escape) {
1284
+ switchToView("discover");
1285
+ return;
1286
+ }
1287
+ if (key.tab) {
1288
+ switchToView(nextEnabledView(state.view));
1289
+ return;
1290
+ }
1291
+ if (input === "q") {
1292
+ exit();
1293
+ return;
1294
+ }
1295
+ if (input === "h" || input === "?") {
1296
+ switchToView("discover");
1297
+ return;
1298
+ }
1299
+ return;
1300
+ }
1301
+ if (key.escape && state.view !== "discover") {
1302
+ switchToView("discover");
1303
+ return;
1304
+ }
1305
+ if (key.tab) {
1306
+ switchToView(nextEnabledView(state.view));
1307
+ return;
1308
+ }
1309
+ if (state.view === "installed" && (key.upArrow || input === "k")) {
1310
+ dispatchInstalled({ type: "move", delta: -1 });
1311
+ return;
1312
+ }
1313
+ if (state.view === "installed" && (key.downArrow || input === "j")) {
1314
+ dispatchInstalled({ type: "move", delta: 1 });
1315
+ return;
1316
+ }
1317
+ if (state.view === "sources" && (key.upArrow || input === "k")) {
1318
+ setState((prev) => ({ ...prev, sourceSelected: Math.max(0, prev.sourceSelected - 1) }));
1319
+ return;
1320
+ }
1321
+ if (state.view === "sources" && (key.downArrow || input === "j")) {
1322
+ setState((prev) => ({ ...prev, sourceSelected: Math.min(Math.max(0, sourceRows(prev.registrySources).length - 1), prev.sourceSelected + 1) }));
1323
+ return;
1324
+ }
1325
+ if (key.upArrow || input === "k") {
1326
+ setState((prev) => ({ ...prev, selected: Math.max(0, prev.selected - 1), pendingRemove: undefined, testResult: undefined, commandLog: undefined }));
1327
+ return;
1328
+ }
1329
+ if (key.downArrow || input === "j") {
1330
+ setState((prev) => ({ ...prev, selected: Math.min(Math.max(0, results.length - 1), prev.selected + 1), pendingRemove: undefined, testResult: undefined, commandLog: undefined }));
1331
+ return;
1332
+ }
1333
+ if (key.return) {
1334
+ if (state.view === "sources") {
1335
+ void toggleSelectedSource();
1336
+ }
1337
+ else if ((state.view === "discover" || state.view === "details") && selectedServer) {
1338
+ switchToView("plan");
1339
+ }
1340
+ else if (state.view === "plan" && selectedServer) {
1341
+ beginInstallFlow();
1342
+ }
1343
+ return;
1344
+ }
1345
+ switch (input) {
1346
+ case "q":
1347
+ exit();
1348
+ break;
1349
+ case "/":
1350
+ setState((prev) => ({ ...prev, inputMode: "search", view: "discover" }));
1351
+ break;
1352
+ case ":":
1353
+ setState((prev) => ({ ...prev, inputMode: "command", commandQuery: "", commandSelected: 0 }));
1354
+ break;
1355
+ case "r":
1356
+ void refreshCache(state.view === "sources" ? "all" : state.sourceMode);
1357
+ break;
1358
+ case "R":
1359
+ if (state.view === "sources")
1360
+ void refreshCache("all");
1361
+ else
1362
+ resetViewDefaults();
1363
+ break;
1364
+ case " ":
1365
+ if (state.view === "sources")
1366
+ void toggleSelectedSource();
1367
+ break;
1368
+ case "b":
1369
+ setState((prev) => ({
1370
+ ...prev,
1371
+ browseVersionMode: prev.browseVersionMode === "latest" ? "all" : "latest",
1372
+ selected: 0,
1373
+ lastAction: `browse ${prev.browseVersionMode === "latest" ? "all cached versions" : "latest servers"}`,
1374
+ }));
1375
+ break;
1376
+ case "a":
1377
+ if (state.view === "discover") {
1378
+ setState((prev) => {
1379
+ const browseSortMode = nextBrowseSortMode(prev.browseSortMode);
1380
+ return {
1381
+ ...prev,
1382
+ browseSortMode,
1383
+ selected: 0,
1384
+ pendingRemove: undefined,
1385
+ testResult: undefined,
1386
+ commandLog: undefined,
1387
+ lastAction: `browse sort ${browseSortLabel(browseSortMode)}`,
1388
+ };
1389
+ });
1390
+ }
1391
+ break;
1392
+ case "I":
1393
+ switchToView("installed");
1394
+ void refreshInstalledRows();
1395
+ break;
1396
+ case "i":
1397
+ if (state.view !== "installed")
1398
+ beginInstallFlow();
1399
+ break;
1400
+ case "m":
1401
+ case "+":
1402
+ showMoreResults();
1403
+ break;
1404
+ case "f":
1405
+ if (state.view === "discover") {
1406
+ setState((prev) => ({
1407
+ ...prev,
1408
+ browseLayout: nextBrowseLayout(prev.browseLayout, hasCategoryMetadata(results)),
1409
+ selected: 0,
1410
+ pendingRemove: undefined,
1411
+ testResult: undefined,
1412
+ commandLog: undefined,
1413
+ }));
1414
+ }
1415
+ break;
1416
+ case "x":
1417
+ if (state.view === "installed") {
1418
+ requestInstalledRemoveConfirmation(selectedInstalled);
1419
+ }
1420
+ else {
1421
+ requestRemoveConfirmation();
1422
+ }
1423
+ break;
1424
+ case "t":
1425
+ if (state.view === "installed") {
1426
+ void testInstalled(selectedInstalled);
1427
+ }
1428
+ else {
1429
+ void testSelected();
1430
+ }
1431
+ break;
1432
+ case "u":
1433
+ if (state.view === "installed")
1434
+ void updateInstalled(selectedInstalled);
1435
+ break;
1436
+ case "U":
1437
+ if (state.view === "installed")
1438
+ void updateAllInstalled();
1439
+ break;
1440
+ case "d":
1441
+ if (state.view === "installed") {
1442
+ void runInstalledDoctor();
1443
+ }
1444
+ break;
1445
+ case "l":
1446
+ void loadData(state.dataMode === "cache" ? "live" : "cache");
1447
+ break;
1448
+ case "g":
1449
+ if (state.view === "installed") {
1450
+ const nextScope = installed.scope === "all" ? "project" : installed.scope === "project" ? "global" : "all";
1451
+ dispatchInstalled({ type: "scope", scope: nextScope });
1452
+ void refreshInstalledRows(state.servers, state.lockfile, nextScope);
1453
+ }
1454
+ else {
1455
+ void loadData(state.dataMode, state.query, nextSource(state.sourceMode, state.registrySources));
1456
+ }
1457
+ break;
1458
+ case "G":
1459
+ setState((prev) => ({
1460
+ ...prev,
1461
+ installScope: prev.installScope === "project" ? "global" : "project",
1462
+ pendingRemove: undefined,
1463
+ lastAction: `install scope ${prev.installScope === "project" ? "global" : "project"}`,
1464
+ }));
1465
+ break;
1466
+ case "v":
1467
+ if (state.view === "installed")
1468
+ cycleInstalledVersion(1);
1469
+ else
1470
+ cycleSelectedVersion(1);
1471
+ break;
1472
+ case "V":
1473
+ if (state.view === "installed")
1474
+ cycleInstalledVersion(-1);
1475
+ else
1476
+ cycleSelectedVersion(-1);
1477
+ break;
1478
+ case "c":
1479
+ setState((prev) => ({ ...prev, client: nextClientForServerScope(prev.client, prev.installScope, selectedServer), pendingRemove: undefined }));
1480
+ break;
1481
+ case "o":
1482
+ setState((prev) => ({ ...prev, client: "opencode", pendingRemove: undefined }));
1483
+ break;
1484
+ case "w":
1485
+ void writeSelectedLock();
1486
+ break;
1487
+ case "s":
1488
+ void saveSelectedConfig();
1489
+ break;
1490
+ case "S":
1491
+ switchToView("sources");
1492
+ break;
1493
+ case "h":
1494
+ case "?":
1495
+ setState((prev) => ({ ...prev, view: prev.view === "help" ? "discover" : "help" }));
1496
+ break;
1497
+ case "1":
1498
+ switchToView("discover");
1499
+ break;
1500
+ case "2":
1501
+ switchToView("installed");
1502
+ break;
1503
+ case "3":
1504
+ switchToView("details");
1505
+ break;
1506
+ case "4":
1507
+ switchToView("plan");
1508
+ break;
1509
+ case "5":
1510
+ switchToView("config");
1511
+ break;
1512
+ case "6":
1513
+ case "7":
1514
+ switchToView("help");
1515
+ break;
1516
+ }
1517
+ });
1518
+ function handleMouseClick(x, y) {
1519
+ if (state.installFlow || state.deleteConfirm)
1520
+ return false;
1521
+ const hit = hitTestTui(x, y, buildTuiHitZones({
1522
+ width,
1523
+ listHeight,
1524
+ selectedIndex: state.view === "installed" ? installed.selected : selectedIndex,
1525
+ resultCount: state.view === "installed" ? installed.rows.length : results.length,
1526
+ hasSelection: Boolean(selectedServer),
1527
+ selectedLabel: selectedServer?.title || selectedServer?.name,
1528
+ listActive: state.inputMode === "normal" && (state.view === "discover" || state.view === "installed"),
1529
+ }));
1530
+ if (hit?.kind === "view") {
1531
+ setState((prev) => switchView(prev, hit.view));
1532
+ return true;
1533
+ }
1534
+ if (hit?.kind === "server") {
1535
+ if (state.view === "installed") {
1536
+ dispatchInstalled({ type: "select", selected: hit.index });
1537
+ }
1538
+ else {
1539
+ setState((prev) => ({ ...prev, selected: hit.index, pendingRemove: undefined, testResult: undefined, commandLog: undefined }));
1540
+ }
1541
+ return true;
1542
+ }
1543
+ return false;
1544
+ }
1545
+ function cycleSelectedVersion(direction) {
1546
+ if (!selectedResult)
1547
+ return;
1548
+ const versions = knownVersions(state.servers, selectedResult.server.name);
1549
+ if (versions.length <= 1) {
1550
+ setState((prev) => ({
1551
+ ...prev,
1552
+ commandLog: {
1553
+ title: "versions",
1554
+ command: commandLineFor("info", prev, selectedServer),
1555
+ ok: true,
1556
+ lines: [`only one known version for ${selectedResult.server.name}: ${selectedResult.server.version}`],
1557
+ },
1558
+ }));
1559
+ return;
1560
+ }
1561
+ const currentVersion = selectedServer?.version ?? selectedResult.server.version;
1562
+ const currentIndex = Math.max(0, versions.findIndex((entry) => entry.version === currentVersion));
1563
+ const nextIndex = (currentIndex + direction + versions.length) % versions.length;
1564
+ const nextVersion = versions[nextIndex]?.version ?? selectedResult.server.version;
1565
+ setState((prev) => ({
1566
+ ...prev,
1567
+ versionSelections: {
1568
+ ...prev.versionSelections,
1569
+ [selectedResult.server.name]: nextVersion,
1570
+ },
1571
+ testResult: undefined,
1572
+ commandLog: {
1573
+ title: "versions",
1574
+ command: commandLineFor("info", prev, selectedServer),
1575
+ ok: true,
1576
+ lines: [
1577
+ `selected ${selectedResult.server.name}@${nextVersion}`,
1578
+ "Install uses the selected version shown in the Install tab.",
1579
+ ],
1580
+ },
1581
+ lastAction: `selected version ${nextVersion}`,
1582
+ }));
1583
+ }
1584
+ function installedVersionServers(row) {
1585
+ if (!row)
1586
+ return [];
1587
+ const targetName = row.updateServer?.name ?? row.installableServer?.name;
1588
+ if (!targetName)
1589
+ return [];
1590
+ return knownVersions(state.servers, targetName)
1591
+ .map((entry) => state.servers.find((candidate) => candidate.name === targetName && candidate.version === entry.version))
1592
+ .filter((server) => Boolean(server?.installable));
1593
+ }
1594
+ function installedTargetServer(row, selectedVersion) {
1595
+ if (!selectedVersion)
1596
+ return row.updateServer;
1597
+ return installedVersionServers(row).find((server) => server.version === selectedVersion);
1598
+ }
1599
+ function cycleInstalledVersion(direction) {
1600
+ const row = selectedInstalled;
1601
+ if (!row)
1602
+ return;
1603
+ if (!row.locked) {
1604
+ setState((prev) => ({
1605
+ ...prev,
1606
+ commandLog: {
1607
+ title: "versions",
1608
+ command: `toolpin update ${row.serverName} --client ${row.client} --scope ${row.scope}`,
1609
+ ok: false,
1610
+ lines: ["Version selection is for locked installed entries. Use u to adopt and lock this entry first."],
1611
+ },
1612
+ }));
1613
+ return;
1614
+ }
1615
+ const versions = installedVersionServers(row);
1616
+ if (versions.length <= 1) {
1617
+ setState((prev) => ({
1618
+ ...prev,
1619
+ commandLog: {
1620
+ title: "versions",
1621
+ command: `toolpin update ${row.serverName} --client ${row.client} --scope ${row.scope}`,
1622
+ ok: true,
1623
+ lines: [`only one known registry version for ${row.serverName}: ${row.lockedVersion ?? row.latestVersion ?? "unknown"}`],
1624
+ },
1625
+ }));
1626
+ return;
1627
+ }
1628
+ const currentVersion = state.installedVersionSelections[row.id] ?? row.updateServer?.version ?? row.lockedVersion ?? versions[0]?.version;
1629
+ const currentIndex = Math.max(0, versions.findIndex((entry) => entry.version === currentVersion));
1630
+ const nextIndex = (currentIndex + direction + versions.length) % versions.length;
1631
+ const nextVersion = versions[nextIndex]?.version ?? currentVersion;
1632
+ setState((prev) => ({
1633
+ ...prev,
1634
+ installedVersionSelections: {
1635
+ ...prev.installedVersionSelections,
1636
+ [row.id]: nextVersion,
1637
+ },
1638
+ commandLog: {
1639
+ title: "versions",
1640
+ command: `toolpin update ${row.serverName} --client ${row.client} --scope ${row.scope} --version ${nextVersion}`,
1641
+ ok: true,
1642
+ lines: [
1643
+ `selected installed target ${row.serverName}@${nextVersion}`,
1644
+ "Press u to rewrite the client config and mcp-lock.json for this explicit version.",
1645
+ ],
1646
+ },
1647
+ lastAction: `selected installed version ${nextVersion}`,
1648
+ }));
1649
+ }
1650
+ function switchToView(view) {
1651
+ if (SERVER_VIEWS.has(view) && !selectedServer)
1652
+ return;
1653
+ setState((prev) => switchView(prev, view));
1654
+ }
1655
+ function nextEnabledView(view) {
1656
+ const order = selectedServer ? ["discover", "installed", "details", "plan", "config", "help"] : ["discover", "installed", "help"];
1657
+ return order[(order.indexOf(view) + 1) % order.length] ?? "discover";
1658
+ }
1659
+ const visibleCommandLog = commandLogForView(state);
1660
+ const activityRows = visibleCommandLog?.lines.length ? Math.min(3, visibleCommandLog.lines.length) : 1;
1661
+ const paneHeight = Math.max(8, height - 15 - activityRows);
1662
+ const listHeight = state.view === "discover" || state.view === "installed" ? paneHeight : Math.min(6, Math.max(3, height - 18 - activityRows));
1663
+ const modalWidth = Math.min(width - 4, 104);
1664
+ const modalContentWidth = Math.max(40, modalWidth - 4);
1665
+ const desiredLeftPaneWidth = Math.max(44, Math.min(88, Math.floor(width * 0.56)));
1666
+ const rightPaneWidth = Math.max(40, width - desiredLeftPaneWidth - 4);
1667
+ const leftPaneWidth = Math.max(34, width - rightPaneWidth - 4);
1668
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(ChromeHeader, { state: state, resultCount: state.view === "installed" ? installed.rows.length : results.length, totalMatches: state.view === "installed" ? installed.rows.length : allResults.length, selectedServer: selectedServer, width: width }), _jsx(PromptBar, { state: state, width: width }), _jsx(ModeLine, { active: state.view, selectedServer: selectedServer, width: width }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: state.inputMode === "command" ? (_jsx(Centered, { width: width, children: _jsx(Box, { width: modalWidth, children: _jsx(CommandPalette, { commands: commandResults, selected: selectedCommandIndex, state: state, selectedServer: selectedServer, width: modalContentWidth }) }) })) : state.view === "help" ? (_jsx(Box, { marginX: 2, height: paneHeight, children: _jsx(HelpView, { width: width - 4, height: paneHeight }) })) : state.view === "sources" ? (_jsx(Box, { marginX: 2, height: paneHeight, children: _jsx(SourcesView, { sources: state.registrySources, entries: state.entries, activeSource: state.sourceMode, selectedSource: state.sourceSelected, dataMode: state.dataMode, width: width - 4, height: paneHeight }) })) : state.view === "installed" ? (_jsxs(Box, { marginX: 2, height: paneHeight, children: [_jsx(InstalledServersView, { rows: installed.rows, selected: installed.selected, height: paneHeight, width: leftPaneWidth, loading: installed.loading }), _jsx(Box, { width: rightPaneWidth, height: paneHeight, flexDirection: "column", children: _jsx(InstalledServerDetails, { row: selectedInstalled, width: rightPaneWidth - 4, selectedVersion: selectedInstalledTargetVersion, selectedTarget: selectedInstalledTarget, runtimeAdvisory: selectedInstalledRuntimeAdvisory }) })] })) : (_jsxs(Box, { marginX: 2, height: paneHeight, children: [_jsx(OptionList, { results: results, totalMatches: allResults.length, totalServers: latestOnly(state.servers).length, totalVersions: state.servers.length, selected: selectedIndex, height: paneHeight, width: leftPaneWidth, query: state.query, loading: state.loading && state.servers.length === 0, browseLayout: browseLayout, browseSortMode: state.browseSortMode, sourceMode: state.sourceMode, dimmed: state.view !== "discover" }), (state.view === "discover" && selectedServer) || SERVER_VIEWS.has(state.view) || state.installFlow ? (_jsx(Box, { width: rightPaneWidth, height: paneHeight, flexDirection: "column", children: state.installFlow ? (_jsx(InstallWizard, { flow: state.installFlow, width: rightPaneWidth - 4, height: paneHeight })) : (_jsx(SelectedServerPanel, { view: state.view, result: selectedResult, server: selectedServer, client: state.client, installScope: state.installScope, width: rightPaneWidth - 4, testResult: state.testResult, testing: state.testing, versionInfo: selectedVersionInfo })) })) : null] })) }), _jsx(OperationModal, { state: state, width: width, height: height }), _jsx(DeleteConfirmModal, { state: state, width: width, height: height }), _jsx(ActivityStrip, { state: state, width: width }), state.error ? _jsxs(Text, { color: ERR, wrap: "truncate", children: [" error: ", truncate(state.error, width - 8)] }) : null, _jsx(Footer, { state: state, width: width })] }));
1669
+ }
1670
+ function sourceRows(sources) {
1671
+ return [...sources].sort(compareRegistrySources);
1672
+ }
1673
+ function registrySourcesWithFetchResult(sources, result) {
1674
+ const results = result.results ?? [result];
1675
+ const bySource = new Map(results.map((entry) => [entry.source.id, entry]));
1676
+ return sources.map((source) => {
1677
+ const fetched = bySource.get(source.id);
1678
+ if (!fetched)
1679
+ return source;
1680
+ return {
1681
+ ...source,
1682
+ status: fetched.status,
1683
+ setupHint: fetched.source.setupHint ?? source.setupHint,
1684
+ cacheEntries: fetched.entries.length,
1685
+ cachePageInfo: fetched.pageInfo,
1686
+ };
1687
+ });
1688
+ }
1689
+ function useTerminalSize(stdout) {
1690
+ const readSize = () => ({
1691
+ width: Math.max(72, stdout.columns ?? 110),
1692
+ height: Math.max(24, stdout.rows ?? 34),
1693
+ });
1694
+ const [size, setSize] = useState(readSize);
1695
+ useEffect(() => {
1696
+ const onResize = () => setSize(readSize());
1697
+ onResize();
1698
+ stdout.on("resize", onResize);
1699
+ return () => {
1700
+ stdout.off("resize", onResize);
1701
+ };
1702
+ }, [stdout]);
1703
+ return size;
1704
+ }
1705
+ function nextBrowseLayout(current, hasCategories) {
1706
+ if (current === "flat")
1707
+ return "project";
1708
+ if (current === "project")
1709
+ return hasCategories ? "category" : "flat";
1710
+ return "flat";
1711
+ }
1712
+ function hasCategoryMetadata(results) {
1713
+ return results.some((result) => {
1714
+ const sourceMeta = result.server.raw._meta?.["dev.toolpin/source"];
1715
+ const category = sourceMeta && typeof sourceMeta === "object" && !Array.isArray(sourceMeta)
1716
+ ? sourceMeta.category
1717
+ : undefined;
1718
+ return typeof category === "string" && category.trim().length > 0;
1719
+ });
1720
+ }
1721
+ function parseMouse(input) {
1722
+ const match = /^\x1b\[<(\d+);(\d+);(\d+)([mM])$/.exec(input);
1723
+ if (!match)
1724
+ return undefined;
1725
+ const button = Number(match[1]);
1726
+ return {
1727
+ x: Number(match[2]),
1728
+ y: Number(match[3]),
1729
+ pressed: match[4] === "M" && button === 0,
1730
+ };
1731
+ }