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.
Files changed (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. 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: our own (magus*) first, deprecated last, others between.
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.owned ? 0 : m.deprecated || m.name === "claude-code-plugins" ? 2 : 1;
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
- const isEnabled = marketplacePlugins.length > 0 || !!marketplace.official;
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
- /** Current screen: highlights its tab and picks its footer bindings. */
91
- currentScreen: S;
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 = 6 + (hasSearchBar ? 1 : 0) + (notice ? 1 : 0);
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 footerRow = footerHints(
200
- currentScreen,
201
- Math.max(0, dimensions.terminalWidth - APP_SIDE_PADDING - FOOTER_PADDING),
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
- <box height={1} paddingLeft={1} paddingRight={1}>
218
- <TabBar currentScreen={currentScreen} />
219
- </box>
220
-
221
- {/* Line below tabs */}
222
- <box height={1} paddingLeft={1} paddingRight={1}>
223
- <text fg={theme.colors.dim}>{"─".repeat(lineWidth)}</text>
224
- </box>
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 React from "react";
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({ title, message }: ConfirmModalProps) {
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={60}
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
- <box marginTop={1} flexDirection="row">
35
- <text fg={theme.colors.muted}>Press </text>
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: 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 <ConfirmModal title={modal.title} message={modal.message} />;
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 <HelpOverlay screen={modal.screen} scrollRef={helpScrollRef} />;
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 label = `${marker}${title}${count !== undefined ? ` (${count})` : ""}`;
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 && badge ? <span fg={colors.badgeFg}> {badge}</span> : null}
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={selected ? theme.selection.fg : theme.colors.text}
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
+ }