magus-cli 7.6.0 → 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__/notices-rules.test.ts +476 -0
- package/src/__tests__/notices.test.tsx +604 -0
- package/src/__tests__/update-apply.test.ts +3 -3
- package/src/__tests__/useStaleLoadModal.test.ts +27 -12
- package/src/services/gitignore-service.ts +0 -19
- package/src/services/profile-absorb.ts +9 -0
- package/src/services/profile-activate.ts +2 -1
- package/src/services/profile-startup.ts +45 -17
- package/src/services/profile-state.ts +9 -0
- package/src/ui/App.tsx +216 -97
- package/src/ui/adapters/pluginsAdapter.ts +28 -9
- package/src/ui/components/modals/MessageModal.tsx +1 -2
- package/src/ui/hooks/useGitignoreModal.ts +45 -23
- package/src/ui/hooks/useProfileStartupModal.ts +112 -84
- package/src/ui/hooks/useStaleLoadModal.ts +38 -28
- 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
|
@@ -17,10 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
import { useCallback, useRef } from "react";
|
|
19
19
|
import { getManifestPath } from "../../services/manifest.js";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
activationNotes,
|
|
20
|
+
import type {
|
|
21
|
+
ActivationResult,
|
|
22
|
+
ProfileChoice,
|
|
24
23
|
} from "../../services/profile-activate.js";
|
|
25
24
|
import type { DisplacedArtifact } from "../../services/profile-live.js";
|
|
26
25
|
import {
|
|
@@ -37,8 +36,8 @@ import {
|
|
|
37
36
|
} from "../../services/profile-startup.js";
|
|
38
37
|
import { applyProfileChoice } from "../../services/profile-state.js";
|
|
39
38
|
import { SettingsParseError } from "../../services/settings-file.js";
|
|
40
|
-
import { useApp,
|
|
41
|
-
import type { ModalState } from "../state/types.js";
|
|
39
|
+
import { useApp, useNotify } from "../state/AppContext.js";
|
|
40
|
+
import type { ModalState, NoticeLine } from "../state/types.js";
|
|
42
41
|
|
|
43
42
|
export interface BuildProfileModalArgs {
|
|
44
43
|
need: ProfileStartupNeed;
|
|
@@ -85,38 +84,79 @@ export function buildProfileStartupModal(
|
|
|
85
84
|
};
|
|
86
85
|
}
|
|
87
86
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
87
|
+
/** `typescript-lsp@claude-plugins-official` → `typescript-lsp`. */
|
|
88
|
+
function pluginName(pluginId: string): string {
|
|
89
|
+
const at = pluginId.lastIndexOf("@");
|
|
90
|
+
return at > 0 ? pluginId.slice(0, at) : pluginId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "a", "a and b", "a, b and c". */
|
|
94
|
+
function listed(names: readonly string[]): string {
|
|
95
|
+
if (names.length <= 1) return names.join("");
|
|
96
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What an activation did, as notices — for the startup question and the
|
|
101
|
+
* Profiles tab alike: one line saying which profile is in use, then
|
|
102
|
+
* {@link activationWarnings}.
|
|
103
|
+
*/
|
|
104
|
+
export function activationNotices(result: ActivationResult): NoticeLine[] {
|
|
105
|
+
return [
|
|
106
|
+
{ text: `Using profile "${result.profileId}"`, tone: "success" },
|
|
107
|
+
...activationWarnings(result),
|
|
102
108
|
];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What an activation left for the user to act on, one warning per thing, in
|
|
113
|
+
* user terms. Alone, without {@link activationNotices}' "Using profile" line,
|
|
114
|
+
* for an activation the user did not ask for — a re-render at startup, a
|
|
115
|
+
* profile format update — where which profile is in use is not news.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately NOT {@link activationNotes}, which the CLI prints. Several of
|
|
118
|
+
* its lines are for a terminal log and would only be noise on this banner:
|
|
119
|
+
* where displaced files were backed up (the backup still happens; nobody needs
|
|
120
|
+
* to be told where), edits recorded into the profile being left (the switch
|
|
121
|
+
* confirmation already said so), skills handed back to the team. Tracked
|
|
122
|
+
* generated files are left out too: every activation signals
|
|
123
|
+
* PROFILE_ACTIVATED, and the app re-runs its gitignore check on it — the
|
|
124
|
+
* startup question and a Profiles-tab switch alike — which asks about exactly
|
|
125
|
+
* those files, with a way to fix them.
|
|
126
|
+
*/
|
|
127
|
+
export function activationWarnings(result: ActivationResult): NoticeLine[] {
|
|
128
|
+
const lines: NoticeLine[] = [];
|
|
129
|
+
const report = result.dependencies;
|
|
130
|
+
for (const { plugin, dependency } of report?.blocked ?? []) {
|
|
131
|
+
lines.push({
|
|
132
|
+
text: `${pluginName(plugin)} won't load: profile "${result.profileId}" turns off ${pluginName(dependency)}, which it needs`,
|
|
133
|
+
tone: "warning",
|
|
134
|
+
});
|
|
117
135
|
}
|
|
118
|
-
|
|
119
|
-
|
|
136
|
+
// The plugin's details are not on this machine yet, so what it depends on
|
|
137
|
+
// could not be enabled. One line for all of them: the fix is the same.
|
|
138
|
+
const missing = (report?.unreadable ?? []).map(pluginName);
|
|
139
|
+
if (missing.length > 0) {
|
|
140
|
+
lines.push({
|
|
141
|
+
text: `${listed(missing)} ${missing.length === 1 ? "isn't" : "aren't"} downloaded yet — run magus install`,
|
|
142
|
+
tone: "warning",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
for (const f of result.skills.failed) {
|
|
146
|
+
lines.push({
|
|
147
|
+
text: `Skill "${f.skill}" could not be installed: ${f.reason}`,
|
|
148
|
+
tone: "warning",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// The one case that names a path: two copies of a skill, and which to keep
|
|
152
|
+
// is the user's call — they need to know where the other one is.
|
|
153
|
+
for (const c of result.conversion.conflicts) {
|
|
154
|
+
lines.push({
|
|
155
|
+
text: `Skill "${c.folder}" exists in two versions; kept ${c.kept}, the other is at ${c.other}`,
|
|
156
|
+
tone: "warning",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return lines;
|
|
120
160
|
}
|
|
121
161
|
|
|
122
162
|
/** Project-relative, so a path does not wrap over two lines. */
|
|
@@ -170,7 +210,10 @@ export function describeFailure(
|
|
|
170
210
|
export interface ProfileStartupModalCallbacks {
|
|
171
211
|
/** Leave magus: the modal's only non-action. */
|
|
172
212
|
onQuit: () => void;
|
|
173
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Called once a choice has activated a profile, or a format update has
|
|
215
|
+
* rewritten it — the app dispatches PROFILE_ACTIVATED on it.
|
|
216
|
+
*/
|
|
174
217
|
onActivated?: () => void;
|
|
175
218
|
/**
|
|
176
219
|
* The question is ANSWERED: a profile is active, or the user chose to quit.
|
|
@@ -185,7 +228,8 @@ export interface ProfileStartupModalCallbacks {
|
|
|
185
228
|
* question on screen NOW, which is not always the one magus opened with: "Update
|
|
186
229
|
* it now" asks a new one, and a manifest that stopped parsing turns the question
|
|
187
230
|
* into the unparseable one. `working` is true while an activation or a format
|
|
188
|
-
* update is running —
|
|
231
|
+
* update is running — when it settles it empties the slot (success, reported as
|
|
232
|
+
* a notice) or puts its error modal there.
|
|
189
233
|
*/
|
|
190
234
|
interface ProfileFlow {
|
|
191
235
|
need: ProfileStartupNeed;
|
|
@@ -198,7 +242,7 @@ export function useProfileStartupModal({
|
|
|
198
242
|
onAnswered,
|
|
199
243
|
}: ProfileStartupModalCallbacks) {
|
|
200
244
|
const { state, dispatch } = useApp();
|
|
201
|
-
const
|
|
245
|
+
const notify = useNotify();
|
|
202
246
|
const projectPath = state.projectPath;
|
|
203
247
|
// A ref, not state: `show` is recreated on every render, and the flow must
|
|
204
248
|
// survive that — a re-show has to know what was last asked, and that work is
|
|
@@ -235,7 +279,6 @@ export function useProfileStartupModal({
|
|
|
235
279
|
*/
|
|
236
280
|
const apply = (
|
|
237
281
|
choice: ProfileChoice,
|
|
238
|
-
automatic: boolean,
|
|
239
282
|
current: ProfileStartupNeed = need,
|
|
240
283
|
): void => {
|
|
241
284
|
flowRef.current = { need: current, working: true };
|
|
@@ -269,28 +312,14 @@ export function useProfileStartupModal({
|
|
|
269
312
|
// question never came back.
|
|
270
313
|
answered();
|
|
271
314
|
onActivated?.();
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
displaced,
|
|
281
|
-
projectPath,
|
|
282
|
-
automatic,
|
|
283
|
-
activationNotes(result),
|
|
284
|
-
),
|
|
285
|
-
variant: "success",
|
|
286
|
-
onDismiss: () => {
|
|
287
|
-
dispatch({ type: "HIDE_MODAL" });
|
|
288
|
-
// The Profiles tab is where the result is visible, and
|
|
289
|
-
// where the user will want to look next.
|
|
290
|
-
navigateToScreen("profiles");
|
|
291
|
-
},
|
|
292
|
-
},
|
|
293
|
-
});
|
|
315
|
+
// No result modal and no jump to the Profiles tab: the user
|
|
316
|
+
// stays where they are, told in the notice banner. An automatic
|
|
317
|
+
// activation is reported the same way, so it is never
|
|
318
|
+
// invisible.
|
|
319
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
320
|
+
for (const line of activationNotices(result)) {
|
|
321
|
+
notify(line.text, line.tone);
|
|
322
|
+
}
|
|
294
323
|
})
|
|
295
324
|
.catch((error: unknown) => {
|
|
296
325
|
// The manifest stopped parsing between launch and this answer —
|
|
@@ -344,11 +373,24 @@ export function useProfileStartupModal({
|
|
|
344
373
|
void prepareProfileStartup(projectPath, { convert: true })
|
|
345
374
|
.then((result) => {
|
|
346
375
|
const outcome = formatUpdateOutcome(result);
|
|
376
|
+
// What the conversion's activation warns about — a plugin that
|
|
377
|
+
// will not load, one not downloaded yet, a skill that failed —
|
|
378
|
+
// in user terms. Posted whichever way it went, except a refusal,
|
|
379
|
+
// which converted nothing. Posting under the next question is
|
|
380
|
+
// safe: notices do not run down while a modal is open.
|
|
381
|
+
const warnings = (): void => {
|
|
382
|
+
for (const activation of result.activations) {
|
|
383
|
+
for (const line of activationWarnings(activation)) {
|
|
384
|
+
notify(line.text, line.tone);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
347
388
|
if (outcome.kind === "ask") {
|
|
348
389
|
// Converted, but no profile is active yet: a new question,
|
|
349
390
|
// still unanswered.
|
|
350
391
|
onActivated?.();
|
|
351
392
|
ask(outcome.need);
|
|
393
|
+
warnings();
|
|
352
394
|
return;
|
|
353
395
|
}
|
|
354
396
|
if (outcome.kind === "failed") {
|
|
@@ -370,19 +412,9 @@ export function useProfileStartupModal({
|
|
|
370
412
|
// Nothing left to ask: the converted profile is active.
|
|
371
413
|
answered();
|
|
372
414
|
onActivated?.();
|
|
373
|
-
dispatch({
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
});
|
|
415
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
416
|
+
notify(outcome.notice);
|
|
417
|
+
warnings();
|
|
386
418
|
})
|
|
387
419
|
.catch((error: unknown) => {
|
|
388
420
|
flowRef.current = { need, working: false };
|
|
@@ -427,11 +459,7 @@ export function useProfileStartupModal({
|
|
|
427
459
|
askName(suggestion, current, value, check.reason);
|
|
428
460
|
return;
|
|
429
461
|
}
|
|
430
|
-
apply(
|
|
431
|
-
{ kind: "adopt", id: check.id, name: check.name },
|
|
432
|
-
false,
|
|
433
|
-
current,
|
|
434
|
-
);
|
|
462
|
+
apply({ kind: "adopt", id: check.id, name: check.name }, current);
|
|
435
463
|
},
|
|
436
464
|
onCancel: () => ask(current),
|
|
437
465
|
},
|
|
@@ -447,7 +475,7 @@ export function useProfileStartupModal({
|
|
|
447
475
|
onChoose: (choice) =>
|
|
448
476
|
choice.kind === "adopt"
|
|
449
477
|
? askName(choice.id, current)
|
|
450
|
-
: apply(choice,
|
|
478
|
+
: apply(choice, current),
|
|
451
479
|
onUpdateFormat: updateFormat,
|
|
452
480
|
onQuit: quit,
|
|
453
481
|
}),
|
|
@@ -466,15 +494,15 @@ export function useProfileStartupModal({
|
|
|
466
494
|
|
|
467
495
|
// The repo already answered and there is nothing of the user's to
|
|
468
496
|
// replace: asking here would ask someone to re-decide what their team
|
|
469
|
-
// decided, with nothing at stake. Reported in the
|
|
497
|
+
// decided, with nothing at stake. Reported in the notice banner, so it
|
|
470
498
|
// is never invisible.
|
|
471
499
|
if (need.autoActivate && need.declaredDefault !== null) {
|
|
472
|
-
apply({ kind: "activate", id: need.declaredDefault }
|
|
500
|
+
apply({ kind: "activate", id: need.declaredDefault });
|
|
473
501
|
return;
|
|
474
502
|
}
|
|
475
503
|
ask();
|
|
476
504
|
},
|
|
477
|
-
[dispatch,
|
|
505
|
+
[dispatch, notify, projectPath, onQuit, onActivated, onAnswered],
|
|
478
506
|
);
|
|
479
507
|
|
|
480
508
|
return { show };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
-
import { useApp, useNavigation } from "../state/AppContext.js";
|
|
3
|
-
import type { ModalState } from "../state/types.js";
|
|
2
|
+
import { useApp, useNavigation, useNotify } from "../state/AppContext.js";
|
|
3
|
+
import type { ModalState, NoticeLine } from "../state/types.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Startup broken-plugin modal: magus launches, finds that Claude Code will load
|
|
@@ -71,12 +71,13 @@ export function buildStaleLoadModal(args: BuildStaleLoadModalArgs): ModalState {
|
|
|
71
71
|
export function useStaleLoadModal() {
|
|
72
72
|
const { dispatch } = useApp();
|
|
73
73
|
const { navigateToScreen } = useNavigation();
|
|
74
|
+
const notify = useNotify();
|
|
74
75
|
|
|
75
76
|
const show = useCallback(
|
|
76
77
|
(args: {
|
|
77
78
|
brokenCount: number;
|
|
78
79
|
/** Runs the repair. Resolves to what to tell the user afterwards. */
|
|
79
|
-
onFix: () => Promise<
|
|
80
|
+
onFix: () => Promise<readonly NoticeLine[]> | readonly NoticeLine[];
|
|
80
81
|
}) => {
|
|
81
82
|
if (args.brokenCount === 0) return;
|
|
82
83
|
const dismiss = () => dispatch({ type: "HIDE_MODAL" });
|
|
@@ -97,16 +98,11 @@ export function useStaleLoadModal() {
|
|
|
97
98
|
void Promise.resolve()
|
|
98
99
|
.then(() => args.onFix())
|
|
99
100
|
.then((summary) => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
message: summary,
|
|
106
|
-
variant: "info",
|
|
107
|
-
onDismiss: dismiss,
|
|
108
|
-
},
|
|
109
|
-
});
|
|
101
|
+
// Hidden, not replaced: the loading modal swallows every key,
|
|
102
|
+
// and the result belongs in the notice banner, outside the
|
|
103
|
+
// modal slot the next startup question is waiting for.
|
|
104
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
105
|
+
for (const line of summary) notify(line.text, line.tone);
|
|
110
106
|
})
|
|
111
107
|
.catch((err) => {
|
|
112
108
|
dispatch({
|
|
@@ -129,33 +125,47 @@ export function useStaleLoadModal() {
|
|
|
129
125
|
});
|
|
130
126
|
dispatch({ type: "SHOW_MODAL", modal });
|
|
131
127
|
},
|
|
132
|
-
[dispatch, navigateToScreen],
|
|
128
|
+
[dispatch, navigateToScreen, notify],
|
|
133
129
|
);
|
|
134
130
|
|
|
135
131
|
return { show };
|
|
136
132
|
}
|
|
137
133
|
|
|
138
134
|
/**
|
|
139
|
-
*
|
|
135
|
+
* What the repair did, as notices: one line for every plugin that now loads
|
|
136
|
+
* the version installed, and a warning for each that still does not.
|
|
140
137
|
*
|
|
141
|
-
* Exported and pure so the wording is tested rather than inspected by eye.
|
|
142
|
-
*
|
|
143
|
-
* the
|
|
138
|
+
* Exported and pure so the wording is tested rather than inspected by eye. The
|
|
139
|
+
* lines say what loads NOW, because that — not the row count — is the thing
|
|
140
|
+
* the user came here to change. A failure is never folded into the success
|
|
141
|
+
* line: `ok` is the loader re-asked after the write, and a repair that did not
|
|
142
|
+
* take must read as one.
|
|
144
143
|
*/
|
|
145
|
-
export function
|
|
144
|
+
export function staleLoadRepairNotices(
|
|
146
145
|
results: ReadonlyArray<{
|
|
147
146
|
pluginId: string;
|
|
148
147
|
target: string;
|
|
149
148
|
ok: boolean;
|
|
150
149
|
error?: string;
|
|
151
150
|
}>,
|
|
152
|
-
):
|
|
153
|
-
if (results.length === 0)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
151
|
+
): NoticeLine[] {
|
|
152
|
+
if (results.length === 0) {
|
|
153
|
+
return [{ text: "Nothing needed repairing", tone: "success" }];
|
|
154
|
+
}
|
|
155
|
+
const lines: NoticeLine[] = [];
|
|
156
|
+
const fixed = results.filter((r) => r.ok);
|
|
157
|
+
if (fixed.length > 0) {
|
|
158
|
+
lines.push({
|
|
159
|
+
text: `Now loading ${fixed.map((r) => `${r.pluginId} v${r.target}`).join(", ")}`,
|
|
160
|
+
tone: "success",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
for (const r of results) {
|
|
164
|
+
if (r.ok) continue;
|
|
165
|
+
lines.push({
|
|
166
|
+
text: `${r.pluginId} not repaired: ${r.error ?? "repair did not take effect"}`,
|
|
167
|
+
tone: "warning",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return lines;
|
|
161
171
|
}
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
40
40
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
41
41
|
import { moveDirection } from "../keymap.js";
|
|
42
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
42
|
+
import { useApp, useModal, useNotify } from "../state/AppContext.js";
|
|
43
43
|
import { displayWidth, truncate } from "../text-width.js";
|
|
44
44
|
import { theme } from "../theme.js";
|
|
45
45
|
|
|
@@ -114,6 +114,7 @@ type ViewMode = { kind: "main" } | { kind: "flag-detail"; flagId: string };
|
|
|
114
114
|
export function AliasScreen() {
|
|
115
115
|
const { state } = useApp();
|
|
116
116
|
const modal = useModal();
|
|
117
|
+
const notify = useNotify();
|
|
117
118
|
|
|
118
119
|
const [config, setConfig] = useState<AliasConfig | null>(null);
|
|
119
120
|
const [shells, setShells] = useState<ShellTarget[]>([]);
|
|
@@ -565,10 +566,10 @@ export function AliasScreen() {
|
|
|
565
566
|
shells.find((s) => s.exists);
|
|
566
567
|
|
|
567
568
|
if (!target) {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
"No
|
|
571
|
-
"
|
|
569
|
+
// Nothing was written. Named files, because creating one is the fix.
|
|
570
|
+
notify(
|
|
571
|
+
"No shell config to write to — create ~/.zshrc, ~/.bashrc or ~/.config/fish/config.fish first",
|
|
572
|
+
"warning",
|
|
572
573
|
);
|
|
573
574
|
return;
|
|
574
575
|
}
|
|
@@ -602,7 +603,7 @@ export function AliasScreen() {
|
|
|
602
603
|
"error",
|
|
603
604
|
);
|
|
604
605
|
}
|
|
605
|
-
}, [config, shells, modal, pendingAdoptRange]);
|
|
606
|
+
}, [config, shells, modal, notify, pendingAdoptRange]);
|
|
606
607
|
|
|
607
608
|
// ── Keyboard ──────────────────────────────────────────────────────────
|
|
608
609
|
// j/k navigate the selectable subset, jumping over headers/legend lines.
|
|
@@ -4,7 +4,7 @@ import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
|
4
4
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
5
5
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
6
6
|
import { moveDirection } from "../keymap.js";
|
|
7
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
7
|
+
import { useApp, useModal, useNotify } from "../state/AppContext.js";
|
|
8
8
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
9
9
|
|
|
10
10
|
import { cliTools } from "../../data/cli-tools.js";
|
|
@@ -91,6 +91,7 @@ export function CliToolsScreen() {
|
|
|
91
91
|
const { state, dispatch } = useApp();
|
|
92
92
|
const { cliTools: cliToolsState } = state;
|
|
93
93
|
const modal = useModal();
|
|
94
|
+
const notify = useNotify();
|
|
94
95
|
const dimensions = useDimensions();
|
|
95
96
|
|
|
96
97
|
const [rows, setRows] = useState<CliToolRow[]>(
|
|
@@ -333,21 +334,27 @@ export function CliToolsScreen() {
|
|
|
333
334
|
|
|
334
335
|
modal.hideModal();
|
|
335
336
|
|
|
336
|
-
const
|
|
337
|
-
errors.length > 0
|
|
338
|
-
? `These removals failed. Run them by hand:\n${errors.join("\n")}`
|
|
339
|
-
: "",
|
|
337
|
+
const stays =
|
|
340
338
|
leftBehind.length > 0
|
|
341
339
|
? `${stuckList(leftBehind)}\nRemove it by hand if you want it gone.`
|
|
342
|
-
: ""
|
|
343
|
-
].filter((part) => part !== "");
|
|
340
|
+
: "";
|
|
344
341
|
|
|
345
|
-
|
|
342
|
+
// A failed removal is an error, and stays a dialog with the commands to
|
|
343
|
+
// run. An install magus could never remove is only a warning: nothing
|
|
344
|
+
// failed, and the row still shows the conflict until it is dealt with.
|
|
345
|
+
if (errors.length > 0) {
|
|
346
346
|
await modal.message(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
347
|
+
"Partly removed",
|
|
348
|
+
[
|
|
349
|
+
`These removals failed. Run them by hand:\n${errors.join("\n")}`,
|
|
350
|
+
stays,
|
|
351
|
+
]
|
|
352
|
+
.filter((part) => part !== "")
|
|
353
|
+
.join("\n\n"),
|
|
354
|
+
"error",
|
|
350
355
|
);
|
|
356
|
+
} else if (stays !== "") {
|
|
357
|
+
notify(stays.replace(/\n/g, " "), "warning");
|
|
351
358
|
}
|
|
352
359
|
|
|
353
360
|
handleRefresh();
|
|
@@ -359,11 +366,7 @@ export function CliToolsScreen() {
|
|
|
359
366
|
// it anyway" the CLI no longer does either.
|
|
360
367
|
const updatable = rows.filter((r) => r.status?.state === "upgrade");
|
|
361
368
|
if (updatable.length === 0) {
|
|
362
|
-
|
|
363
|
-
"Up to Date",
|
|
364
|
-
"No tool is known to be behind.",
|
|
365
|
-
"info",
|
|
366
|
-
);
|
|
369
|
+
notify("No tool is known to be behind");
|
|
367
370
|
return;
|
|
368
371
|
}
|
|
369
372
|
|
|
@@ -54,9 +54,12 @@ export function SettingsScreen() {
|
|
|
54
54
|
}
|
|
55
55
|
}, [dispatch, state.projectPath]);
|
|
56
56
|
|
|
57
|
+
// Load data on mount or when dataRefreshVersion changes — a profile
|
|
58
|
+
// activating changes what this screen shows.
|
|
59
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
|
|
57
60
|
useEffect(() => {
|
|
58
61
|
fetchData();
|
|
59
|
-
}, [fetchData]);
|
|
62
|
+
}, [fetchData, state.dataRefreshVersion]);
|
|
60
63
|
|
|
61
64
|
const listItems = useMemo((): SettingsBrowserItem[] => {
|
|
62
65
|
if (settings.values.status !== "success") return [];
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type RuleRow,
|
|
20
20
|
renderRuleRow,
|
|
21
21
|
} from "../renderers/gitignoreRenderers.js";
|
|
22
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
22
|
+
import { useApp, useModal, useNotify } from "../state/AppContext.js";
|
|
23
23
|
import { theme } from "../theme.js";
|
|
24
24
|
|
|
25
25
|
// Clean rows are grouped by the manifest that defined them. Order = display
|
|
@@ -36,6 +36,7 @@ const SOURCE_LABELS: Record<Tier, string> = {
|
|
|
36
36
|
export function GitignoreScreen() {
|
|
37
37
|
const { state } = useApp();
|
|
38
38
|
const modal = useModal();
|
|
39
|
+
const notify = useNotify();
|
|
39
40
|
const renderer = useRenderer();
|
|
40
41
|
const cwd = state.projectPath;
|
|
41
42
|
|
|
@@ -57,9 +58,12 @@ export function GitignoreScreen() {
|
|
|
57
58
|
}
|
|
58
59
|
}, [cwd]);
|
|
59
60
|
|
|
61
|
+
// Load on mount or when dataRefreshVersion changes — a profile activating
|
|
62
|
+
// can turn committed files into generated ones this screen lists.
|
|
63
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the load even though the body never reads it
|
|
60
64
|
useEffect(() => {
|
|
61
65
|
void reload();
|
|
62
|
-
}, [reload]);
|
|
66
|
+
}, [reload, state.dataRefreshVersion]);
|
|
63
67
|
|
|
64
68
|
// Build the managed list: every desired gitignore rule, with all matching
|
|
65
69
|
// violations grouped under the rule that owns them.
|
|
@@ -163,19 +167,11 @@ export function GitignoreScreen() {
|
|
|
163
167
|
// Fix just the highlighted row.
|
|
164
168
|
const handleFixOne = useCallback(async () => {
|
|
165
169
|
if (!selectedRow) {
|
|
166
|
-
|
|
167
|
-
"No row selected",
|
|
168
|
-
"Use ↑↓ to highlight a row that needs fixing, then press f.",
|
|
169
|
-
"info",
|
|
170
|
-
);
|
|
170
|
+
notify("Highlight a row that needs fixing, then press f", "warning");
|
|
171
171
|
return;
|
|
172
172
|
}
|
|
173
173
|
if (selectedRow.violations.length === 0) {
|
|
174
|
-
|
|
175
|
-
"Nothing to fix",
|
|
176
|
-
`${selectedRow.rule.pattern} already matches. f only applies to rows that need fixing.`,
|
|
177
|
-
"info",
|
|
178
|
-
);
|
|
174
|
+
notify(`${selectedRow.rule.pattern} already matches — nothing to fix`);
|
|
179
175
|
return;
|
|
180
176
|
}
|
|
181
177
|
|
|
@@ -204,16 +200,12 @@ export function GitignoreScreen() {
|
|
|
204
200
|
"error",
|
|
205
201
|
);
|
|
206
202
|
}
|
|
207
|
-
}, [selectedRow, modal, cwd, reload]);
|
|
203
|
+
}, [selectedRow, modal, notify, cwd, reload]);
|
|
208
204
|
|
|
209
205
|
const handleApplyTemplate = useCallback(async () => {
|
|
210
206
|
const names = Object.keys(BUILTIN_TEMPLATES);
|
|
211
207
|
if (names.length === 0) {
|
|
212
|
-
|
|
213
|
-
"No templates",
|
|
214
|
-
"No built-in templates available.",
|
|
215
|
-
"info",
|
|
216
|
-
);
|
|
208
|
+
notify("No built-in templates available", "warning");
|
|
217
209
|
return;
|
|
218
210
|
}
|
|
219
211
|
const selected = await modal.select(
|
|
@@ -224,15 +216,16 @@ export function GitignoreScreen() {
|
|
|
224
216
|
if (!selected) return;
|
|
225
217
|
try {
|
|
226
218
|
const r = await applyTemplate(cwd, selected);
|
|
227
|
-
|
|
228
|
-
r.
|
|
229
|
-
? `\n\nSkipped due to conflicts: ${r.conflicts.join(", ")}`
|
|
230
|
-
: "";
|
|
231
|
-
await modal.message(
|
|
232
|
-
"Template applied",
|
|
233
|
-
`Wrote ${r.appliedTo}\n\nAdded ${r.added.ignore.length} ignore + ${r.added.track.length} track entries.${conflictNote}`,
|
|
234
|
-
"success",
|
|
219
|
+
notify(
|
|
220
|
+
`Applied "${selected}": ${r.added.ignore.length} ignore + ${r.added.track.length} track entries`,
|
|
235
221
|
);
|
|
222
|
+
// Skipped entries are not visible in the list, so they get their own line.
|
|
223
|
+
if (r.conflicts.length > 0) {
|
|
224
|
+
notify(
|
|
225
|
+
`Skipped, conflicting with existing rules: ${r.conflicts.join(", ")}`,
|
|
226
|
+
"warning",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
236
229
|
} catch (err) {
|
|
237
230
|
await modal.message(
|
|
238
231
|
"Template failed",
|
|
@@ -241,7 +234,7 @@ export function GitignoreScreen() {
|
|
|
241
234
|
);
|
|
242
235
|
}
|
|
243
236
|
await reload();
|
|
244
|
-
}, [cwd, modal, reload]);
|
|
237
|
+
}, [cwd, modal, notify, reload]);
|
|
245
238
|
|
|
246
239
|
/**
|
|
247
240
|
* Open the project manifest in $EDITOR, creating it first if needed.
|
|
@@ -2,7 +2,12 @@ import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
|
2
2
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
3
3
|
import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
|
|
4
4
|
import { moveDirection } from "../keymap.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
useApp,
|
|
7
|
+
useModal,
|
|
8
|
+
useNavigation,
|
|
9
|
+
useNotify,
|
|
10
|
+
} from "../state/AppContext.js";
|
|
6
11
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
7
12
|
|
|
8
13
|
import { addMcpServer, setAllowMcp } from "../../services/claude-settings.js";
|
|
@@ -75,6 +80,7 @@ export function McpRegistryScreen() {
|
|
|
75
80
|
const { state, dispatch } = useApp();
|
|
76
81
|
const { mcpRegistry } = state;
|
|
77
82
|
const modal = useModal();
|
|
83
|
+
const notify = useNotify();
|
|
78
84
|
const { navigateToScreen } = useNavigation();
|
|
79
85
|
const dimensions = useDimensions();
|
|
80
86
|
|
|
@@ -190,11 +196,8 @@ export function McpRegistryScreen() {
|
|
|
190
196
|
await setAllowMcp(true, state.projectPath);
|
|
191
197
|
await addMcpServer(server.name, config, state.projectPath);
|
|
192
198
|
modal.hideModal();
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
`${server.name} has been configured.\n\nRestart Claude Code to activate.`,
|
|
196
|
-
"success",
|
|
197
|
-
);
|
|
199
|
+
// A warning: the server does nothing until the user restarts.
|
|
200
|
+
notify(`Added ${server.name} — restart Claude Code to use it`, "warning");
|
|
198
201
|
} catch (error) {
|
|
199
202
|
modal.hideModal();
|
|
200
203
|
await modal.message("Error", `Failed to install: ${error}`, "error");
|