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
@@ -789,11 +789,11 @@ describe("applyPlugins — read-back against beforeVersions", () => {
789
789
  reasons.reporter,
790
790
  );
791
791
  expect(result.unchanged).toEqual(["dev@magus"]);
792
- expect(reasons.lines(result.unchanged)).toBe(
792
+ expect(reasons.lines(result.unchanged)).toEqual([
793
793
  "dev@magus: the installer exited 0 but dev@magus is still 1.0.0",
794
- );
794
+ ]);
795
795
  // An item with no recorded cause still gets a line.
796
- expect(reasons.lines(["other@magus"])).toBe("other@magus");
796
+ expect(reasons.lines(["other@magus"])).toEqual(["other@magus"]);
797
797
  });
798
798
  });
799
799
 
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  buildStaleLoadModal,
4
- staleLoadRepairSummary,
4
+ staleLoadRepairNotices,
5
5
  } from "../ui/hooks/useStaleLoadModal";
6
6
 
7
7
  /**
@@ -138,19 +138,24 @@ describe("buildStaleLoadModal", () => {
138
138
  });
139
139
  });
140
140
 
141
- describe("staleLoadRepairSummary", () => {
142
- it("says what loads NOW for each repaired plugin", () => {
141
+ describe("staleLoadRepairNotices", () => {
142
+ it("says what loads NOW for the repaired plugins, in one line", () => {
143
143
  expect(
144
- staleLoadRepairSummary([
144
+ staleLoadRepairNotices([
145
145
  { pluginId: "madbench@magus", target: "0.5.0", ok: true },
146
146
  { pluginId: "dev@magus", target: "7.4.0", ok: true },
147
147
  ]),
148
- ).toBe("✓ madbench@magus now loads v0.5.0\n✓ dev@magus now loads v7.4.0");
148
+ ).toEqual([
149
+ {
150
+ text: "Now loading madbench@magus v0.5.0, dev@magus v7.4.0",
151
+ tone: "success",
152
+ },
153
+ ]);
149
154
  });
150
155
 
151
156
  it("reports a failed repair with its own reason, never as a success", () => {
152
157
  expect(
153
- staleLoadRepairSummary([
158
+ staleLoadRepairNotices([
154
159
  {
155
160
  pluginId: "madbench@magus",
156
161
  target: "0.5.0",
@@ -158,20 +163,30 @@ describe("staleLoadRepairSummary", () => {
158
163
  error: "the loader still resolves v0.3.0 here, not v0.5.0",
159
164
  },
160
165
  ]),
161
- ).toBe(
162
- "✗ madbench@magus — the loader still resolves v0.3.0 here, not v0.5.0",
163
- );
166
+ ).toEqual([
167
+ {
168
+ text: "madbench@magus not repaired: the loader still resolves v0.3.0 here, not v0.5.0",
169
+ tone: "warning",
170
+ },
171
+ ]);
164
172
  });
165
173
 
166
174
  it("falls back to a plain reason rather than an empty line", () => {
167
175
  expect(
168
- staleLoadRepairSummary([
176
+ staleLoadRepairNotices([
169
177
  { pluginId: "a@magus", target: "1.0.0", ok: false },
170
178
  ]),
171
- ).toBe("✗ a@magus — repair did not take effect");
179
+ ).toEqual([
180
+ {
181
+ text: "a@magus not repaired: repair did not take effect",
182
+ tone: "warning",
183
+ },
184
+ ]);
172
185
  });
173
186
 
174
187
  it("says so when there was nothing to repair", () => {
175
- expect(staleLoadRepairSummary([])).toBe("Nothing to repair.");
188
+ expect(staleLoadRepairNotices([])).toEqual([
189
+ { text: "Nothing needed repairing", tone: "success" },
190
+ ]);
176
191
  });
177
192
  });
@@ -35,6 +35,7 @@ import {
35
35
  needsNothing,
36
36
  oldFormatNeed,
37
37
  parseProfileChoice,
38
+ profileIdForName,
38
39
  } from "../services/profile-startup.js";
39
40
  import {
40
41
  applyProfileChoice,
@@ -47,7 +48,7 @@ import { SettingsParseError } from "../services/settings-file.js";
47
48
  import type { ProfileManifest } from "../types/index.js";
48
49
  import { brand } from "../ui/theme.js";
49
50
  import { badge, bold, dim } from "./ansi.js";
50
- import { askYesNo } from "./prompt.js";
51
+ import { askYesNo, isPromptAborted, promptValue } from "./prompt.js";
51
52
  import { selectOption } from "./select.js";
52
53
  import {
53
54
  type ScopeName,
@@ -447,6 +448,31 @@ export async function ensureManifest(
447
448
  return result.manifest;
448
449
  }
449
450
 
451
+ /**
452
+ * The name for a profile saved from what the project has, or null when the
453
+ * user gave none (Ctrl+C, or input ended). Empty accepts `suggestion`. An
454
+ * invalid name is explained and asked again — never swapped for another id.
455
+ */
456
+ async function askProfileName(
457
+ suggestion: string,
458
+ manifest: ProfileManifest,
459
+ ): Promise<{ id: string; name: string } | null> {
460
+ for (;;) {
461
+ let typed: string;
462
+ try {
463
+ typed = await promptValue(
464
+ question("Name for the new profile", `[${suggestion}]`),
465
+ );
466
+ } catch (error) {
467
+ if (isPromptAborted(error)) return null;
468
+ throw error;
469
+ }
470
+ const check = profileIdForName(typed === "" ? suggestion : typed, manifest);
471
+ if (check.ok) return { id: check.id, name: check.name };
472
+ console.log(warnLine(check.reason));
473
+ }
474
+ }
475
+
450
476
  /**
451
477
  * Require an active profile, or stop the command.
452
478
  *
@@ -545,7 +571,13 @@ export async function requireActiveProfile(
545
571
  // "Quit magus", Esc and Ctrl+C all land here, and there is no third
546
572
  // outcome: the command stops rather than proceed without a profile.
547
573
  const value = picked === null ? undefined : options[picked]?.value;
548
- const choice = value === undefined ? null : parseProfileChoice(value, need);
574
+ let choice = value === undefined ? null : parseProfileChoice(value, need);
575
+ // Saving asks for the name, and an unanswered name is an unanswered
576
+ // question like any other: nothing is created and the command stops.
577
+ if (choice?.kind === "adopt") {
578
+ const named = await askProfileName(choice.id, need.manifest);
579
+ choice = named === null ? null : { kind: "adopt", ...named };
580
+ }
549
581
  if (!choice) {
550
582
  console.error(failLine("No profile chosen — stopping."));
551
583
  return null;
package/src/cli/prompt.ts CHANGED
@@ -39,15 +39,25 @@ export function isPromptAborted(error: unknown): boolean {
39
39
  );
40
40
  }
41
41
 
42
- /** One readline question, with Ctrl+C normalised. */
42
+ /** One readline question, with Ctrl+C and end of input normalised. */
43
43
  async function ask(question: string): Promise<string> {
44
44
  const rl = createInterface({ input: process.stdin, output: process.stdout });
45
+ // End of input (Ctrl+D, or a pipe that ran dry) closes the interface and
46
+ // never settles `question()` — MEASURED under Bun, it waits forever. Input
47
+ // that ends before an answer is a cancellation, the same as Ctrl+C.
48
+ let settled = false;
49
+ const closed = new Promise<never>((_, reject) => {
50
+ rl.once("close", () => {
51
+ if (!settled) reject(new PromptAborted());
52
+ });
53
+ });
45
54
  try {
46
- return (await rl.question(question)).trim();
55
+ return (await Promise.race([rl.question(question), closed])).trim();
47
56
  } catch (error) {
48
57
  if (isPromptAborted(error)) throw new PromptAborted();
49
58
  throw error;
50
59
  } finally {
60
+ settled = true;
51
61
  rl.close();
52
62
  }
53
63
  }
@@ -214,11 +214,13 @@ export function scopeBadge(scope: ScopeName): string {
214
214
  }
215
215
 
216
216
  /**
217
- * The prompt for a yes/no question, in the glyph column: `? <question> [Y/n] `.
218
- * Printed by readline, so the answer is typed on the same line.
217
+ * The prompt for a question, in the glyph column: `? <question> [Y/n] `.
218
+ * Printed by readline, so the answer is typed on the same line. `hint` is what
219
+ * an empty answer means — yes by default, or the suggested value for a
220
+ * free-text question.
219
221
  */
220
- export function question(text: string): string {
221
- return `${PAD}${fg(brand.accent, "?")} ${bold(text)} ${dim("[Y/n]")} `;
222
+ export function question(text: string, hint = "[Y/n]"): string {
223
+ return `${PAD}${fg(brand.accent, "?")} ${bold(text)} ${dim(hint)} `;
222
224
  }
223
225
 
224
226
  /**
@@ -93,25 +93,6 @@ export async function applyStartupFixes(
93
93
  return result;
94
94
  }
95
95
 
96
- /** What {@link applyStartupFixes} did, for the message the TUI shows. */
97
- export function describeStartupFix(result: StartupFixResult): string {
98
- const lines: string[] = [];
99
- if (result.untracked.length > 0) {
100
- lines.push(
101
- "Stopped tracking (the removal is staged — commit it):",
102
- ...result.untracked.map((p) => ` ${p}`),
103
- ' git commit -m "Stop tracking generated Claude Code config"',
104
- );
105
- }
106
- if (result.appended.length > 0) {
107
- lines.push("Added to .gitignore:", ...result.appended.map((p) => ` ${p}`));
108
- }
109
- for (const f of result.failed) {
110
- lines.push(`Could not untrack ${f.path}: ${f.reason}`);
111
- }
112
- return lines.length > 0 ? lines.join("\n") : "Nothing needed changing.";
113
- }
114
-
115
96
  export async function applyAllSafeFixes(
116
97
  cwd: string,
117
98
  violations: Violation[],
@@ -59,6 +59,7 @@ import { readManifest, writeManifest } from "./manifest.js";
59
59
  import { validateModelsConfig } from "./models-core.js";
60
60
  import {
61
61
  type ActivateOptions,
62
+ type ActivationResult,
62
63
  activateProfile,
63
64
  activationNotes,
64
65
  releasedCommittedSkillNote,
@@ -588,6 +589,12 @@ export interface ReconcileResult {
588
589
  conflicts: string[];
589
590
  /** Everything worth printing, in order. */
590
591
  lines: string[];
592
+ /**
593
+ * The re-render, when profiles.json changed and the live config was
594
+ * generated again. `lines` already carries its CLI wording; this is for a
595
+ * surface that words it differently (the TUI's notices).
596
+ */
597
+ activation: ActivationResult | null;
591
598
  }
592
599
 
593
600
  function emptyResult(profile: string | null): ReconcileResult {
@@ -600,6 +607,7 @@ function emptyResult(profile: string | null): ReconcileResult {
600
607
  unreadable: [],
601
608
  conflicts: [],
602
609
  lines: [],
610
+ activation: null,
603
611
  };
604
612
  }
605
613
 
@@ -1529,6 +1537,7 @@ export async function reconcileActiveProfile(
1529
1537
  // command that ran it reports that once, itself, and repeating it here
1530
1538
  // printed the same warning three times in one `magus update`.
1531
1539
  notes.push(...activationNotes(activation, { tracked: false }));
1540
+ result.activation = activation;
1532
1541
  } catch (error) {
1533
1542
  result.problems.push(
1534
1543
  error instanceof Error ? error.message : String(error),
@@ -209,7 +209,8 @@ export function releasedCommittedSkillNote(folder: string): string {
209
209
 
210
210
  /**
211
211
  * Lines describing what activation did beyond the switch itself, phrased for
212
- * both the terminal and the TUI's outcome modal.
212
+ * the terminal. The TUI words its own, shorter set (`activationNotices` in
213
+ * ui/hooks/useProfileStartupModal.ts).
213
214
  *
214
215
  * `tracked: false` leaves the tracked-path notices out, for the CLI, which
215
216
  * prints them itself as a warning with the fix beneath it
@@ -516,5 +517,6 @@ export async function recordRefetchedSkills(
516
517
 
517
518
  /** The two ways a project can be put onto a profile. */
518
519
  export type ProfileChoice =
519
- | { kind: "adopt"; id: string }
520
+ /** `name` is what the user typed; without it the adopted default name is used. */
521
+ | { kind: "adopt"; id: string; name?: string }
520
522
  | { kind: "activate"; id: string };
@@ -36,12 +36,19 @@ export const ADOPTED_PROFILE_ID = "default";
36
36
  * Lives here rather than at a call site because both surfaces adopt now — the
37
37
  * CLI prompt and the TUI's startup offer — and two copies of "which id is
38
38
  * free?" is two chances to overwrite somebody's profile.
39
+ *
40
+ * Own keys, the same test `profileIdForName` applies to the name the user
41
+ * submits. A truthiness test called a hand-written `"default": null` free, so
42
+ * the suggestion offered in the name field was the one name it then refused,
43
+ * and Enter looped on it.
39
44
  */
40
45
  export function freeProfileId(manifest: ProfileManifest): string {
41
- if (!manifest.profiles[ADOPTED_PROFILE_ID]) return ADOPTED_PROFILE_ID;
46
+ if (!Object.hasOwn(manifest.profiles, ADOPTED_PROFILE_ID)) {
47
+ return ADOPTED_PROFILE_ID;
48
+ }
42
49
  for (let n = 2; ; n++) {
43
50
  const id = `${ADOPTED_PROFILE_ID}-${n}`;
44
- if (!manifest.profiles[id]) return id;
51
+ if (!Object.hasOwn(manifest.profiles, id)) return id;
45
52
  }
46
53
  }
47
54
 
@@ -69,7 +69,11 @@ import {
69
69
  findEnclosingManifest,
70
70
  readManifest,
71
71
  } from "./manifest.js";
72
- import type { ProfileChoice } from "./profile-activate.js";
72
+ import {
73
+ type ActivationResult,
74
+ type ProfileChoice,
75
+ activationNotes,
76
+ } from "./profile-activate.js";
73
77
  import { freeProfileId } from "./profile-adopt.js";
74
78
  import {
75
79
  activeProfile,
@@ -310,7 +314,10 @@ export async function oldFormatNeed(
310
314
  /** What the TUI shows at startup, beyond the question (if any). */
311
315
  export interface ProfileStartup {
312
316
  need: ProfileStartupNeed;
313
- /** One line per thing the gate did: absorbed edits, regeneration, repairs. */
317
+ /**
318
+ * One line per thing the gate did: absorbed edits, regeneration, repairs.
319
+ * Not what an activation reported — see `activations`.
320
+ */
314
321
  notices: string[];
315
322
  /**
316
323
  * One line per thing the gate REFUSED — the lines the CLI stops on. Kept
@@ -319,6 +326,14 @@ export interface ProfileStartup {
319
326
  * still names its profile, so only this says the update did not happen.
320
327
  */
321
328
  problems: string[];
329
+ /**
330
+ * The activations the gate ran (a re-render, a format update). Handed over
331
+ * as results rather than lines because the CLI's lines are the wrong words
332
+ * for the TUI: they name plugin.json and backups, and they say "will not
333
+ * load" in a line nothing marks as a warning. The TUI words them itself, in
334
+ * user terms and with a tone (`activationWarnings` in the startup hook).
335
+ */
336
+ activations: ActivationResult[];
322
337
  }
323
338
 
324
339
  /**
@@ -338,14 +353,24 @@ export async function prepareProfileStartup(
338
353
  ): Promise<ProfileStartup> {
339
354
  if (!opts.convert) {
340
355
  const format = await oldFormatNeed(projectPath);
341
- if (format !== null) return { need: format, notices: [], problems: [] };
356
+ if (format !== null) {
357
+ return { need: format, notices: [], problems: [], activations: [] };
358
+ }
342
359
  }
343
360
  const notices: string[] = [];
344
361
  const problems: string[] = [];
362
+ const activations: ActivationResult[] = [];
345
363
  try {
346
364
  const prep = await prepareProfileState(projectPath);
347
- notices.push(...prep.lines);
365
+ // The gate's lines, less the ones its activations contributed: those
366
+ // reach the TUI as `activations`, and passing both would say the same
367
+ // thing twice — once in the CLI's words, as if it were a success.
368
+ const fromActivations = new Set(
369
+ prep.activations.flatMap((a) => activationNotes(a, { tracked: false })),
370
+ );
371
+ notices.push(...prep.lines.filter((line) => !fromActivations.has(line)));
348
372
  problems.push(...prep.problems);
373
+ activations.push(...prep.activations);
349
374
  } catch (error) {
350
375
  if (!(error instanceof SettingsParseError)) {
351
376
  problems.push(error instanceof Error ? error.message : String(error));
@@ -355,13 +380,17 @@ export async function prepareProfileStartup(
355
380
  need: await detectProfileStartupNeed(projectPath),
356
381
  notices,
357
382
  problems,
383
+ activations,
358
384
  };
359
385
  }
360
386
 
361
- /** What the TUI shows once "Update it now" has run. */
387
+ /**
388
+ * What the TUI shows once "Update it now" has run: another question, a notice
389
+ * line, or an error modal.
390
+ */
362
391
  export type FormatUpdateOutcome =
363
392
  | { kind: "ask"; need: ProfileStartupNeed }
364
- | { kind: "updated"; title: string; message: string }
393
+ | { kind: "updated"; notice: string }
365
394
  | { kind: "failed"; title: string; message: string };
366
395
 
367
396
  /**
@@ -381,14 +410,12 @@ export function formatUpdateOutcome(
381
410
  };
382
411
  }
383
412
  if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
384
- return {
385
- kind: "updated",
386
- title: "Profile format updated",
387
- message:
388
- result.notices.length > 0
389
- ? result.notices.join("\n")
390
- : "This project now uses the current profile format.",
391
- };
413
+ // One line, not the gate's notices: those are the conversion's working log
414
+ // (edits carried over, the old format's backup), and the result the user
415
+ // cares about is only that the update is done. What the conversion's
416
+ // activation warns about is NOT dropped: the caller words it from
417
+ // `result.activations`, as it does for any activation.
418
+ return { kind: "updated", notice: "Profile format updated" };
392
419
  }
393
420
 
394
421
  // ─── What to offer, as data both surfaces render ─────────────────────────────
@@ -426,9 +453,10 @@ export const UPDATE_FORMAT_VALUE = "update-format";
426
453
  * Neither mentions the backup, deliberately. A sentence promising "nothing is
427
454
  * deleted — the old files are moved to a backup" raises a question the reader
428
455
  * did not have (which files? what was at risk?) and implies data loss, when what
429
- * changes is settings. Where the displaced files went belongs in the outcome
430
- * modal AFTER the switch, where it is something to act on rather than a worry to
431
- * plant beforehand.
456
+ * changes is settings. The TUI does not name the backup afterwards either: the
457
+ * backup is magus's own storage, and the user acts on nothing there. Only a
458
+ * failed activation lists what was moved, because then it is something to act
459
+ * on (see `describeFailure` in ui/hooks/useProfileStartupModal.ts).
432
460
  *
433
461
  * No file paths in these strings, deliberately. The TUI modal soft-wraps its
434
462
  * message at whatever width the terminal gives it, and OpenTUI treats `.` as a
@@ -555,7 +583,11 @@ export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
555
583
  // Kept short on purpose: this is the longest label in the list, and the
556
584
  // meta column is measured against the longest one. "…and use it" made it
557
585
  // 41 cells and pushed every plugin count onto a line of its own.
558
- label: `Save what you have as "${need.adopt}"`,
586
+ //
587
+ // No id in it. The generated one read as a copy of an existing profile
588
+ // ("default-2" as "overwrite Default"); the name is asked for once this
589
+ // row is picked, with that id only as the suggestion.
590
+ label: "Save what you have as a new profile",
559
591
  value: ADOPT_VALUE,
560
592
  });
561
593
  }
@@ -595,6 +627,47 @@ export function defaultOptionIndex(need: ProfileStartupNeed): number {
595
627
  return at === -1 ? 0 : at;
596
628
  }
597
629
 
630
+ /** A typed profile name, judged: the id it becomes, or why it cannot. */
631
+ export type ProfileNameCheck =
632
+ | { ok: true; id: string; name: string }
633
+ | { ok: false; reason: string };
634
+
635
+ /**
636
+ * Validate a profile name typed by the user and derive its id. Pure.
637
+ *
638
+ * The one rule both surfaces ask with, so the CLI and the TUI cannot accept
639
+ * different names. A clash is refused, never resolved: quietly saving as
640
+ * `<id>-2` is the very thing that made users read the old generated label as
641
+ * "overwrite Default".
642
+ */
643
+ export function profileIdForName(
644
+ input: string,
645
+ manifest: ProfileManifest,
646
+ ): ProfileNameCheck {
647
+ const name = input.trim();
648
+ if (name === "") {
649
+ return { ok: false, reason: "Enter a name for the profile." };
650
+ }
651
+ const id = name
652
+ .toLowerCase()
653
+ .replace(/[^a-z0-9]+/g, "-")
654
+ .replace(/^-+|-+$/g, "");
655
+ if (id === "") {
656
+ return { ok: false, reason: "Use at least one letter or digit." };
657
+ }
658
+ // Own keys only: a name like "constructor" is free, not a clash with the
659
+ // object prototype.
660
+ if (Object.hasOwn(manifest.profiles, id)) {
661
+ // Named as the user knows it; a hand-written entry may carry no name.
662
+ const known = manifest.profiles[id]?.name || id;
663
+ return {
664
+ ok: false,
665
+ reason: `A profile called "${known}" already exists. Choose another name.`,
666
+ };
667
+ }
668
+ return { ok: true, id, name };
669
+ }
670
+
598
671
  /**
599
672
  * Turn a chosen option value into the action it means.
600
673
  *
@@ -299,6 +299,12 @@ export interface ProfileStatePrep {
299
299
  problems: string[];
300
300
  /** The files behind `problems` that could not be parsed. */
301
301
  unreadable: SettingsParseError[];
302
+ /**
303
+ * Every activation the gate ran, in order: a re-render after profiles.json
304
+ * changed, an older layout converted. `lines` already carries them in the
305
+ * CLI's words; the TUI words them itself from these.
306
+ */
307
+ activations: ActivationResult[];
302
308
  }
303
309
 
304
310
  /**
@@ -319,11 +325,13 @@ export async function prepareProfileState(
319
325
  lines: [],
320
326
  problems: [],
321
327
  unreadable: [],
328
+ activations: [],
322
329
  };
323
330
  const take = (r: ReconcileResult) => {
324
331
  out.lines.push(...r.lines.filter((l) => !r.problems.includes(l)));
325
332
  out.problems.push(...r.problems);
326
333
  out.unreadable.push(...r.unreadable);
334
+ if (r.activation) out.activations.push(r.activation);
327
335
  };
328
336
 
329
337
  if (check.state === "active" && check.restored) {
@@ -358,6 +366,7 @@ export async function prepareProfileState(
358
366
  }
359
367
  // Tracked files are the command's to report, once (see profile-absorb).
360
368
  out.lines.push(...activationNotes(result, { tracked: false }));
369
+ out.activations.push(result);
361
370
  }
362
371
  out.check = { state: "active", profile, restored: false };
363
372
  }
@@ -556,7 +565,7 @@ export async function applyProfileChoice(
556
565
  }
557
566
  const result = await adoptCurrentProject(
558
567
  projectPath,
559
- undefined,
568
+ choice.name,
560
569
  manifest,
561
570
  choice.id,
562
571
  );