magus-cli 7.0.0 → 7.2.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-parity.test.ts +58 -17
  3. package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
  4. package/src/__tests__/cli-update-view.test.ts +100 -21
  5. package/src/__tests__/local-marketplace-scan.test.ts +74 -0
  6. package/src/__tests__/marketplace-refresh.test.ts +12 -7
  7. package/src/__tests__/plugin-row-text.test.ts +33 -24
  8. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  9. package/src/__tests__/profile-migration.test.ts +242 -0
  10. package/src/__tests__/profile-startup.test.ts +458 -0
  11. package/src/__tests__/theme-env.test.ts +29 -0
  12. package/src/__tests__/update-plan.test.ts +57 -34
  13. package/src/__tests__/version-unknown-state.test.ts +29 -14
  14. package/src/cli/ansi.ts +58 -11
  15. package/src/cli/bootstrap.ts +169 -3
  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/router.ts +59 -2
  20. package/src/cli/tui-prompt.tsx +143 -0
  21. package/src/cli/update-view.ts +237 -45
  22. package/src/cli/update.ts +59 -29
  23. package/src/main.tsx +11 -2
  24. package/src/services/claude-settings.ts +15 -40
  25. package/src/services/local-marketplace.ts +76 -6
  26. package/src/services/manifest.ts +21 -0
  27. package/src/services/marketplace-refresh.ts +7 -15
  28. package/src/services/plugin-manager.ts +0 -31
  29. package/src/services/profile-activate.ts +147 -0
  30. package/src/services/profile-adopt.ts +50 -4
  31. package/src/services/profile-startup.ts +311 -0
  32. package/src/services/profiles.ts +49 -11
  33. package/src/services/resolver.ts +33 -0
  34. package/src/services/symlink-manager.ts +184 -30
  35. package/src/services/update-plan.ts +23 -66
  36. package/src/types/index.ts +44 -23
  37. package/src/ui/App.tsx +60 -3
  38. package/src/ui/components/layout/FooterHints.tsx +21 -9
  39. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  40. package/src/ui/components/modals/SelectModal.tsx +142 -21
  41. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  42. package/src/ui/renderers/profileRenderers.tsx +27 -4
  43. package/src/ui/screens/PluginsScreen.tsx +9 -50
  44. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  45. package/src/ui/state/types.ts +18 -0
  46. package/src/ui/theme-mode.ts +142 -0
  47. package/src/utils/command-utils.ts +19 -14
  48. package/src/__tests__/moved-marketplace.test.ts +0 -86
@@ -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
 
@@ -1,10 +1,41 @@
1
- import React from "react";
1
+ import type React from "react";
2
2
  import { useDimensions } from "../../state/DimensionsContext.js";
3
3
  import type { SelectOption } from "../../state/types.js";
4
+ import { type ReportBadgeKind, reportBadge } from "../../theme-mode.js";
4
5
  import { theme } from "../../theme.js";
6
+ import { FooterHints } from "../layout/FooterHints.js";
7
+
8
+ /** Pad a label so every badge and meta column starts at the same cell. */
9
+ function labelPad(label: string, to: number): string {
10
+ return " ".repeat(Math.max(0, to - label.length) + 2);
11
+ }
12
+
13
+ /**
14
+ * A discrete state as a chip: dark ink on a saturated fill, padded OUTSIDE the
15
+ * fill.
16
+ *
17
+ * Padding inside the label would extend the background, and a column of chips
18
+ * padded that way fuses into one solid rectangle with the words floating in it.
19
+ */
20
+ function Chip({
21
+ badge,
22
+ width,
23
+ }: {
24
+ badge: { label: string; kind: ReportBadgeKind };
25
+ width: number;
26
+ }): React.ReactNode {
27
+ const ink = reportBadge(badge.kind);
28
+ const pad = Math.max(0, width - badge.label.length - 2);
29
+ return (
30
+ <>
31
+ <span bg={ink.fill} fg={ink.ink}>{` ${badge.label} `}</span>
32
+ <span fg={theme.colors.dim}>{" ".repeat(pad)}</span>
33
+ </>
34
+ );
35
+ }
5
36
 
6
37
  interface SelectModalProps {
7
- /** Modal title */
38
+ /** Modal title — drawn in the border, not as a body row. */
8
39
  title: string;
9
40
  /** Modal message */
10
41
  message: string;
@@ -16,6 +47,8 @@ interface SelectModalProps {
16
47
  onSelect: (value: string) => void;
17
48
  /** Callback when cancelled */
18
49
  onCancel: () => void;
50
+ /** False for a question that must be answered — drops the `esc` hint. */
51
+ cancellable?: boolean;
19
52
  }
20
53
 
21
54
  export function SelectModal({
@@ -25,6 +58,7 @@ export function SelectModal({
25
58
  defaultIndex,
26
59
  onSelect: _onSelect,
27
60
  onCancel: _onCancel,
61
+ cancellable = true,
28
62
  }: SelectModalProps) {
29
63
  // Keyboard handling is done by ModalContainer
30
64
  // defaultIndex is the live selectedIndex from ModalContainer state
@@ -40,48 +74,135 @@ export function SelectModal({
40
74
  Math.min(90, Math.floor(dimensions.terminalWidth * 0.6)),
41
75
  );
42
76
 
77
+ // Columns a list row may paint: the box width less its border and padding.
78
+ // The selected row is a FULL-WIDTH fill, so it has to know the row width
79
+ // rather than ending wherever its label happens to end.
80
+ const inner = width - 6;
81
+
82
+ // Column widths measured over the whole list, so badges and bars line up
83
+ // down the column instead of drifting with each label's length.
84
+ const labelWidth = Math.max(...options.map((o) => o.label.length), 0);
85
+ const badgeWidth = Math.max(
86
+ ...options.map((o) => (o.badge ? o.badge.label.length + 2 : 0)),
87
+ 0,
88
+ );
89
+
90
+ // Meta is dim trailing detail, so it is the first thing to go when the row
91
+ // will not fit — dropped for EVERY row, not per row, because a column that
92
+ // appears on some rows and not others is worse than no column.
93
+ //
94
+ // Without this the row simply overflowed and the terminal wrapped it:
95
+ // MEASURED on screen, a 41-cell label put "14 plugins" on a line of its own
96
+ // under the option, reading as a second list item. The longest label sets the
97
+ // column, so one long option breaks every row's meta at once.
98
+ const metaWidth = Math.max(
99
+ ...options.map((o) => (o.meta ? o.meta.length + 2 : 0)),
100
+ 0,
101
+ );
102
+ const showMeta = 2 + labelWidth + 2 + badgeWidth + metaWidth <= inner;
103
+
43
104
  return (
44
105
  <box
45
106
  flexDirection="column"
46
107
  border
47
108
  borderStyle="rounded"
48
- borderColor={theme.colors.dim}
109
+ // A modal is the focused surface by definition, and accent means
110
+ // focus everywhere else in this app — so the border wears it here
111
+ // too, and the title drawn in it comes along in the same tone.
112
+ borderColor={theme.colors.accent}
49
113
  backgroundColor={theme.surface.bg}
50
- paddingLeft={3}
51
- paddingRight={3}
114
+ title={title}
115
+ titleAlignment="left"
116
+ paddingLeft={2}
117
+ paddingRight={2}
52
118
  paddingTop={1}
53
119
  paddingBottom={1}
54
120
  width={width}
55
121
  >
56
- <box marginBottom={1}>
57
- <text fg={theme.colors.text}>
58
- <strong>{title}</strong>
59
- </text>
60
- </box>
61
-
62
122
  <box marginBottom={1}>
63
123
  <text fg={theme.colors.muted}>{message}</text>
64
124
  </box>
65
125
 
66
- <box flexDirection="column" paddingLeft={1}>
126
+ <box flexDirection="column">
67
127
  {options.map((option, idx) => {
68
128
  const isSelected = idx === selectedIndex;
129
+ const pad = labelPad(option.label, labelWidth);
130
+ // The plain width of everything the row draws, so the
131
+ // selected fill can be padded out to the full row width.
132
+ const drawn =
133
+ 2 +
134
+ option.label.length +
135
+ pad.length +
136
+ badgeWidth +
137
+ (showMeta && option.meta ? option.meta.length + 2 : 0);
138
+ const fill = " ".repeat(Math.max(0, inner - drawn));
139
+
140
+ if (isSelected) {
141
+ // The selected row owns the colour channel — see
142
+ // `theme.selection`. On its fill no page-level tone
143
+ // clears contrast, so the row has exactly two inks:
144
+ // `fg` for the choice, `dim` for what surrounds it.
145
+ // A badge surrenders its chip the same way a model
146
+ // ink does on the Models screen.
147
+ return (
148
+ <text
149
+ key={option.value}
150
+ bg={theme.selection.bg}
151
+ fg={theme.selection.fg}
152
+ >
153
+ <span>❯ </span>
154
+ <strong>{option.label}</strong>
155
+ <span>{pad}</span>
156
+ {option.badge ? (
157
+ <span fg={theme.selection.dim}>{option.badge.label}</span>
158
+ ) : null}
159
+ {badgeWidth > 0 ? (
160
+ <span fg={theme.selection.dim}>{" ".repeat(badgeWidth)}</span>
161
+ ) : null}
162
+ {showMeta && option.meta ? (
163
+ <span fg={theme.selection.dim}>{` ${option.meta}`}</span>
164
+ ) : null}
165
+ <span>{fill}</span>
166
+ </text>
167
+ );
168
+ }
169
+
170
+ // Unselected rows sit in the terminal's own foreground —
171
+ // NOT muted. Muting every unselected row turned the list
172
+ // into the wall of grey this dialog was rejected for; the
173
+ // choices are the signal, and only the chrome is dim.
69
174
  return (
70
- <text
71
- key={option.value}
72
- fg={isSelected ? theme.colors.text : theme.colors.muted}
73
- >
74
- <span fg={isSelected ? theme.colors.text : theme.colors.muted}>
75
- {isSelected ? "❯ " : " "}
76
- </span>
77
- {isSelected ? <strong>{option.label}</strong> : option.label}
175
+ <text key={option.value} fg={theme.colors.text}>
176
+ <span>{" "}</span>
177
+ <span>{option.label}</span>
178
+ <span>{pad}</span>
179
+ {option.badge ? (
180
+ <Chip badge={option.badge} width={badgeWidth} />
181
+ ) : (
182
+ <span fg={theme.colors.dim}>{" ".repeat(badgeWidth)}</span>
183
+ )}
184
+ {showMeta && option.meta ? (
185
+ <span fg={theme.colors.muted}>{` ${option.meta}`}</span>
186
+ ) : null}
78
187
  </text>
79
188
  );
80
189
  })}
81
190
  </box>
82
191
 
192
+ {/* The shared footer key chips, not a prose hint line: a chip reads
193
+ as a pressable key at a glance, and every other footer in the
194
+ app already renders this way. */}
83
195
  <box marginTop={1}>
84
- <text fg={theme.colors.muted}>↑↓ Select • ↵ Confirm • Esc Cancel</text>
196
+ <FooterHints
197
+ hints={[
198
+ { keys: ["↑", "↓"], label: "select" },
199
+ { keys: ["↵"], label: "confirm" },
200
+ // Omitted when there is nothing to cancel to. A hint for a key
201
+ // that does nothing is worse than no hint: the user presses it,
202
+ // nothing happens, and they read the app as hung.
203
+ ...(cancellable ? [{ keys: ["esc"], label: "cancel" }] : []),
204
+ ]}
205
+ />
85
206
  </box>
86
207
  </box>
87
208
  );
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Startup profile modal: magus opens, finds the project is not running a
3
+ * profile, and either applies the repo's declared answer or requires one.
4
+ *
5
+ * This file is only the React wiring. What to ask, which options to offer, which
6
+ * row to start on and what a chosen value means all live in
7
+ * `services/profile-startup.ts`, and carrying a choice out lives in
8
+ * `services/profile-activate.ts` — both shared with the CLI, so the two
9
+ * surfaces cannot ask different questions or write different things in answer
10
+ * to the same one.
11
+ *
12
+ * The modal cannot be dismissed. Without an active profile there is no profile
13
+ * state to read and nowhere to write updates, so letting the user past it would
14
+ * drop them into a magus that cannot do its job. Its only non-action is to
15
+ * leave, which is why it needs the app's own exit.
16
+ */
17
+
18
+ import { useCallback } from "react";
19
+ import {
20
+ type ProfileChoice,
21
+ applyProfileChoice,
22
+ } from "../../services/profile-activate.js";
23
+ import {
24
+ type ProfileStartupNeed,
25
+ buildProfileOptions,
26
+ defaultOptionIndex,
27
+ describeNeed,
28
+ parseProfileChoice,
29
+ } from "../../services/profile-startup.js";
30
+ import type { DisplacedArtifact } from "../../services/symlink-manager.js";
31
+ import { useApp, useNavigation } from "../state/AppContext.js";
32
+ import type { ModalState } from "../state/types.js";
33
+
34
+ export interface BuildProfileModalArgs {
35
+ need: ProfileStartupNeed;
36
+ onChoose: (choice: ProfileChoice) => void;
37
+ /**
38
+ * Leave magus. NOT "dismiss" — there is deliberately no way to carry on
39
+ * without a profile, because there is nothing to carry on with.
40
+ */
41
+ onQuit: () => void;
42
+ }
43
+
44
+ export function buildProfileStartupModal(
45
+ args: BuildProfileModalArgs,
46
+ ): ModalState {
47
+ const { need, onChoose, onQuit } = args;
48
+ const { title, message } = describeNeed(need);
49
+ return {
50
+ type: "select",
51
+ title,
52
+ message,
53
+ options: buildProfileOptions(need),
54
+ // The row the repo declares, so Enter does the intended thing for a
55
+ // developer who has local work and therefore still gets asked.
56
+ defaultIndex: defaultOptionIndex(need),
57
+ onSelect: (value: string) => {
58
+ const choice = parseProfileChoice(value, need);
59
+ if (choice) onChoose(choice);
60
+ else onQuit();
61
+ },
62
+ // Escape and `q` route here, and here they do NOTHING. Dismissing would
63
+ // drop the user into the app this modal exists to say is unusable. Leaving
64
+ // is the "Quit magus" row, which says so on screen rather than making the
65
+ // user guess a keystroke.
66
+ onCancel: () => {},
67
+ // ...and the footer must not offer `esc cancel` when that is what Escape
68
+ // does. The two have to agree or the app reads as hung.
69
+ cancellable: false,
70
+ };
71
+ }
72
+
73
+ /** What changed, phrased for a message modal rather than a terminal. */
74
+ export function describeOutcome(
75
+ profileId: string,
76
+ displaced: DisplacedArtifact[],
77
+ projectPath: string,
78
+ /** True when this happened without asking, following the committed default. */
79
+ automatic = false,
80
+ ): string {
81
+ const lines = [
82
+ automatic
83
+ ? `This project declares "${profileId}" as its profile, so magus activated it.`
84
+ : `"${profileId}" is now the active profile.`,
85
+ ];
86
+ if (displaced.length > 0) {
87
+ lines.push(
88
+ "",
89
+ "These were moved aside to make room for links into the profile, and are kept:",
90
+ ...displaced.map((d) => {
91
+ const from = d.link.startsWith(projectPath)
92
+ ? d.link.slice(projectPath.length + 1)
93
+ : d.link;
94
+ const to = d.savedTo.startsWith(projectPath)
95
+ ? d.savedTo.slice(projectPath.length + 1)
96
+ : d.savedTo;
97
+ return ` ${from} → ${to}`;
98
+ }),
99
+ );
100
+ }
101
+ return lines.join("\n");
102
+ }
103
+
104
+ /** Project-relative, so a path does not wrap over two lines. */
105
+ function rel(p: string, projectPath: string): string {
106
+ return p.startsWith(projectPath) ? p.slice(projectPath.length + 1) : p;
107
+ }
108
+
109
+ /**
110
+ * What to say when activation threw partway through.
111
+ *
112
+ * The hard part is that a failure is NOT always a no-op. Activation moves up to
113
+ * four artifacts in sequence and `settings.json` goes first, so a throw on the
114
+ * second leaves it already in the backup. The old message said "Nothing was
115
+ * activated" unconditionally, which in exactly that case is false about the
116
+ * user's own file — and `.magus-backups/` is gitignored, so `git status` showed
117
+ * the deletion with no sign of the copy. It read as "magus deleted my settings".
118
+ *
119
+ * So the sentence is chosen by what actually moved, and when anything did, the
120
+ * backup paths are named here rather than left in a return value the error path
121
+ * never receives.
122
+ */
123
+ export function describeFailure(
124
+ error: unknown,
125
+ moved: DisplacedArtifact[],
126
+ projectPath: string,
127
+ declaredDefault: string | null,
128
+ ): string {
129
+ const reason = error instanceof Error ? error.message : String(error);
130
+ if (moved.length === 0) {
131
+ return `${reason}\n\nNothing was moved, so this project is exactly as it was.`;
132
+ }
133
+ return [
134
+ reason,
135
+ "",
136
+ "This did not finish, and these were already moved aside. They are kept:",
137
+ ...moved.map(
138
+ (d) => ` ${rel(d.link, projectPath)} → ${rel(d.savedTo, projectPath)}`,
139
+ ),
140
+ "",
141
+ // The trap the reviewer found: re-choosing "save what you have" after a
142
+ // partial failure runs adoption again, and adoption reads settings.json —
143
+ // which is now in the backup. It would capture an empty plugin set into a
144
+ // second profile and call that the user's setup.
145
+ declaredDefault !== null
146
+ ? `Activating ${declaredDefault} again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.`
147
+ : "Activating a profile again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.",
148
+ ].join("\n");
149
+ }
150
+
151
+ export function useProfileStartupModal(onQuit: () => void) {
152
+ const { state, dispatch } = useApp();
153
+ const { navigateToScreen } = useNavigation();
154
+ const projectPath = state.projectPath;
155
+
156
+ const show = useCallback(
157
+ (need: ProfileStartupNeed) => {
158
+ /** Apply a choice, reporting the outcome either way. */
159
+ const apply = (choice: ProfileChoice, automatic: boolean): void => {
160
+ dispatch({
161
+ type: "SHOW_MODAL",
162
+ modal: {
163
+ type: "loading",
164
+ message:
165
+ choice.kind === "adopt"
166
+ ? `Creating "${choice.id}"…`
167
+ : `Activating ${choice.id}…`,
168
+ },
169
+ });
170
+ // Collected as they move, NOT read from the resolved result.
171
+ //
172
+ // Activation moves up to four artifacts in sequence and
173
+ // `settings.json` goes first, so a throw on the second leaves the
174
+ // user's file already in the backup — and the resolved value that
175
+ // would have listed it never arrives. This array is the only record
176
+ // the error path has, and without it the failure modal claimed
177
+ // "Nothing was activated" while their settings.json was gone from
178
+ // where they left it. `.magus-backups/` is gitignored, so git status
179
+ // showed the deletion and no sign of the copy.
180
+ const moved: DisplacedArtifact[] = [];
181
+ void applyProfileChoice(
182
+ choice,
183
+ need.manifest,
184
+ projectPath,
185
+ (artifact) => moved.push(artifact),
186
+ )
187
+ .then(({ profileId, displaced }) => {
188
+ dispatch({
189
+ type: "SHOW_MODAL",
190
+ modal: {
191
+ type: "message",
192
+ title: "Profile active",
193
+ message: describeOutcome(
194
+ profileId,
195
+ displaced,
196
+ projectPath,
197
+ automatic,
198
+ ),
199
+ variant: "success",
200
+ onDismiss: () => {
201
+ dispatch({ type: "HIDE_MODAL" });
202
+ // The Profiles tab is where the result is visible, and
203
+ // where the user will want to look next.
204
+ navigateToScreen("profiles");
205
+ },
206
+ },
207
+ });
208
+ })
209
+ .catch((error: unknown) => {
210
+ dispatch({
211
+ type: "SHOW_MODAL",
212
+ modal: {
213
+ type: "message",
214
+ title:
215
+ choice.kind === "adopt"
216
+ ? "Could not create the profile"
217
+ : "Could not activate the profile",
218
+ message: describeFailure(
219
+ error,
220
+ moved,
221
+ projectPath,
222
+ need.declaredDefault,
223
+ ),
224
+ variant: "error",
225
+ // A failure leaves the project without an active profile, so
226
+ // dismissing returns to the question — including when the
227
+ // attempt was automatic, which is the one case where there
228
+ // was no question on screen yet.
229
+ onDismiss: ask,
230
+ },
231
+ });
232
+ });
233
+ };
234
+
235
+ function ask(): void {
236
+ dispatch({
237
+ type: "SHOW_MODAL",
238
+ modal: buildProfileStartupModal({
239
+ need,
240
+ onChoose: (choice) => apply(choice, false),
241
+ onQuit,
242
+ }),
243
+ });
244
+ }
245
+
246
+ // The repo already answered and there is nothing of the user's to
247
+ // replace: asking here would ask someone to re-decide what their team
248
+ // decided, with nothing at stake. Reported in the outcome modal, so it
249
+ // is never invisible.
250
+ if (need.autoActivate && need.declaredDefault !== null) {
251
+ apply({ kind: "activate", id: need.declaredDefault }, true);
252
+ return;
253
+ }
254
+ ask();
255
+ },
256
+ [dispatch, navigateToScreen, projectPath, onQuit],
257
+ );
258
+
259
+ return { show };
260
+ }
@@ -68,7 +68,16 @@ export function wrapNames(names: string[], lineMax = 40): string[] {
68
68
  return lines;
69
69
  }
70
70
 
71
- export function formatDate(iso: string): string {
71
+ /**
72
+ * A profile's timestamp, or an em dash when it has none.
73
+ *
74
+ * `createdAt`/`updatedAt` are bookkeeping the TUI writes and the CLI does not,
75
+ * so a CLI-authored or hand-written profile legitimately has neither. Rendering
76
+ * "—" states that plainly; the alternative, printing "Invalid Date", reads as a
77
+ * corrupt file rather than an absent field.
78
+ */
79
+ export function formatDate(iso: string | undefined): string {
80
+ if (!iso) return "—";
72
81
  try {
73
82
  const d = new Date(iso);
74
83
  return d.toLocaleDateString("en-US", {
@@ -232,18 +241,23 @@ const predefinedRenderer: ItemRenderer<{
232
241
  const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
233
242
  renderRow: ({ item, isSelected }) => {
234
243
  const { entry } = item;
235
- const pluginCount = Object.keys(entry.plugins).length;
244
+ const pluginCount = Object.keys(entry.plugins ?? {}).length;
236
245
  const dateStr = formatDate(entry.updatedAt);
237
246
  const label = truncate(
238
247
  `${entry.name} — ${pluginCount} plugin${pluginCount !== 1 ? "s" : ""} · ${dateStr}`,
239
248
  45,
240
249
  );
250
+ // `●` for the profile the project is actually running, matching the glyph
251
+ // `magus profile list` prints. A column that is blank on every row costs
252
+ // two cells and is worth it: without the mark, activating a profile and
253
+ // landing on this tab shows two rows that look identical.
254
+ const marker = entry.active ? "●" : " ";
241
255
 
242
256
  if (isSelected) {
243
257
  return (
244
258
  <text bg={theme.selection.bg} fg={theme.selection.fg}>
245
259
  {" "}
246
- {label}{" "}
260
+ {marker} {label}{" "}
247
261
  </text>
248
262
  );
249
263
  }
@@ -254,6 +268,9 @@ const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
254
268
 
255
269
  return (
256
270
  <text fg={theme.colors.text}>
271
+ <span fg={entry.active ? theme.colors.success : theme.colors.text}>
272
+ {marker}{" "}
273
+ </span>
257
274
  <span fg={scopeColor}>{scopeLabel} </span>
258
275
  <span fg={theme.colors.text}>{label}</span>
259
276
  </text>
@@ -262,7 +279,7 @@ const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
262
279
 
263
280
  renderDetail: ({ item }) => {
264
281
  const { entry: selectedProfile } = item;
265
- const plugins = Object.keys(selectedProfile.plugins);
282
+ const plugins = Object.keys(selectedProfile.plugins ?? {});
266
283
  const cleanPlugins = plugins.map(stripSuffix);
267
284
  const scopeColor =
268
285
  selectedProfile.scope === "user"
@@ -281,6 +298,12 @@ const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
281
298
  <box marginTop={1}>
282
299
  <text fg={theme.colors.muted}>Scope: </text>
283
300
  <text fg={scopeColor}>{scopeLabel}</text>
301
+ {/* Stated in words as well as the row glyph: the detail pane is
302
+ where someone looks to check, and "●" alone is not an answer
303
+ to "is this the one that is running?". */}
304
+ {selectedProfile.active ? (
305
+ <text fg={theme.colors.success}> · active</text>
306
+ ) : null}
284
307
  </box>
285
308
  <box marginTop={1}>
286
309
  <text fg={theme.colors.muted}>