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.
Files changed (39) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/format-update-outcome.test.ts +9 -8
  3. package/src/__tests__/gitignore-profile.test.ts +9 -5
  4. package/src/__tests__/key-focus-guard.test.tsx +260 -0
  5. package/src/__tests__/notices-rules.test.ts +476 -0
  6. package/src/__tests__/notices.test.tsx +604 -0
  7. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  8. package/src/__tests__/profile-startup.test.ts +1 -1
  9. package/src/__tests__/prompt-eof.test.ts +245 -0
  10. package/src/__tests__/update-apply.test.ts +3 -3
  11. package/src/__tests__/useStaleLoadModal.test.ts +27 -12
  12. package/src/cli/bootstrap.ts +34 -2
  13. package/src/cli/prompt.ts +12 -2
  14. package/src/cli/update-view.ts +6 -4
  15. package/src/services/gitignore-service.ts +0 -19
  16. package/src/services/profile-absorb.ts +9 -0
  17. package/src/services/profile-activate.ts +4 -2
  18. package/src/services/profile-adopt.ts +9 -2
  19. package/src/services/profile-startup.ts +91 -18
  20. package/src/services/profile-state.ts +10 -1
  21. package/src/ui/App.tsx +295 -141
  22. package/src/ui/adapters/pluginsAdapter.ts +28 -9
  23. package/src/ui/components/modals/MessageModal.tsx +1 -2
  24. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  25. package/src/ui/hooks/useGitignoreModal.ts +58 -18
  26. package/src/ui/hooks/useProfileStartupModal.ts +232 -85
  27. package/src/ui/hooks/useStaleLoadModal.ts +43 -29
  28. package/src/ui/input/KeyRouter.tsx +43 -2
  29. package/src/ui/screens/AliasScreen.tsx +7 -6
  30. package/src/ui/screens/CliToolsScreen.tsx +19 -16
  31. package/src/ui/screens/EnvVarsScreen.tsx +4 -1
  32. package/src/ui/screens/GitignoreScreen.tsx +20 -27
  33. package/src/ui/screens/McpRegistryScreen.tsx +9 -6
  34. package/src/ui/screens/McpScreen.tsx +16 -12
  35. package/src/ui/screens/PluginsScreen.tsx +35 -56
  36. package/src/ui/screens/ProfilesScreen.tsx +27 -51
  37. package/src/ui/state/AppContext.tsx +46 -3
  38. package/src/ui/state/reducer.ts +100 -1
  39. package/src/ui/state/types.ts +53 -2
@@ -2,7 +2,7 @@ import React, { useEffect, useCallback } from "react";
2
2
  import { ScreenLayout } from "../components/layout/index.js";
3
3
  import { useScreenKeys } from "../input/KeyRouter.js";
4
4
  import { moveDirection } from "../keymap.js";
5
- import { useApp, useModal } from "../state/AppContext.js";
5
+ import { useApp, useModal, useNotify } from "../state/AppContext.js";
6
6
  import { useDimensions } from "../state/DimensionsContext.js";
7
7
 
8
8
  import {
@@ -15,7 +15,6 @@ import {
15
15
  writeSettings,
16
16
  } from "../../services/claude-settings.js";
17
17
  import { reconcileActiveProfile } from "../../services/profile-absorb.js";
18
- import { activationNotes } from "../../services/profile-activate.js";
19
18
  import { switchToProfile } from "../../services/profile-state.js";
20
19
  import {
21
20
  applyUserProfile,
@@ -32,7 +31,7 @@ import {
32
31
  writeClipboard,
33
32
  } from "../../utils/clipboard.js";
34
33
  import { ScrollableList } from "../components/ScrollableList.js";
35
- import { describeOutcome } from "../hooks/useProfileStartupModal.js";
34
+ import { activationNotices } from "../hooks/useProfileStartupModal.js";
36
35
  import {
37
36
  type ProfileListItem,
38
37
  buildProfileListItems,
@@ -45,6 +44,7 @@ export function ProfilesScreen() {
45
44
  const { state, dispatch } = useApp();
46
45
  const { profiles: profilesState } = state;
47
46
  const modal = useModal();
47
+ const notify = useNotify();
48
48
  const dimensions = useDimensions();
49
49
 
50
50
  const fetchData = useCallback(async () => {
@@ -165,14 +165,10 @@ export function ProfilesScreen() {
165
165
  const recorded = await reconcileActiveProfile(state.projectPath);
166
166
  modal.hideModal();
167
167
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
168
- await modal.message(
169
- "Applied",
170
- [
171
- `Profile "${profile.name}" merged into project settings.`,
172
- ...recorded.lines,
173
- ].join("\n"),
174
- "success",
175
- );
168
+ notify(`Merged "${profile.name}" into this project`);
169
+ // What was recorded is routine; a refusal to record it is not, and
170
+ // the user has to fix it before the merge reaches the profile.
171
+ for (const problem of recorded.problems) notify(problem, "warning");
176
172
  } catch (error) {
177
173
  modal.hideModal();
178
174
  await modal.message(
@@ -215,8 +211,10 @@ export function ProfilesScreen() {
215
211
  { onDisplace: () => {} },
216
212
  );
217
213
  modal.hideModal();
218
- dispatch({ type: "DATA_REFRESH_COMPLETE" });
219
214
  if (result.problems.length > 0 || !result.activation) {
215
+ // Nothing activated, but what was recorded on the way may have
216
+ // changed the list.
217
+ dispatch({ type: "DATA_REFRESH_COMPLETE" });
220
218
  await modal.message(
221
219
  "Could not switch",
222
220
  [...result.lines, ...result.problems].join("\n"),
@@ -224,20 +222,16 @@ export function ProfilesScreen() {
224
222
  );
225
223
  return;
226
224
  }
227
- const outcome = describeOutcome(
228
- result.activation.profileId,
229
- result.activation.displaced,
230
- state.projectPath,
231
- false,
232
- activationNotes(result.activation),
233
- );
234
- await modal.message(
235
- "Profile active",
236
- result.lines.length > 0
237
- ? `${result.lines.join("\n")}\n\n${outcome}`
238
- : outcome,
239
- "success",
240
- );
225
+ // The same signal as the startup question: every screen reloads,
226
+ // and the app re-runs its gitignore check — the profile switched
227
+ // to may generate files git still tracks, and that question is how
228
+ // the user hears of them (activationNotices leaves them out).
229
+ dispatch({ type: "PROFILE_ACTIVATED" });
230
+ // `result.lines` (edits saved into the profile left behind) are not
231
+ // repeated: the confirmation above already said that happens.
232
+ for (const line of activationNotices(result.activation)) {
233
+ notify(line.text, line.tone);
234
+ }
241
235
  } catch (error) {
242
236
  modal.hideModal();
243
237
  await modal.message(
@@ -266,11 +260,7 @@ export function ProfilesScreen() {
266
260
  await applyUserProfile(selectedProfile);
267
261
  modal.hideModal();
268
262
  dispatch({ type: "DATA_REFRESH_COMPLETE" });
269
- await modal.message(
270
- "Applied",
271
- `Profile "${selectedProfile.name}" applied to ~/.claude/settings.json.`,
272
- "success",
273
- );
263
+ notify(`Applied "${selectedProfile.name}" at USER scope`);
274
264
  } catch (error) {
275
265
  modal.hideModal();
276
266
  await modal.message(
@@ -301,12 +291,8 @@ export function ProfilesScreen() {
301
291
  state.projectPath,
302
292
  );
303
293
  modal.hideModal();
294
+ // The row shows the new name; nothing else to say.
304
295
  await fetchData();
305
- await modal.message(
306
- "Renamed",
307
- `Profile renamed to "${newName.trim()}".`,
308
- "success",
309
- );
310
296
  } catch (error) {
311
297
  modal.hideModal();
312
298
  await modal.message("Error", `Failed to rename: ${error}`, "error");
@@ -336,8 +322,8 @@ export function ProfilesScreen() {
336
322
  Math.min(profilesState.selectedIndex, allItems.length - 2),
337
323
  );
338
324
  dispatch({ type: "PROFILES_SELECT", index: newIndex });
325
+ // The row is gone from the list; nothing else to say.
339
326
  await fetchData();
340
- await modal.message("Deleted", "Profile deleted.", "success");
341
327
  } catch (error) {
342
328
  modal.hideModal();
343
329
  await modal.message("Error", `Failed to delete: ${error}`, "error");
@@ -358,10 +344,8 @@ export function ProfilesScreen() {
358
344
  modal.hideModal();
359
345
  try {
360
346
  await writeClipboard(json);
361
- await modal.message(
362
- "Copied",
363
- "Profile JSON copied to clipboard.\nShare it with teammates to import.",
364
- "success",
347
+ notify(
348
+ `Copied "${selectedProfile.name}" — share it for teammates to import`,
365
349
  );
366
350
  } catch (err) {
367
351
  if (err instanceof ClipboardUnavailableError) {
@@ -409,18 +393,10 @@ export function ProfilesScreen() {
409
393
 
410
394
  modal.loading("Importing...");
411
395
  try {
412
- const id = await importProfileFromJson(
413
- json,
414
- targetScope,
415
- state.projectPath,
416
- );
396
+ await importProfileFromJson(json, targetScope, state.projectPath);
417
397
  modal.hideModal();
398
+ // The imported profile appears in the list; nothing else to say.
418
399
  await fetchData();
419
- await modal.message(
420
- "Imported",
421
- `Profile imported successfully (id: ${id}).`,
422
- "success",
423
- );
424
400
  } catch (error) {
425
401
  modal.hideModal();
426
402
  await modal.message("Error", `Failed to import: ${error}`, "error");
@@ -1,10 +1,17 @@
1
1
  import type React from "react";
2
- import { type ReactNode, createContext, useContext, useReducer } from "react";
3
- import { appReducer, initialState } from "./reducer.js";
2
+ import {
3
+ type ReactNode,
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useReducer,
8
+ } from "react";
9
+ import { NOTICE_DURATION_MS, appReducer, initialState } from "./reducer.js";
4
10
  import type {
5
11
  AppAction,
6
12
  AppState,
7
13
  ModalState,
14
+ NoticeTone,
8
15
  ProgressState,
9
16
  Route,
10
17
  Screen,
@@ -177,7 +184,7 @@ export function useModal() {
177
184
  message: (
178
185
  title: string,
179
186
  message: string,
180
- variant: "info" | "success" | "error" = "info",
187
+ variant: "info" | "error" = "info",
181
188
  ): Promise<void> => {
182
189
  return new Promise((resolve) => {
183
190
  dispatch({
@@ -213,6 +220,42 @@ export function useModal() {
213
220
  };
214
221
  }
215
222
 
223
+ // ============================================================================
224
+ // Hook: useNotify
225
+ // ============================================================================
226
+
227
+ export { NOTICE_DURATION_MS };
228
+
229
+ let nextNoticeId = 0;
230
+
231
+ /**
232
+ * `notify(text, tone)` puts one line in the app's notice banner (rendered by
233
+ * `NoticeBanner` in ui/App.tsx) and returns at once. It is how a screen or hook
234
+ * reports the result of an action: it never opens a modal, never takes a key,
235
+ * and the line clears itself after {@link NOTICE_DURATION_MS}. Time spent
236
+ * under a modal does not count (see PAUSE_NOTICES in the reducer), so a line
237
+ * posted while a question is on screen is still there once it is answered.
238
+ *
239
+ * Stable across renders, so it is safe in a `useCallback` dependency list.
240
+ */
241
+ export function useNotify(): (text: string, tone?: NoticeTone) => void {
242
+ const { dispatch } = useApp();
243
+ return useCallback(
244
+ (text: string, tone: NoticeTone = "success") => {
245
+ dispatch({
246
+ type: "NOTIFY",
247
+ notice: {
248
+ id: nextNoticeId++,
249
+ text,
250
+ tone,
251
+ expiresAt: Date.now() + NOTICE_DURATION_MS[tone],
252
+ },
253
+ });
254
+ },
255
+ [dispatch],
256
+ );
257
+ }
258
+
216
259
  // ============================================================================
217
260
  // Hook: useProgress
218
261
  // ============================================================================
@@ -1,6 +1,38 @@
1
- import type { AppAction, AppState } from "./types.js";
1
+ import type { AppAction, AppState, Notice, NoticeTone } from "./types.js";
2
2
  import { asyncValue } from "./types.js";
3
3
 
4
+ /** How many notice lines the banner shows at once. */
5
+ export const MAX_NOTICES = 4;
6
+
7
+ /**
8
+ * How long a notice stays. A warning may ask the user to do something, so it
9
+ * gets long enough to read and act on; a success only confirms what happened.
10
+ *
11
+ * Here rather than beside `useNotify`, because RESUME_NOTICES caps a held line
12
+ * at its full duration and the reducer cannot import from the context module
13
+ * that imports it.
14
+ */
15
+ export const NOTICE_DURATION_MS: Record<NoticeTone, number> = {
16
+ success: 4000,
17
+ warning: 10000,
18
+ };
19
+
20
+ /**
21
+ * Add `notice` and keep at most {@link MAX_NOTICES}, dropping successes first.
22
+ *
23
+ * A success only confirms what the screen already shows; a warning may be the
24
+ * one thing telling the user to act. So a burst of lines evicts the oldest
25
+ * success, and the oldest warning goes only when nothing but warnings is left.
26
+ */
27
+ function withNotice(notices: readonly Notice[], notice: Notice): Notice[] {
28
+ const next = [...notices, notice];
29
+ while (next.length > MAX_NOTICES) {
30
+ const success = next.findIndex((n) => n.tone === "success");
31
+ next.splice(success === -1 ? 0 : success, 1);
32
+ }
33
+ return next;
34
+ }
35
+
4
36
  export const initialState: AppState = {
5
37
  // Navigation - start on plugins screen
6
38
  currentRoute: { screen: "plugins" },
@@ -10,12 +42,17 @@ export const initialState: AppState = {
10
42
 
11
43
  // Global UI state
12
44
  modal: null,
45
+ notices: [],
46
+ noticesPausedAt: null,
13
47
  progress: null,
14
48
  isSearching: false,
15
49
 
16
50
  // Refresh counter for triggering data refetch
17
51
  dataRefreshVersion: 0,
18
52
 
53
+ // 0 is the check every open runs; each bump asks for another
54
+ gitignoreCheckRequest: 0,
55
+
19
56
  // Screen-specific state
20
57
  plugins: {
21
58
  scope: "project",
@@ -777,6 +814,57 @@ export function appReducer(state: AppState, action: AppAction): AppState {
777
814
  case "HIDE_MODAL":
778
815
  return { ...state, modal: null };
779
816
 
817
+ // =========================================================================
818
+ // Notices
819
+ // =========================================================================
820
+ // The same text again replaces the old line rather than stacking a copy,
821
+ // and only a few are kept: the banner takes rows from the screen, and a
822
+ // burst must not push the content off the terminal. Which few is
823
+ // `withNotice`'s call — warnings outlast successes.
824
+ case "NOTIFY": {
825
+ const rest = state.notices.filter((n) => n.text !== action.notice.text);
826
+ return { ...state, notices: withNotice(rest, action.notice) };
827
+ }
828
+
829
+ case "EXPIRE_NOTICES": {
830
+ // Held behind a modal: nothing runs down, so nothing expires.
831
+ if (state.noticesPausedAt !== null) return state;
832
+ const live = state.notices.filter((n) => n.expiresAt > action.now);
833
+ // Same state when nothing expired, so the timer effect is not re-run.
834
+ return live.length === state.notices.length
835
+ ? state
836
+ : { ...state, notices: live };
837
+ }
838
+
839
+ // A modal opened. Only the first pause counts: modal to modal (a confirm
840
+ // followed by its loading modal) is one uninterrupted hold.
841
+ case "PAUSE_NOTICES":
842
+ return state.noticesPausedAt !== null
843
+ ? state
844
+ : { ...state, noticesPausedAt: action.now };
845
+
846
+ // The modal slot emptied: every line gets back the time it spent held.
847
+ // A line posted DURING the hold would be over-credited by the time
848
+ // between the pause and its posting, so it is capped at its full
849
+ // duration from now — which is exactly what it would have had, had it
850
+ // been posted the moment the modal closed.
851
+ case "RESUME_NOTICES": {
852
+ const pausedAt = state.noticesPausedAt;
853
+ if (pausedAt === null) return state;
854
+ const held = action.now - pausedAt;
855
+ return {
856
+ ...state,
857
+ noticesPausedAt: null,
858
+ notices: state.notices.map((n) => ({
859
+ ...n,
860
+ expiresAt: Math.min(
861
+ n.expiresAt + held,
862
+ action.now + NOTICE_DURATION_MS[n.tone],
863
+ ),
864
+ })),
865
+ };
866
+ }
867
+
780
868
  // =========================================================================
781
869
  // Progress
782
870
  // =========================================================================
@@ -801,6 +889,17 @@ export function appReducer(state: AppState, action: AppAction): AppState {
801
889
  case "DATA_REFRESH_COMPLETE":
802
890
  return { ...state, dataRefreshVersion: state.dataRefreshVersion + 1 };
803
891
 
892
+ // One action for both, so no activation path can do one and forget the
893
+ // other: the screens reload what the profile turned on, and the gitignore
894
+ // question is asked again, because a first activation is what turns a
895
+ // committed settings file into a generated one git should not track.
896
+ case "PROFILE_ACTIVATED":
897
+ return {
898
+ ...state,
899
+ dataRefreshVersion: state.dataRefreshVersion + 1,
900
+ gitignoreCheckRequest: state.gitignoreCheckRequest + 1,
901
+ };
902
+
804
903
  default:
805
904
  return state;
806
905
  }
@@ -122,10 +122,15 @@ export type ModalState =
122
122
  cancellable?: boolean;
123
123
  }
124
124
  | {
125
+ /**
126
+ * A dialog the user dismisses. No "success" variant, deliberately: a
127
+ * result the screen already shows needs nothing, and one it does not
128
+ * is a notice (see {@link Notice}), which never takes a key.
129
+ */
125
130
  type: "message";
126
131
  title: string;
127
132
  message: string;
128
- variant: "info" | "success" | "error";
133
+ variant: "info" | "error";
129
134
  onDismiss: () => void;
130
135
  }
131
136
  | {
@@ -138,6 +143,28 @@ export type ModalState =
138
143
  screen: Screen;
139
144
  };
140
145
 
146
+ // ============================================================================
147
+ // Notice Types
148
+ // ============================================================================
149
+
150
+ /** A warning is something the user may need to act on, so it stays longer. */
151
+ export type NoticeTone = "success" | "warning";
152
+
153
+ /**
154
+ * One line of the app-level notice banner: the result of an action, shown
155
+ * without taking the modal slot or any key, and gone by itself at `expiresAt`.
156
+ */
157
+ export interface Notice {
158
+ id: number;
159
+ text: string;
160
+ tone: NoticeTone;
161
+ /** Epoch ms after which the banner drops it. */
162
+ expiresAt: number;
163
+ }
164
+
165
+ /** A notice as a pure function words it, before `notify` stamps it. */
166
+ export type NoticeLine = Pick<Notice, "text" | "tone">;
167
+
141
168
  // ============================================================================
142
169
  // Progress Types
143
170
  // ============================================================================
@@ -352,6 +379,14 @@ export interface AppState {
352
379
 
353
380
  // Global UI state
354
381
  modal: ModalState | null;
382
+ /** The notice banner, oldest first. Never occupies the modal slot. */
383
+ notices: Notice[];
384
+ /**
385
+ * When the notices stopped running down, or null while they run. They are
386
+ * held while a modal is open: a line posted under a startup question, or
387
+ * just before one, would otherwise expire behind it unread.
388
+ */
389
+ noticesPausedAt: number | null;
355
390
  progress: ProgressState | null;
356
391
  /**
357
392
  * The current screen's search box has focus. It decides what the box draws
@@ -364,6 +399,14 @@ export interface AppState {
364
399
  // Screens can watch this to know when to refetch data
365
400
  dataRefreshVersion: number;
366
401
 
402
+ /**
403
+ * Bumped to ask the app for a fresh gitignore check. The app shell owns the
404
+ * check and its question (ui/App.tsx); anything that makes generated files
405
+ * appear — a profile activating — asks through this rather than reaching
406
+ * into the shell.
407
+ */
408
+ gitignoreCheckRequest: number;
409
+
367
410
  // Screen-specific state
368
411
  plugins: PluginsScreenState;
369
412
  mcp: McpScreenState;
@@ -449,6 +492,12 @@ export type AppAction =
449
492
  | { type: "SHOW_MODAL"; modal: ModalState }
450
493
  | { type: "HIDE_MODAL" }
451
494
 
495
+ // Notices
496
+ | { type: "NOTIFY"; notice: Notice }
497
+ | { type: "EXPIRE_NOTICES"; now: number }
498
+ | { type: "PAUSE_NOTICES"; now: number }
499
+ | { type: "RESUME_NOTICES"; now: number }
500
+
452
501
  // Progress
453
502
  | { type: "SHOW_PROGRESS"; state: ProgressState }
454
503
  | { type: "UPDATE_PROGRESS"; state: ProgressState }
@@ -520,4 +569,6 @@ export type AppAction =
520
569
  | { type: "MODELS_CATALOG_LOADED"; catalog: CatalogModel[] }
521
570
 
522
571
  // Data refresh - triggers screens to refetch
523
- | { type: "DATA_REFRESH_COMPLETE" };
572
+ | { type: "DATA_REFRESH_COMPLETE" }
573
+ // A profile became active: screens refetch, and the gitignore check re-runs
574
+ | { type: "PROFILE_ACTIVATED" };