magus-cli 7.3.0 → 7.4.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 (130) hide show
  1. package/package.json +4 -4
  2. package/scripts/build-binaries.ts +4 -2
  3. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  4. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  5. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  6. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  7. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  8. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  9. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  10. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  11. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  12. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  15. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  16. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  17. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  18. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  19. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  20. package/src/__tests__/blind/child-run.ts +114 -0
  21. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  22. package/src/__tests__/component-badge.test.ts +20 -36
  23. package/src/__tests__/footer-hints.test.ts +1 -1
  24. package/src/__tests__/manifest.test.ts +25 -17
  25. package/src/__tests__/marketplace-badge.test.ts +4 -4
  26. package/src/__tests__/models-screen-state.test.ts +36 -13
  27. package/src/__tests__/npm-package-name-api.test.ts +111 -0
  28. package/src/__tests__/npm-package-name.test.ts +280 -0
  29. package/src/__tests__/scope-squares.test.tsx +28 -114
  30. package/src/__tests__/shutdown.test.ts +578 -0
  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__/upgrade-spawn.test.ts +355 -0
  37. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  38. package/src/cli/ansi.ts +2 -2
  39. package/src/cli/bootstrap.ts +33 -2
  40. package/src/cli/doctor.ts +105 -14
  41. package/src/cli/install.ts +8 -2
  42. package/src/cli/profile.ts +18 -1
  43. package/src/cli/router.ts +31 -18
  44. package/src/cli/update-view.ts +1 -1
  45. package/src/cli/upgrade.ts +37 -9
  46. package/src/data/cli-tools.ts +13 -2
  47. package/src/data/npm-package.ts +15 -0
  48. package/src/services/claude-settings.ts +151 -73
  49. package/src/services/cli-tool-commands.ts +15 -5
  50. package/src/services/cli-tool-version.ts +101 -0
  51. package/src/services/hook-registration.ts +23 -6
  52. package/src/services/manifest.ts +117 -52
  53. package/src/services/plugin-manager.ts +19 -5
  54. package/src/services/profile-adopt.ts +7 -2
  55. package/src/services/profile-startup.ts +87 -7
  56. package/src/services/profiles.ts +36 -23
  57. package/src/services/settings-file.ts +158 -4
  58. package/src/services/settings-manager.ts +19 -5
  59. package/src/services/skills-manager.ts +46 -0
  60. package/src/services/tool-probe.ts +272 -45
  61. package/src/services/toolchain.ts +39 -0
  62. package/src/services/update-engine.ts +5 -0
  63. package/src/services/update-plan.ts +9 -0
  64. package/src/services/version-check.ts +20 -10
  65. package/src/tui.tsx +48 -21
  66. package/src/ui/App.tsx +58 -197
  67. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  68. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  69. package/src/ui/adapters/settingsAdapter.ts +0 -21
  70. package/src/ui/adapters/skillsAdapter.ts +27 -8
  71. package/src/ui/adapters/stylesAdapter.ts +15 -11
  72. package/src/ui/components/CategoryHeader.tsx +7 -1
  73. package/src/ui/components/EmptyFilterState.tsx +2 -2
  74. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  75. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  76. package/src/ui/components/TabBar.tsx +93 -21
  77. package/src/ui/components/layout/FooterHints.tsx +6 -27
  78. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  79. package/src/ui/components/layout/index.ts +1 -5
  80. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  81. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  82. package/src/ui/components/modals/MessageModal.tsx +3 -15
  83. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  84. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  85. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  86. package/src/ui/components/primitives/MetaText.tsx +1 -1
  87. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  88. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  89. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  90. package/src/ui/components/primitives/index.ts +4 -3
  91. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  92. package/src/ui/input/KeyRouter.tsx +180 -0
  93. package/src/ui/input/key-dispatch.ts +149 -0
  94. package/src/ui/keymap.ts +898 -0
  95. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  96. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  97. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  98. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  99. package/src/ui/renderers/modelRenderers.tsx +12 -13
  100. package/src/ui/renderers/modelVisuals.tsx +0 -2
  101. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  102. package/src/ui/renderers/profileRenderers.tsx +32 -37
  103. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  104. package/src/ui/renderers/skillRenderers.tsx +237 -190
  105. package/src/ui/renderers/styleRenderers.tsx +13 -2
  106. package/src/ui/runtime/shutdown.ts +268 -0
  107. package/src/ui/screens/AliasScreen.tsx +118 -100
  108. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  109. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  110. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  111. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  112. package/src/ui/screens/McpScreen.tsx +47 -33
  113. package/src/ui/screens/ModelsScreen.tsx +59 -104
  114. package/src/ui/screens/PluginsScreen.tsx +123 -127
  115. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  116. package/src/ui/screens/SkillsScreen.tsx +184 -204
  117. package/src/ui/screens/StylesScreen.tsx +104 -157
  118. package/src/ui/state/AppContext.tsx +6 -0
  119. package/src/ui/state/DimensionsContext.tsx +1 -10
  120. package/src/ui/state/reducer.ts +5 -8
  121. package/src/ui/state/types.ts +11 -9
  122. package/src/ui/text-width.ts +341 -0
  123. package/src/ui/theme-mode.ts +34 -71
  124. package/src/ui/theme.ts +50 -28
  125. package/src/utils/request-gate.ts +37 -0
  126. package/src/ui/components/ScopeIndicator.tsx +0 -63
  127. package/src/ui/components/SearchInput.tsx +0 -57
  128. package/src/ui/hooks/index.ts +0 -2
  129. package/src/ui/hooks/useKeyboard.ts +0 -24
  130. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -6,16 +6,46 @@ import {
6
6
  } from "../../services/cli-tool-commands.js";
7
7
  import { formatCommand } from "../../utils/command-utils.js";
8
8
  import {
9
+ type CliToolEnterAction,
9
10
  type CliToolRow,
11
+ cliToolEnterAction,
10
12
  cliToolStatusText,
11
13
  displayVersion,
12
14
  isInstalled,
13
15
  } from "../adapters/cliToolsAdapter.js";
16
+ import { rowLead } from "../components/primitives/index.js";
14
17
  import { type UiColor, theme } from "../theme.js";
15
18
  import type { StatusTone } from "./pluginStatusText.js";
19
+ import { wrapText } from "./styleRenderers.js";
16
20
 
17
21
  // ─── Helpers ────────────────────────────────────────────────────────────────
18
22
 
23
+ /** The Enter chip's background, by what Enter does. */
24
+ const ENTER_CHIP_BG: Record<CliToolEnterAction["kind"], UiColor> = {
25
+ install: theme.colors.success,
26
+ update: theme.colors.warning,
27
+ "update-anyway": theme.colors.muted,
28
+ reinstall: theme.colors.muted,
29
+ };
30
+
31
+ /** "Status ", "Update " …: every detail label is this many columns. */
32
+ const LABEL_COLS = 9;
33
+
34
+ /**
35
+ * A detail value broken at SPACES, to the columns beside the label.
36
+ *
37
+ * Left to itself OpenTUI breaks a long line at the column edge, mid-token, and
38
+ * these values are commands and notes carrying paths. MEASURED at 150 columns
39
+ * (a 63-cell value column): `npm install -g --prefix /Users/…/node/v24.14.0
40
+ * @charmland/crush@latest` broke after `v24.14.`, with `0 @charmland/…` below
41
+ * — which reads as two paths, neither real. Broken here, every token that fits
42
+ * the column stays whole. One longer than the column (that same path at 100
43
+ * columns, where the value column is 38 cells) still has to break.
44
+ */
45
+ function wrapValue(text: string, width: number): string {
46
+ return wrapText(text, width - LABEL_COLS).join("\n");
47
+ }
48
+
19
49
  /**
20
50
  * The uninstall command as a line the reader can copy.
21
51
  *
@@ -93,7 +123,7 @@ export function renderCliToolRow(
93
123
  if (isSelected) {
94
124
  return (
95
125
  <text bg={theme.selection.bg} fg={theme.selection.fg}>
96
- {" "}
126
+ {rowLead(true)}
97
127
  {icon} {tool.displayName}
98
128
  {versionText}
99
129
  {methodTag}
@@ -104,7 +134,10 @@ export function renderCliToolRow(
104
134
 
105
135
  return (
106
136
  <text fg={theme.colors.text}>
107
- <span fg={iconColor}> {icon}</span>
137
+ <span fg={iconColor}>
138
+ {rowLead(false)}
139
+ {icon}
140
+ </span>
108
141
  <span fg={theme.colors.text}> {tool.displayName}</span>
109
142
  {versionText ? (
110
143
  <span fg={theme.colors.success}>{versionText}</span>
@@ -123,6 +156,8 @@ export function renderCliToolRow(
123
156
 
124
157
  export function renderCliToolDetail(
125
158
  row: CliToolRow | undefined,
159
+ /** Usable detail-panel columns (`detailPanelWidth`); the scrollbox does not constrain width. */
160
+ width: number,
126
161
  ): React.ReactNode {
127
162
  if (!row) {
128
163
  return (
@@ -144,15 +179,13 @@ export function renderCliToolDetail(
144
179
  const brewFormula = install?.brewFormula;
145
180
  const hasConflict = allMethods.length > 1;
146
181
  const state = status?.state;
182
+ const enter = cliToolEnterAction(row);
147
183
 
148
184
  return (
149
- <box flexDirection="column">
150
- <box marginBottom={1}>
185
+ <box flexDirection="column" width={width}>
186
+ <box flexDirection="row" marginBottom={1}>
151
187
  <text fg={theme.colors.info}>
152
- <strong>
153
- {"⚙ "}
154
- {tool.displayName}
155
- </strong>
188
+ <strong>{tool.displayName}</strong>
156
189
  </text>
157
190
  {state === "upgrade" ? <text fg={theme.colors.warning}> ⬆</text> : null}
158
191
  {hasConflict ? <text fg={theme.colors.danger}> !</text> : null}
@@ -161,36 +194,58 @@ export function renderCliToolDetail(
161
194
  <text fg={theme.colors.muted}>{tool.description}</text>
162
195
 
163
196
  <box marginTop={1} flexDirection="column">
164
- <box>
165
- <text fg={theme.colors.muted}>{"Status "}</text>
197
+ <box flexDirection="row">
198
+ <text fg={theme.colors.muted} flexShrink={0}>
199
+ {"Status "}
200
+ </text>
166
201
  {checking || !status ? (
167
- <text fg={theme.colors.muted}>{"● Checking..."}</text>
202
+ <text fg={theme.colors.muted} flexShrink={0}>
203
+ {"● Checking..."}
204
+ </text>
168
205
  ) : state === "install" ? (
169
- <text fg={theme.colors.muted}>{"○ Not installed"}</text>
206
+ <text fg={theme.colors.muted} flexShrink={0}>
207
+ {"○ Not installed"}
208
+ </text>
170
209
  ) : state === "upgrade" ? (
171
210
  <text fg={theme.colors.warning}>{"● Update available"}</text>
172
211
  ) : state === "unknown" ? (
173
- <text fg={theme.colors.warning}>{"? Unverified"}</text>
212
+ // A version that was read but cannot be checked for updates is
213
+ // still a version: say "installed", and let Why say the rest.
214
+ <text fg={theme.colors.warning}>
215
+ {status.installed
216
+ ? "? Installed — not checked"
217
+ : "? Version unreadable"}
218
+ </text>
174
219
  ) : (
175
220
  <text fg={theme.colors.success}>{"● Up to date"}</text>
176
221
  )}
177
222
  </box>
178
223
  {state === "unknown" && status?.note ? (
179
- <box>
180
- <text fg={theme.colors.muted}>{"Why "}</text>
181
- <text fg={theme.colors.warning}>{status.note}</text>
224
+ <box flexDirection="row">
225
+ <text fg={theme.colors.muted} flexShrink={0}>
226
+ {"Why "}
227
+ </text>
228
+ <text fg={theme.colors.warning} minWidth={0}>
229
+ {wrapValue(status.note, width)}
230
+ </text>
182
231
  </box>
183
232
  ) : null}
184
233
  {/* An upgrade that also takes the tool over from another installer. */}
185
234
  {status?.adopted ? (
186
- <box>
187
- <text fg={theme.colors.muted}>{"Note "}</text>
188
- <text fg={theme.colors.warning}>{status.adopted}</text>
235
+ <box flexDirection="row">
236
+ <text fg={theme.colors.muted} flexShrink={0}>
237
+ {"Note "}
238
+ </text>
239
+ <text fg={theme.colors.warning} minWidth={0}>
240
+ {status.adopted}
241
+ </text>
189
242
  </box>
190
243
  ) : null}
191
244
  {status?.installed ? (
192
- <box>
193
- <text fg={theme.colors.muted}>{"Version "}</text>
245
+ <box flexDirection="row">
246
+ <text fg={theme.colors.muted} flexShrink={0}>
247
+ {"Version "}
248
+ </text>
194
249
  <text fg={theme.colors.text}>
195
250
  <span fg={theme.colors.success}>
196
251
  {displayVersion(status.installed)}
@@ -204,33 +259,51 @@ export function renderCliToolDetail(
204
259
  </text>
205
260
  </box>
206
261
  ) : status?.latest ? (
207
- <box>
208
- <text fg={theme.colors.muted}>{"Latest "}</text>
262
+ <box flexDirection="row">
263
+ <text fg={theme.colors.muted} flexShrink={0}>
264
+ {"Latest "}
265
+ </text>
209
266
  <text fg={theme.colors.text}>{displayVersion(status.latest)}</text>
210
267
  </box>
211
268
  ) : null}
212
269
  {status?.pin ? (
213
- <box>
214
- <text fg={theme.colors.muted}>{"Pinned "}</text>
270
+ <box flexDirection="row">
271
+ <text fg={theme.colors.muted} flexShrink={0}>
272
+ {"Pinned "}
273
+ </text>
215
274
  <text fg={theme.colors.text}>{displayVersion(status.pin)}</text>
216
275
  </box>
217
276
  ) : null}
218
277
  {installed && updateCommand ? (
219
- <box>
220
- <text fg={theme.colors.muted}>{"Update "}</text>
221
- <text fg={theme.colors.accent}>{formatCommand(updateCommand)}</text>
278
+ <box flexDirection="row">
279
+ <text fg={theme.colors.muted} flexShrink={0}>
280
+ {"Update "}
281
+ </text>
282
+ <text fg={theme.colors.info} minWidth={0}>
283
+ {wrapValue(formatCommand(updateCommand), width)}
284
+ </text>
222
285
  </box>
223
286
  ) : !installed && !checking ? (
224
- <box>
225
- <text fg={theme.colors.muted}>{"Install "}</text>
226
- <text fg={theme.colors.accent}>
227
- {formatCommand(installCommand)}
287
+ <box flexDirection="row">
288
+ <text fg={theme.colors.muted} flexShrink={0}>
289
+ {"Install "}
290
+ </text>
291
+ <text fg={theme.colors.info} minWidth={0}>
292
+ {wrapValue(formatCommand(installCommand), width)}
228
293
  </text>
229
294
  </box>
230
295
  ) : null}
231
- <box>
232
- <text fg={theme.colors.muted}>{"Website "}</text>
233
- <text fg={theme.colors.link}>{tool.website}</text>
296
+ <box flexDirection="row">
297
+ <text fg={theme.colors.muted} flexShrink={0}>
298
+ {"Website "}
299
+ </text>
300
+ {/* A URL has no spaces, so word wrap cannot break it: it breaks anywhere,
301
+ inside the width the label leaves. */}
302
+ <box flexGrow={1} minWidth={0}>
303
+ <text fg={theme.colors.info} wrapMode="char">
304
+ {tool.website}
305
+ </text>
306
+ </box>
234
307
  </box>
235
308
  </box>
236
309
 
@@ -268,7 +341,7 @@ export function renderCliToolDetail(
268
341
  </text>
269
342
  </box>
270
343
  ))}
271
- <box marginTop={1}>
344
+ <box flexDirection="row" marginTop={1}>
272
345
  <text bg={theme.colors.danger} fg={theme.hints.fg}>
273
346
  {" "}
274
347
  c{" "}
@@ -279,47 +352,36 @@ export function renderCliToolDetail(
279
352
  ) : null}
280
353
 
281
354
  {/* Actions */}
355
+ {/* The words come from `cliToolEnterAction`, which the footer hint and
356
+ the Enter handler read too — so all three name the same action. */}
282
357
  <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
- ) : (
358
+ {enter ? (
305
359
  <box flexDirection="column">
306
- <box>
307
- <text bg={theme.colors.muted} fg={theme.hints.fg}>
360
+ <box flexDirection="row">
361
+ <text bg={ENTER_CHIP_BG[enter.kind]} fg={theme.hints.fg}>
308
362
  {" "}
309
363
  Enter{" "}
310
364
  </text>
311
- <text fg={theme.colors.muted}>
312
- {state === "unknown" ? " Update anyway" : " Reinstall"}
313
- </text>
365
+ <text fg={theme.colors.muted}> {enter.detail}</text>
366
+ {enter.kind === "install" ? (
367
+ <text fg={theme.colors.dim}>
368
+ {" "}
369
+ {formatCommand(enter.command)}
370
+ </text>
371
+ ) : null}
314
372
  </box>
315
373
  {/* The status warns that this command may not replace the
316
374
  running binary; the menu still offers it, as an explicit
317
375
  user action, and says so beside it. */}
318
- {status.reason === "not-managed" && status.note ? (
319
- <text fg={theme.colors.warning}>{` ${status.note}`}</text>
376
+ {enter.kind === "update-anyway" &&
377
+ status?.reason === "not-managed" &&
378
+ status.note ? (
379
+ <text fg={theme.colors.warning}>
380
+ {wrapText(` ${status.note}`, width).join("\n")}
381
+ </text>
320
382
  ) : null}
321
383
  </box>
322
- )}
384
+ ) : null}
323
385
  </box>
324
386
  </box>
325
387
  );
@@ -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{" "}
@@ -30,7 +30,7 @@ import {
30
30
  SelectableRow,
31
31
  } from "../components/primitives/index.js";
32
32
  import { effortInk, trackFill } from "../theme-mode.js";
33
- import { type UiColor, theme } from "../theme.js";
33
+ import { type BadgeTone, type UiColor, theme } from "../theme.js";
34
34
  import {
35
35
  type AgentRow,
36
36
  type AgentTableLayout,
@@ -85,14 +85,13 @@ const STATE_TONE: Record<ModelsState, UiColor> = {
85
85
  unhooked: theme.colors.danger,
86
86
  };
87
87
 
88
- /** Badge tone for the header row. `unhooked` is red with `invalid`: both route nothing. */
89
- const STATE_CATEGORY: Record<ModelsState, "gray" | "green" | "yellow" | "red"> =
90
- {
91
- off: "gray",
92
- on: "green",
93
- invalid: "red",
94
- unhooked: "red",
95
- };
88
+ /** Badge tone for the header row. `unhooked` is danger with `invalid`: both route nothing. */
89
+ const STATE_CATEGORY: Record<ModelsState, BadgeTone> = {
90
+ off: "neutral",
91
+ on: "success",
92
+ invalid: "danger",
93
+ unhooked: "danger",
94
+ };
96
95
 
97
96
  /**
98
97
  * How many validation errors to draw before summarising the rest.
@@ -131,9 +130,9 @@ function Chip({
131
130
  tone,
132
131
  }: {
133
132
  label: string;
134
- tone: keyof typeof theme.category;
133
+ tone: BadgeTone;
135
134
  }): React.ReactNode {
136
- const colors = theme.category[tone];
135
+ const colors = theme.badge[tone];
137
136
  return (
138
137
  <span bg={colors.bg} fg={colors.fg}>
139
138
  <strong>{` ${label} `}</strong>
@@ -206,7 +205,7 @@ function StateBlock({
206
205
  <text fg={theme.colors.text}>
207
206
  <Chip
208
207
  label={`${isInvalid ? "errors" : "drift"} ${status.drift.length}`}
209
- tone={isInvalid ? "red" : "yellow"}
208
+ tone={isInvalid ? "danger" : "warning"}
210
209
  />
211
210
  </text>
212
211
  {status.drift.slice(0, MAX_DRIFT_LINES).map((line) => (
@@ -231,7 +230,7 @@ function StateBlock({
231
230
  {status.warnings.length > 0 ? (
232
231
  <box flexDirection="column" marginTop={1}>
233
232
  <text fg={theme.colors.text}>
234
- <Chip label={`warnings ${status.warnings.length}`} tone="yellow" />
233
+ <Chip label={`warnings ${status.warnings.length}`} tone="warning" />
235
234
  </text>
236
235
  {status.warnings.map((line) => (
237
236
  <Wrapped
@@ -55,8 +55,6 @@ import { type UiColor, theme } from "../theme.js";
55
55
  * `theme-mode.ts`, so the palette is a theme decision and this file is a consumer of one.
56
56
  */
57
57
 
58
- export type CategoryTone = keyof typeof theme.category;
59
-
60
58
  /**
61
59
  * The ink a model's NAME is written in.
62
60
  *