magus-cli 7.5.4 → 7.7.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 (39) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/format-update-outcome.test.ts +9 -8
  3. package/src/__tests__/gitignore-profile.test.ts +9 -5
  4. package/src/__tests__/key-focus-guard.test.tsx +260 -0
  5. package/src/__tests__/notices-rules.test.ts +476 -0
  6. package/src/__tests__/notices.test.tsx +604 -0
  7. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  8. package/src/__tests__/profile-startup.test.ts +1 -1
  9. package/src/__tests__/prompt-eof.test.ts +245 -0
  10. package/src/__tests__/update-apply.test.ts +3 -3
  11. package/src/__tests__/useStaleLoadModal.test.ts +27 -12
  12. package/src/cli/bootstrap.ts +34 -2
  13. package/src/cli/prompt.ts +12 -2
  14. package/src/cli/update-view.ts +6 -4
  15. package/src/services/gitignore-service.ts +0 -19
  16. package/src/services/profile-absorb.ts +9 -0
  17. package/src/services/profile-activate.ts +4 -2
  18. package/src/services/profile-adopt.ts +9 -2
  19. package/src/services/profile-startup.ts +91 -18
  20. package/src/services/profile-state.ts +10 -1
  21. package/src/ui/App.tsx +295 -141
  22. package/src/ui/adapters/pluginsAdapter.ts +28 -9
  23. package/src/ui/components/modals/MessageModal.tsx +1 -2
  24. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  25. package/src/ui/hooks/useGitignoreModal.ts +58 -18
  26. package/src/ui/hooks/useProfileStartupModal.ts +232 -85
  27. package/src/ui/hooks/useStaleLoadModal.ts +43 -29
  28. package/src/ui/input/KeyRouter.tsx +43 -2
  29. package/src/ui/screens/AliasScreen.tsx +7 -6
  30. package/src/ui/screens/CliToolsScreen.tsx +19 -16
  31. package/src/ui/screens/EnvVarsScreen.tsx +4 -1
  32. package/src/ui/screens/GitignoreScreen.tsx +20 -27
  33. package/src/ui/screens/McpRegistryScreen.tsx +9 -6
  34. package/src/ui/screens/McpScreen.tsx +16 -12
  35. package/src/ui/screens/PluginsScreen.tsx +35 -56
  36. package/src/ui/screens/ProfilesScreen.tsx +27 -51
  37. package/src/ui/state/AppContext.tsx +46 -3
  38. package/src/ui/state/reducer.ts +100 -1
  39. package/src/ui/state/types.ts +53 -2
package/src/ui/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from "react";
1
+ import React, { useEffect, useRef, useState } from "react";
2
2
  import { activePluginPins } from "../services/active-closure.js";
3
3
  import { isClaudeAvailable } from "../services/claude-cli.js";
4
4
  import {
@@ -11,10 +11,7 @@ import {
11
11
  type StartupGitignoreNeed,
12
12
  startupGitignoreNeed,
13
13
  } from "../services/gitignore-check.js";
14
- import {
15
- applyStartupFixes,
16
- describeStartupFix,
17
- } from "../services/gitignore-service.js";
14
+ import { applyStartupFixes } from "../services/gitignore-service.js";
18
15
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
19
16
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
20
17
  import {
@@ -31,10 +28,16 @@ import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
31
28
  import { TABS } from "./components/TabBar.js";
32
29
  import { ProgressBar } from "./components/layout/ProgressBar.js";
33
30
  import { ModalContainer } from "./components/modals/index.js";
34
- import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
35
- import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
36
31
  import {
37
- staleLoadRepairSummary,
32
+ startupFixNotices,
33
+ useGitignoreModal,
34
+ } from "./hooks/useGitignoreModal.js";
35
+ import {
36
+ activationWarnings,
37
+ useProfileStartupModal,
38
+ } from "./hooks/useProfileStartupModal.js";
39
+ import {
40
+ staleLoadRepairNotices,
38
41
  useStaleLoadModal,
39
42
  } from "./hooks/useStaleLoadModal.js";
40
43
  import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
@@ -57,12 +60,14 @@ import {
57
60
  useApp,
58
61
  useModal,
59
62
  useNavigation,
63
+ useNotify,
60
64
  } from "./state/AppContext.js";
61
65
  import {
62
66
  DimensionsProvider,
63
67
  useDimensions,
64
68
  } from "./state/DimensionsContext.js";
65
- import type { Screen } from "./state/types.js";
69
+ import type { Notice, Screen } from "./state/types.js";
70
+ import { truncate } from "./text-width.js";
66
71
  import { theme } from "./theme.js";
67
72
 
68
73
  export const VERSION = getCurrentVersion();
@@ -180,6 +185,72 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
180
185
  );
181
186
  }
182
187
 
188
+ /**
189
+ * NoticeBanner Component
190
+ * The app's one notice line (or a few): what an action did, from `useNotify`.
191
+ * It sits above the screen and outside the modal slot, so it never takes a key
192
+ * and never stands in the way of a startup question. Each line clears itself
193
+ * at its `expiresAt` — counting only time with no modal open.
194
+ *
195
+ * One notice is exactly ONE row: the layout reserves one row per notice
196
+ * (`transientBannerCount`), so a line that wrapped would push the screen's
197
+ * last rows off the terminal. Hence no wrapping, and the text clipped to the
198
+ * width — the start of a line says what happened, and that is what survives.
199
+ */
200
+ function NoticeBanner({ notices }: { notices: readonly Notice[] }) {
201
+ const { state, dispatch } = useApp();
202
+ const { terminalWidth } = useDimensions();
203
+ const modalOpen = state.modal !== null;
204
+ const paused = state.noticesPausedAt !== null;
205
+
206
+ // Hold the notices while a modal is open. A warning posted just before a
207
+ // startup question, or under it, would otherwise run down behind it and be
208
+ // gone by the time the user has answered.
209
+ useEffect(() => {
210
+ dispatch({
211
+ type: modalOpen ? "PAUSE_NOTICES" : "RESUME_NOTICES",
212
+ now: Date.now(),
213
+ });
214
+ }, [modalOpen, dispatch]);
215
+
216
+ // One timer, for the line that expires first; the reducer then drops every
217
+ // line past its time, and the next render schedules the one after. None
218
+ // while paused: RESUME_NOTICES moves every `expiresAt` on first.
219
+ useEffect(() => {
220
+ if (notices.length === 0 || paused) return;
221
+ const next = Math.min(...notices.map((n) => n.expiresAt));
222
+ const timer = setTimeout(
223
+ () => dispatch({ type: "EXPIRE_NOTICES", now: Date.now() }),
224
+ Math.max(0, next - Date.now()),
225
+ );
226
+ return () => clearTimeout(timer);
227
+ }, [notices, paused, dispatch]);
228
+
229
+ // The box's left and right padding.
230
+ const width = Math.max(0, terminalWidth - 2);
231
+ return (
232
+ <>
233
+ {notices.map((notice) => (
234
+ <box key={notice.id} paddingLeft={1} paddingRight={1} height={1}>
235
+ <text
236
+ wrapMode="none"
237
+ fg={
238
+ notice.tone === "warning"
239
+ ? theme.colors.warning
240
+ : theme.colors.success
241
+ }
242
+ >
243
+ {truncate(
244
+ `${notice.tone === "warning" ? "⚠" : "✓"} ${notice.text}`,
245
+ width,
246
+ )}
247
+ </text>
248
+ </box>
249
+ ))}
250
+ </>
251
+ );
252
+ }
253
+
183
254
  /**
184
255
  * ProgressIndicator Component
185
256
  * Shows progress bar when operations are running
@@ -207,34 +278,17 @@ function ProgressIndicator({
207
278
  interface AppContentInnerProps {
208
279
  updateInfo: VersionCheckResult | null;
209
280
  onExit: () => void;
210
- recoveryReport: string | null;
211
- /** What the startup profile gate did: absorbed edits, regeneration, repairs. */
212
- profileNotices: string[];
213
281
  }
214
282
 
215
- function AppContentInner({
216
- updateInfo,
217
- onExit,
218
- recoveryReport,
219
- profileNotices,
220
- }: AppContentInnerProps) {
283
+ function AppContentInner({ updateInfo, onExit }: AppContentInnerProps) {
221
284
  const { state } = useApp();
222
- const { progress } = state;
285
+ const { progress, notices } = state;
223
286
  const dimensions = useDimensions();
224
287
 
225
288
  return (
226
289
  <box flexDirection="column" height={dimensions.terminalHeight}>
227
290
  {updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
228
- {recoveryReport && (
229
- <box paddingLeft={1} paddingRight={1}>
230
- <text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
231
- </box>
232
- )}
233
- {profileNotices.map((line) => (
234
- <box key={line} paddingLeft={1} paddingRight={1}>
235
- <text fg={theme.colors.success}>• {line}</text>
236
- </box>
237
- ))}
291
+ <NoticeBanner notices={notices} />
238
292
  {progress && <ProgressIndicator {...progress} />}
239
293
  <box
240
294
  flexDirection="column"
@@ -250,6 +304,27 @@ function AppContentInner({
250
304
  );
251
305
  }
252
306
 
307
+ /** A gitignore check's answer, waiting for the modal slot. */
308
+ interface QueuedGitignore {
309
+ need: StartupGitignoreNeed;
310
+ /** The `gitignoreCheckRequest` it answers. */
311
+ request: number;
312
+ }
313
+
314
+ /**
315
+ * The gitignore check's bookkeeping. A ref, not state: a landing check reads
316
+ * the latest request and what was last shown at that moment, not as of the
317
+ * render that started it.
318
+ */
319
+ interface GitignoreCheck {
320
+ /** A check is running. There is never more than one. */
321
+ inFlight: boolean;
322
+ /** The newest `gitignoreCheckRequest` seen. */
323
+ latest: number;
324
+ /** The request whose answer was last shown as a question; -1 for none. */
325
+ shown: number;
326
+ }
327
+
253
328
  /**
254
329
  * AppContent Component
255
330
  * Wraps app with DimensionsProvider and manages state for debug/updates
@@ -261,11 +336,21 @@ interface AppContentProps {
261
336
  function AppContent({ onExit }: AppContentProps) {
262
337
  const { state, dispatch } = useApp();
263
338
  const { progress } = state;
339
+ const notify = useNotify();
264
340
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
265
- const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
266
- const [profileNotices, setProfileNotices] = useState<string[]>([]);
267
- const [gitignoreData, setGitignoreData] =
268
- useState<StartupGitignoreNeed | null>(null);
341
+ const [gitignoreData, setGitignoreData] = useState<QueuedGitignore | null>(
342
+ null,
343
+ );
344
+ // The last request whose check has landed. Behind the latest request, a
345
+ // check is pending, and `gitignoreData` — if any — is an answer to an
346
+ // earlier question about a project that has since changed.
347
+ const [gitignoreSettled, setGitignoreSettled] = useState(-1);
348
+ const gitignorePending = gitignoreSettled < state.gitignoreCheckRequest;
349
+ const gitignoreCheck = useRef<GitignoreCheck>({
350
+ inFlight: false,
351
+ latest: 0,
352
+ shown: -1,
353
+ });
269
354
  const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
270
355
  null,
271
356
  );
@@ -274,22 +359,23 @@ function AppContent({ onExit }: AppContentProps) {
274
359
  null,
275
360
  );
276
361
  const staleLoadModal = useStaleLoadModal();
277
- // The gitignore question, asked fresh: on every open, and again once the
278
- // profile modal has activated a profile — a first adoption is what turns a
279
- // committed settings.json into a generated file git should not track.
280
- // Nothing remembers an earlier answer.
281
- const checkGitignoreNow = React.useCallback(() => {
282
- startupGitignoreNeed(process.cwd())
283
- .then((need) => {
284
- if (need) setGitignoreData(need);
285
- })
286
- .catch(() => {
287
- // non-fatal: the Git State tab still shows the same rows
288
- });
289
- }, []);
362
+ // Stable, so `profileModal.show` is not rebuilt for it on every render.
363
+ const profileAnswered = React.useCallback(() => setProfileNeed(null), []);
364
+ // Activating a profile changes which plugins are on, so the open screen must
365
+ // reload. The result modal that used to follow sent the user to the Profiles
366
+ // tab, which remounted it; without that modal the user stays where they are,
367
+ // and the screen under them would keep showing the pre-activation state.
368
+ // The same action re-runs the gitignore check — see the effect below.
369
+ const profileActivated = React.useCallback(() => {
370
+ dispatch({ type: "PROFILE_ACTIVATED" });
371
+ }, [dispatch]);
290
372
  // The profile modal cannot be dismissed, so it needs the app's own exit: its
291
373
  // only non-action is to leave.
292
- const profileModal = useProfileStartupModal(onExit, checkGitignoreNow);
374
+ const profileModal = useProfileStartupModal({
375
+ onQuit: onExit,
376
+ onActivated: profileActivated,
377
+ onAnswered: profileAnswered,
378
+ });
293
379
 
294
380
  // Check for updates on startup, then re-check periodically (force-bypassing
295
381
  // the session cache) so the banner reflects a release that lands while
@@ -309,66 +395,102 @@ function AppContent({ onExit }: AppContentProps) {
309
395
  return () => clearInterval(id);
310
396
  }, []);
311
397
 
312
- // Auto-dismiss the profile gate's notices after 10 seconds — long enough to
313
- // read a line per absorbed change.
314
- useEffect(() => {
315
- if (profileNotices.length === 0) return;
316
- const timer = setTimeout(() => setProfileNotices([]), 10000);
317
- return () => clearTimeout(timer);
318
- }, [profileNotices]);
319
-
320
- // Auto-dismiss recovery banner after 5 seconds
321
- useEffect(() => {
322
- if (!recoveryReport) return;
323
- const timer = setTimeout(() => setRecoveryReport(null), 5000);
324
- return () => clearTimeout(timer);
325
- }, [recoveryReport]);
326
-
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.
398
+ // The startup questions — gitignore, then broken plugins, then profile — are
399
+ // asked ONE at a time, from ONE effect, and only while the modal slot is free.
400
+ //
401
+ // They used to be three effects, each checking `state.modal` from the same
402
+ // render. Two needs arriving in one commit both saw an empty slot, both
403
+ // showed, and the later SHOW_MODAL replaced the earlier question after its
404
+ // need had already been cleared — so it never came back. The profile question
405
+ // was lost exactly that way, and the user updated plugins believing a profile
406
+ // was active. So: show at most one per run and return; the next question
407
+ // follows once that one's flow has ended and the slot is empty again.
408
+ //
409
+ // The hooks keep the slot occupied for their whole flow (a loading modal
410
+ // while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
411
+ // what lets the next question in. A flow that succeeds ends by hiding its
412
+ // loading modal and reporting through `useNotify`: a notice is not in the
413
+ // modal slot, so it cannot hold the next question back or be replaced by it. That does not stop a SCREEN from emptying
414
+ // it: `useModal().confirm` hides its modal when answered and the screen shows
415
+ // its loading modal a microtask later. A key pressed before the profile check
416
+ // resolved lands the profile question in exactly that gap, and the screen's
417
+ // loading modal then replaces it.
418
+ //
419
+ // So the two kinds of question are cleared at different points.
420
+ //
421
+ // - The profile need is cleared when it is ANSWERED (`profileAnswered`, from
422
+ // the hook: a profile activated, or quit), never when shown. The modal
423
+ // cannot be dismissed, so an empty slot while the need is still set means
424
+ // it was lost, and this effect puts it back. The hook keeps what was last
425
+ // asked and whether work is in flight, so a re-show re-asks the question
426
+ // that was on screen and never starts a second activation.
427
+ // - The gitignore and broken-plugin needs are still cleared on show. Both
428
+ // can be dismissed, so an empty slot does not mean they were lost —
429
+ // re-showing would re-ask a question the user just declined. Losing one
430
+ // costs nothing lasting: the Git State and Plugins tabs show the same rows,
431
+ // and gitignore is asked again after a profile activates.
432
+ //
433
+ // The gitignore question also waits while a check is pending. An activation
434
+ // both empties the slot and asks for a fresh check, so the slot is free
435
+ // while an answer from BEFORE the activation may still be queued: showing
436
+ // it and then the fresh one asked the same question twice. The fresh result
437
+ // replaces the queued one instead (see the check effect below).
347
438
  useEffect(() => {
348
- if (brokenPluginCount === null) return;
349
439
  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),
440
+ if (gitignoreData && !gitignorePending) {
441
+ // Anything older than what is now on screen is dropped on arrival.
442
+ gitignoreCheck.current.shown = gitignoreData.request;
443
+ const need = gitignoreData.need;
444
+ gitignoreModal.show({
445
+ violationCount: need.violationCount,
446
+ safeCount: need.safeCount,
447
+ generated: need.generated,
448
+ // In place, then back to the app: tracked generated files are
449
+ // untracked, missing entries added; other items stay for the tab.
450
+ onFixSafe: async () =>
451
+ startupFixNotices(await applyStartupFixes(state.projectPath)),
452
+ });
453
+ setGitignoreData(null);
454
+ return;
455
+ }
456
+ if (brokenPluginCount !== null) {
457
+ staleLoadModal.show({
458
+ brokenCount: brokenPluginCount,
459
+ // Re-surveys before repairing, like the gitignore fixer: the registry
460
+ // may have moved between the startup scan and the user pressing Enter,
461
+ // and repairing from the stale count would act on rows that are no
462
+ // longer there. `repairAllStaleLoads` is the same `repairStaleLoad` the
463
+ // Plugins screen's `a` key drives through the apply engine.
464
+ //
465
+ // The pins are re-read with it, from the same helper the survey used: a
466
+ // repair that capped at a different ceiling than the offer was counted
467
+ // against would move rows the dialog never mentioned.
468
+ onFix: async () =>
469
+ staleLoadRepairNotices(
470
+ await repairAllStaleLoads(
471
+ state.projectPath,
472
+ undefined,
473
+ await activePluginPins(state.projectPath),
474
+ ),
367
475
  ),
368
- ),
369
- });
370
- setBrokenPluginCount(null);
371
- }, [brokenPluginCount, staleLoadModal, state.modal, state.projectPath]);
476
+ });
477
+ setBrokenPluginCount(null);
478
+ return;
479
+ }
480
+ // Not cleared here — see above. Re-called on every empty slot until the
481
+ // hook reports it answered.
482
+ if (profileNeed) profileModal.show(profileNeed);
483
+ }, [
484
+ state.modal,
485
+ state.projectPath,
486
+ gitignoreData,
487
+ gitignorePending,
488
+ gitignoreModal,
489
+ brokenPluginCount,
490
+ staleLoadModal,
491
+ profileNeed,
492
+ profileModal,
493
+ ]);
372
494
 
373
495
  // Does this project load an older plugin than it installed? Detection only;
374
496
  // nothing is written until the user chooses Fix.
@@ -393,23 +515,46 @@ function AppContent({ onExit }: AppContentProps) {
393
515
  })();
394
516
  }, []);
395
517
 
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
- // The gitignore question on every open. Its own effect, NOT part of the
408
- // marketplace block below, which returns early on `--no-refresh`: that flag
409
- // is about network calls and must not silence this question.
518
+ // The gitignore question, asked fresh: on every open (request 0), and again
519
+ // on each PROFILE_ACTIVATED — a first adoption is what turns a committed
520
+ // settings.json into a generated file git should not track, and a
521
+ // Profiles-tab switch can do the same. Nothing remembers an earlier answer.
522
+ //
523
+ // At most ONE check runs at a time. A request that arrives while one runs
524
+ // is not started beside it: the running check, on landing, sees that it is
525
+ // no longer the latest, throws its answer away — it describes the project
526
+ // before whatever prompted the request — and runs again. Only the latest
527
+ // request's answer is kept, and it REPLACES anything still queued, including
528
+ // with nothing when the project no longer needs the question.
529
+ //
530
+ // Its own effect, NOT part of the marketplace block below, which returns
531
+ // early on `--no-refresh`: that flag is about network calls and must not
532
+ // silence this question.
410
533
  useEffect(() => {
411
- checkGitignoreNow();
412
- }, [checkGitignoreNow]);
534
+ const check = gitignoreCheck.current;
535
+ check.latest = state.gitignoreCheckRequest;
536
+ if (check.inFlight) return;
537
+ const run = (request: number): void => {
538
+ check.inFlight = true;
539
+ startupGitignoreNeed(process.cwd())
540
+ // non-fatal: the Git State tab still shows the same rows
541
+ .catch(() => null)
542
+ .then((need) => {
543
+ if (request !== check.latest) {
544
+ run(check.latest);
545
+ return;
546
+ }
547
+ check.inFlight = false;
548
+ setGitignoreSettled(request);
549
+ // Defence in depth: an answer to a request no newer than the
550
+ // question last shown predates the user's reply to it.
551
+ setGitignoreData(
552
+ need && request > check.shown ? { need, request } : null,
553
+ );
554
+ });
555
+ };
556
+ run(check.latest);
557
+ }, [state.gitignoreCheckRequest]);
413
558
 
414
559
  // Is this project actually running a profile? Detection only — nothing is
415
560
  // written until the user picks something in the modal. The Profiles tab could
@@ -427,19 +572,25 @@ function AppContent({ onExit }: AppContentProps) {
427
572
  // edits into the active profile, regenerate from a changed
428
573
  // profiles.json, restore a lost marker — then the question, if one
429
574
  // is left to ask.
430
- const { need, notices, problems } = await prepareProfileStartup(
431
- process.cwd(),
432
- );
575
+ const { need, notices, problems, activations } =
576
+ await prepareProfileStartup(process.cwd());
433
577
  // A refusal never blocks the TUI at startup: it is shown beside what
434
- // the gate did, and the user is still asked whatever is left.
435
- const shown = [...notices, ...problems];
436
- if (shown.length > 0) setProfileNotices(shown);
578
+ // the gate did, as a warning, and the user is still asked whatever
579
+ // is left. What a re-render left to act on — a plugin that will not
580
+ // load, one not downloaded yet — is a warning too, in user terms.
581
+ for (const line of notices) notify(line);
582
+ for (const activation of activations) {
583
+ for (const line of activationWarnings(activation)) {
584
+ notify(line.text, line.tone);
585
+ }
586
+ }
587
+ for (const line of problems) notify(line, "warning");
437
588
  if (!needsNothing(need)) setProfileNeed(need);
438
589
  } catch {
439
590
  // non-fatal: the Profiles tab still works by hand
440
591
  }
441
592
  })();
442
- }, []);
593
+ }, [notify]);
443
594
 
444
595
  // Auto-refresh marketplaces on startup
445
596
  useEffect(() => {
@@ -463,7 +614,7 @@ function AppContent({ onExit }: AppContentProps) {
463
614
  if (!(await isClaudeAvailable())) return;
464
615
  const added = await autoAddMissingMarketplaces(process.cwd());
465
616
  if (added.length > 0) {
466
- setRecoveryReport(`added marketplace(s): ${added.join(", ")}`);
617
+ notify(`Fixed: added marketplace(s): ${added.join(", ")}`);
467
618
  }
468
619
  } catch {
469
620
  // non-fatal: user can still install marketplaces manually
@@ -474,6 +625,9 @@ function AppContent({ onExit }: AppContentProps) {
474
625
  recoverMarketplaceSettings()
475
626
  .then(async (recovery) => {
476
627
  const parts: string[] = [];
628
+ // Apart from `parts`: a success line would carry them in green,
629
+ // and a marketplace left stale is something the user may act on.
630
+ const failed: string[] = [];
477
631
  if (recovery.reregistered.length > 0) {
478
632
  // Update the marketplace clone now that the source is fixed
479
633
  const { updateMarketplace } = await import(
@@ -484,7 +638,7 @@ function AppContent({ onExit }: AppContentProps) {
484
638
  await updateMarketplace(mp);
485
639
  parts.push(`${mp} refreshed`);
486
640
  } catch {
487
- parts.push(`${mp} (update failed)`);
641
+ failed.push(mp);
488
642
  }
489
643
  }
490
644
  }
@@ -492,7 +646,13 @@ function AppContent({ onExit }: AppContentProps) {
492
646
  parts.push(`removed: ${recovery.removed.join(", ")}`);
493
647
  }
494
648
  if (parts.length > 0) {
495
- setRecoveryReport(parts.join(" | "));
649
+ notify(`Fixed: ${parts.join(" | ")}`);
650
+ }
651
+ if (failed.length > 0) {
652
+ notify(
653
+ `Could not refresh marketplace(s): ${failed.join(", ")}`,
654
+ "warning",
655
+ );
496
656
  }
497
657
  })
498
658
  .catch(() => {}); // non-fatal
@@ -505,23 +665,17 @@ function AppContent({ onExit }: AppContentProps) {
505
665
  .catch(() => {
506
666
  dispatch({ type: "HIDE_PROGRESS" });
507
667
  });
508
- }, [dispatch]);
509
-
510
- // Count transient banners for dimension calculation
511
- const transientBannerCount = recoveryReport ? 1 : 0;
668
+ }, [dispatch, notify]);
512
669
 
513
670
  return (
514
671
  <DimensionsProvider
515
672
  showProgress={!!progress}
516
673
  showUpdateBanner={!!updateInfo?.updateAvailable}
517
- transientBannerCount={transientBannerCount}
674
+ // One row per notice line, so the screen below shrinks rather than
675
+ // being pushed off the bottom of the terminal.
676
+ transientBannerCount={state.notices.length}
518
677
  >
519
- <AppContentInner
520
- updateInfo={updateInfo}
521
- onExit={onExit}
522
- recoveryReport={recoveryReport}
523
- profileNotices={profileNotices}
524
- />
678
+ <AppContentInner updateInfo={updateInfo} onExit={onExit} />
525
679
  </DimensionsProvider>
526
680
  );
527
681
  }
@@ -247,10 +247,27 @@ function isMadAppGang(mp: Marketplace): boolean {
247
247
  */
248
248
  export function noMoveReasons(): {
249
249
  reporter: ApplyReporter;
250
- /** One line per unchanged item, with its cause when one was reported. */
251
- lines(unchanged: readonly string[]): string;
250
+ /**
251
+ * One line per unchanged item, with its cause when one was reported, so one
252
+ * plugin's reason never runs into the next. The screen posts
253
+ * {@link notice} instead; this is the per-item form it is built from.
254
+ */
255
+ lines(unchanged: readonly string[]): string[];
256
+ /**
257
+ * The same, as ONE notice line, or null when nothing was unchanged. "Update
258
+ * all" can leave many plugins where they were, and a line each would fill
259
+ * the banner and push out whatever else it had to say. So several become
260
+ * `3 plugins unchanged — a, b, c`, with the cause only when they all share
261
+ * it; one keeps its own cause.
262
+ */
263
+ notice(unchanged: readonly string[]): string | null;
252
264
  } {
253
265
  const why = new Map<string, string>();
266
+ const lines = (unchanged: readonly string[]): string[] =>
267
+ unchanged.map((name) => {
268
+ const reason = why.get(name);
269
+ return reason ? `${name}: ${reason}` : name;
270
+ });
254
271
  return {
255
272
  reporter: {
256
273
  begin: () => {},
@@ -260,13 +277,15 @@ export function noMoveReasons(): {
260
277
  },
261
278
  detach: (work) => work(),
262
279
  },
263
- lines: (unchanged) =>
264
- unchanged
265
- .map((name) => {
266
- const reason = why.get(name);
267
- return reason ? `${name}: ${reason}` : name;
268
- })
269
- .join("\n"),
280
+ lines,
281
+ notice: (unchanged) => {
282
+ if (unchanged.length === 0) return null;
283
+ if (unchanged.length === 1) return `No change: ${lines(unchanged)[0]}`;
284
+ const reasons = new Set(unchanged.map((name) => why.get(name)));
285
+ const [shared] = reasons;
286
+ const cause = reasons.size === 1 && shared ? `: ${shared}` : "";
287
+ return `${unchanged.length} plugins unchanged — ${unchanged.join(", ")}${cause}`;
288
+ },
270
289
  };
271
290
  }
272
291
 
@@ -7,12 +7,11 @@ interface MessageModalProps {
7
7
  /** Modal message */
8
8
  message: string;
9
9
  /** Message variant */
10
- variant: "info" | "success" | "error";
10
+ variant: "info" | "error";
11
11
  }
12
12
 
13
13
  const variantConfig = {
14
14
  info: { icon: "ℹ", color: theme.colors.info },
15
- success: { icon: "✓", color: theme.colors.success },
16
15
  error: { icon: "✗", color: theme.colors.danger },
17
16
  } as const;
18
17
 
@@ -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.