magus-cli 7.5.3 → 7.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +39 -18
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -6
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +7 -2
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useProfileStartupModal.ts +87 -7
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -2,8 +2,11 @@ import type React from "react";
|
|
|
2
2
|
import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
|
|
3
3
|
import {
|
|
4
4
|
compareVersions,
|
|
5
|
-
|
|
5
|
+
hasMissingFiles,
|
|
6
|
+
isInstalledAnywhere,
|
|
6
7
|
isInstalledInScope,
|
|
8
|
+
scopeFilesMissing,
|
|
9
|
+
versionInScope,
|
|
7
10
|
} from "../../services/plugin-manager.js";
|
|
8
11
|
import type { ScopeStatus } from "../../services/plugin-manager.js";
|
|
9
12
|
import type { PluginComponentKind } from "../../services/skills-manager.js";
|
|
@@ -195,9 +198,12 @@ function pluginRow(
|
|
|
195
198
|
isSelected: boolean,
|
|
196
199
|
): React.ReactNode {
|
|
197
200
|
const { plugin } = item;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
// The squares and the name colour read the one installed-at-scope test, the
|
|
202
|
+
// same one the detail panel's dots read, so the row and the panel beside it
|
|
203
|
+
// cannot show two different states.
|
|
204
|
+
const hasUser = isInstalledInScope(plugin.userScope);
|
|
205
|
+
const hasProject = isInstalledInScope(plugin.projectScope);
|
|
206
|
+
const hasLocal = isInstalledInScope(plugin.localScope);
|
|
201
207
|
const hasAnyScope = hasUser || hasProject || hasLocal;
|
|
202
208
|
|
|
203
209
|
// Everything after the name — version, history badge, status — comes from
|
|
@@ -257,24 +263,13 @@ function pluginRow(
|
|
|
257
263
|
);
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
const installedSomewhere =
|
|
261
|
-
isInstalledInScope(plugin.userScope) ||
|
|
262
|
-
isInstalledInScope(plugin.projectScope) ||
|
|
263
|
-
isInstalledInScope(plugin.localScope);
|
|
264
|
-
|
|
265
266
|
return (
|
|
266
267
|
<text fg={theme.colors.text}>
|
|
267
268
|
<span> </span>
|
|
268
269
|
<ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
|
|
269
270
|
<span> </span>
|
|
270
271
|
<span fg={theme.colors.dim}>{arrow}</span>
|
|
271
|
-
<span
|
|
272
|
-
fg={
|
|
273
|
-
hasAnyScope && installedSomewhere
|
|
274
|
-
? theme.colors.text
|
|
275
|
-
: theme.colors.muted
|
|
276
|
-
}
|
|
277
|
-
>
|
|
272
|
+
<span fg={hasAnyScope ? theme.colors.text : theme.colors.muted}>
|
|
278
273
|
{displayName}
|
|
279
274
|
</span>
|
|
280
275
|
<RowSegments segments={segments} selected={false} />
|
|
@@ -344,14 +339,10 @@ function pluginDetail(
|
|
|
344
339
|
fetched?: PluginContentsEntry,
|
|
345
340
|
): React.ReactNode {
|
|
346
341
|
const { plugin, status } = item;
|
|
347
|
-
// "Installed"
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
isInstalledInScope(plugin.userScope) ||
|
|
352
|
-
isInstalledInScope(plugin.projectScope) ||
|
|
353
|
-
isInstalledInScope(plugin.localScope);
|
|
354
|
-
const brokenInstall = isEnabledButNotInstalled(plugin);
|
|
342
|
+
// "Installed" means the same thing here as in the list row: the one
|
|
343
|
+
// installed-at-scope test, at any scope.
|
|
344
|
+
const isInstalled = isInstalledAnywhere(plugin);
|
|
345
|
+
const filesMissing = hasMissingFiles(plugin);
|
|
355
346
|
// The ⬆ marker and the U hint claim that pressing U does something. That is
|
|
356
347
|
// exactly the classifier's update/repair, and nothing else.
|
|
357
348
|
const needsWork = status?.action === "update" || status?.action === "repair";
|
|
@@ -453,25 +444,25 @@ function pluginDetail(
|
|
|
453
444
|
<box marginTop={1}>
|
|
454
445
|
<text
|
|
455
446
|
fg={
|
|
456
|
-
|
|
457
|
-
? theme.colors.
|
|
458
|
-
:
|
|
459
|
-
? theme.colors.
|
|
447
|
+
filesMissing
|
|
448
|
+
? theme.colors.danger
|
|
449
|
+
: isInstalled
|
|
450
|
+
? theme.colors.success
|
|
460
451
|
: theme.colors.muted
|
|
461
452
|
}
|
|
462
453
|
>
|
|
463
|
-
{
|
|
464
|
-
? "● Installed"
|
|
465
|
-
:
|
|
466
|
-
? "
|
|
454
|
+
{filesMissing
|
|
455
|
+
? "● Installed — files missing"
|
|
456
|
+
: isInstalled
|
|
457
|
+
? "● Installed"
|
|
467
458
|
: "○ Not installed"}
|
|
468
459
|
</text>
|
|
469
460
|
</box>
|
|
470
|
-
{
|
|
461
|
+
{filesMissing ? (
|
|
471
462
|
<box>
|
|
472
463
|
<text fg={theme.colors.muted}>
|
|
473
464
|
Claude Code cannot load it in this state. Press the scope key to
|
|
474
|
-
|
|
465
|
+
reinstall.
|
|
475
466
|
</text>
|
|
476
467
|
</box>
|
|
477
468
|
) : null}
|
|
@@ -631,10 +622,15 @@ function pluginDetail(
|
|
|
631
622
|
/**
|
|
632
623
|
* One line of the detail panel's scope breakdown.
|
|
633
624
|
*
|
|
634
|
-
* The filled dot means
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
625
|
+
* The filled dot means what the list row's square means — installed at this
|
|
626
|
+
* scope — because both read `isInstalledInScope`. The version shows only where
|
|
627
|
+
* the plugin is installed: a registry row can be inherited from another
|
|
628
|
+
* directory of the same repository, and magus run in `tools/magus` once showed
|
|
629
|
+
* "Project team v0.8.1" for a plugin that directory's settings never turn on and
|
|
630
|
+
* Claude Code started there never loads.
|
|
631
|
+
*
|
|
632
|
+
* Installed with no version is named, in the danger colour, because it needs an
|
|
633
|
+
* action — the scope key reinstalls — and a plain dot would read as healthy.
|
|
638
634
|
*/
|
|
639
635
|
function ScopeLine({
|
|
640
636
|
keyHint,
|
|
@@ -647,18 +643,16 @@ function ScopeLine({
|
|
|
647
643
|
qualifier: string;
|
|
648
644
|
scope?: ScopeStatus;
|
|
649
645
|
}): React.ReactNode {
|
|
650
|
-
const
|
|
646
|
+
const version = versionInScope(scope);
|
|
651
647
|
return (
|
|
652
648
|
<text fg={theme.colors.text}>
|
|
653
649
|
<ScopeKeyChip scopeKey={keyHint} />
|
|
654
|
-
<ScopeDot installed={
|
|
650
|
+
<ScopeDot installed={isInstalledInScope(scope)} />
|
|
655
651
|
<span>{name}</span>
|
|
656
652
|
<span fg={theme.colors.muted}> {qualifier}</span>
|
|
657
|
-
{
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
{!installed && scope?.enabled ? (
|
|
661
|
-
<span fg={theme.colors.danger}> enabled, not installed</span>
|
|
653
|
+
{version ? <span fg={theme.colors.info}> v{version}</span> : null}
|
|
654
|
+
{scopeFilesMissing(scope) ? (
|
|
655
|
+
<span fg={theme.colors.danger}> files missing</span>
|
|
662
656
|
) : null}
|
|
663
657
|
</text>
|
|
664
658
|
);
|
|
@@ -38,8 +38,12 @@ export function pluginRowText(item: PluginPluginItem): PluginRowText {
|
|
|
38
38
|
// A version is shown only when it carries information. "0.0.0" means
|
|
39
39
|
// installed with an unknown version (Anthropic's official plugins record
|
|
40
40
|
// exactly that) — it is never printed, and nothing below depends on it.
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
//
|
|
42
|
+
// The classifier's version and nothing else. A fallback to
|
|
43
|
+
// `plugin.installedVersion` printed a version where the classifier had none —
|
|
44
|
+
// "v0.8.1 files missing", with 0.8.1 read off a scope not installed here.
|
|
45
|
+
const installed = status?.installed ?? undefined;
|
|
46
|
+
const shown = isKnownVersion(installed) ? installed : undefined;
|
|
43
47
|
|
|
44
48
|
// "What just changed" and "what state it is in now" are independent facts
|
|
45
49
|
// and both can be true at once: an external update can move a plugin
|
|
@@ -57,13 +57,14 @@ export function pluginStatusText(
|
|
|
57
57
|
return { suffix: " BROKEN — press a to fix", tone: "danger" };
|
|
58
58
|
case "install":
|
|
59
59
|
// `absent` only ever comes from a profile pin (see `pinForRow`), so it
|
|
60
|
-
// may say so. An undeclared row
|
|
60
|
+
// may say so. An undeclared row whose files are missing must not.
|
|
61
61
|
return status.reason === "absent"
|
|
62
62
|
? { suffix: " not installed — profile declares it", tone: "warning" }
|
|
63
|
-
: //
|
|
63
|
+
: // Files missing is installed, and broken — never "not installed",
|
|
64
|
+
// which would contradict the filled square beside it. A missing
|
|
64
65
|
// declared plugin is routine work. Red against amber is the
|
|
65
66
|
// distinction that matters.
|
|
66
|
-
{ suffix: `
|
|
67
|
+
{ suffix: ` ${status.note ?? "files missing"}`, tone: "danger" };
|
|
67
68
|
case "current":
|
|
68
69
|
return { suffix: withNote, tone: "muted" };
|
|
69
70
|
}
|
|
@@ -26,9 +26,12 @@ import {
|
|
|
26
26
|
getAvailablePlugins,
|
|
27
27
|
getLocalMarketplacesInfo,
|
|
28
28
|
getMarketplaceFetchFailures,
|
|
29
|
+
isInstalledAnywhere,
|
|
29
30
|
isInstalledInScope,
|
|
30
31
|
refreshAllMarketplaces,
|
|
31
32
|
resolveScopeAction,
|
|
33
|
+
scopeFilesMissing,
|
|
34
|
+
versionInScope,
|
|
32
35
|
} from "../../services/plugin-manager.js";
|
|
33
36
|
import {
|
|
34
37
|
getPluginEnvRequirements,
|
|
@@ -871,17 +874,16 @@ export function PluginsScreen() {
|
|
|
871
874
|
scope: ScopeStatus | undefined,
|
|
872
875
|
desc: string,
|
|
873
876
|
) => {
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
let label = installed ? `● ${name}` : `○ ${name}`;
|
|
877
|
+
const ver = versionInScope(scope);
|
|
878
|
+
let label = isInstalledInScope(scope) ? `● ${name}` : `○ ${name}`;
|
|
877
879
|
label += ` (${desc})`;
|
|
878
880
|
if (ver) label += ` v${ver}`;
|
|
879
881
|
if (scopeIsBehind(status, scopeKey) && status?.target)
|
|
880
882
|
label += ` → v${status.target}`;
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
if (
|
|
883
|
+
// Installed with no version. Without saying so the row reads like a
|
|
884
|
+
// healthy install, and the user cannot tell that picking it
|
|
885
|
+
// reinstalls rather than uninstalls.
|
|
886
|
+
if (scopeFilesMissing(scope)) label += " — files missing, reinstalls";
|
|
885
887
|
return label;
|
|
886
888
|
};
|
|
887
889
|
|
|
@@ -930,9 +932,9 @@ export function PluginsScreen() {
|
|
|
930
932
|
? "Project"
|
|
931
933
|
: "Local";
|
|
932
934
|
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
935
|
+
// Files missing reinstalls rather than uninstalls — see
|
|
936
|
+
// resolveScopeAction. Update-versus-uninstall is the same
|
|
937
|
+
// `scopeIsBehind` the label above used.
|
|
936
938
|
const action = resolveScopeAction(
|
|
937
939
|
selectedScope,
|
|
938
940
|
scopeIsBehind(status, scopeValue as PluginScope),
|
|
@@ -1060,10 +1062,12 @@ export function PluginsScreen() {
|
|
|
1060
1062
|
};
|
|
1061
1063
|
|
|
1062
1064
|
/**
|
|
1063
|
-
* "Update all" — the SAME
|
|
1064
|
-
* update` runs
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1065
|
+
* "Update all" — the SAME plan, the SAME selection and the SAME apply engine
|
|
1066
|
+
* `magus update` runs: every item of {@link planPluginUpdates} handed to
|
|
1067
|
+
* `applyPlugins`, which does the work each one needs (advance, repair,
|
|
1068
|
+
* reinstall missing files) and skips the rest. There used to be a different
|
|
1069
|
+
* selection here — "rows that say update" against the CLI's "what the profile
|
|
1070
|
+
* declares" — and the two surfaces disagreed about the same machine.
|
|
1067
1071
|
*
|
|
1068
1072
|
* This used to be a hand-rolled loop, and it had drifted from the CLI in
|
|
1069
1073
|
* three ways that were bugs, not choices — it touched one scope derived from
|
|
@@ -1074,20 +1078,11 @@ export function PluginsScreen() {
|
|
|
1074
1078
|
const handleUpdateAll = async () => {
|
|
1075
1079
|
if (pluginsState.plugins.status !== "success") return;
|
|
1076
1080
|
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
// the machine with silently outdated files. Broken (`stale-load`) rows are
|
|
1081
|
-
// included because the row itself now names this key — leaving them out
|
|
1082
|
-
// would make that hint a lie. `install` stays out: a plugin nothing has
|
|
1083
|
-
// installed is `s`'s business, per the note above.
|
|
1081
|
+
// Every item that needs work, by the same `pluginNeedsWork` the CLI's
|
|
1082
|
+
// apply uses: updates, content repairs, broken loads, missing files, and a
|
|
1083
|
+
// profile plugin not installed yet — exactly what `magus update` would do.
|
|
1084
1084
|
const items = [...statuses.values()]
|
|
1085
|
-
.filter(
|
|
1086
|
-
(s) =>
|
|
1087
|
-
s.action === "update" ||
|
|
1088
|
-
s.action === "repair" ||
|
|
1089
|
-
s.action === "stale-load",
|
|
1090
|
-
)
|
|
1085
|
+
.filter(pluginNeedsWork)
|
|
1091
1086
|
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
1092
1087
|
if (items.length === 0) return;
|
|
1093
1088
|
|
|
@@ -1155,12 +1150,10 @@ export function PluginsScreen() {
|
|
|
1155
1150
|
? plugin.projectScope
|
|
1156
1151
|
: plugin.localScope;
|
|
1157
1152
|
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
// uninstall is the classifier's `scopeIsBehind`, the same rule the Enter
|
|
1163
|
-
// scope picker labels with.
|
|
1153
|
+
// A scope whose files are missing answers its key with a reinstall. It once
|
|
1154
|
+
// answered with an uninstall, removing the plugin the row was reporting as
|
|
1155
|
+
// broken. Update-versus-uninstall is the classifier's `scopeIsBehind`, the
|
|
1156
|
+
// same rule the Enter scope picker labels with.
|
|
1164
1157
|
const action = resolveScopeAction(
|
|
1165
1158
|
scopeData,
|
|
1166
1159
|
scopeIsBehind(statuses.get(plugin.id), scope),
|
|
@@ -1355,14 +1348,8 @@ export function PluginsScreen() {
|
|
|
1355
1348
|
|
|
1356
1349
|
const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
|
|
1357
1350
|
const plugins: PluginInfo[] = shownPlugins ?? [];
|
|
1358
|
-
// "installed"
|
|
1359
|
-
//
|
|
1360
|
-
// on a screen whose every row said "not installed". Same predicate as the
|
|
1361
|
-
// row and the detail panel now.
|
|
1362
|
-
const isInstalledAnywhere = (p: PluginInfo) =>
|
|
1363
|
-
isInstalledInScope(p.userScope) ||
|
|
1364
|
-
isInstalledInScope(p.projectScope) ||
|
|
1365
|
-
isInstalledInScope(p.localScope);
|
|
1351
|
+
// "installed" means the same thing here as in the rows beside it: the one
|
|
1352
|
+
// installed-at-scope test the row and the detail panel read.
|
|
1366
1353
|
const installedCount = plugins.filter(isInstalledAnywhere).length;
|
|
1367
1354
|
// Every state count is the classifier's, over the same map the rows read,
|
|
1368
1355
|
// so a header can never count a row the list beside it does not show.
|
package/src/cli/tui-prompt.tsx
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One OpenTUI dialog, for a command that is otherwise not a TUI.
|
|
3
|
-
*
|
|
4
|
-
* `magus update` prints a report and exits; it is not the interactive app. But
|
|
5
|
-
* a question it must ask deserves the same dialog the TUI asks with, so this
|
|
6
|
-
* mounts the REAL `SelectModal` — the component tab 5 and tab 0 use — for the
|
|
7
|
-
* length of one question, then tears the renderer down and lets the command
|
|
8
|
-
* carry on printing. Nothing here re-draws a list, a border or a highlight: the
|
|
9
|
-
* component and the theme own all of that, and a second implementation is how
|
|
10
|
-
* the two surfaces drift apart.
|
|
11
|
-
*
|
|
12
|
-
* Every import of `@opentui/*` and `ui/` is DYNAMIC. `main.tsx` splits the TUI
|
|
13
|
-
* out precisely so `magus hook …`, which Claude Code runs on every matching
|
|
14
|
-
* tool call, never pays to load the renderer — a static import here would undo
|
|
15
|
-
* that for every command that imports this module's file, whether or not it
|
|
16
|
-
* ever asks a question.
|
|
17
|
-
*
|
|
18
|
-
* `screenMode: "main-screen"` keeps the dialog in the scrollback beside the
|
|
19
|
-
* report. The alternate screen would take the terminal over and then hand back
|
|
20
|
-
* a screen with no trace the question was ever asked.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import type { SelectOption } from "../ui/state/types.js";
|
|
24
|
-
|
|
25
|
-
export interface SelectDialogInput {
|
|
26
|
-
title: string;
|
|
27
|
-
message: string;
|
|
28
|
-
options: SelectOption[];
|
|
29
|
-
/** Row to start on. Defaults to the first. */
|
|
30
|
-
defaultIndex?: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Ask the user to pick one option. Resolves to the chosen value, or null when
|
|
35
|
-
* they cancel with Esc or Ctrl+C — the caller decides whether that is fatal.
|
|
36
|
-
*
|
|
37
|
-
* Falls back to `null` when stdin or stdout is not a TTY. A renderer needs both:
|
|
38
|
-
* `magus update > file` has a TTY stdin and a pipe for stdout, and mounting
|
|
39
|
-
* there writes escape sequences into the file.
|
|
40
|
-
*/
|
|
41
|
-
export async function selectDialog(
|
|
42
|
-
input: SelectDialogInput,
|
|
43
|
-
): Promise<string | null> {
|
|
44
|
-
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const [{ createCliRenderer }, { createRoot }, React, modals, dimensions] =
|
|
49
|
-
await Promise.all([
|
|
50
|
-
import("@opentui/core"),
|
|
51
|
-
import("@opentui/react"),
|
|
52
|
-
import("react"),
|
|
53
|
-
import("../ui/components/modals/index.js"),
|
|
54
|
-
import("../ui/state/DimensionsContext.js"),
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
|
-
const renderer = await createCliRenderer({
|
|
58
|
-
screenMode: "main-screen",
|
|
59
|
-
// BOTH, so the renderer registers no handler of its own and teardown
|
|
60
|
-
// below is the only path. With either left at its default, a Ctrl+C
|
|
61
|
-
// destroys the renderer underneath this promise and it never settles.
|
|
62
|
-
exitSignals: [],
|
|
63
|
-
exitOnCtrlC: false,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
const root = createRoot(renderer);
|
|
67
|
-
|
|
68
|
-
return await new Promise<string | null>((resolve) => {
|
|
69
|
-
// Clamped, not trusted: a caller computing an index from a list it built
|
|
70
|
-
// separately could hand over one that is out of range, and a cursor
|
|
71
|
-
// pointing past the end makes Enter resolve to null.
|
|
72
|
-
let index = Math.min(
|
|
73
|
-
Math.max(0, input.defaultIndex ?? 0),
|
|
74
|
-
Math.max(0, input.options.length - 1),
|
|
75
|
-
);
|
|
76
|
-
let done = false;
|
|
77
|
-
|
|
78
|
-
const finish = (value: string | null): void => {
|
|
79
|
-
if (done) return;
|
|
80
|
-
done = true;
|
|
81
|
-
renderer.keyInput.off("keypress", onKey);
|
|
82
|
-
root.unmount();
|
|
83
|
-
renderer.destroy();
|
|
84
|
-
resolve(value);
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
function onKey(key: {
|
|
88
|
-
name?: string;
|
|
89
|
-
ctrl?: boolean;
|
|
90
|
-
sequence?: string;
|
|
91
|
-
}): void {
|
|
92
|
-
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
93
|
-
finish(null);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
if (key.name === "return" || key.name === "enter") {
|
|
97
|
-
finish(input.options[index]?.value ?? null);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
if (key.name === "up" || key.name === "k") {
|
|
101
|
-
index = (index - 1 + input.options.length) % input.options.length;
|
|
102
|
-
draw();
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
if (key.name === "down" || key.name === "j") {
|
|
106
|
-
index = (index + 1) % input.options.length;
|
|
107
|
-
draw();
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
// The numbers stay live: the list is short, and the plain-text prompt
|
|
111
|
-
// this replaced numbered it, so the habit already exists.
|
|
112
|
-
const digit = Number(key.sequence);
|
|
113
|
-
if (
|
|
114
|
-
Number.isInteger(digit) &&
|
|
115
|
-
digit >= 1 &&
|
|
116
|
-
digit <= input.options.length
|
|
117
|
-
) {
|
|
118
|
-
index = digit - 1;
|
|
119
|
-
finish(input.options[index]?.value ?? null);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const draw = (): void => {
|
|
124
|
-
root.render(
|
|
125
|
-
React.createElement(
|
|
126
|
-
dimensions.DimensionsProvider,
|
|
127
|
-
null,
|
|
128
|
-
React.createElement(modals.SelectModal, {
|
|
129
|
-
title: input.title,
|
|
130
|
-
message: input.message,
|
|
131
|
-
options: input.options,
|
|
132
|
-
defaultIndex: index,
|
|
133
|
-
onSelect: finish,
|
|
134
|
-
onCancel: () => finish(null),
|
|
135
|
-
}),
|
|
136
|
-
),
|
|
137
|
-
);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
renderer.keyInput.on("keypress", onKey);
|
|
141
|
-
draw();
|
|
142
|
-
});
|
|
143
|
-
}
|