magus-cli 7.5.3 → 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__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -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,
|
|
@@ -25,10 +25,14 @@ import {
|
|
|
25
25
|
import type { DisplacedArtifact } from "../../services/profile-live.js";
|
|
26
26
|
import {
|
|
27
27
|
type ProfileStartupNeed,
|
|
28
|
+
UPDATE_FORMAT_VALUE,
|
|
28
29
|
buildProfileOptions,
|
|
29
30
|
defaultOptionIndex,
|
|
30
31
|
describeNeed,
|
|
32
|
+
formatUpdateOutcome,
|
|
31
33
|
parseProfileChoice,
|
|
34
|
+
prepareProfileStartup,
|
|
35
|
+
profileIdForName,
|
|
32
36
|
unparseableNeed,
|
|
33
37
|
} from "../../services/profile-startup.js";
|
|
34
38
|
import { applyProfileChoice } from "../../services/profile-state.js";
|
|
@@ -39,6 +43,8 @@ import type { ModalState } from "../state/types.js";
|
|
|
39
43
|
export interface BuildProfileModalArgs {
|
|
40
44
|
need: ProfileStartupNeed;
|
|
41
45
|
onChoose: (choice: ProfileChoice) => void;
|
|
46
|
+
/** "Update it now" on the `old-format` question — not a profile choice. */
|
|
47
|
+
onUpdateFormat?: () => void;
|
|
42
48
|
/**
|
|
43
49
|
* Leave magus. NOT "dismiss" — there is deliberately no way to carry on
|
|
44
50
|
* without a profile, because there is nothing to carry on with.
|
|
@@ -49,7 +55,7 @@ export interface BuildProfileModalArgs {
|
|
|
49
55
|
export function buildProfileStartupModal(
|
|
50
56
|
args: BuildProfileModalArgs,
|
|
51
57
|
): ModalState {
|
|
52
|
-
const { need, onChoose, onQuit } = args;
|
|
58
|
+
const { need, onChoose, onUpdateFormat, onQuit } = args;
|
|
53
59
|
const { title, message } = describeNeed(need);
|
|
54
60
|
return {
|
|
55
61
|
type: "select",
|
|
@@ -60,6 +66,10 @@ export function buildProfileStartupModal(
|
|
|
60
66
|
// developer who has local work and therefore still gets asked.
|
|
61
67
|
defaultIndex: defaultOptionIndex(need),
|
|
62
68
|
onSelect: (value: string) => {
|
|
69
|
+
if (value === UPDATE_FORMAT_VALUE && onUpdateFormat) {
|
|
70
|
+
onUpdateFormat();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
63
73
|
const choice = parseProfileChoice(value, need);
|
|
64
74
|
if (choice) onChoose(choice);
|
|
65
75
|
else onQuit();
|
|
@@ -157,26 +167,85 @@ export function describeFailure(
|
|
|
157
167
|
].join("\n");
|
|
158
168
|
}
|
|
159
169
|
|
|
160
|
-
export
|
|
161
|
-
|
|
170
|
+
export interface ProfileStartupModalCallbacks {
|
|
171
|
+
/** Leave magus: the modal's only non-action. */
|
|
172
|
+
onQuit: () => void;
|
|
162
173
|
/** Called once a choice has activated a profile — see App's gitignore re-check. */
|
|
163
|
-
onActivated?: () => void
|
|
164
|
-
|
|
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) {
|
|
165
200
|
const { state, dispatch } = useApp();
|
|
166
201
|
const { navigateToScreen } = useNavigation();
|
|
167
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);
|
|
168
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
|
+
*/
|
|
169
220
|
const show = useCallback(
|
|
170
221
|
(need: ProfileStartupNeed) => {
|
|
171
|
-
|
|
172
|
-
|
|
222
|
+
const answered = (): void => {
|
|
223
|
+
flowRef.current = null;
|
|
224
|
+
onAnswered();
|
|
225
|
+
};
|
|
226
|
+
const quit = (): void => {
|
|
227
|
+
answered();
|
|
228
|
+
onQuit();
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Apply a choice, reporting the outcome either way. `current` is the
|
|
233
|
+
* question it answers — after "Update it now" that is a NEW question,
|
|
234
|
+
* whose manifest is the converted one, not the one magus opened with.
|
|
235
|
+
*/
|
|
236
|
+
const apply = (
|
|
237
|
+
choice: ProfileChoice,
|
|
238
|
+
automatic: boolean,
|
|
239
|
+
current: ProfileStartupNeed = need,
|
|
240
|
+
): void => {
|
|
241
|
+
flowRef.current = { need: current, working: true };
|
|
173
242
|
dispatch({
|
|
174
243
|
type: "SHOW_MODAL",
|
|
175
244
|
modal: {
|
|
176
245
|
type: "loading",
|
|
177
246
|
message:
|
|
178
247
|
choice.kind === "adopt"
|
|
179
|
-
? `Creating "${choice.id}"…`
|
|
248
|
+
? `Creating "${choice.name ?? choice.id}"…`
|
|
180
249
|
: `Activating ${choice.id}…`,
|
|
181
250
|
},
|
|
182
251
|
});
|
|
@@ -191,10 +260,14 @@ export function useProfileStartupModal(
|
|
|
191
260
|
// where they left it. `.magus-backups/` is gitignored, so git status
|
|
192
261
|
// showed the deletion and no sign of the copy.
|
|
193
262
|
const moved: DisplacedArtifact[] = [];
|
|
194
|
-
void applyProfileChoice(choice,
|
|
263
|
+
void applyProfileChoice(choice, current.manifest, projectPath, {
|
|
195
264
|
onDisplace: (artifact) => moved.push(artifact),
|
|
196
265
|
})
|
|
197
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();
|
|
198
271
|
onActivated?.();
|
|
199
272
|
const { profileId, displaced } = result;
|
|
200
273
|
dispatch({
|
|
@@ -228,7 +301,10 @@ export function useProfileStartupModal(
|
|
|
228
301
|
error instanceof SettingsParseError &&
|
|
229
302
|
error.path === getManifestPath(projectPath)
|
|
230
303
|
? unparseableNeed(error)
|
|
231
|
-
:
|
|
304
|
+
: current;
|
|
305
|
+
// Still unanswered, and `next` is what a lost error modal
|
|
306
|
+
// returns to.
|
|
307
|
+
flowRef.current = { need: next, working: false };
|
|
232
308
|
dispatch({
|
|
233
309
|
type: "SHOW_MODAL",
|
|
234
310
|
modal: {
|
|
@@ -241,7 +317,7 @@ export function useProfileStartupModal(
|
|
|
241
317
|
error,
|
|
242
318
|
moved,
|
|
243
319
|
projectPath,
|
|
244
|
-
|
|
320
|
+
current.declaredDefault,
|
|
245
321
|
),
|
|
246
322
|
variant: "error",
|
|
247
323
|
// A failure leaves the project without an active profile, so
|
|
@@ -254,17 +330,140 @@ export function useProfileStartupModal(
|
|
|
254
330
|
});
|
|
255
331
|
};
|
|
256
332
|
|
|
333
|
+
/**
|
|
334
|
+
* "Update it now" on an old profile format: update it — the same gate
|
|
335
|
+
* the CLI runs after its own question — then ask whatever is left, or
|
|
336
|
+
* say it is done.
|
|
337
|
+
*/
|
|
338
|
+
const updateFormat = (): void => {
|
|
339
|
+
flowRef.current = { need, working: true };
|
|
340
|
+
dispatch({
|
|
341
|
+
type: "SHOW_MODAL",
|
|
342
|
+
modal: { type: "loading", message: "Updating the profile format…" },
|
|
343
|
+
});
|
|
344
|
+
void prepareProfileStartup(projectPath, { convert: true })
|
|
345
|
+
.then((result) => {
|
|
346
|
+
const outcome = formatUpdateOutcome(result);
|
|
347
|
+
if (outcome.kind === "ask") {
|
|
348
|
+
// Converted, but no profile is active yet: a new question,
|
|
349
|
+
// still unanswered.
|
|
350
|
+
onActivated?.();
|
|
351
|
+
ask(outcome.need);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (outcome.kind === "failed") {
|
|
355
|
+
// Refused, so still the old format: back to the question,
|
|
356
|
+
// as the CLI stops on the same lines.
|
|
357
|
+
flowRef.current = { need, working: false };
|
|
358
|
+
dispatch({
|
|
359
|
+
type: "SHOW_MODAL",
|
|
360
|
+
modal: {
|
|
361
|
+
type: "message",
|
|
362
|
+
title: outcome.title,
|
|
363
|
+
message: outcome.message,
|
|
364
|
+
variant: "error",
|
|
365
|
+
onDismiss: () => ask(need),
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Nothing left to ask: the converted profile is active.
|
|
371
|
+
answered();
|
|
372
|
+
onActivated?.();
|
|
373
|
+
dispatch({
|
|
374
|
+
type: "SHOW_MODAL",
|
|
375
|
+
modal: {
|
|
376
|
+
type: "message",
|
|
377
|
+
title: outcome.title,
|
|
378
|
+
message: outcome.message,
|
|
379
|
+
variant: "success",
|
|
380
|
+
onDismiss: () => {
|
|
381
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
382
|
+
navigateToScreen("profiles");
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
})
|
|
387
|
+
.catch((error: unknown) => {
|
|
388
|
+
flowRef.current = { need, working: false };
|
|
389
|
+
dispatch({
|
|
390
|
+
type: "SHOW_MODAL",
|
|
391
|
+
modal: {
|
|
392
|
+
type: "message",
|
|
393
|
+
title: "Could not update the profile format",
|
|
394
|
+
message: `${error instanceof Error ? error.message : String(error)}\n\nYour profiles are kept. Open magus again to retry.`,
|
|
395
|
+
variant: "error",
|
|
396
|
+
onDismiss: () => ask(need),
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
};
|
|
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
|
+
|
|
257
441
|
function ask(current: ProfileStartupNeed = need): void {
|
|
442
|
+
flowRef.current = { need: current, working: false };
|
|
258
443
|
dispatch({
|
|
259
444
|
type: "SHOW_MODAL",
|
|
260
445
|
modal: buildProfileStartupModal({
|
|
261
446
|
need: current,
|
|
262
|
-
onChoose: (choice) =>
|
|
263
|
-
|
|
447
|
+
onChoose: (choice) =>
|
|
448
|
+
choice.kind === "adopt"
|
|
449
|
+
? askName(choice.id, current)
|
|
450
|
+
: apply(choice, false, current),
|
|
451
|
+
onUpdateFormat: updateFormat,
|
|
452
|
+
onQuit: quit,
|
|
264
453
|
}),
|
|
265
454
|
});
|
|
266
455
|
}
|
|
267
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
|
+
|
|
268
467
|
// The repo already answered and there is nothing of the user's to
|
|
269
468
|
// replace: asking here would ask someone to re-decide what their team
|
|
270
469
|
// decided, with nothing at stake. Reported in the outcome modal, so it
|
|
@@ -275,7 +474,7 @@ export function useProfileStartupModal(
|
|
|
275
474
|
}
|
|
276
475
|
ask();
|
|
277
476
|
},
|
|
278
|
-
[dispatch, navigateToScreen, projectPath, onQuit, onActivated],
|
|
477
|
+
[dispatch, navigateToScreen, projectPath, onQuit, onActivated, onAnswered],
|
|
279
478
|
);
|
|
280
479
|
|
|
281
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);
|
package/src/ui/keymap.ts
CHANGED
|
@@ -2,8 +2,11 @@ import type React from "react";
|
|
|
2
2
|
import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
|
|
3
3
|
import {
|
|
4
4
|
compareVersions,
|
|
5
|
-
|
|
5
|
+
hasMissingFiles,
|
|
6
|
+
isInstalledAnywhere,
|
|
6
7
|
isInstalledInScope,
|
|
8
|
+
scopeFilesMissing,
|
|
9
|
+
versionInScope,
|
|
7
10
|
} from "../../services/plugin-manager.js";
|
|
8
11
|
import type { ScopeStatus } from "../../services/plugin-manager.js";
|
|
9
12
|
import type { PluginComponentKind } from "../../services/skills-manager.js";
|
|
@@ -195,9 +198,12 @@ function pluginRow(
|
|
|
195
198
|
isSelected: boolean,
|
|
196
199
|
): React.ReactNode {
|
|
197
200
|
const { plugin } = item;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
// The squares and the name colour read the one installed-at-scope test, the
|
|
202
|
+
// same one the detail panel's dots read, so the row and the panel beside it
|
|
203
|
+
// cannot show two different states.
|
|
204
|
+
const hasUser = isInstalledInScope(plugin.userScope);
|
|
205
|
+
const hasProject = isInstalledInScope(plugin.projectScope);
|
|
206
|
+
const hasLocal = isInstalledInScope(plugin.localScope);
|
|
201
207
|
const hasAnyScope = hasUser || hasProject || hasLocal;
|
|
202
208
|
|
|
203
209
|
// Everything after the name — version, history badge, status — comes from
|
|
@@ -257,24 +263,13 @@ function pluginRow(
|
|
|
257
263
|
);
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
const installedSomewhere =
|
|
261
|
-
isInstalledInScope(plugin.userScope) ||
|
|
262
|
-
isInstalledInScope(plugin.projectScope) ||
|
|
263
|
-
isInstalledInScope(plugin.localScope);
|
|
264
|
-
|
|
265
266
|
return (
|
|
266
267
|
<text fg={theme.colors.text}>
|
|
267
268
|
<span> </span>
|
|
268
269
|
<ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
|
|
269
270
|
<span> </span>
|
|
270
271
|
<span fg={theme.colors.dim}>{arrow}</span>
|
|
271
|
-
<span
|
|
272
|
-
fg={
|
|
273
|
-
hasAnyScope && installedSomewhere
|
|
274
|
-
? theme.colors.text
|
|
275
|
-
: theme.colors.muted
|
|
276
|
-
}
|
|
277
|
-
>
|
|
272
|
+
<span fg={hasAnyScope ? theme.colors.text : theme.colors.muted}>
|
|
278
273
|
{displayName}
|
|
279
274
|
</span>
|
|
280
275
|
<RowSegments segments={segments} selected={false} />
|
|
@@ -344,14 +339,10 @@ function pluginDetail(
|
|
|
344
339
|
fetched?: PluginContentsEntry,
|
|
345
340
|
): React.ReactNode {
|
|
346
341
|
const { plugin, status } = item;
|
|
347
|
-
// "Installed"
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
isInstalledInScope(plugin.userScope) ||
|
|
352
|
-
isInstalledInScope(plugin.projectScope) ||
|
|
353
|
-
isInstalledInScope(plugin.localScope);
|
|
354
|
-
const brokenInstall = isEnabledButNotInstalled(plugin);
|
|
342
|
+
// "Installed" means the same thing here as in the list row: the one
|
|
343
|
+
// installed-at-scope test, at any scope.
|
|
344
|
+
const isInstalled = isInstalledAnywhere(plugin);
|
|
345
|
+
const filesMissing = hasMissingFiles(plugin);
|
|
355
346
|
// The ⬆ marker and the U hint claim that pressing U does something. That is
|
|
356
347
|
// exactly the classifier's update/repair, and nothing else.
|
|
357
348
|
const needsWork = status?.action === "update" || status?.action === "repair";
|
|
@@ -453,25 +444,25 @@ function pluginDetail(
|
|
|
453
444
|
<box marginTop={1}>
|
|
454
445
|
<text
|
|
455
446
|
fg={
|
|
456
|
-
|
|
457
|
-
? theme.colors.
|
|
458
|
-
:
|
|
459
|
-
? theme.colors.
|
|
447
|
+
filesMissing
|
|
448
|
+
? theme.colors.danger
|
|
449
|
+
: isInstalled
|
|
450
|
+
? theme.colors.success
|
|
460
451
|
: theme.colors.muted
|
|
461
452
|
}
|
|
462
453
|
>
|
|
463
|
-
{
|
|
464
|
-
? "● Installed"
|
|
465
|
-
:
|
|
466
|
-
? "
|
|
454
|
+
{filesMissing
|
|
455
|
+
? "● Installed — files missing"
|
|
456
|
+
: isInstalled
|
|
457
|
+
? "● Installed"
|
|
467
458
|
: "○ Not installed"}
|
|
468
459
|
</text>
|
|
469
460
|
</box>
|
|
470
|
-
{
|
|
461
|
+
{filesMissing ? (
|
|
471
462
|
<box>
|
|
472
463
|
<text fg={theme.colors.muted}>
|
|
473
464
|
Claude Code cannot load it in this state. Press the scope key to
|
|
474
|
-
|
|
465
|
+
reinstall.
|
|
475
466
|
</text>
|
|
476
467
|
</box>
|
|
477
468
|
) : null}
|
|
@@ -631,10 +622,15 @@ function pluginDetail(
|
|
|
631
622
|
/**
|
|
632
623
|
* One line of the detail panel's scope breakdown.
|
|
633
624
|
*
|
|
634
|
-
* The filled dot means
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
625
|
+
* The filled dot means what the list row's square means — installed at this
|
|
626
|
+
* scope — because both read `isInstalledInScope`. The version shows only where
|
|
627
|
+
* the plugin is installed: a registry row can be inherited from another
|
|
628
|
+
* directory of the same repository, and magus run in `tools/magus` once showed
|
|
629
|
+
* "Project team v0.8.1" for a plugin that directory's settings never turn on and
|
|
630
|
+
* Claude Code started there never loads.
|
|
631
|
+
*
|
|
632
|
+
* Installed with no version is named, in the danger colour, because it needs an
|
|
633
|
+
* action — the scope key reinstalls — and a plain dot would read as healthy.
|
|
638
634
|
*/
|
|
639
635
|
function ScopeLine({
|
|
640
636
|
keyHint,
|
|
@@ -647,18 +643,16 @@ function ScopeLine({
|
|
|
647
643
|
qualifier: string;
|
|
648
644
|
scope?: ScopeStatus;
|
|
649
645
|
}): React.ReactNode {
|
|
650
|
-
const
|
|
646
|
+
const version = versionInScope(scope);
|
|
651
647
|
return (
|
|
652
648
|
<text fg={theme.colors.text}>
|
|
653
649
|
<ScopeKeyChip scopeKey={keyHint} />
|
|
654
|
-
<ScopeDot installed={
|
|
650
|
+
<ScopeDot installed={isInstalledInScope(scope)} />
|
|
655
651
|
<span>{name}</span>
|
|
656
652
|
<span fg={theme.colors.muted}> {qualifier}</span>
|
|
657
|
-
{
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
{!installed && scope?.enabled ? (
|
|
661
|
-
<span fg={theme.colors.danger}> enabled, not installed</span>
|
|
653
|
+
{version ? <span fg={theme.colors.info}> v{version}</span> : null}
|
|
654
|
+
{scopeFilesMissing(scope) ? (
|
|
655
|
+
<span fg={theme.colors.danger}> files missing</span>
|
|
662
656
|
) : null}
|
|
663
657
|
</text>
|
|
664
658
|
);
|
|
@@ -38,8 +38,12 @@ export function pluginRowText(item: PluginPluginItem): PluginRowText {
|
|
|
38
38
|
// A version is shown only when it carries information. "0.0.0" means
|
|
39
39
|
// installed with an unknown version (Anthropic's official plugins record
|
|
40
40
|
// exactly that) — it is never printed, and nothing below depends on it.
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
//
|
|
42
|
+
// The classifier's version and nothing else. A fallback to
|
|
43
|
+
// `plugin.installedVersion` printed a version where the classifier had none —
|
|
44
|
+
// "v0.8.1 files missing", with 0.8.1 read off a scope not installed here.
|
|
45
|
+
const installed = status?.installed ?? undefined;
|
|
46
|
+
const shown = isKnownVersion(installed) ? installed : undefined;
|
|
43
47
|
|
|
44
48
|
// "What just changed" and "what state it is in now" are independent facts
|
|
45
49
|
// and both can be true at once: an external update can move a plugin
|
|
@@ -57,13 +57,14 @@ export function pluginStatusText(
|
|
|
57
57
|
return { suffix: " BROKEN — press a to fix", tone: "danger" };
|
|
58
58
|
case "install":
|
|
59
59
|
// `absent` only ever comes from a profile pin (see `pinForRow`), so it
|
|
60
|
-
// may say so. An undeclared row
|
|
60
|
+
// may say so. An undeclared row whose files are missing must not.
|
|
61
61
|
return status.reason === "absent"
|
|
62
62
|
? { suffix: " not installed — profile declares it", tone: "warning" }
|
|
63
|
-
: //
|
|
63
|
+
: // Files missing is installed, and broken — never "not installed",
|
|
64
|
+
// which would contradict the filled square beside it. A missing
|
|
64
65
|
// declared plugin is routine work. Red against amber is the
|
|
65
66
|
// distinction that matters.
|
|
66
|
-
{ suffix: `
|
|
67
|
+
{ suffix: ` ${status.note ?? "files missing"}`, tone: "danger" };
|
|
67
68
|
case "current":
|
|
68
69
|
return { suffix: withNote, tone: "muted" };
|
|
69
70
|
}
|