magus-cli 7.1.0 → 7.3.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 (48) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
  3. package/src/__tests__/doctor.test.ts +31 -0
  4. package/src/__tests__/plugin-resolution.test.ts +431 -0
  5. package/src/__tests__/plugin-row-text.test.ts +66 -0
  6. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  7. package/src/__tests__/profile-migration.test.ts +242 -0
  8. package/src/__tests__/profile-startup.test.ts +458 -0
  9. package/src/__tests__/stale-load-repair.test.ts +1076 -0
  10. package/src/__tests__/update-apply.test.ts +146 -1
  11. package/src/__tests__/update-plan.test.ts +338 -0
  12. package/src/__tests__/useStaleLoadModal.test.ts +177 -0
  13. package/src/cli/ansi.ts +39 -0
  14. package/src/cli/bootstrap.ts +169 -3
  15. package/src/cli/doctor.ts +128 -2
  16. package/src/cli/install.ts +5 -1
  17. package/src/cli/profile.ts +5 -1
  18. package/src/cli/prompt.ts +44 -12
  19. package/src/cli/tui-prompt.tsx +143 -0
  20. package/src/cli/update-view.ts +32 -1
  21. package/src/cli/update.ts +50 -7
  22. package/src/main.tsx +10 -1
  23. package/src/services/active-closure.ts +23 -0
  24. package/src/services/claude-settings.ts +645 -34
  25. package/src/services/manifest.ts +21 -0
  26. package/src/services/plugin-manager.ts +61 -0
  27. package/src/services/plugin-resolution.ts +280 -0
  28. package/src/services/profile-activate.ts +147 -0
  29. package/src/services/profile-adopt.ts +50 -4
  30. package/src/services/profile-startup.ts +311 -0
  31. package/src/services/profiles.ts +49 -11
  32. package/src/services/symlink-manager.ts +184 -30
  33. package/src/services/update-engine.ts +104 -2
  34. package/src/services/update-plan.ts +188 -1
  35. package/src/types/index.ts +44 -23
  36. package/src/ui/App.tsx +123 -0
  37. package/src/ui/components/layout/FooterHints.tsx +21 -9
  38. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  39. package/src/ui/components/modals/SelectModal.tsx +142 -21
  40. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  41. package/src/ui/hooks/useStaleLoadModal.ts +157 -0
  42. package/src/ui/renderers/pluginRenderers.tsx +29 -0
  43. package/src/ui/renderers/pluginStatusText.ts +14 -0
  44. package/src/ui/renderers/profileRenderers.tsx +27 -4
  45. package/src/ui/screens/PluginsScreen.tsx +46 -13
  46. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  47. package/src/ui/state/types.ts +18 -0
  48. package/src/ui/theme-mode.ts +54 -0
@@ -34,6 +34,7 @@ import {
34
34
  type PluginInfo,
35
35
  compareVersions,
36
36
  isBehind,
37
+ isRepairablyBehind,
37
38
  } from "./plugin-manager.js";
38
39
  import {
39
40
  type ToolFacts,
@@ -54,6 +55,23 @@ export type PluginUpdateAction =
54
55
  | "repair"
55
56
  /** Already on the right version. */
56
57
  | "current"
58
+ /**
59
+ * BROKEN: the right version is installed, but Claude Code's loader will
60
+ * resolve an OLDER one — so the session runs stale code while every version
61
+ * magus records looks correct.
62
+ *
63
+ * A REINSTALL DOES NOT FIX IT (measured). `claude plugin install` writes a
64
+ * row for this project, and the row that wins belongs to another one — often
65
+ * a worktree that no longer exists, whose registry entry nothing prunes.
66
+ *
67
+ * It IS applied, by `repairStaleLoad` (services/claude-settings.ts), which
68
+ * moves those rows forward onto the installed version rather than reinstalling
69
+ * anything. `target` is therefore the version installed here — the one whose
70
+ * cache directory exists — and not what the catalog offers. `scopes` stays
71
+ * empty because this is not a per-scope install: one registry rewrite covers
72
+ * every row, whichever scope or project each belongs to.
73
+ */
74
+ | "stale-load"
57
75
  /** The catalog check failed; we cannot claim either way. */
58
76
  | "unknown";
59
77
 
@@ -78,6 +96,8 @@ export type PluginReason =
78
96
  | "content-stale"
79
97
  /** update: a scope is provably behind. */
80
98
  | "behind"
99
+ /** stale-load: the loader resolves an older version than the one installed. */
100
+ | "older-version-loads"
81
101
  /** current|update: an exact pin differs from what the marketplace offers. */
82
102
  | "pin-undeliverable"
83
103
  /** current: every installed scope is at (or ahead of) the target. */
@@ -89,6 +109,16 @@ export interface PluginUpdateItem {
89
109
  pinned: string;
90
110
  /** Highest version installed in any scope, or null when absent. */
91
111
  installed: string | null;
112
+ /**
113
+ * The version Claude Code's loader will ACTUALLY load in this project, or
114
+ * null when it will load nothing (and when the registry was unreadable).
115
+ *
116
+ * A separate column from `installed` because they answer different questions
117
+ * and routinely disagree — see services/plugin-resolution.ts. `installed` is
118
+ * what the settings file and this project's registry rows record; `loaded` is
119
+ * what runs.
120
+ */
121
+ loaded: string | null;
92
122
  /** Version the marketplace catalog offers, or null when unknown. */
93
123
  available: string | null;
94
124
  /** Version this item would move to. Null for `current`/`unknown`. */
@@ -142,6 +172,40 @@ function highestInstalled(info: PluginInfo | undefined): string | null {
142
172
  );
143
173
  }
144
174
 
175
+ /**
176
+ * The highest installed version THE PIN PERMITS — the ceiling a stale-load
177
+ * repair may aim at. Null when no installed version qualifies.
178
+ *
179
+ * Under "latest" that is simply {@link highestInstalled}: nothing caps it, and
180
+ * the newest version on this machine is the one to converge on.
181
+ *
182
+ * Under an EXACT pin it is the highest installed version at or below the pin.
183
+ * `installed` reaches across every scope and `scopesNeeding` never downgrades, so
184
+ * a scope sitting above an exact pin is a normal, permanent state — not something
185
+ * to plan toward. Comparing the stale-load branch against the uncapped
186
+ * `installed` made a plugin loading exactly its pinned version read as BROKEN,
187
+ * and the repair then moved the pinned rows off the pin.
188
+ *
189
+ * A version that cannot be ORDERED against the pin (a commit sha, the literal
190
+ * "unknown") is excluded rather than assumed to qualify: "cannot tell" must never
191
+ * license a registry rewrite, which is the same rule {@link isRepairablyBehind}
192
+ * applies one step later.
193
+ */
194
+ function highestPermittedInstalled(
195
+ info: PluginInfo | undefined,
196
+ pin: string,
197
+ ): string | null {
198
+ if (pin === "latest") return highestInstalled(info);
199
+ const permitted = scopeVersions(info)
200
+ .map(([, v]) => v)
201
+ // pin >= v, i.e. v is at or below the pin. Null (unorderable) fails this.
202
+ .filter((v) => (compareVersions(pin, v) ?? -1) >= 0);
203
+ if (permitted.length === 0) return null;
204
+ return permitted.reduce((a, b) =>
205
+ (compareVersions(a, b) ?? -1) >= 0 ? a : b,
206
+ );
207
+ }
208
+
145
209
  /**
146
210
  * Scopes that are strictly BEHIND `target`.
147
211
  *
@@ -277,6 +341,7 @@ export function classifyPlugin(
277
341
  pluginId,
278
342
  pinned: pin,
279
343
  installed,
344
+ loaded: info?.loadedVersion ?? null,
280
345
  available,
281
346
  beforeVersions: Object.fromEntries(scopeVersions(info)) as Partial<
282
347
  Record<PluginScope, string>
@@ -375,6 +440,112 @@ export function classifyPlugin(
375
440
  };
376
441
  }
377
442
 
443
+ // Installed and listed — but the loader will resolve an OLDER version than
444
+ // what is installed here, so this session runs stale code.
445
+ //
446
+ // Decided BEFORE every branch below, and that ordering is the whole fix.
447
+ // Those branches compare the REGISTERED version against the catalog, and the
448
+ // registered version is precisely the one that is not loading: a plugin at
449
+ // the newest version in every scope classified `current` while Claude Code
450
+ // loaded a two-versions-old copy. The report was green about code that was
451
+ // not running — which is worse than no report, because magus is the tool
452
+ // relied on to stay current.
453
+ //
454
+ // `loaded` is the loader's own answer (services/plugin-resolution.ts), not
455
+ // magus's registry resolution. Null means we could not establish it, and
456
+ // "cannot tell" never becomes a finding here — the plugin falls through to
457
+ // the ordinary comparisons below.
458
+ //
459
+ // `target` is what is INSTALLED here, never `available`. The repair points
460
+ // registry rows at a cache directory, and only the installed version's cache
461
+ // is on disk — aiming at the catalog version would name a directory that does
462
+ // not exist, which `repairStaleLoad` refuses. When the install is also behind
463
+ // the catalog, the next run plans the ordinary `update` on top, so the two
464
+ // steps converge instead of competing.
465
+ //
466
+ // `scopes` stays empty: one registry rewrite covers every row, so there is no
467
+ // per-scope work to enumerate. `pluginNeedsWork` says so directly.
468
+ //
469
+ // `isRepairablyBehind`, NOT `isBehind` — the one predicate `findStaleLoads`
470
+ // asks too, so this header's count and the startup popup's count are the same
471
+ // question. `isBehind` calls two differing commit shas "behind", which is the
472
+ // right answer to ITS question and the wrong one to this: moving rows forward
473
+ // between two unordered shas is a guess, not a repair. That difference alone
474
+ // made the popup claim 8 broken plugins where this header said 6.
475
+ //
476
+ // INSTALLED against LOADED — never `available` against loaded. This state is
477
+ // "what is installed HERE is not what runs here", and the catalog is not a
478
+ // party to it. Asking the catalog question here was wrong in both directions,
479
+ // measured against this module:
480
+ //
481
+ // - installed 7.1.0, loaded 7.1.0, catalog 7.2.0 — an ordinary pending
482
+ // release, nothing stale at all — classified `stale-load` with an empty
483
+ // `scopes`, so no installer ran, `repairStaleLoad` found no row behind
484
+ // 7.1.0, wrote nothing, and its post-write assertion passed because the
485
+ // loader already resolved 7.1.0. The engine counted that as a success and
486
+ // 7.2.0 was never installed. Every run classified identically while
487
+ // `planIsBehind` stayed true, so `magus update --check` failed forever over
488
+ // something `magus update` could not clear — the un-greenable gate the note
489
+ // on `planIsBehind` argues must not exist. It fired on any machine with a
490
+ // pending release, which is the normal state for this marketplace.
491
+ // - a marketplace publishing NO version (278 of the 292 official plugins)
492
+ // leaves `available` null, so `isRepairablyBehind` answered false and a
493
+ // genuine stale load classified `current` — green about a session running
494
+ // old code, the exact defect this state exists to end.
495
+ //
496
+ // The two formulas agree whenever `available` equals `installed`, which is
497
+ // every machine with nothing pending — which is why it held up by hand and why
498
+ // the parity fixture in stale-load-repair.test.ts had to be changed to be able
499
+ // to fail at all.
500
+ //
501
+ // BOTH AT ONCE — stale-loaded AND behind the catalog — is deliberately NOT
502
+ // folded together, and neither fact is dropped. This branch wins (what runs is
503
+ // the more consequential fact, and a reinstall is measured not to clear it),
504
+ // `target` stays an installed version because only an installed version's cache
505
+ // directory exists, and the pending catalog version is named in the note rather
506
+ // than left for the user to infer from a column. `magus update` then converges
507
+ // over consecutive runs: repair the load, then install the update once the load
508
+ // is no longer stale. It is not collapsed into one run because the two are
509
+ // different mechanisms — a registry rewrite and an installer — and the apply
510
+ // engine runs one action per item; interleaving them silently would make a
511
+ // failed half look like a whole success.
512
+ //
513
+ // CAPPED AT THE PIN, which is why the comparison is against `staleTarget` and
514
+ // not `installed`. `installed` is the highest version in ANY scope, and
515
+ // `scopesNeeding` deliberately never downgrades, so a scope legitimately sits
516
+ // ABOVE an exact pin. Measured: pin 0.3.0, project scope at 0.3.0 and loading,
517
+ // user scope at 0.5.0 — `installed` is 0.5.0, so an uncapped comparison called
518
+ // a plugin loading exactly what its pin asked for a broken load, and the repair
519
+ // then succeeded (this repo's row is writable, and 0.5.0's cache exists because
520
+ // user scope installed it), moving a pinned plugin off its pin and reporting
521
+ // success. An exact pin is a request, not a starting point: this branch may
522
+ // never plan past it, for the same reason the pin branch below refuses to plan
523
+ // toward something the marketplace cannot deliver.
524
+ //
525
+ // A pin naming a version installed NOWHERE at or below it leaves `staleTarget`
526
+ // null and the branch silent. That is the honest answer rather than a quiet
527
+ // promotion: the only versions on disk are above the pin, so there is no repair
528
+ // this may perform, and the pin branch below reports the pin state instead.
529
+ const staleTarget = highestPermittedInstalled(info, pin);
530
+ if (isRepairablyBehind(staleTarget, base.loaded)) {
531
+ // Only ever under "latest". Under an exact pin the catalog is not what this
532
+ // plugin is judged against, so naming a newer published version as something
533
+ // "available once the load is repaired" would advertise a move the pin
534
+ // forbids.
535
+ const pending =
536
+ pin === "latest" && isRepairablyBehind(available, installed);
537
+ return {
538
+ ...base,
539
+ target: staleTarget,
540
+ action: "stale-load",
541
+ reason: "older-version-loads",
542
+ scopes: [],
543
+ note: pending
544
+ ? `loads v${base.loaded}, installed v${staleTarget}; v${available} is available once the load is repaired`
545
+ : `loads v${base.loaded}, installed v${staleTarget}`,
546
+ };
547
+ }
548
+
378
549
  // An exact pin is planned against what can ACTUALLY be delivered.
379
550
  //
380
551
  // This is dependency management, not a package manager: there is no
@@ -563,6 +734,12 @@ export interface PluginStateCounts {
563
734
  update: number;
564
735
  repair: number;
565
736
  current: number;
737
+ /**
738
+ * Its own count, never folded into `current`. The installed version being
739
+ * right is exactly what makes this state dangerous: counted as current, the
740
+ * header would report a clean bill of health for a session running old code.
741
+ */
742
+ "stale-load": number;
566
743
  unknown: number;
567
744
  }
568
745
 
@@ -575,6 +752,7 @@ export function countPluginStates(
575
752
  update: 0,
576
753
  repair: 0,
577
754
  current: 0,
755
+ "stale-load": 0,
578
756
  unknown: 0,
579
757
  };
580
758
  for (const item of items) counts[item.action]++;
@@ -762,6 +940,14 @@ export function planHasWork(plan: UpdatePlan): boolean {
762
940
  * binary had no version probe and "upgrade" meant "re-run the installer"; it
763
941
  * now means the owning installer says the tool is behind, which is drift.
764
942
  *
943
+ * A `stale-load` plugin DOES count. It was excluded while it was report-only, on
944
+ * the un-greenable-gate argument above: nothing magus ran could clear it, so a
945
+ * gate that failed on it would have failed forever. `repairStaleLoad` clears it
946
+ * now, and `magus update` applies that, so the gate closes — and leaving it out
947
+ * would mean `--check` printing "nothing behind" over a plugin whose session
948
+ * loads a version nobody installed, which is the deception the state exists to
949
+ * end.
950
+ *
765
951
  * NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
766
952
  * this" is not evidence of drift, and most unknowns are structural and
767
953
  * permanent rather than transient: a plugin whose marketplace publishes no
@@ -784,7 +970,8 @@ export function planIsBehind(plan: UpdatePlan): boolean {
784
970
  (p) =>
785
971
  p.action === "install" ||
786
972
  p.action === "update" ||
787
- p.action === "repair",
973
+ p.action === "repair" ||
974
+ p.action === "stale-load",
788
975
  ) ||
789
976
  plan.bins.some((b) => b.action === "install" || b.action === "upgrade") ||
790
977
  plan.skills.some((s) => s.action === "install")
@@ -295,26 +295,33 @@ export interface ClaudeMdConventions {
295
295
 
296
296
  // ─── Plugin Profile Types ──────────────────────────────────────────────────────
297
297
 
298
- export interface Profile {
299
- name: string;
300
- plugins: Record<string, boolean>;
301
- createdAt: string;
302
- updatedAt: string;
303
- }
304
-
305
- export interface ProfilesFile {
306
- version: number;
307
- profiles: Record<string, Profile>;
308
- }
309
-
310
- /** A profile with its id and scope, as used in UI lists */
311
- export interface ProfileEntry {
298
+ /**
299
+ * A profile with its id and scope, as used in UI lists.
300
+ *
301
+ * This IS a v2 manifest entry. There is no separate TUI profile shape: both
302
+ * surfaces read one `.claude/profiles.json`, so a second type describing the
303
+ * same file is a second answer to one question. The earlier `Profile` /
304
+ * `ProfilesFile` pair required `createdAt`/`updatedAt` and typed `plugins` as
305
+ * booleans, which is a v1 file — handed a v2 file written by `magus update`,
306
+ * the list sort dereferenced a missing `updatedAt` and threw, so the Profiles
307
+ * tab reported an error for anyone who had ever run the CLI.
308
+ */
309
+ export interface ProfileEntry extends ProfileManifestEntry {
312
310
  id: string;
313
- name: string;
314
- plugins: Record<string, boolean>;
315
- createdAt: string;
316
- updatedAt: string;
317
311
  scope: "user" | "project";
312
+ /**
313
+ * This profile is the one the project is actually running.
314
+ *
315
+ * Derived from the `.claude/settings.json` symlink, not stored — see
316
+ * `activeProfile`. Carried on the entry because a list of profiles with no
317
+ * mark on the live one cannot answer the first question anybody asks of it:
318
+ * the Profiles tab showed two identical-looking rows straight after
319
+ * activating one of them.
320
+ *
321
+ * Only ever true for a `project` profile; a user-scope profile is a template,
322
+ * and nothing links to it.
323
+ */
324
+ active?: boolean;
318
325
  }
319
326
 
320
327
  // NOTE: The built-in profile type lives with its data in
@@ -324,11 +331,11 @@ export interface ProfileEntry {
324
331
 
325
332
  // ─── Team Profile Manifest (v2, committed .claude/profiles.json) ────────────────
326
333
  //
327
- // The v1 `Profile`/`ProfilesFile` above are the TUI's saved presets (plugins
328
- // only). The v2 manifest below is the committed, team-shared source of truth:
329
- // a superset that also carries marketplaces, MCP servers, CLI tools, skills,
330
- // settings, and env requirements. A v1 profile is a structural subset of a v2
331
- // entry, so v1 files migrate forward by a version bump alone.
334
+ // The ONE shape of `.claude/profiles.json`, read by the TUI and the CLI alike.
335
+ // It carries marketplaces, MCP servers, CLI tools, skills, settings and env
336
+ // requirements as well as plugins. A v1 file — the TUI's old plugins-only
337
+ // preset — is a structural subset, so it is read as a v2 entry whose optional
338
+ // fields are simply absent, and rewritten as v2 on the next save.
332
339
 
333
340
  /** A marketplace a profile depends on (registered before its plugins install). */
334
341
  export interface ProfileMarketplaceRef {
@@ -385,6 +392,20 @@ export interface ProfileManifest {
385
392
  version: 2;
386
393
  /** When true, `install --check` fails on any plugin version drift. */
387
394
  strictVersions?: boolean;
395
+ /**
396
+ * Which profile this project wants, by id. Committed with the manifest.
397
+ *
398
+ * Without it a manifest says which profiles exist and never which one to
399
+ * use, so every teammate cloning the repo is asked to choose and has nothing
400
+ * to choose by — the repo knows the answer and had no way to write it down.
401
+ *
402
+ * It is intent, not state: the profile that is ACTUALLY active is the symlink
403
+ * (see `activeProfile`), which is per-developer and gitignored. This field is
404
+ * what a fresh clone activates so nobody is asked at all.
405
+ *
406
+ * Must name a profile that exists; `validateManifest` rejects it otherwise.
407
+ */
408
+ default?: string;
388
409
  profiles: Record<string, ProfileManifestEntry>;
389
410
  }
390
411
 
package/src/ui/App.tsx CHANGED
@@ -1,10 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import { useRenderer } from "@opentui/react";
3
3
  import React, { useEffect, useState } from "react";
4
+ import { activePluginPins } from "../services/active-closure.js";
4
5
  import { isClaudeAvailable } from "../services/claude-cli.js";
5
6
  import {
7
+ findStaleLoads,
6
8
  migrateMarketplaceRename,
7
9
  recoverMarketplaceSettings,
10
+ repairAllStaleLoads,
8
11
  } from "../services/claude-settings.js";
9
12
  import { checkGitignore } from "../services/gitignore-check.js";
10
13
  import {
@@ -13,6 +16,11 @@ import {
13
16
  } from "../services/gitignore-service.js";
14
17
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
15
18
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
19
+ import {
20
+ type ProfileStartupNeed,
21
+ detectProfileStartupNeed,
22
+ needsNothing,
23
+ } from "../services/profile-startup.js";
16
24
  import {
17
25
  type VersionCheckResult,
18
26
  checkForUpdates,
@@ -22,6 +30,11 @@ import { ProgressBar } from "./components/layout/ProgressBar.js";
22
30
  import { ModalContainer } from "./components/modals/index.js";
23
31
  import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
24
32
  import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
33
+ import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
34
+ import {
35
+ staleLoadRepairSummary,
36
+ useStaleLoadModal,
37
+ } from "./hooks/useStaleLoadModal.js";
25
38
  import {
26
39
  AliasScreen,
27
40
  CliToolsScreen,
@@ -128,6 +141,23 @@ function GlobalKeyHandler({
128
141
  return;
129
142
  }
130
143
 
144
+ // Ctrl+C quits from ANYWHERE, including from under an open modal, and is
145
+ // checked before the guard below rather than beside `q` further down.
146
+ //
147
+ // Every modal used to be dismissable, so Escape-then-Ctrl+C always worked
148
+ // and this ordering never mattered. The profile gate cannot be dismissed —
149
+ // by design, since there is nothing to carry on to — which left Ctrl+C
150
+ // swallowed by the guard and no interrupt at all. A terminal app that
151
+ // ignores Ctrl+C is indistinguishable from one that has hung.
152
+ //
153
+ // `q` deliberately does NOT get the same treatment: a modal may want it
154
+ // (the select list's own cancel), and it is a printable character an input
155
+ // modal has to receive.
156
+ if (key.ctrl && input === "c") {
157
+ onExit();
158
+ return;
159
+ }
160
+
131
161
  // Don't handle keys when modal is open or searching
132
162
  if (state.modal || state.isSearching) return;
133
163
 
@@ -364,7 +394,17 @@ function AppContent({ onExit }: AppContentProps) {
364
394
  violationCount: number;
365
395
  safeCount: number;
366
396
  } | null>(null);
397
+ const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
398
+ null,
399
+ );
367
400
  const gitignoreModal = useGitignoreModal();
401
+ const [brokenPluginCount, setBrokenPluginCount] = useState<number | null>(
402
+ null,
403
+ );
404
+ const staleLoadModal = useStaleLoadModal();
405
+ // The profile modal cannot be dismissed, so it needs the app's own exit: its
406
+ // only non-action is to leave.
407
+ const profileModal = useProfileStartupModal(onExit);
368
408
 
369
409
  // Check for updates on startup, then re-check periodically (force-bypassing
370
410
  // the session cache) so the banner reflects a release that lands while
@@ -407,6 +447,89 @@ function AppContent({ onExit }: AppContentProps) {
407
447
  setGitignoreData(null);
408
448
  }, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
409
449
 
450
+ // Show the broken-plugin modal — same "wait for the modal slot" rule as the
451
+ // gitignore one above, so two startup findings queue instead of one stomping
452
+ // the other.
453
+ useEffect(() => {
454
+ if (brokenPluginCount === null) return;
455
+ if (state.modal) return;
456
+ staleLoadModal.show({
457
+ brokenCount: brokenPluginCount,
458
+ // Re-surveys before repairing, like the gitignore fixer: the registry may
459
+ // have moved between the startup scan and the user pressing Enter, and
460
+ // repairing from the stale count would act on rows that are no longer
461
+ // there. `repairAllStaleLoads` is the same `repairStaleLoad` the Plugins
462
+ // screen's `a` key drives through the apply engine.
463
+ //
464
+ // The pins are re-read with it, from the same helper the survey used: a
465
+ // repair that capped at a different ceiling than the offer was counted
466
+ // against would move rows the dialog never mentioned.
467
+ onFix: async () =>
468
+ staleLoadRepairSummary(
469
+ await repairAllStaleLoads(
470
+ state.projectPath,
471
+ undefined,
472
+ await activePluginPins(state.projectPath),
473
+ ),
474
+ ),
475
+ });
476
+ setBrokenPluginCount(null);
477
+ }, [brokenPluginCount, staleLoadModal, state.modal, state.projectPath]);
478
+
479
+ // Does this project load an older plugin than it installed? Detection only;
480
+ // nothing is written until the user chooses Fix.
481
+ //
482
+ // Its own effect for the same reason the profile check below has one: the
483
+ // marketplace block returns early on `--no-refresh`, which is about network
484
+ // calls, and this question is answered from the local registry alone. Sharing
485
+ // that effect let the flag silently disable the check — observed, while this
486
+ // was still inside it.
487
+ useEffect(() => {
488
+ (async () => {
489
+ try {
490
+ const broken = await findStaleLoads(
491
+ process.cwd(),
492
+ undefined,
493
+ await activePluginPins(process.cwd()),
494
+ );
495
+ if (broken.length > 0) setBrokenPluginCount(broken.length);
496
+ } catch {
497
+ // non-fatal: the Plugins tab still shows the same rows
498
+ }
499
+ })();
500
+ }, []);
501
+
502
+ // Show the profile modal on the same terms as the gitignore one: only once
503
+ // the modal slot is free, so two startup checks that both found something do
504
+ // not stomp on each other. Gitignore is asked first simply because its
505
+ // effect is declared first; whichever the user dismisses, the other follows.
506
+ useEffect(() => {
507
+ if (!profileNeed) return;
508
+ if (state.modal) return;
509
+ profileModal.show(profileNeed);
510
+ setProfileNeed(null);
511
+ }, [profileNeed, profileModal, state.modal]);
512
+
513
+ // Is this project actually running a profile? Detection only — nothing is
514
+ // written until the user picks something in the modal. The Profiles tab could
515
+ // list two profiles while the project ran neither, and nothing anywhere said
516
+ // so.
517
+ //
518
+ // Its own effect, NOT part of the marketplace block below: that one returns
519
+ // early on `--no-refresh`, which is about network calls to marketplaces and
520
+ // has no bearing on whether this project is on a profile. Sharing the effect
521
+ // would let that flag silently disable this question.
522
+ useEffect(() => {
523
+ (async () => {
524
+ try {
525
+ const need = await detectProfileStartupNeed(process.cwd());
526
+ if (!needsNothing(need)) setProfileNeed(need);
527
+ } catch {
528
+ // non-fatal: the Profiles tab still works by hand
529
+ }
530
+ })();
531
+ }, []);
532
+
410
533
  // Auto-refresh marketplaces on startup
411
534
  useEffect(() => {
412
535
  const noRefresh = process.argv.includes("--no-refresh");
@@ -1,4 +1,5 @@
1
1
  import type React from "react";
2
+ import { hintChip } from "../../theme-mode.js";
2
3
  import { theme } from "../../theme.js";
3
4
 
4
5
  /** One footer hint: a key (or key group) and the action it performs. */
@@ -28,11 +29,12 @@ export function joinKeys(keys: string[]): string {
28
29
  // The shared chip palette, not a local one. This file used to define its own
29
30
  // (`theme.colors.dim` block + terminal-foreground ink), which is why the footer
30
31
  // stayed a low-contrast grey-on-grey strip after the palette was made vivid —
31
- // the new colours simply never reached it. Anything chip-shaped uses theme.hints.
32
- const KEY_BG = theme.hints.defaultBg;
33
- const KEY_FG = theme.hints.fg;
34
- const LABEL_FG = theme.colors.muted;
35
- const GAP = " ";
32
+ // the new colours simply never reached it. Anything chip-shaped uses the theme.
33
+ //
34
+ // `hintChip()` rather than `theme.hints`: the chip is two fills that must sit a
35
+ // measured step apart, and "a step quieter" is only definable against a page.
36
+ // The static palette cannot express it; `theme-mode` resolves it.
37
+ const GAP = " ";
36
38
 
37
39
  /**
38
40
  * Render a row of footer hints as key badges + labels:
@@ -45,25 +47,35 @@ const GAP = " ";
45
47
  export function FooterHints({
46
48
  hints,
47
49
  }: { hints: FooterHint[] }): React.ReactNode {
50
+ const ink = hintChip();
48
51
  const nodes: React.ReactNode[] = [];
49
52
  hints.forEach((hint, i) => {
50
53
  // A hint is identified by its key chord plus label, not its position.
51
54
  const id = `${joinKeys(hint.keys)}:${hint.label}`;
52
55
  if (i > 0) {
53
56
  nodes.push(
54
- <span key={`gap-${id}`} fg={LABEL_FG}>
57
+ <span key={`gap-${id}`} fg={theme.colors.muted}>
55
58
  {GAP}
56
59
  </span>,
57
60
  );
58
61
  }
62
+ // The two halves butt together with no separator: the key's fill ends
63
+ // exactly where the label's begins, so the pair reads as one chip with a
64
+ // bright end rather than as a badge followed by a word.
65
+ //
66
+ // Both halves are padded on BOTH sides. The label used to carry a trailing
67
+ // space only, which left its first letter hard against the boundary between
68
+ // the two fills — the key's own trailing space sits on the bright fill, so
69
+ // there was no quiet gap on the label's side at all and `↑↓ select` read as
70
+ // one cramped word.
59
71
  nodes.push(
60
- <span key={`k-${id}`} bg={KEY_BG} fg={KEY_FG}>
72
+ <span key={`k-${id}`} bg={ink.keyBg} fg={ink.keyFg}>
61
73
  {` ${joinKeys(hint.keys)} `}
62
74
  </span>,
63
75
  );
64
76
  nodes.push(
65
- <span key={`l-${id}`} fg={LABEL_FG}>
66
- {` ${hint.label}`}
77
+ <span key={`l-${id}`} bg={ink.labelBg} fg={ink.labelFg}>
78
+ {` ${hint.label} `}
67
79
  </span>,
68
80
  );
69
81
  });
@@ -121,6 +121,7 @@ export function ModalContainer() {
121
121
  defaultIndex={selectIndex}
122
122
  onSelect={modal.onSelect}
123
123
  onCancel={modal.onCancel}
124
+ cancellable={modal.cancellable}
124
125
  />
125
126
  );
126
127