magus-cli 7.6.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 +9 -34
- package/src/__tests__/gitignore-profile.test.ts +0 -4
- 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 -42
- 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/gitignore-service.ts +0 -19
- 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-absorb.ts +9 -0
- package/src/services/profile-activate.ts +1 -1
- package/src/services/profile-startup.ts +30 -27
- package/src/services/profile-state.ts +81 -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 +186 -123
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +299 -28
- 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 -13
- 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 +31 -31
- package/src/ui/hooks/useProfileStartupModal.ts +127 -122
- package/src/ui/hooks/useStaleLoadModal.ts +33 -53
- 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 -44
- package/src/ui/screens/CliToolsScreen.tsx +65 -51
- package/src/ui/screens/EnvVarsScreen.tsx +21 -6
- package/src/ui/screens/GitignoreScreen.tsx +49 -75
- package/src/ui/screens/McpRegistryScreen.tsx +72 -40
- package/src/ui/screens/McpScreen.tsx +55 -24
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +316 -245
- package/src/ui/screens/ProfilesScreen.tsx +88 -73
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +72 -7
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +25 -14
- package/src/ui/state/types.ts +61 -31
- package/src/utils/user-path.ts +60 -0
- 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
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type ReactNode,
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useReducer,
|
|
8
|
+
} from "react";
|
|
3
9
|
import { appReducer, initialState } from "./reducer.js";
|
|
4
10
|
import type {
|
|
5
11
|
AppAction,
|
|
6
12
|
AppState,
|
|
13
|
+
FailureReport,
|
|
7
14
|
ModalState,
|
|
8
15
|
ProgressState,
|
|
9
16
|
Route,
|
|
@@ -83,6 +90,44 @@ export function useNavigation() {
|
|
|
83
90
|
};
|
|
84
91
|
}
|
|
85
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
|
+
|
|
86
131
|
// ============================================================================
|
|
87
132
|
// Hook: useModal
|
|
88
133
|
// ============================================================================
|
|
@@ -174,11 +219,11 @@ export function useModal() {
|
|
|
174
219
|
});
|
|
175
220
|
},
|
|
176
221
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
): 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> => {
|
|
182
227
|
return new Promise((resolve) => {
|
|
183
228
|
dispatch({
|
|
184
229
|
type: "SHOW_MODAL",
|
|
@@ -186,7 +231,27 @@ export function useModal() {
|
|
|
186
231
|
type: "message",
|
|
187
232
|
title,
|
|
188
233
|
message,
|
|
189
|
-
|
|
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,
|
|
190
255
|
onDismiss: () => {
|
|
191
256
|
dispatch({ type: "HIDE_MODAL" });
|
|
192
257
|
resolve();
|
|
@@ -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
|
@@ -16,6 +16,9 @@ export const initialState: AppState = {
|
|
|
16
16
|
// Refresh counter for triggering data refetch
|
|
17
17
|
dataRefreshVersion: 0,
|
|
18
18
|
|
|
19
|
+
// 0 is the check every open runs; each bump asks for another
|
|
20
|
+
gitignoreCheckRequest: 0,
|
|
21
|
+
|
|
19
22
|
// Screen-specific state
|
|
20
23
|
plugins: {
|
|
21
24
|
scope: "project",
|
|
@@ -33,6 +36,7 @@ export const initialState: AppState = {
|
|
|
33
36
|
selectedIndex: 0,
|
|
34
37
|
servers: { status: "idle" },
|
|
35
38
|
installedServers: {},
|
|
39
|
+
changedThisSession: [],
|
|
36
40
|
},
|
|
37
41
|
|
|
38
42
|
mcpRegistry: {
|
|
@@ -69,7 +73,6 @@ export const initialState: AppState = {
|
|
|
69
73
|
searchQuery: "",
|
|
70
74
|
snapshot: { status: "idle" },
|
|
71
75
|
selected: new Set(),
|
|
72
|
-
status: null,
|
|
73
76
|
isFilling: false,
|
|
74
77
|
},
|
|
75
78
|
|
|
@@ -81,7 +84,6 @@ export const initialState: AppState = {
|
|
|
81
84
|
selectedIndex: 0,
|
|
82
85
|
searchQuery: "",
|
|
83
86
|
data: { status: "idle" },
|
|
84
|
-
status: null,
|
|
85
87
|
isApplying: false,
|
|
86
88
|
// False until the plugin lookup answers, and false again if it fails. Both cases
|
|
87
89
|
// render the screen as it was before mates existed, which is the honest drawing of
|
|
@@ -294,6 +296,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
294
296
|
},
|
|
295
297
|
};
|
|
296
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
|
+
|
|
297
309
|
// =========================================================================
|
|
298
310
|
// MCP Registry Screen
|
|
299
311
|
// =========================================================================
|
|
@@ -647,12 +659,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
647
659
|
},
|
|
648
660
|
};
|
|
649
661
|
|
|
650
|
-
case "STYLES_STATUS_SET":
|
|
651
|
-
return { ...state, styles: { ...state.styles, status: action.status } };
|
|
652
|
-
|
|
653
|
-
case "STYLES_STATUS_CLEAR":
|
|
654
|
-
return { ...state, styles: { ...state.styles, status: null } };
|
|
655
|
-
|
|
656
662
|
case "STYLES_FILL_START":
|
|
657
663
|
return { ...state, styles: { ...state.styles, isFilling: true } };
|
|
658
664
|
|
|
@@ -742,12 +748,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
742
748
|
},
|
|
743
749
|
};
|
|
744
750
|
|
|
745
|
-
case "MODELS_STATUS_SET":
|
|
746
|
-
return { ...state, models: { ...state.models, status: action.status } };
|
|
747
|
-
|
|
748
|
-
case "MODELS_STATUS_CLEAR":
|
|
749
|
-
return { ...state, models: { ...state.models, status: null } };
|
|
750
|
-
|
|
751
751
|
case "MODELS_APPLY_START":
|
|
752
752
|
return { ...state, models: { ...state.models, isApplying: true } };
|
|
753
753
|
|
|
@@ -801,6 +801,17 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
801
801
|
case "DATA_REFRESH_COMPLETE":
|
|
802
802
|
return { ...state, dataRefreshVersion: state.dataRefreshVersion + 1 };
|
|
803
803
|
|
|
804
|
+
// One action for both, so no activation path can do one and forget the
|
|
805
|
+
// other: the screens reload what the profile turned on, and the gitignore
|
|
806
|
+
// question is asked again, because a first activation is what turns a
|
|
807
|
+
// committed settings file into a generated one git should not track.
|
|
808
|
+
case "PROFILE_ACTIVATED":
|
|
809
|
+
return {
|
|
810
|
+
...state,
|
|
811
|
+
dataRefreshVersion: state.dataRefreshVersion + 1,
|
|
812
|
+
gitignoreCheckRequest: state.gitignoreCheckRequest + 1,
|
|
813
|
+
};
|
|
814
|
+
|
|
804
815
|
default:
|
|
805
816
|
return state;
|
|
806
817
|
}
|
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";
|
|
@@ -122,10 +151,21 @@ export type ModalState =
|
|
|
122
151
|
cancellable?: boolean;
|
|
123
152
|
}
|
|
124
153
|
| {
|
|
154
|
+
/**
|
|
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.
|
|
159
|
+
*/
|
|
125
160
|
type: "message";
|
|
126
161
|
title: string;
|
|
127
162
|
message: string;
|
|
128
|
-
|
|
163
|
+
onDismiss: () => void;
|
|
164
|
+
}
|
|
165
|
+
| {
|
|
166
|
+
/** What failed and why — see {@link FailureReport}. Any key dismisses it. */
|
|
167
|
+
type: "failures";
|
|
168
|
+
report: FailureReport;
|
|
129
169
|
onDismiss: () => void;
|
|
130
170
|
}
|
|
131
171
|
| {
|
|
@@ -188,6 +228,13 @@ export interface McpScreenState {
|
|
|
188
228
|
selectedIndex: number;
|
|
189
229
|
servers: AsyncData<McpServer[]>;
|
|
190
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[];
|
|
191
238
|
}
|
|
192
239
|
|
|
193
240
|
export interface McpRegistryScreenState {
|
|
@@ -252,16 +299,6 @@ export interface StylesScreenState {
|
|
|
252
299
|
* style, which is not something a stray Space press should do.
|
|
253
300
|
*/
|
|
254
301
|
selected: Set<string>;
|
|
255
|
-
/**
|
|
256
|
-
* The status line's current message, or null for the default line.
|
|
257
|
-
*
|
|
258
|
-
* App state for the same reason `isFilling` is: `Router` swaps the component
|
|
259
|
-
* type on a tab change, so anything held in the screen's own `useState` is
|
|
260
|
-
* destroyed by `9 → 1 → 9`. It also stops being cleared on a wall clock —
|
|
261
|
-
* the message now survives until the next action replaces it, so the last
|
|
262
|
-
* thing you did is still on screen whenever you look.
|
|
263
|
-
*/
|
|
264
|
-
status: { text: string; tone: "success" | "error" } | null;
|
|
265
302
|
/**
|
|
266
303
|
* True while a template fill (Claude Code over the whole project) is running.
|
|
267
304
|
*
|
|
@@ -293,15 +330,6 @@ export interface ModelsScreenState {
|
|
|
293
330
|
searchQuery: string;
|
|
294
331
|
/** Routing status + the config behind it, in one read. */
|
|
295
332
|
data: AsyncData<ModelsSnapshot>;
|
|
296
|
-
/**
|
|
297
|
-
* The status line's current message, or null for the default line.
|
|
298
|
-
*
|
|
299
|
-
* App state for the same reason the Styles screen's is: `Router` swaps the
|
|
300
|
-
* component type on a tab change, so anything held in the screen's own
|
|
301
|
-
* `useState` is destroyed by `0 → 1 → 0`. The message survives until the next
|
|
302
|
-
* action replaces it, so the last thing you did is still on screen.
|
|
303
|
-
*/
|
|
304
|
-
status: { text: string; tone: "success" | "error" } | null;
|
|
305
333
|
/**
|
|
306
334
|
* True while an apply or a clear is writing.
|
|
307
335
|
*
|
|
@@ -364,6 +392,14 @@ export interface AppState {
|
|
|
364
392
|
// Screens can watch this to know when to refetch data
|
|
365
393
|
dataRefreshVersion: number;
|
|
366
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Bumped to ask the app for a fresh gitignore check. The app shell owns the
|
|
397
|
+
* check and its question (ui/App.tsx); anything that makes generated files
|
|
398
|
+
* appear — a profile activating — asks through this rather than reaching
|
|
399
|
+
* into the shell.
|
|
400
|
+
*/
|
|
401
|
+
gitignoreCheckRequest: number;
|
|
402
|
+
|
|
367
403
|
// Screen-specific state
|
|
368
404
|
plugins: PluginsScreenState;
|
|
369
405
|
mcp: McpScreenState;
|
|
@@ -422,6 +458,8 @@ export type AppAction =
|
|
|
422
458
|
installedServers: Record<string, boolean>;
|
|
423
459
|
}
|
|
424
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 }
|
|
425
463
|
|
|
426
464
|
// MCP Registry screen
|
|
427
465
|
| { type: "MCP_REGISTRY_SET_QUERY"; query: string }
|
|
@@ -487,11 +525,6 @@ export type AppAction =
|
|
|
487
525
|
| { type: "STYLES_DATA_SUCCESS"; snapshot: StylesSnapshot }
|
|
488
526
|
| { type: "STYLES_DATA_ERROR"; error: Error }
|
|
489
527
|
| { type: "STYLES_TOGGLE"; id: string }
|
|
490
|
-
| {
|
|
491
|
-
type: "STYLES_STATUS_SET";
|
|
492
|
-
status: { text: string; tone: "success" | "error" };
|
|
493
|
-
}
|
|
494
|
-
| { type: "STYLES_STATUS_CLEAR" }
|
|
495
528
|
| { type: "STYLES_FILL_START" }
|
|
496
529
|
| { type: "STYLES_FILL_END" }
|
|
497
530
|
| { type: "STYLES_SET_SELECTION"; ids: string[] }
|
|
@@ -508,11 +541,6 @@ export type AppAction =
|
|
|
508
541
|
| { type: "MODELS_DATA_LOADING" }
|
|
509
542
|
| { type: "MODELS_DATA_SUCCESS"; snapshot: ModelsSnapshot }
|
|
510
543
|
| { type: "MODELS_DATA_ERROR"; error: Error }
|
|
511
|
-
| {
|
|
512
|
-
type: "MODELS_STATUS_SET";
|
|
513
|
-
status: { text: string; tone: "success" | "error" };
|
|
514
|
-
}
|
|
515
|
-
| { type: "MODELS_STATUS_CLEAR" }
|
|
516
544
|
| { type: "MODELS_APPLY_START" }
|
|
517
545
|
| { type: "MODELS_APPLY_END" }
|
|
518
546
|
// Dispatched once, when the plugin lookup behind the mate slots lands.
|
|
@@ -520,4 +548,6 @@ export type AppAction =
|
|
|
520
548
|
| { type: "MODELS_CATALOG_LOADED"; catalog: CatalogModel[] }
|
|
521
549
|
|
|
522
550
|
// Data refresh - triggers screens to refetch
|
|
523
|
-
| { type: "DATA_REFRESH_COMPLETE" }
|
|
551
|
+
| { type: "DATA_REFRESH_COMPLETE" }
|
|
552
|
+
// A profile became active: screens refetch, and the gitignore check re-runs
|
|
553
|
+
| { type: "PROFILE_ACTIVATED" };
|
|
@@ -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
|
+
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { appReducer, initialState } from "../ui/state/reducer.js";
|
|
3
|
-
import type { AppAction, AppState } from "../ui/state/types.js";
|
|
4
|
-
|
|
5
|
-
function run(actions: AppAction[], from: AppState = initialState): AppState {
|
|
6
|
-
return actions.reduce(appReducer, from);
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The status line lives in app state, not in the screen.
|
|
11
|
-
*
|
|
12
|
-
* Two bugs drove this, and both were invisible to every test we had:
|
|
13
|
-
*
|
|
14
|
-
* 1. It was cleared on a 5-second wall clock, so the message describing what
|
|
15
|
-
* you just did was usually gone before you looked at it — and unobservable
|
|
16
|
-
* to anything sampling the screen a moment later.
|
|
17
|
-
* 2. It was component-local, and `Router` swaps the component type on a tab
|
|
18
|
-
* change, so `9 → 1 → 9` destroyed it.
|
|
19
|
-
*/
|
|
20
|
-
describe("status line state", () => {
|
|
21
|
-
test("survives leaving the tab and coming back", () => {
|
|
22
|
-
const shown = run([
|
|
23
|
-
{
|
|
24
|
-
type: "STYLES_STATUS_SET",
|
|
25
|
-
status: { text: "Selection reset to the live style", tone: "success" },
|
|
26
|
-
},
|
|
27
|
-
]);
|
|
28
|
-
expect(shown.styles.status?.text).toBe("Selection reset to the live style");
|
|
29
|
-
|
|
30
|
-
// Navigating away and back is what unmounts the screen.
|
|
31
|
-
const roundTrip = run(
|
|
32
|
-
[
|
|
33
|
-
{ type: "NAVIGATE", route: { screen: "plugins" } },
|
|
34
|
-
{ type: "NAVIGATE", route: { screen: "styles" } },
|
|
35
|
-
],
|
|
36
|
-
shown,
|
|
37
|
-
);
|
|
38
|
-
expect(roundTrip.styles.status?.text).toBe(
|
|
39
|
-
"Selection reset to the live style",
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("a new message replaces the previous one", () => {
|
|
44
|
-
const state = run([
|
|
45
|
-
{ type: "STYLES_STATUS_SET", status: { text: "first", tone: "success" } },
|
|
46
|
-
{ type: "STYLES_STATUS_SET", status: { text: "second", tone: "error" } },
|
|
47
|
-
]);
|
|
48
|
-
expect(state.styles.status).toEqual({ text: "second", tone: "error" });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("clearing returns the line to its default", () => {
|
|
52
|
-
const state = run([
|
|
53
|
-
{ type: "STYLES_STATUS_SET", status: { text: "done", tone: "success" } },
|
|
54
|
-
{ type: "STYLES_STATUS_CLEAR" },
|
|
55
|
-
]);
|
|
56
|
-
expect(state.styles.status).toBeNull();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("starts empty", () => {
|
|
60
|
-
expect(initialState.styles.status).toBeNull();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("keeps the tone, so an error cannot render as a success", () => {
|
|
64
|
-
const state = run([
|
|
65
|
-
{
|
|
66
|
-
type: "STYLES_STATUS_SET",
|
|
67
|
-
status: { text: "Already talking to GitHub", tone: "error" },
|
|
68
|
-
},
|
|
69
|
-
]);
|
|
70
|
-
expect(state.styles.status?.tone).toBe("error");
|
|
71
|
-
});
|
|
72
|
-
});
|