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
|
@@ -4,6 +4,7 @@ import type { ApplyReporter } from "../../services/update-engine.js";
|
|
|
4
4
|
import type { PluginUpdateItem } from "../../services/update-plan.js";
|
|
5
5
|
import type { Marketplace } from "../../types/index.js";
|
|
6
6
|
import type { PluginContentsEntry } from "../state/types.js";
|
|
7
|
+
import type { BadgeTone } from "../theme.js";
|
|
7
8
|
|
|
8
9
|
// Virtual marketplace name for the community sub-section of claude-plugins-official
|
|
9
10
|
export const COMMUNITY_VIRTUAL_MARKETPLACE =
|
|
@@ -23,7 +24,7 @@ export interface PluginCategoryItem {
|
|
|
23
24
|
isExpanded: boolean;
|
|
24
25
|
isCommunitySection?: boolean;
|
|
25
26
|
/** Visual tone for the category row */
|
|
26
|
-
tone:
|
|
27
|
+
tone: BadgeTone | "byline";
|
|
27
28
|
/** Badge text shown on category row (e.g. "★ Official") */
|
|
28
29
|
badge?: string;
|
|
29
30
|
}
|
|
@@ -196,24 +197,26 @@ function categoryStyling(
|
|
|
196
197
|
marketplaceEnabled: boolean,
|
|
197
198
|
): { tone: PluginCategoryItem["tone"]; badge?: string } {
|
|
198
199
|
if (!marketplaceEnabled) {
|
|
199
|
-
return { tone: "
|
|
200
|
+
return { tone: "neutral" };
|
|
200
201
|
}
|
|
201
202
|
if (isCommunitySection) {
|
|
202
|
-
return { tone: "
|
|
203
|
+
return { tone: "neutral", badge: "3rd Party" };
|
|
203
204
|
}
|
|
205
|
+
// `info`, not `warning`: "official" is a fact about the source, and amber
|
|
206
|
+
// means "needs a look". The ★ carries the distinction.
|
|
204
207
|
if (mp.name === "claude-plugins-official") {
|
|
205
|
-
return { tone: "
|
|
208
|
+
return { tone: "info", badge: "★ Official" };
|
|
206
209
|
}
|
|
207
210
|
if (mp.name === "claude-code-plugins") {
|
|
208
|
-
return { tone: "
|
|
211
|
+
return { tone: "danger", badge: "⚠ Deprecated" };
|
|
209
212
|
}
|
|
210
213
|
if (mp.official) {
|
|
211
|
-
return { tone: "
|
|
214
|
+
return { tone: "info", badge: "★ Official" };
|
|
212
215
|
}
|
|
213
216
|
if (isMadAppGang(mp)) {
|
|
214
217
|
return { tone: "byline", badge: "by MadAppGang" };
|
|
215
218
|
}
|
|
216
|
-
return { tone: "
|
|
219
|
+
return { tone: "success", badge: "✓ Added" };
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
/**
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
type SettingDefinition,
|
|
5
5
|
} from "../../data/settings-catalog.js";
|
|
6
6
|
import type { ScopedSettingValues } from "../../services/settings-manager.js";
|
|
7
|
-
import { type UiColor, theme } from "../theme.js";
|
|
8
7
|
|
|
9
8
|
// ─── Item types ────────────────────────────────────────────────────────────────
|
|
10
9
|
|
|
@@ -51,26 +50,6 @@ export const CATEGORY_ORDER: SettingCategory[] = [
|
|
|
51
50
|
"advanced",
|
|
52
51
|
];
|
|
53
52
|
|
|
54
|
-
export const CATEGORY_BG: Record<SettingCategory, UiColor> = {
|
|
55
|
-
recommended: theme.colors.success,
|
|
56
|
-
agents: theme.colors.info,
|
|
57
|
-
models: theme.colors.accent,
|
|
58
|
-
workflow: theme.colors.link,
|
|
59
|
-
terminal: theme.colors.warning,
|
|
60
|
-
performance: theme.colors.accent,
|
|
61
|
-
advanced: theme.colors.warning,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export const CATEGORY_COLOR: Record<SettingCategory, UiColor> = {
|
|
65
|
-
recommended: theme.colors.success,
|
|
66
|
-
agents: theme.colors.info,
|
|
67
|
-
models: theme.colors.info,
|
|
68
|
-
workflow: theme.colors.link,
|
|
69
|
-
terminal: theme.colors.link,
|
|
70
|
-
performance: "magentaBright",
|
|
71
|
-
advanced: theme.colors.warning,
|
|
72
|
-
};
|
|
73
|
-
|
|
74
53
|
export const CATEGORY_DESCRIPTIONS: Record<SettingCategory, string> = {
|
|
75
54
|
recommended: "Most impactful settings every user should know.",
|
|
76
55
|
agents: "Agent teams, task lists, and subagent configuration.",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SkillInfo, SkillSetInfo } from "../../types/index.js";
|
|
2
|
+
import type { BadgeTone } from "../theme.js";
|
|
2
3
|
|
|
3
4
|
// ─── Item types ───────────────────────────────────────────────────────────────
|
|
4
5
|
|
|
@@ -9,7 +10,7 @@ export interface SkillCategoryItem {
|
|
|
9
10
|
title: string;
|
|
10
11
|
categoryKey: string;
|
|
11
12
|
count?: number;
|
|
12
|
-
tone:
|
|
13
|
+
tone: BadgeTone;
|
|
13
14
|
badge?: string;
|
|
14
15
|
star?: string;
|
|
15
16
|
}
|
|
@@ -38,6 +39,25 @@ export type SkillBrowserItem =
|
|
|
38
39
|
|
|
39
40
|
// ─── Adapter ─────────────────────────────────────────────────────────────────
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The skills the Installed group lists: every skill on disk, narrowed by the
|
|
44
|
+
* search query.
|
|
45
|
+
*
|
|
46
|
+
* The ONE selector for "installed" on this screen. The header's "N installed"
|
|
47
|
+
* used to count a different list — the fetched catalogue's `installed` flags —
|
|
48
|
+
* so it said "1 installed" above a group headed "Installed (16)". Both now read
|
|
49
|
+
* this, so they cannot disagree.
|
|
50
|
+
*/
|
|
51
|
+
export function installedGroup(
|
|
52
|
+
installed: readonly SkillInfo[],
|
|
53
|
+
query: string,
|
|
54
|
+
): SkillInfo[] {
|
|
55
|
+
const lowerQuery = query.toLowerCase();
|
|
56
|
+
return lowerQuery
|
|
57
|
+
? installed.filter((s) => s.name.toLowerCase().includes(lowerQuery))
|
|
58
|
+
: [...installed];
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
export interface BuildSkillBrowserItemsArgs {
|
|
42
62
|
recommended: SkillInfo[];
|
|
43
63
|
popular: SkillInfo[];
|
|
@@ -67,9 +87,7 @@ export function buildSkillBrowserItems({
|
|
|
67
87
|
const items: SkillBrowserItem[] = [];
|
|
68
88
|
|
|
69
89
|
// ── INSTALLED: always shown at top (if any) ──
|
|
70
|
-
const installedFiltered =
|
|
71
|
-
? installed.filter((s) => s.name.toLowerCase().includes(lowerQuery))
|
|
72
|
-
: installed;
|
|
90
|
+
const installedFiltered = installedGroup(installed, query);
|
|
73
91
|
|
|
74
92
|
if (installedFiltered.length > 0) {
|
|
75
93
|
items.push({
|
|
@@ -79,7 +97,8 @@ export function buildSkillBrowserItems({
|
|
|
79
97
|
title: "Installed",
|
|
80
98
|
categoryKey: "installed",
|
|
81
99
|
count: installedFiltered.length,
|
|
82
|
-
|
|
100
|
+
// The group's name is a state, so it wears that state's role.
|
|
101
|
+
tone: "success",
|
|
83
102
|
star: "\u25CF ",
|
|
84
103
|
});
|
|
85
104
|
for (const skill of installedFiltered) {
|
|
@@ -123,7 +142,7 @@ export function buildSkillBrowserItems({
|
|
|
123
142
|
title: "Recommended",
|
|
124
143
|
categoryKey: "recommended",
|
|
125
144
|
count: recommendedCount,
|
|
126
|
-
tone: "
|
|
145
|
+
tone: "neutral",
|
|
127
146
|
star: "\u2605 ",
|
|
128
147
|
});
|
|
129
148
|
|
|
@@ -180,7 +199,7 @@ export function buildSkillBrowserItems({
|
|
|
180
199
|
title: "Search",
|
|
181
200
|
categoryKey: "popular",
|
|
182
201
|
count: deduped.length,
|
|
183
|
-
tone: "
|
|
202
|
+
tone: "neutral",
|
|
184
203
|
});
|
|
185
204
|
for (const skill of deduped) {
|
|
186
205
|
items.push({
|
|
@@ -213,7 +232,7 @@ export function buildSkillBrowserItems({
|
|
|
213
232
|
title: "Popular",
|
|
214
233
|
categoryKey: "popular",
|
|
215
234
|
count: popularDeduped.length,
|
|
216
|
-
tone: "
|
|
235
|
+
tone: "neutral",
|
|
217
236
|
});
|
|
218
237
|
for (const skill of popularDeduped) {
|
|
219
238
|
items.push({
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
StylePreset,
|
|
11
11
|
StyleSource,
|
|
12
12
|
} from "../../services/styles-manager.js";
|
|
13
|
+
import type { BadgeTone } from "../theme.js";
|
|
13
14
|
|
|
14
15
|
// ─── Item types ───────────────────────────────────────────────────────────────
|
|
15
16
|
|
|
@@ -44,7 +45,7 @@ export interface StyleCategoryItem {
|
|
|
44
45
|
title: string;
|
|
45
46
|
categoryKey: StyleCategoryKey;
|
|
46
47
|
count: number;
|
|
47
|
-
tone:
|
|
48
|
+
tone: BadgeTone;
|
|
48
49
|
badge?: string;
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -326,11 +327,11 @@ export function buildStyleBrowserItems({
|
|
|
326
327
|
title: "Community",
|
|
327
328
|
categoryKey: "community",
|
|
328
329
|
count: rows.length + orphans.length,
|
|
329
|
-
//
|
|
330
|
-
// captured from somewhere else onto this disk
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
tone: "
|
|
330
|
+
// `info`, shared with Anthropic captures: both sections are text
|
|
331
|
+
// captured from somewhere else onto this disk — a fact about where it
|
|
332
|
+
// came from, which is what `info` means. Never `danger`: a normal,
|
|
333
|
+
// working section must not wear it.
|
|
334
|
+
tone: "info",
|
|
334
335
|
badge: "from GitHub",
|
|
335
336
|
});
|
|
336
337
|
|
|
@@ -373,17 +374,20 @@ export function buildStyleBrowserItems({
|
|
|
373
374
|
// count, and a long badge wraps onto a second line on a narrow pane, which
|
|
374
375
|
// costs a whole row and breaks the scan down the left column. The full
|
|
375
376
|
// explanation lives in the detail panel, where there is room for it.
|
|
377
|
+
//
|
|
378
|
+
// Sections are groups, not states, so they wear `neutral` — except the two
|
|
379
|
+
// captured from outside, which share `info` (see pushCommunity).
|
|
376
380
|
pushImports(
|
|
377
381
|
"anthropic",
|
|
378
382
|
"anthropic",
|
|
379
383
|
"Anthropic official",
|
|
380
|
-
"
|
|
384
|
+
"info",
|
|
381
385
|
"captured",
|
|
382
386
|
);
|
|
383
|
-
pushPresets(verbosity, "verbosity", "Verbosity", "
|
|
384
|
-
pushPresets(modifiers, "modifier", "Modifiers", "
|
|
385
|
-
pushImports("team", "team", "Team", "
|
|
386
|
-
pushImports("personal", "personal", "Personal", "
|
|
387
|
+
pushPresets(verbosity, "verbosity", "Verbosity", "neutral", "pick one");
|
|
388
|
+
pushPresets(modifiers, "modifier", "Modifiers", "neutral", "combine freely");
|
|
389
|
+
pushImports("team", "team", "Team", "neutral", "in the repo");
|
|
390
|
+
pushImports("personal", "personal", "Personal", "neutral", "this machine");
|
|
387
391
|
pushCommunity();
|
|
388
392
|
|
|
389
393
|
return items;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { type UiColor, theme } from "../theme.js";
|
|
3
|
+
import { rowLead } from "./primitives/SelectableRow.js";
|
|
3
4
|
|
|
4
5
|
interface CategoryHeaderProps {
|
|
5
6
|
/** Category title */
|
|
@@ -38,7 +39,12 @@ export function CategoryHeader({
|
|
|
38
39
|
<box flexDirection="row" width="100%" height={1} overflow="hidden">
|
|
39
40
|
<box flexShrink={1} minWidth={4} overflow="hidden" height={1}>
|
|
40
41
|
<text fg={theme.colors.text}>
|
|
41
|
-
|
|
42
|
+
{/* The lead cell every list row keeps blank, so the arrow sits in
|
|
43
|
+
the same column as it does on the selected row. */}
|
|
44
|
+
<span fg={theme.colors.muted}>
|
|
45
|
+
{rowLead(false)}
|
|
46
|
+
{expandIcon}
|
|
47
|
+
</span>
|
|
42
48
|
<span fg={theme.colors.text}>
|
|
43
49
|
<strong> {title}</strong>
|
|
44
50
|
</span>
|
|
@@ -12,7 +12,7 @@ export function EmptyFilterState({
|
|
|
12
12
|
}: EmptyFilterStateProps) {
|
|
13
13
|
return (
|
|
14
14
|
<box flexDirection="column" marginTop={2} paddingLeft={2} paddingRight={2}>
|
|
15
|
-
<text fg={theme.colors.
|
|
15
|
+
<text fg={theme.colors.text}>
|
|
16
16
|
No {entityName} found for "{query}"
|
|
17
17
|
</text>
|
|
18
18
|
<box marginTop={1}>
|
|
@@ -22,7 +22,7 @@ export function EmptyFilterState({
|
|
|
22
22
|
</box>
|
|
23
23
|
<text fg={theme.colors.muted}>this is a bug, report it at:</text>
|
|
24
24
|
<box marginTop={1}>
|
|
25
|
-
<text fg={theme.colors.
|
|
25
|
+
<text fg={theme.colors.info}>github.com/MadAppGang/magus/issues</text>
|
|
26
26
|
</box>
|
|
27
27
|
<box marginTop={1}>
|
|
28
28
|
<text fg={theme.colors.muted}>Press Esc to clear the filter.</text>
|
|
Binary file
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Screen } from "../state/types.js";
|
|
3
|
+
import { theme } from "../theme.js";
|
|
4
|
+
import { ScreenLayout } from "./layout/ScreenLayout.js";
|
|
5
|
+
import { ErrorNotice } from "./primitives/index.js";
|
|
6
|
+
|
|
7
|
+
interface ScreenErrorBoundaryProps {
|
|
8
|
+
/** The screen being guarded. The fallback keeps its tab highlighted. */
|
|
9
|
+
screen: Screen;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ScreenErrorBoundaryState {
|
|
14
|
+
error: Error | null;
|
|
15
|
+
/** The screen the error belongs to, so leaving it clears the error. */
|
|
16
|
+
screen: Screen;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Contains a render error to the screen that threw it.
|
|
21
|
+
*
|
|
22
|
+
* Without it a throw in any one screen reached OpenTUI's root boundary, which
|
|
23
|
+
* replaces the WHOLE app with a stack trace: the tab bar went with it, and so
|
|
24
|
+
* did every key handler mounted under the root, leaving the user nothing to
|
|
25
|
+
* press but Ctrl+C. Here the failing screen alone is replaced by an error
|
|
26
|
+
* panel that keeps the tab bar, and the global keys (digits, Tab, q, ?, Esc)
|
|
27
|
+
* stay live: they are a key layer of their own, and the failed screen's layers
|
|
28
|
+
* unregistered when it unmounted — a search box it left focused included.
|
|
29
|
+
*
|
|
30
|
+
* Switching tab clears the error, which is also how the user retries: coming
|
|
31
|
+
* back mounts the screen fresh.
|
|
32
|
+
*/
|
|
33
|
+
export class ScreenErrorBoundary extends React.Component<
|
|
34
|
+
ScreenErrorBoundaryProps,
|
|
35
|
+
ScreenErrorBoundaryState
|
|
36
|
+
> {
|
|
37
|
+
constructor(props: ScreenErrorBoundaryProps) {
|
|
38
|
+
super(props);
|
|
39
|
+
this.state = { error: null, screen: props.screen };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static getDerivedStateFromProps(
|
|
43
|
+
props: ScreenErrorBoundaryProps,
|
|
44
|
+
state: ScreenErrorBoundaryState,
|
|
45
|
+
): Partial<ScreenErrorBoundaryState> | null {
|
|
46
|
+
// A different screen owes nothing to the previous one's failure.
|
|
47
|
+
return props.screen === state.screen
|
|
48
|
+
? null
|
|
49
|
+
: { error: null, screen: props.screen };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static getDerivedStateFromError(
|
|
53
|
+
error: unknown,
|
|
54
|
+
): Partial<ScreenErrorBoundaryState> {
|
|
55
|
+
return {
|
|
56
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
render(): React.ReactNode {
|
|
61
|
+
if (this.state.error) {
|
|
62
|
+
return (
|
|
63
|
+
<ScreenErrorPanel screen={this.props.screen} error={this.state.error} />
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return this.props.children;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The panel drawn in place of a screen that failed to render. Its footer
|
|
72
|
+
* advertises only what still works — the global and scroll keys — because the
|
|
73
|
+
* screen's own bindings went with the screen.
|
|
74
|
+
*/
|
|
75
|
+
function ScreenErrorPanel({
|
|
76
|
+
screen,
|
|
77
|
+
error,
|
|
78
|
+
}: {
|
|
79
|
+
screen: Screen;
|
|
80
|
+
error: Error;
|
|
81
|
+
}) {
|
|
82
|
+
const stack = (error.stack ?? "").split("\n").slice(1);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<ScreenLayout
|
|
86
|
+
title="magus — this screen failed"
|
|
87
|
+
currentScreen={screen}
|
|
88
|
+
footer={{ owner: "none" }}
|
|
89
|
+
listPanel={
|
|
90
|
+
<ErrorNotice
|
|
91
|
+
title="This screen stopped drawing because of an error."
|
|
92
|
+
detail={error.message || String(error)}
|
|
93
|
+
hint="Other tabs still work. Switch away and back to retry."
|
|
94
|
+
/>
|
|
95
|
+
}
|
|
96
|
+
detailPanel={stack.map((line, index) => (
|
|
97
|
+
// Stack lines have no identity beyond their position.
|
|
98
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a static, never-reordered list
|
|
99
|
+
<text key={index} fg={theme.colors.muted}>
|
|
100
|
+
{line.trim()}
|
|
101
|
+
</text>
|
|
102
|
+
))}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
3
3
|
import type { Screen } from "../state/types.js";
|
|
4
|
+
import { displayWidth } from "../text-width.js";
|
|
4
5
|
import { theme } from "../theme.js";
|
|
5
6
|
|
|
6
7
|
interface Tab {
|
|
@@ -26,36 +27,106 @@ export const TABS: Tab[] = [
|
|
|
26
27
|
{ key: "0", label: "Models", screen: "models" },
|
|
27
28
|
];
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
/** What the bar draws: one text per tab, in TABS order, and whether each cell
|
|
31
|
+
* carries its one-space padding either side. */
|
|
32
|
+
export interface TabLayout {
|
|
33
|
+
texts: string[];
|
|
34
|
+
padded: boolean;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
37
|
+
/** Shortest label prefix an inactive tab may shrink to before it drops to its
|
|
38
|
+
* key alone. Three letters still name the tab ("Plu", "Set", "Git"). */
|
|
39
|
+
const MIN_PREFIX = 3;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Columns the whole bar occupies: every cell plus a one-column separator
|
|
43
|
+
* between cells. A padded cell adds one space each side of its text.
|
|
44
|
+
*/
|
|
45
|
+
export function barWidth(texts: string[], padded = true): number {
|
|
36
46
|
if (texts.length === 0) return 0;
|
|
37
|
-
|
|
47
|
+
const pad = padded ? 2 : 0;
|
|
48
|
+
return (
|
|
49
|
+
texts.reduce((n, t) => n + displayWidth(t) + pad, 0) + (texts.length - 1)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fullText = (tab: Tab) => `${tab.key}:${tab.label}`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Tier 3: the active tab in full, every inactive tab as `key:` plus a label
|
|
57
|
+
* prefix. Prefixes start at MIN_PREFIX and grow one letter at a time, round
|
|
58
|
+
* robin in tab order, while the bar still fits — so the spare columns go to
|
|
59
|
+
* names rather than to blank space. `null` when even the shortest form does not
|
|
60
|
+
* fit.
|
|
61
|
+
*/
|
|
62
|
+
function prefixLayout(
|
|
63
|
+
tabs: Tab[],
|
|
64
|
+
currentScreen: Screen,
|
|
65
|
+
available: number,
|
|
66
|
+
): string[] | null {
|
|
67
|
+
const lengths = tabs.map((tab) =>
|
|
68
|
+
tab.screen === currentScreen
|
|
69
|
+
? tab.label.length
|
|
70
|
+
: Math.min(MIN_PREFIX, tab.label.length),
|
|
71
|
+
);
|
|
72
|
+
const render = () =>
|
|
73
|
+
tabs.map((tab, i) =>
|
|
74
|
+
tab.screen === currentScreen
|
|
75
|
+
? fullText(tab)
|
|
76
|
+
: `${tab.key}:${tab.label.slice(0, lengths[i])}`,
|
|
77
|
+
);
|
|
78
|
+
let width = barWidth(render(), false);
|
|
79
|
+
if (width > available) return null;
|
|
80
|
+
|
|
81
|
+
let grew = true;
|
|
82
|
+
while (grew) {
|
|
83
|
+
grew = false;
|
|
84
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
85
|
+
const tab = tabs[i] as Tab;
|
|
86
|
+
const length = lengths[i] as number;
|
|
87
|
+
if (length >= tab.label.length || width + 1 > available) continue;
|
|
88
|
+
lengths[i] = length + 1;
|
|
89
|
+
width += 1;
|
|
90
|
+
grew = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return render();
|
|
38
94
|
}
|
|
39
95
|
|
|
40
96
|
/**
|
|
41
|
-
* Choose what each tab shows for the space available
|
|
97
|
+
* Choose what each tab shows for the space available — the first tier that
|
|
98
|
+
* fits, in this order:
|
|
99
|
+
*
|
|
100
|
+
* 1. every tab `key:label`, padded;
|
|
101
|
+
* 2. every tab `key:label`, unpadded;
|
|
102
|
+
* 3. the active tab `key:label`, each inactive tab `key:` plus a label prefix
|
|
103
|
+
* of at least three letters;
|
|
104
|
+
* 4. the active tab `key:label`, each inactive tab its key alone.
|
|
42
105
|
*
|
|
43
|
-
* The
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
106
|
+
* The active tab always keeps its whole name and every tab always keeps its
|
|
107
|
+
* key. The steps exist because the old all-or-nothing rule dropped straight
|
|
108
|
+
* from the full bar (110 columns) to bare numbers, so a 100-column window
|
|
109
|
+
* showed "1 2 3 … 9 0" beside one name when a readable bar fitted with room to
|
|
110
|
+
* spare.
|
|
48
111
|
*/
|
|
49
112
|
export function layoutTabs(
|
|
50
113
|
tabs: Tab[],
|
|
51
114
|
currentScreen: Screen,
|
|
52
115
|
available: number,
|
|
53
|
-
):
|
|
54
|
-
const full = tabs.map(
|
|
55
|
-
if (barWidth(full) <= available) return full;
|
|
56
|
-
|
|
57
|
-
|
|
116
|
+
): TabLayout {
|
|
117
|
+
const full = tabs.map(fullText);
|
|
118
|
+
if (barWidth(full, true) <= available) return { texts: full, padded: true };
|
|
119
|
+
if (barWidth(full, false) <= available) return { texts: full, padded: false };
|
|
120
|
+
|
|
121
|
+
const prefixed = prefixLayout(tabs, currentScreen, available);
|
|
122
|
+
if (prefixed) return { texts: prefixed, padded: false };
|
|
123
|
+
|
|
124
|
+
const keys = tabs.map((tab) =>
|
|
125
|
+
tab.screen === currentScreen ? fullText(tab) : tab.key,
|
|
58
126
|
);
|
|
127
|
+
// Tier 4 keeps its padding when that still fits — nothing better can use
|
|
128
|
+
// the room, and the padding is what makes a bare digit read as a tab.
|
|
129
|
+
return { texts: keys, padded: barWidth(keys, true) <= available };
|
|
59
130
|
}
|
|
60
131
|
|
|
61
132
|
interface TabBarProps {
|
|
@@ -66,14 +137,15 @@ export function TabBar({ currentScreen }: TabBarProps) {
|
|
|
66
137
|
const dimensions = useDimensions();
|
|
67
138
|
// ScreenLayout pads the bar by 1 each side, inside a container padded by 1.
|
|
68
139
|
const available = Math.max(10, dimensions.terminalWidth - 4);
|
|
69
|
-
const texts = layoutTabs(TABS, currentScreen, available);
|
|
140
|
+
const { texts, padded } = layoutTabs(TABS, currentScreen, available);
|
|
141
|
+
const pad = padded ? " " : "";
|
|
70
142
|
|
|
71
143
|
return (
|
|
72
144
|
<box flexDirection="row" gap={0}>
|
|
73
145
|
{TABS.map((tab, index) => {
|
|
74
146
|
const isSelected = tab.screen === currentScreen;
|
|
75
147
|
const isLast = index === TABS.length - 1;
|
|
76
|
-
const text = texts[index]
|
|
148
|
+
const text = `${pad}${texts[index]}${pad}`;
|
|
77
149
|
|
|
78
150
|
return (
|
|
79
151
|
<box key={tab.key} flexDirection="row">
|
|
@@ -81,12 +153,12 @@ export function TabBar({ currentScreen }: TabBarProps) {
|
|
|
81
153
|
{isSelected ? (
|
|
82
154
|
<box>
|
|
83
155
|
<text bg={theme.colors.accent} fg={theme.hints.fg}>
|
|
84
|
-
<strong>
|
|
156
|
+
<strong>{text}</strong>
|
|
85
157
|
</text>
|
|
86
158
|
</box>
|
|
87
159
|
) : (
|
|
88
160
|
<box>
|
|
89
|
-
<text fg={theme.colors.muted}>
|
|
161
|
+
<text fg={theme.colors.muted}>{text}</text>
|
|
90
162
|
</box>
|
|
91
163
|
)}
|
|
92
164
|
{/* Separator */}
|
|
@@ -1,31 +1,15 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
+
import { FOOTER_GAP, joinKeys } from "../../keymap.js";
|
|
2
3
|
import { hintChip } from "../../theme-mode.js";
|
|
3
4
|
import { theme } from "../../theme.js";
|
|
4
5
|
|
|
5
6
|
/** One footer hint: a key (or key group) and the action it performs. */
|
|
6
7
|
export interface FooterHint {
|
|
7
8
|
/** Key(s) for this action, e.g. ["↑", "↓"] or ["U"]. Joined by `joinKeys`. */
|
|
8
|
-
keys: string[];
|
|
9
|
+
keys: readonly string[];
|
|
9
10
|
label: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
-
* Render a key group as one chip's text.
|
|
14
|
-
*
|
|
15
|
-
* Single-character keys butt together, because `↑↓` is how an arrow pair is written
|
|
16
|
-
* everywhere and a separator there would be noise. Anything longer needs a gap, or the
|
|
17
|
-
* names run into each other and read as one key that does not exist — MEASURED on screen:
|
|
18
|
-
* `["Enter", "a"]` rendered as `Entera`, and the layout's own scroll hint
|
|
19
|
-
* `["^U/^D", "PgUp/Dn"]` rendered as `^U/^DPgUp/Dn` on every screen in the app.
|
|
20
|
-
*
|
|
21
|
-
* A space rather than a slash, because several key names already contain one.
|
|
22
|
-
*/
|
|
23
|
-
export function joinKeys(keys: string[]): string {
|
|
24
|
-
return keys.every((k) => [...k].length === 1)
|
|
25
|
-
? keys.join("")
|
|
26
|
-
: keys.join(" ");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
13
|
// The shared chip palette, not a local one. This file used to define its own
|
|
30
14
|
// (`theme.colors.dim` block + terminal-foreground ink), which is why the footer
|
|
31
15
|
// stayed a low-contrast grey-on-grey strip after the palette was made vivid —
|
|
@@ -34,7 +18,6 @@ export function joinKeys(keys: string[]): string {
|
|
|
34
18
|
// `hintChip()` rather than `theme.hints`: the chip is two fills that must sit a
|
|
35
19
|
// measured step apart, and "a step quieter" is only definable against a page.
|
|
36
20
|
// The static palette cannot express it; `theme-mode` resolves it.
|
|
37
|
-
const GAP = " ";
|
|
38
21
|
|
|
39
22
|
/**
|
|
40
23
|
* Render a row of footer hints as key badges + labels:
|
|
@@ -42,11 +25,12 @@ const GAP = " ";
|
|
|
42
25
|
* [↑↓] move [U] update [/] search
|
|
43
26
|
*
|
|
44
27
|
* Each key is shown in a chip so it reads as a pressable key, not prose.
|
|
45
|
-
* Shared across screens so every footer looks the same.
|
|
28
|
+
* Shared across screens so every footer looks the same. `footerWidth` in
|
|
29
|
+
* ui/keymap.ts measures exactly this layout — change one, change both.
|
|
46
30
|
*/
|
|
47
31
|
export function FooterHints({
|
|
48
32
|
hints,
|
|
49
|
-
}: { hints: FooterHint[] }): React.ReactNode {
|
|
33
|
+
}: { hints: readonly FooterHint[] }): React.ReactNode {
|
|
50
34
|
const ink = hintChip();
|
|
51
35
|
const nodes: React.ReactNode[] = [];
|
|
52
36
|
hints.forEach((hint, i) => {
|
|
@@ -55,7 +39,7 @@ export function FooterHints({
|
|
|
55
39
|
if (i > 0) {
|
|
56
40
|
nodes.push(
|
|
57
41
|
<span key={`gap-${id}`} fg={theme.colors.muted}>
|
|
58
|
-
{
|
|
42
|
+
{FOOTER_GAP}
|
|
59
43
|
</span>,
|
|
60
44
|
);
|
|
61
45
|
}
|
|
@@ -82,9 +66,4 @@ export function FooterHints({
|
|
|
82
66
|
return <text fg={theme.colors.text}>{nodes}</text>;
|
|
83
67
|
}
|
|
84
68
|
|
|
85
|
-
/** Convenience for callers that already have a node: same as <FooterHints>. */
|
|
86
|
-
export function renderFooterHints(hints: FooterHint[]): React.ReactNode {
|
|
87
|
-
return <FooterHints hints={hints} />;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
69
|
export default FooterHints;
|