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.
Files changed (127) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -3,21 +3,53 @@ import type { CliTool } from "../../data/cli-tools.js";
3
3
  import {
4
4
  type InstallMethod,
5
5
  cliToolUninstall,
6
+ uninstallBlocker,
6
7
  } from "../../services/cli-tool-commands.js";
7
8
  import { formatCommand } from "../../utils/command-utils.js";
8
9
  import {
10
+ type CliToolEnterAction,
9
11
  type CliToolRow,
12
+ cliToolEnterAction,
10
13
  cliToolStatusText,
11
14
  displayVersion,
12
15
  isInstalled,
13
16
  } from "../adapters/cliToolsAdapter.js";
17
+ import { rowLead } from "../components/primitives/index.js";
14
18
  import { type UiColor, theme } from "../theme.js";
15
19
  import type { StatusTone } from "./pluginStatusText.js";
20
+ import { wrapText } from "./styleRenderers.js";
16
21
 
17
22
  // ─── Helpers ────────────────────────────────────────────────────────────────
18
23
 
24
+ /** The Enter chip's background, by what Enter does. */
25
+ const ENTER_CHIP_BG: Record<CliToolEnterAction["kind"], UiColor> = {
26
+ install: theme.colors.success,
27
+ update: theme.colors.warning,
28
+ "update-anyway": theme.colors.muted,
29
+ reinstall: theme.colors.muted,
30
+ };
31
+
32
+ /** "Status ", "Update " …: every detail label is this many columns. */
33
+ const LABEL_COLS = 9;
34
+
35
+ /**
36
+ * A detail value broken at SPACES, to the columns beside the label.
37
+ *
38
+ * Left to itself OpenTUI breaks a long line at the column edge, mid-token, and
39
+ * these values are commands and notes carrying paths. MEASURED at 150 columns
40
+ * (a 63-cell value column): `npm install -g --prefix /Users/…/node/v24.14.0
41
+ * @charmland/crush@latest` broke after `v24.14.`, with `0 @charmland/…` below
42
+ * — which reads as two paths, neither real. Broken here, every token that fits
43
+ * the column stays whole. One longer than the column (that same path at 100
44
+ * columns, where the value column is 38 cells) still has to break.
45
+ */
46
+ function wrapValue(text: string, width: number): string {
47
+ return wrapText(text, width - LABEL_COLS).join("\n");
48
+ }
49
+
19
50
  /**
20
- * The uninstall command as a line the reader can copy.
51
+ * One line of the conflict list: what happens to this install, and the command
52
+ * that would do it.
21
53
  *
22
54
  * This used to be a second, hand-written copy of the screen's own uninstall
23
55
  * switch — same shape, and already drifted (`pip uninstall` here,
@@ -25,16 +57,26 @@ import type { StatusTone } from "./pluginStatusText.js";
25
57
  * is not an executable. Now the renderer and the screen ask the same function,
26
58
  * so what is shown is what would run.
27
59
  *
28
- * Empty string when there is nothing to offer, which is what the caller
29
- * interpolates into the conflict list.
60
+ * An install magus cannot remove says so, rather than reading "remove" beside
61
+ * an empty command: the conflict fix will not offer to remove it either
62
+ * (R9.10), and a pane that promises what the dialog refuses is the same lie one
63
+ * step earlier.
30
64
  */
31
- function uninstallHint(
65
+ function removalLine(
32
66
  tool: CliTool,
33
67
  method: InstallMethod,
34
68
  brewFormula?: string,
35
- ): string {
69
+ ): { mark: string; hint: string; removable: boolean } {
70
+ const blocker = uninstallBlocker(tool, method, brewFormula);
71
+ if (blocker !== null) {
72
+ return { mark: " ⊘ stays ", hint: ` ${blocker}`, removable: false };
73
+ }
36
74
  const cmd = cliToolUninstall(tool, method, brewFormula);
37
- return cmd ? formatCommand(cmd) : "";
75
+ return {
76
+ mark: " ○ remove ",
77
+ hint: cmd ? ` ${formatCommand(cmd)}` : "",
78
+ removable: true,
79
+ };
38
80
  }
39
81
 
40
82
  function toneColor(tone: StatusTone): UiColor {
@@ -93,7 +135,7 @@ export function renderCliToolRow(
93
135
  if (isSelected) {
94
136
  return (
95
137
  <text bg={theme.selection.bg} fg={theme.selection.fg}>
96
- {" "}
138
+ {rowLead(true)}
97
139
  {icon} {tool.displayName}
98
140
  {versionText}
99
141
  {methodTag}
@@ -104,7 +146,10 @@ export function renderCliToolRow(
104
146
 
105
147
  return (
106
148
  <text fg={theme.colors.text}>
107
- <span fg={iconColor}> {icon}</span>
149
+ <span fg={iconColor}>
150
+ {rowLead(false)}
151
+ {icon}
152
+ </span>
108
153
  <span fg={theme.colors.text}> {tool.displayName}</span>
109
154
  {versionText ? (
110
155
  <span fg={theme.colors.success}>{versionText}</span>
@@ -123,6 +168,8 @@ export function renderCliToolRow(
123
168
 
124
169
  export function renderCliToolDetail(
125
170
  row: CliToolRow | undefined,
171
+ /** Usable detail-panel columns (`detailPanelWidth`); the scrollbox does not constrain width. */
172
+ width: number,
126
173
  ): React.ReactNode {
127
174
  if (!row) {
128
175
  return (
@@ -144,15 +191,13 @@ export function renderCliToolDetail(
144
191
  const brewFormula = install?.brewFormula;
145
192
  const hasConflict = allMethods.length > 1;
146
193
  const state = status?.state;
194
+ const enter = cliToolEnterAction(row);
147
195
 
148
196
  return (
149
- <box flexDirection="column">
150
- <box marginBottom={1}>
197
+ <box flexDirection="column" width={width}>
198
+ <box flexDirection="row" marginBottom={1}>
151
199
  <text fg={theme.colors.info}>
152
- <strong>
153
- {"⚙ "}
154
- {tool.displayName}
155
- </strong>
200
+ <strong>{tool.displayName}</strong>
156
201
  </text>
157
202
  {state === "upgrade" ? <text fg={theme.colors.warning}> ⬆</text> : null}
158
203
  {hasConflict ? <text fg={theme.colors.danger}> !</text> : null}
@@ -161,36 +206,58 @@ export function renderCliToolDetail(
161
206
  <text fg={theme.colors.muted}>{tool.description}</text>
162
207
 
163
208
  <box marginTop={1} flexDirection="column">
164
- <box>
165
- <text fg={theme.colors.muted}>{"Status "}</text>
209
+ <box flexDirection="row">
210
+ <text fg={theme.colors.muted} flexShrink={0}>
211
+ {"Status "}
212
+ </text>
166
213
  {checking || !status ? (
167
- <text fg={theme.colors.muted}>{"● Checking..."}</text>
214
+ <text fg={theme.colors.muted} flexShrink={0}>
215
+ {"● Checking..."}
216
+ </text>
168
217
  ) : state === "install" ? (
169
- <text fg={theme.colors.muted}>{"○ Not installed"}</text>
218
+ <text fg={theme.colors.muted} flexShrink={0}>
219
+ {"○ Not installed"}
220
+ </text>
170
221
  ) : state === "upgrade" ? (
171
222
  <text fg={theme.colors.warning}>{"● Update available"}</text>
172
223
  ) : state === "unknown" ? (
173
- <text fg={theme.colors.warning}>{"? Unverified"}</text>
224
+ // A version that was read but cannot be checked for updates is
225
+ // still a version: say "installed", and let Why say the rest.
226
+ <text fg={theme.colors.warning}>
227
+ {status.installed
228
+ ? "? Installed — not checked"
229
+ : "? Version unreadable"}
230
+ </text>
174
231
  ) : (
175
232
  <text fg={theme.colors.success}>{"● Up to date"}</text>
176
233
  )}
177
234
  </box>
178
235
  {state === "unknown" && status?.note ? (
179
- <box>
180
- <text fg={theme.colors.muted}>{"Why "}</text>
181
- <text fg={theme.colors.warning}>{status.note}</text>
236
+ <box flexDirection="row">
237
+ <text fg={theme.colors.muted} flexShrink={0}>
238
+ {"Why "}
239
+ </text>
240
+ <text fg={theme.colors.warning} minWidth={0}>
241
+ {wrapValue(status.note, width)}
242
+ </text>
182
243
  </box>
183
244
  ) : null}
184
245
  {/* An upgrade that also takes the tool over from another installer. */}
185
246
  {status?.adopted ? (
186
- <box>
187
- <text fg={theme.colors.muted}>{"Note "}</text>
188
- <text fg={theme.colors.warning}>{status.adopted}</text>
247
+ <box flexDirection="row">
248
+ <text fg={theme.colors.muted} flexShrink={0}>
249
+ {"Note "}
250
+ </text>
251
+ <text fg={theme.colors.warning} minWidth={0}>
252
+ {status.adopted}
253
+ </text>
189
254
  </box>
190
255
  ) : null}
191
256
  {status?.installed ? (
192
- <box>
193
- <text fg={theme.colors.muted}>{"Version "}</text>
257
+ <box flexDirection="row">
258
+ <text fg={theme.colors.muted} flexShrink={0}>
259
+ {"Version "}
260
+ </text>
194
261
  <text fg={theme.colors.text}>
195
262
  <span fg={theme.colors.success}>
196
263
  {displayVersion(status.installed)}
@@ -204,33 +271,51 @@ export function renderCliToolDetail(
204
271
  </text>
205
272
  </box>
206
273
  ) : status?.latest ? (
207
- <box>
208
- <text fg={theme.colors.muted}>{"Latest "}</text>
274
+ <box flexDirection="row">
275
+ <text fg={theme.colors.muted} flexShrink={0}>
276
+ {"Latest "}
277
+ </text>
209
278
  <text fg={theme.colors.text}>{displayVersion(status.latest)}</text>
210
279
  </box>
211
280
  ) : null}
212
281
  {status?.pin ? (
213
- <box>
214
- <text fg={theme.colors.muted}>{"Pinned "}</text>
282
+ <box flexDirection="row">
283
+ <text fg={theme.colors.muted} flexShrink={0}>
284
+ {"Pinned "}
285
+ </text>
215
286
  <text fg={theme.colors.text}>{displayVersion(status.pin)}</text>
216
287
  </box>
217
288
  ) : null}
218
289
  {installed && updateCommand ? (
219
- <box>
220
- <text fg={theme.colors.muted}>{"Update "}</text>
221
- <text fg={theme.colors.accent}>{formatCommand(updateCommand)}</text>
290
+ <box flexDirection="row">
291
+ <text fg={theme.colors.muted} flexShrink={0}>
292
+ {"Update "}
293
+ </text>
294
+ <text fg={theme.colors.info} minWidth={0}>
295
+ {wrapValue(formatCommand(updateCommand), width)}
296
+ </text>
222
297
  </box>
223
298
  ) : !installed && !checking ? (
224
- <box>
225
- <text fg={theme.colors.muted}>{"Install "}</text>
226
- <text fg={theme.colors.accent}>
227
- {formatCommand(installCommand)}
299
+ <box flexDirection="row">
300
+ <text fg={theme.colors.muted} flexShrink={0}>
301
+ {"Install "}
302
+ </text>
303
+ <text fg={theme.colors.info} minWidth={0}>
304
+ {wrapValue(formatCommand(installCommand), width)}
228
305
  </text>
229
306
  </box>
230
307
  ) : null}
231
- <box>
232
- <text fg={theme.colors.muted}>{"Website "}</text>
233
- <text fg={theme.colors.link}>{tool.website}</text>
308
+ <box flexDirection="row">
309
+ <text fg={theme.colors.muted} flexShrink={0}>
310
+ {"Website "}
311
+ </text>
312
+ {/* A URL has no spaces, so word wrap cannot break it: it breaks anywhere,
313
+ inside the width the label leaves. */}
314
+ <box flexGrow={1} minWidth={0}>
315
+ <text fg={theme.colors.info} wrapMode="char">
316
+ {tool.website}
317
+ </text>
318
+ </box>
234
319
  </box>
235
320
  </box>
236
321
 
@@ -251,75 +336,90 @@ export function renderCliToolDetail(
251
336
  {"\n"}Keep one, remove the rest:
252
337
  </text>
253
338
  </box>
254
- {allMethods.map((method, i) => (
255
- <box key={method}>
256
- <text fg={theme.colors.text}>
257
- <span fg={i === 0 ? theme.colors.success : theme.colors.danger}>
258
- {i === 0 ? " ● keep " : " ○ remove "}
259
- </span>
260
- <span fg={theme.colors.warning}>{method}</span>
261
- {i > 0 ? (
339
+ {allMethods.map((method, i) => {
340
+ if (i === 0) {
341
+ return (
342
+ <box key={method}>
343
+ <text fg={theme.colors.text}>
344
+ <span fg={theme.colors.success}>{" ● keep "}</span>
345
+ <span fg={theme.colors.warning}>{method}</span>
346
+ <span fg={theme.colors.dim}> (active in PATH)</span>
347
+ </text>
348
+ </box>
349
+ );
350
+ }
351
+ const line = removalLine(tool, method, brewFormula);
352
+ return (
353
+ <box key={method}>
354
+ <text fg={theme.colors.text}>
262
355
  <span
263
- fg={theme.colors.dim}
264
- >{` ${uninstallHint(tool, method, brewFormula)}`}</span>
265
- ) : (
266
- <span fg={theme.colors.dim}> (active in PATH)</span>
267
- )}
356
+ fg={
357
+ line.removable ? theme.colors.danger : theme.colors.muted
358
+ }
359
+ >
360
+ {line.mark}
361
+ </span>
362
+ <span fg={theme.colors.warning}>{method}</span>
363
+ <span fg={theme.colors.dim}>{line.hint}</span>
364
+ </text>
365
+ </box>
366
+ );
367
+ })}
368
+ {/* `c` is advertised only while it can remove something. With
369
+ every install stuck it opens a dialog with no choices in
370
+ it, and a key hint for a key that does nothing reads as a
371
+ hung app. */}
372
+ {allMethods.some(
373
+ (m) => uninstallBlocker(tool, m, brewFormula) === null,
374
+ ) ? (
375
+ <box flexDirection="row" marginTop={1}>
376
+ <text bg={theme.colors.danger} fg={theme.hints.fg}>
377
+ {" "}
378
+ c{" "}
268
379
  </text>
380
+ <text fg={theme.colors.muted}> Resolve — pick which to keep</text>
269
381
  </box>
270
- ))}
271
- <box marginTop={1}>
272
- <text bg={theme.colors.danger} fg={theme.hints.fg}>
273
- {" "}
274
- c{" "}
275
- </text>
276
- <text fg={theme.colors.muted}> Resolve — pick which to keep</text>
277
- </box>
382
+ ) : (
383
+ <box marginTop={1}>
384
+ <text fg={theme.colors.muted}>
385
+ magus can remove none of these. Take one out by hand, then r.
386
+ </text>
387
+ </box>
388
+ )}
278
389
  </box>
279
390
  ) : null}
280
391
 
281
392
  {/* Actions */}
393
+ {/* The words come from `cliToolEnterAction`, which the footer hint and
394
+ the Enter handler read too — so all three name the same action. */}
282
395
  <box marginTop={2} flexDirection="column">
283
- {checking || !status ? null : !installed ? (
284
- <box>
285
- <text bg={theme.colors.success} fg={theme.hints.fg}>
286
- {" "}
287
- Enter{" "}
288
- </text>
289
- <text fg={theme.colors.muted}> Install</text>
290
- <text fg={theme.colors.dim}> {formatCommand(installCommand)}</text>
291
- </box>
292
- ) : state === "upgrade" ? (
293
- <box>
294
- <text bg={theme.colors.warning} fg={theme.hints.fg}>
295
- {" "}
296
- Enter{" "}
297
- </text>
298
- <text fg={theme.colors.muted}>
299
- {" "}
300
- Update
301
- {status.target ? ` to ${displayVersion(status.target)}` : ""}
302
- </text>
303
- </box>
304
- ) : (
396
+ {enter ? (
305
397
  <box flexDirection="column">
306
- <box>
307
- <text bg={theme.colors.muted} fg={theme.hints.fg}>
398
+ <box flexDirection="row">
399
+ <text bg={ENTER_CHIP_BG[enter.kind]} fg={theme.hints.fg}>
308
400
  {" "}
309
401
  Enter{" "}
310
402
  </text>
311
- <text fg={theme.colors.muted}>
312
- {state === "unknown" ? " Update anyway" : " Reinstall"}
313
- </text>
403
+ <text fg={theme.colors.muted}> {enter.detail}</text>
404
+ {enter.kind === "install" ? (
405
+ <text fg={theme.colors.dim}>
406
+ {" "}
407
+ {formatCommand(enter.command)}
408
+ </text>
409
+ ) : null}
314
410
  </box>
315
411
  {/* The status warns that this command may not replace the
316
412
  running binary; the menu still offers it, as an explicit
317
413
  user action, and says so beside it. */}
318
- {status.reason === "not-managed" && status.note ? (
319
- <text fg={theme.colors.warning}>{` ${status.note}`}</text>
414
+ {enter.kind === "update-anyway" &&
415
+ status?.reason === "not-managed" &&
416
+ status.note ? (
417
+ <text fg={theme.colors.warning}>
418
+ {wrapText(` ${status.note}`, width).join("\n")}
419
+ </text>
320
420
  ) : null}
321
421
  </box>
322
- )}
422
+ ) : null}
323
423
  </box>
324
424
  </box>
325
425
  );
@@ -1,5 +1,7 @@
1
1
  import type React from "react";
2
2
  import type { ResolvedRule, Violation } from "../../types/gitignore.js";
3
+ import { rowLead } from "../components/primitives/SelectableRow.js";
4
+ import { truncate } from "../text-width.js";
3
5
  import { theme } from "../theme.js";
4
6
 
5
7
  /**
@@ -11,69 +13,65 @@ export interface RuleRow {
11
13
  violations: Violation[];
12
14
  }
13
15
 
14
- // Two color channels that never collide:
15
- // • Badge color = direction (where the item ends up): green = tracked by git,
16
- // reddish = ignored/dropped from git. Same on clean AND violation rows.
17
- // • Filename color = severity: dim gray when fine, red when it's a violation.
18
- // No row background tints — red is reserved entirely for "needs attention".
19
- const TRACK_FG = theme.colors.success; // green — item ends up tracked by git
20
- const IGNORE_FG = theme.colors.danger; // reddish — item ends up ignored / dropped
21
- const VIOLATION_PATH_FG = theme.colors.danger; // bright red filename = look here
22
- const CLEAN_PATH_FG = theme.colors.muted;
23
- const SELECTED_BG = theme.surface.bg; // faint slate highlight for the cursor row
16
+ // One colour channel, and it means one thing: something is wrong.
17
+ //
18
+ // • The action word ("ignore" / "track") is what the rule INTENDS. An intended
19
+ // state is not a problem, so it is neutral on every row. It used to be red
20
+ // for "ignore" and green for "track", which put the danger hue on every
21
+ // ignore rule in the list — half the screen read as broken while nothing was.
22
+ // • A violation is what is wrong, so its path wears `danger`, and its action
23
+ // sits on a neutral chip so the row reads as "this needs fixing" at a glance
24
+ // without the chip's colour claiming anything the word does not.
25
+ // • A clean row stays quiet: action and path in `muted`/`text`.
26
+ const INTENT_FG = theme.colors.muted;
27
+ const CLEAN_PATH_FG = theme.colors.text;
28
+ const VIOLATION_PATH_FG = theme.colors.danger;
29
+ const CHIP_BG = theme.colors.dim;
30
+ const CHIP_FG = theme.hints.fg;
24
31
 
25
- // Violation badges are solid chips so the required direction reads at a glance:
26
- // green chip = will be tracked, red chip = will be ignored/dropped.
27
- const TRACK_CHIP_BG = theme.colors.success;
28
- const IGNORE_CHIP_BG = theme.colors.danger;
29
- const CHIP_FG = theme.colors.text;
30
-
31
- // The only marker is the cursor: ▶ on the selected row, blank otherwise.
32
- // Action (track/ignore) is read from the badge word and, for clean items,
33
- // from the group the row sits under — so no per-row dot is needed.
34
- const MARKER_SELECTED = "▶";
35
- const MARKER_NONE = " ";
36
-
37
- // Layout: " M action path" — keep the prefix compact so the narrow terminal
32
+ // Layout: "▌ action path" — keep the prefix compact so the narrow terminal
38
33
  // case still has room for the path without wrapping.
39
34
  const MAX_PATH_LEN = 22;
40
35
 
41
- function truncatePath(p: string): string {
42
- return p.length > MAX_PATH_LEN ? `${p.slice(0, MAX_PATH_LEN - 1)}…` : p;
43
- }
44
-
45
36
  export function renderRuleRow(
46
37
  row: RuleRow,
47
38
  isSelected: boolean,
48
39
  ): React.ReactNode {
49
40
  const { rule, violations } = row;
50
- const path = truncatePath(rule.pattern);
41
+ const path = truncate(rule.pattern, MAX_PATH_LEN);
51
42
  const key = `${rule.action}:${rule.pattern}`;
52
- // Badge text padded to a fixed width so paths line up in a column.
43
+ // Badge text padded to a fixed width, and drawn in the same padded cell on
44
+ // every row — chip or not — so paths line up in one column.
53
45
  const badge = rule.action === "ignore" ? "ignore" : "track ";
54
- const badgeFg = rule.action === "ignore" ? IGNORE_FG : TRACK_FG;
55
46
  const suffix = violations.length > 1 ? ` +${violations.length - 1}` : "";
56
- const rowBg = isSelected ? SELECTED_BG : undefined;
57
- const marker = isSelected ? MARKER_SELECTED : MARKER_NONE;
58
- const isViolation = violations.length > 0;
47
+ const lead = `${rowLead(isSelected)} `;
48
+
49
+ // The selection owns the colour channel, as on every other screen: the mark
50
+ // and the fill say where the cursor is, the words say the rest.
51
+ if (isSelected) {
52
+ return (
53
+ <text key={key} bg={theme.selection.bg} fg={theme.selection.fg}>
54
+ {lead}
55
+ {` ${badge} `}
56
+ {` ${path}${suffix} `}
57
+ </text>
58
+ );
59
+ }
59
60
 
60
- if (isViolation) {
61
- // Solid badge chip + red filename. The chip color is the fix direction.
62
- const chipBg = rule.action === "ignore" ? IGNORE_CHIP_BG : TRACK_CHIP_BG;
61
+ if (violations.length > 0) {
63
62
  return (
64
- <text key={key} bg={rowBg}>
65
- <span fg={VIOLATION_PATH_FG}>{` ${marker} `}</span>
66
- <span bg={chipBg} fg={CHIP_FG}>{` ${badge} `}</span>
63
+ <text key={key} fg={theme.colors.text}>
64
+ <span fg={VIOLATION_PATH_FG}>{lead}</span>
65
+ <span bg={CHIP_BG} fg={CHIP_FG}>{` ${badge} `}</span>
67
66
  <span fg={VIOLATION_PATH_FG}>{` ${path}${suffix}`}</span>
68
67
  </text>
69
68
  );
70
69
  }
71
70
 
72
- // Clean row: flat colored badge word, dim path, no chip.
73
71
  return (
74
- <text key={key} bg={rowBg}>
75
- <span fg={badgeFg}>{` ${marker} `}</span>
76
- <span fg={badgeFg}>{badge}</span>
72
+ <text key={key} fg={theme.colors.text}>
73
+ <span fg={INTENT_FG}>{lead}</span>
74
+ <span fg={INTENT_FG}>{` ${badge} `}</span>
77
75
  <span fg={CLEAN_PATH_FG}>{` ${path}`}</span>
78
76
  </text>
79
77
  );
@@ -55,7 +55,7 @@ function inlineSpans(text: string, keyPrefix: string): React.ReactNode[] {
55
55
  }
56
56
  if (match[1]) {
57
57
  nodes.push(
58
- <span key={`${keyPrefix}-c${i}`} fg={theme.colors.link}>
58
+ <span key={`${keyPrefix}-c${i}`} fg={theme.colors.info}>
59
59
  {match[1].slice(1, -1)}
60
60
  </span>,
61
61
  );
@@ -212,7 +212,7 @@ export function renderMarkdown(
212
212
  while (i < lines.length && !/^\s*```/.test(lines[i])) {
213
213
  const code = lines[i];
214
214
  push(
215
- <text key={`fc${i}`} fg={theme.colors.link}>
215
+ <text key={`fc${i}`} fg={theme.colors.info}>
216
216
  {` ${code}` || " "}
217
217
  </text>,
218
218
  );
@@ -1,7 +1,15 @@
1
1
  import type React from "react";
2
2
  import type { McpServer } from "../../types/index.js";
3
+ import { rowLead } from "../components/primitives/index.js";
3
4
  import { theme } from "../theme.js";
4
5
 
6
+ /**
7
+ * The MCP-server glyph, shared with the Plugins screen's component chips.
8
+ * Single-width on purpose: `⚡` (U+26A1) is East-Asian-Width Wide, two cells,
9
+ * which shifted everything after it on the line.
10
+ */
11
+ const SERVER_ICON = "⬢";
12
+
5
13
  // ─── List item types ───────────────────────────────────────────────────────────
6
14
 
7
15
  export type McpListItem =
@@ -16,8 +24,10 @@ export function renderMcpRow(
16
24
  isSelected: boolean,
17
25
  ): React.ReactNode {
18
26
  if (item.kind === "category") {
27
+ // A section heading, so the accent — as a word, never as the fill the
28
+ // cursor wears.
19
29
  return (
20
- <text fg={theme.selection.bg}>
30
+ <text fg={theme.colors.accent}>
21
31
  <strong>
22
32
  {"▸ "}
23
33
  {item.label}
@@ -33,15 +43,19 @@ export function renderMcpRow(
33
43
  if (isSelected) {
34
44
  return (
35
45
  <text bg={theme.selection.bg} fg={theme.selection.fg}>
36
- {" "}
37
- {icon} {server.name}{" "}
46
+ {rowLead(true)}
47
+ {icon} {server.name}
48
+ {server.requiresConfig ? " ⚙" : ""}{" "}
38
49
  </text>
39
50
  );
40
51
  }
41
52
 
42
53
  return (
43
54
  <text fg={theme.colors.text}>
44
- <span fg={iconColor}>{icon}</span>
55
+ <span fg={iconColor}>
56
+ {rowLead(false)}
57
+ {icon}
58
+ </span>
45
59
  <span fg={theme.colors.text}> {server.name}</span>
46
60
  {server.requiresConfig ? <span fg={theme.colors.warning}> ⚙</span> : null}
47
61
  </text>
@@ -75,10 +89,10 @@ export function renderMcpDetail(
75
89
 
76
90
  return (
77
91
  <box flexDirection="column">
78
- <box marginBottom={1}>
92
+ <box flexDirection="row" marginBottom={1}>
79
93
  <text fg={theme.colors.info}>
80
94
  <strong>
81
- {"⚡ "}
95
+ {`${SERVER_ICON} `}
82
96
  {server.name}
83
97
  </strong>
84
98
  </text>
@@ -90,7 +104,7 @@ export function renderMcpDetail(
90
104
  <text fg={theme.colors.muted}>{server.description}</text>
91
105
 
92
106
  <box marginTop={1} flexDirection="column">
93
- <box>
107
+ <box flexDirection="row">
94
108
  <text fg={theme.colors.muted}>{"Status "}</text>
95
109
  {isInstalled ? (
96
110
  <text fg={theme.colors.success}>{"● Installed"}</text>
@@ -98,25 +112,25 @@ export function renderMcpDetail(
98
112
  <text fg={theme.colors.muted}>{"○ Not installed"}</text>
99
113
  )}
100
114
  </box>
101
- <box>
115
+ <box flexDirection="row">
102
116
  <text fg={theme.colors.muted}>{"Type "}</text>
103
117
  <text fg={theme.colors.text}>
104
118
  {server.type === "http" ? "HTTP" : "Command"}
105
119
  </text>
106
120
  </box>
107
121
  {server.type === "http" ? (
108
- <box>
122
+ <box flexDirection="row">
109
123
  <text fg={theme.colors.muted}>{"URL "}</text>
110
- <text fg={theme.colors.link}>{server.url}</text>
124
+ <text fg={theme.colors.info}>{server.url}</text>
111
125
  </box>
112
126
  ) : (
113
- <box>
127
+ <box flexDirection="row">
114
128
  <text fg={theme.colors.muted}>{"Command "}</text>
115
129
  <text fg={theme.colors.info}>{server.command}</text>
116
130
  </box>
117
131
  )}
118
132
  {server.requiresConfig ? (
119
- <box>
133
+ <box flexDirection="row">
120
134
  <text fg={theme.colors.muted}>{"Config "}</text>
121
135
  <text fg={theme.colors.warning}>
122
136
  {server.configFields?.length ?? 0} fields required
@@ -127,7 +141,7 @@ export function renderMcpDetail(
127
141
 
128
142
  <box marginTop={2}>
129
143
  {isInstalled ? (
130
- <box>
144
+ <box flexDirection="row">
131
145
  <text bg={theme.colors.danger} fg={theme.hints.fg}>
132
146
  {" "}
133
147
  Enter{" "}
@@ -135,7 +149,7 @@ export function renderMcpDetail(
135
149
  <text fg={theme.colors.muted}> Remove server</text>
136
150
  </box>
137
151
  ) : (
138
- <box>
152
+ <box flexDirection="row">
139
153
  <text bg={theme.colors.success} fg={theme.hints.fg}>
140
154
  {" "}
141
155
  Enter{" "}