magus-cli 7.6.0 → 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.
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,26 +359,21 @@ 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
- }, []);
290
362
  // Stable, so `profileModal.show` is not rebuilt for it on every render.
291
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]);
292
372
  // The profile modal cannot be dismissed, so it needs the app's own exit: its
293
373
  // only non-action is to leave.
294
374
  const profileModal = useProfileStartupModal({
295
375
  onQuit: onExit,
296
- onActivated: checkGitignoreNow,
376
+ onActivated: profileActivated,
297
377
  onAnswered: profileAnswered,
298
378
  });
299
379
 
@@ -315,21 +395,6 @@ function AppContent({ onExit }: AppContentProps) {
315
395
  return () => clearInterval(id);
316
396
  }, []);
317
397
 
318
- // Auto-dismiss the profile gate's notices after 10 seconds — long enough to
319
- // read a line per absorbed change.
320
- useEffect(() => {
321
- if (profileNotices.length === 0) return;
322
- const timer = setTimeout(() => setProfileNotices([]), 10000);
323
- return () => clearTimeout(timer);
324
- }, [profileNotices]);
325
-
326
- // Auto-dismiss recovery banner after 5 seconds
327
- useEffect(() => {
328
- if (!recoveryReport) return;
329
- const timer = setTimeout(() => setRecoveryReport(null), 5000);
330
- return () => clearTimeout(timer);
331
- }, [recoveryReport]);
332
-
333
398
  // The startup questions — gitignore, then broken plugins, then profile — are
334
399
  // asked ONE at a time, from ONE effect, and only while the modal slot is free.
335
400
  //
@@ -339,12 +404,13 @@ function AppContent({ onExit }: AppContentProps) {
339
404
  // need had already been cleared — so it never came back. The profile question
340
405
  // was lost exactly that way, and the user updated plugins believing a profile
341
406
  // 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.
407
+ // follows once that one's flow has ended and the slot is empty again.
344
408
  //
345
409
  // The hooks keep the slot occupied for their whole flow (a loading modal
346
410
  // 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
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
348
414
  // it: `useModal().confirm` hides its modal when answered and the screen shows
349
415
  // its loading modal a microtask later. A key pressed before the profile check
350
416
  // resolved lands the profile question in exactly that gap, and the screen's
@@ -363,17 +429,26 @@ function AppContent({ onExit }: AppContentProps) {
363
429
  // re-showing would re-ask a question the user just declined. Losing one
364
430
  // costs nothing lasting: the Git State and Plugins tabs show the same rows,
365
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).
366
438
  useEffect(() => {
367
439
  if (state.modal) return;
368
- if (gitignoreData) {
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;
369
444
  gitignoreModal.show({
370
- violationCount: gitignoreData.violationCount,
371
- safeCount: gitignoreData.safeCount,
372
- generated: gitignoreData.generated,
445
+ violationCount: need.violationCount,
446
+ safeCount: need.safeCount,
447
+ generated: need.generated,
373
448
  // In place, then back to the app: tracked generated files are
374
449
  // untracked, missing entries added; other items stay for the tab.
375
450
  onFixSafe: async () =>
376
- describeStartupFix(await applyStartupFixes(state.projectPath)),
451
+ startupFixNotices(await applyStartupFixes(state.projectPath)),
377
452
  });
378
453
  setGitignoreData(null);
379
454
  return;
@@ -391,7 +466,7 @@ function AppContent({ onExit }: AppContentProps) {
391
466
  // repair that capped at a different ceiling than the offer was counted
392
467
  // against would move rows the dialog never mentioned.
393
468
  onFix: async () =>
394
- staleLoadRepairSummary(
469
+ staleLoadRepairNotices(
395
470
  await repairAllStaleLoads(
396
471
  state.projectPath,
397
472
  undefined,
@@ -409,6 +484,7 @@ function AppContent({ onExit }: AppContentProps) {
409
484
  state.modal,
410
485
  state.projectPath,
411
486
  gitignoreData,
487
+ gitignorePending,
412
488
  gitignoreModal,
413
489
  brokenPluginCount,
414
490
  staleLoadModal,
@@ -439,12 +515,46 @@ function AppContent({ onExit }: AppContentProps) {
439
515
  })();
440
516
  }, []);
441
517
 
442
- // The gitignore question on every open. Its own effect, NOT part of the
443
- // marketplace block below, which returns early on `--no-refresh`: that flag
444
- // 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.
445
533
  useEffect(() => {
446
- checkGitignoreNow();
447
- }, [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]);
448
558
 
449
559
  // Is this project actually running a profile? Detection only — nothing is
450
560
  // written until the user picks something in the modal. The Profiles tab could
@@ -462,19 +572,25 @@ function AppContent({ onExit }: AppContentProps) {
462
572
  // edits into the active profile, regenerate from a changed
463
573
  // profiles.json, restore a lost marker — then the question, if one
464
574
  // is left to ask.
465
- const { need, notices, problems } = await prepareProfileStartup(
466
- process.cwd(),
467
- );
575
+ const { need, notices, problems, activations } =
576
+ await prepareProfileStartup(process.cwd());
468
577
  // 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);
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");
472
588
  if (!needsNothing(need)) setProfileNeed(need);
473
589
  } catch {
474
590
  // non-fatal: the Profiles tab still works by hand
475
591
  }
476
592
  })();
477
- }, []);
593
+ }, [notify]);
478
594
 
479
595
  // Auto-refresh marketplaces on startup
480
596
  useEffect(() => {
@@ -498,7 +614,7 @@ function AppContent({ onExit }: AppContentProps) {
498
614
  if (!(await isClaudeAvailable())) return;
499
615
  const added = await autoAddMissingMarketplaces(process.cwd());
500
616
  if (added.length > 0) {
501
- setRecoveryReport(`added marketplace(s): ${added.join(", ")}`);
617
+ notify(`Fixed: added marketplace(s): ${added.join(", ")}`);
502
618
  }
503
619
  } catch {
504
620
  // non-fatal: user can still install marketplaces manually
@@ -509,6 +625,9 @@ function AppContent({ onExit }: AppContentProps) {
509
625
  recoverMarketplaceSettings()
510
626
  .then(async (recovery) => {
511
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[] = [];
512
631
  if (recovery.reregistered.length > 0) {
513
632
  // Update the marketplace clone now that the source is fixed
514
633
  const { updateMarketplace } = await import(
@@ -519,7 +638,7 @@ function AppContent({ onExit }: AppContentProps) {
519
638
  await updateMarketplace(mp);
520
639
  parts.push(`${mp} refreshed`);
521
640
  } catch {
522
- parts.push(`${mp} (update failed)`);
641
+ failed.push(mp);
523
642
  }
524
643
  }
525
644
  }
@@ -527,7 +646,13 @@ function AppContent({ onExit }: AppContentProps) {
527
646
  parts.push(`removed: ${recovery.removed.join(", ")}`);
528
647
  }
529
648
  if (parts.length > 0) {
530
- 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
+ );
531
656
  }
532
657
  })
533
658
  .catch(() => {}); // non-fatal
@@ -540,23 +665,17 @@ function AppContent({ onExit }: AppContentProps) {
540
665
  .catch(() => {
541
666
  dispatch({ type: "HIDE_PROGRESS" });
542
667
  });
543
- }, [dispatch]);
544
-
545
- // Count transient banners for dimension calculation
546
- const transientBannerCount = recoveryReport ? 1 : 0;
668
+ }, [dispatch, notify]);
547
669
 
548
670
  return (
549
671
  <DimensionsProvider
550
672
  showProgress={!!progress}
551
673
  showUpdateBanner={!!updateInfo?.updateAvailable}
552
- 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}
553
677
  >
554
- <AppContentInner
555
- updateInfo={updateInfo}
556
- onExit={onExit}
557
- recoveryReport={recoveryReport}
558
- profileNotices={profileNotices}
559
- />
678
+ <AppContentInner updateInfo={updateInfo} onExit={onExit} />
560
679
  </DimensionsProvider>
561
680
  );
562
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
 
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from "react";
2
- import { useApp, useNavigation } from "../state/AppContext.js";
3
- import type { ModalState } from "../state/types.js";
2
+ import type { StartupFixResult } from "../../services/gitignore-service.js";
3
+ import { useApp, useNavigation, useNotify } from "../state/AppContext.js";
4
+ import type { ModalState, NoticeLine } from "../state/types.js";
4
5
 
5
6
  /**
6
7
  * Startup gitignore-violation modal: magus launches, detects a problem, and
@@ -79,17 +80,49 @@ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
79
80
  };
80
81
  }
81
82
 
83
+ /**
84
+ * What the startup fix did, as notices. Untracking is a warning because it is
85
+ * not finished until the user commits the staged removal — the one thing here
86
+ * they must still do. The files are named only there, and in a failure, which
87
+ * the user has to deal with by hand.
88
+ */
89
+ export function startupFixNotices(result: StartupFixResult): NoticeLine[] {
90
+ const lines: NoticeLine[] = [];
91
+ if (result.untracked.length > 0) {
92
+ lines.push({
93
+ text: `Stopped tracking ${result.untracked.join(", ")} — commit the staged removal`,
94
+ tone: "warning",
95
+ });
96
+ }
97
+ if (result.appended.length > 0) {
98
+ lines.push({
99
+ text: `Added ${entries(result.appended.length)}`,
100
+ tone: "success",
101
+ });
102
+ }
103
+ for (const f of result.failed) {
104
+ lines.push({
105
+ text: `Could not stop tracking ${f.path}: ${f.reason}`,
106
+ tone: "warning",
107
+ });
108
+ }
109
+ return lines.length > 0
110
+ ? lines
111
+ : [{ text: "Gitignore already up to date", tone: "success" }];
112
+ }
113
+
82
114
  export function useGitignoreModal() {
83
115
  const { dispatch } = useApp();
84
116
  const { navigateToScreen } = useNavigation();
117
+ const notify = useNotify();
85
118
 
86
119
  const show = useCallback(
87
120
  (args: {
88
121
  violationCount: number;
89
122
  safeCount: number;
90
123
  generated?: readonly string[];
91
- /** Returns what it did, shown before returning to the app. */
92
- onFixSafe: () => Promise<string | undefined> | string | undefined;
124
+ /** Returns what it did, reported as notices once the fix settles. */
125
+ onFixSafe: () => Promise<readonly NoticeLine[]> | readonly NoticeLine[];
93
126
  }) => {
94
127
  if (args.violationCount === 0) return;
95
128
  const dismiss = () => dispatch({ type: "HIDE_MODAL" });
@@ -101,9 +134,9 @@ export function useGitignoreModal() {
101
134
  // A loading modal, NOT HIDE_MODAL. The fix is async, and an empty
102
135
  // modal slot is App's signal that the next startup question may be
103
136
  // asked: hiding here let the profile question show during the await,
104
- // be marked as asked, and then be replaced by "Gitignore fixed" —
137
+ // be marked as asked, and then be replaced by the fix's result —
105
138
  // lost for the whole session. The slot stays occupied until the
106
- // result below is dismissed.
139
+ // fix settles.
107
140
  dispatch({
108
141
  type: "SHOW_MODAL",
109
142
  modal: { type: "loading", message: "Fixing gitignore…" },
@@ -114,22 +147,11 @@ export function useGitignoreModal() {
114
147
  void Promise.resolve()
115
148
  .then(() => args.onFixSafe())
116
149
  .then((report) => {
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
- }
123
- dispatch({
124
- type: "SHOW_MODAL",
125
- modal: {
126
- type: "message",
127
- title: "Gitignore fixed",
128
- message: report,
129
- variant: "success",
130
- onDismiss: dismiss,
131
- },
132
- });
150
+ // The loading modal must go — it swallows every key — and the
151
+ // result goes to the notice banner, which is outside the modal
152
+ // slot, so the next startup question can take the slot now.
153
+ dispatch({ type: "HIDE_MODAL" });
154
+ for (const line of report) notify(line.text, line.tone);
133
155
  })
134
156
  .catch((err) => {
135
157
  dispatch({
@@ -152,7 +174,7 @@ export function useGitignoreModal() {
152
174
  });
153
175
  dispatch({ type: "SHOW_MODAL", modal });
154
176
  },
155
- [dispatch, navigateToScreen],
177
+ [dispatch, navigateToScreen, notify],
156
178
  );
157
179
 
158
180
  return { show };