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
|
@@ -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 {
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
-
|
|
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" | "
|
|
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
|
// ============================================================================
|
package/src/ui/state/reducer.ts
CHANGED
|
@@ -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
|
}
|
package/src/ui/state/types.ts
CHANGED
|
@@ -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" | "
|
|
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" };
|