magus-cli 7.3.0 → 7.4.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/scripts/build-binaries.ts +4 -2
- 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/c2-settings-parse.test.ts +350 -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__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- 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__/upgrade-spawn.test.ts +355 -0
- 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 +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +13 -2
- package/src/data/npm-package.ts +15 -0
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- 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 +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- 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/services/version-check.ts +20 -10
- 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 +99 -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 +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- 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 +98 -87
- 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 +123 -127
- 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,7 +2,8 @@ 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
|
|
|
@@ -19,11 +20,12 @@ import {
|
|
|
19
20
|
} from "../../services/tool-probe.js";
|
|
20
21
|
import type { Command } from "../../utils/command-utils.js";
|
|
21
22
|
import { formatCommand } from "../../utils/command-utils.js";
|
|
23
|
+
import { createRequestGate } from "../../utils/request-gate.js";
|
|
22
24
|
import {
|
|
23
25
|
type CliToolRow,
|
|
24
26
|
buildToolRow,
|
|
27
|
+
cliToolEnterAction,
|
|
25
28
|
countToolRows,
|
|
26
|
-
isInstalled,
|
|
27
29
|
} from "../adapters/cliToolsAdapter.js";
|
|
28
30
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
29
31
|
import {
|
|
@@ -43,8 +45,8 @@ const execFileAsync = promisify(execFile);
|
|
|
43
45
|
|
|
44
46
|
let cachedRows: CliToolRow[] | null = null;
|
|
45
47
|
let cacheInitialized = false;
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
+
/** Every fetch takes a token, so a refresh discards the answers of the one before. */
|
|
49
|
+
const fetchGate = createRequestGate();
|
|
48
50
|
|
|
49
51
|
function clearCliToolsCache(): void {
|
|
50
52
|
cachedRows = null;
|
|
@@ -90,6 +92,8 @@ export function CliToolsScreen() {
|
|
|
90
92
|
const [rows, setRows] = useState<CliToolRow[]>(
|
|
91
93
|
() => cachedRows || initialRows(),
|
|
92
94
|
);
|
|
95
|
+
/** Why the last check could not run, shown under the header; null when it ran. */
|
|
96
|
+
const [probeError, setProbeError] = useState<string | null>(null);
|
|
93
97
|
|
|
94
98
|
const rowsRef = useRef(rows);
|
|
95
99
|
rowsRef.current = rows;
|
|
@@ -109,49 +113,59 @@ export function CliToolsScreen() {
|
|
|
109
113
|
* Detection first — it decides which installer each tool is probed through
|
|
110
114
|
* (§4.5 of the design: the closure's spec, else the detected installer, else
|
|
111
115
|
* the catalogue) — then one shared probe, rows filling in as tools settle.
|
|
116
|
+
*
|
|
117
|
+
* Never rejects. It runs fire-and-forget on mount and on `r`, and a rejection
|
|
118
|
+
* nobody catches ends the app; a failed probe is said under the header
|
|
119
|
+
* instead, and `r` tries again.
|
|
112
120
|
*/
|
|
113
121
|
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
|
-
|
|
122
|
+
const token = fetchGate.begin();
|
|
123
|
+
setProbeError(null);
|
|
124
|
+
try {
|
|
125
|
+
const projectPath = state.projectPath || process.cwd();
|
|
126
|
+
const [active, installs] = await Promise.all([
|
|
127
|
+
// Full resolution, not `resolveBins: false`: a catalogue tool may be
|
|
128
|
+
// declared by a PLUGIN's requires.bin, and parity needs the spec the
|
|
129
|
+
// CLI plans with. `resolveBins: false` would still keep the profile's
|
|
130
|
+
// own cliTools, but lose every plugin-declared bin and its pin.
|
|
131
|
+
loadActiveClosure(projectPath),
|
|
132
|
+
mapLimited(cliTools, PROBE_CONCURRENCY, (tool) =>
|
|
133
|
+
detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
|
|
134
|
+
),
|
|
135
|
+
]);
|
|
136
|
+
if (!fetchGate.isCurrent(token)) return;
|
|
137
|
+
|
|
138
|
+
const closureBins = active?.closure.bins ?? null;
|
|
139
|
+
const planned = cliTools.map((tool, i) => ({
|
|
140
|
+
tool,
|
|
141
|
+
install: installs[i],
|
|
142
|
+
spec: specForCatalogTool(tool, closureBins, installs[i]),
|
|
143
|
+
}));
|
|
144
|
+
setAndCache(() =>
|
|
145
|
+
planned.map((p) => buildToolRow(p.tool, p.spec, undefined, p.install)),
|
|
146
|
+
);
|
|
137
147
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
await probeTools(
|
|
149
|
+
planned.map((p) => p.spec),
|
|
150
|
+
REAL_TOOL_PROBE_IO,
|
|
151
|
+
{
|
|
152
|
+
onResult: (facts, { index }) => {
|
|
153
|
+
if (!fetchGate.isCurrent(token)) return;
|
|
154
|
+
const p = planned[index];
|
|
155
|
+
if (!p) return;
|
|
156
|
+
const row = buildToolRow(p.tool, p.spec, facts, p.install);
|
|
157
|
+
setAndCache((prev) => {
|
|
158
|
+
const next = [...prev];
|
|
159
|
+
next[index] = row;
|
|
160
|
+
return next;
|
|
161
|
+
});
|
|
162
|
+
},
|
|
152
163
|
},
|
|
153
|
-
|
|
154
|
-
)
|
|
164
|
+
);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (!fetchGate.isCurrent(token)) return;
|
|
167
|
+
setProbeError(error instanceof Error ? error.message : String(error));
|
|
168
|
+
}
|
|
155
169
|
}, [setAndCache, state.projectPath]);
|
|
156
170
|
|
|
157
171
|
useEffect(() => {
|
|
@@ -162,27 +176,24 @@ export function CliToolsScreen() {
|
|
|
162
176
|
}
|
|
163
177
|
}, [fetchVersionInfo, rows]);
|
|
164
178
|
|
|
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
|
-
}
|
|
179
|
+
useScreenKeys("cli-tools", {
|
|
180
|
+
install: () => {
|
|
181
|
+
void handleInstall();
|
|
182
|
+
},
|
|
183
|
+
"update-all": () => {
|
|
184
|
+
void handleUpdateAll();
|
|
185
|
+
},
|
|
186
|
+
conflict: () => {
|
|
187
|
+
void handleResolveConflict();
|
|
188
|
+
},
|
|
189
|
+
refresh: () => handleRefresh(),
|
|
190
|
+
move: (key) => {
|
|
191
|
+
const index = cliToolsState.selectedIndex + moveDirection(key);
|
|
192
|
+
dispatch({
|
|
193
|
+
type: "CLITOOLS_SELECT",
|
|
194
|
+
index: Math.max(0, Math.min(rows.length - 1, index)),
|
|
195
|
+
});
|
|
196
|
+
},
|
|
186
197
|
});
|
|
187
198
|
|
|
188
199
|
const handleRefresh = () => {
|
|
@@ -215,21 +226,11 @@ export function CliToolsScreen() {
|
|
|
215
226
|
|
|
216
227
|
const handleInstall = async () => {
|
|
217
228
|
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;
|
|
229
|
+
const enter = row ? cliToolEnterAction(row) : null;
|
|
230
|
+
if (!row || !enter) return;
|
|
231
|
+
|
|
232
|
+
const { tool } = row;
|
|
233
|
+
const { command, progress: action } = enter;
|
|
233
234
|
|
|
234
235
|
modal.loading(`${action} ${tool.displayName}...`);
|
|
235
236
|
const result = await runCommand(command);
|
|
@@ -332,24 +333,32 @@ export function CliToolsScreen() {
|
|
|
332
333
|
)}
|
|
333
334
|
{counts.unverified > 0 && (
|
|
334
335
|
<>
|
|
335
|
-
<span fg={theme.colors.muted}> │
|
|
336
|
+
<span fg={theme.colors.muted}> │ Not checked: </span>
|
|
336
337
|
<span fg={theme.colors.warning}>{counts.unverified}</span>
|
|
337
338
|
</>
|
|
338
339
|
)}
|
|
339
340
|
</text>
|
|
340
341
|
);
|
|
341
342
|
|
|
343
|
+
// The footer names what Enter does on THIS row, from the same function the
|
|
344
|
+
// detail pane and the handler read; while the row is checked it does nothing.
|
|
345
|
+
const enter = selectedRow ? cliToolEnterAction(selectedRow) : null;
|
|
346
|
+
|
|
342
347
|
return (
|
|
343
348
|
<ScreenLayout
|
|
344
349
|
title="magus CLI Tools"
|
|
345
350
|
currentScreen="cli-tools"
|
|
351
|
+
footer={
|
|
352
|
+
enter ? { labels: { install: enter.label } } : { hide: ["install"] }
|
|
353
|
+
}
|
|
346
354
|
statusLine={statusContent}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
355
|
+
notice={
|
|
356
|
+
probeError ? (
|
|
357
|
+
<text fg={theme.colors.danger}>
|
|
358
|
+
Could not check the tools: {probeError} — r to retry
|
|
359
|
+
</text>
|
|
360
|
+
) : undefined
|
|
361
|
+
}
|
|
353
362
|
listPanel={
|
|
354
363
|
<ScrollableList
|
|
355
364
|
items={rows}
|
|
@@ -359,7 +368,9 @@ export function CliToolsScreen() {
|
|
|
359
368
|
getKey={(row) => row.tool.name}
|
|
360
369
|
/>
|
|
361
370
|
}
|
|
362
|
-
detailPanel={
|
|
371
|
+
detailPanel={(detailWidth) =>
|
|
372
|
+
renderCliToolDetail(selectedRow, detailWidth)
|
|
373
|
+
}
|
|
363
374
|
/>
|
|
364
375
|
);
|
|
365
376
|
}
|
|
@@ -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>
|