magus-cli 7.5.4 → 7.6.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.
@@ -15,7 +15,7 @@
15
15
  * leave, which is why it needs the app's own exit.
16
16
  */
17
17
 
18
- import { useCallback } from "react";
18
+ import { useCallback, useRef } from "react";
19
19
  import { getManifestPath } from "../../services/manifest.js";
20
20
  import {
21
21
  type ActivationResult,
@@ -32,6 +32,7 @@ import {
32
32
  formatUpdateOutcome,
33
33
  parseProfileChoice,
34
34
  prepareProfileStartup,
35
+ profileIdForName,
35
36
  unparseableNeed,
36
37
  } from "../../services/profile-startup.js";
37
38
  import { applyProfileChoice } from "../../services/profile-state.js";
@@ -166,17 +167,67 @@ export function describeFailure(
166
167
  ].join("\n");
167
168
  }
168
169
 
169
- export function useProfileStartupModal(
170
- onQuit: () => void,
170
+ export interface ProfileStartupModalCallbacks {
171
+ /** Leave magus: the modal's only non-action. */
172
+ onQuit: () => void;
171
173
  /** Called once a choice has activated a profile — see App's gitignore re-check. */
172
- onActivated?: () => void,
173
- ) {
174
+ onActivated?: () => void;
175
+ /**
176
+ * The question is ANSWERED: a profile is active, or the user chose to quit.
177
+ * Until this fires the caller must treat the question as outstanding and
178
+ * call `show` again whenever the modal slot is empty — see `show`.
179
+ */
180
+ onAnswered: () => void;
181
+ }
182
+
183
+ /**
184
+ * Where the profile question stands between `show` calls. `need` is the
185
+ * question on screen NOW, which is not always the one magus opened with: "Update
186
+ * it now" asks a new one, and a manifest that stopped parsing turns the question
187
+ * into the unparseable one. `working` is true while an activation or a format
188
+ * update is running — its result modal takes the slot when it settles.
189
+ */
190
+ interface ProfileFlow {
191
+ need: ProfileStartupNeed;
192
+ working: boolean;
193
+ }
194
+
195
+ export function useProfileStartupModal({
196
+ onQuit,
197
+ onActivated,
198
+ onAnswered,
199
+ }: ProfileStartupModalCallbacks) {
174
200
  const { state, dispatch } = useApp();
175
201
  const { navigateToScreen } = useNavigation();
176
202
  const projectPath = state.projectPath;
203
+ // A ref, not state: `show` is recreated on every render, and the flow must
204
+ // survive that — a re-show has to know what was last asked, and that work is
205
+ // in flight, without a render in between.
206
+ const flowRef = useRef<ProfileFlow | null>(null);
177
207
 
208
+ /**
209
+ * Show the profile question, or put it back.
210
+ *
211
+ * The caller calls this again whenever the modal slot is empty and the
212
+ * question is unanswered. The modal cannot be dismissed, so an empty slot
213
+ * then means something else took the slot and closed it — a screen's
214
+ * `confirm` resolving, say, before its loading modal lands. So a second call
215
+ * re-asks the question that was on screen, and never re-runs an automatic
216
+ * activation. While work is in flight it does nothing: that work puts its own
217
+ * result up when it settles, and asking again meanwhile would let a second
218
+ * choice race the first.
219
+ */
178
220
  const show = useCallback(
179
221
  (need: ProfileStartupNeed) => {
222
+ const answered = (): void => {
223
+ flowRef.current = null;
224
+ onAnswered();
225
+ };
226
+ const quit = (): void => {
227
+ answered();
228
+ onQuit();
229
+ };
230
+
180
231
  /**
181
232
  * Apply a choice, reporting the outcome either way. `current` is the
182
233
  * question it answers — after "Update it now" that is a NEW question,
@@ -187,13 +238,14 @@ export function useProfileStartupModal(
187
238
  automatic: boolean,
188
239
  current: ProfileStartupNeed = need,
189
240
  ): void => {
241
+ flowRef.current = { need: current, working: true };
190
242
  dispatch({
191
243
  type: "SHOW_MODAL",
192
244
  modal: {
193
245
  type: "loading",
194
246
  message:
195
247
  choice.kind === "adopt"
196
- ? `Creating "${choice.id}"…`
248
+ ? `Creating "${choice.name ?? choice.id}"…`
197
249
  : `Activating ${choice.id}…`,
198
250
  },
199
251
  });
@@ -212,6 +264,10 @@ export function useProfileStartupModal(
212
264
  onDisplace: (artifact) => moved.push(artifact),
213
265
  })
214
266
  .then((result: ActivationResult) => {
267
+ // Answered here, on success — NOT when the question was shown.
268
+ // Clearing it on show let a screen's modal replace it and the
269
+ // question never came back.
270
+ answered();
215
271
  onActivated?.();
216
272
  const { profileId, displaced } = result;
217
273
  dispatch({
@@ -246,6 +302,9 @@ export function useProfileStartupModal(
246
302
  error.path === getManifestPath(projectPath)
247
303
  ? unparseableNeed(error)
248
304
  : current;
305
+ // Still unanswered, and `next` is what a lost error modal
306
+ // returns to.
307
+ flowRef.current = { need: next, working: false };
249
308
  dispatch({
250
309
  type: "SHOW_MODAL",
251
310
  modal: {
@@ -277,6 +336,7 @@ export function useProfileStartupModal(
277
336
  * say it is done.
278
337
  */
279
338
  const updateFormat = (): void => {
339
+ flowRef.current = { need, working: true };
280
340
  dispatch({
281
341
  type: "SHOW_MODAL",
282
342
  modal: { type: "loading", message: "Updating the profile format…" },
@@ -285,6 +345,8 @@ export function useProfileStartupModal(
285
345
  .then((result) => {
286
346
  const outcome = formatUpdateOutcome(result);
287
347
  if (outcome.kind === "ask") {
348
+ // Converted, but no profile is active yet: a new question,
349
+ // still unanswered.
288
350
  onActivated?.();
289
351
  ask(outcome.need);
290
352
  return;
@@ -292,6 +354,7 @@ export function useProfileStartupModal(
292
354
  if (outcome.kind === "failed") {
293
355
  // Refused, so still the old format: back to the question,
294
356
  // as the CLI stops on the same lines.
357
+ flowRef.current = { need, working: false };
295
358
  dispatch({
296
359
  type: "SHOW_MODAL",
297
360
  modal: {
@@ -304,6 +367,8 @@ export function useProfileStartupModal(
304
367
  });
305
368
  return;
306
369
  }
370
+ // Nothing left to ask: the converted profile is active.
371
+ answered();
307
372
  onActivated?.();
308
373
  dispatch({
309
374
  type: "SHOW_MODAL",
@@ -320,6 +385,7 @@ export function useProfileStartupModal(
320
385
  });
321
386
  })
322
387
  .catch((error: unknown) => {
388
+ flowRef.current = { need, working: false };
323
389
  dispatch({
324
390
  type: "SHOW_MODAL",
325
391
  modal: {
@@ -333,18 +399,71 @@ export function useProfileStartupModal(
333
399
  });
334
400
  };
335
401
 
402
+ /**
403
+ * "Save what you have" picked: ask what to call it, with the generated
404
+ * id as the suggestion. An invalid name is explained above the field and
405
+ * asked again with what was typed still in it; Escape creates nothing
406
+ * and goes back to the profile question.
407
+ */
408
+ const askName = (
409
+ suggestion: string,
410
+ current: ProfileStartupNeed,
411
+ typed: string = suggestion,
412
+ reason?: string,
413
+ ): void => {
414
+ dispatch({
415
+ type: "SHOW_MODAL",
416
+ modal: {
417
+ type: "input",
418
+ title: "Save as a new profile",
419
+ label: reason ? `${reason}\n\nProfile name` : "Profile name",
420
+ defaultValue: typed,
421
+ onSubmit: (value: string) => {
422
+ const check = profileIdForName(
423
+ value.trim() === "" ? suggestion : value,
424
+ current.manifest,
425
+ );
426
+ if (!check.ok) {
427
+ askName(suggestion, current, value, check.reason);
428
+ return;
429
+ }
430
+ apply(
431
+ { kind: "adopt", id: check.id, name: check.name },
432
+ false,
433
+ current,
434
+ );
435
+ },
436
+ onCancel: () => ask(current),
437
+ },
438
+ });
439
+ };
440
+
336
441
  function ask(current: ProfileStartupNeed = need): void {
442
+ flowRef.current = { need: current, working: false };
337
443
  dispatch({
338
444
  type: "SHOW_MODAL",
339
445
  modal: buildProfileStartupModal({
340
446
  need: current,
341
- onChoose: (choice) => apply(choice, false, current),
447
+ onChoose: (choice) =>
448
+ choice.kind === "adopt"
449
+ ? askName(choice.id, current)
450
+ : apply(choice, false, current),
342
451
  onUpdateFormat: updateFormat,
343
- onQuit,
452
+ onQuit: quit,
344
453
  }),
345
454
  });
346
455
  }
347
456
 
457
+ const flow = flowRef.current;
458
+ // Work in flight owns the slot's next state; see the doc comment above.
459
+ if (flow?.working) return;
460
+ // Shown before and lost: put back the question that was on screen, not
461
+ // the one magus opened with, and never re-run an automatic activation.
462
+ if (flow) {
463
+ ask(flow.need);
464
+ return;
465
+ }
466
+
348
467
  // The repo already answered and there is nothing of the user's to
349
468
  // replace: asking here would ask someone to re-decide what their team
350
469
  // decided, with nothing at stake. Reported in the outcome modal, so it
@@ -355,7 +474,7 @@ export function useProfileStartupModal(
355
474
  }
356
475
  ask();
357
476
  },
358
- [dispatch, navigateToScreen, projectPath, onQuit, onActivated],
477
+ [dispatch, navigateToScreen, projectPath, onQuit, onActivated, onAnswered],
359
478
  );
360
479
 
361
480
  return { show };
@@ -91,7 +91,11 @@ export function useStaleLoadModal() {
91
91
  // the loader after writing and can legitimately answer "still not
92
92
  // loading the target"; swallowing that would leave the user believing
93
93
  // a broken plugin was fixed.
94
- void Promise.resolve(args.onFix())
94
+ //
95
+ // Called inside `.then` so a synchronous throw reaches `.catch`
96
+ // too; otherwise the loading modal stays up and swallows every key.
97
+ void Promise.resolve()
98
+ .then(() => args.onFix())
95
99
  .then((summary) => {
96
100
  dispatch({
97
101
  type: "SHOW_MODAL",
@@ -25,6 +25,10 @@
25
25
  * The paste event is deliberately NOT stopped: OpenTUI hands it to the focused
26
26
  * renderable afterwards, which is how the input modal's `<input>` still takes a
27
27
  * paste of its own — exactly the arrangement the modal layer relies on for keys.
28
+ *
29
+ * With ONE exception, for keys and pastes alike: an event never reaches a
30
+ * renderable that took focus while this router was dispatching it. See
31
+ * {@link dispatchThenGuardFocus}.
28
32
  */
29
33
 
30
34
  import { appendFileSync } from "node:fs";
@@ -133,6 +137,41 @@ function describeKey(key: KeyPress): string {
133
137
  return `name=${JSON.stringify(key.name)} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} meta=${key.meta} shift=${key.shift}`;
134
138
  }
135
139
 
140
+ /**
141
+ * Run `dispatch` for one key or paste event, committed synchronously, and then
142
+ * keep that event away from any renderable that took focus DURING it.
143
+ *
144
+ * OpenTUI delivers an event in two phases: every global listener first (this
145
+ * router is one), then every focused renderable. The renderable set is read
146
+ * AFTER the global phase returns (`InternalKeyHandler.emitWithPriority` in
147
+ * @opentui/core 0.1.107), not when the event arrived. And `flushSync` commits
148
+ * inside the global phase, so a modal this key opened is mounted, and its
149
+ * `<input focused>` has registered its handler, before that read happens.
150
+ * Left alone, the one keypress acts twice. MEASURED: Enter on "Save what you
151
+ * have" in the startup select opened the "Save as a new profile" input AND
152
+ * submitted its prefilled name, so the profile was created with no chance to
153
+ * type. By the same path, a screen key that opens an input before its first
154
+ * `await` (`n` on Styles, `r` on Profiles) reaches the new box as a typed
155
+ * letter, and Enter on such a row submits it.
156
+ *
157
+ * So the rule: an event goes to the renderable that held focus when it was
158
+ * pressed, or to nobody. When focus moved during dispatch, `preventDefault()`
159
+ * makes OpenTUI skip the renderable phase. Keys meant for an input modal that
160
+ * was already open are untouched: the router's modal layer does not move focus
161
+ * for them, and they reach the `<input>` as before.
162
+ */
163
+ function dispatchThenGuardFocus(
164
+ renderer: { readonly currentFocusedRenderable: unknown },
165
+ event: { preventDefault(): void },
166
+ dispatch: () => void,
167
+ ): void {
168
+ const focusedBefore = renderer.currentFocusedRenderable;
169
+ flushSync(dispatch);
170
+ if (renderer.currentFocusedRenderable !== focusedBefore) {
171
+ event.preventDefault();
172
+ }
173
+ }
174
+
136
175
  export function KeyRouter({ children }: { children: ReactNode }) {
137
176
  const [layers] = useState<LayerRegistry>(() => ({
138
177
  keys: new Set<KeyLayer>(),
@@ -143,7 +182,7 @@ export function KeyRouter({ children }: { children: ReactNode }) {
143
182
  useKeyboard((event) => {
144
183
  const key = toKeyPress(event);
145
184
  let consumer: KeyLayerId | null = null;
146
- flushSync(() => {
185
+ dispatchThenGuardFocus(renderer, event, () => {
147
186
  consumer = dispatchKey([...layers.keys], key);
148
187
  });
149
188
  logKey(describeKey(key), consumer);
@@ -156,7 +195,9 @@ export function KeyRouter({ children }: { children: ReactNode }) {
156
195
  const onPaste = (event: PasteEvent): void => {
157
196
  const text = pastedText(event);
158
197
  let consumer: KeyLayerId | null = null;
159
- flushSync(() => {
198
+ // Same guard as keys: no paste handler moves focus today, but a paste
199
+ // that did must not land in the box it just opened.
200
+ dispatchThenGuardFocus(renderer, event, () => {
160
201
  consumer = dispatchPaste([...layers.pastes], text);
161
202
  });
162
203
  logKey(`paste=${JSON.stringify(text)}`, consumer);