magus-cli 7.7.0 → 7.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +29 -15
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1203 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +920 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +724 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +23 -17
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/doctor.ts +151 -30
- package/src/cli/install.ts +5 -6
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +27 -5
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +84 -36
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +31 -19
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +95 -1
- package/src/services/update-engine.ts +326 -31
- package/src/services/update-plan.ts +293 -33
- package/src/types/index.ts +7 -0
- package/src/ui/App.tsx +90 -146
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +282 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +65 -54
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +313 -221
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/version-snapshot.ts +0 -189
|
@@ -6,12 +6,12 @@ import {
|
|
|
6
6
|
useContext,
|
|
7
7
|
useReducer,
|
|
8
8
|
} from "react";
|
|
9
|
-
import {
|
|
9
|
+
import { appReducer, initialState } from "./reducer.js";
|
|
10
10
|
import type {
|
|
11
11
|
AppAction,
|
|
12
12
|
AppState,
|
|
13
|
+
FailureReport,
|
|
13
14
|
ModalState,
|
|
14
|
-
NoticeTone,
|
|
15
15
|
ProgressState,
|
|
16
16
|
Route,
|
|
17
17
|
Screen,
|
|
@@ -90,6 +90,44 @@ export function useNavigation() {
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// Hook: useFailurePopup
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The failure popup as ONE stable callback: show `report`, or do nothing for
|
|
99
|
+
* null — the "nothing failed" answer every report builder gives. Stable
|
|
100
|
+
* across renders, unlike `useModal()`, so a `useCallback` that reports a
|
|
101
|
+
* failure can list it without being rebuilt — and without re-running an
|
|
102
|
+
* effect that depends on that callback.
|
|
103
|
+
*/
|
|
104
|
+
export function useFailurePopup(): (
|
|
105
|
+
report: FailureReport | null,
|
|
106
|
+
) => Promise<void> {
|
|
107
|
+
const { dispatch } = useApp();
|
|
108
|
+
return useCallback(
|
|
109
|
+
(report: FailureReport | null) =>
|
|
110
|
+
new Promise<void>((resolve) => {
|
|
111
|
+
if (!report) {
|
|
112
|
+
resolve();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
dispatch({
|
|
116
|
+
type: "SHOW_MODAL",
|
|
117
|
+
modal: {
|
|
118
|
+
type: "failures",
|
|
119
|
+
report,
|
|
120
|
+
onDismiss: () => {
|
|
121
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
122
|
+
resolve();
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}),
|
|
127
|
+
[dispatch],
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
93
131
|
// ============================================================================
|
|
94
132
|
// Hook: useModal
|
|
95
133
|
// ============================================================================
|
|
@@ -181,11 +219,11 @@ export function useModal() {
|
|
|
181
219
|
});
|
|
182
220
|
},
|
|
183
221
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
): Promise<void> => {
|
|
222
|
+
/**
|
|
223
|
+
* Information to read and dismiss. Never a failure: that is `failures`,
|
|
224
|
+
* which has the one shape every failure uses.
|
|
225
|
+
*/
|
|
226
|
+
message: (title: string, message: string): Promise<void> => {
|
|
189
227
|
return new Promise((resolve) => {
|
|
190
228
|
dispatch({
|
|
191
229
|
type: "SHOW_MODAL",
|
|
@@ -193,7 +231,27 @@ export function useModal() {
|
|
|
193
231
|
type: "message",
|
|
194
232
|
title,
|
|
195
233
|
message,
|
|
196
|
-
|
|
234
|
+
onDismiss: () => {
|
|
235
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
236
|
+
resolve();
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The failure popup — see {@link FailureReport}. Resolves once dismissed.
|
|
245
|
+
* Use it for anything that did not happen and should have; `message` is
|
|
246
|
+
* for information.
|
|
247
|
+
*/
|
|
248
|
+
failures: (report: FailureReport): Promise<void> => {
|
|
249
|
+
return new Promise((resolve) => {
|
|
250
|
+
dispatch({
|
|
251
|
+
type: "SHOW_MODAL",
|
|
252
|
+
modal: {
|
|
253
|
+
type: "failures",
|
|
254
|
+
report,
|
|
197
255
|
onDismiss: () => {
|
|
198
256
|
dispatch({ type: "HIDE_MODAL" });
|
|
199
257
|
resolve();
|
|
@@ -220,42 +278,6 @@ export function useModal() {
|
|
|
220
278
|
};
|
|
221
279
|
}
|
|
222
280
|
|
|
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
|
-
|
|
259
281
|
// ============================================================================
|
|
260
282
|
// Hook: useProgress
|
|
261
283
|
// ============================================================================
|
|
@@ -29,8 +29,6 @@ interface DimensionsProviderProps {
|
|
|
29
29
|
showProgress?: boolean;
|
|
30
30
|
/** Whether update banner is visible */
|
|
31
31
|
showUpdateBanner?: boolean;
|
|
32
|
-
/** Number of transient banners (recovery, mismatch) currently visible */
|
|
33
|
-
transientBannerCount?: number;
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
function calculateDimensions(
|
|
@@ -38,7 +36,6 @@ function calculateDimensions(
|
|
|
38
36
|
rows: number,
|
|
39
37
|
showProgress: boolean,
|
|
40
38
|
showUpdateBanner: boolean,
|
|
41
|
-
transientBannerCount: number,
|
|
42
39
|
): Dimensions {
|
|
43
40
|
const terminalWidth = columns;
|
|
44
41
|
const terminalHeight = rows;
|
|
@@ -47,7 +44,6 @@ function calculateDimensions(
|
|
|
47
44
|
let contentHeight = terminalHeight - APP_MARGINS;
|
|
48
45
|
if (showProgress) contentHeight -= PROGRESS_HEIGHT;
|
|
49
46
|
if (showUpdateBanner) contentHeight -= 1;
|
|
50
|
-
contentHeight -= transientBannerCount;
|
|
51
47
|
contentHeight = Math.max(10, contentHeight); // Minimum 10 lines for full layout
|
|
52
48
|
|
|
53
49
|
// Calculate available content width (accounting for padding)
|
|
@@ -74,7 +70,6 @@ export function DimensionsProvider({
|
|
|
74
70
|
children,
|
|
75
71
|
showProgress = false,
|
|
76
72
|
showUpdateBanner = false,
|
|
77
|
-
transientBannerCount = 0,
|
|
78
73
|
}: DimensionsProviderProps) {
|
|
79
74
|
const renderer = useRenderer();
|
|
80
75
|
|
|
@@ -84,24 +79,17 @@ export function DimensionsProvider({
|
|
|
84
79
|
renderer.height,
|
|
85
80
|
showProgress,
|
|
86
81
|
showUpdateBanner,
|
|
87
|
-
transientBannerCount,
|
|
88
82
|
),
|
|
89
83
|
);
|
|
90
84
|
|
|
91
85
|
// Handle terminal resize
|
|
92
86
|
useOnResize((width, height) => {
|
|
93
87
|
setDimensions(
|
|
94
|
-
calculateDimensions(
|
|
95
|
-
width,
|
|
96
|
-
height,
|
|
97
|
-
showProgress,
|
|
98
|
-
showUpdateBanner,
|
|
99
|
-
transientBannerCount,
|
|
100
|
-
),
|
|
88
|
+
calculateDimensions(width, height, showProgress, showUpdateBanner),
|
|
101
89
|
);
|
|
102
90
|
});
|
|
103
91
|
|
|
104
|
-
// Update when showProgress/showUpdateBanner
|
|
92
|
+
// Update when showProgress/showUpdateBanner changes
|
|
105
93
|
useEffect(() => {
|
|
106
94
|
setDimensions(
|
|
107
95
|
calculateDimensions(
|
|
@@ -109,16 +97,9 @@ export function DimensionsProvider({
|
|
|
109
97
|
renderer.height,
|
|
110
98
|
showProgress,
|
|
111
99
|
showUpdateBanner,
|
|
112
|
-
transientBannerCount,
|
|
113
100
|
),
|
|
114
101
|
);
|
|
115
|
-
}, [
|
|
116
|
-
renderer.width,
|
|
117
|
-
renderer.height,
|
|
118
|
-
showProgress,
|
|
119
|
-
showUpdateBanner,
|
|
120
|
-
transientBannerCount,
|
|
121
|
-
]);
|
|
102
|
+
}, [renderer.width, renderer.height, showProgress, showUpdateBanner]);
|
|
122
103
|
|
|
123
104
|
return (
|
|
124
105
|
<DimensionsContext.Provider value={dimensions}>
|
package/src/ui/state/reducer.ts
CHANGED
|
@@ -1,38 +1,6 @@
|
|
|
1
|
-
import type { AppAction, AppState
|
|
1
|
+
import type { AppAction, AppState } 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
|
-
|
|
36
4
|
export const initialState: AppState = {
|
|
37
5
|
// Navigation - start on plugins screen
|
|
38
6
|
currentRoute: { screen: "plugins" },
|
|
@@ -42,8 +10,6 @@ export const initialState: AppState = {
|
|
|
42
10
|
|
|
43
11
|
// Global UI state
|
|
44
12
|
modal: null,
|
|
45
|
-
notices: [],
|
|
46
|
-
noticesPausedAt: null,
|
|
47
13
|
progress: null,
|
|
48
14
|
isSearching: false,
|
|
49
15
|
|
|
@@ -70,6 +36,7 @@ export const initialState: AppState = {
|
|
|
70
36
|
selectedIndex: 0,
|
|
71
37
|
servers: { status: "idle" },
|
|
72
38
|
installedServers: {},
|
|
39
|
+
changedThisSession: [],
|
|
73
40
|
},
|
|
74
41
|
|
|
75
42
|
mcpRegistry: {
|
|
@@ -106,7 +73,6 @@ export const initialState: AppState = {
|
|
|
106
73
|
searchQuery: "",
|
|
107
74
|
snapshot: { status: "idle" },
|
|
108
75
|
selected: new Set(),
|
|
109
|
-
status: null,
|
|
110
76
|
isFilling: false,
|
|
111
77
|
},
|
|
112
78
|
|
|
@@ -118,7 +84,6 @@ export const initialState: AppState = {
|
|
|
118
84
|
selectedIndex: 0,
|
|
119
85
|
searchQuery: "",
|
|
120
86
|
data: { status: "idle" },
|
|
121
|
-
status: null,
|
|
122
87
|
isApplying: false,
|
|
123
88
|
// False until the plugin lookup answers, and false again if it fails. Both cases
|
|
124
89
|
// render the screen as it was before mates existed, which is the honest drawing of
|
|
@@ -331,6 +296,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
331
296
|
},
|
|
332
297
|
};
|
|
333
298
|
|
|
299
|
+
case "MCP_SERVER_CHANGED":
|
|
300
|
+
if (state.mcp.changedThisSession.includes(action.name)) return state;
|
|
301
|
+
return {
|
|
302
|
+
...state,
|
|
303
|
+
mcp: {
|
|
304
|
+
...state.mcp,
|
|
305
|
+
changedThisSession: [...state.mcp.changedThisSession, action.name],
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
334
309
|
// =========================================================================
|
|
335
310
|
// MCP Registry Screen
|
|
336
311
|
// =========================================================================
|
|
@@ -684,12 +659,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
684
659
|
},
|
|
685
660
|
};
|
|
686
661
|
|
|
687
|
-
case "STYLES_STATUS_SET":
|
|
688
|
-
return { ...state, styles: { ...state.styles, status: action.status } };
|
|
689
|
-
|
|
690
|
-
case "STYLES_STATUS_CLEAR":
|
|
691
|
-
return { ...state, styles: { ...state.styles, status: null } };
|
|
692
|
-
|
|
693
662
|
case "STYLES_FILL_START":
|
|
694
663
|
return { ...state, styles: { ...state.styles, isFilling: true } };
|
|
695
664
|
|
|
@@ -779,12 +748,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
779
748
|
},
|
|
780
749
|
};
|
|
781
750
|
|
|
782
|
-
case "MODELS_STATUS_SET":
|
|
783
|
-
return { ...state, models: { ...state.models, status: action.status } };
|
|
784
|
-
|
|
785
|
-
case "MODELS_STATUS_CLEAR":
|
|
786
|
-
return { ...state, models: { ...state.models, status: null } };
|
|
787
|
-
|
|
788
751
|
case "MODELS_APPLY_START":
|
|
789
752
|
return { ...state, models: { ...state.models, isApplying: true } };
|
|
790
753
|
|
|
@@ -814,57 +777,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
814
777
|
case "HIDE_MODAL":
|
|
815
778
|
return { ...state, modal: null };
|
|
816
779
|
|
|
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
|
-
|
|
868
780
|
// =========================================================================
|
|
869
781
|
// Progress
|
|
870
782
|
// =========================================================================
|
package/src/ui/state/types.ts
CHANGED
|
@@ -86,6 +86,35 @@ export interface SelectOption {
|
|
|
86
86
|
meta?: string;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* What failed, as ONE popup shape every failure report on every screen uses:
|
|
91
|
+
* the state counted in the headline, one row per thing that failed (or per
|
|
92
|
+
* group sharing a reason), and — only when every row shares it — the one fix.
|
|
93
|
+
* Subjects are whatever failed: plugins, marketplaces, skills, files, the
|
|
94
|
+
* profile. Rendered by `FailureListModal`; built by pure functions
|
|
95
|
+
* (`ui/adapters/failureReport.ts`), so the words are tested without a renderer.
|
|
96
|
+
*/
|
|
97
|
+
export interface FailureReport {
|
|
98
|
+
/** The state, counted: "3 plugins were not updated", "claudish was not updated". */
|
|
99
|
+
headline: string;
|
|
100
|
+
rows: FailureRow[];
|
|
101
|
+
/** The one thing to do about ALL of them, or null when they differ. */
|
|
102
|
+
fix: string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface FailureRow {
|
|
106
|
+
/** What failed — a plugin name, a file, or several sharing the reason. */
|
|
107
|
+
subject: string;
|
|
108
|
+
/** Why, in the user's terms. One line: an exception keeps its first line only. */
|
|
109
|
+
reason: string;
|
|
110
|
+
/**
|
|
111
|
+
* What to do about THIS row, when it differs from the others'. Absent when
|
|
112
|
+
* there is none, or when every row shares it — the report's `fix` then says
|
|
113
|
+
* it once.
|
|
114
|
+
*/
|
|
115
|
+
fix?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
89
118
|
export type ModalState =
|
|
90
119
|
| {
|
|
91
120
|
type: "confirm";
|
|
@@ -123,14 +152,20 @@ export type ModalState =
|
|
|
123
152
|
}
|
|
124
153
|
| {
|
|
125
154
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
155
|
+
* Information the user dismisses: help text, a command to run. It has
|
|
156
|
+
* no variants, deliberately. A success shows as the new state of the
|
|
157
|
+
* screen and needs nothing more; a failure goes in the failure popup
|
|
158
|
+
* (`failures`), so every failure reads the same way.
|
|
129
159
|
*/
|
|
130
160
|
type: "message";
|
|
131
161
|
title: string;
|
|
132
162
|
message: string;
|
|
133
|
-
|
|
163
|
+
onDismiss: () => void;
|
|
164
|
+
}
|
|
165
|
+
| {
|
|
166
|
+
/** What failed and why — see {@link FailureReport}. Any key dismisses it. */
|
|
167
|
+
type: "failures";
|
|
168
|
+
report: FailureReport;
|
|
134
169
|
onDismiss: () => void;
|
|
135
170
|
}
|
|
136
171
|
| {
|
|
@@ -143,28 +178,6 @@ export type ModalState =
|
|
|
143
178
|
screen: Screen;
|
|
144
179
|
};
|
|
145
180
|
|
|
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
|
-
|
|
168
181
|
// ============================================================================
|
|
169
182
|
// Progress Types
|
|
170
183
|
// ============================================================================
|
|
@@ -215,6 +228,13 @@ export interface McpScreenState {
|
|
|
215
228
|
selectedIndex: number;
|
|
216
229
|
servers: AsyncData<McpServer[]>;
|
|
217
230
|
installedServers: Record<string, boolean>;
|
|
231
|
+
/**
|
|
232
|
+
* Servers added or removed in THIS magus session. A running Claude Code
|
|
233
|
+
* reads its MCP configuration at startup, so each of these takes effect
|
|
234
|
+
* only after it restarts — a property of the server, shown on its row and
|
|
235
|
+
* in its detail rather than said once as a message and lost.
|
|
236
|
+
*/
|
|
237
|
+
changedThisSession: string[];
|
|
218
238
|
}
|
|
219
239
|
|
|
220
240
|
export interface McpRegistryScreenState {
|
|
@@ -279,16 +299,6 @@ export interface StylesScreenState {
|
|
|
279
299
|
* style, which is not something a stray Space press should do.
|
|
280
300
|
*/
|
|
281
301
|
selected: Set<string>;
|
|
282
|
-
/**
|
|
283
|
-
* The status line's current message, or null for the default line.
|
|
284
|
-
*
|
|
285
|
-
* App state for the same reason `isFilling` is: `Router` swaps the component
|
|
286
|
-
* type on a tab change, so anything held in the screen's own `useState` is
|
|
287
|
-
* destroyed by `9 → 1 → 9`. It also stops being cleared on a wall clock —
|
|
288
|
-
* the message now survives until the next action replaces it, so the last
|
|
289
|
-
* thing you did is still on screen whenever you look.
|
|
290
|
-
*/
|
|
291
|
-
status: { text: string; tone: "success" | "error" } | null;
|
|
292
302
|
/**
|
|
293
303
|
* True while a template fill (Claude Code over the whole project) is running.
|
|
294
304
|
*
|
|
@@ -320,15 +330,6 @@ export interface ModelsScreenState {
|
|
|
320
330
|
searchQuery: string;
|
|
321
331
|
/** Routing status + the config behind it, in one read. */
|
|
322
332
|
data: AsyncData<ModelsSnapshot>;
|
|
323
|
-
/**
|
|
324
|
-
* The status line's current message, or null for the default line.
|
|
325
|
-
*
|
|
326
|
-
* App state for the same reason the Styles screen's is: `Router` swaps the
|
|
327
|
-
* component type on a tab change, so anything held in the screen's own
|
|
328
|
-
* `useState` is destroyed by `0 → 1 → 0`. The message survives until the next
|
|
329
|
-
* action replaces it, so the last thing you did is still on screen.
|
|
330
|
-
*/
|
|
331
|
-
status: { text: string; tone: "success" | "error" } | null;
|
|
332
333
|
/**
|
|
333
334
|
* True while an apply or a clear is writing.
|
|
334
335
|
*
|
|
@@ -379,14 +380,6 @@ export interface AppState {
|
|
|
379
380
|
|
|
380
381
|
// Global UI state
|
|
381
382
|
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;
|
|
390
383
|
progress: ProgressState | null;
|
|
391
384
|
/**
|
|
392
385
|
* The current screen's search box has focus. It decides what the box draws
|
|
@@ -465,6 +458,8 @@ export type AppAction =
|
|
|
465
458
|
installedServers: Record<string, boolean>;
|
|
466
459
|
}
|
|
467
460
|
| { type: "MCP_DATA_ERROR"; error: Error }
|
|
461
|
+
/** A server was added or removed — it needs a Claude Code restart. */
|
|
462
|
+
| { type: "MCP_SERVER_CHANGED"; name: string }
|
|
468
463
|
|
|
469
464
|
// MCP Registry screen
|
|
470
465
|
| { type: "MCP_REGISTRY_SET_QUERY"; query: string }
|
|
@@ -492,12 +487,6 @@ export type AppAction =
|
|
|
492
487
|
| { type: "SHOW_MODAL"; modal: ModalState }
|
|
493
488
|
| { type: "HIDE_MODAL" }
|
|
494
489
|
|
|
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
|
-
|
|
501
490
|
// Progress
|
|
502
491
|
| { type: "SHOW_PROGRESS"; state: ProgressState }
|
|
503
492
|
| { type: "UPDATE_PROGRESS"; state: ProgressState }
|
|
@@ -536,11 +525,6 @@ export type AppAction =
|
|
|
536
525
|
| { type: "STYLES_DATA_SUCCESS"; snapshot: StylesSnapshot }
|
|
537
526
|
| { type: "STYLES_DATA_ERROR"; error: Error }
|
|
538
527
|
| { type: "STYLES_TOGGLE"; id: string }
|
|
539
|
-
| {
|
|
540
|
-
type: "STYLES_STATUS_SET";
|
|
541
|
-
status: { text: string; tone: "success" | "error" };
|
|
542
|
-
}
|
|
543
|
-
| { type: "STYLES_STATUS_CLEAR" }
|
|
544
528
|
| { type: "STYLES_FILL_START" }
|
|
545
529
|
| { type: "STYLES_FILL_END" }
|
|
546
530
|
| { type: "STYLES_SET_SELECTION"; ids: string[] }
|
|
@@ -557,11 +541,6 @@ export type AppAction =
|
|
|
557
541
|
| { type: "MODELS_DATA_LOADING" }
|
|
558
542
|
| { type: "MODELS_DATA_SUCCESS"; snapshot: ModelsSnapshot }
|
|
559
543
|
| { type: "MODELS_DATA_ERROR"; error: Error }
|
|
560
|
-
| {
|
|
561
|
-
type: "MODELS_STATUS_SET";
|
|
562
|
-
status: { text: string; tone: "success" | "error" };
|
|
563
|
-
}
|
|
564
|
-
| { type: "MODELS_STATUS_CLEAR" }
|
|
565
544
|
| { type: "MODELS_APPLY_START" }
|
|
566
545
|
| { type: "MODELS_APPLY_END" }
|
|
567
546
|
// Dispatched once, when the plugin lookup behind the mate slots lands.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A file as the user names it, never as an absolute path.
|
|
3
|
+
*
|
|
4
|
+
* magus names a file only when the user must act on it (fix it by hand,
|
|
5
|
+
* commit it), and then in the words they would type: `~/.claude/settings.json`
|
|
6
|
+
* for their own settings, `.claude/settings.json` for this project's. The
|
|
7
|
+
* absolute path is where this machine keeps it — the mechanism, and noise
|
|
8
|
+
* that pushes the actual problem off the end of the line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
/** `true` when `file` is `dir` itself or inside it. */
|
|
15
|
+
function isInside(file: string, dir: string): boolean {
|
|
16
|
+
const rel = path.relative(dir, file);
|
|
17
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `file` in the user's terms:
|
|
22
|
+
*
|
|
23
|
+
* 1. Under the user's own `~/.claude/` → `~/.claude/…` (their USER settings),
|
|
24
|
+
* checked first so a project opened at the home folder still names it so.
|
|
25
|
+
* 2. Under the project folder → relative to it: `.claude/settings.json`.
|
|
26
|
+
* 3. Anywhere else with a `.claude/` folder in its path → from that folder
|
|
27
|
+
* on, which is how the user knows a project's settings file.
|
|
28
|
+
* 4. Under the home folder → `~/…`.
|
|
29
|
+
* 5. Otherwise its name alone.
|
|
30
|
+
*/
|
|
31
|
+
export function userFacingPath(
|
|
32
|
+
file: string,
|
|
33
|
+
projectDir: string = process.cwd(),
|
|
34
|
+
home: string = os.homedir(),
|
|
35
|
+
): string {
|
|
36
|
+
const abs = path.resolve(file);
|
|
37
|
+
const userClaude = home ? path.join(home, ".claude") : "";
|
|
38
|
+
if (userClaude && isInside(abs, userClaude)) {
|
|
39
|
+
return `~/${path.relative(home, abs)}`;
|
|
40
|
+
}
|
|
41
|
+
if (projectDir && isInside(abs, path.resolve(projectDir))) {
|
|
42
|
+
const rel = path.relative(path.resolve(projectDir), abs);
|
|
43
|
+
if (rel) return rel;
|
|
44
|
+
}
|
|
45
|
+
const marker = `${path.sep}.claude${path.sep}`;
|
|
46
|
+
const at = abs.lastIndexOf(marker);
|
|
47
|
+
if (at >= 0) return abs.slice(at + 1);
|
|
48
|
+
if (home && isInside(abs, home)) return `~/${path.relative(home, abs)}`;
|
|
49
|
+
return path.basename(abs);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every occurrence of `file`'s absolute path in `text`, in the user's terms. */
|
|
53
|
+
export function withUserFacingPath(
|
|
54
|
+
text: string,
|
|
55
|
+
file: string,
|
|
56
|
+
projectDir?: string,
|
|
57
|
+
home?: string,
|
|
58
|
+
): string {
|
|
59
|
+
return text.split(file).join(userFacingPath(file, projectDir, home));
|
|
60
|
+
}
|