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.
Files changed (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/key-focus-guard.test.tsx +260 -0
  13. package/src/__tests__/mate-availability.test.ts +11 -13
  14. package/src/__tests__/plugin-contents.test.ts +0 -1
  15. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  16. package/src/__tests__/plugin-row-text.test.ts +18 -21
  17. package/src/__tests__/profile-command.test.ts +2 -2
  18. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  19. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  20. package/src/__tests__/profile-skills.test.ts +3 -3
  21. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  22. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  23. package/src/__tests__/profile-startup.test.ts +1 -1
  24. package/src/__tests__/profile-state.test.ts +27 -22
  25. package/src/__tests__/prompt-eof.test.ts +245 -0
  26. package/src/__tests__/scope-action.test.ts +35 -38
  27. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  28. package/src/__tests__/select-restore.test.ts +209 -0
  29. package/src/__tests__/stale-load-repair.test.ts +1 -2
  30. package/src/__tests__/update-apply.test.ts +18 -2
  31. package/src/__tests__/update-null-readback.test.ts +300 -0
  32. package/src/__tests__/update-plan-parity.test.ts +249 -0
  33. package/src/__tests__/update-plan.test.ts +66 -67
  34. package/src/__tests__/version-unknown-state.test.ts +38 -39
  35. package/src/cli/bootstrap.ts +271 -66
  36. package/src/cli/doctor.ts +4 -3
  37. package/src/cli/install.ts +83 -27
  38. package/src/cli/prompt.ts +19 -5
  39. package/src/cli/router.ts +1 -0
  40. package/src/cli/select.ts +168 -0
  41. package/src/cli/update-view.ts +120 -6
  42. package/src/cli/update.ts +6 -4
  43. package/src/services/claude-settings.ts +28 -0
  44. package/src/services/manifest.ts +31 -0
  45. package/src/services/mate-availability.ts +23 -29
  46. package/src/services/plugin-manager.ts +247 -105
  47. package/src/services/profile-absorb.ts +10 -7
  48. package/src/services/profile-activate.ts +41 -19
  49. package/src/services/profile-adopt.ts +9 -2
  50. package/src/services/profile-startup.ts +206 -5
  51. package/src/services/profile-state.ts +8 -7
  52. package/src/services/tracked-live-files.ts +24 -3
  53. package/src/services/update-engine.ts +28 -10
  54. package/src/services/update-plan.ts +78 -94
  55. package/src/ui/App.tsx +96 -56
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  58. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  59. package/src/ui/hooks/useGitignoreModal.ts +21 -3
  60. package/src/ui/hooks/useProfileStartupModal.ts +214 -15
  61. package/src/ui/hooks/useStaleLoadModal.ts +5 -1
  62. package/src/ui/input/KeyRouter.tsx +43 -2
  63. package/src/ui/keymap.ts +1 -1
  64. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  65. package/src/ui/renderers/pluginRowText.ts +6 -2
  66. package/src/ui/renderers/pluginStatusText.ts +4 -3
  67. package/src/ui/screens/PluginsScreen.tsx +28 -41
  68. 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 nowhere. */
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: enabled in a settings file, but no registry row in any scope. */
87
- | "enabled-not-installed"
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
- /** Scopes holding an install this action must touch. Empty for a fresh install. */
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 highestInstalled}: nothing caps it, and
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 highestInstalled(info);
200
- const permitted = scopeVersions(info)
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 scopeVersions(info)
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
- * installed or enabled, but published by no marketplace we read — into the same
259
- * list with `isOrphaned: true`, so a plain `has(id)` is true for a plugin that
260
- * nothing publishes. Treating an orphan as listed is what let an orphan plan as
261
- * `current`: its `version` is filled in from its own installed version, so the
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 = highestInstalled(info);
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(scopeVersions(info)) as Partial<
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
- // Not installed anywhere — the "checked but not installed" case.
358
+ // No installed scope has a version — the "checked but not installed" case.
392
359
  //
393
- // Nothing is installed in any scope here, so "enabled somewhere" is exactly
394
- // "enabled but not installed". It is tested directly rather than through
395
- // `isEnabledButNotInstalled`, which excludes orphans: an orphan that is only
396
- // a settings key would otherwise read as `absent`, and the TUI renders
397
- // `absent` as "the profile declares it", which an undeclared orphan does not.
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 brokenEnable = enabledAnywhere(info);
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: brokenEnable ? "enabled-not-installed" : "absent",
405
- scopes: [],
406
- ...(brokenEnable
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: scopeVersions(info).map(([scope]) => scope),
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
- * Decide what to do with every plugin the profile declares.
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, keyed by plugin id
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: Map<string, PluginInfo>,
661
+ pinned: Readonly<Record<string, string>>,
662
+ catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
680
663
  ): PluginUpdateItem[] {
681
664
  const index = indexCatalog(catalog);
682
- return Object.entries(pinned)
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 or enabled
693
- * in one — what the TUI's own update actions have always planned with, so
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 neither installed
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 (scopeVersions(info).length > 0 || enabledAnywhere(info)) return "latest";
700
+ if (isInstalledAnywhere(info)) return "latest";
709
701
  return null;
710
702
  }
711
703
 
712
704
  /**
713
- * One status per row that has a pin. Rows whose {@link pinForRow} is null are
714
- * absent from the map.
715
- *
716
- * Computed once per data/pins change, and the same map feeds every consumer —
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
- const index = indexCatalog(plugins);
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(onExit, checkGitignoreNow);
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
- // Show gitignore modal — wait for the modal slot to be free so we don't
328
- // stomp on another modal that may have just been shown.
329
- useEffect(() => {
330
- if (!gitignoreData) return;
331
- if (state.modal) return;
332
- gitignoreModal.show({
333
- violationCount: gitignoreData.violationCount,
334
- safeCount: gitignoreData.safeCount,
335
- generated: gitignoreData.generated,
336
- // In place, then back to the app: tracked generated files are
337
- // untracked, missing entries added; other items stay for the tab.
338
- onFixSafe: async () =>
339
- describeStartupFix(await applyStartupFixes(state.projectPath)),
340
- });
341
- setGitignoreData(null);
342
- }, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
343
-
344
- // Show the broken-plugin modal — same "wait for the modal slot" rule as the
345
- // gitignore one above, so two startup findings queue instead of one stomping
346
- // the other.
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
- staleLoadModal.show({
351
- brokenCount: brokenPluginCount,
352
- // Re-surveys before repairing, like the gitignore fixer: the registry may
353
- // have moved between the startup scan and the user pressing Enter, and
354
- // repairing from the stale count would act on rows that are no longer
355
- // there. `repairAllStaleLoads` is the same `repairStaleLoad` the Plugins
356
- // screen's `a` key drives through the apply engine.
357
- //
358
- // The pins are re-read with it, from the same helper the survey used: a
359
- // repair that capped at a different ceiling than the offer was counted
360
- // against would move rows the dialog never mentioned.
361
- onFix: async () =>
362
- staleLoadRepairSummary(
363
- await repairAllStaleLoads(
364
- state.projectPath,
365
- undefined,
366
- await activePluginPins(state.projectPath),
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
- setBrokenPluginCount(null);
371
- }, [brokenPluginCount, staleLoadModal, state.modal, state.projectPath]);
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(process.cwd());
431
- if (notices.length > 0) setProfileNotices(notices);
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, installed, nor enabled): it has no state to claim.
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
- /** Installed at that scope. */
11
+ /** On at that scope. */
12
12
  export const SCOPE_ON = "■";
13
- /** Not installed at that scope. */
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: `■` installed, `□` not.
21
- * That reads with no colour at all, which the bar never used to: every square
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
- * (installed is what it means, on every screen); a hollow one wears `muted`,
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
- dispatch({ type: "HIDE_MODAL" });
102
- void Promise.resolve(args.onFixSafe())
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
- if (!report) return;
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: {