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.
- package/package.json +4 -4
- package/src/__tests__/format-update-outcome.test.ts +9 -8
- package/src/__tests__/gitignore-profile.test.ts +9 -5
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/notices-rules.test.ts +476 -0
- package/src/__tests__/notices.test.tsx +604 -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/__tests__/update-apply.test.ts +3 -3
- package/src/__tests__/useStaleLoadModal.test.ts +27 -12
- 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/gitignore-service.ts +0 -19
- package/src/services/profile-absorb.ts +9 -0
- package/src/services/profile-activate.ts +4 -2
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +91 -18
- package/src/services/profile-state.ts +10 -1
- package/src/ui/App.tsx +295 -141
- package/src/ui/adapters/pluginsAdapter.ts +28 -9
- package/src/ui/components/modals/MessageModal.tsx +1 -2
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/hooks/useGitignoreModal.ts +58 -18
- package/src/ui/hooks/useProfileStartupModal.ts +232 -85
- package/src/ui/hooks/useStaleLoadModal.ts +43 -29
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/screens/AliasScreen.tsx +7 -6
- package/src/ui/screens/CliToolsScreen.tsx +19 -16
- package/src/ui/screens/EnvVarsScreen.tsx +4 -1
- package/src/ui/screens/GitignoreScreen.tsx +20 -27
- package/src/ui/screens/McpRegistryScreen.tsx +9 -6
- package/src/ui/screens/McpScreen.tsx +16 -12
- package/src/ui/screens/PluginsScreen.tsx +35 -56
- package/src/ui/screens/ProfilesScreen.tsx +27 -51
- package/src/ui/state/AppContext.tsx +46 -3
- package/src/ui/state/reducer.ts +100 -1
- package/src/ui/state/types.ts +53 -2
package/src/ui/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useState } from "react";
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { activePluginPins } from "../services/active-closure.js";
|
|
3
3
|
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
4
4
|
import {
|
|
@@ -11,10 +11,7 @@ import {
|
|
|
11
11
|
type StartupGitignoreNeed,
|
|
12
12
|
startupGitignoreNeed,
|
|
13
13
|
} from "../services/gitignore-check.js";
|
|
14
|
-
import {
|
|
15
|
-
applyStartupFixes,
|
|
16
|
-
describeStartupFix,
|
|
17
|
-
} from "../services/gitignore-service.js";
|
|
14
|
+
import { applyStartupFixes } from "../services/gitignore-service.js";
|
|
18
15
|
import { repairAllMarketplaces } from "../services/local-marketplace.js";
|
|
19
16
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
20
17
|
import {
|
|
@@ -31,10 +28,16 @@ import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
|
|
|
31
28
|
import { TABS } from "./components/TabBar.js";
|
|
32
29
|
import { ProgressBar } from "./components/layout/ProgressBar.js";
|
|
33
30
|
import { ModalContainer } from "./components/modals/index.js";
|
|
34
|
-
import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
|
|
35
|
-
import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
|
|
36
31
|
import {
|
|
37
|
-
|
|
32
|
+
startupFixNotices,
|
|
33
|
+
useGitignoreModal,
|
|
34
|
+
} from "./hooks/useGitignoreModal.js";
|
|
35
|
+
import {
|
|
36
|
+
activationWarnings,
|
|
37
|
+
useProfileStartupModal,
|
|
38
|
+
} from "./hooks/useProfileStartupModal.js";
|
|
39
|
+
import {
|
|
40
|
+
staleLoadRepairNotices,
|
|
38
41
|
useStaleLoadModal,
|
|
39
42
|
} from "./hooks/useStaleLoadModal.js";
|
|
40
43
|
import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
|
|
@@ -57,12 +60,14 @@ import {
|
|
|
57
60
|
useApp,
|
|
58
61
|
useModal,
|
|
59
62
|
useNavigation,
|
|
63
|
+
useNotify,
|
|
60
64
|
} from "./state/AppContext.js";
|
|
61
65
|
import {
|
|
62
66
|
DimensionsProvider,
|
|
63
67
|
useDimensions,
|
|
64
68
|
} from "./state/DimensionsContext.js";
|
|
65
|
-
import type { Screen } from "./state/types.js";
|
|
69
|
+
import type { Notice, Screen } from "./state/types.js";
|
|
70
|
+
import { truncate } from "./text-width.js";
|
|
66
71
|
import { theme } from "./theme.js";
|
|
67
72
|
|
|
68
73
|
export const VERSION = getCurrentVersion();
|
|
@@ -180,6 +185,72 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
|
|
|
180
185
|
);
|
|
181
186
|
}
|
|
182
187
|
|
|
188
|
+
/**
|
|
189
|
+
* NoticeBanner Component
|
|
190
|
+
* The app's one notice line (or a few): what an action did, from `useNotify`.
|
|
191
|
+
* It sits above the screen and outside the modal slot, so it never takes a key
|
|
192
|
+
* and never stands in the way of a startup question. Each line clears itself
|
|
193
|
+
* at its `expiresAt` — counting only time with no modal open.
|
|
194
|
+
*
|
|
195
|
+
* One notice is exactly ONE row: the layout reserves one row per notice
|
|
196
|
+
* (`transientBannerCount`), so a line that wrapped would push the screen's
|
|
197
|
+
* last rows off the terminal. Hence no wrapping, and the text clipped to the
|
|
198
|
+
* width — the start of a line says what happened, and that is what survives.
|
|
199
|
+
*/
|
|
200
|
+
function NoticeBanner({ notices }: { notices: readonly Notice[] }) {
|
|
201
|
+
const { state, dispatch } = useApp();
|
|
202
|
+
const { terminalWidth } = useDimensions();
|
|
203
|
+
const modalOpen = state.modal !== null;
|
|
204
|
+
const paused = state.noticesPausedAt !== null;
|
|
205
|
+
|
|
206
|
+
// Hold the notices while a modal is open. A warning posted just before a
|
|
207
|
+
// startup question, or under it, would otherwise run down behind it and be
|
|
208
|
+
// gone by the time the user has answered.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
dispatch({
|
|
211
|
+
type: modalOpen ? "PAUSE_NOTICES" : "RESUME_NOTICES",
|
|
212
|
+
now: Date.now(),
|
|
213
|
+
});
|
|
214
|
+
}, [modalOpen, dispatch]);
|
|
215
|
+
|
|
216
|
+
// One timer, for the line that expires first; the reducer then drops every
|
|
217
|
+
// line past its time, and the next render schedules the one after. None
|
|
218
|
+
// while paused: RESUME_NOTICES moves every `expiresAt` on first.
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (notices.length === 0 || paused) return;
|
|
221
|
+
const next = Math.min(...notices.map((n) => n.expiresAt));
|
|
222
|
+
const timer = setTimeout(
|
|
223
|
+
() => dispatch({ type: "EXPIRE_NOTICES", now: Date.now() }),
|
|
224
|
+
Math.max(0, next - Date.now()),
|
|
225
|
+
);
|
|
226
|
+
return () => clearTimeout(timer);
|
|
227
|
+
}, [notices, paused, dispatch]);
|
|
228
|
+
|
|
229
|
+
// The box's left and right padding.
|
|
230
|
+
const width = Math.max(0, terminalWidth - 2);
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
{notices.map((notice) => (
|
|
234
|
+
<box key={notice.id} paddingLeft={1} paddingRight={1} height={1}>
|
|
235
|
+
<text
|
|
236
|
+
wrapMode="none"
|
|
237
|
+
fg={
|
|
238
|
+
notice.tone === "warning"
|
|
239
|
+
? theme.colors.warning
|
|
240
|
+
: theme.colors.success
|
|
241
|
+
}
|
|
242
|
+
>
|
|
243
|
+
{truncate(
|
|
244
|
+
`${notice.tone === "warning" ? "⚠" : "✓"} ${notice.text}`,
|
|
245
|
+
width,
|
|
246
|
+
)}
|
|
247
|
+
</text>
|
|
248
|
+
</box>
|
|
249
|
+
))}
|
|
250
|
+
</>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
183
254
|
/**
|
|
184
255
|
* ProgressIndicator Component
|
|
185
256
|
* Shows progress bar when operations are running
|
|
@@ -207,34 +278,17 @@ function ProgressIndicator({
|
|
|
207
278
|
interface AppContentInnerProps {
|
|
208
279
|
updateInfo: VersionCheckResult | null;
|
|
209
280
|
onExit: () => void;
|
|
210
|
-
recoveryReport: string | null;
|
|
211
|
-
/** What the startup profile gate did: absorbed edits, regeneration, repairs. */
|
|
212
|
-
profileNotices: string[];
|
|
213
281
|
}
|
|
214
282
|
|
|
215
|
-
function AppContentInner({
|
|
216
|
-
updateInfo,
|
|
217
|
-
onExit,
|
|
218
|
-
recoveryReport,
|
|
219
|
-
profileNotices,
|
|
220
|
-
}: AppContentInnerProps) {
|
|
283
|
+
function AppContentInner({ updateInfo, onExit }: AppContentInnerProps) {
|
|
221
284
|
const { state } = useApp();
|
|
222
|
-
const { progress } = state;
|
|
285
|
+
const { progress, notices } = state;
|
|
223
286
|
const dimensions = useDimensions();
|
|
224
287
|
|
|
225
288
|
return (
|
|
226
289
|
<box flexDirection="column" height={dimensions.terminalHeight}>
|
|
227
290
|
{updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
|
|
228
|
-
{
|
|
229
|
-
<box paddingLeft={1} paddingRight={1}>
|
|
230
|
-
<text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
|
|
231
|
-
</box>
|
|
232
|
-
)}
|
|
233
|
-
{profileNotices.map((line) => (
|
|
234
|
-
<box key={line} paddingLeft={1} paddingRight={1}>
|
|
235
|
-
<text fg={theme.colors.success}>• {line}</text>
|
|
236
|
-
</box>
|
|
237
|
-
))}
|
|
291
|
+
<NoticeBanner notices={notices} />
|
|
238
292
|
{progress && <ProgressIndicator {...progress} />}
|
|
239
293
|
<box
|
|
240
294
|
flexDirection="column"
|
|
@@ -250,6 +304,27 @@ function AppContentInner({
|
|
|
250
304
|
);
|
|
251
305
|
}
|
|
252
306
|
|
|
307
|
+
/** A gitignore check's answer, waiting for the modal slot. */
|
|
308
|
+
interface QueuedGitignore {
|
|
309
|
+
need: StartupGitignoreNeed;
|
|
310
|
+
/** The `gitignoreCheckRequest` it answers. */
|
|
311
|
+
request: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The gitignore check's bookkeeping. A ref, not state: a landing check reads
|
|
316
|
+
* the latest request and what was last shown at that moment, not as of the
|
|
317
|
+
* render that started it.
|
|
318
|
+
*/
|
|
319
|
+
interface GitignoreCheck {
|
|
320
|
+
/** A check is running. There is never more than one. */
|
|
321
|
+
inFlight: boolean;
|
|
322
|
+
/** The newest `gitignoreCheckRequest` seen. */
|
|
323
|
+
latest: number;
|
|
324
|
+
/** The request whose answer was last shown as a question; -1 for none. */
|
|
325
|
+
shown: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
253
328
|
/**
|
|
254
329
|
* AppContent Component
|
|
255
330
|
* Wraps app with DimensionsProvider and manages state for debug/updates
|
|
@@ -261,11 +336,21 @@ interface AppContentProps {
|
|
|
261
336
|
function AppContent({ onExit }: AppContentProps) {
|
|
262
337
|
const { state, dispatch } = useApp();
|
|
263
338
|
const { progress } = state;
|
|
339
|
+
const notify = useNotify();
|
|
264
340
|
const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
|
|
265
|
-
const [
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
341
|
+
const [gitignoreData, setGitignoreData] = useState<QueuedGitignore | null>(
|
|
342
|
+
null,
|
|
343
|
+
);
|
|
344
|
+
// The last request whose check has landed. Behind the latest request, a
|
|
345
|
+
// check is pending, and `gitignoreData` — if any — is an answer to an
|
|
346
|
+
// earlier question about a project that has since changed.
|
|
347
|
+
const [gitignoreSettled, setGitignoreSettled] = useState(-1);
|
|
348
|
+
const gitignorePending = gitignoreSettled < state.gitignoreCheckRequest;
|
|
349
|
+
const gitignoreCheck = useRef<GitignoreCheck>({
|
|
350
|
+
inFlight: false,
|
|
351
|
+
latest: 0,
|
|
352
|
+
shown: -1,
|
|
353
|
+
});
|
|
269
354
|
const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
|
|
270
355
|
null,
|
|
271
356
|
);
|
|
@@ -274,22 +359,23 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
274
359
|
null,
|
|
275
360
|
);
|
|
276
361
|
const staleLoadModal = useStaleLoadModal();
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
// non-fatal: the Git State tab still shows the same rows
|
|
288
|
-
});
|
|
289
|
-
}, []);
|
|
362
|
+
// Stable, so `profileModal.show` is not rebuilt for it on every render.
|
|
363
|
+
const profileAnswered = React.useCallback(() => setProfileNeed(null), []);
|
|
364
|
+
// Activating a profile changes which plugins are on, so the open screen must
|
|
365
|
+
// reload. The result modal that used to follow sent the user to the Profiles
|
|
366
|
+
// tab, which remounted it; without that modal the user stays where they are,
|
|
367
|
+
// and the screen under them would keep showing the pre-activation state.
|
|
368
|
+
// The same action re-runs the gitignore check — see the effect below.
|
|
369
|
+
const profileActivated = React.useCallback(() => {
|
|
370
|
+
dispatch({ type: "PROFILE_ACTIVATED" });
|
|
371
|
+
}, [dispatch]);
|
|
290
372
|
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
291
373
|
// only non-action is to leave.
|
|
292
|
-
const profileModal = useProfileStartupModal(
|
|
374
|
+
const profileModal = useProfileStartupModal({
|
|
375
|
+
onQuit: onExit,
|
|
376
|
+
onActivated: profileActivated,
|
|
377
|
+
onAnswered: profileAnswered,
|
|
378
|
+
});
|
|
293
379
|
|
|
294
380
|
// Check for updates on startup, then re-check periodically (force-bypassing
|
|
295
381
|
// the session cache) so the banner reflects a release that lands while
|
|
@@ -309,66 +395,102 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
309
395
|
return () => clearInterval(id);
|
|
310
396
|
}, []);
|
|
311
397
|
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
//
|
|
345
|
-
// gitignore
|
|
346
|
-
//
|
|
398
|
+
// The startup questions — gitignore, then broken plugins, then profile — are
|
|
399
|
+
// asked ONE at a time, from ONE effect, and only while the modal slot is free.
|
|
400
|
+
//
|
|
401
|
+
// They used to be three effects, each checking `state.modal` from the same
|
|
402
|
+
// render. Two needs arriving in one commit both saw an empty slot, both
|
|
403
|
+
// showed, and the later SHOW_MODAL replaced the earlier question after its
|
|
404
|
+
// need had already been cleared — so it never came back. The profile question
|
|
405
|
+
// was lost exactly that way, and the user updated plugins believing a profile
|
|
406
|
+
// was active. So: show at most one per run and return; the next question
|
|
407
|
+
// follows once that one's flow has ended and the slot is empty again.
|
|
408
|
+
//
|
|
409
|
+
// The hooks keep the slot occupied for their whole flow (a loading modal
|
|
410
|
+
// while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
|
|
411
|
+
// what lets the next question in. A flow that succeeds ends by hiding its
|
|
412
|
+
// loading modal and reporting through `useNotify`: a notice is not in the
|
|
413
|
+
// modal slot, so it cannot hold the next question back or be replaced by it. That does not stop a SCREEN from emptying
|
|
414
|
+
// it: `useModal().confirm` hides its modal when answered and the screen shows
|
|
415
|
+
// its loading modal a microtask later. A key pressed before the profile check
|
|
416
|
+
// resolved lands the profile question in exactly that gap, and the screen's
|
|
417
|
+
// loading modal then replaces it.
|
|
418
|
+
//
|
|
419
|
+
// So the two kinds of question are cleared at different points.
|
|
420
|
+
//
|
|
421
|
+
// - The profile need is cleared when it is ANSWERED (`profileAnswered`, from
|
|
422
|
+
// the hook: a profile activated, or quit), never when shown. The modal
|
|
423
|
+
// cannot be dismissed, so an empty slot while the need is still set means
|
|
424
|
+
// it was lost, and this effect puts it back. The hook keeps what was last
|
|
425
|
+
// asked and whether work is in flight, so a re-show re-asks the question
|
|
426
|
+
// that was on screen and never starts a second activation.
|
|
427
|
+
// - The gitignore and broken-plugin needs are still cleared on show. Both
|
|
428
|
+
// can be dismissed, so an empty slot does not mean they were lost —
|
|
429
|
+
// re-showing would re-ask a question the user just declined. Losing one
|
|
430
|
+
// costs nothing lasting: the Git State and Plugins tabs show the same rows,
|
|
431
|
+
// and gitignore is asked again after a profile activates.
|
|
432
|
+
//
|
|
433
|
+
// The gitignore question also waits while a check is pending. An activation
|
|
434
|
+
// both empties the slot and asks for a fresh check, so the slot is free
|
|
435
|
+
// while an answer from BEFORE the activation may still be queued: showing
|
|
436
|
+
// it and then the fresh one asked the same question twice. The fresh result
|
|
437
|
+
// replaces the queued one instead (see the check effect below).
|
|
347
438
|
useEffect(() => {
|
|
348
|
-
if (brokenPluginCount === null) return;
|
|
349
439
|
if (state.modal) return;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
440
|
+
if (gitignoreData && !gitignorePending) {
|
|
441
|
+
// Anything older than what is now on screen is dropped on arrival.
|
|
442
|
+
gitignoreCheck.current.shown = gitignoreData.request;
|
|
443
|
+
const need = gitignoreData.need;
|
|
444
|
+
gitignoreModal.show({
|
|
445
|
+
violationCount: need.violationCount,
|
|
446
|
+
safeCount: need.safeCount,
|
|
447
|
+
generated: need.generated,
|
|
448
|
+
// In place, then back to the app: tracked generated files are
|
|
449
|
+
// untracked, missing entries added; other items stay for the tab.
|
|
450
|
+
onFixSafe: async () =>
|
|
451
|
+
startupFixNotices(await applyStartupFixes(state.projectPath)),
|
|
452
|
+
});
|
|
453
|
+
setGitignoreData(null);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (brokenPluginCount !== null) {
|
|
457
|
+
staleLoadModal.show({
|
|
458
|
+
brokenCount: brokenPluginCount,
|
|
459
|
+
// Re-surveys before repairing, like the gitignore fixer: the registry
|
|
460
|
+
// may have moved between the startup scan and the user pressing Enter,
|
|
461
|
+
// and repairing from the stale count would act on rows that are no
|
|
462
|
+
// longer there. `repairAllStaleLoads` is the same `repairStaleLoad` the
|
|
463
|
+
// Plugins screen's `a` key drives through the apply engine.
|
|
464
|
+
//
|
|
465
|
+
// The pins are re-read with it, from the same helper the survey used: a
|
|
466
|
+
// repair that capped at a different ceiling than the offer was counted
|
|
467
|
+
// against would move rows the dialog never mentioned.
|
|
468
|
+
onFix: async () =>
|
|
469
|
+
staleLoadRepairNotices(
|
|
470
|
+
await repairAllStaleLoads(
|
|
471
|
+
state.projectPath,
|
|
472
|
+
undefined,
|
|
473
|
+
await activePluginPins(state.projectPath),
|
|
474
|
+
),
|
|
367
475
|
),
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
476
|
+
});
|
|
477
|
+
setBrokenPluginCount(null);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// Not cleared here — see above. Re-called on every empty slot until the
|
|
481
|
+
// hook reports it answered.
|
|
482
|
+
if (profileNeed) profileModal.show(profileNeed);
|
|
483
|
+
}, [
|
|
484
|
+
state.modal,
|
|
485
|
+
state.projectPath,
|
|
486
|
+
gitignoreData,
|
|
487
|
+
gitignorePending,
|
|
488
|
+
gitignoreModal,
|
|
489
|
+
brokenPluginCount,
|
|
490
|
+
staleLoadModal,
|
|
491
|
+
profileNeed,
|
|
492
|
+
profileModal,
|
|
493
|
+
]);
|
|
372
494
|
|
|
373
495
|
// Does this project load an older plugin than it installed? Detection only;
|
|
374
496
|
// nothing is written until the user chooses Fix.
|
|
@@ -393,23 +515,46 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
393
515
|
})();
|
|
394
516
|
}, []);
|
|
395
517
|
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
// marketplace block below, which returns
|
|
409
|
-
// is about network calls and must not
|
|
518
|
+
// The gitignore question, asked fresh: on every open (request 0), and again
|
|
519
|
+
// on each PROFILE_ACTIVATED — a first adoption is what turns a committed
|
|
520
|
+
// settings.json into a generated file git should not track, and a
|
|
521
|
+
// Profiles-tab switch can do the same. Nothing remembers an earlier answer.
|
|
522
|
+
//
|
|
523
|
+
// At most ONE check runs at a time. A request that arrives while one runs
|
|
524
|
+
// is not started beside it: the running check, on landing, sees that it is
|
|
525
|
+
// no longer the latest, throws its answer away — it describes the project
|
|
526
|
+
// before whatever prompted the request — and runs again. Only the latest
|
|
527
|
+
// request's answer is kept, and it REPLACES anything still queued, including
|
|
528
|
+
// with nothing when the project no longer needs the question.
|
|
529
|
+
//
|
|
530
|
+
// Its own effect, NOT part of the marketplace block below, which returns
|
|
531
|
+
// early on `--no-refresh`: that flag is about network calls and must not
|
|
532
|
+
// silence this question.
|
|
410
533
|
useEffect(() => {
|
|
411
|
-
|
|
412
|
-
|
|
534
|
+
const check = gitignoreCheck.current;
|
|
535
|
+
check.latest = state.gitignoreCheckRequest;
|
|
536
|
+
if (check.inFlight) return;
|
|
537
|
+
const run = (request: number): void => {
|
|
538
|
+
check.inFlight = true;
|
|
539
|
+
startupGitignoreNeed(process.cwd())
|
|
540
|
+
// non-fatal: the Git State tab still shows the same rows
|
|
541
|
+
.catch(() => null)
|
|
542
|
+
.then((need) => {
|
|
543
|
+
if (request !== check.latest) {
|
|
544
|
+
run(check.latest);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
check.inFlight = false;
|
|
548
|
+
setGitignoreSettled(request);
|
|
549
|
+
// Defence in depth: an answer to a request no newer than the
|
|
550
|
+
// question last shown predates the user's reply to it.
|
|
551
|
+
setGitignoreData(
|
|
552
|
+
need && request > check.shown ? { need, request } : null,
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
};
|
|
556
|
+
run(check.latest);
|
|
557
|
+
}, [state.gitignoreCheckRequest]);
|
|
413
558
|
|
|
414
559
|
// Is this project actually running a profile? Detection only — nothing is
|
|
415
560
|
// written until the user picks something in the modal. The Profiles tab could
|
|
@@ -427,19 +572,25 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
427
572
|
// edits into the active profile, regenerate from a changed
|
|
428
573
|
// profiles.json, restore a lost marker — then the question, if one
|
|
429
574
|
// is left to ask.
|
|
430
|
-
const { need, notices, problems } =
|
|
431
|
-
process.cwd()
|
|
432
|
-
);
|
|
575
|
+
const { need, notices, problems, activations } =
|
|
576
|
+
await prepareProfileStartup(process.cwd());
|
|
433
577
|
// A refusal never blocks the TUI at startup: it is shown beside what
|
|
434
|
-
// the gate did, and the user is still asked whatever
|
|
435
|
-
|
|
436
|
-
|
|
578
|
+
// the gate did, as a warning, and the user is still asked whatever
|
|
579
|
+
// is left. What a re-render left to act on — a plugin that will not
|
|
580
|
+
// load, one not downloaded yet — is a warning too, in user terms.
|
|
581
|
+
for (const line of notices) notify(line);
|
|
582
|
+
for (const activation of activations) {
|
|
583
|
+
for (const line of activationWarnings(activation)) {
|
|
584
|
+
notify(line.text, line.tone);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
for (const line of problems) notify(line, "warning");
|
|
437
588
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
438
589
|
} catch {
|
|
439
590
|
// non-fatal: the Profiles tab still works by hand
|
|
440
591
|
}
|
|
441
592
|
})();
|
|
442
|
-
}, []);
|
|
593
|
+
}, [notify]);
|
|
443
594
|
|
|
444
595
|
// Auto-refresh marketplaces on startup
|
|
445
596
|
useEffect(() => {
|
|
@@ -463,7 +614,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
463
614
|
if (!(await isClaudeAvailable())) return;
|
|
464
615
|
const added = await autoAddMissingMarketplaces(process.cwd());
|
|
465
616
|
if (added.length > 0) {
|
|
466
|
-
|
|
617
|
+
notify(`Fixed: added marketplace(s): ${added.join(", ")}`);
|
|
467
618
|
}
|
|
468
619
|
} catch {
|
|
469
620
|
// non-fatal: user can still install marketplaces manually
|
|
@@ -474,6 +625,9 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
474
625
|
recoverMarketplaceSettings()
|
|
475
626
|
.then(async (recovery) => {
|
|
476
627
|
const parts: string[] = [];
|
|
628
|
+
// Apart from `parts`: a success line would carry them in green,
|
|
629
|
+
// and a marketplace left stale is something the user may act on.
|
|
630
|
+
const failed: string[] = [];
|
|
477
631
|
if (recovery.reregistered.length > 0) {
|
|
478
632
|
// Update the marketplace clone now that the source is fixed
|
|
479
633
|
const { updateMarketplace } = await import(
|
|
@@ -484,7 +638,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
484
638
|
await updateMarketplace(mp);
|
|
485
639
|
parts.push(`${mp} refreshed`);
|
|
486
640
|
} catch {
|
|
487
|
-
|
|
641
|
+
failed.push(mp);
|
|
488
642
|
}
|
|
489
643
|
}
|
|
490
644
|
}
|
|
@@ -492,7 +646,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
492
646
|
parts.push(`removed: ${recovery.removed.join(", ")}`);
|
|
493
647
|
}
|
|
494
648
|
if (parts.length > 0) {
|
|
495
|
-
|
|
649
|
+
notify(`Fixed: ${parts.join(" | ")}`);
|
|
650
|
+
}
|
|
651
|
+
if (failed.length > 0) {
|
|
652
|
+
notify(
|
|
653
|
+
`Could not refresh marketplace(s): ${failed.join(", ")}`,
|
|
654
|
+
"warning",
|
|
655
|
+
);
|
|
496
656
|
}
|
|
497
657
|
})
|
|
498
658
|
.catch(() => {}); // non-fatal
|
|
@@ -505,23 +665,17 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
505
665
|
.catch(() => {
|
|
506
666
|
dispatch({ type: "HIDE_PROGRESS" });
|
|
507
667
|
});
|
|
508
|
-
}, [dispatch]);
|
|
509
|
-
|
|
510
|
-
// Count transient banners for dimension calculation
|
|
511
|
-
const transientBannerCount = recoveryReport ? 1 : 0;
|
|
668
|
+
}, [dispatch, notify]);
|
|
512
669
|
|
|
513
670
|
return (
|
|
514
671
|
<DimensionsProvider
|
|
515
672
|
showProgress={!!progress}
|
|
516
673
|
showUpdateBanner={!!updateInfo?.updateAvailable}
|
|
517
|
-
|
|
674
|
+
// One row per notice line, so the screen below shrinks rather than
|
|
675
|
+
// being pushed off the bottom of the terminal.
|
|
676
|
+
transientBannerCount={state.notices.length}
|
|
518
677
|
>
|
|
519
|
-
<AppContentInner
|
|
520
|
-
updateInfo={updateInfo}
|
|
521
|
-
onExit={onExit}
|
|
522
|
-
recoveryReport={recoveryReport}
|
|
523
|
-
profileNotices={profileNotices}
|
|
524
|
-
/>
|
|
678
|
+
<AppContentInner updateInfo={updateInfo} onExit={onExit} />
|
|
525
679
|
</DimensionsProvider>
|
|
526
680
|
);
|
|
527
681
|
}
|
|
@@ -247,10 +247,27 @@ function isMadAppGang(mp: Marketplace): boolean {
|
|
|
247
247
|
*/
|
|
248
248
|
export function noMoveReasons(): {
|
|
249
249
|
reporter: ApplyReporter;
|
|
250
|
-
/**
|
|
251
|
-
|
|
250
|
+
/**
|
|
251
|
+
* One line per unchanged item, with its cause when one was reported, so one
|
|
252
|
+
* plugin's reason never runs into the next. The screen posts
|
|
253
|
+
* {@link notice} instead; this is the per-item form it is built from.
|
|
254
|
+
*/
|
|
255
|
+
lines(unchanged: readonly string[]): string[];
|
|
256
|
+
/**
|
|
257
|
+
* The same, as ONE notice line, or null when nothing was unchanged. "Update
|
|
258
|
+
* all" can leave many plugins where they were, and a line each would fill
|
|
259
|
+
* the banner and push out whatever else it had to say. So several become
|
|
260
|
+
* `3 plugins unchanged — a, b, c`, with the cause only when they all share
|
|
261
|
+
* it; one keeps its own cause.
|
|
262
|
+
*/
|
|
263
|
+
notice(unchanged: readonly string[]): string | null;
|
|
252
264
|
} {
|
|
253
265
|
const why = new Map<string, string>();
|
|
266
|
+
const lines = (unchanged: readonly string[]): string[] =>
|
|
267
|
+
unchanged.map((name) => {
|
|
268
|
+
const reason = why.get(name);
|
|
269
|
+
return reason ? `${name}: ${reason}` : name;
|
|
270
|
+
});
|
|
254
271
|
return {
|
|
255
272
|
reporter: {
|
|
256
273
|
begin: () => {},
|
|
@@ -260,13 +277,15 @@ export function noMoveReasons(): {
|
|
|
260
277
|
},
|
|
261
278
|
detach: (work) => work(),
|
|
262
279
|
},
|
|
263
|
-
lines
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
280
|
+
lines,
|
|
281
|
+
notice: (unchanged) => {
|
|
282
|
+
if (unchanged.length === 0) return null;
|
|
283
|
+
if (unchanged.length === 1) return `No change: ${lines(unchanged)[0]}`;
|
|
284
|
+
const reasons = new Set(unchanged.map((name) => why.get(name)));
|
|
285
|
+
const [shared] = reasons;
|
|
286
|
+
const cause = reasons.size === 1 && shared ? `: ${shared}` : "";
|
|
287
|
+
return `${unchanged.length} plugins unchanged — ${unchanged.join(", ")}${cause}`;
|
|
288
|
+
},
|
|
270
289
|
};
|
|
271
290
|
}
|
|
272
291
|
|
|
@@ -7,12 +7,11 @@ interface MessageModalProps {
|
|
|
7
7
|
/** Modal message */
|
|
8
8
|
message: string;
|
|
9
9
|
/** Message variant */
|
|
10
|
-
variant: "info" | "
|
|
10
|
+
variant: "info" | "error";
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const variantConfig = {
|
|
14
14
|
info: { icon: "ℹ", color: theme.colors.info },
|
|
15
|
-
success: { icon: "✓", color: theme.colors.success },
|
|
16
15
|
error: { icon: "✗", color: theme.colors.danger },
|
|
17
16
|
} as const;
|
|
18
17
|
|
|
@@ -34,6 +34,9 @@ const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
|
|
|
34
34
|
* question that must be answered).
|
|
35
35
|
* - input: Esc cancels; everything else goes to the focused <input>, which
|
|
36
36
|
* OpenTUI delivers after this layer and which submits on Enter.
|
|
37
|
+
* Only an <input> that was ALREADY focused when the key arrived:
|
|
38
|
+
* the Enter that opens an input modal from a select never reaches
|
|
39
|
+
* it (KeyRouter's `dispatchThenGuardFocus`).
|
|
37
40
|
* - message: any key dismisses.
|
|
38
41
|
* - loading: keys are swallowed until the work finishes.
|
|
39
42
|
* - help: ↑↓ / PgUp / PgDn / Ctrl+U / Ctrl+D scroll; Esc or `?` close.
|