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.
- package/package.json +4 -4
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/cli/bootstrap.ts +34 -2
- package/src/cli/prompt.ts +12 -2
- package/src/cli/update-view.ts +6 -4
- package/src/services/profile-activate.ts +2 -1
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +46 -1
- package/src/services/profile-state.ts +1 -1
- package/src/ui/App.tsx +89 -54
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +128 -9
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
|
@@ -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
|
|
170
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|