magus-cli 7.12.2 → 7.14.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/README.md +21 -0
- package/package.json +4 -4
- package/src/__tests__/market-bases.test.ts +112 -0
- package/src/__tests__/market-config.test.ts +84 -0
- package/src/__tests__/market-conflicts.test.ts +175 -0
- package/src/__tests__/market-consent.test.ts +185 -0
- package/src/__tests__/market-content.test.ts +183 -0
- package/src/__tests__/market-delivery.test.ts +308 -0
- package/src/__tests__/market-fixture.ts +1167 -0
- package/src/__tests__/market-git.test.ts +130 -0
- package/src/__tests__/market-identity.test.ts +127 -0
- package/src/__tests__/market-install.test.ts +143 -0
- package/src/__tests__/market-layout.test.ts +206 -0
- package/src/__tests__/market-publish.test.ts +263 -0
- package/src/__tests__/market-r2-adapters.test.ts +190 -0
- package/src/__tests__/market-r2-company-cli.test.ts +350 -0
- package/src/__tests__/market-r2-company-unit.test.ts +224 -0
- package/src/__tests__/market-r2-local-items.test.ts +197 -0
- package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
- package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
- package/src/__tests__/market-r2-routing.test.ts +127 -0
- package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
- package/src/__tests__/market-r2b-display-path.test.ts +180 -0
- package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
- package/src/__tests__/market-r2b-local-items.test.ts +253 -0
- package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
- package/src/__tests__/market-readonly.test.ts +162 -0
- package/src/__tests__/market-repositories.test.ts +263 -0
- package/src/__tests__/market-results.test.ts +198 -0
- package/src/__tests__/market-router.test.ts +65 -0
- package/src/__tests__/market-safety.test.ts +357 -0
- package/src/__tests__/market-teammates.test.ts +304 -0
- package/src/cli/install.ts +64 -5
- package/src/cli/market-view.ts +329 -0
- package/src/cli/market.ts +664 -0
- package/src/cli/router.ts +69 -3
- package/src/cli/update-view.ts +56 -0
- package/src/cli/update.ts +111 -9
- package/src/data/marketplaces.ts +8 -2
- package/src/main.tsx +20 -2
- package/src/market-tui.tsx +21 -0
- package/src/services/active-closure.ts +71 -9
- package/src/services/claude-cli.ts +140 -10
- package/src/services/claude-settings.ts +19 -25
- package/src/services/doctor.ts +5 -5
- package/src/services/git-write.ts +615 -0
- package/src/services/github-cli.ts +309 -0
- package/src/services/local-marketplace.ts +112 -50
- package/src/services/market-bases.ts +191 -0
- package/src/services/market-change.ts +242 -0
- package/src/services/market-clone.ts +572 -0
- package/src/services/market-fs.ts +138 -0
- package/src/services/market-install.ts +172 -0
- package/src/services/market-layout.ts +311 -0
- package/src/services/market-local.ts +93 -0
- package/src/services/market-marker.ts +49 -0
- package/src/services/market-ops-content.ts +277 -0
- package/src/services/market-ops-plugin.ts +655 -0
- package/src/services/market-ops-publish.ts +1227 -0
- package/src/services/market-ops-repo.ts +916 -0
- package/src/services/market-ops-skill.ts +788 -0
- package/src/services/market-ops.ts +686 -0
- package/src/services/market-publish.ts +468 -0
- package/src/services/market-registry.ts +382 -0
- package/src/services/market-types.ts +268 -0
- package/src/services/marketplace-conflicts.ts +127 -0
- package/src/services/marketplace-refresh.ts +33 -3
- package/src/services/marketplace-sync.ts +253 -22
- package/src/services/plugin-dependencies.ts +17 -0
- package/src/services/plugin-manager.ts +1 -1
- package/src/services/plugin-mcp-config.ts +6 -9
- package/src/services/plugin-requires.ts +20 -8
- package/src/services/profile-absorb.ts +88 -3
- package/src/services/profile-adopt.ts +10 -1
- package/src/services/profiles.ts +64 -1
- package/src/services/resolver.ts +17 -5
- package/src/services/settings-manager.ts +2 -2
- package/src/services/skills-manager.ts +2 -2
- package/src/services/update-plan.ts +67 -8
- package/src/tui-runtime.tsx +142 -0
- package/src/tui.tsx +7 -124
- package/src/types/index.ts +7 -0
- package/src/ui/adapters/marketAdapter.ts +366 -0
- package/src/ui/adapters/pluginsAdapter.ts +45 -3
- package/src/ui/components/layout/ScreenLayout.tsx +43 -17
- package/src/ui/components/modals/ConfirmModal.tsx +145 -8
- package/src/ui/components/modals/HelpOverlay.tsx +10 -3
- package/src/ui/components/modals/ModalContainer.tsx +47 -5
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
- package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
- package/src/ui/components/primitives/SelectableRow.tsx +14 -1
- package/src/ui/components/primitives/ToneLine.tsx +25 -0
- package/src/ui/components/primitives/index.ts +5 -0
- package/src/ui/keymap.ts +21 -5
- package/src/ui/market/MarketApp.tsx +59 -0
- package/src/ui/market/MarketplaceScreen.tsx +280 -0
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
- package/src/ui/market/MarketplacesScreen.tsx +264 -0
- package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
- package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
- package/src/ui/market/marketKeymap.ts +243 -0
- package/src/ui/market/useMarketState.ts +694 -0
- package/src/ui/renderers/pluginRenderers.tsx +31 -3
- package/src/ui/renderers/pluginStatusText.ts +6 -0
- package/src/ui/screens/PluginsScreen.tsx +49 -5
- package/src/ui/screens/SkillsScreen.tsx +2 -2
- package/src/ui/state/AppContext.tsx +14 -2
- package/src/ui/state/types.ts +9 -2
- package/src/utils/config-dir.ts +29 -9
- package/src/utils/display-path.ts +33 -0
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
FailureRow,
|
|
16
16
|
PluginContentsEntry,
|
|
17
17
|
} from "../state/types.js";
|
|
18
|
+
import { truncate } from "../text-width.js";
|
|
18
19
|
import type { BadgeTone } from "../theme.js";
|
|
19
20
|
import {
|
|
20
21
|
failureReport,
|
|
@@ -215,6 +216,12 @@ function pushPluginItems(
|
|
|
215
216
|
}
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The widest company name a group header shows. The header keeps its badge
|
|
221
|
+
* whole and clips the title instead, so a long name is clipped here first.
|
|
222
|
+
*/
|
|
223
|
+
const COMPANY_BADGE_COLUMNS = 24;
|
|
224
|
+
|
|
218
225
|
/**
|
|
219
226
|
* Derives tone and badge for a category item based on marketplace identity.
|
|
220
227
|
*/
|
|
@@ -240,6 +247,18 @@ function categoryStyling(
|
|
|
240
247
|
if (mp.official) {
|
|
241
248
|
return { tone: "info", badge: "★ Official" };
|
|
242
249
|
}
|
|
250
|
+
// Before the MadAppGang byline: a company marketplace may itself live under
|
|
251
|
+
// MadAppGang, and being this machine's company marketplace is the stronger
|
|
252
|
+
// fact.
|
|
253
|
+
if (mp.company) {
|
|
254
|
+
const company = companyName(mp);
|
|
255
|
+
return {
|
|
256
|
+
tone: "info",
|
|
257
|
+
badge: company
|
|
258
|
+
? `by ${truncate(company, COMPANY_BADGE_COLUMNS)}`
|
|
259
|
+
: "◆ Company",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
243
262
|
if (isMadAppGang(mp)) {
|
|
244
263
|
return { tone: "byline", badge: "by MadAppGang" };
|
|
245
264
|
}
|
|
@@ -256,6 +275,17 @@ function isMadAppGang(mp: Marketplace): boolean {
|
|
|
256
275
|
return (mp.source.repo ?? "").toLowerCase().startsWith("madappgang/");
|
|
257
276
|
}
|
|
258
277
|
|
|
278
|
+
/**
|
|
279
|
+
* The name a company marketplace is shown by: the manifest's `owner.name`,
|
|
280
|
+
* else the GitHub owner of its repository.
|
|
281
|
+
*/
|
|
282
|
+
export function companyName(mp: Marketplace): string | undefined {
|
|
283
|
+
if (mp.ownerName) return mp.ownerName;
|
|
284
|
+
const repo = mp.source.repo ?? "";
|
|
285
|
+
const slash = repo.indexOf("/");
|
|
286
|
+
return slash > 0 ? repo.slice(0, slash) : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
259
289
|
// ─── "It failed, and here is why" ────────────────────────────────────────────
|
|
260
290
|
|
|
261
291
|
/** A version token — `v2.0.3`, `8.1.3`, `1.0.0-beta.2`. */
|
|
@@ -557,9 +587,16 @@ export function buildPluginBrowserItems({
|
|
|
557
587
|
pluginsByMarketplace.set(plugin.marketplace, existing);
|
|
558
588
|
}
|
|
559
589
|
|
|
560
|
-
// Sort marketplaces:
|
|
590
|
+
// Sort marketplaces: this machine's company marketplaces first, then our own
|
|
591
|
+
// (magus*), deprecated last, others between.
|
|
561
592
|
const rank = (m: Marketplace) =>
|
|
562
|
-
m.
|
|
593
|
+
m.company
|
|
594
|
+
? 0
|
|
595
|
+
: m.owned
|
|
596
|
+
? 1
|
|
597
|
+
: m.deprecated || m.name === "claude-code-plugins"
|
|
598
|
+
? 3
|
|
599
|
+
: 2;
|
|
563
600
|
const sortedMarketplaces = [...marketplaces].sort(
|
|
564
601
|
(a, b) => rank(a) - rank(b),
|
|
565
602
|
);
|
|
@@ -569,7 +606,12 @@ export function buildPluginBrowserItems({
|
|
|
569
606
|
for (const marketplace of sortedMarketplaces) {
|
|
570
607
|
const marketplacePlugins = pluginsByMarketplace.get(marketplace.name) || [];
|
|
571
608
|
const isCollapsed = collapsedMarketplaces.has(marketplace.name);
|
|
572
|
-
|
|
609
|
+
// A company marketplace is registered by definition, so it keeps its
|
|
610
|
+
// badge while it holds no plugins yet.
|
|
611
|
+
const isEnabled =
|
|
612
|
+
marketplacePlugins.length > 0 ||
|
|
613
|
+
!!marketplace.official ||
|
|
614
|
+
!!marketplace.company;
|
|
573
615
|
const hasPlugins = marketplacePlugins.length > 0;
|
|
574
616
|
|
|
575
617
|
// Special handling: split claude-plugins-official into two sub-sections
|
|
@@ -5,8 +5,10 @@ import { useKeyLayer } from "../../input/KeyRouter.js";
|
|
|
5
5
|
import { isScrollKey } from "../../input/key-dispatch.js";
|
|
6
6
|
import {
|
|
7
7
|
type FooterOptions,
|
|
8
|
+
type KeyBinding,
|
|
8
9
|
SCROLL_BINDINGS,
|
|
9
10
|
type ScrollHandlers,
|
|
11
|
+
fitHints,
|
|
10
12
|
footerHints,
|
|
11
13
|
runBindings,
|
|
12
14
|
} from "../../keymap.js";
|
|
@@ -87,8 +89,20 @@ interface ScreenLayoutProps<S extends Screen> {
|
|
|
87
89
|
title: string;
|
|
88
90
|
/** Optional subtitle shown to the right of title */
|
|
89
91
|
subtitle?: string;
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Current screen: highlights its tab and picks its footer bindings. Left
|
|
94
|
+
* out by an app with no tabs, which passes `keymap` instead.
|
|
95
|
+
*/
|
|
96
|
+
currentScreen?: S;
|
|
97
|
+
/**
|
|
98
|
+
* The footer of an app with no tabs (the market screens): these bindings,
|
|
99
|
+
* fitted to the width by the same rule as a tab's footer, with `pinned`
|
|
100
|
+
* kept whenever it fits. Ignored when `currentScreen` is given.
|
|
101
|
+
*/
|
|
102
|
+
keymap?: {
|
|
103
|
+
bindings: readonly KeyBinding[];
|
|
104
|
+
pinned: readonly KeyBinding[];
|
|
105
|
+
};
|
|
92
106
|
/** Search bar configuration (for screens with search) */
|
|
93
107
|
search?: {
|
|
94
108
|
/** Is search currently active */
|
|
@@ -134,6 +148,7 @@ export function ScreenLayout<S extends Screen>({
|
|
|
134
148
|
title,
|
|
135
149
|
subtitle,
|
|
136
150
|
currentScreen,
|
|
151
|
+
keymap,
|
|
137
152
|
search,
|
|
138
153
|
statusLine,
|
|
139
154
|
notice,
|
|
@@ -145,10 +160,13 @@ export function ScreenLayout<S extends Screen>({
|
|
|
145
160
|
const dimensions = useDimensions();
|
|
146
161
|
|
|
147
162
|
const hasSearchBar = search && (search.isActive || search.query);
|
|
163
|
+
const hasTabs = currentScreen !== undefined;
|
|
148
164
|
|
|
149
|
-
// Fixed chrome: top line + tabs + line + header + separator + footer = 6
|
|
165
|
+
// Fixed chrome: top line + tabs + line + header + separator + footer = 6;
|
|
166
|
+
// without tabs, the tab row and the line under it go, leaving 4.
|
|
150
167
|
// Search bar adds 1 when active; the notice adds 1 while present.
|
|
151
|
-
const fixedHeight =
|
|
168
|
+
const fixedHeight =
|
|
169
|
+
(hasTabs ? 6 : 4) + (hasSearchBar ? 1 : 0) + (notice ? 1 : 0);
|
|
152
170
|
const panelHeight = Math.max(5, dimensions.contentHeight - fixedHeight);
|
|
153
171
|
const lineWidth = Math.max(10, dimensions.terminalWidth - 4);
|
|
154
172
|
|
|
@@ -196,11 +214,17 @@ export function ScreenLayout<S extends Screen>({
|
|
|
196
214
|
|
|
197
215
|
// Fitted to the row, highest priority first, so no hint is ever cut
|
|
198
216
|
// mid-word at the terminal's edge — see `footerHints`.
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
search?.isActive ? { owner: "input" } : footer,
|
|
217
|
+
const footerWidthCells = Math.max(
|
|
218
|
+
0,
|
|
219
|
+
dimensions.terminalWidth - APP_SIDE_PADDING - FOOTER_PADDING,
|
|
203
220
|
);
|
|
221
|
+
const footerRow = hasTabs
|
|
222
|
+
? footerHints(
|
|
223
|
+
currentScreen,
|
|
224
|
+
footerWidthCells,
|
|
225
|
+
search?.isActive ? { owner: "input" } : footer,
|
|
226
|
+
)
|
|
227
|
+
: fitHints(keymap?.bindings ?? [], keymap?.pinned ?? [], footerWidthCells);
|
|
204
228
|
|
|
205
229
|
useEffect(() => {
|
|
206
230
|
if (detailKey !== undefined) detailScrollRef.current?.scrollTo(0);
|
|
@@ -213,15 +237,17 @@ export function ScreenLayout<S extends Screen>({
|
|
|
213
237
|
<text fg={theme.colors.dim}>{"─".repeat(lineWidth)}</text>
|
|
214
238
|
</box>
|
|
215
239
|
|
|
216
|
-
{/* Tab bar */}
|
|
217
|
-
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
<
|
|
224
|
-
|
|
240
|
+
{/* Tab bar, and the line below it — only in an app with tabs */}
|
|
241
|
+
{hasTabs && (
|
|
242
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
243
|
+
<TabBar currentScreen={currentScreen} />
|
|
244
|
+
</box>
|
|
245
|
+
)}
|
|
246
|
+
{hasTabs && (
|
|
247
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
248
|
+
<text fg={theme.colors.dim}>{"─".repeat(lineWidth)}</text>
|
|
249
|
+
</box>
|
|
250
|
+
)}
|
|
225
251
|
|
|
226
252
|
{/* Header — title on left, subtitle/status on right */}
|
|
227
253
|
<box
|
|
@@ -1,15 +1,128 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
import { useDimensions } from "../../state/DimensionsContext.js";
|
|
4
|
+
import { displayWidth } from "../../text-width.js";
|
|
2
5
|
import { theme } from "../../theme.js";
|
|
3
6
|
|
|
7
|
+
/** The dialog's width when it carries a plain question. */
|
|
8
|
+
const QUESTION_WIDTH = 60;
|
|
9
|
+
/** The widest a dialog listing a plan grows, so each line reads as one row. */
|
|
10
|
+
const PLAN_MAX_WIDTH = 100;
|
|
11
|
+
/** Cells kept free on each side of the dialog, and rows above and below it. */
|
|
12
|
+
const SCREEN_MARGIN = 2;
|
|
13
|
+
/** The narrowest the dialog gets, however small the terminal. */
|
|
14
|
+
const MIN_WIDTH = 20;
|
|
15
|
+
/** What each plan line starts with. */
|
|
16
|
+
const BULLET = "• ";
|
|
17
|
+
/** Border and padding, left plus right. */
|
|
18
|
+
const SIDE_CELLS = 2 + 2 * 3;
|
|
19
|
+
/**
|
|
20
|
+
* Rows that are not the message or the plan: border 2, padding 2, the title
|
|
21
|
+
* and its gap 2, the gap under the message 1, the gap above the keys and the
|
|
22
|
+
* keys line 2.
|
|
23
|
+
*/
|
|
24
|
+
const CHROME_ROWS = 9;
|
|
25
|
+
|
|
26
|
+
export interface ConfirmModalSize {
|
|
27
|
+
width: number;
|
|
28
|
+
/**
|
|
29
|
+
* Rows the plan gets when it does not fit and scrolls — also one page for
|
|
30
|
+
* PgUp / PgDn. Null when the whole plan fits.
|
|
31
|
+
*/
|
|
32
|
+
planHeight: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Rows `text` takes wrapped at `width` cells. */
|
|
36
|
+
function wrappedRows(text: string, width: number): number {
|
|
37
|
+
return text
|
|
38
|
+
.split("\n")
|
|
39
|
+
.reduce(
|
|
40
|
+
(n, line) =>
|
|
41
|
+
n + Math.max(1, Math.ceil(displayWidth(line) / Math.max(1, width))),
|
|
42
|
+
0,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The dialog's box, from the terminal's size and what it shows. Never wider
|
|
48
|
+
* or taller than the terminal: a plan that would not fit gets a fixed height
|
|
49
|
+
* and scrolls, so every line of what confirming does can be read before it is
|
|
50
|
+
* confirmed, and the keys line always stays on screen.
|
|
51
|
+
*/
|
|
52
|
+
export function confirmModalSize(
|
|
53
|
+
terminalWidth: number,
|
|
54
|
+
terminalHeight: number,
|
|
55
|
+
message: string,
|
|
56
|
+
details: readonly string[] | undefined,
|
|
57
|
+
): ConfirmModalSize {
|
|
58
|
+
// The minimum width yields to the terminal: below it, the dialog takes
|
|
59
|
+
// every column there is rather than run past the edge.
|
|
60
|
+
const room = Math.min(
|
|
61
|
+
Math.max(1, terminalWidth),
|
|
62
|
+
Math.max(MIN_WIDTH, terminalWidth - 2 * SCREEN_MARGIN),
|
|
63
|
+
);
|
|
64
|
+
const hasDetails = details !== undefined && details.length > 0;
|
|
65
|
+
const width = Math.min(room, hasDetails ? PLAN_MAX_WIDTH : QUESTION_WIDTH);
|
|
66
|
+
if (!hasDetails) return { width, planHeight: null };
|
|
67
|
+
const inner = width - SIDE_CELLS;
|
|
68
|
+
const lineWidth = inner - BULLET.length;
|
|
69
|
+
const planRows = details.reduce(
|
|
70
|
+
(n, line) => n + wrappedRows(line, lineWidth),
|
|
71
|
+
0,
|
|
72
|
+
);
|
|
73
|
+
const height =
|
|
74
|
+
terminalHeight >= 8
|
|
75
|
+
? terminalHeight - SCREEN_MARGIN
|
|
76
|
+
: Math.max(1, terminalHeight);
|
|
77
|
+
const left = height - CHROME_ROWS - wrappedRows(message, inner);
|
|
78
|
+
return { width, planHeight: planRows <= left ? null : Math.max(1, left) };
|
|
79
|
+
}
|
|
80
|
+
|
|
4
81
|
interface ConfirmModalProps {
|
|
5
82
|
/** Modal title */
|
|
6
83
|
title: string;
|
|
7
84
|
/** Modal message */
|
|
8
85
|
message: string;
|
|
86
|
+
/**
|
|
87
|
+
* What confirming will do, one line each — a plan. Drawn as a bulleted
|
|
88
|
+
* list under the message, in a dialog as wide as the terminal allows, so a
|
|
89
|
+
* line wraps only when it must and never runs into the next one. A plan
|
|
90
|
+
* taller than the terminal scrolls.
|
|
91
|
+
*/
|
|
92
|
+
details?: readonly string[];
|
|
93
|
+
/** The plan's scroll box, scrolled by ModalContainer's keys. */
|
|
94
|
+
scrollRef?: React.RefObject<ScrollBoxRenderable | null>;
|
|
9
95
|
}
|
|
10
96
|
|
|
11
|
-
/** Draws the question. Its keys (y / Enter, n / Esc / q) are ModalContainer's. */
|
|
12
|
-
export function ConfirmModal({
|
|
97
|
+
/** Draws the question. Its keys (y / Enter, n / Esc / q, ↑↓ PgUp PgDn) are ModalContainer's. */
|
|
98
|
+
export function ConfirmModal({
|
|
99
|
+
title,
|
|
100
|
+
message,
|
|
101
|
+
details,
|
|
102
|
+
scrollRef,
|
|
103
|
+
}: ConfirmModalProps) {
|
|
104
|
+
const { terminalWidth, terminalHeight } = useDimensions();
|
|
105
|
+
const size = confirmModalSize(
|
|
106
|
+
terminalWidth,
|
|
107
|
+
terminalHeight,
|
|
108
|
+
message,
|
|
109
|
+
details,
|
|
110
|
+
);
|
|
111
|
+
const plan =
|
|
112
|
+
details !== undefined && details.length > 0 ? (
|
|
113
|
+
<box flexDirection="column">
|
|
114
|
+
{details.map((line, i) => (
|
|
115
|
+
// Plan lines may repeat word for word; their order is their identity.
|
|
116
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: positional plan lines
|
|
117
|
+
<box key={i} flexDirection="row">
|
|
118
|
+
<text fg={theme.colors.muted}>{BULLET}</text>
|
|
119
|
+
<box flexGrow={1} flexShrink={1}>
|
|
120
|
+
<text fg={theme.colors.text}>{line}</text>
|
|
121
|
+
</box>
|
|
122
|
+
</box>
|
|
123
|
+
))}
|
|
124
|
+
</box>
|
|
125
|
+
) : null;
|
|
13
126
|
return (
|
|
14
127
|
<box
|
|
15
128
|
flexDirection="column"
|
|
@@ -21,18 +134,42 @@ export function ConfirmModal({ title, message }: ConfirmModalProps) {
|
|
|
21
134
|
paddingRight={3}
|
|
22
135
|
paddingTop={1}
|
|
23
136
|
paddingBottom={1}
|
|
24
|
-
width={
|
|
137
|
+
width={size.width}
|
|
25
138
|
>
|
|
26
|
-
<box marginBottom={1}>
|
|
139
|
+
<box marginBottom={1} flexShrink={0}>
|
|
27
140
|
<text fg={theme.colors.text}>
|
|
28
141
|
<strong>{title}</strong>
|
|
29
142
|
</text>
|
|
30
143
|
</box>
|
|
31
|
-
<box marginBottom={1}>
|
|
144
|
+
<box marginBottom={1} flexShrink={0}>
|
|
32
145
|
<text fg={theme.colors.muted}>{message}</text>
|
|
33
146
|
</box>
|
|
34
|
-
|
|
35
|
-
<
|
|
147
|
+
{plan && size.planHeight !== null ? (
|
|
148
|
+
<scrollbox
|
|
149
|
+
ref={scrollRef}
|
|
150
|
+
height={size.planHeight}
|
|
151
|
+
flexShrink={0}
|
|
152
|
+
scrollY={true}
|
|
153
|
+
scrollX={false}
|
|
154
|
+
verticalScrollbarOptions={{
|
|
155
|
+
showArrows: false,
|
|
156
|
+
trackOptions: { foregroundColor: theme.colors.muted },
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{plan}
|
|
160
|
+
</scrollbox>
|
|
161
|
+
) : (
|
|
162
|
+
plan
|
|
163
|
+
)}
|
|
164
|
+
<box marginTop={1} flexDirection="row" flexShrink={0}>
|
|
165
|
+
{size.planHeight !== null ? (
|
|
166
|
+
<>
|
|
167
|
+
<text fg={theme.colors.text}>↑↓ PgUp PgDn</text>
|
|
168
|
+
<text fg={theme.colors.muted}> read the whole plan • </text>
|
|
169
|
+
</>
|
|
170
|
+
) : (
|
|
171
|
+
<text fg={theme.colors.muted}>Press </text>
|
|
172
|
+
)}
|
|
36
173
|
<text fg={theme.colors.text}>Y</text>
|
|
37
174
|
<text fg={theme.colors.muted}> to confirm • </text>
|
|
38
175
|
<text fg={theme.colors.text}>N</text>
|
|
@@ -86,14 +86,21 @@ function keyColumnWidth(sections: readonly HelpSection[]): number {
|
|
|
86
86
|
*/
|
|
87
87
|
export function HelpOverlay({
|
|
88
88
|
screen,
|
|
89
|
+
sections: given,
|
|
89
90
|
scrollRef,
|
|
90
91
|
}: {
|
|
91
|
-
screen
|
|
92
|
+
/** The main TUI's current screen; its section is listed first. */
|
|
93
|
+
screen?: Screen;
|
|
94
|
+
/**
|
|
95
|
+
* An app with its own keymap passes its sections, already in order; a
|
|
96
|
+
* title that should read "(this screen)" says so itself.
|
|
97
|
+
*/
|
|
98
|
+
sections?: readonly HelpSection[];
|
|
92
99
|
scrollRef: React.RefObject<ScrollBoxRenderable | null>;
|
|
93
100
|
}) {
|
|
94
101
|
const { terminalWidth, terminalHeight } = useDimensions();
|
|
95
102
|
const size = helpOverlaySize(terminalWidth, terminalHeight);
|
|
96
|
-
const sections = helpSections(screen);
|
|
103
|
+
const sections = given ?? (screen ? helpSections(screen) : []);
|
|
97
104
|
const keyWidth = keyColumnWidth(sections);
|
|
98
105
|
const textRoom = Math.max(1, size.textWidth - keyWidth - 4);
|
|
99
106
|
|
|
@@ -116,7 +123,7 @@ export function HelpOverlay({
|
|
|
116
123
|
);
|
|
117
124
|
}
|
|
118
125
|
const title =
|
|
119
|
-
section.screen === screen
|
|
126
|
+
screen !== undefined && section.screen === screen
|
|
120
127
|
? `${section.title} (this screen)`
|
|
121
128
|
: section.title;
|
|
122
129
|
rows.push(
|
|
@@ -4,7 +4,7 @@ import { useKeyLayer, usePasteLayer } from "../../input/KeyRouter.js";
|
|
|
4
4
|
import { type KeyPress, isScrollKey } from "../../input/key-dispatch.js";
|
|
5
5
|
import { useApp, useModal } from "../../state/AppContext.js";
|
|
6
6
|
import { useDimensions } from "../../state/DimensionsContext.js";
|
|
7
|
-
import { ConfirmModal } from "./ConfirmModal.js";
|
|
7
|
+
import { ConfirmModal, confirmModalSize } from "./ConfirmModal.js";
|
|
8
8
|
import { FailureListModal } from "./FailureListModal.js";
|
|
9
9
|
import { HelpOverlay, helpOverlaySize } from "./HelpOverlay.js";
|
|
10
10
|
import { InputModal } from "./InputModal.js";
|
|
@@ -30,7 +30,8 @@ const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
|
|
|
30
30
|
* stopped, so the input modal's focused `<input>` still receives it — the
|
|
31
31
|
* arrangement the `input` case below already relies on for keys.
|
|
32
32
|
*
|
|
33
|
-
* - confirm: y / Enter confirm; n / Esc / q cancel
|
|
33
|
+
* - confirm: y / Enter confirm; n / Esc / q cancel; a plan too tall for the
|
|
34
|
+
* terminal scrolls with ↑↓ (j/k) and PgUp / PgDn.
|
|
34
35
|
* - select: ↑↓ (j/k) move, Enter picks, Esc / q cancel (a no-op for a
|
|
35
36
|
* question that must be answered).
|
|
36
37
|
* - input: Esc cancels; everything else goes to the focused <input>, which
|
|
@@ -51,6 +52,7 @@ export function ModalContainer() {
|
|
|
51
52
|
// Track select modal index here (lifted from SelectModal)
|
|
52
53
|
const [selectIndex, setSelectIndex] = useState(0);
|
|
53
54
|
const helpScrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
55
|
+
const confirmScrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
54
56
|
|
|
55
57
|
// Reset select index when modal changes
|
|
56
58
|
const modalRef = React.useRef(modal);
|
|
@@ -89,6 +91,31 @@ export function ModalContainer() {
|
|
|
89
91
|
}
|
|
90
92
|
};
|
|
91
93
|
|
|
94
|
+
/** Scroll a confirm dialog's plan when it scrolls. True when the key did. */
|
|
95
|
+
const scrollConfirm = (
|
|
96
|
+
key: KeyPress,
|
|
97
|
+
message: string,
|
|
98
|
+
details: readonly string[] | undefined,
|
|
99
|
+
): boolean => {
|
|
100
|
+
const scroll = confirmScrollRef.current;
|
|
101
|
+
const { planHeight } = confirmModalSize(
|
|
102
|
+
dimensions.terminalWidth,
|
|
103
|
+
dimensions.terminalHeight,
|
|
104
|
+
message,
|
|
105
|
+
details,
|
|
106
|
+
);
|
|
107
|
+
if (!scroll || planHeight === null) return false;
|
|
108
|
+
const page = Math.max(1, planHeight - 1);
|
|
109
|
+
if (key.name === "pageup") scroll.scrollBy(-page);
|
|
110
|
+
else if (key.name === "pagedown") scroll.scrollBy(page);
|
|
111
|
+
else if (isPlain(key) && (key.name === "up" || key.name === "k")) {
|
|
112
|
+
scroll.scrollBy(-1);
|
|
113
|
+
} else if (isPlain(key) && (key.name === "down" || key.name === "j")) {
|
|
114
|
+
scroll.scrollBy(1);
|
|
115
|
+
} else return false;
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
|
|
92
119
|
usePasteLayer("modal", modal !== null, () => true);
|
|
93
120
|
|
|
94
121
|
useKeyLayer("modal", modal !== null, (key) => {
|
|
@@ -109,7 +136,9 @@ export function ModalContainer() {
|
|
|
109
136
|
|
|
110
137
|
case "confirm":
|
|
111
138
|
if (key.name === "y" || isEnter(key)) modal.onConfirm();
|
|
112
|
-
else if (
|
|
139
|
+
else if (scrollConfirm(key, modal.message, modal.details)) {
|
|
140
|
+
// A long plan scrolled; nothing else happens on that key.
|
|
141
|
+
} else if (
|
|
113
142
|
key.name === "n" ||
|
|
114
143
|
key.name === "q" ||
|
|
115
144
|
key.name === "escape"
|
|
@@ -149,7 +178,14 @@ export function ModalContainer() {
|
|
|
149
178
|
const renderModal = () => {
|
|
150
179
|
switch (modal.type) {
|
|
151
180
|
case "confirm":
|
|
152
|
-
return
|
|
181
|
+
return (
|
|
182
|
+
<ConfirmModal
|
|
183
|
+
title={modal.title}
|
|
184
|
+
message={modal.message}
|
|
185
|
+
details={modal.details}
|
|
186
|
+
scrollRef={confirmScrollRef}
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
153
189
|
|
|
154
190
|
case "input":
|
|
155
191
|
return (
|
|
@@ -191,7 +227,13 @@ export function ModalContainer() {
|
|
|
191
227
|
return <LoadingModal message={modal.message} />;
|
|
192
228
|
|
|
193
229
|
case "help":
|
|
194
|
-
return
|
|
230
|
+
return (
|
|
231
|
+
<HelpOverlay
|
|
232
|
+
screen={modal.screen}
|
|
233
|
+
sections={modal.sections}
|
|
234
|
+
scrollRef={helpScrollRef}
|
|
235
|
+
/>
|
|
236
|
+
);
|
|
195
237
|
|
|
196
238
|
default:
|
|
197
239
|
return null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { displayWidth, truncate } from "../../text-width.js";
|
|
2
3
|
import { type BadgeTone, theme } from "../../theme.js";
|
|
3
4
|
import { SelectableRow, rowLead } from "./SelectableRow.js";
|
|
4
5
|
|
|
@@ -17,6 +18,12 @@ interface ListCategoryRowProps {
|
|
|
17
18
|
* that is broken rather than as a section title.
|
|
18
19
|
*/
|
|
19
20
|
expandable?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Columns the row may use. When given, the title and count are cut with
|
|
23
|
+
* "…" to fit one line (the badge goes first), so a narrow panel never
|
|
24
|
+
* wraps the heading onto a second row.
|
|
25
|
+
*/
|
|
26
|
+
width?: number;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
export function ListCategoryRow({
|
|
@@ -27,10 +34,21 @@ export function ListCategoryRow({
|
|
|
27
34
|
tone = "neutral",
|
|
28
35
|
selected,
|
|
29
36
|
expandable = true,
|
|
37
|
+
width,
|
|
30
38
|
}: ListCategoryRowProps) {
|
|
31
39
|
const colors = theme.badge[tone];
|
|
32
40
|
const marker = expandable ? (expanded ? "▼ " : "▶ ") : "";
|
|
33
|
-
const
|
|
41
|
+
const full = `${marker}${title}${count !== undefined ? ` (${count})` : ""}`;
|
|
42
|
+
// The lead cell and the trailing space inside the chip take two columns.
|
|
43
|
+
const label =
|
|
44
|
+
width === undefined ? full : truncate(full, Math.max(1, width - 2));
|
|
45
|
+
const shownBadge =
|
|
46
|
+
width === undefined ||
|
|
47
|
+
(badge !== undefined &&
|
|
48
|
+
label === full &&
|
|
49
|
+
displayWidth(full) + 3 + displayWidth(badge) <= width)
|
|
50
|
+
? badge
|
|
51
|
+
: undefined;
|
|
34
52
|
|
|
35
53
|
// Both forms start with the row's lead cell — a blank inside the chip, the
|
|
36
54
|
// selection mark on the cursor — so the title never moves as it is selected.
|
|
@@ -50,7 +68,9 @@ export function ListCategoryRow({
|
|
|
50
68
|
{label}{" "}
|
|
51
69
|
</strong>
|
|
52
70
|
)}
|
|
53
|
-
{!selected &&
|
|
71
|
+
{!selected && shownBadge ? (
|
|
72
|
+
<span fg={colors.badgeFg}> {shownBadge}</span>
|
|
73
|
+
) : null}
|
|
54
74
|
</SelectableRow>
|
|
55
75
|
);
|
|
56
76
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { theme } from "../../theme.js";
|
|
2
|
+
|
|
3
|
+
/** The scopes as magus names them to the user. */
|
|
4
|
+
export type ScopeWord = "USER" | "PROJECT" | "LOCAL";
|
|
5
|
+
|
|
6
|
+
interface ScopeBadgeProps {
|
|
7
|
+
scope: ScopeWord;
|
|
8
|
+
/** True inside a selected row, where the selection owns the colour channel. */
|
|
9
|
+
selected?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A scope as a word chip — ` USER `, ` PROJECT `, ` LOCAL ` — the badge the
|
|
14
|
+
* CLI reports print, for a row that says WHERE something is rather than
|
|
15
|
+
* whether it is on (that is `ScopeSquares`). A neutral fill, because a scope
|
|
16
|
+
* is a place, not a state. On a selected row the chip gives up its fill and
|
|
17
|
+
* keeps the word, like every other tone there. Inside a `<text>`.
|
|
18
|
+
*/
|
|
19
|
+
export function ScopeBadge({ scope, selected = false }: ScopeBadgeProps) {
|
|
20
|
+
if (selected) {
|
|
21
|
+
return <span fg={theme.selection.fg}>{` ${scope} `}</span>;
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<span bg={theme.badge.neutral.bg} fg={theme.badge.neutral.fg}>
|
|
25
|
+
{` ${scope} `}
|
|
26
|
+
</span>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { MetaText } from "./MetaText.js";
|
|
2
|
+
import { ScopeBadge, type ScopeWord } from "./ScopeBadge.js";
|
|
3
|
+
import { ToneLine } from "./ToneLine.js";
|
|
4
|
+
|
|
5
|
+
interface ScopeVersionLineProps {
|
|
6
|
+
scope: ScopeWord;
|
|
7
|
+
/** The version installed at this scope; null where it is not installed. */
|
|
8
|
+
version: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One scope of an install, as a detail line: the scope's badge, then the
|
|
13
|
+
* installed version in `success`, or "not installed" muted. The words carry
|
|
14
|
+
* the state, so it reads the same without colour.
|
|
15
|
+
*/
|
|
16
|
+
export function ScopeVersionLine({ scope, version }: ScopeVersionLineProps) {
|
|
17
|
+
return (
|
|
18
|
+
<ToneLine>
|
|
19
|
+
<ScopeBadge scope={scope} />{" "}
|
|
20
|
+
{version !== null ? (
|
|
21
|
+
<MetaText text={version} tone="success" />
|
|
22
|
+
) : (
|
|
23
|
+
<MetaText text="not installed" />
|
|
24
|
+
)}
|
|
25
|
+
</ToneLine>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -19,6 +19,12 @@ export function rowLead(selected: boolean): string {
|
|
|
19
19
|
|
|
20
20
|
interface SelectableRowProps {
|
|
21
21
|
selected: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* `item` (the default) is a thing in the list. `action` is a row that does
|
|
24
|
+
* something when opened — "+ Create…" — and reads in the accent until the
|
|
25
|
+
* selection takes the colour channel over.
|
|
26
|
+
*/
|
|
27
|
+
variant?: "item" | "action";
|
|
22
28
|
/**
|
|
23
29
|
* Leading cells before the children. The first of them is the lead cell,
|
|
24
30
|
* which carries `SELECTED_MARK` on the selected row. At 0 the children own
|
|
@@ -30,6 +36,7 @@ interface SelectableRowProps {
|
|
|
30
36
|
|
|
31
37
|
export function SelectableRow({
|
|
32
38
|
selected,
|
|
39
|
+
variant = "item",
|
|
33
40
|
indent = 0,
|
|
34
41
|
children,
|
|
35
42
|
}: SelectableRowProps) {
|
|
@@ -44,7 +51,13 @@ export function SelectableRow({
|
|
|
44
51
|
// truecolor white, so every unselected row here rendered white-on-cream
|
|
45
52
|
// and vanished on a light theme. Same root cause as the original bug,
|
|
46
53
|
// reached by omission rather than by a wrong literal.
|
|
47
|
-
fg={
|
|
54
|
+
fg={
|
|
55
|
+
selected
|
|
56
|
+
? theme.selection.fg
|
|
57
|
+
: variant === "action"
|
|
58
|
+
? theme.colors.accent
|
|
59
|
+
: theme.colors.text
|
|
60
|
+
}
|
|
48
61
|
>
|
|
49
62
|
{lead}
|
|
50
63
|
{children}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { theme } from "../../theme.js";
|
|
3
|
+
|
|
4
|
+
/** A line's tone: plain text, or one of the meta tones by what it says. */
|
|
5
|
+
export type LineTone = "text" | keyof typeof theme.meta;
|
|
6
|
+
|
|
7
|
+
interface ToneLineProps {
|
|
8
|
+
/** Plain `text` by default; `muted` for an aside, the rest by meaning. */
|
|
9
|
+
tone?: LineTone;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One line of words in a detail panel, a notice or an empty list, coloured by
|
|
15
|
+
* what it says rather than by where it sits: a note is `muted`, a warning
|
|
16
|
+
* `warning`. Spans inside it (`MetaText`, `ScopeBadge`) keep their own tones.
|
|
17
|
+
* Not for list rows: a selectable row is `SelectableRow`.
|
|
18
|
+
*/
|
|
19
|
+
export function ToneLine({ tone = "text", children }: ToneLineProps) {
|
|
20
|
+
return (
|
|
21
|
+
<text fg={tone === "text" ? theme.colors.text : theme.meta[tone]}>
|
|
22
|
+
{children}
|
|
23
|
+
</text>
|
|
24
|
+
);
|
|
25
|
+
}
|