magus-cli 7.3.1 → 7.4.1
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__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +120 -15
- package/src/cli/install.ts +35 -4
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +434 -198
- package/src/services/cli-tool-commands.ts +45 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +164 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +316 -44
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +107 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +304 -0
- package/src/ui/input/key-dispatch.ts +197 -0
- package/src/ui/keymap.ts +905 -0
- package/src/ui/renderers/cliToolRenderers.tsx +192 -92
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +173 -101
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +129 -129
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- package/src/ui/hooks/useKeyboardHandler.ts +0 -63
|
@@ -2,13 +2,18 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
4
4
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
6
|
+
import { moveDirection } from "../keymap.js";
|
|
6
7
|
import { useApp, useModal } from "../state/AppContext.js";
|
|
7
8
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
8
9
|
|
|
9
10
|
import { cliTools } from "../../data/cli-tools.js";
|
|
10
11
|
import { loadActiveClosure } from "../../services/active-closure.js";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
type InstallMethod,
|
|
14
|
+
cliToolUninstall,
|
|
15
|
+
uninstallBlocker,
|
|
16
|
+
} from "../../services/cli-tool-commands.js";
|
|
12
17
|
import {
|
|
13
18
|
type InstallInfo,
|
|
14
19
|
PROBE_CONCURRENCY,
|
|
@@ -19,11 +24,12 @@ import {
|
|
|
19
24
|
} from "../../services/tool-probe.js";
|
|
20
25
|
import type { Command } from "../../utils/command-utils.js";
|
|
21
26
|
import { formatCommand } from "../../utils/command-utils.js";
|
|
27
|
+
import { createRequestGate } from "../../utils/request-gate.js";
|
|
22
28
|
import {
|
|
23
29
|
type CliToolRow,
|
|
24
30
|
buildToolRow,
|
|
31
|
+
cliToolEnterAction,
|
|
25
32
|
countToolRows,
|
|
26
|
-
isInstalled,
|
|
27
33
|
} from "../adapters/cliToolsAdapter.js";
|
|
28
34
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
29
35
|
import {
|
|
@@ -43,8 +49,8 @@ const execFileAsync = promisify(execFile);
|
|
|
43
49
|
|
|
44
50
|
let cachedRows: CliToolRow[] | null = null;
|
|
45
51
|
let cacheInitialized = false;
|
|
46
|
-
/**
|
|
47
|
-
|
|
52
|
+
/** Every fetch takes a token, so a refresh discards the answers of the one before. */
|
|
53
|
+
const fetchGate = createRequestGate();
|
|
48
54
|
|
|
49
55
|
function clearCliToolsCache(): void {
|
|
50
56
|
cachedRows = null;
|
|
@@ -90,6 +96,8 @@ export function CliToolsScreen() {
|
|
|
90
96
|
const [rows, setRows] = useState<CliToolRow[]>(
|
|
91
97
|
() => cachedRows || initialRows(),
|
|
92
98
|
);
|
|
99
|
+
/** Why the last check could not run, shown under the header; null when it ran. */
|
|
100
|
+
const [probeError, setProbeError] = useState<string | null>(null);
|
|
93
101
|
|
|
94
102
|
const rowsRef = useRef(rows);
|
|
95
103
|
rowsRef.current = rows;
|
|
@@ -109,49 +117,59 @@ export function CliToolsScreen() {
|
|
|
109
117
|
* Detection first — it decides which installer each tool is probed through
|
|
110
118
|
* (§4.5 of the design: the closure's spec, else the detected installer, else
|
|
111
119
|
* the catalogue) — then one shared probe, rows filling in as tools settle.
|
|
120
|
+
*
|
|
121
|
+
* Never rejects. It runs fire-and-forget on mount and on `r`, and a rejection
|
|
122
|
+
* nobody catches ends the app; a failed probe is said under the header
|
|
123
|
+
* instead, and `r` tries again.
|
|
112
124
|
*/
|
|
113
125
|
const fetchVersionInfo = useCallback(async () => {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
126
|
+
const token = fetchGate.begin();
|
|
127
|
+
setProbeError(null);
|
|
128
|
+
try {
|
|
129
|
+
const projectPath = state.projectPath || process.cwd();
|
|
130
|
+
const [active, installs] = await Promise.all([
|
|
131
|
+
// Full resolution, not `resolveBins: false`: a catalogue tool may be
|
|
132
|
+
// declared by a PLUGIN's requires.bin, and parity needs the spec the
|
|
133
|
+
// CLI plans with. `resolveBins: false` would still keep the profile's
|
|
134
|
+
// own cliTools, but lose every plugin-declared bin and its pin.
|
|
135
|
+
loadActiveClosure(projectPath),
|
|
136
|
+
mapLimited(cliTools, PROBE_CONCURRENCY, (tool) =>
|
|
137
|
+
detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
|
|
138
|
+
),
|
|
139
|
+
]);
|
|
140
|
+
if (!fetchGate.isCurrent(token)) return;
|
|
141
|
+
|
|
142
|
+
const closureBins = active?.closure.bins ?? null;
|
|
143
|
+
const planned = cliTools.map((tool, i) => ({
|
|
144
|
+
tool,
|
|
145
|
+
install: installs[i],
|
|
146
|
+
spec: specForCatalogTool(tool, closureBins, installs[i]),
|
|
147
|
+
}));
|
|
148
|
+
setAndCache(() =>
|
|
149
|
+
planned.map((p) => buildToolRow(p.tool, p.spec, undefined, p.install)),
|
|
150
|
+
);
|
|
137
151
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
+
await probeTools(
|
|
153
|
+
planned.map((p) => p.spec),
|
|
154
|
+
REAL_TOOL_PROBE_IO,
|
|
155
|
+
{
|
|
156
|
+
onResult: (facts, { index }) => {
|
|
157
|
+
if (!fetchGate.isCurrent(token)) return;
|
|
158
|
+
const p = planned[index];
|
|
159
|
+
if (!p) return;
|
|
160
|
+
const row = buildToolRow(p.tool, p.spec, facts, p.install);
|
|
161
|
+
setAndCache((prev) => {
|
|
162
|
+
const next = [...prev];
|
|
163
|
+
next[index] = row;
|
|
164
|
+
return next;
|
|
165
|
+
});
|
|
166
|
+
},
|
|
152
167
|
},
|
|
153
|
-
|
|
154
|
-
)
|
|
168
|
+
);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (!fetchGate.isCurrent(token)) return;
|
|
171
|
+
setProbeError(error instanceof Error ? error.message : String(error));
|
|
172
|
+
}
|
|
155
173
|
}, [setAndCache, state.projectPath]);
|
|
156
174
|
|
|
157
175
|
useEffect(() => {
|
|
@@ -162,27 +180,24 @@ export function CliToolsScreen() {
|
|
|
162
180
|
}
|
|
163
181
|
}, [fetchVersionInfo, rows]);
|
|
164
182
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
} else if (event.name === "enter" || event.name === "return") {
|
|
184
|
-
handleInstall();
|
|
185
|
-
}
|
|
183
|
+
useScreenKeys("cli-tools", {
|
|
184
|
+
install: () => {
|
|
185
|
+
void handleInstall();
|
|
186
|
+
},
|
|
187
|
+
"update-all": () => {
|
|
188
|
+
void handleUpdateAll();
|
|
189
|
+
},
|
|
190
|
+
conflict: () => {
|
|
191
|
+
void handleResolveConflict();
|
|
192
|
+
},
|
|
193
|
+
refresh: () => handleRefresh(),
|
|
194
|
+
move: (key) => {
|
|
195
|
+
const index = cliToolsState.selectedIndex + moveDirection(key);
|
|
196
|
+
dispatch({
|
|
197
|
+
type: "CLITOOLS_SELECT",
|
|
198
|
+
index: Math.max(0, Math.min(rows.length - 1, index)),
|
|
199
|
+
});
|
|
200
|
+
},
|
|
186
201
|
});
|
|
187
202
|
|
|
188
203
|
const handleRefresh = () => {
|
|
@@ -215,21 +230,11 @@ export function CliToolsScreen() {
|
|
|
215
230
|
|
|
216
231
|
const handleInstall = async () => {
|
|
217
232
|
const row = rows[cliToolsState.selectedIndex];
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
// an explicit user action, not a plan. The detail panel shows why the
|
|
224
|
-
// row is unverified beside it.
|
|
225
|
-
const action = !installed
|
|
226
|
-
? "Installing"
|
|
227
|
-
: status?.state === "upgrade" || status?.state === "unknown"
|
|
228
|
-
? "Updating"
|
|
229
|
-
: "Reinstalling";
|
|
230
|
-
// Both sides come from the row, which built them from the SAME rule its
|
|
231
|
-
// state came from — a declared tool's pin included.
|
|
232
|
-
const command = installed && updateCommand ? updateCommand : installCommand;
|
|
233
|
+
const enter = row ? cliToolEnterAction(row) : null;
|
|
234
|
+
if (!row || !enter) return;
|
|
235
|
+
|
|
236
|
+
const { tool } = row;
|
|
237
|
+
const { command, progress: action } = enter;
|
|
233
238
|
|
|
234
239
|
modal.loading(`${action} ${tool.displayName}...`);
|
|
235
240
|
const result = await runCommand(command);
|
|
@@ -246,6 +251,20 @@ export function CliToolsScreen() {
|
|
|
246
251
|
}
|
|
247
252
|
};
|
|
248
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Keep one install of a tool installed more than one way.
|
|
256
|
+
*
|
|
257
|
+
* Two rules, both from R9.10. An install magus cannot remove is never
|
|
258
|
+
* offered as something to remove: a choice reading "Keep npm, remove others"
|
|
259
|
+
* used to include Claude Code's native copy in "others", find no uninstall
|
|
260
|
+
* command for it, `continue` past it and report done — leaving the conflict
|
|
261
|
+
* exactly as it was, with the row still showing "!". And whatever is left
|
|
262
|
+
* behind is named afterwards, rather than silently skipped.
|
|
263
|
+
*
|
|
264
|
+
* Which installs can be removed comes from `cliToolUninstall` itself
|
|
265
|
+
* (through `uninstallBlocker`), so the list of choices and the commands that
|
|
266
|
+
* run cannot disagree about a method.
|
|
267
|
+
*/
|
|
249
268
|
const handleResolveConflict = async () => {
|
|
250
269
|
const row = rows[cliToolsState.selectedIndex];
|
|
251
270
|
const install: InstallInfo | undefined = row?.install;
|
|
@@ -253,38 +272,81 @@ export function CliToolsScreen() {
|
|
|
253
272
|
|
|
254
273
|
const { tool } = row;
|
|
255
274
|
const allMethods = install.all;
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
275
|
+
const blockers = new Map(
|
|
276
|
+
allMethods.map((m) => [
|
|
277
|
+
m,
|
|
278
|
+
uninstallBlocker(tool, m, install.brewFormula),
|
|
279
|
+
]),
|
|
280
|
+
);
|
|
281
|
+
const removable = allMethods.filter((m) => blockers.get(m) === null);
|
|
282
|
+
const stuck = allMethods.filter((m) => blockers.get(m) !== null);
|
|
283
|
+
const stuckList = (methods: readonly InstallMethod[]): string =>
|
|
284
|
+
methods
|
|
285
|
+
.map((m) => `The ${m} install stays: ${blockers.get(m)}.`)
|
|
286
|
+
.join("\n");
|
|
287
|
+
|
|
288
|
+
// One choice per install that can be KEPT — but only where keeping it
|
|
289
|
+
// removes something. "Keep npm" beside an unremovable native copy would
|
|
290
|
+
// promise a removal magus cannot perform.
|
|
291
|
+
const options = allMethods
|
|
292
|
+
.map((keep) => ({ keep, remove: removable.filter((m) => m !== keep) }))
|
|
293
|
+
.filter(({ remove }) => remove.length > 0)
|
|
294
|
+
.map(({ keep, remove }) => ({
|
|
295
|
+
label: `Keep ${keep}${keep === install.primary ? " (active)" : ""}, remove ${remove.join(" + ")}`,
|
|
296
|
+
value: keep,
|
|
297
|
+
}));
|
|
298
|
+
|
|
299
|
+
if (options.length === 0) {
|
|
300
|
+
await modal.message(
|
|
301
|
+
`${tool.displayName} conflict`,
|
|
302
|
+
`Installed via ${allMethods.join(" + ")}, and magus can remove none of them.\n\n${stuckList(allMethods)}\n\nRemove one by hand, then press r.`,
|
|
303
|
+
"error",
|
|
304
|
+
);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
262
307
|
|
|
263
308
|
const keep = await modal.select(
|
|
264
309
|
`Resolve ${tool.displayName} conflict`,
|
|
265
|
-
|
|
310
|
+
stuck.length > 0
|
|
311
|
+
? `Installed via ${allMethods.join(" + ")}. Which to keep?\n\n${stuckList(stuck)}`
|
|
312
|
+
: `Installed via ${allMethods.join(" + ")}. Which to keep?`,
|
|
266
313
|
options,
|
|
267
314
|
);
|
|
268
315
|
if (keep === null) return;
|
|
269
316
|
|
|
270
|
-
const toRemove =
|
|
317
|
+
const toRemove = removable.filter((m) => m !== keep);
|
|
318
|
+
const leftBehind = stuck.filter((m) => m !== keep);
|
|
271
319
|
modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
|
|
272
320
|
|
|
273
321
|
const errors: string[] = [];
|
|
274
322
|
for (const method of toRemove) {
|
|
275
323
|
const cmd = cliToolUninstall(tool, method, install.brewFormula);
|
|
276
|
-
|
|
324
|
+
// Unreachable: `removable` was built from this same call. Reported
|
|
325
|
+
// rather than skipped, because a silent skip is the defect above.
|
|
326
|
+
if (!cmd) {
|
|
327
|
+
errors.push(`${method}: no uninstall command`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
277
330
|
const result = await runCommand(cmd);
|
|
278
331
|
if (!result.ok) errors.push(`${method}: ${formatCommand(cmd)}`);
|
|
279
332
|
}
|
|
280
333
|
|
|
281
334
|
modal.hideModal();
|
|
282
335
|
|
|
283
|
-
|
|
336
|
+
const report = [
|
|
337
|
+
errors.length > 0
|
|
338
|
+
? `These removals failed. Run them by hand:\n${errors.join("\n")}`
|
|
339
|
+
: "",
|
|
340
|
+
leftBehind.length > 0
|
|
341
|
+
? `${stuckList(leftBehind)}\nRemove it by hand if you want it gone.`
|
|
342
|
+
: "",
|
|
343
|
+
].filter((part) => part !== "");
|
|
344
|
+
|
|
345
|
+
if (report.length > 0) {
|
|
284
346
|
await modal.message(
|
|
285
|
-
"
|
|
286
|
-
|
|
287
|
-
"error",
|
|
347
|
+
errors.length > 0 ? "Partly removed" : "One install stays",
|
|
348
|
+
report.join("\n\n"),
|
|
349
|
+
errors.length > 0 ? "error" : "info",
|
|
288
350
|
);
|
|
289
351
|
}
|
|
290
352
|
|
|
@@ -332,24 +394,32 @@ export function CliToolsScreen() {
|
|
|
332
394
|
)}
|
|
333
395
|
{counts.unverified > 0 && (
|
|
334
396
|
<>
|
|
335
|
-
<span fg={theme.colors.muted}> │
|
|
397
|
+
<span fg={theme.colors.muted}> │ Not checked: </span>
|
|
336
398
|
<span fg={theme.colors.warning}>{counts.unverified}</span>
|
|
337
399
|
</>
|
|
338
400
|
)}
|
|
339
401
|
</text>
|
|
340
402
|
);
|
|
341
403
|
|
|
404
|
+
// The footer names what Enter does on THIS row, from the same function the
|
|
405
|
+
// detail pane and the handler read; while the row is checked it does nothing.
|
|
406
|
+
const enter = selectedRow ? cliToolEnterAction(selectedRow) : null;
|
|
407
|
+
|
|
342
408
|
return (
|
|
343
409
|
<ScreenLayout
|
|
344
410
|
title="magus CLI Tools"
|
|
345
411
|
currentScreen="cli-tools"
|
|
412
|
+
footer={
|
|
413
|
+
enter ? { labels: { install: enter.label } } : { hide: ["install"] }
|
|
414
|
+
}
|
|
346
415
|
statusLine={statusContent}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
416
|
+
notice={
|
|
417
|
+
probeError ? (
|
|
418
|
+
<text fg={theme.colors.danger}>
|
|
419
|
+
Could not check the tools: {probeError} — r to retry
|
|
420
|
+
</text>
|
|
421
|
+
) : undefined
|
|
422
|
+
}
|
|
353
423
|
listPanel={
|
|
354
424
|
<ScrollableList
|
|
355
425
|
items={rows}
|
|
@@ -359,7 +429,9 @@ export function CliToolsScreen() {
|
|
|
359
429
|
getKey={(row) => row.tool.name}
|
|
360
430
|
/>
|
|
361
431
|
}
|
|
362
|
-
detailPanel={
|
|
432
|
+
detailPanel={(detailWidth) =>
|
|
433
|
+
renderCliToolDetail(selectedRow, detailWidth)
|
|
434
|
+
}
|
|
363
435
|
/>
|
|
364
436
|
);
|
|
365
437
|
}
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
} from "../adapters/settingsAdapter.js";
|
|
12
12
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
13
13
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
14
|
-
import {
|
|
14
|
+
import { ErrorNotice } from "../components/primitives/index.js";
|
|
15
|
+
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
16
|
+
import { moveDirection } from "../keymap.js";
|
|
15
17
|
import {
|
|
16
18
|
renderSettingDetail,
|
|
17
19
|
renderSettingRow,
|
|
@@ -138,25 +140,20 @@ export function SettingsScreen() {
|
|
|
138
140
|
}
|
|
139
141
|
};
|
|
140
142
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
} else if (event.name === "p") {
|
|
156
|
-
handleScopeChange("project");
|
|
157
|
-
} else if (event.name === "enter") {
|
|
158
|
-
handleScopeChange("project");
|
|
159
|
-
}
|
|
143
|
+
useScreenKeys("settings", {
|
|
144
|
+
user: () => {
|
|
145
|
+
void handleScopeChange("user");
|
|
146
|
+
},
|
|
147
|
+
project: () => {
|
|
148
|
+
void handleScopeChange("project");
|
|
149
|
+
},
|
|
150
|
+
move: (key) => {
|
|
151
|
+
const index = settings.selectedIndex + moveDirection(key);
|
|
152
|
+
dispatch({
|
|
153
|
+
type: "SETTINGS_SELECT",
|
|
154
|
+
index: Math.max(0, Math.min(listItems.length - 1, index)),
|
|
155
|
+
});
|
|
156
|
+
},
|
|
160
157
|
});
|
|
161
158
|
|
|
162
159
|
const selectedItem = listItems[settings.selectedIndex];
|
|
@@ -165,9 +162,8 @@ export function SettingsScreen() {
|
|
|
165
162
|
if (settings.values.status === "loading") {
|
|
166
163
|
return <text fg={theme.colors.muted}>Loading settings...</text>;
|
|
167
164
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
165
|
+
// The error itself is in the list panel, where it replaces the rows.
|
|
166
|
+
if (settings.values.status === "error") return null;
|
|
171
167
|
return renderSettingDetail(selectedItem);
|
|
172
168
|
};
|
|
173
169
|
|
|
@@ -178,10 +174,16 @@ export function SettingsScreen() {
|
|
|
178
174
|
).length
|
|
179
175
|
: 0;
|
|
180
176
|
|
|
177
|
+
// A settings file that failed to read is not "0 configured" — that count
|
|
178
|
+
// would invite the user to configure, over a file that still holds it all.
|
|
181
179
|
const statusContent = (
|
|
182
180
|
<text fg={theme.colors.text}>
|
|
183
181
|
<span fg={theme.colors.muted}>Settings: </span>
|
|
184
|
-
|
|
182
|
+
{settings.values.status === "error" ? (
|
|
183
|
+
<span fg={theme.colors.danger}>unreadable</span>
|
|
184
|
+
) : (
|
|
185
|
+
<span fg={theme.colors.info}>{totalSet} configured</span>
|
|
186
|
+
)}
|
|
185
187
|
<span fg={theme.colors.muted}> │ u:user p:project</span>
|
|
186
188
|
</text>
|
|
187
189
|
);
|
|
@@ -191,26 +193,26 @@ export function SettingsScreen() {
|
|
|
191
193
|
title="magus Settings"
|
|
192
194
|
currentScreen="settings"
|
|
193
195
|
statusLine={statusContent}
|
|
194
|
-
footerHints={[
|
|
195
|
-
{ keys: ["↑", "↓"], label: "nav" },
|
|
196
|
-
{ keys: ["u"], label: "user scope" },
|
|
197
|
-
{ keys: ["p"], label: "project scope" },
|
|
198
|
-
{ keys: ["Enter"], label: "project" },
|
|
199
|
-
]}
|
|
200
196
|
listPanel={
|
|
201
|
-
settings.values.status
|
|
202
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
: "Error loading settings"}
|
|
206
|
-
</text>
|
|
207
|
-
) : (
|
|
208
|
-
<ScrollableList
|
|
209
|
-
items={listItems}
|
|
210
|
-
selectedIndex={settings.selectedIndex}
|
|
211
|
-
renderItem={renderSettingRow}
|
|
212
|
-
maxHeight={dimensions.listPanelHeight}
|
|
197
|
+
settings.values.status === "error" ? (
|
|
198
|
+
<ErrorNotice
|
|
199
|
+
title="Could not load settings."
|
|
200
|
+
detail={settings.values.error.message}
|
|
213
201
|
/>
|
|
202
|
+
) : settings.values.status !== "success" ? (
|
|
203
|
+
<text fg={theme.colors.muted}>Loading...</text>
|
|
204
|
+
) : (
|
|
205
|
+
// A function of the panel width: rows fit label and value to it.
|
|
206
|
+
(width: number) => (
|
|
207
|
+
<ScrollableList
|
|
208
|
+
items={listItems}
|
|
209
|
+
selectedIndex={settings.selectedIndex}
|
|
210
|
+
renderItem={(item, _index, isSelected) =>
|
|
211
|
+
renderSettingRow(item, isSelected, width)
|
|
212
|
+
}
|
|
213
|
+
maxHeight={dimensions.listPanelHeight}
|
|
214
|
+
/>
|
|
215
|
+
)
|
|
214
216
|
)
|
|
215
217
|
}
|
|
216
218
|
detailPanel={renderDetail()}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { appendFileSync } from "node:fs";
|
|
3
2
|
import { useRenderer } from "@opentui/react";
|
|
4
3
|
import type React from "react";
|
|
5
4
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
@@ -14,7 +13,8 @@ import {
|
|
|
14
13
|
} from "../../services/gitignore-service.js";
|
|
15
14
|
import type { ResolvedRule, Tier, Violation } from "../../types/gitignore.js";
|
|
16
15
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
17
|
-
import {
|
|
16
|
+
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
17
|
+
import { moveDirection } from "../keymap.js";
|
|
18
18
|
import {
|
|
19
19
|
type RuleRow,
|
|
20
20
|
renderRuleRow,
|
|
@@ -243,12 +243,29 @@ export function GitignoreScreen() {
|
|
|
243
243
|
await reload();
|
|
244
244
|
}, [cwd, modal, reload]);
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Open the project manifest in $EDITOR, creating it first if needed.
|
|
248
|
+
*
|
|
249
|
+
* Every await sits inside the try: this runs fire-and-forget from a key, and a
|
|
250
|
+
* rejection nobody catches ends the app. A manifest that cannot be read or
|
|
251
|
+
* created is said in a dialog on this screen instead.
|
|
252
|
+
*/
|
|
246
253
|
const handleEditManifest = useCallback(async () => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
cwd
|
|
250
|
-
|
|
251
|
-
|
|
254
|
+
let projectManifest: string;
|
|
255
|
+
try {
|
|
256
|
+
const current = data ?? (await loadGitignoreState(cwd));
|
|
257
|
+
projectManifest = await ensureProjectManifestForEdit(
|
|
258
|
+
cwd,
|
|
259
|
+
current.resolved,
|
|
260
|
+
);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
await modal.message(
|
|
263
|
+
"Cannot open the manifest",
|
|
264
|
+
err instanceof Error ? err.message : String(err),
|
|
265
|
+
"error",
|
|
266
|
+
);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
252
269
|
const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
|
|
253
270
|
renderer.suspend();
|
|
254
271
|
try {
|
|
@@ -262,48 +279,30 @@ export function GitignoreScreen() {
|
|
|
262
279
|
}
|
|
263
280
|
await reload();
|
|
264
281
|
renderer.requestRender();
|
|
265
|
-
}, [cwd, data, reload, renderer]);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
try {
|
|
270
|
-
appendFileSync(
|
|
271
|
-
"/tmp/magus-keys.log",
|
|
272
|
-
`${new Date().toISOString()} screen=gitignore input=${JSON.stringify(input)} name=${JSON.stringify(key.name)} shift=${key.shift} ctrl=${key.ctrl} meta=${key.meta}\n`,
|
|
273
|
-
);
|
|
274
|
-
} catch {
|
|
275
|
-
// best-effort logging — never break the UI
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
if (state.modal) return;
|
|
279
|
-
if (input === "j" || key.downArrow) {
|
|
280
|
-
setSelectedIdx((i) => Math.min(i + 1, Math.max(0, rows.length - 1)));
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
if (input === "k" || key.upArrow) {
|
|
284
|
-
setSelectedIdx((i) => Math.max(i - 1, 0));
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
// F = fix everything out of sync; f = fix just the selected row.
|
|
288
|
-
if (input === "F" || (input === "f" && key.shift)) {
|
|
289
|
-
void handleFixAll();
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
if (input === "f") {
|
|
282
|
+
}, [cwd, data, modal, reload, renderer]);
|
|
283
|
+
|
|
284
|
+
useScreenKeys("gitignore", {
|
|
285
|
+
fix: () => {
|
|
293
286
|
void handleFixOne();
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
if (input === "e") {
|
|
287
|
+
},
|
|
288
|
+
"fix-all": () => {
|
|
289
|
+
void handleFixAll();
|
|
290
|
+
},
|
|
291
|
+
edit: () => {
|
|
301
292
|
void handleEditManifest();
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
293
|
+
},
|
|
294
|
+
template: () => {
|
|
295
|
+
void handleApplyTemplate();
|
|
296
|
+
},
|
|
297
|
+
reload: () => {
|
|
305
298
|
void reload();
|
|
306
|
-
}
|
|
299
|
+
},
|
|
300
|
+
move: (key) => {
|
|
301
|
+
const delta = moveDirection(key);
|
|
302
|
+
setSelectedIdx((i) =>
|
|
303
|
+
Math.max(0, Math.min(Math.max(0, rows.length - 1), i + delta)),
|
|
304
|
+
);
|
|
305
|
+
},
|
|
307
306
|
});
|
|
308
307
|
|
|
309
308
|
// ── Rendering ─────────────────────────────────────────────────────────
|
|
@@ -368,14 +367,6 @@ export function GitignoreScreen() {
|
|
|
368
367
|
title="Managed Git State"
|
|
369
368
|
subtitle={subtitle}
|
|
370
369
|
currentScreen="gitignore"
|
|
371
|
-
footerHints={[
|
|
372
|
-
{ keys: ["↑", "↓"], label: "move" },
|
|
373
|
-
{ keys: ["f"], label: "fix selected" },
|
|
374
|
-
{ keys: ["F"], label: "fix all" },
|
|
375
|
-
{ keys: ["t"], label: "template" },
|
|
376
|
-
{ keys: ["e"], label: "edit" },
|
|
377
|
-
{ keys: ["r"], label: "reload" },
|
|
378
|
-
]}
|
|
379
370
|
listPanel={listPanel}
|
|
380
371
|
detailPanel={detailPanel}
|
|
381
372
|
/>
|
|
@@ -496,7 +487,11 @@ function renderDetail(
|
|
|
496
487
|
>
|
|
497
488
|
<text fg={theme.colors.text}>{v.path}</text>
|
|
498
489
|
<text fg={theme.colors.muted}>{describeKind(v.kind)}</text>
|
|
499
|
-
|
|
490
|
+
{/* A safe fix is a fact about the key; one that rewrites what git
|
|
491
|
+
tracks is destructive, and says so in `danger`. */}
|
|
492
|
+
<text
|
|
493
|
+
fg={v.severity === "safe" ? theme.colors.info : theme.colors.danger}
|
|
494
|
+
>
|
|
500
495
|
{actionHint(v.kind)}
|
|
501
496
|
</text>
|
|
502
497
|
</box>
|