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
package/src/tui.tsx
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { RGBA, type ThemeMode, createCliRenderer } from "@opentui/core";
|
|
11
11
|
import { createRoot } from "@opentui/react";
|
|
12
12
|
import { App } from "./ui/App.js";
|
|
13
|
+
import { installShutdown } from "./ui/runtime/shutdown.js";
|
|
13
14
|
import { setThemeMode } from "./ui/theme-mode.js";
|
|
14
15
|
import { type ThemeEnv, resolveTheme } from "./ui/theme-resolve.js";
|
|
15
16
|
|
|
@@ -57,6 +58,49 @@ export async function startTui({
|
|
|
57
58
|
backgroundColor: RGBA.defaultBackground(),
|
|
58
59
|
useMouse: true,
|
|
59
60
|
enableMouseMovement: false,
|
|
61
|
+
// BOTH, so the renderer registers no signal handler and never quits on
|
|
62
|
+
// Ctrl+C by itself: `installShutdown` below is the ONE teardown owner.
|
|
63
|
+
// Left at their defaults the renderer is a second owner — it destroys
|
|
64
|
+
// itself first on a signal and the React tree is torn down after the
|
|
65
|
+
// terminal was already restored (measured; see ui/runtime/shutdown.ts).
|
|
66
|
+
exitSignals: [],
|
|
67
|
+
exitOnCtrlC: false,
|
|
68
|
+
// OFF, so a click never gives a renderable keyboard focus. With it on, one
|
|
69
|
+
// click in a scrollbox made that box a second key receiver: OpenTUI hands
|
|
70
|
+
// every key to the focused element as well as to the KeyRouter, so arrows
|
|
71
|
+
// and PgUp/PgDn acted twice (measured on the test renderer: scrollTop 0 → 2
|
|
72
|
+
// after a click, 0 → 0 with this off). The router is the one key owner;
|
|
73
|
+
// InputModal focuses its own <input>, which is the only focus magus needs.
|
|
74
|
+
autoFocus: false,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Wired immediately, before anything that can wait: theme resolution below
|
|
78
|
+
// can probe the terminal for 250ms, and a signal or a throw landing in that
|
|
79
|
+
// window must restore the terminal too.
|
|
80
|
+
//
|
|
81
|
+
// SIGINT/SIGTERM/SIGHUP/… restore the terminal and exit 128 + signum; an
|
|
82
|
+
// uncaught exception or unhandled rejection restores it, prints the error,
|
|
83
|
+
// and exits 1. `shutdown(0)` is the clean quit.
|
|
84
|
+
const root = createRoot(renderer);
|
|
85
|
+
const shutdown = installShutdown(renderer, root);
|
|
86
|
+
const quit = (): void => {
|
|
87
|
+
void shutdown(0);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Ctrl+C is a KEYPRESS here, not a signal: raw mode clears ISIG, so the tty
|
|
91
|
+
// delivers 0x03 and SIGINT is never raised. Without this listener
|
|
92
|
+
// (`exitOnCtrlC: false` above) Ctrl+C would do nothing at all.
|
|
93
|
+
//
|
|
94
|
+
// It is registered on the renderer before React mounts, so it runs ahead of
|
|
95
|
+
// every `useKeyboard` handler — and stopping propagation means no screen or
|
|
96
|
+
// modal ever sees the key. That is what makes Ctrl+C quit from under any
|
|
97
|
+
// modal, including the undismissable profile gate, and from a screen whose
|
|
98
|
+
// render crashed.
|
|
99
|
+
renderer.keyInput.on("keypress", (key) => {
|
|
100
|
+
if (key.ctrl && key.name === "c") {
|
|
101
|
+
key.stopPropagation();
|
|
102
|
+
quit();
|
|
103
|
+
}
|
|
60
104
|
});
|
|
61
105
|
|
|
62
106
|
// Mouse selection → system clipboard, on release. OSC 52 survives SSH;
|
|
@@ -86,27 +130,10 @@ export async function startTui({
|
|
|
86
130
|
});
|
|
87
131
|
setThemeMode(mode);
|
|
88
132
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// Cleanup function to restore terminal
|
|
92
|
-
const cleanup = () => {
|
|
93
|
-
root.unmount();
|
|
94
|
-
renderer.destroy(); // CRITICAL: Never use process.exit() directly
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
// Handle cleanup on exit signals
|
|
98
|
-
const handleExit = () => {
|
|
99
|
-
cleanup();
|
|
100
|
-
// Exit after cleanup completes
|
|
101
|
-
process.exit(0);
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
process.on("SIGINT", handleExit);
|
|
105
|
-
process.on("SIGTERM", handleExit);
|
|
106
|
-
|
|
107
|
-
// Render the OpenTUI app with exit handler
|
|
108
|
-
root.render(<App onExit={handleExit} />);
|
|
133
|
+
// `q`, Escape on the home tab and the profile gate's quit all end here.
|
|
134
|
+
root.render(<App onExit={quit} />);
|
|
109
135
|
|
|
110
|
-
// Wait indefinitely
|
|
136
|
+
// Wait indefinitely: every exit goes through `shutdown`, which ends the
|
|
137
|
+
// process itself.
|
|
111
138
|
await new Promise(() => {});
|
|
112
139
|
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import { useRenderer } from "@opentui/react";
|
|
3
1
|
import React, { useEffect, useState } from "react";
|
|
4
2
|
import { activePluginPins } from "../services/active-closure.js";
|
|
5
3
|
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
@@ -26,15 +24,18 @@ import {
|
|
|
26
24
|
checkForUpdates,
|
|
27
25
|
getCurrentVersion,
|
|
28
26
|
} from "../services/version-check.js";
|
|
27
|
+
import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
|
|
28
|
+
import { TABS } from "./components/TabBar.js";
|
|
29
29
|
import { ProgressBar } from "./components/layout/ProgressBar.js";
|
|
30
30
|
import { ModalContainer } from "./components/modals/index.js";
|
|
31
31
|
import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
|
|
32
|
-
import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
|
|
33
32
|
import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
|
|
34
33
|
import {
|
|
35
34
|
staleLoadRepairSummary,
|
|
36
35
|
useStaleLoadModal,
|
|
37
36
|
} from "./hooks/useStaleLoadModal.js";
|
|
37
|
+
import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
|
|
38
|
+
import { type GlobalHandlers, runGlobalBindings } from "./keymap.js";
|
|
38
39
|
import {
|
|
39
40
|
AliasScreen,
|
|
40
41
|
CliToolsScreen,
|
|
@@ -65,13 +66,22 @@ export const VERSION = getCurrentVersion();
|
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
68
|
* Router Component
|
|
68
|
-
* Renders the current screen based on route
|
|
69
|
+
* Renders the current screen based on route, inside a boundary that confines a
|
|
70
|
+
* render error to that screen — see ScreenErrorBoundary.
|
|
69
71
|
*/
|
|
70
72
|
function Router() {
|
|
71
73
|
const { state } = useApp();
|
|
72
|
-
const {
|
|
74
|
+
const { screen } = state.currentRoute;
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
return (
|
|
77
|
+
<ScreenErrorBoundary screen={screen}>
|
|
78
|
+
<RouteScreen screen={screen} />
|
|
79
|
+
</ScreenErrorBoundary>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function RouteScreen({ screen }: { screen: Screen }) {
|
|
84
|
+
switch (screen) {
|
|
75
85
|
case "plugins":
|
|
76
86
|
return <PluginsScreen />;
|
|
77
87
|
case "mcp":
|
|
@@ -100,181 +110,47 @@ function Router() {
|
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
113
|
+
* GlobalKeys Component
|
|
114
|
+
* The GLOBAL key layer — the lowest one: tab digits, Tab / Shift+Tab, `q`,
|
|
115
|
+
* `?` and Esc, from `GLOBAL_BINDINGS` in ui/keymap.ts. It only ever sees a key
|
|
116
|
+
* that the modal, a focused search box, the screen and the panel scroll all
|
|
117
|
+
* declined, so a digit typed into a search box never switches tab and an Esc
|
|
118
|
+
* that cleared a filter never also quits.
|
|
119
|
+
*
|
|
120
|
+
* Ctrl+C is NOT here. It is owned by the bootstrap (src/tui.tsx), which
|
|
121
|
+
* catches it on the renderer before any React handler, stops its propagation
|
|
122
|
+
* and runs the one shutdown routine — so it quits from any screen, from under
|
|
123
|
+
* any modal (the profile gate cannot be dismissed), and even after a render
|
|
124
|
+
* error has taken the React tree down.
|
|
106
125
|
*/
|
|
107
|
-
function
|
|
108
|
-
onDebugToggle,
|
|
109
|
-
onExit,
|
|
110
|
-
}: { onDebugToggle: () => void; onExit: () => void }): null {
|
|
126
|
+
function GlobalKeys({ onExit }: { onExit: () => void }): null {
|
|
111
127
|
const { state } = useApp();
|
|
112
128
|
const { navigateToScreen } = useNavigation();
|
|
113
129
|
const modal = useModal();
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
"/tmp/magus-debug.json",
|
|
139
|
-
JSON.stringify(debugInfo, null, 2),
|
|
140
|
-
);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Ctrl+C quits from ANYWHERE, including from under an open modal, and is
|
|
145
|
-
// checked before the guard below rather than beside `q` further down.
|
|
146
|
-
//
|
|
147
|
-
// Every modal used to be dismissable, so Escape-then-Ctrl+C always worked
|
|
148
|
-
// and this ordering never mattered. The profile gate cannot be dismissed —
|
|
149
|
-
// by design, since there is nothing to carry on to — which left Ctrl+C
|
|
150
|
-
// swallowed by the guard and no interrupt at all. A terminal app that
|
|
151
|
-
// ignores Ctrl+C is indistinguishable from one that has hung.
|
|
152
|
-
//
|
|
153
|
-
// `q` deliberately does NOT get the same treatment: a modal may want it
|
|
154
|
-
// (the select list's own cancel), and it is a printable character an input
|
|
155
|
-
// modal has to receive.
|
|
156
|
-
if (key.ctrl && input === "c") {
|
|
157
|
-
onExit();
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Don't handle keys when modal is open or searching
|
|
162
|
-
if (state.modal || state.isSearching) return;
|
|
163
|
-
|
|
164
|
-
// Global navigation shortcuts (1-9, then 0) - include mcp-registry as it's
|
|
165
|
-
// a sub-screen of mcp
|
|
166
|
-
const isTopLevel = [
|
|
167
|
-
"plugins",
|
|
168
|
-
"mcp",
|
|
169
|
-
"mcp-registry",
|
|
170
|
-
"settings",
|
|
171
|
-
"cli-tools",
|
|
172
|
-
"profiles",
|
|
173
|
-
"skills",
|
|
174
|
-
"styles",
|
|
175
|
-
"gitignore",
|
|
176
|
-
"alias",
|
|
177
|
-
"models",
|
|
178
|
-
].includes(state.currentRoute.screen);
|
|
179
|
-
|
|
180
|
-
if (isTopLevel) {
|
|
181
|
-
if (input === "1") navigateToScreen("plugins");
|
|
182
|
-
else if (input === "2") navigateToScreen("skills");
|
|
183
|
-
else if (input === "3") navigateToScreen("mcp");
|
|
184
|
-
else if (input === "4") navigateToScreen("settings");
|
|
185
|
-
else if (input === "5") navigateToScreen("profiles");
|
|
186
|
-
else if (input === "6") navigateToScreen("cli-tools");
|
|
187
|
-
else if (input === "7") navigateToScreen("gitignore");
|
|
188
|
-
else if (input === "8") navigateToScreen("alias");
|
|
189
|
-
else if (input === "9") navigateToScreen("styles");
|
|
190
|
-
else if (input === "0") navigateToScreen("models");
|
|
191
|
-
|
|
192
|
-
// Tab navigation cycling
|
|
193
|
-
if (key.tab) {
|
|
194
|
-
const screens: Screen[] = [
|
|
195
|
-
"plugins",
|
|
196
|
-
"skills",
|
|
197
|
-
"mcp",
|
|
198
|
-
"settings",
|
|
199
|
-
"profiles",
|
|
200
|
-
"cli-tools",
|
|
201
|
-
"gitignore",
|
|
202
|
-
"alias",
|
|
203
|
-
"styles",
|
|
204
|
-
"models",
|
|
205
|
-
];
|
|
206
|
-
const currentIndex = screens.indexOf(
|
|
207
|
-
state.currentRoute.screen as Screen,
|
|
208
|
-
);
|
|
209
|
-
if (currentIndex !== -1) {
|
|
210
|
-
const nextIndex = key.shift
|
|
211
|
-
? (currentIndex - 1 + screens.length) % screens.length
|
|
212
|
-
: (currentIndex + 1) % screens.length;
|
|
213
|
-
navigateToScreen(screens[nextIndex]);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// q or Ctrl+C quits from anywhere — no need to bounce to home first.
|
|
219
|
-
if (input === "q" || (key.ctrl && input === "c")) {
|
|
220
|
-
onExit();
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Escape goes back: registry → mcp, any other tab → plugins home,
|
|
225
|
-
// already-home → exit. But when a screen has a focused sub-view open
|
|
226
|
-
// (e.g. the Alias flag-detail editor), that sub-view owns Escape and
|
|
227
|
-
// backs out to itself — don't let the global handler jump to home.
|
|
228
|
-
// Scoped to the Escape branch ONLY so q/Ctrl+C still quit from anywhere.
|
|
229
|
-
if (key.escape) {
|
|
230
|
-
if (state.detailActive) return;
|
|
231
|
-
if (state.currentRoute.screen === "plugins") {
|
|
232
|
-
onExit();
|
|
233
|
-
} else if (state.currentRoute.screen === "mcp-registry") {
|
|
234
|
-
navigateToScreen("mcp");
|
|
235
|
-
} else {
|
|
236
|
-
navigateToScreen("plugins");
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ? for help
|
|
241
|
-
if (input === "?") {
|
|
242
|
-
modal.message(
|
|
243
|
-
"magus Help",
|
|
244
|
-
`Navigation
|
|
245
|
-
↑/↓ or j/k Move selection
|
|
246
|
-
Enter Select / Toggle
|
|
247
|
-
Escape Back (to home)
|
|
248
|
-
q / Ctrl+C Quit (from anywhere)
|
|
249
|
-
? This help
|
|
250
|
-
|
|
251
|
-
Quick Navigation
|
|
252
|
-
1 Plugins 4 Settings 7 Git State 0 Models
|
|
253
|
-
2 Skills 5 Profiles 8 Alias
|
|
254
|
-
3 MCP Servers 6 CLI Tools 9 Styles
|
|
255
|
-
|
|
256
|
-
Plugin Actions
|
|
257
|
-
u Update d Uninstall
|
|
258
|
-
a Update All r Refresh
|
|
259
|
-
|
|
260
|
-
MCP Servers
|
|
261
|
-
/ Search local + remote
|
|
262
|
-
r Browse MCP registry
|
|
263
|
-
|
|
264
|
-
Styles
|
|
265
|
-
Space Tick / untick a style
|
|
266
|
-
a Apply the selection to this project
|
|
267
|
-
x Reset the selection to what is live
|
|
268
|
-
c Clear the active output style
|
|
269
|
-
|
|
270
|
-
Models
|
|
271
|
-
a / Enter Route this project through the selected preset
|
|
272
|
-
c Turn model tiers off
|
|
273
|
-
r Re-read the config`,
|
|
274
|
-
"info",
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
});
|
|
130
|
+
const screen = state.currentRoute.screen;
|
|
131
|
+
|
|
132
|
+
const handlers: GlobalHandlers = {
|
|
133
|
+
help: () => modal.help(screen),
|
|
134
|
+
quit: () => onExit(),
|
|
135
|
+
"cycle-tab": (key) => {
|
|
136
|
+
// The registry is a sub-screen of MCP, so it cycles from MCP's place.
|
|
137
|
+
const from = screen === "mcp-registry" ? "mcp" : screen;
|
|
138
|
+
const index = TABS.findIndex((tab) => tab.screen === from);
|
|
139
|
+
if (index === -1) return;
|
|
140
|
+
const step = key.shift ? TABS.length - 1 : 1;
|
|
141
|
+
const next = TABS[(index + step) % TABS.length];
|
|
142
|
+
if (next) navigateToScreen(next.screen);
|
|
143
|
+
},
|
|
144
|
+
// Back: registry → MCP, any other tab → Plugins, Plugins → quit.
|
|
145
|
+
back: () => {
|
|
146
|
+
if (screen === "plugins") onExit();
|
|
147
|
+
else if (screen === "mcp-registry") navigateToScreen("mcp");
|
|
148
|
+
else navigateToScreen("plugins");
|
|
149
|
+
},
|
|
150
|
+
tab: (target) => navigateToScreen(target),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
useKeyLayer("global", true, (key) => runGlobalBindings(handlers, key));
|
|
278
154
|
|
|
279
155
|
return null;
|
|
280
156
|
}
|
|
@@ -287,7 +163,7 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
|
|
|
287
163
|
if (!result.updateAvailable) return null;
|
|
288
164
|
|
|
289
165
|
return (
|
|
290
|
-
<box paddingLeft={1} paddingRight={1}>
|
|
166
|
+
<box flexDirection="row" paddingLeft={1} paddingRight={1}>
|
|
291
167
|
<text bg={theme.colors.warning} fg={theme.hints.fg}>
|
|
292
168
|
<strong> UPDATE </strong>
|
|
293
169
|
</text>
|
|
@@ -326,16 +202,12 @@ function ProgressIndicator({
|
|
|
326
202
|
* Main app layout with all UI elements
|
|
327
203
|
*/
|
|
328
204
|
interface AppContentInnerProps {
|
|
329
|
-
showDebug: boolean;
|
|
330
|
-
onDebugToggle: () => void;
|
|
331
205
|
updateInfo: VersionCheckResult | null;
|
|
332
206
|
onExit: () => void;
|
|
333
207
|
recoveryReport: string | null;
|
|
334
208
|
}
|
|
335
209
|
|
|
336
210
|
function AppContentInner({
|
|
337
|
-
showDebug,
|
|
338
|
-
onDebugToggle,
|
|
339
211
|
updateInfo,
|
|
340
212
|
onExit,
|
|
341
213
|
recoveryReport,
|
|
@@ -352,15 +224,6 @@ function AppContentInner({
|
|
|
352
224
|
<text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
|
|
353
225
|
</box>
|
|
354
226
|
)}
|
|
355
|
-
{showDebug && (
|
|
356
|
-
<box paddingLeft={1} paddingRight={1}>
|
|
357
|
-
<text fg={theme.colors.muted}>
|
|
358
|
-
DEBUG: {dimensions.terminalWidth}x{dimensions.terminalHeight} |
|
|
359
|
-
content={dimensions.contentHeight} | screen=
|
|
360
|
-
{state.currentRoute.screen}
|
|
361
|
-
</text>
|
|
362
|
-
</box>
|
|
363
|
-
)}
|
|
364
227
|
{progress && <ProgressIndicator {...progress} />}
|
|
365
228
|
<box
|
|
366
229
|
flexDirection="column"
|
|
@@ -370,7 +233,7 @@ function AppContentInner({
|
|
|
370
233
|
>
|
|
371
234
|
<Router />
|
|
372
235
|
</box>
|
|
373
|
-
<
|
|
236
|
+
<GlobalKeys onExit={onExit} />
|
|
374
237
|
<ModalContainer />
|
|
375
238
|
</box>
|
|
376
239
|
);
|
|
@@ -387,7 +250,6 @@ interface AppContentProps {
|
|
|
387
250
|
function AppContent({ onExit }: AppContentProps) {
|
|
388
251
|
const { state, dispatch } = useApp();
|
|
389
252
|
const { progress } = state;
|
|
390
|
-
const [showDebug, setShowDebug] = useState(false);
|
|
391
253
|
const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
|
|
392
254
|
const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
|
|
393
255
|
const [gitignoreData, setGitignoreData] = useState<{
|
|
@@ -623,13 +485,10 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
623
485
|
return (
|
|
624
486
|
<DimensionsProvider
|
|
625
487
|
showProgress={!!progress}
|
|
626
|
-
showDebug={showDebug}
|
|
627
488
|
showUpdateBanner={!!updateInfo?.updateAvailable}
|
|
628
489
|
transientBannerCount={transientBannerCount}
|
|
629
490
|
>
|
|
630
491
|
<AppContentInner
|
|
631
|
-
showDebug={showDebug}
|
|
632
|
-
onDebugToggle={() => setShowDebug((s) => !s)}
|
|
633
492
|
updateInfo={updateInfo}
|
|
634
493
|
onExit={onExit}
|
|
635
494
|
recoveryReport={recoveryReport}
|
|
@@ -650,7 +509,9 @@ interface AppProps {
|
|
|
650
509
|
export function App({ onExit }: AppProps) {
|
|
651
510
|
return (
|
|
652
511
|
<AppProvider>
|
|
653
|
-
<
|
|
512
|
+
<KeyRouter>
|
|
513
|
+
<AppContent onExit={onExit} />
|
|
514
|
+
</KeyRouter>
|
|
654
515
|
</AppProvider>
|
|
655
516
|
);
|
|
656
517
|
}
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* `classifyTool(spec, facts)`, and for a tool the active profile declares the
|
|
9
9
|
* spec IS the profile's (`specForCatalogTool`), so the two cannot differ.
|
|
10
10
|
*
|
|
11
|
-
* The same goes for the COMMANDS:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* The same goes for the COMMANDS: the probe's own `upgradeCommand` when it
|
|
12
|
+
* built one (an npm prefix, Claude Code's updater) — the very argv `magus
|
|
13
|
+
* update` carries on its plan item — and otherwise `binCommandForState`, the
|
|
14
|
+
* rule the apply path uses, so the row's target and the row's key can never
|
|
15
|
+
* aim at different versions.
|
|
14
16
|
*
|
|
15
17
|
* Pure: no IO, no React. The screen gathers facts; this only assembles.
|
|
16
18
|
*/
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
import {
|
|
24
26
|
type InstallInfo,
|
|
25
27
|
type ToolFacts,
|
|
28
|
+
type ToolReason,
|
|
26
29
|
type ToolStatus,
|
|
27
30
|
classifyTool,
|
|
28
31
|
} from "../../services/tool-probe.js";
|
|
@@ -80,7 +83,13 @@ export function buildToolRow(
|
|
|
80
83
|
const base = { tool, spec, installCommand, ...(install ? { install } : {}) };
|
|
81
84
|
if (!facts) return { ...base, checking: true, status: null };
|
|
82
85
|
const status = classifyTool(spec, facts);
|
|
83
|
-
const updateCommand = updateCommandFor(
|
|
86
|
+
const updateCommand = updateCommandFor(
|
|
87
|
+
tool,
|
|
88
|
+
declared,
|
|
89
|
+
status,
|
|
90
|
+
facts,
|
|
91
|
+
install,
|
|
92
|
+
);
|
|
84
93
|
return {
|
|
85
94
|
...base,
|
|
86
95
|
checking: false,
|
|
@@ -92,11 +101,16 @@ export function buildToolRow(
|
|
|
92
101
|
/**
|
|
93
102
|
* The command the row's key runs, from the same rule as the row's state.
|
|
94
103
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
104
|
+
* First the probe's own `upgradeCommand`: set when the file PATH runs belongs
|
|
105
|
+
* to an owner the spec's installer would miss — another npm prefix, Claude
|
|
106
|
+
* Code's native installer — and already aimed at the pin when there is one.
|
|
107
|
+
* `magus update` runs the same value, so Enter and the CLI cannot differ.
|
|
108
|
+
*
|
|
109
|
+
* Otherwise a declared tool goes through `binCommandForState` — the CLI apply
|
|
110
|
+
* path's own builder over the PROFILE's requirement — so a row whose target is
|
|
111
|
+
* the pin installs the pin. Building `@latest` here (what this used to do,
|
|
112
|
+
* always) let the TUI overshoot a pin the CLI hits exactly, and the tool then
|
|
113
|
+
* classified `unknown / ahead-of-pin` for good: magus never downgrades.
|
|
100
114
|
*
|
|
101
115
|
* An undeclared catalogue tool keeps the screen's own rule, "follow how it IS
|
|
102
116
|
* installed": there is no pin to honour, and `pnpm`/`yarn` have no
|
|
@@ -106,9 +120,11 @@ function updateCommandFor(
|
|
|
106
120
|
tool: CliTool,
|
|
107
121
|
declared: ResolvedBin | null,
|
|
108
122
|
status: ToolStatus,
|
|
123
|
+
facts: ToolFacts,
|
|
109
124
|
install: InstallInfo | undefined,
|
|
110
125
|
): Command | undefined {
|
|
111
126
|
if (!isInstalled(status)) return undefined;
|
|
127
|
+
if (facts.upgradeCommand) return facts.upgradeCommand;
|
|
112
128
|
if (declared) {
|
|
113
129
|
return binCommandForState("upgrade", status.target, declared) ?? undefined;
|
|
114
130
|
}
|
|
@@ -126,6 +142,78 @@ export function displayVersion(v: string): string {
|
|
|
126
142
|
return `v${v.replace(/^v/i, "")}`;
|
|
127
143
|
}
|
|
128
144
|
|
|
145
|
+
/** What Enter does on one row. */
|
|
146
|
+
export interface CliToolEnterAction {
|
|
147
|
+
kind: "install" | "update" | "update-anyway" | "reinstall";
|
|
148
|
+
/** The footer hint's label, e.g. "update". */
|
|
149
|
+
label: string;
|
|
150
|
+
/** The detail pane's words beside the Enter chip, e.g. "Update to v0.95.0". */
|
|
151
|
+
detail: string;
|
|
152
|
+
/** The progress word while it runs, e.g. "Updating". */
|
|
153
|
+
progress: string;
|
|
154
|
+
command: Command;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What Enter does on `row` — null while the row is still being checked, when
|
|
159
|
+
* Enter does nothing.
|
|
160
|
+
*
|
|
161
|
+
* The footer hint, the detail pane and the handler each used to decide this
|
|
162
|
+
* on their own, and the footer said "install" on every row while the detail
|
|
163
|
+
* pane beside it said "Update to v0.95.0". All three read this now.
|
|
164
|
+
*
|
|
165
|
+
* An `unknown` row still offers the detected method's command: an explicit
|
|
166
|
+
* user action, not a plan, and the detail pane shows why beside it.
|
|
167
|
+
*/
|
|
168
|
+
export function cliToolEnterAction(row: CliToolRow): CliToolEnterAction | null {
|
|
169
|
+
const { status, updateCommand, installCommand } = row;
|
|
170
|
+
if (row.checking || !status) return null;
|
|
171
|
+
// Installed NEWER than its pin: every command there is wrong. The pin's own
|
|
172
|
+
// command downgrades — for an npm prefix or Claude Code's native installer the
|
|
173
|
+
// probe aims its command AT the pin — and latest moves further past the pin
|
|
174
|
+
// the profile asked for. Enter does nothing; the row's note says why.
|
|
175
|
+
if (status.reason === "ahead-of-pin") return null;
|
|
176
|
+
if (!isInstalled(status)) {
|
|
177
|
+
return {
|
|
178
|
+
kind: "install",
|
|
179
|
+
label: "install",
|
|
180
|
+
detail: "Install",
|
|
181
|
+
progress: "Installing",
|
|
182
|
+
command: installCommand,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
// Both commands come from the row, which built them from the SAME rule its
|
|
186
|
+
// state came from — a declared tool's pin included.
|
|
187
|
+
const command = updateCommand ?? installCommand;
|
|
188
|
+
if (status.state === "upgrade") {
|
|
189
|
+
return {
|
|
190
|
+
kind: "update",
|
|
191
|
+
label: "update",
|
|
192
|
+
detail: status.target
|
|
193
|
+
? `Update to ${displayVersion(status.target)}`
|
|
194
|
+
: "Update",
|
|
195
|
+
progress: "Updating",
|
|
196
|
+
command,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (status.state === "unknown") {
|
|
200
|
+
return {
|
|
201
|
+
kind: "update-anyway",
|
|
202
|
+
label: "update anyway",
|
|
203
|
+
detail: "Update anyway",
|
|
204
|
+
progress: "Updating",
|
|
205
|
+
command,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
kind: "reinstall",
|
|
210
|
+
label: "reinstall",
|
|
211
|
+
detail: "Reinstall",
|
|
212
|
+
progress: "Reinstalling",
|
|
213
|
+
command,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
129
217
|
export interface CliToolStatusText {
|
|
130
218
|
/** Text after the version, with its leading space; "" for nothing to say. */
|
|
131
219
|
suffix: string;
|
|
@@ -133,8 +221,49 @@ export interface CliToolStatusText {
|
|
|
133
221
|
}
|
|
134
222
|
|
|
135
223
|
/**
|
|
136
|
-
* The row's
|
|
137
|
-
*
|
|
224
|
+
* The row's short word for WHY an `unknown` could not be checked.
|
|
225
|
+
*
|
|
226
|
+
* A label, never the note. The note is a sentence with a path in it ("on PATH
|
|
227
|
+
* at /Users/…, which npm … does not own — …"); a list row has room for a
|
|
228
|
+
* version, a method tag and a few words, so printing the note there cut it off
|
|
229
|
+
* mid-path on every machine. The full note stays in the detail pane's "Why"
|
|
230
|
+
* line, the same string `magus update` prints.
|
|
231
|
+
*
|
|
232
|
+
* Every label is at most 19 cells, so the row suffix (" ? " + label) stays
|
|
233
|
+
* within 24. Keyed by reason alone — plus the spec's installer, which is a
|
|
234
|
+
* word, never a path.
|
|
235
|
+
*/
|
|
236
|
+
function unknownLabel(reason: ToolReason, via: BinRequirement["via"]): string {
|
|
237
|
+
switch (reason) {
|
|
238
|
+
case "not-managed":
|
|
239
|
+
return `not managed by ${via}`;
|
|
240
|
+
case "version-unreadable":
|
|
241
|
+
return "version unreadable";
|
|
242
|
+
case "latest-unavailable":
|
|
243
|
+
return "latest unavailable";
|
|
244
|
+
case "unorderable":
|
|
245
|
+
return "not comparable";
|
|
246
|
+
case "ahead-of-pin":
|
|
247
|
+
return "ahead of pin";
|
|
248
|
+
case "held-back":
|
|
249
|
+
return "held back";
|
|
250
|
+
case "pin-not-installable":
|
|
251
|
+
return "pin not installable";
|
|
252
|
+
case "not-probed":
|
|
253
|
+
return "not checked";
|
|
254
|
+
// Never `unknown`: these reasons plan work or report `current`.
|
|
255
|
+
case "absent":
|
|
256
|
+
case "up-to-date":
|
|
257
|
+
case "behind":
|
|
258
|
+
case "at-pin":
|
|
259
|
+
case "behind-pin":
|
|
260
|
+
return "not checked";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The row's status words. An `unknown` shows a short label for its reason
|
|
266
|
+
* ({@link unknownLabel}); the detail pane carries the full note.
|
|
138
267
|
*/
|
|
139
268
|
export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
|
|
140
269
|
const { status } = row;
|
|
@@ -142,7 +271,7 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
|
|
|
142
271
|
switch (status.state) {
|
|
143
272
|
case "unknown":
|
|
144
273
|
return {
|
|
145
|
-
suffix: ` ? ${status.
|
|
274
|
+
suffix: ` ? ${unknownLabel(status.reason, row.spec.via)}`,
|
|
146
275
|
tone: "warning",
|
|
147
276
|
};
|
|
148
277
|
case "upgrade":
|