magus-cli 7.3.1 → 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.
- package/package.json +4 -4
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- 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
|
+
}
|
package/src/ui/theme-mode.ts
CHANGED
|
@@ -37,70 +37,30 @@ export function resetThemeMode(): void {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
* `
|
|
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
|
|
146
|
-
* 1.00–1.07:1 against that slate — the same luminance as the page — so
|
|
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
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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:
|
|
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
|
-
* `
|
|
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
|
-
/**
|
|
489
|
-
export function
|
|
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
|
|
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
|
|
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),
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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 —
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
123
|
-
*
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|