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
@@ -1,6 +1,6 @@
1
1
  import type React from "react";
2
2
  import { STAR_RELIABILITY_INFO } from "../../data/skill-repos.js";
3
- import type { StarReliability } from "../../types/index.js";
3
+ import type { SkillInfo, StarReliability } from "../../types/index.js";
4
4
  import type {
5
5
  SkillBrowserItem,
6
6
  SkillCategoryItem,
@@ -18,6 +18,7 @@ import {
18
18
  SelectableRow,
19
19
  } from "../components/primitives/index.js";
20
20
  import type { ItemRenderer } from "../registry.js";
21
+ import { displayWidth, padTo, truncate } from "../text-width.js";
21
22
  import { type UiColor, theme } from "../theme.js";
22
23
 
23
24
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -30,17 +31,26 @@ function formatStarsNum(stars?: number): string {
30
31
  return `${stars}`;
31
32
  }
32
33
 
33
- /** Star icon and color based on reliability classification */
34
+ /** Star icon based on reliability classification */
34
35
  function starIcon(reliability?: StarReliability): string {
35
36
  if (reliability === "mega-repo") return "☆";
36
37
  if (reliability === "skill-dump") return "☆";
37
38
  return "★";
38
39
  }
39
40
 
41
+ /**
42
+ * A star count is a reference fact, so a meaningful one is `info`. A hollow
43
+ * star — a count that says little about the skill — recedes to `muted`. Never
44
+ * `warning`: amber means "needs a look", and a star count asks for nothing.
45
+ */
46
+ function starTone(reliability?: StarReliability): "info" | "muted" {
47
+ return reliability === "mega-repo" || reliability === "skill-dump"
48
+ ? "muted"
49
+ : "info";
50
+ }
51
+
40
52
  function starColor(reliability?: StarReliability): UiColor {
41
- if (reliability === "mega-repo") return theme.colors.muted;
42
- if (reliability === "skill-dump") return theme.colors.warning;
43
- return theme.colors.warning;
53
+ return theme.meta[starTone(reliability)];
44
54
  }
45
55
 
46
56
  function formatStars(stars?: number, reliability?: StarReliability): string {
@@ -68,7 +78,7 @@ const categoryRenderer: ItemRenderer<SkillCategoryItem> = {
68
78
  const isRec = item.categoryKey === "recommended";
69
79
  return (
70
80
  <box flexDirection="column">
71
- <text fg={isRec ? theme.colors.success : theme.colors.info}>
81
+ <text fg={theme.colors.info}>
72
82
  <strong>
73
83
  {item.star ?? ""}
74
84
  {item.title}
@@ -89,23 +99,28 @@ const categoryRenderer: ItemRenderer<SkillCategoryItem> = {
89
99
  // ─── Skill renderer ───────────────────────────────────────────────────────────
90
100
 
91
101
  const MAX_SKILL_NAME_LEN = 35;
92
-
93
- function truncateName(name: string): string {
94
- return name.length > MAX_SKILL_NAME_LEN
95
- ? `${name.slice(0, MAX_SKILL_NAME_LEN - 1)}\u2026`
96
- : name;
97
- }
102
+ /** Width a row assumes when its caller does not pass the panel's. */
103
+ const DEFAULT_ROW_WIDTH = 45;
98
104
 
99
105
  const skillRenderer: ItemRenderer<SkillSkillItem> = {
100
- renderRow: ({ item, isSelected }) => {
106
+ renderRow: ({ item, isSelected, width = DEFAULT_ROW_WIDTH }) => {
101
107
  const { skill } = item;
102
108
  const hasUser = skill.installedScope === "user";
103
109
  const hasProject = skill.installedScope === "project";
104
110
  const reliability = skill.starReliability;
105
111
  const starsStr = formatStars(skill.stars, reliability);
106
- const sColor = starColor(reliability);
107
- const displayName = truncateName(skill.name);
108
112
  const indentLevel = item.indent ?? 1;
113
+ // Cells before and after the name: indent, two squares and a space; the
114
+ // update arrow; the star count. The name takes what is left, in cells.
115
+ const fixed =
116
+ indentLevel +
117
+ 3 +
118
+ (skill.hasUpdate ? 2 : 0) +
119
+ (starsStr ? displayWidth(starsStr) + 2 : 0);
120
+ const displayName = truncate(
121
+ skill.name,
122
+ Math.max(1, Math.min(MAX_SKILL_NAME_LEN, width - fixed)),
123
+ );
109
124
 
110
125
  return (
111
126
  <SelectableRow selected={isSelected} indent={indentLevel}>
@@ -118,7 +133,7 @@ const skillRenderer: ItemRenderer<SkillSkillItem> = {
118
133
  <span
119
134
  fg={
120
135
  isSelected
121
- ? theme.colors.text
136
+ ? theme.selection.fg
122
137
  : skill.installed
123
138
  ? theme.colors.text
124
139
  : theme.colors.muted
@@ -129,170 +144,208 @@ const skillRenderer: ItemRenderer<SkillSkillItem> = {
129
144
  {skill.hasUpdate ? (
130
145
  <MetaText text=" ⬆" tone="warning" selected={isSelected} />
131
146
  ) : null}
132
- {starsStr ? <span fg={sColor}>{` ${starsStr}`}</span> : null}
147
+ {starsStr ? (
148
+ <MetaText
149
+ text={` ${starsStr}`}
150
+ tone={starTone(reliability)}
151
+ selected={isSelected}
152
+ />
153
+ ) : null}
133
154
  </SelectableRow>
134
155
  );
135
156
  },
136
157
 
137
- renderDetail: ({ item }) => {
138
- const { skill } = item;
139
- const fm = skill.frontmatter;
140
- const description = fm?.description || skill.description || "Loading...";
141
- const reliability = skill.starReliability;
142
- const starsStr = formatStars(skill.stars, reliability);
143
- const sColor = starColor(reliability);
144
- const reliabilityInfo = reliability
145
- ? STAR_RELIABILITY_INFO[reliability]
146
- : null;
158
+ renderDetail: ({ item }) => skillDetail(item.skill, {}),
159
+ };
147
160
 
148
- return (
149
- <box flexDirection="column">
150
- <text fg={theme.colors.info}>
151
- <strong>{skill.name}</strong>
152
- {starsStr ? <span fg={sColor}> {starsStr}</span> : null}
153
- </text>
161
+ /** What the detail pane knows about a skill's description fetch. */
162
+ export interface SkillDetailState {
163
+ /** The remote SKILL.md fetch failed. */
164
+ descriptionFailed?: boolean;
165
+ }
154
166
 
155
- <box marginTop={1}>
156
- <text fg={theme.colors.text}>{description}</text>
157
- </box>
167
+ /**
168
+ * The description line, always in a definite state.
169
+ *
170
+ * It used to fall back to "Loading..." whenever the description was empty, so a
171
+ * LOCAL skill — which is never fetched — and a remote one whose fetch had failed
172
+ * both sat on "Loading..." for good. Now: the text when there is one; for a local
173
+ * skill, the fact that its SKILL.md has none; for a remote one, loading only
174
+ * while a fetch is outstanding, and the failure once it is not.
175
+ */
176
+ function descriptionLine(
177
+ skill: SkillInfo,
178
+ state: SkillDetailState,
179
+ ): { text: string; tone: UiColor } {
180
+ const text = skill.frontmatter?.description || skill.description;
181
+ if (text) return { text, tone: theme.colors.text };
182
+ if (skill.source.repo === "local") {
183
+ return {
184
+ text: "No description in its SKILL.md.",
185
+ tone: theme.colors.muted,
186
+ };
187
+ }
188
+ if (state.descriptionFailed) {
189
+ return {
190
+ text: "Could not fetch the description. Press r to retry.",
191
+ tone: theme.colors.warning,
192
+ };
193
+ }
194
+ if (skill.frontmatter) {
195
+ return { text: "No description.", tone: theme.colors.muted };
196
+ }
197
+ return { text: "Loading the description…", tone: theme.colors.muted };
198
+ }
158
199
 
159
- {fm?.category ? (
160
- <KeyValueLine
161
- label="Category"
162
- value={<span fg={theme.colors.info}>{fm.category}</span>}
163
- />
164
- ) : null}
165
- {fm?.author ? (
166
- <KeyValueLine label="Author" value={<span>{fm.author}</span>} />
167
- ) : null}
168
- {fm?.version ? (
169
- <KeyValueLine label="Version" value={<span>{fm.version}</span>} />
170
- ) : null}
171
- {fm?.tags && fm.tags.length > 0 ? (
172
- <KeyValueLine
173
- label="Tags"
174
- value={<span>{(fm.tags as string[]).join(", ")}</span>}
175
- />
176
- ) : null}
200
+ function skillDetail(
201
+ skill: SkillInfo,
202
+ state: SkillDetailState,
203
+ ): React.ReactNode {
204
+ const fm = skill.frontmatter;
205
+ const description = descriptionLine(skill, state);
206
+ const reliability = skill.starReliability;
207
+ const starsStr = formatStars(skill.stars, reliability);
208
+ const sColor = starColor(reliability);
209
+ const reliabilityInfo = reliability
210
+ ? STAR_RELIABILITY_INFO[reliability]
211
+ : null;
212
+
213
+ return (
214
+ <box flexDirection="column">
215
+ <text fg={theme.colors.info}>
216
+ <strong>{skill.name}</strong>
217
+ {starsStr ? <span fg={sColor}> {starsStr}</span> : null}
218
+ </text>
219
+
220
+ <box marginTop={1}>
221
+ <text fg={description.tone}>{description.text}</text>
222
+ </box>
177
223
 
178
- <DetailSection>
179
- <text fg={theme.colors.text}>
180
- <span fg={theme.colors.muted}>Source </span>
181
- <span fg={theme.colors.link}>{skill.source.repo}</span>
182
- </text>
183
- <text fg={theme.colors.text}>
184
- <span fg={theme.colors.muted}>{" "}</span>
185
- <span fg={theme.colors.muted}>{skill.repoPath}</span>
186
- </text>
187
- </DetailSection>
224
+ {fm?.category ? (
225
+ <KeyValueLine
226
+ label="Category"
227
+ value={<span fg={theme.colors.info}>{fm.category}</span>}
228
+ />
229
+ ) : null}
230
+ {fm?.author ? (
231
+ <KeyValueLine label="Author" value={<span>{fm.author}</span>} />
232
+ ) : null}
233
+ {fm?.version ? (
234
+ <KeyValueLine label="Version" value={<span>{fm.version}</span>} />
235
+ ) : null}
236
+ {fm?.tags && fm.tags.length > 0 ? (
237
+ <KeyValueLine
238
+ label="Tags"
239
+ value={<span>{(fm.tags as string[]).join(", ")}</span>}
240
+ />
241
+ ) : null}
188
242
 
189
- {reliabilityInfo && reliability !== "dedicated" && (
190
- <box marginTop={1}>
191
- <text fg={theme.colors.text}>
192
- <span fg={sColor}>{starIcon(reliability)} </span>
193
- <span fg={sColor}>
194
- <strong>{reliabilityInfo.label}</strong>
195
- </span>
196
- <span fg={theme.colors.muted}>
197
- {" "}
198
- — {reliabilityInfo.description}
199
- </span>
200
- </text>
201
- </box>
202
- )}
243
+ <DetailSection>
244
+ <text fg={theme.colors.text}>
245
+ <span fg={theme.colors.muted}>Source </span>
246
+ <span fg={theme.colors.info}>{skill.source.repo}</span>
247
+ </text>
248
+ <text fg={theme.colors.text}>
249
+ <span fg={theme.colors.muted}>{" "}</span>
250
+ <span fg={theme.colors.muted}>{skill.repoPath}</span>
251
+ </text>
252
+ </DetailSection>
203
253
 
204
- <DetailSection>
205
- <text fg={theme.colors.text}>{"─".repeat(24)}</text>
254
+ {reliabilityInfo && reliability !== "dedicated" && (
255
+ <box marginTop={1}>
206
256
  <text fg={theme.colors.text}>
207
- <strong>Scopes:</strong>
208
- </text>
209
- <box marginTop={1} flexDirection="column">
210
- <text fg={theme.colors.text}>
211
- <span bg={theme.colors.info} fg={theme.hints.fg}>
212
- {" "}
213
- u{" "}
214
- </span>
215
- <span
216
- fg={
217
- skill.installedScope === "user"
218
- ? theme.colors.info
219
- : theme.colors.muted
220
- }
221
- >
222
- {skill.installedScope === "user" ? " ● " : " ○ "}
223
- </span>
224
- <span fg={theme.colors.info}>User</span>
225
- <span fg={theme.colors.muted}> ~/.claude/skills/</span>
226
- </text>
227
- <text fg={theme.colors.text}>
228
- <span bg={theme.colors.success} fg={theme.hints.fg}>
229
- {" "}
230
- p{" "}
231
- </span>
232
- <span
233
- fg={
234
- skill.installedScope === "project"
235
- ? theme.colors.success
236
- : theme.colors.muted
237
- }
238
- >
239
- {skill.installedScope === "project" ? " ● " : " ○ "}
240
- </span>
241
- <span fg={theme.colors.success}>Project</span>
242
- <span fg={theme.colors.muted}> .claude/skills/</span>
243
- </text>
244
- </box>
245
- </DetailSection>
246
-
247
- {skill.hasUpdate && (
248
- <box marginTop={1}>
249
- <text bg={theme.colors.warning} fg={theme.hints.fg}>
257
+ <span fg={sColor}>{starIcon(reliability)} </span>
258
+ <span fg={sColor}>
259
+ <strong>{reliabilityInfo.label}</strong>
260
+ </span>
261
+ <span fg={theme.colors.muted}>
250
262
  {" "}
251
- UPDATE AVAILABLE{" "}
252
- </text>
253
- </box>
254
- )}
263
+ — {reliabilityInfo.description}
264
+ </span>
265
+ </text>
266
+ </box>
267
+ )}
255
268
 
269
+ <DetailSection>
270
+ <text fg={theme.colors.text}>{"─".repeat(24)}</text>
271
+ <text fg={theme.colors.text}>
272
+ <strong>Scopes:</strong>
273
+ </text>
256
274
  <box marginTop={1}>
257
- <text fg={theme.colors.muted}>
258
- {skill.installed
259
- ? "Press u/p to toggle scope"
260
- : "Press u/p to install"}
261
- </text>
275
+ <ScopeDetail
276
+ scopes={{
277
+ user: skill.installedScope === "user",
278
+ project: skill.installedScope === "project",
279
+ }}
280
+ paths={{ user: "~/.claude/skills/", project: ".claude/skills/" }}
281
+ />
262
282
  </box>
263
- <box>
264
- <text fg={theme.colors.text}>
265
- <span bg={theme.colors.dim} fg={theme.hints.fg}>
266
- {" "}
267
- o{" "}
268
- </span>
269
- <span fg={theme.colors.muted}> Open in browser</span>
283
+ </DetailSection>
284
+
285
+ {skill.hasUpdate && (
286
+ <box marginTop={1}>
287
+ <text bg={theme.colors.warning} fg={theme.hints.fg}>
288
+ {" "}
289
+ UPDATE AVAILABLE{" "}
270
290
  </text>
271
291
  </box>
292
+ )}
293
+
294
+ <box marginTop={1}>
295
+ <text fg={theme.colors.muted}>
296
+ {skill.installed
297
+ ? "Press u/p to toggle scope"
298
+ : "Press u/p to install"}
299
+ </text>
272
300
  </box>
273
- );
274
- },
275
- };
301
+ <box>
302
+ <text fg={theme.colors.text}>
303
+ <span bg={theme.colors.dim} fg={theme.hints.fg}>
304
+ {" "}
305
+ o{" "}
306
+ </span>
307
+ <span fg={theme.colors.muted}> Open in browser</span>
308
+ </text>
309
+ </box>
310
+ </box>
311
+ );
312
+ }
276
313
 
277
314
  // ─── Skill Set renderer ──────────────────────────────────────────────────────
278
315
 
279
- const MAX_SET_NAME_LEN = 28;
316
+ /**
317
+ * A skill set's icon in exactly two cells.
318
+ *
319
+ * The icons come from a remote catalogue, so their widths are whatever its
320
+ * author typed: most are wide emoji (\uD83E\uDD17 \uD83C\uDF10 \uD83E\uDDF1 \uD83E\uDDF0, two cells), one is `\u2601\uFE0F` \u2014 a
321
+ * one-cell symbol plus U+FE0F asking for emoji presentation. MEASURED in tmux
322
+ * under OpenTUI 0.1.107, that pair paints as two BLANK cells: the icon vanished.
323
+ * Dropping the selector draws the plain `\u2601`, and padding to two cells in
324
+ * `displayWidth` terms puts every name after it in the same column, whatever
325
+ * the icon's width.
326
+ */
327
+ function iconCell(icon: string): string {
328
+ return padTo(icon.replaceAll("\uFE0F", ""), 2);
329
+ }
330
+
331
+ /** Row cells before the name: lead, arrow, space, icon, space. */
332
+ const SET_ROW_HEAD = 6;
333
+ /** Row cells after the stars: a space and the two scope squares. */
334
+ const SET_ROW_TAIL = 3;
335
+ /** Below this many cells for the name, the star count is dropped first. */
336
+ const MIN_SET_NAME = 8;
280
337
 
281
338
  const skillSetRenderer: ItemRenderer<SkillSetItem> = {
282
- renderRow: ({ item, isSelected }) => {
339
+ renderRow: ({ item, isSelected, width = DEFAULT_ROW_WIDTH }) => {
283
340
  const { skillSet, expanded } = item;
284
341
  const arrow = expanded ? "\u25BC" : "\u25B6";
285
- const displayName =
286
- skillSet.name.length > MAX_SET_NAME_LEN
287
- ? `${skillSet.name.slice(0, MAX_SET_NAME_LEN - 1)}\u2026`
288
- : skillSet.name;
289
342
 
290
343
  // Count installed child skills
291
344
  const installedCount = skillSet.loaded
292
345
  ? skillSet.skills.filter((s) => s.installed).length
293
346
  : 0;
294
347
  const totalCount = skillSet.loaded ? skillSet.skills.length : "...";
295
- const countStr = `(${installedCount}/${totalCount})`;
348
+ const count = ` (${installedCount}/${totalCount})`;
296
349
 
297
350
  // Any child installed at user or project scope?
298
351
  const hasUser =
@@ -302,28 +355,34 @@ const skillSetRenderer: ItemRenderer<SkillSetItem> = {
302
355
  skillSet.loaded &&
303
356
  skillSet.skills.some((s) => s.installedScope === "project");
304
357
 
305
- const starsStr = formatStars(skillSet.stars);
358
+ // Fitted in cells, not characters: the row is measured end to end so the
359
+ // panel never has to clip it, and a narrow panel loses the star count
360
+ // before it loses the name.
361
+ const fixed = SET_ROW_HEAD + displayWidth(count) + SET_ROW_TAIL;
362
+ const starsFull = skillSet.stars ? ` ${formatStars(skillSet.stars)}` : "";
363
+ const stars =
364
+ width - fixed - displayWidth(starsFull) >= MIN_SET_NAME ? starsFull : "";
365
+ const displayName = truncate(
366
+ skillSet.name,
367
+ Math.max(1, width - fixed - displayWidth(stars)),
368
+ );
306
369
 
307
370
  return (
308
371
  <SelectableRow selected={isSelected} indent={1}>
309
- <span fg={isSelected ? theme.colors.text : theme.colors.muted}>
372
+ <span fg={isSelected ? theme.selection.fg : theme.colors.muted}>
310
373
  {arrow}{" "}
311
374
  </span>
312
- <span fg={isSelected ? theme.colors.text : theme.colors.warning}>
313
- {skillSet.icon}{" "}
375
+ <span fg={isSelected ? theme.selection.fg : theme.colors.text}>
376
+ {iconCell(skillSet.icon)}{" "}
314
377
  </span>
315
- <span fg={isSelected ? theme.colors.text : theme.colors.info}>
378
+ <span fg={isSelected ? theme.selection.fg : theme.colors.info}>
316
379
  <strong>{displayName}</strong>
317
380
  </span>
318
381
  <span fg={isSelected ? theme.selection.dim : theme.colors.muted}>
319
- {` ${countStr}`}
382
+ {count}
320
383
  </span>
321
- {starsStr ? (
322
- <MetaText
323
- text={` ${starsStr}`}
324
- tone="warning"
325
- selected={isSelected}
326
- />
384
+ {stars ? (
385
+ <MetaText text={stars} tone="info" selected={isSelected} />
327
386
  ) : null}
328
387
  <span> </span>
329
388
  <ScopeSquares
@@ -343,9 +402,9 @@ const skillSetRenderer: ItemRenderer<SkillSetItem> = {
343
402
  <box flexDirection="column">
344
403
  <text fg={theme.colors.info}>
345
404
  <strong>
346
- {skillSet.icon} {skillSet.name}
405
+ {iconCell(skillSet.icon)} {skillSet.name}
347
406
  </strong>
348
- {starsStr ? <span fg={theme.colors.warning}> {starsStr}</span> : null}
407
+ {starsStr ? <span fg={theme.colors.info}> {starsStr}</span> : null}
349
408
  </text>
350
409
 
351
410
  <box marginTop={1}>
@@ -355,13 +414,13 @@ const skillSetRenderer: ItemRenderer<SkillSetItem> = {
355
414
  <DetailSection>
356
415
  <text fg={theme.colors.text}>
357
416
  <span fg={theme.colors.muted}>Source </span>
358
- <span fg={theme.colors.link}>{skillSet.repo}</span>
417
+ <span fg={theme.colors.info}>{skillSet.repo}</span>
359
418
  </text>
360
419
  </DetailSection>
361
420
 
362
421
  {skillSet.loading && (
363
422
  <box marginTop={1}>
364
- <text fg={theme.colors.warning}>Loading skills...</text>
423
+ <text fg={theme.colors.muted}>Loading skills…</text>
365
424
  </box>
366
425
  )}
367
426
 
@@ -383,25 +442,11 @@ const skillSetRenderer: ItemRenderer<SkillSetItem> = {
383
442
  </text>
384
443
  <box marginTop={1} flexDirection="column">
385
444
  {skillSet.skills.map((s) => (
386
- <text key={s.id}>
387
- <span
388
- fg={
389
- s.installedScope === "user"
390
- ? theme.colors.info
391
- : theme.colors.muted
392
- }
393
- >
394
- {s.installedScope === "user" ? "\u25A0" : "\u25A1"}
395
- </span>
396
- <span
397
- fg={
398
- s.installedScope === "project"
399
- ? theme.colors.success
400
- : theme.colors.muted
401
- }
402
- >
403
- {s.installedScope === "project" ? "\u25A0" : "\u25A1"}
404
- </span>
445
+ <text key={s.id} fg={theme.colors.text}>
446
+ <ScopeSquares
447
+ user={s.installedScope === "user"}
448
+ project={s.installedScope === "project"}
449
+ />
405
450
  <span
406
451
  fg={s.installed ? theme.colors.text : theme.colors.muted}
407
452
  >
@@ -448,24 +493,26 @@ export const skillRenderers: {
448
493
  };
449
494
 
450
495
  /**
451
- * Dispatch rendering by item kind.
496
+ * Dispatch rendering by item kind, fitted to `width` — the list panel's
497
+ * usable columns.
452
498
  */
453
499
  export function renderSkillRow(
454
500
  item: SkillBrowserItem,
455
- _index: number,
456
501
  isSelected: boolean,
502
+ width: number,
457
503
  ): React.ReactNode {
458
504
  if (item.kind === "category") {
459
- return skillRenderers.category.renderRow({ item, isSelected });
505
+ return skillRenderers.category.renderRow({ item, isSelected, width });
460
506
  }
461
507
  if (item.kind === "skillset") {
462
- return skillRenderers.skillset.renderRow({ item, isSelected });
508
+ return skillRenderers.skillset.renderRow({ item, isSelected, width });
463
509
  }
464
- return skillRenderers.skill.renderRow({ item, isSelected });
510
+ return skillRenderers.skill.renderRow({ item, isSelected, width });
465
511
  }
466
512
 
467
513
  export function renderSkillDetail(
468
514
  item: SkillBrowserItem | undefined,
515
+ state: SkillDetailState = {},
469
516
  ): React.ReactNode {
470
517
  if (!item)
471
518
  return <text fg={theme.colors.muted}>Select a skill to see details</text>;
@@ -475,5 +522,5 @@ export function renderSkillDetail(
475
522
  if (item.kind === "skillset") {
476
523
  return skillRenderers.skillset.renderDetail({ item });
477
524
  }
478
- return skillRenderers.skill.renderDetail({ item });
525
+ return skillDetail(item.skill, state);
479
526
  }
@@ -32,13 +32,24 @@ function truncate(text: string, max: number): string {
32
32
  * selection) are distinct on purpose: the whole point of a staged multi-select
33
33
  * is that you can see, before pressing apply, how the pending set differs from
34
34
  * what is live. A single glyph for both would hide exactly that.
35
+ *
36
+ * The glyph carries the state; the colour only repeats it. Both pending states
37
+ * — `[+]` will be added, `[~]` will be removed — are unapplied changes, so both
38
+ * are `warning`. `[~]` used to be `danger`, but nothing is broken: it is a
39
+ * change waiting for `a`, exactly like `[+]`.
40
+ *
41
+ * `[–]` is an EN DASH, and must stay one: it is the symbol the help legend
42
+ * explains ("a template; fill it before it can be ticked"). A hyphen drew a
43
+ * glyph the legend did not name.
35
44
  */
45
+ const TEMPLATE_GLYPH = "[–]";
46
+
36
47
  function checkbox(item: StyleEntryItem): { glyph: string; tone: string } {
37
- if (item.disabled) return { glyph: "[-]", tone: theme.colors.muted };
48
+ if (item.disabled) return { glyph: TEMPLATE_GLYPH, tone: theme.colors.muted };
38
49
  if (item.checked && item.applied)
39
50
  return { glyph: "[x]", tone: theme.colors.success };
40
51
  if (item.checked) return { glyph: "[+]", tone: theme.colors.warning };
41
- if (item.applied) return { glyph: "[~]", tone: theme.colors.danger };
52
+ if (item.applied) return { glyph: "[~]", tone: theme.colors.warning };
42
53
  return { glyph: "[ ]", tone: theme.colors.muted };
43
54
  }
44
55