magus-cli 7.1.0 → 7.2.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__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/cli/ansi.ts +39 -0
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +1 -0
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/manifest.ts +21 -0
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/symlink-manager.ts +184 -30
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -0
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +54 -0
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* would ship to every teammate.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import type { Stats } from "node:fs";
|
|
24
25
|
import path from "node:path";
|
|
25
26
|
import fs from "fs-extra";
|
|
26
27
|
|
|
@@ -32,6 +33,7 @@ import fs from "fs-extra";
|
|
|
32
33
|
*/
|
|
33
34
|
export const PROFILE_GITIGNORE_ENTRIES = [
|
|
34
35
|
".claude/_profiles/",
|
|
36
|
+
".claude/.magus-backups/",
|
|
35
37
|
".claude/settings.json",
|
|
36
38
|
".claude/skills",
|
|
37
39
|
".claude/models.json",
|
|
@@ -52,6 +54,30 @@ export function profileDir(name: string, projectPath?: string): string {
|
|
|
52
54
|
/** The artifacts an active profile links into place. */
|
|
53
55
|
export type ProfileLinkKey = "settings" | "skills" | "mcp" | "models";
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Where each linked artifact lives, and what it is called inside a profile.
|
|
59
|
+
*
|
|
60
|
+
* One table, because the link paths do NOT depend on which profile is active
|
|
61
|
+
* and two places listing them is two places to forget one. `linkTargets` adds
|
|
62
|
+
* the profile dir; `linkPaths` needs no profile at all.
|
|
63
|
+
*/
|
|
64
|
+
const LINKED_ARTIFACTS: Array<{
|
|
65
|
+
key: ProfileLinkKey;
|
|
66
|
+
/** Path segments under the project root. */
|
|
67
|
+
at: string[];
|
|
68
|
+
/** File or directory name inside `_profiles/<name>/`. */
|
|
69
|
+
inProfile: string;
|
|
70
|
+
}> = [
|
|
71
|
+
{
|
|
72
|
+
key: "settings",
|
|
73
|
+
at: [".claude", "settings.json"],
|
|
74
|
+
inProfile: "settings.json",
|
|
75
|
+
},
|
|
76
|
+
{ key: "skills", at: [".claude", "skills"], inProfile: "skills" },
|
|
77
|
+
{ key: "mcp", at: [".mcp.json"], inProfile: "mcp.json" },
|
|
78
|
+
{ key: "models", at: [".claude", "models.json"], inProfile: "models.json" },
|
|
79
|
+
];
|
|
80
|
+
|
|
55
81
|
/** The set of links an active profile owns, as [linkPath, targetPath] pairs. */
|
|
56
82
|
export function linkTargets(
|
|
57
83
|
name: string,
|
|
@@ -59,28 +85,121 @@ export function linkTargets(
|
|
|
59
85
|
): Array<{ key: ProfileLinkKey; link: string; target: string }> {
|
|
60
86
|
const base = projectPath ?? process.cwd();
|
|
61
87
|
const dir = profileDir(name, projectPath);
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
return LINKED_ARTIFACTS.map(({ key, at, inProfile }) => ({
|
|
89
|
+
key,
|
|
90
|
+
link: path.join(base, ...at),
|
|
91
|
+
target: path.join(dir, inProfile),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The paths activation writes links at, whichever profile is chosen. */
|
|
96
|
+
export function linkPaths(projectPath?: string): string[] {
|
|
97
|
+
const base = projectPath ?? process.cwd();
|
|
98
|
+
return LINKED_ARTIFACTS.map(({ at }) => path.join(base, ...at));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Real artifacts that activating ANY profile would have to move aside.
|
|
103
|
+
*
|
|
104
|
+
* Empty means activating is lossless — a fresh clone, where the only things at
|
|
105
|
+
* these paths are nothing at all or links magus wrote itself. That is what
|
|
106
|
+
* makes it safe to honour the manifest's declared default without asking: there
|
|
107
|
+
* is no work of the user's to replace, so there is no decision to put to them.
|
|
108
|
+
*
|
|
109
|
+
* Non-empty means a human put something there, and the choice is theirs.
|
|
110
|
+
*/
|
|
111
|
+
export async function displaceableArtifacts(
|
|
112
|
+
projectPath?: string,
|
|
113
|
+
): Promise<string[]> {
|
|
114
|
+
const found: string[] = [];
|
|
115
|
+
for (const link of linkPaths(projectPath)) {
|
|
116
|
+
// A link we own is generated state the manifest reproduces on demand.
|
|
117
|
+
if (await isProfileLink(link, projectPath)) continue;
|
|
118
|
+
try {
|
|
119
|
+
await fs.lstat(link);
|
|
120
|
+
found.push(link);
|
|
121
|
+
} catch {
|
|
122
|
+
// Nothing there.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where activation moves a real file it had to displace.
|
|
130
|
+
*
|
|
131
|
+
* Beside `_profiles/`, never inside it. `_profiles/` is generated and is
|
|
132
|
+
* documented as regenerable from the manifest, so it is the first thing a
|
|
133
|
+
* cleanup — a user's, or ours — deletes. A backup stored there is the only copy
|
|
134
|
+
* of a hand-written file sitting in the one directory everybody is told is safe
|
|
135
|
+
* to remove. Gitignored all the same: these are recovery copies, not something
|
|
136
|
+
* to commit.
|
|
137
|
+
*/
|
|
138
|
+
export function backupRoot(projectPath?: string): string {
|
|
139
|
+
const base = projectPath ?? process.cwd();
|
|
140
|
+
return path.join(base, ".claude", ".magus-backups");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One artifact activation moved aside to make room for its symlink. */
|
|
144
|
+
export interface DisplacedArtifact {
|
|
145
|
+
/** The path that is now a profile symlink. */
|
|
146
|
+
link: string;
|
|
147
|
+
/** Where its previous contents were moved. */
|
|
148
|
+
savedTo: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A directory name that sorts chronologically and is legal on every platform. */
|
|
152
|
+
function backupStamp(): string {
|
|
153
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Move whatever sits at `link` into `backupDir`, unless it is generated state.
|
|
158
|
+
*
|
|
159
|
+
* Activation replaces four paths with symlinks, and replacing means removing
|
|
160
|
+
* what is there. Three of those paths are ordinary files a project writes by
|
|
161
|
+
* hand and commits. `fs.remove` on `.claude/settings.json` is unrecoverable, and
|
|
162
|
+
* it ran on every activation — which is why adoption deliberately stopped short
|
|
163
|
+
* of activating and left migrations half-done.
|
|
164
|
+
*
|
|
165
|
+
* A symlink magus itself wrote is skipped: it points into `_profiles/`, which
|
|
166
|
+
* the manifest regenerates on demand, so copying it would archive nothing and
|
|
167
|
+
* every `profile switch` would leave another directory behind. A symlink
|
|
168
|
+
* pointing ANYWHERE ELSE is somebody's deliberate wiring and is preserved like
|
|
169
|
+
* a real file — the link is moved, never its target.
|
|
170
|
+
*/
|
|
171
|
+
async function displace(
|
|
172
|
+
link: string,
|
|
173
|
+
backupDir: string,
|
|
174
|
+
projectPath?: string,
|
|
175
|
+
): Promise<DisplacedArtifact | null> {
|
|
176
|
+
if (await isProfileLink(link, projectPath)) return null;
|
|
177
|
+
let stat: Stats;
|
|
178
|
+
try {
|
|
179
|
+
stat = await fs.lstat(link);
|
|
180
|
+
} catch {
|
|
181
|
+
return null; // Nothing there — the common case on a first activation.
|
|
182
|
+
}
|
|
183
|
+
// The four link paths have four distinct basenames, so one flat directory
|
|
184
|
+
// holds a whole activation without collisions.
|
|
185
|
+
const savedTo = path.join(backupDir, path.basename(link));
|
|
186
|
+
await fs.ensureDir(backupDir);
|
|
187
|
+
|
|
188
|
+
if (stat.isSymbolicLink()) {
|
|
189
|
+
// A symlink's target is resolved against the directory the LINK sits in,
|
|
190
|
+
// so moving the link two levels deeper silently repoints it: a relative
|
|
191
|
+
// `../shared/mcp.json` that worked becomes dangling, and the "preserved"
|
|
192
|
+
// copy preserves nothing. Rewrite it as absolute at its original meaning.
|
|
193
|
+
const target = await fs.readlink(link);
|
|
194
|
+
const absolute = path.resolve(path.dirname(link), target);
|
|
195
|
+
await fs.remove(savedTo);
|
|
196
|
+
await fs.symlink(absolute, savedTo);
|
|
197
|
+
await fs.remove(link);
|
|
198
|
+
return { link, savedTo };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await fs.move(link, savedTo, { overwrite: true });
|
|
202
|
+
return { link, savedTo };
|
|
84
203
|
}
|
|
85
204
|
|
|
86
205
|
/**
|
|
@@ -100,20 +219,44 @@ async function relink(link: string, target: string): Promise<void> {
|
|
|
100
219
|
* Activate a profile: repoint every shared-config symlink into its
|
|
101
220
|
* materialized directory. Only links whose target exists in the profile dir
|
|
102
221
|
* are created (e.g. a profile with no skills/ dir gets no skills symlink).
|
|
222
|
+
*
|
|
223
|
+
* Returns whatever had to be moved aside to make room — empty for an ordinary
|
|
224
|
+
* `profile switch`, one entry per hand-written artifact the first time a
|
|
225
|
+
* project adopts profiles. Callers report it so the move is never silent.
|
|
103
226
|
*/
|
|
104
227
|
export async function activateProfile(
|
|
105
228
|
name: string,
|
|
106
229
|
projectPath?: string,
|
|
107
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Called the instant an artifact is moved, before the next link is touched.
|
|
232
|
+
*
|
|
233
|
+
* The return value alone is not enough, and the difference is data the user
|
|
234
|
+
* cannot otherwise find: activation moves up to four artifacts in sequence,
|
|
235
|
+
* so a throw on the second discards the array describing the first — leaving
|
|
236
|
+
* `.claude/settings.json` gone from where they put it and no message saying
|
|
237
|
+
* where it went. Reporting as it happens is what makes a half-finished
|
|
238
|
+
* activation recoverable by hand.
|
|
239
|
+
*/
|
|
240
|
+
onDisplace?: (artifact: DisplacedArtifact) => void,
|
|
241
|
+
): Promise<DisplacedArtifact[]> {
|
|
108
242
|
const dir = profileDir(name, projectPath);
|
|
109
243
|
if (!(await fs.pathExists(dir))) {
|
|
110
244
|
throw new Error(
|
|
111
245
|
`Profile "${name}" is not materialized (${dir} missing). Run \`magus install ${name}\`.`,
|
|
112
246
|
);
|
|
113
247
|
}
|
|
248
|
+
// One stamped directory per activation, so the four artifacts that were live
|
|
249
|
+
// together stay together and can be restored as the set they were.
|
|
250
|
+
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
251
|
+
const displaced: DisplacedArtifact[] = [];
|
|
114
252
|
for (const { link, target } of linkTargets(name, projectPath)) {
|
|
115
|
-
await syncLink(link, target, projectPath);
|
|
253
|
+
const moved = await syncLink(link, target, projectPath, backupDir);
|
|
254
|
+
if (moved) {
|
|
255
|
+
displaced.push(moved);
|
|
256
|
+
onDisplace?.(moved);
|
|
257
|
+
}
|
|
116
258
|
}
|
|
259
|
+
return displaced;
|
|
117
260
|
}
|
|
118
261
|
|
|
119
262
|
/**
|
|
@@ -124,13 +267,20 @@ export async function activateProfile(
|
|
|
124
267
|
async function syncLink(
|
|
125
268
|
link: string,
|
|
126
269
|
target: string,
|
|
127
|
-
projectPath
|
|
128
|
-
|
|
270
|
+
projectPath: string | undefined,
|
|
271
|
+
// Required, not optional. An optional backup directory keeps a
|
|
272
|
+
// replace-without-backup path expressible, and that path is the whole defect
|
|
273
|
+
// this function was changed to close — a future caller omitting the argument
|
|
274
|
+
// would reintroduce it silently, and type-check clean while doing so.
|
|
275
|
+
backupDir: string,
|
|
276
|
+
): Promise<DisplacedArtifact | null> {
|
|
129
277
|
if (await fs.pathExists(target)) {
|
|
278
|
+
const moved = await displace(link, backupDir, projectPath);
|
|
130
279
|
await relink(link, target);
|
|
131
|
-
return;
|
|
280
|
+
return moved;
|
|
132
281
|
}
|
|
133
282
|
if (await isProfileLink(link, projectPath)) await fs.remove(link);
|
|
283
|
+
return null;
|
|
134
284
|
}
|
|
135
285
|
|
|
136
286
|
/**
|
|
@@ -145,10 +295,14 @@ export async function syncProfileLink(
|
|
|
145
295
|
name: string,
|
|
146
296
|
key: ProfileLinkKey,
|
|
147
297
|
projectPath?: string,
|
|
148
|
-
): Promise<
|
|
298
|
+
): Promise<DisplacedArtifact | null> {
|
|
149
299
|
const entry = linkTargets(name, projectPath).find((t) => t.key === key);
|
|
150
|
-
if (!entry) return;
|
|
151
|
-
|
|
300
|
+
if (!entry) return null;
|
|
301
|
+
// Backed up for the same reason activation is: `magus models use` on a
|
|
302
|
+
// project with a hand-written `.claude/models.json` would otherwise delete it
|
|
303
|
+
// as a side effect of choosing a model.
|
|
304
|
+
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
305
|
+
return syncLink(entry.link, entry.target, projectPath, backupDir);
|
|
152
306
|
}
|
|
153
307
|
|
|
154
308
|
/** Is `link` a symlink pointing into `.claude/_profiles/`? */
|
package/src/types/index.ts
CHANGED
|
@@ -295,26 +295,33 @@ export interface ClaudeMdConventions {
|
|
|
295
295
|
|
|
296
296
|
// ─── Plugin Profile Types ──────────────────────────────────────────────────────
|
|
297
297
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
/** A profile with its id and scope, as used in UI lists */
|
|
311
|
-
export interface ProfileEntry {
|
|
298
|
+
/**
|
|
299
|
+
* A profile with its id and scope, as used in UI lists.
|
|
300
|
+
*
|
|
301
|
+
* This IS a v2 manifest entry. There is no separate TUI profile shape: both
|
|
302
|
+
* surfaces read one `.claude/profiles.json`, so a second type describing the
|
|
303
|
+
* same file is a second answer to one question. The earlier `Profile` /
|
|
304
|
+
* `ProfilesFile` pair required `createdAt`/`updatedAt` and typed `plugins` as
|
|
305
|
+
* booleans, which is a v1 file — handed a v2 file written by `magus update`,
|
|
306
|
+
* the list sort dereferenced a missing `updatedAt` and threw, so the Profiles
|
|
307
|
+
* tab reported an error for anyone who had ever run the CLI.
|
|
308
|
+
*/
|
|
309
|
+
export interface ProfileEntry extends ProfileManifestEntry {
|
|
312
310
|
id: string;
|
|
313
|
-
name: string;
|
|
314
|
-
plugins: Record<string, boolean>;
|
|
315
|
-
createdAt: string;
|
|
316
|
-
updatedAt: string;
|
|
317
311
|
scope: "user" | "project";
|
|
312
|
+
/**
|
|
313
|
+
* This profile is the one the project is actually running.
|
|
314
|
+
*
|
|
315
|
+
* Derived from the `.claude/settings.json` symlink, not stored — see
|
|
316
|
+
* `activeProfile`. Carried on the entry because a list of profiles with no
|
|
317
|
+
* mark on the live one cannot answer the first question anybody asks of it:
|
|
318
|
+
* the Profiles tab showed two identical-looking rows straight after
|
|
319
|
+
* activating one of them.
|
|
320
|
+
*
|
|
321
|
+
* Only ever true for a `project` profile; a user-scope profile is a template,
|
|
322
|
+
* and nothing links to it.
|
|
323
|
+
*/
|
|
324
|
+
active?: boolean;
|
|
318
325
|
}
|
|
319
326
|
|
|
320
327
|
// NOTE: The built-in profile type lives with its data in
|
|
@@ -324,11 +331,11 @@ export interface ProfileEntry {
|
|
|
324
331
|
|
|
325
332
|
// ─── Team Profile Manifest (v2, committed .claude/profiles.json) ────────────────
|
|
326
333
|
//
|
|
327
|
-
// The
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
334
|
+
// The ONE shape of `.claude/profiles.json`, read by the TUI and the CLI alike.
|
|
335
|
+
// It carries marketplaces, MCP servers, CLI tools, skills, settings and env
|
|
336
|
+
// requirements as well as plugins. A v1 file — the TUI's old plugins-only
|
|
337
|
+
// preset — is a structural subset, so it is read as a v2 entry whose optional
|
|
338
|
+
// fields are simply absent, and rewritten as v2 on the next save.
|
|
332
339
|
|
|
333
340
|
/** A marketplace a profile depends on (registered before its plugins install). */
|
|
334
341
|
export interface ProfileMarketplaceRef {
|
|
@@ -385,6 +392,20 @@ export interface ProfileManifest {
|
|
|
385
392
|
version: 2;
|
|
386
393
|
/** When true, `install --check` fails on any plugin version drift. */
|
|
387
394
|
strictVersions?: boolean;
|
|
395
|
+
/**
|
|
396
|
+
* Which profile this project wants, by id. Committed with the manifest.
|
|
397
|
+
*
|
|
398
|
+
* Without it a manifest says which profiles exist and never which one to
|
|
399
|
+
* use, so every teammate cloning the repo is asked to choose and has nothing
|
|
400
|
+
* to choose by — the repo knows the answer and had no way to write it down.
|
|
401
|
+
*
|
|
402
|
+
* It is intent, not state: the profile that is ACTUALLY active is the symlink
|
|
403
|
+
* (see `activeProfile`), which is per-developer and gitignored. This field is
|
|
404
|
+
* what a fresh clone activates so nobody is asked at all.
|
|
405
|
+
*
|
|
406
|
+
* Must name a profile that exists; `validateManifest` rejects it otherwise.
|
|
407
|
+
*/
|
|
408
|
+
default?: string;
|
|
388
409
|
profiles: Record<string, ProfileManifestEntry>;
|
|
389
410
|
}
|
|
390
411
|
|
package/src/ui/App.tsx
CHANGED
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
} from "../services/gitignore-service.js";
|
|
14
14
|
import { repairAllMarketplaces } from "../services/local-marketplace.js";
|
|
15
15
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
16
|
+
import {
|
|
17
|
+
type ProfileStartupNeed,
|
|
18
|
+
detectProfileStartupNeed,
|
|
19
|
+
needsNothing,
|
|
20
|
+
} from "../services/profile-startup.js";
|
|
16
21
|
import {
|
|
17
22
|
type VersionCheckResult,
|
|
18
23
|
checkForUpdates,
|
|
@@ -22,6 +27,7 @@ import { ProgressBar } from "./components/layout/ProgressBar.js";
|
|
|
22
27
|
import { ModalContainer } from "./components/modals/index.js";
|
|
23
28
|
import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
|
|
24
29
|
import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
|
|
30
|
+
import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
|
|
25
31
|
import {
|
|
26
32
|
AliasScreen,
|
|
27
33
|
CliToolsScreen,
|
|
@@ -128,6 +134,23 @@ function GlobalKeyHandler({
|
|
|
128
134
|
return;
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
// Ctrl+C quits from ANYWHERE, including from under an open modal, and is
|
|
138
|
+
// checked before the guard below rather than beside `q` further down.
|
|
139
|
+
//
|
|
140
|
+
// Every modal used to be dismissable, so Escape-then-Ctrl+C always worked
|
|
141
|
+
// and this ordering never mattered. The profile gate cannot be dismissed —
|
|
142
|
+
// by design, since there is nothing to carry on to — which left Ctrl+C
|
|
143
|
+
// swallowed by the guard and no interrupt at all. A terminal app that
|
|
144
|
+
// ignores Ctrl+C is indistinguishable from one that has hung.
|
|
145
|
+
//
|
|
146
|
+
// `q` deliberately does NOT get the same treatment: a modal may want it
|
|
147
|
+
// (the select list's own cancel), and it is a printable character an input
|
|
148
|
+
// modal has to receive.
|
|
149
|
+
if (key.ctrl && input === "c") {
|
|
150
|
+
onExit();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
// Don't handle keys when modal is open or searching
|
|
132
155
|
if (state.modal || state.isSearching) return;
|
|
133
156
|
|
|
@@ -364,7 +387,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
364
387
|
violationCount: number;
|
|
365
388
|
safeCount: number;
|
|
366
389
|
} | null>(null);
|
|
390
|
+
const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
|
|
391
|
+
null,
|
|
392
|
+
);
|
|
367
393
|
const gitignoreModal = useGitignoreModal();
|
|
394
|
+
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
395
|
+
// only non-action is to leave.
|
|
396
|
+
const profileModal = useProfileStartupModal(onExit);
|
|
368
397
|
|
|
369
398
|
// Check for updates on startup, then re-check periodically (force-bypassing
|
|
370
399
|
// the session cache) so the banner reflects a release that lands while
|
|
@@ -407,6 +436,37 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
407
436
|
setGitignoreData(null);
|
|
408
437
|
}, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
|
|
409
438
|
|
|
439
|
+
// Show the profile modal on the same terms as the gitignore one: only once
|
|
440
|
+
// the modal slot is free, so two startup checks that both found something do
|
|
441
|
+
// not stomp on each other. Gitignore is asked first simply because its
|
|
442
|
+
// effect is declared first; whichever the user dismisses, the other follows.
|
|
443
|
+
useEffect(() => {
|
|
444
|
+
if (!profileNeed) return;
|
|
445
|
+
if (state.modal) return;
|
|
446
|
+
profileModal.show(profileNeed);
|
|
447
|
+
setProfileNeed(null);
|
|
448
|
+
}, [profileNeed, profileModal, state.modal]);
|
|
449
|
+
|
|
450
|
+
// Is this project actually running a profile? Detection only — nothing is
|
|
451
|
+
// written until the user picks something in the modal. The Profiles tab could
|
|
452
|
+
// list two profiles while the project ran neither, and nothing anywhere said
|
|
453
|
+
// so.
|
|
454
|
+
//
|
|
455
|
+
// Its own effect, NOT part of the marketplace block below: that one returns
|
|
456
|
+
// early on `--no-refresh`, which is about network calls to marketplaces and
|
|
457
|
+
// has no bearing on whether this project is on a profile. Sharing the effect
|
|
458
|
+
// would let that flag silently disable this question.
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
(async () => {
|
|
461
|
+
try {
|
|
462
|
+
const need = await detectProfileStartupNeed(process.cwd());
|
|
463
|
+
if (!needsNothing(need)) setProfileNeed(need);
|
|
464
|
+
} catch {
|
|
465
|
+
// non-fatal: the Profiles tab still works by hand
|
|
466
|
+
}
|
|
467
|
+
})();
|
|
468
|
+
}, []);
|
|
469
|
+
|
|
410
470
|
// Auto-refresh marketplaces on startup
|
|
411
471
|
useEffect(() => {
|
|
412
472
|
const noRefresh = process.argv.includes("--no-refresh");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
+
import { hintChip } from "../../theme-mode.js";
|
|
2
3
|
import { theme } from "../../theme.js";
|
|
3
4
|
|
|
4
5
|
/** One footer hint: a key (or key group) and the action it performs. */
|
|
@@ -28,11 +29,12 @@ export function joinKeys(keys: string[]): string {
|
|
|
28
29
|
// The shared chip palette, not a local one. This file used to define its own
|
|
29
30
|
// (`theme.colors.dim` block + terminal-foreground ink), which is why the footer
|
|
30
31
|
// stayed a low-contrast grey-on-grey strip after the palette was made vivid —
|
|
31
|
-
// the new colours simply never reached it. Anything chip-shaped uses theme.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// the new colours simply never reached it. Anything chip-shaped uses the theme.
|
|
33
|
+
//
|
|
34
|
+
// `hintChip()` rather than `theme.hints`: the chip is two fills that must sit a
|
|
35
|
+
// measured step apart, and "a step quieter" is only definable against a page.
|
|
36
|
+
// The static palette cannot express it; `theme-mode` resolves it.
|
|
37
|
+
const GAP = " ";
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* Render a row of footer hints as key badges + labels:
|
|
@@ -45,25 +47,35 @@ const GAP = " ";
|
|
|
45
47
|
export function FooterHints({
|
|
46
48
|
hints,
|
|
47
49
|
}: { hints: FooterHint[] }): React.ReactNode {
|
|
50
|
+
const ink = hintChip();
|
|
48
51
|
const nodes: React.ReactNode[] = [];
|
|
49
52
|
hints.forEach((hint, i) => {
|
|
50
53
|
// A hint is identified by its key chord plus label, not its position.
|
|
51
54
|
const id = `${joinKeys(hint.keys)}:${hint.label}`;
|
|
52
55
|
if (i > 0) {
|
|
53
56
|
nodes.push(
|
|
54
|
-
<span key={`gap-${id}`} fg={
|
|
57
|
+
<span key={`gap-${id}`} fg={theme.colors.muted}>
|
|
55
58
|
{GAP}
|
|
56
59
|
</span>,
|
|
57
60
|
);
|
|
58
61
|
}
|
|
62
|
+
// The two halves butt together with no separator: the key's fill ends
|
|
63
|
+
// exactly where the label's begins, so the pair reads as one chip with a
|
|
64
|
+
// bright end rather than as a badge followed by a word.
|
|
65
|
+
//
|
|
66
|
+
// Both halves are padded on BOTH sides. The label used to carry a trailing
|
|
67
|
+
// space only, which left its first letter hard against the boundary between
|
|
68
|
+
// the two fills — the key's own trailing space sits on the bright fill, so
|
|
69
|
+
// there was no quiet gap on the label's side at all and `↑↓ select` read as
|
|
70
|
+
// one cramped word.
|
|
59
71
|
nodes.push(
|
|
60
|
-
<span key={`k-${id}`} bg={
|
|
72
|
+
<span key={`k-${id}`} bg={ink.keyBg} fg={ink.keyFg}>
|
|
61
73
|
{` ${joinKeys(hint.keys)} `}
|
|
62
74
|
</span>,
|
|
63
75
|
);
|
|
64
76
|
nodes.push(
|
|
65
|
-
<span key={`l-${id}`} fg={
|
|
66
|
-
{` ${hint.label}`}
|
|
77
|
+
<span key={`l-${id}`} bg={ink.labelBg} fg={ink.labelFg}>
|
|
78
|
+
{` ${hint.label} `}
|
|
67
79
|
</span>,
|
|
68
80
|
);
|
|
69
81
|
});
|