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
@@ -0,0 +1,341 @@
1
+ /**
2
+ * ui/text-width.ts — a copy of the bunjs `tui` skill's `assets/theme/text.ts`.
3
+ * Copied, never retyped; the lint override for it lives in biome.json.
4
+ *
5
+ * Display-cell maths OpenTUI does not ship: `text-table-width.ts`
6
+ * is internal, and Yoga measures layout, not the columns a string occupies. All
7
+ * four count TERMINAL DISPLAY COLUMNS, never code units. Surface-neutral: no
8
+ * OpenTUI import in this file.
9
+ *
10
+ * TWO CONTRACTS, and they are deliberately different:
11
+ *
12
+ * - `displayWidth` MEASURES. It reports what a terminal paints, so a control
13
+ * character costs 0 — that is the oracle's answer and the terminal's.
14
+ * - `truncate` / `padTo` / `padStartTo` PRODUCE. They sanitise first (see
15
+ * `sanitize`), so their output is always ONE LINE of exactly the columns
16
+ * claimed. `displayWidth(s)` and `displayWidth(padTo(s, w))` therefore need not
17
+ * agree for control-bearing `s`, and that is stated here rather than left to be
18
+ * discovered: a newline costs 0 columns to measure and 1 column to print.
19
+ *
20
+ * ANSI escapes are still NOT accepted — styled text in is a caller bug. It no
21
+ * longer corrupts the layout (the ESC is neutralised to a space, so it cannot move
22
+ * the cursor), but the sequence body prints literally. That is the intended loud
23
+ * failure; do not "fix" it by stripping sequences, which would silently swallow the
24
+ * caller's bug and their colours with it.
25
+ */
26
+
27
+ /**
28
+ * `Bun.stringWidth` — a native, maintained Unicode width table — is the width
29
+ * oracle whenever it exists, which per this skill's stated support matrix is
30
+ * always. The hand table below is a FALLBACK for a non-Bun runtime, nothing more.
31
+ *
32
+ * Bound through `globalThis` rather than the `Bun` global so this file typechecks
33
+ * when it is copied into a project with no `@types/bun`. Bun's defaults are the
34
+ * ones wanted: `ambiguousIsNarrow` (so `⚠` is 1 column, as every modern terminal
35
+ * paints it) and ANSI excluded from the count.
36
+ */
37
+ const NATIVE_WIDTH: ((s: string) => number) | null = (() => {
38
+ const b = (globalThis as { Bun?: { stringWidth?: (s: string) => number } })
39
+ .Bun;
40
+ return typeof b?.stringWidth === "function" ? b.stringWidth.bind(b) : null;
41
+ })();
42
+
43
+ /**
44
+ * FALLBACK ONLY — East_Asian_Width Wide/Fullwidth, MINUS the emoji, which
45
+ * `\p{Emoji_Presentation}` covers far better than any literal range can. Sorted and
46
+ * non-overlapping; `isWide` binary-searches it.
47
+ *
48
+ * Splitting the work this way is what fixed the four measured defects at once: the
49
+ * engine's own Unicode tables answer "is this an emoji that defaults to Wide?", so
50
+ * `🌡` and `🖥` (text presentation) stop being force-widened by a block-coarse
51
+ * `[0x1f300, 0x1f9ff]` run, while an explicit range brings in the non-emoji planes
52
+ * that run never covered — Tangut, Nushu, Khitan, Kana Extended.
53
+ *
54
+ * MEASURED 2026-07-30 against `Bun.stringWidth` across all 1,112,064 codepoints:
55
+ * 1,081 disagreements, down from 11,205 for the previous hand table. 749 are lone
56
+ * combining marks and 237 are codepoints this engine's Unicode tables do not know;
57
+ * neither occurs in real text. Only 17 are anything else, 14 of them Unicode 16
58
+ * emoji where the FALLBACK is right and the oracle is behind. `color.test.ts` pins
59
+ * every one of those numbers, so widening a run without a sweep fails the suite.
60
+ */
61
+ const WIDE: ReadonlyArray<readonly [number, number]> = [
62
+ [0x1100, 0x115f],
63
+ [0x2329, 0x232a],
64
+ [0x2e80, 0x303e],
65
+ [0x3041, 0x31e3],
66
+ [0x31e6, 0x3247],
67
+ [0x3250, 0x33ff],
68
+ [0x3400, 0x4dbf],
69
+ [0x4e00, 0x9fff],
70
+ [0xa000, 0xa4cf],
71
+ [0xa960, 0xa97f],
72
+ [0xac00, 0xd7a3],
73
+ [0xf900, 0xfaff],
74
+ [0xfe10, 0xfe19],
75
+ [0xfe30, 0xfe6f],
76
+ [0xff00, 0xff60],
77
+ [0xffe0, 0xffe6],
78
+ [0x16fe0, 0x16fe4],
79
+ [0x16ff0, 0x16ff1],
80
+ [0x17000, 0x187f7],
81
+ [0x18800, 0x18cd5],
82
+ [0x18d00, 0x18d08],
83
+ [0x1aff0, 0x1aff3],
84
+ [0x1aff5, 0x1affb],
85
+ [0x1affd, 0x1affe],
86
+ [0x1b000, 0x1b122],
87
+ [0x1b132, 0x1b132],
88
+ [0x1b150, 0x1b152],
89
+ [0x1b155, 0x1b155],
90
+ [0x1b164, 0x1b167],
91
+ [0x1b170, 0x1b2fb],
92
+ [0x1f200, 0x1f202],
93
+ [0x1f210, 0x1f23b],
94
+ [0x1f240, 0x1f248],
95
+ [0x1f250, 0x1f251],
96
+ [0x1f260, 0x1f265],
97
+ [0x20000, 0x2fffd],
98
+ [0x30000, 0x3fffd],
99
+ ];
100
+
101
+ const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
102
+
103
+ /** U+FE0F VARIATION SELECTOR-16, spelled by codepoint: as a bare literal it is an
104
+ * invisible character in the source, which is how a `includes("…")` test for it
105
+ * survives review while matching nothing. Its sibling U+FE0E (text presentation)
106
+ * needs no branch — MEASURED against the oracle, it narrows nothing: `⚠︎` is 1
107
+ * because `⚠` already is, and `⛔︎` stays 2 because U+26D4 is intrinsically Wide. */
108
+ const VS16 = "\uFE0F";
109
+
110
+ /** Zero-width in the fallback: format characters (`Cf`) — U+00AD SOFT HYPHEN,
111
+ * U+200B ZERO WIDTH SPACE, U+200D ZWJ, U+FEFF. The previous table had no notion of
112
+ * these at all and billed each one a column. Anchored, because it classifies a
113
+ * grapheme cluster by the codepoint that STARTS it. */
114
+ const LEADING_FORMAT = /^\p{Cf}/u;
115
+
116
+ /** Wide in the fallback, part two: emoji whose default presentation is Wide. This
117
+ * is the engine's own table, so it tracks Unicode releases without an edit here. */
118
+ const LEADING_EMOJI_PRESENTATION = /^\p{Emoji_Presentation}/u;
119
+
120
+ /** C0, DEL and C1. Every one is unprintable, and three of them actively wreck a
121
+ * frame: `\n` breaks the row, `\t` advances to an unknowable tab stop, ESC can
122
+ * reposition the cursor anywhere on screen. */
123
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
124
+
125
+ /**
126
+ * THE CONTROL-CHARACTER CONTRACT for everything that returns a string: every C0
127
+ * control, DEL and every C1 control is REPLACED BY ONE SPACE. Not stripped, not
128
+ * rejected.
129
+ *
130
+ * - Not rejected, because these functions render whatever a log line contains, and
131
+ * a tab in a log line must not throw inside a render loop.
132
+ * - Not stripped, because deleting `\n` fuses the words either side of it
133
+ * (`"a\nbc"` → `"abc"`), which reads as a different message. One space keeps the
134
+ * boundary and costs exactly one predictable column.
135
+ * - One rule with no exceptions, so the guarantee is total: the output of
136
+ * `truncate`, `padTo` and `padStartTo` can never contain a newline, a tab or an
137
+ * ESC, and `Bun.stringWidth(padTo(s, w)) === w` holds for EVERY `s`.
138
+ *
139
+ * `.replace` with a `/g` regex is used unconditionally — `CONTROL.test()` as a
140
+ * fast-path guard would be a stateful-`lastIndex` bug that skips every other call.
141
+ */
142
+ const sanitize = (s: string): string => s.replace(CONTROL, " ");
143
+
144
+ /**
145
+ * THE NUMERIC CONTRACT for every column count in this file: floored to whole
146
+ * columns, clamped at 0, and NON-FINITE THROWS.
147
+ *
148
+ * Fractional is tolerated because it is a legitimate computation — `width / 3`
149
+ * across a flex row — and half a column cannot be painted, so flooring is the only
150
+ * answer and it cannot be wrong. Non-finite is never a legitimate computation: it
151
+ * means the caller's arithmetic already broke, every column downstream is garbage,
152
+ * and there is no honest value to return. `padTo("abcdef", NaN)` used to answer six
153
+ * columns and `padTo("abcdef", Infinity)` used to throw a `RangeError` from
154
+ * `String.repeat` naming neither the function nor the argument. Both now fail at
155
+ * the boundary, loudly, pointing at the caller.
156
+ *
157
+ * Note this governs WIDTHS only. A `splitCells` PART is a datum, not a dimension,
158
+ * and keeps its documented "negative/NaN/Infinity → 0" rule: a missing measurement
159
+ * sensibly contributes nothing, whereas a missing dimension has no sensible value.
160
+ */
161
+ function columns(n: number, fn: string, arg = "width"): number {
162
+ if (!Number.isFinite(n))
163
+ throw new RangeError(`${fn}: ${arg} must be a finite number, got ${n}`);
164
+ return Math.max(0, Math.floor(n));
165
+ }
166
+
167
+ /** Binary search over `WIDE`. Linear `.some()` was fine at 18 runs and is not at 37:
168
+ * this runs once per grapheme on the fallback path. */
169
+ function isWide(cp: number): boolean {
170
+ let lo = 0;
171
+ let hi = WIDE.length - 1;
172
+ while (lo <= hi) {
173
+ const mid = (lo + hi) >> 1;
174
+ const [a, b] = WIDE[mid]!;
175
+ if (cp < a) hi = mid - 1;
176
+ else if (cp > b) lo = mid + 1;
177
+ else return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /** Fallback width of ONE grapheme cluster, classified by the codepoint that starts
183
+ * it plus a VS16 check. Exported for the test that measures the fallback against
184
+ * the oracle — without a way to reach it, the fallback would ship unmeasured on
185
+ * the one runtime that never runs the oracle. */
186
+ export function fallbackClusterWidth(cluster: string): number {
187
+ const cp = cluster.codePointAt(0) ?? 0;
188
+ if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f) || LEADING_FORMAT.test(cluster))
189
+ return 0;
190
+ return isWide(cp) ||
191
+ LEADING_EMOJI_PRESENTATION.test(cluster) ||
192
+ cluster.includes(VS16)
193
+ ? 2
194
+ : 1;
195
+ }
196
+
197
+ /**
198
+ * Columns `s` occupies. `Bun.stringWidth` answers this directly and correctly; the
199
+ * loop below is the fallback for a runtime without it and counts GRAPHEME CLUSTERS,
200
+ * so a cluster costs the same however many code units it spans.
201
+ *
202
+ * Either way: controls and format characters are 0, a ZWJ sequence counts as its
203
+ * base cluster (one emoji = 2), Wide/Fullwidth and U+FE0F-forced emoji presentation
204
+ * are 2, everything else printable is 1. Both paths are grapheme-additive — summing
205
+ * per cluster equals measuring the whole string — which is what lets `truncate`
206
+ * clip cluster by cluster and still land on the width it promises.
207
+ */
208
+ export function displayWidth(s: string): number {
209
+ if (NATIVE_WIDTH) return NATIVE_WIDTH(s);
210
+ let w = 0;
211
+ for (const { segment } of seg.segment(s)) w += fallbackClusterWidth(segment);
212
+ return w;
213
+ }
214
+
215
+ /**
216
+ * Clip to `width` columns, NEVER WRAP: the last column becomes "…". A wide grapheme
217
+ * that would straddle the edge is dropped whole, never split in half.
218
+ *
219
+ * "Never wrap" is a promise about the OUTPUT, so the input is sanitised first — a
220
+ * returned `"a\n…"` was two rows wearing a one-row width, and it reached `padTo`
221
+ * and `padStartTo` too. Output may be SHORTER than `width` when a wide cluster is
222
+ * dropped (`truncate("日本語", 4)` is 3 columns); it is never longer, and it is
223
+ * never more than one line.
224
+ */
225
+ export function truncate(s: string, width: number): string {
226
+ const n = columns(width, "truncate");
227
+ if (n === 0) return "";
228
+ const clean = sanitize(s);
229
+ if (displayWidth(clean) <= n) return clean;
230
+ let out = "";
231
+ let w = 0;
232
+ for (const { segment } of seg.segment(clean)) {
233
+ const cw = displayWidth(segment);
234
+ if (w + cw > n - 1) break;
235
+ out += segment;
236
+ w += cw;
237
+ }
238
+ return `${out}…`;
239
+ }
240
+
241
+ /**
242
+ * Exactly `width` columns: clip if long, pad if short. `displayWidth(padTo(s, w))
243
+ * === w`, always — and so does `Bun.stringWidth(padTo(s, w))`, which is the
244
+ * stronger claim and the one a terminal actually settles.
245
+ *
246
+ * `padEnd()` and `.slice()` are BANNED here — they count UTF-16 code units, so they
247
+ * split surrogate pairs and under-count CJK by half, the exact failure
248
+ * `displayWidth` exists to fix. Build on truncate + displayWidth.
249
+ */
250
+ export function padTo(s: string, width: number): string {
251
+ const n = columns(width, "padTo");
252
+ if (n === 0) return "";
253
+ const clean = sanitize(s);
254
+ const clipped = displayWidth(clean) > n ? truncate(clean, n) : clean;
255
+ return clipped + " ".repeat(Math.max(0, n - displayWidth(clipped)));
256
+ }
257
+
258
+ /**
259
+ * `padTo`'s mirror: exactly `width` columns with the content flush RIGHT. This is
260
+ * what `aesthetics-and-color.md`'s "right-align numerals and pad to a fixed width"
261
+ * needs — `padTo` left-aligns, so a numeral column padded with it jitters at the
262
+ * decimal point, which is the drift that rule exists to stop.
263
+ *
264
+ * `padStart()` is BANNED here for the same reason as `padEnd()`: it counts UTF-16
265
+ * code units, so `"日本".padStart(6)` adds 4 spaces for a string already 4 columns
266
+ * wide. Built on `truncate` + `displayWidth`, so `displayWidth(padStartTo(s, w)) === w`
267
+ * holds for CJK, combining marks and ZWJ emoji alike.
268
+ *
269
+ * Over-long input clips from the END (`"1234567" → "123…"`), matching `padTo`: keeping
270
+ * the tail instead would print a numeral's low-order digits and read as a smaller
271
+ * number.
272
+ */
273
+ export function padStartTo(s: string, width: number): string {
274
+ const n = columns(width, "padStartTo");
275
+ if (n === 0) return "";
276
+ const clean = sanitize(s);
277
+ const clipped = displayWidth(clean) > n ? truncate(clean, n) : clean;
278
+ return " ".repeat(Math.max(0, n - displayWidth(clipped))) + clipped;
279
+ }
280
+
281
+ /**
282
+ * Apportion `cells` across `parts` by share, LARGEST-REMAINDER rounding, so the
283
+ * result sums to EXACTLY `cells` — naive per-segment rounding leaves a stacked bar
284
+ * a cell short and every column right of it drifts. Precondition
285
+ * `cells >= nonZeroParts`: every non-zero part then gets >= 1 cell, stolen from the
286
+ * largest allocation. Over-subscribed (4 parts into 3 cells) that is dropped — the
287
+ * `cells` largest parts get 1 each, rest 0, sum still `cells`. ALL PARTS ZERO →
288
+ * zeros, sum 0, NOT `cells`.
289
+ *
290
+ * `cells` is a DIMENSION and obeys `columns()`: floored, clamped at 0, non-finite
291
+ * throws. `cells <= 0` → zeros. It used to return `[2, 1]` for two parts and 2.5
292
+ * cells — a sum of 3 from a request for 2.5, breaking the one invariant this
293
+ * function exists to hold — and NaN allocations for NaN or Infinity.
294
+ *
295
+ * `parts` are DATA and are forgiving: negative, NaN and Infinity all read as 0.
296
+ * They are also overflow-safe. `[MAX_VALUE, MAX_VALUE]` overflowed `sum` to
297
+ * Infinity and made every share `Infinity / Infinity` = NaN; dividing through by
298
+ * the largest part first is exact in ratio and lands every share back in 0..1.
299
+ * Shares are then `cells * (p / sum)` and not `(cells * p) / sum`, because the
300
+ * latter overflows on a single part near MAX_VALUE even when `sum` is finite.
301
+ */
302
+ export function splitCells(parts: number[], cells: number): number[] {
303
+ const n = columns(cells, "splitCells", "cells");
304
+ const finite = parts.map((p) => (Number.isFinite(p) && p > 0 ? p : 0));
305
+ const out = finite.map(() => 0);
306
+ let safe = finite;
307
+ let sum = finite.reduce((a, b) => a + b, 0);
308
+ if (!Number.isFinite(sum)) {
309
+ const max = finite.reduce((a, b) => (b > a ? b : a), 0);
310
+ safe = finite.map((p) => p / max);
311
+ sum = safe.reduce((a, b) => a + b, 0);
312
+ }
313
+ if (n <= 0 || sum <= 0) return out;
314
+ const byShare = safe.map((p, i) => ({ p, i })).sort((a, b) => b.p - a.p);
315
+ if (n < byShare.filter(({ p }) => p > 0).length) {
316
+ for (const { i } of byShare.slice(0, n)) out[i] = 1;
317
+ return out;
318
+ }
319
+ const exact = safe.map((p) => n * (p / sum));
320
+ exact.forEach((e, i) => {
321
+ out[i] = Math.floor(e);
322
+ });
323
+ const rank = exact
324
+ .map((e, i) => ({ rem: e % 1, i }))
325
+ .sort((a, b) => b.rem - a.rem);
326
+ for (
327
+ let k = 0, left = n - out.reduce((a, b) => a + b, 0);
328
+ left > 0;
329
+ k++, left--
330
+ )
331
+ out[rank[k % rank.length]!.i]! += 1;
332
+ for (const { p, i } of byShare)
333
+ if (p > 0 && out[i] === 0) {
334
+ const d = byShare.reduce((m, c) => (out[c.i]! > out[m]! ? c.i : m), 0);
335
+ if (out[d]! > 1) {
336
+ out[d]! -= 1;
337
+ out[i] = 1;
338
+ }
339
+ }
340
+ return out;
341
+ }
@@ -37,70 +37,30 @@ export function resetThemeMode(): void {
37
37
  }
38
38
 
39
39
  /**
40
- * Fill for an UNLIT segment of the three-square scope bar.
41
- *
42
- * The bar reads as three segments, each lit (in its scope colour) or unlit. Unlit
43
- * used to be the same mid-grey as body de-emphasis, `#6B7280`, which is dark —
44
- * so an unlit segment carried the same visual weight as a lit one and the bar
45
- * read as three similar blobs. Reported directly: "non installed state is dark as
46
- * well - confusing".
47
- *
48
- * An unlit segment should recede toward the page, and "toward the page" is the
49
- * one thing a fixed colour cannot express: a pale fill that sits quietly on cream
50
- * is a bright block on near-black. Hence the two values, chosen per resolved
51
- * mode — this is the only place magus asks.
52
- */
53
- export const SCOPE_OFF_FILL = {
54
- /** Just off a light page — a filled but quiet segment. */
55
- light: "#D9D9CC",
56
- /** Just off a dark page. */
57
- dark: "#3A3A40",
58
- } as const;
59
-
60
- /** Fill colour for an unlit scope segment. Always a colour — never undefined,
61
- * because the segment is a filled block whatever the terminal turns out to be. */
62
- export function scopeOffFill(): string {
63
- return SCOPE_OFF_FILL[detected];
64
- }
65
-
66
- /**
67
- * Badge colours for the four plugin component kinds.
40
+ * The chip behind a plugin component's type glyph (`✱` `/` `@` `⬢`).
41
+ *
42
+ * ONE neutral wash for every kind. The glyph already says the kind, so a hue per
43
+ * kind said it twice — and the four it used (purple / teal / blue / amber) each
44
+ * meant something else elsewhere: amber is "needs a look", purple the cursor. On
45
+ * hundreds of rows at once that was the loudest decoration on the screen and it
46
+ * carried no signal. A kind is chrome; chrome recedes.
68
47
  *
69
- * These are chips, not accents: they sit on hundreds of rows at once, so the
70
- * fill has to be a *wash* of the hue rather than the hue itself. That runs into
71
- * the same wall `SCOPE_OFF_FILL` documents above — a wash is defined relative to
72
- * the page, ANSI has no alpha, so the two ends are picked by hand.
48
+ * It is still a wash rather than nothing, because the chip is what anchors a
49
+ * component row under its plugin. A wash is defined relative to the page and
50
+ * ANSI has no alpha, so the two ends are picked by hand, MEASURED:
73
51
  *
74
- * Hues are deliberately far apart (purple / teal / blue / amber) for the reason
75
- * the scope squares are: one character of colour carries no readable hue shift,
76
- * only a readable hue *difference*.
52
+ * chip vs page glyph on chip
53
+ * light 1.20 (cream) 6.95
54
+ * dark 1.42 (#1C1C1E) 7.80 (1.17 on One Dark's #282C34, 1.76 on black)
77
55
  *
78
- * The glyph colour is set per mode too. Reusing the vivid accent over a dark
79
- * wash would drop it below the contrast the palette guarantees against the
80
- * page — the accent's measured ratio is against `CONTRAST_REFERENCE`, not
81
- * against another tint laid on top of it.
56
+ * Quiet against the page (under 1.6, where a wash stops being a tint and starts
57
+ * being a highlight bar), and the glyph clears 4.5:1 on its own chip.
82
58
  */
83
- export const COMPONENT_BADGE = {
84
- skill: {
85
- light: { bg: "#E9DCF5", fg: "#6B21A8" },
86
- dark: { bg: "#2E2140", fg: "#C4A5F0" },
87
- },
88
- command: {
89
- light: { bg: "#D3E7EC", fg: "#0E5F73" },
90
- dark: { bg: "#123037", fg: "#7DD3E0" },
91
- },
92
- agent: {
93
- light: { bg: "#DCE4F7", fg: "#1D4ED8" },
94
- dark: { bg: "#1B2740", fg: "#A5C0F5" },
95
- },
96
- mcp: {
97
- light: { bg: "#F0E2CE", fg: "#92400E" },
98
- dark: { bg: "#3A2A15", fg: "#E5B877" },
99
- },
59
+ export const COMPONENT_CHIP = {
60
+ light: { bg: "#E4E4D6", fg: "#4A4A44" },
61
+ dark: { bg: "#36363F", fg: "#D0D0D8" },
100
62
  } as const;
101
63
 
102
- export type ComponentBadgeKind = keyof typeof COMPONENT_BADGE;
103
-
104
64
  /**
105
65
  * Badge and chart colours for the four Claude models, the three external-model slots, and
106
66
  * `inherit`.
@@ -131,7 +91,7 @@ export type ComponentBadgeKind = keyof typeof COMPONENT_BADGE;
131
91
  *
132
92
  * ## The two pages are built differently, deliberately
133
93
  *
134
- * `COMPONENT_BADGE` above explains why a chip needs two ends at all. This map goes one step
94
+ * `COMPONENT_CHIP` above explains why a chip needs two ends at all. This map goes one step
135
95
  * further and does not MIRROR them. Cream already carries the light, so a light chip is a
136
96
  * pale wash under mid-tone ink and stays quiet. A near-black page carries none, so the same
137
97
  * construction turns grey and the screen goes flat — a symmetric palette was built, shown,
@@ -142,8 +102,8 @@ export type ComponentBadgeKind = keyof typeof COMPONENT_BADGE;
142
102
  * ## The dark fills clear a page we cannot see
143
103
  *
144
104
  * `CONTRAST_REFERENCE_KEYS.dark` is `#1C1C1E`, but a real terminal may be pure black or a
145
- * slate like One Dark's `#282C34`. MEASURED, the `COMPONENT_BADGE` dark washes sit at
146
- * 1.00–1.07:1 against that slate — the same luminance as the page — so the chip loses its
105
+ * slate like One Dark's `#282C34`. MEASURED, the per-kind component washes this file used to
106
+ * carry sat at 1.00–1.07:1 against that slate — the same luminance as the page — so a chip lost its
147
107
  * edge and reads as a hole punched in the row. These fills clear every ground: 3.1–4.3:1 on
148
108
  * black, 2.8–2.9 on slate, and they still clear 4.5:1 on cream, so even a mis-detected theme
149
109
  * is merely odd rather than unreadable.
@@ -380,14 +340,17 @@ export function modelBadge(kind: string): ModelInk {
380
340
  /**
381
341
  * The UNLIT track: the `·` behind an effort dot, the `░` behind a bar.
382
342
  *
383
- * Light reuses `SCOPE_OFF_FILL`, which already solves this there. Dark does NOT: at
384
- * `#3A3A40` the track measures 1.51:1 on the reference page and simply vanishes, so a bar
385
- * reads as a floating block with no scale and the effort dots lose the denominator that
386
- * makes them a level rather than a count. `#4A4A52` is 1.94:1 — still recessive, still
387
- * visible.
343
+ * An unlit segment should recede toward the page, and "toward the page" is the one thing a
344
+ * fixed colour cannot express: a pale fill that sits quietly on cream is a bright block on
345
+ * near-black. Hence two values, one per resolved mode.
346
+ *
347
+ * Light is `#D9D9CC`, just off the cream page. Dark cannot mirror it: at `#3A3A40` the
348
+ * track measures 1.51:1 on the reference page and simply vanishes, so a bar reads as a
349
+ * floating block with no scale and the effort dots lose the denominator that makes them a
350
+ * level rather than a count. `#4A4A52` is 1.94:1 — still recessive, still visible.
388
351
  */
389
352
  export const TRACK_FILL = {
390
- light: SCOPE_OFF_FILL.light,
353
+ light: "#D9D9CC",
391
354
  dark: "#4A4A52",
392
355
  } as const;
393
356
 
@@ -419,7 +382,7 @@ export function trackFill(): string {
419
382
  * the label sits near the page.
420
383
  *
421
384
  * Both pages are built from the greys already in this file — the light label is
422
- * `SCOPE_OFF_FILL.light`, the dark key is `TRACK_FILL.dark` — so the footer sits
385
+ * `TRACK_FILL.light`, the dark key is `TRACK_FILL.dark` — so the footer sits
423
386
  * in the same neutral family as every other quiet fill rather than introducing a
424
387
  * third grey ramp.
425
388
  */
@@ -485,10 +448,10 @@ export const CONTRAST_REFERENCE_KEYS = {
485
448
  dark: "#1C1C1E",
486
449
  } as const;
487
450
 
488
- /** Chip colours for one component kind, for the resolved mode. */
489
- export function componentBadge(kind: ComponentBadgeKind): {
451
+ /** The component-kind chip, for the resolved mode. */
452
+ export function componentChip(): {
490
453
  bg: string;
491
454
  fg: string;
492
455
  } {
493
- return COMPONENT_BADGE[kind][detected];
456
+ return COMPONENT_CHIP[detected];
494
457
  }
package/src/ui/theme.ts CHANGED
@@ -34,7 +34,29 @@ import { RGBA } from "@opentui/core";
34
34
  * Where we own a badge's background we also own its ink, so `ink` is plain white
35
35
  * and its contrast is deterministic rather than a function of the user's theme.
36
36
  * That is why badge ink is not slot 0 or the terminal background: on a light
37
- * theme, terminal-background ink on a yellow chip is unreadable.
37
+ * theme, terminal-background ink on an amber chip is unreadable.
38
+ *
39
+ * ## One hue, one meaning
40
+ *
41
+ * Every token is named for what it MEANS, never for what it looks like, and
42
+ * each hue carries exactly one meaning across every screen:
43
+ *
44
+ * accent magus's own chrome — headings, the active tab, the selected row
45
+ * info reference facts — paths, URLs, counts, names in a detail heading
46
+ * success on, installed, done
47
+ * warning needs a look — an update, unverified, unapplied, needs config
48
+ * danger broken, failed, a rule violated, a destructive key
49
+ * muted quiet — unset, off, not installed, chrome, work in progress
50
+ *
51
+ * Two consequences worth stating. Loading is `muted`, never `warning`: amber
52
+ * says "look at this", and a spinner asks for nothing. And the three scopes no
53
+ * longer own a hue each — they were blue / green / amber, which made green
54
+ * mean both "installed" and "project" and amber both "warning" and "local".
55
+ * A scope square is now identified by POSITION (user, project, local) and its
56
+ * state by SHAPE (■ installed, □ not), so it reads without colour at all; the
57
+ * filled square wears `success` because that is what it says.
58
+ *
59
+ * `link` (a sixth hue, blue) folded into `info`: a URL is a reference fact.
38
60
  */
39
61
 
40
62
  /** Reference backgrounds every accent is measured against. */
@@ -44,18 +66,16 @@ export const CONTRAST_REFERENCE = {
44
66
  } as const;
45
67
 
46
68
  /**
47
- * The vivid set. Ratios are (on light / on dark), measured — see the test.
48
- * Hues for the three scopes are deliberately far apart (blue / green / amber)
49
- * because they appear as single-character squares, where a subtle hue shift is
50
- * not readable at all.
69
+ * The vivid set: five hues and a grey. Ratios are (on light / on dark),
70
+ * measured — see the test. Each value is named for its meaning; the hue is
71
+ * noted beside it only so a reader can picture it.
51
72
  */
52
73
  export const brand = {
53
- accent: "#9333EA", // 5.04 / 3.16 — brand purple
54
- link: "#2563EB", // 4.84 / 3.29
74
+ accent: "#9333EA", // 5.04 / 3.16 — purple
55
75
  info: "#0E7490", // 5.02 / 3.18 — teal
56
- success: "#15803D", // 4.70 / 3.39
76
+ success: "#15803D", // 4.70 / 3.39 — green
57
77
  warning: "#B45309", // 4.70 / 3.39 — amber
58
- danger: "#DC2626", // 4.52 / 3.52
78
+ danger: "#DC2626", // 4.52 / 3.52 — red
59
79
  muted: "#6B7280", // 4.53 / 3.52 — de-emphasis, still legible on both
60
80
  /** Ink on OUR badges. We own both sides, so contrast is deterministic. */
61
81
  ink: "#FFFFFF",
@@ -80,6 +100,9 @@ export const brand = {
80
100
  /** Any colour a component may hand to a JSX colour prop. */
81
101
  export type UiColor = string | RGBA;
82
102
 
103
+ /** A group header's role — a key of `theme.badge`. */
104
+ export type BadgeTone = "neutral" | "info" | "success" | "warning" | "danger";
105
+
83
106
  /** The terminal's own foreground — the only honest "normal text" colour. */
84
107
  const fg = () => RGBA.defaultForeground();
85
108
 
@@ -94,9 +117,9 @@ export const theme = {
94
117
  /** Same grey as `muted`; a third level should come from layout, not colour. */
95
118
  dim: brand.muted,
96
119
  border: brand.muted,
97
- link: brand.link,
98
120
  accent: brand.accent,
99
- /** Low-key brand tint for bylines — readable, deliberately not attention-grabbing. */
121
+ /** magus's own mark on its makers' marketplaces — the accent, because it is
122
+ * our chrome, and never `success`, which would read as a status. */
100
123
  byline: brand.accent,
101
124
  success: brand.success,
102
125
  warning: brand.warning,
@@ -119,27 +142,26 @@ export const theme = {
119
142
  dim: brand.selectionDim,
120
143
  },
121
144
 
122
- /** Blue / green / amber: the widest hue separation available, because these
123
- * render as one-character squares where a subtle shift reads as noise. */
124
- scopes: {
125
- user: brand.link,
126
- project: brand.success,
127
- local: brand.warning,
128
- },
129
-
130
- /** Badge colours. `fg` is the ink ON the block; `badgeFg` is the same hue used
131
- * as ink on the normal background instead. */
132
- category: {
133
- purple: { bg: brand.accent, fg: brand.ink, badgeFg: brand.accent },
134
- green: { bg: brand.success, fg: brand.ink, badgeFg: brand.success },
135
- teal: { bg: brand.info, fg: brand.ink, badgeFg: brand.info },
136
- yellow: { bg: brand.warning, fg: brand.ink, badgeFg: brand.warning },
137
- gray: { bg: brand.muted, fg: brand.ink, badgeFg: brand.muted },
138
- red: { bg: brand.danger, fg: brand.ink, badgeFg: brand.danger },
145
+ /**
146
+ * Filled group headers, by what the group IS. `fg` is the ink ON the block;
147
+ * `badgeFg` is the same role used as ink on the page, for the badge after it.
148
+ *
149
+ * Most groups are just groups and wear `neutral`. A hue is earned only by a
150
+ * group whose name is a state — "Installed" is `success`, a deprecated
151
+ * marketplace `danger`. There is no accent header: the accent fill is the
152
+ * selected row, and a header wearing it reads as the cursor.
153
+ */
154
+ badge: {
155
+ neutral: { bg: brand.muted, fg: brand.ink, badgeFg: brand.muted },
156
+ info: { bg: brand.info, fg: brand.ink, badgeFg: brand.info },
157
+ success: { bg: brand.success, fg: brand.ink, badgeFg: brand.success },
158
+ warning: { bg: brand.warning, fg: brand.ink, badgeFg: brand.warning },
159
+ danger: { bg: brand.danger, fg: brand.ink, badgeFg: brand.danger },
139
160
  },
140
161
 
141
162
  meta: {
142
163
  muted: brand.muted,
164
+ info: brand.info,
143
165
  warning: brand.warning,
144
166
  success: brand.success,
145
167
  danger: brand.danger,
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The latest request wins.
3
+ *
4
+ * A screen that reloads on mount AND on an external refresh signal can have two
5
+ * loads in flight at once, and they settle in whatever order the network
6
+ * decides. Applying every result means the one that finishes LAST wins — which
7
+ * is often the older one, carrying data from before the thing that triggered
8
+ * the newer load. A gate makes the rule explicit: take a token when a load
9
+ * starts, and apply the result only if that token is still the newest.
10
+ *
11
+ * ```ts
12
+ * const gate = createRequestGate();
13
+ * async function load() {
14
+ * const token = gate.begin();
15
+ * const data = await fetchSomething();
16
+ * if (!gate.isCurrent(token)) return; // a newer load started meanwhile
17
+ * apply(data);
18
+ * }
19
+ * ```
20
+ */
21
+ export interface RequestGate {
22
+ /** Start a request. Every token handed out before this one stops being current. */
23
+ begin(): number;
24
+ /** Is `token` the most recently started request? Asking changes nothing. */
25
+ isCurrent(token: number): boolean;
26
+ }
27
+
28
+ export function createRequestGate(): RequestGate {
29
+ let latest = 0;
30
+ return {
31
+ begin: () => {
32
+ latest += 1;
33
+ return latest;
34
+ },
35
+ isCurrent: (token) => token === latest,
36
+ };
37
+ }