magus-cli 7.5.3 → 7.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/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__/key-focus-guard.test.tsx +260 -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-name-on-save.test.ts +443 -0
- 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-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- 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 +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -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 +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- 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 +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- 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
|
@@ -34,7 +34,11 @@ import type { PluginScope } from "./claude-cli.js";
|
|
|
34
34
|
import {
|
|
35
35
|
type PluginInfo,
|
|
36
36
|
compareVersions,
|
|
37
|
+
filesMissingScopes,
|
|
38
|
+
highestInstalledVersion,
|
|
39
|
+
installedScopeVersions,
|
|
37
40
|
isBehind,
|
|
41
|
+
isInstalledAnywhere,
|
|
38
42
|
isRepairablyBehind,
|
|
39
43
|
} from "./plugin-manager.js";
|
|
40
44
|
import {
|
|
@@ -48,7 +52,7 @@ import {
|
|
|
48
52
|
// ── Plugins ──────────────────────────────────────────────────────────────────
|
|
49
53
|
|
|
50
54
|
export type PluginUpdateAction =
|
|
51
|
-
/** Declared by the profile, installed
|
|
55
|
+
/** Declared by the profile and installed nowhere, or installed with its files missing. */
|
|
52
56
|
| "install"
|
|
53
57
|
/** Installed at a version other than the one we should be on. */
|
|
54
58
|
| "update"
|
|
@@ -83,8 +87,8 @@ export type PluginUpdateAction =
|
|
|
83
87
|
export type PluginReason =
|
|
84
88
|
/** install: declared by the profile, installed nowhere. */
|
|
85
89
|
| "absent"
|
|
86
|
-
/** install:
|
|
87
|
-
| "
|
|
90
|
+
/** install: installed at a scope, but no version resolves at any installed scope. */
|
|
91
|
+
| "files-missing"
|
|
88
92
|
/** unknown: a marketplace we READ does not list it. */
|
|
89
93
|
| "not-offered"
|
|
90
94
|
/** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
|
|
@@ -127,7 +131,11 @@ export interface PluginUpdateItem {
|
|
|
127
131
|
action: PluginUpdateAction;
|
|
128
132
|
/** Why it is in this state. */
|
|
129
133
|
reason: PluginReason;
|
|
130
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Scopes holding an install this action must touch — for `files-missing`, the
|
|
136
|
+
* scopes installed with no version. Empty for an `absent` install, which the
|
|
137
|
+
* engine puts at project scope.
|
|
138
|
+
*/
|
|
131
139
|
scopes: PluginScope[];
|
|
132
140
|
/**
|
|
133
141
|
* The version installed in each scope BEFORE any work, so an apply can tell
|
|
@@ -141,44 +149,12 @@ export interface PluginUpdateItem {
|
|
|
141
149
|
note?: string;
|
|
142
150
|
}
|
|
143
151
|
|
|
144
|
-
/** Scope → installed version, for the three scopes a PluginInfo carries. */
|
|
145
|
-
function scopeVersions(
|
|
146
|
-
info: PluginInfo | undefined,
|
|
147
|
-
): Array<[PluginScope, string]> {
|
|
148
|
-
if (!info) return [];
|
|
149
|
-
const out: Array<[PluginScope, string]> = [];
|
|
150
|
-
for (const [scope, status] of [
|
|
151
|
-
["user", info.userScope],
|
|
152
|
-
["project", info.projectScope],
|
|
153
|
-
["local", info.localScope],
|
|
154
|
-
] as const) {
|
|
155
|
-
if (status?.version) out.push([scope, status.version]);
|
|
156
|
-
}
|
|
157
|
-
return out;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The highest version installed across scopes, or null.
|
|
162
|
-
*
|
|
163
|
-
* `compareVersions` answers null for an unorderable pair, and null is not
|
|
164
|
-
* `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
|
|
165
|
-
* ranked. "Highest" is not meaningful across commit ids; picking one
|
|
166
|
-
* deterministically is, and every caller only needs *an* installed version.
|
|
167
|
-
*/
|
|
168
|
-
function highestInstalled(info: PluginInfo | undefined): string | null {
|
|
169
|
-
const versions = scopeVersions(info).map(([, v]) => v);
|
|
170
|
-
if (versions.length === 0) return null;
|
|
171
|
-
return versions.reduce((a, b) =>
|
|
172
|
-
(compareVersions(a, b) ?? -1) >= 0 ? a : b,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
152
|
/**
|
|
177
153
|
* The highest installed version THE PIN PERMITS — the ceiling a stale-load
|
|
178
154
|
* repair may aim at. Null when no installed version qualifies.
|
|
179
155
|
*
|
|
180
|
-
* Under "latest" that is simply {@link
|
|
181
|
-
* the newest version on this machine is the one to converge on.
|
|
156
|
+
* Under "latest" that is simply {@link highestInstalledVersion}: nothing caps
|
|
157
|
+
* it, and the newest version on this machine is the one to converge on.
|
|
182
158
|
*
|
|
183
159
|
* Under an EXACT pin it is the highest installed version at or below the pin.
|
|
184
160
|
* `installed` reaches across every scope and `scopesNeeding` never downgrades, so
|
|
@@ -196,8 +172,8 @@ function highestPermittedInstalled(
|
|
|
196
172
|
info: PluginInfo | undefined,
|
|
197
173
|
pin: string,
|
|
198
174
|
): string | null {
|
|
199
|
-
if (pin === "latest") return
|
|
200
|
-
const permitted =
|
|
175
|
+
if (pin === "latest") return highestInstalledVersion(info);
|
|
176
|
+
const permitted = installedScopeVersions(info)
|
|
201
177
|
.map(([, v]) => v)
|
|
202
178
|
// pin >= v, i.e. v is at or below the pin. Null (unorderable) fails this.
|
|
203
179
|
.filter((v) => (compareVersions(pin, v) ?? -1) >= 0);
|
|
@@ -238,7 +214,7 @@ export function scopesNeeding(
|
|
|
238
214
|
// A raw `compareVersions(...) > 0` reported false for that pair and the
|
|
239
215
|
// scope was silently left alone. An unknown (null) is still left alone —
|
|
240
216
|
// moving a scope we cannot rank would be a guess.
|
|
241
|
-
return
|
|
217
|
+
return installedScopeVersions(info)
|
|
242
218
|
.filter(([, version]) => isBehind(target, version) === true)
|
|
243
219
|
.map(([scope]) => scope);
|
|
244
220
|
}
|
|
@@ -255,11 +231,11 @@ function marketplaceSuffix(id: string): string | null {
|
|
|
255
231
|
* The catalog, indexed for the questions the classifier asks of it.
|
|
256
232
|
*
|
|
257
233
|
* "Listed" is narrower than "present". `getAvailablePlugins` puts ORPHANS —
|
|
258
|
-
*
|
|
259
|
-
* list with `isOrphaned: true`, so a plain `has(id)` is
|
|
260
|
-
* nothing publishes. Treating an orphan as listed is
|
|
261
|
-
* `current`: its `version` is filled in from its own
|
|
262
|
-
* compare found them equal.
|
|
234
|
+
* in this project's settings or registry, but published by no marketplace we
|
|
235
|
+
* read — into the same list with `isOrphaned: true`, so a plain `has(id)` is
|
|
236
|
+
* true for a plugin that nothing publishes. Treating an orphan as listed is
|
|
237
|
+
* what let an orphan plan as `current`: its `version` is filled in from its own
|
|
238
|
+
* installed version, so the compare found them equal.
|
|
263
239
|
*/
|
|
264
240
|
export interface CatalogIndex {
|
|
265
241
|
get(pluginId: string): PluginInfo | undefined;
|
|
@@ -306,15 +282,6 @@ export function indexCatalog(
|
|
|
306
282
|
};
|
|
307
283
|
}
|
|
308
284
|
|
|
309
|
-
/** True when a settings file enables the plugin in at least one scope. */
|
|
310
|
-
function enabledAnywhere(info: PluginInfo | undefined): boolean {
|
|
311
|
-
return (
|
|
312
|
-
!!info?.userScope?.enabled ||
|
|
313
|
-
!!info?.projectScope?.enabled ||
|
|
314
|
-
!!info?.localScope?.enabled
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
285
|
/**
|
|
319
286
|
* THE plugin classifier. Pure.
|
|
320
287
|
*
|
|
@@ -332,7 +299,7 @@ export function classifyPlugin(
|
|
|
332
299
|
): PluginUpdateItem {
|
|
333
300
|
const info = index.get(pluginId);
|
|
334
301
|
const listed = index.isListed(pluginId);
|
|
335
|
-
const installed =
|
|
302
|
+
const installed = highestInstalledVersion(info);
|
|
336
303
|
// An orphan's `version` is its own installed version (or a stale local
|
|
337
304
|
// clone's), not something a marketplace we read offers — so it is not
|
|
338
305
|
// "available" from anywhere.
|
|
@@ -344,7 +311,7 @@ export function classifyPlugin(
|
|
|
344
311
|
installed,
|
|
345
312
|
loaded: info?.loadedVersion ?? null,
|
|
346
313
|
available,
|
|
347
|
-
beforeVersions: Object.fromEntries(
|
|
314
|
+
beforeVersions: Object.fromEntries(installedScopeVersions(info)) as Partial<
|
|
348
315
|
Record<PluginScope, string>
|
|
349
316
|
>,
|
|
350
317
|
};
|
|
@@ -388,24 +355,29 @@ export function classifyPlugin(
|
|
|
388
355
|
};
|
|
389
356
|
}
|
|
390
357
|
|
|
391
|
-
//
|
|
358
|
+
// No installed scope has a version — the "checked but not installed" case.
|
|
392
359
|
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
360
|
+
// So a scope that IS installed here has its files missing. It is tested
|
|
361
|
+
// directly rather than through `hasMissingFiles`, which excludes orphans: an
|
|
362
|
+
// orphan that is only a settings key would otherwise read as `absent`, and the
|
|
363
|
+
// TUI renders `absent` as "the profile declares it", which an undeclared
|
|
364
|
+
// orphan does not.
|
|
365
|
+
//
|
|
366
|
+
// Files missing carries the scopes to reinstall at: exactly the ones installed
|
|
367
|
+
// with no version. It used to carry none, and the engine read "install" as
|
|
368
|
+
// "project scope" — so a user-scope plugin with its files missing was
|
|
369
|
+
// reinstalled at project scope, turning it on in the team's settings.json.
|
|
370
|
+
// `absent` still carries none: the engine installs it at project scope.
|
|
398
371
|
if (!installed) {
|
|
399
|
-
const
|
|
372
|
+
const missingAt = filesMissingScopes(info);
|
|
373
|
+
const filesMissing = missingAt.length > 0;
|
|
400
374
|
return {
|
|
401
375
|
...base,
|
|
402
376
|
target: pin === "latest" ? available : pin,
|
|
403
377
|
action: "install",
|
|
404
|
-
reason:
|
|
405
|
-
scopes:
|
|
406
|
-
...(
|
|
407
|
-
? { note: "enabled in settings, but no files on disk" }
|
|
408
|
-
: {}),
|
|
378
|
+
reason: filesMissing ? "files-missing" : "absent",
|
|
379
|
+
scopes: missingAt,
|
|
380
|
+
...(filesMissing ? { note: "files missing" } : {}),
|
|
409
381
|
};
|
|
410
382
|
}
|
|
411
383
|
|
|
@@ -622,7 +594,7 @@ export function classifyPlugin(
|
|
|
622
594
|
target: installed,
|
|
623
595
|
action: "repair",
|
|
624
596
|
reason: "content-stale",
|
|
625
|
-
scopes:
|
|
597
|
+
scopes: installedScopeVersions(info).map(([scope]) => scope),
|
|
626
598
|
note: "files changed without a version bump",
|
|
627
599
|
};
|
|
628
600
|
}
|
|
@@ -669,17 +641,38 @@ export function classifyPlugin(
|
|
|
669
641
|
}
|
|
670
642
|
|
|
671
643
|
/**
|
|
672
|
-
*
|
|
644
|
+
* THE plugin plan — for `magus update` and the Plugins screen alike.
|
|
645
|
+
*
|
|
646
|
+
* Every plugin the active profile declares, under its pin, and every plugin
|
|
647
|
+
* installed in any scope (user, project, local), under "latest" — the rule
|
|
648
|
+
* {@link pinForRow} states — each classified by {@link classifyPlugin}, sorted.
|
|
649
|
+
*
|
|
650
|
+
* ONE list, read by both surfaces: the screen's rows, header counts and
|
|
651
|
+
* "update all", and the CLI's report and apply. It used to be two — the CLI
|
|
652
|
+
* planned the profile's plugins only, the screen every installed plugin — so a
|
|
653
|
+
* USER-scope plugin the screen showed as "update available" was never seen by
|
|
654
|
+
* `magus update`, which then reported everything up to date. The owner asked
|
|
655
|
+
* for "absolutely the same code" on both sides; this is it.
|
|
673
656
|
*
|
|
674
657
|
* @param pinned closure.plugins — pluginId -> exact version | "latest"
|
|
675
|
-
* @param catalog getAvailablePlugins() output
|
|
658
|
+
* @param catalog getAvailablePlugins() output — keyed by id, or as a list
|
|
676
659
|
*/
|
|
677
660
|
export function planPluginUpdates(
|
|
678
|
-
pinned: Record<string, string
|
|
679
|
-
catalog:
|
|
661
|
+
pinned: Readonly<Record<string, string>>,
|
|
662
|
+
catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
|
|
680
663
|
): PluginUpdateItem[] {
|
|
681
664
|
const index = indexCatalog(catalog);
|
|
682
|
-
|
|
665
|
+
const plugins =
|
|
666
|
+
catalog instanceof Map
|
|
667
|
+
? [...catalog.values()]
|
|
668
|
+
: (catalog as readonly PluginInfo[]);
|
|
669
|
+
const pins = new Map(Object.entries(pinned));
|
|
670
|
+
for (const info of plugins) {
|
|
671
|
+
if (pins.has(info.id)) continue;
|
|
672
|
+
const pin = pinForRow(info, pinned);
|
|
673
|
+
if (pin !== null) pins.set(info.id, pin);
|
|
674
|
+
}
|
|
675
|
+
return [...pins]
|
|
683
676
|
.map(([pluginId, pin]) => classifyPlugin(pluginId, pin, index))
|
|
684
677
|
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
685
678
|
}
|
|
@@ -689,45 +682,36 @@ export function planPluginUpdates(
|
|
|
689
682
|
*
|
|
690
683
|
* 1. The active profile's pin, when it declares the plugin — the SAME value
|
|
691
684
|
* `magus update` plans with, which is what makes the two agree.
|
|
692
|
-
* 2. Otherwise "latest", when the plugin is installed in any scope
|
|
693
|
-
*
|
|
694
|
-
* the row and the action agree.
|
|
685
|
+
* 2. Otherwise "latest", when the plugin is installed in any scope, files
|
|
686
|
+
* missing included — what the TUI's own update actions have always planned
|
|
687
|
+
* with, so the row and the action agree.
|
|
695
688
|
* 3. Otherwise null: an undeclared catalog entry the user is only browsing. It
|
|
696
689
|
* gets no status at all. `install` means "the profile wants it", and an
|
|
697
690
|
* undeclared plugin carries no such claim.
|
|
698
691
|
*
|
|
699
692
|
* A consequence the renderer relies on: reason `absent` can only come from
|
|
700
|
-
* rule 1, because rule 2 never assigns a pin to a row that is
|
|
701
|
-
* nor enabled.
|
|
693
|
+
* rule 1, because rule 2 never assigns a pin to a row that is not installed.
|
|
702
694
|
*/
|
|
703
695
|
export function pinForRow(
|
|
704
696
|
info: PluginInfo,
|
|
705
697
|
pins: Readonly<Record<string, string>>,
|
|
706
698
|
): string | null {
|
|
707
699
|
if (Object.hasOwn(pins, info.id)) return pins[info.id] ?? null;
|
|
708
|
-
if (
|
|
700
|
+
if (isInstalledAnywhere(info)) return "latest";
|
|
709
701
|
return null;
|
|
710
702
|
}
|
|
711
703
|
|
|
712
704
|
/**
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
* so the row, the detail panel, the header and the scope picker cannot disagree.
|
|
705
|
+
* {@link planPluginUpdates} keyed by plugin id, for the screen's lookups — the
|
|
706
|
+
* SAME plan `magus update` runs, not a second classification. Computed once
|
|
707
|
+
* per data/pins change, and the one map feeds the row, the detail panel, the
|
|
708
|
+
* header and the scope picker, so none of them can disagree.
|
|
718
709
|
*/
|
|
719
710
|
export function classifyPluginRows(
|
|
720
711
|
plugins: readonly PluginInfo[],
|
|
721
712
|
pins: Readonly<Record<string, string>>,
|
|
722
713
|
): Map<string, PluginUpdateItem> {
|
|
723
|
-
|
|
724
|
-
const out = new Map<string, PluginUpdateItem>();
|
|
725
|
-
for (const info of plugins) {
|
|
726
|
-
const pin = pinForRow(info, pins);
|
|
727
|
-
if (pin === null) continue;
|
|
728
|
-
out.set(info.id, classifyPlugin(info.id, pin, index));
|
|
729
|
-
}
|
|
730
|
-
return out;
|
|
714
|
+
return new Map(planPluginUpdates(pins, plugins).map((i) => [i.pluginId, i]));
|
|
731
715
|
}
|
|
732
716
|
|
|
733
717
|
export interface PluginStateCounts {
|
package/src/ui/App.tsx
CHANGED
|
@@ -287,9 +287,15 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
287
287
|
// non-fatal: the Git State tab still shows the same rows
|
|
288
288
|
});
|
|
289
289
|
}, []);
|
|
290
|
+
// Stable, so `profileModal.show` is not rebuilt for it on every render.
|
|
291
|
+
const profileAnswered = React.useCallback(() => setProfileNeed(null), []);
|
|
290
292
|
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
291
293
|
// only non-action is to leave.
|
|
292
|
-
const profileModal = useProfileStartupModal(
|
|
294
|
+
const profileModal = useProfileStartupModal({
|
|
295
|
+
onQuit: onExit,
|
|
296
|
+
onActivated: checkGitignoreNow,
|
|
297
|
+
onAnswered: profileAnswered,
|
|
298
|
+
});
|
|
293
299
|
|
|
294
300
|
// Check for updates on startup, then re-check periodically (force-bypassing
|
|
295
301
|
// the session cache) so the banner reflects a release that lands while
|
|
@@ -324,51 +330,91 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
324
330
|
return () => clearTimeout(timer);
|
|
325
331
|
}, [recoveryReport]);
|
|
326
332
|
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
333
|
+
// The startup questions — gitignore, then broken plugins, then profile — are
|
|
334
|
+
// asked ONE at a time, from ONE effect, and only while the modal slot is free.
|
|
335
|
+
//
|
|
336
|
+
// They used to be three effects, each checking `state.modal` from the same
|
|
337
|
+
// render. Two needs arriving in one commit both saw an empty slot, both
|
|
338
|
+
// showed, and the later SHOW_MODAL replaced the earlier question after its
|
|
339
|
+
// need had already been cleared — so it never came back. The profile question
|
|
340
|
+
// was lost exactly that way, and the user updated plugins believing a profile
|
|
341
|
+
// was active. So: show at most one per run and return; the next question
|
|
342
|
+
// follows once that one's last modal is dismissed and the slot is empty
|
|
343
|
+
// again.
|
|
344
|
+
//
|
|
345
|
+
// The hooks keep the slot occupied for their whole flow (a loading modal
|
|
346
|
+
// while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
|
|
347
|
+
// what lets the next question in. That does not stop a SCREEN from emptying
|
|
348
|
+
// it: `useModal().confirm` hides its modal when answered and the screen shows
|
|
349
|
+
// its loading modal a microtask later. A key pressed before the profile check
|
|
350
|
+
// resolved lands the profile question in exactly that gap, and the screen's
|
|
351
|
+
// loading modal then replaces it.
|
|
352
|
+
//
|
|
353
|
+
// So the two kinds of question are cleared at different points.
|
|
354
|
+
//
|
|
355
|
+
// - The profile need is cleared when it is ANSWERED (`profileAnswered`, from
|
|
356
|
+
// the hook: a profile activated, or quit), never when shown. The modal
|
|
357
|
+
// cannot be dismissed, so an empty slot while the need is still set means
|
|
358
|
+
// it was lost, and this effect puts it back. The hook keeps what was last
|
|
359
|
+
// asked and whether work is in flight, so a re-show re-asks the question
|
|
360
|
+
// that was on screen and never starts a second activation.
|
|
361
|
+
// - The gitignore and broken-plugin needs are still cleared on show. Both
|
|
362
|
+
// can be dismissed, so an empty slot does not mean they were lost —
|
|
363
|
+
// re-showing would re-ask a question the user just declined. Losing one
|
|
364
|
+
// costs nothing lasting: the Git State and Plugins tabs show the same rows,
|
|
365
|
+
// and gitignore is asked again after a profile activates.
|
|
347
366
|
useEffect(() => {
|
|
348
|
-
if (brokenPluginCount === null) return;
|
|
349
367
|
if (state.modal) return;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
368
|
+
if (gitignoreData) {
|
|
369
|
+
gitignoreModal.show({
|
|
370
|
+
violationCount: gitignoreData.violationCount,
|
|
371
|
+
safeCount: gitignoreData.safeCount,
|
|
372
|
+
generated: gitignoreData.generated,
|
|
373
|
+
// In place, then back to the app: tracked generated files are
|
|
374
|
+
// untracked, missing entries added; other items stay for the tab.
|
|
375
|
+
onFixSafe: async () =>
|
|
376
|
+
describeStartupFix(await applyStartupFixes(state.projectPath)),
|
|
377
|
+
});
|
|
378
|
+
setGitignoreData(null);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (brokenPluginCount !== null) {
|
|
382
|
+
staleLoadModal.show({
|
|
383
|
+
brokenCount: brokenPluginCount,
|
|
384
|
+
// Re-surveys before repairing, like the gitignore fixer: the registry
|
|
385
|
+
// may have moved between the startup scan and the user pressing Enter,
|
|
386
|
+
// and repairing from the stale count would act on rows that are no
|
|
387
|
+
// longer there. `repairAllStaleLoads` is the same `repairStaleLoad` the
|
|
388
|
+
// Plugins screen's `a` key drives through the apply engine.
|
|
389
|
+
//
|
|
390
|
+
// The pins are re-read with it, from the same helper the survey used: a
|
|
391
|
+
// repair that capped at a different ceiling than the offer was counted
|
|
392
|
+
// against would move rows the dialog never mentioned.
|
|
393
|
+
onFix: async () =>
|
|
394
|
+
staleLoadRepairSummary(
|
|
395
|
+
await repairAllStaleLoads(
|
|
396
|
+
state.projectPath,
|
|
397
|
+
undefined,
|
|
398
|
+
await activePluginPins(state.projectPath),
|
|
399
|
+
),
|
|
367
400
|
),
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
401
|
+
});
|
|
402
|
+
setBrokenPluginCount(null);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Not cleared here — see above. Re-called on every empty slot until the
|
|
406
|
+
// hook reports it answered.
|
|
407
|
+
if (profileNeed) profileModal.show(profileNeed);
|
|
408
|
+
}, [
|
|
409
|
+
state.modal,
|
|
410
|
+
state.projectPath,
|
|
411
|
+
gitignoreData,
|
|
412
|
+
gitignoreModal,
|
|
413
|
+
brokenPluginCount,
|
|
414
|
+
staleLoadModal,
|
|
415
|
+
profileNeed,
|
|
416
|
+
profileModal,
|
|
417
|
+
]);
|
|
372
418
|
|
|
373
419
|
// Does this project load an older plugin than it installed? Detection only;
|
|
374
420
|
// nothing is written until the user chooses Fix.
|
|
@@ -393,17 +439,6 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
393
439
|
})();
|
|
394
440
|
}, []);
|
|
395
441
|
|
|
396
|
-
// Show the profile modal on the same terms as the gitignore one: only once
|
|
397
|
-
// the modal slot is free, so two startup checks that both found something do
|
|
398
|
-
// not stomp on each other. Gitignore is asked first simply because its
|
|
399
|
-
// effect is declared first; whichever the user dismisses, the other follows.
|
|
400
|
-
useEffect(() => {
|
|
401
|
-
if (!profileNeed) return;
|
|
402
|
-
if (state.modal) return;
|
|
403
|
-
profileModal.show(profileNeed);
|
|
404
|
-
setProfileNeed(null);
|
|
405
|
-
}, [profileNeed, profileModal, state.modal]);
|
|
406
|
-
|
|
407
442
|
// The gitignore question on every open. Its own effect, NOT part of the
|
|
408
443
|
// marketplace block below, which returns early on `--no-refresh`: that flag
|
|
409
444
|
// is about network calls and must not silence this question.
|
|
@@ -427,8 +462,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
427
462
|
// edits into the active profile, regenerate from a changed
|
|
428
463
|
// profiles.json, restore a lost marker — then the question, if one
|
|
429
464
|
// is left to ask.
|
|
430
|
-
const { need, notices } = await prepareProfileStartup(
|
|
431
|
-
|
|
465
|
+
const { need, notices, problems } = await prepareProfileStartup(
|
|
466
|
+
process.cwd(),
|
|
467
|
+
);
|
|
468
|
+
// A refusal never blocks the TUI at startup: it is shown beside what
|
|
469
|
+
// the gate did, and the user is still asked whatever is left.
|
|
470
|
+
const shown = [...notices, ...problems];
|
|
471
|
+
if (shown.length > 0) setProfileNotices(shown);
|
|
432
472
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
433
473
|
} catch {
|
|
434
474
|
// non-fatal: the Profiles tab still works by hand
|
|
@@ -43,7 +43,7 @@ export interface PluginPluginItem {
|
|
|
43
43
|
/**
|
|
44
44
|
* The classifier's answer for this plugin — the same item `magus update`
|
|
45
45
|
* plans with. Null for a catalog entry the user is only browsing (neither
|
|
46
|
-
* declared by the profile
|
|
46
|
+
* declared by the profile nor installed): it has no state to claim.
|
|
47
47
|
*/
|
|
48
48
|
status: PluginUpdateItem | null;
|
|
49
49
|
}
|
|
@@ -34,6 +34,9 @@ const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
|
|
|
34
34
|
* question that must be answered).
|
|
35
35
|
* - input: Esc cancels; everything else goes to the focused <input>, which
|
|
36
36
|
* OpenTUI delivers after this layer and which submits on Enter.
|
|
37
|
+
* Only an <input> that was ALREADY focused when the key arrived:
|
|
38
|
+
* the Enter that opens an input modal from a select never reaches
|
|
39
|
+
* it (KeyRouter's `dispatchThenGuardFocus`).
|
|
37
40
|
* - message: any key dismisses.
|
|
38
41
|
* - loading: keys are swallowed until the work finishes.
|
|
39
42
|
* - help: ↑↓ / PgUp / PgDn / Ctrl+U / Ctrl+D scroll; Esc or `?` close.
|
|
@@ -8,23 +8,28 @@ interface ScopeSquaresProps {
|
|
|
8
8
|
selected?: boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/** On at that scope. */
|
|
12
12
|
export const SCOPE_ON = "■";
|
|
13
|
-
/** Not
|
|
13
|
+
/** Not on at that scope. */
|
|
14
14
|
export const SCOPE_OFF = "□";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Scope bar: three positional squares, user / project / local.
|
|
18
18
|
*
|
|
19
19
|
* POSITION names the scope — the first square is always user, the second
|
|
20
|
-
* project, the third local — and SHAPE says the state: `■`
|
|
21
|
-
*
|
|
20
|
+
* project, the third local — and SHAPE says the state: `■` on, `□` not. "On"
|
|
21
|
+
* is the caller's to decide, and for a plugin and a skill alike it is
|
|
22
|
+
* installed. For a plugin that is `isInstalledInScope` — the scope's settings
|
|
23
|
+
* file turns it on, which is what Claude Code loads by — the same function the
|
|
24
|
+
* detail panel's dot reads, so the two cannot disagree.
|
|
25
|
+
*
|
|
26
|
+
* The shape reads with no colour at all, which the bar never used to: every square
|
|
22
27
|
* was the same filled block, lit in a scope hue or unlit in a fill near the
|
|
23
28
|
* page, so on a monochrome terminal, to a colour-blind reader, or on a theme
|
|
24
29
|
* that drew two hues alike, all three states were one shape.
|
|
25
30
|
*
|
|
26
31
|
* Colour now only repeats what the shape says. A filled square wears `success`
|
|
27
|
-
* (
|
|
32
|
+
* (on is what it means, on every screen); a hollow one wears `muted`,
|
|
28
33
|
* which a thin outline needs to stay visible — the shape, not a pale fill, is
|
|
29
34
|
* what makes it recede. On a selected row the selection owns the colour
|
|
30
35
|
* channel: filled squares take its ink, hollow ones its dim ink.
|
|
@@ -98,10 +98,28 @@ export function useGitignoreModal() {
|
|
|
98
98
|
safeCount: args.safeCount,
|
|
99
99
|
generated: args.generated,
|
|
100
100
|
onFixSafe: () => {
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
// A loading modal, NOT HIDE_MODAL. The fix is async, and an empty
|
|
102
|
+
// modal slot is App's signal that the next startup question may be
|
|
103
|
+
// asked: hiding here let the profile question show during the await,
|
|
104
|
+
// be marked as asked, and then be replaced by "Gitignore fixed" —
|
|
105
|
+
// lost for the whole session. The slot stays occupied until the
|
|
106
|
+
// result below is dismissed.
|
|
107
|
+
dispatch({
|
|
108
|
+
type: "SHOW_MODAL",
|
|
109
|
+
modal: { type: "loading", message: "Fixing gitignore…" },
|
|
110
|
+
});
|
|
111
|
+
// Called inside `.then`, not passed to `Promise.resolve`: a fixer
|
|
112
|
+
// that throws synchronously must land in `.catch` below too, or
|
|
113
|
+
// the loading modal above stays up and swallows every key.
|
|
114
|
+
void Promise.resolve()
|
|
115
|
+
.then(() => args.onFixSafe())
|
|
103
116
|
.then((report) => {
|
|
104
|
-
|
|
117
|
+
// Nothing to report still has to clear the loading modal: it
|
|
118
|
+
// swallows every key, so leaving it would hang the app.
|
|
119
|
+
if (!report) {
|
|
120
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
105
123
|
dispatch({
|
|
106
124
|
type: "SHOW_MODAL",
|
|
107
125
|
modal: {
|