@mythicalos/ui-core 0.2.2 → 0.3.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.
- package/dist/index.d.ts +6 -0
- package/dist/index.js +23 -0
- package/dist/logic/file-explorer.d.ts +374 -0
- package/dist/logic/file-explorer.js +624 -0
- package/dist/logic/popover.d.ts +242 -0
- package/dist/logic/popover.js +303 -0
- package/dist/logic/queue.d.ts +142 -0
- package/dist/logic/queue.js +143 -0
- package/dist/logic/sendbar.d.ts +105 -0
- package/dist/logic/sendbar.js +136 -0
- package/dist/logic/session-card.d.ts +263 -0
- package/dist/logic/session-card.js +443 -0
- package/dist/logic/terminal.d.ts +222 -0
- package/dist/logic/terminal.js +264 -0
- package/package.json +1 -1
- package/src/select/mythical-select.js +3 -2
- package/styles.css +652 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// @mythicalos/ui-core — the pure derivation behind the `session-card` atom (design registry spec
|
|
2
|
+
// v1, `mythical-design/ds/components-session-card.html`). Extracted from the only product that
|
|
3
|
+
// implemented it (the Control Room's rail `SessionCard.tsx` + its `derive.ts`/`sessionsVm.ts`
|
|
4
|
+
// state-dot derivation), with the product's session view-model lifted OUT: every function here
|
|
5
|
+
// takes plain primitives, so a second product can render the same card from a different wire shape.
|
|
6
|
+
//
|
|
7
|
+
// Everything branchy lives here — the Preact and React bindings only render what these functions
|
|
8
|
+
// return, so they cannot drift.
|
|
9
|
+
//
|
|
10
|
+
// THREE HONESTY INVARIANTS ARE LOAD-BEARING (they are design, not product logic):
|
|
11
|
+
//
|
|
12
|
+
// 1. ABSENCE IS NOT ZERO. A session with no context reading belongs to NO band: `ctxBand`
|
|
13
|
+
// returns `"unknown"`, `ctxFillPct` returns `undefined` (so the binding draws no fill rect at
|
|
14
|
+
// all — never a 0-width one that reads as "0%"), and `ctxValueText` returns `"—"`, never
|
|
15
|
+
// `"0%"`. The same rule governs the spine strip: an unreported `distills` yields ZERO nodes
|
|
16
|
+
// (the strip is omitted), never a fabricated "0 distills"; an unreported `savedTok` renders
|
|
17
|
+
// `"—"`, never `"0 tok"`. A REPORTED zero is a real reading and renders as zero.
|
|
18
|
+
//
|
|
19
|
+
// 2. UNKNOWN IS NOT IDLE. `idle` is a claim the product's wire has to make. When no activity is
|
|
20
|
+
// reported, `sessionStatus` falls back to the honest LIFECYCLE label (an active session with
|
|
21
|
+
// no activity signal reads `"active"`, not `"idle"`); when nothing at all is reported it
|
|
22
|
+
// returns the distinct `"unknown"` key/tone. `"idle"` is returned ONLY for an explicit
|
|
23
|
+
// `activity: "idle"`.
|
|
24
|
+
//
|
|
25
|
+
// 3. THE 75/90 THRESHOLDS ARE PRODUCT-TUNABLE (canonical tokens.css rule #4: "thresholds are
|
|
26
|
+
// product-defined (tweakable)"). They are a defaulted parameter — `CTX_THRESHOLDS_DEFAULT` —
|
|
27
|
+
// never a constant baked into a render path.
|
|
28
|
+
/** The design card's tick positions: warn at 75%, critical at 90% — overridable per product. */
|
|
29
|
+
export const CTX_THRESHOLDS_DEFAULT = { warn: 75, critical: 90 };
|
|
30
|
+
/** A defensive read for the public entry points below: a parameter default only covers
|
|
31
|
+
* `undefined`, and a product's runtime-shaped config can hand us `null` or a non-object. */
|
|
32
|
+
function isObject(v) {
|
|
33
|
+
return typeof v === "object" && v !== null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a product's threshold pair ONCE, so the band and the bar's ticks can never disagree
|
|
37
|
+
* about where the thresholds are. Two corrections:
|
|
38
|
+
*
|
|
39
|
+
* · A member that is not a finite number strictly inside the rail (0 < t < 100) is not a usable
|
|
40
|
+
* threshold — it marks nothing the meter can draw — so it falls back to the design default
|
|
41
|
+
* rather than silently reclassifying every reading. (`{warn: NaN}` must not quietly make every
|
|
42
|
+
* session look nominal; `{warn: -5}` must not quietly make every session look hot while
|
|
43
|
+
* drawing no tick to explain why.)
|
|
44
|
+
* · The pair is ORDERED (`warn` ≤ `critical`). A mis-ordered pair leaves the higher tick
|
|
45
|
+
* unreachable — with `{warn: 90, critical: 75}` an 80% reading is already `error` while a
|
|
46
|
+
* `warn` tick still sits at 90, so the bar marks a boundary that can never be crossed. Both
|
|
47
|
+
* boundaries the product asked for are kept; only which one is named `warn` is corrected.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeCtxThresholds(thresholds) {
|
|
50
|
+
// These are PUBLIC entry points a product reaches with runtime-shaped config (a settings blob,
|
|
51
|
+
// an API response), so a non-object gets the defaults rather than a TypeError mid-render — a
|
|
52
|
+
// parameter default only covers `undefined`.
|
|
53
|
+
const t = isObject(thresholds) ? thresholds : CTX_THRESHOLDS_DEFAULT;
|
|
54
|
+
const usable = (v, fallback) => typeof v === "number" && Number.isFinite(v) && v > 0 && v < CTX_BAR_SPAN ? v : fallback;
|
|
55
|
+
const warn = usable(t.warn, CTX_THRESHOLDS_DEFAULT.warn);
|
|
56
|
+
const critical = usable(t.critical, CTX_THRESHOLDS_DEFAULT.critical);
|
|
57
|
+
return { warn: Math.min(warn, critical), critical: Math.max(warn, critical) };
|
|
58
|
+
}
|
|
59
|
+
/** True only for a usable numeric reading — a garbage reading is not a reading. */
|
|
60
|
+
function isReading(pct) {
|
|
61
|
+
return typeof pct === "number" && Number.isFinite(pct);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The ONE number the card works from: the reading clamped to 0–100, at FULL precision, or
|
|
65
|
+
* `undefined` when there is no reading. The band and the fill are derived from this — the
|
|
66
|
+
* threshold comparison is against what was actually reported, so an 89.5% session has NOT crossed
|
|
67
|
+
* a 90% critical line. The LABEL then adapts its precision to this same number (see
|
|
68
|
+
* `ctxValueText`) so what the card displays can never contradict what it claims.
|
|
69
|
+
*/
|
|
70
|
+
export function ctxReading(pct) {
|
|
71
|
+
return isReading(pct) ? Math.min(100, Math.max(0, pct)) : undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Band for a context reading, derived from the reading AS REPORTED — 89.5 has not crossed a 90
|
|
75
|
+
* critical line and must not be shown as though it had. Absent ⇒ `"unknown"`. The thresholds are
|
|
76
|
+
* normalized (and ordered) first, so the band and the bar's ticks always agree.
|
|
77
|
+
*/
|
|
78
|
+
export function ctxBand(pct, thresholds) {
|
|
79
|
+
const v = ctxReading(pct);
|
|
80
|
+
if (v === undefined)
|
|
81
|
+
return "unknown";
|
|
82
|
+
const t = normalizeCtxThresholds(thresholds);
|
|
83
|
+
if (v >= t.critical)
|
|
84
|
+
return "error";
|
|
85
|
+
if (v >= t.warn)
|
|
86
|
+
return "warn";
|
|
87
|
+
return "ok";
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The 0–100 fill length, or `undefined` when there is no reading. `undefined` means the binding
|
|
91
|
+
* draws NO fill at all — the caller must never substitute 0, which would render as a confident
|
|
92
|
+
* "empty context" (invariant 1).
|
|
93
|
+
*/
|
|
94
|
+
export function ctxFillPct(pct) {
|
|
95
|
+
return ctxReading(pct);
|
|
96
|
+
}
|
|
97
|
+
/** What an absent reading renders as — never `"0%"`. */
|
|
98
|
+
export const CTX_UNKNOWN_TEXT = "—";
|
|
99
|
+
/** How many decimal places the value may grow to before it is shown verbatim. */
|
|
100
|
+
const CTX_VALUE_PLACES = [0, 1, 2];
|
|
101
|
+
/**
|
|
102
|
+
* The meter's right-hand value: `"62%"` for a reading, `"—"` for none (invariant 1).
|
|
103
|
+
*
|
|
104
|
+
* Whole percents are what the design card shows, but a rounded percent must never contradict the
|
|
105
|
+
* band the card is enforcing: at the default thresholds, 89.5% is `warn`, so displaying it as
|
|
106
|
+
* "90%" would put an amber card next to the critical number. The label therefore takes the FIRST
|
|
107
|
+
* precision whose displayed number lands in the same band as the reading — "89.5%" here, plain
|
|
108
|
+
* "62%" in the ordinary case — falling back to the reading verbatim, which by definition agrees.
|
|
109
|
+
*/
|
|
110
|
+
export function ctxValueText(pct, thresholds) {
|
|
111
|
+
const v = ctxReading(pct);
|
|
112
|
+
if (v === undefined)
|
|
113
|
+
return CTX_UNKNOWN_TEXT;
|
|
114
|
+
const band = ctxBand(v, thresholds);
|
|
115
|
+
for (const places of CTX_VALUE_PLACES) {
|
|
116
|
+
const shown = Number(v.toFixed(places));
|
|
117
|
+
if (ctxBand(shown, thresholds) === band)
|
|
118
|
+
return `${shown}%`;
|
|
119
|
+
}
|
|
120
|
+
return `${v}%`;
|
|
121
|
+
}
|
|
122
|
+
/** The meter's left-hand label and the separator the card composes its notes with. */
|
|
123
|
+
export const CTX_LABEL = "context";
|
|
124
|
+
export const SESSION_CARD_SEP = " · ";
|
|
125
|
+
/**
|
|
126
|
+
* The meter's left-hand note. The design card annotates the band (`context · distill suggested` at
|
|
127
|
+
* warn, `context · distill now` at error, `context · stale` for a stale reading); the unknown state
|
|
128
|
+
* says so outright, and says it BEFORE `stale` — "stale" implies a last-known value, so it must
|
|
129
|
+
* never stand in for "we never measured it".
|
|
130
|
+
*/
|
|
131
|
+
export function ctxNoteText(band, opts) {
|
|
132
|
+
if (band === "unknown")
|
|
133
|
+
return `${CTX_LABEL}${SESSION_CARD_SEP}not measured`;
|
|
134
|
+
if (isObject(opts) && opts.stale === true)
|
|
135
|
+
return `${CTX_LABEL}${SESSION_CARD_SEP}stale`;
|
|
136
|
+
if (band === "error")
|
|
137
|
+
return `${CTX_LABEL}${SESSION_CARD_SEP}distill now`;
|
|
138
|
+
if (band === "warn")
|
|
139
|
+
return `${CTX_LABEL}${SESSION_CARD_SEP}distill suggested`;
|
|
140
|
+
return CTX_LABEL;
|
|
141
|
+
}
|
|
142
|
+
/** Wrapper class for the meter — the band modifier tints the fill AND the value in one place. */
|
|
143
|
+
export function ctxMeterClass(input) {
|
|
144
|
+
const band = isObject(input) ? input.band : "unknown";
|
|
145
|
+
const stale = isObject(input) && input.stale === true;
|
|
146
|
+
return `my-session-card__ctx my-session-card__ctx--${band}${stale ? " is-stale" : ""}`;
|
|
147
|
+
}
|
|
148
|
+
// ── bar geometry (SVG presentation attributes only — CSP forbids the inline `width:62%` the
|
|
149
|
+
// design prototype used, and product-tunable ticks cannot be expressed as fixed classes) ──
|
|
150
|
+
/** The bar's user-space width: percent IS the x axis, so a reading maps 1:1 onto it. */
|
|
151
|
+
export const CTX_BAR_SPAN = 100;
|
|
152
|
+
/** The bar's user-space height (the design card's 8px rail). */
|
|
153
|
+
export const CTX_BAR_HEIGHT = 8;
|
|
154
|
+
/** `viewBox` for the bar; the binding stretches it to the card width (`preserveAspectRatio="none"`). */
|
|
155
|
+
export const CTX_BAR_VIEWBOX = `0 0 ${CTX_BAR_SPAN} ${CTX_BAR_HEIGHT}`;
|
|
156
|
+
/** Tick width in user space (≈1.5px once the bar is stretched to a ~250px card). */
|
|
157
|
+
export const CTX_BAR_TICK_WIDTH = 0.6;
|
|
158
|
+
/**
|
|
159
|
+
* Pure bar geometry, from the SAME normalized thresholds `ctxBand` uses — an unusable threshold
|
|
160
|
+
* falls back to the default in both places, so the ticks always mark the boundaries the band is
|
|
161
|
+
* actually enforcing. Duplicates collapse, so `{warn: 90, critical: 90}` draws one tick, not two
|
|
162
|
+
* stacked ones.
|
|
163
|
+
*/
|
|
164
|
+
export function ctxBarGeom(pct, thresholds) {
|
|
165
|
+
const norm = normalizeCtxThresholds(thresholds);
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
const ticks = [];
|
|
168
|
+
for (const t of [norm.warn, norm.critical]) {
|
|
169
|
+
if (seen.has(t))
|
|
170
|
+
continue;
|
|
171
|
+
seen.add(t);
|
|
172
|
+
const x = Math.min(CTX_BAR_SPAN - CTX_BAR_TICK_WIDTH, Math.max(0, t - CTX_BAR_TICK_WIDTH / 2));
|
|
173
|
+
ticks.push({ pct: t, x, width: CTX_BAR_TICK_WIDTH });
|
|
174
|
+
}
|
|
175
|
+
ticks.sort((a, b) => a.pct - b.pct);
|
|
176
|
+
return { span: CTX_BAR_SPAN, height: CTX_BAR_HEIGHT, fill: ctxFillPct(pct), ticks };
|
|
177
|
+
}
|
|
178
|
+
/** The design card's context escalation: at warn/error the status line stops restating the
|
|
179
|
+
* lifecycle and names the thing that actually needs acting on. */
|
|
180
|
+
const CONTEXT_ESCALATION = {
|
|
181
|
+
warn: { key: "context-high", label: "context high", tone: "warn" },
|
|
182
|
+
error: { key: "context-critical", label: "context critical", tone: "error" },
|
|
183
|
+
};
|
|
184
|
+
/** Statuses the context escalation may speak over: a live session, or one whose lifecycle was
|
|
185
|
+
* never reported. A TERMINAL or TRANSIENT lifecycle claim (stopped/failed/spawning/stopping/
|
|
186
|
+
* paused) and a DOWN LINK are all more important than a hot context and keep the line. */
|
|
187
|
+
const ESCALATABLE_KEYS = new Set(["working", "idle", "active", "unknown"]);
|
|
188
|
+
const LIFECYCLE_STATUS = {
|
|
189
|
+
spawning: { label: "spawning…", tone: "info", pulse: true },
|
|
190
|
+
active: { label: "active", tone: "ok", pulse: false },
|
|
191
|
+
stopping: { label: "stopping…", tone: "warn", pulse: true },
|
|
192
|
+
stopped: { label: "stopped", tone: "muted", pulse: false },
|
|
193
|
+
failed: { label: "failed", tone: "error", pulse: false },
|
|
194
|
+
paused: { label: "paused", tone: "warn", pulse: false },
|
|
195
|
+
};
|
|
196
|
+
/** The honest "we were told nothing" status — distinct key AND distinct tone from `idle`. */
|
|
197
|
+
export const SESSION_STATUS_UNKNOWN = {
|
|
198
|
+
key: "unknown",
|
|
199
|
+
label: "unknown",
|
|
200
|
+
tone: "unknown",
|
|
201
|
+
pulse: false,
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Derive the card's status. Precedence, and why:
|
|
205
|
+
*
|
|
206
|
+
* 1. A DOWN LINK on a session the product calls `active` — or on one whose lifecycle it does not
|
|
207
|
+
* report at all — wins: whatever else was claimed, we are not hearing from it. It does NOT
|
|
208
|
+
* override `spawning`/`stopping`/`stopped`/`failed`, whose own lifecycle already explains the
|
|
209
|
+
* missing link. The label is `"disconnected"`, not the design prototype's "reconnecting…":
|
|
210
|
+
* retry machinery is a product capability this atom cannot verify (a product that really does
|
|
211
|
+
* retry can pass its own `statusLabel`).
|
|
212
|
+
* 2. An ACTIVITY claim, but only for a session that is `active` or has no reported lifecycle —
|
|
213
|
+
* a "working" claim about a stopped session is a contradiction, and the lifecycle is the
|
|
214
|
+
* stronger truth.
|
|
215
|
+
* 3. The LIFECYCLE claim. This is where an active-but-no-activity session lands: `"active"`,
|
|
216
|
+
* never a fabricated `"idle"` or `"working"` (invariant 2).
|
|
217
|
+
* 4. Nothing reported at all ⇒ `"unknown"` (invariant 2).
|
|
218
|
+
*/
|
|
219
|
+
export function sessionStatus(input, contextBand) {
|
|
220
|
+
const base = baseSessionStatus(input);
|
|
221
|
+
// The design card's warn/error states replace the status line with the context claim ("context
|
|
222
|
+
// high" / "context critical", in the band's hue). It is a claim about a REAL MEASUREMENT, so it
|
|
223
|
+
// never fabricates anything — and `unknown` is not a band, so an unmeasured session is never
|
|
224
|
+
// escalated (invariant 1). The underlying pulse rides along: a working session that goes hot is
|
|
225
|
+
// still working.
|
|
226
|
+
if ((contextBand === "warn" || contextBand === "error") && ESCALATABLE_KEYS.has(base.key)) {
|
|
227
|
+
const esc = CONTEXT_ESCALATION[contextBand];
|
|
228
|
+
return { key: esc.key, label: esc.label, tone: esc.tone, pulse: base.pulse };
|
|
229
|
+
}
|
|
230
|
+
return base;
|
|
231
|
+
}
|
|
232
|
+
function baseSessionStatus(input) {
|
|
233
|
+
const { lifecycle, activity, connected } = isObject(input) ? input : {};
|
|
234
|
+
if (connected === false && (lifecycle === "active" || lifecycle === undefined)) {
|
|
235
|
+
return { key: "disconnected", label: "disconnected", tone: "muted", pulse: true };
|
|
236
|
+
}
|
|
237
|
+
if (lifecycle === "active" || lifecycle === undefined) {
|
|
238
|
+
if (activity === "working")
|
|
239
|
+
return { key: "working", label: "working", tone: "ok", pulse: true };
|
|
240
|
+
if (activity === "idle")
|
|
241
|
+
return { key: "idle", label: "idle", tone: "muted", pulse: false };
|
|
242
|
+
}
|
|
243
|
+
if (lifecycle !== undefined) {
|
|
244
|
+
const base = LIFECYCLE_STATUS[lifecycle];
|
|
245
|
+
return { key: lifecycle, label: base.label, tone: base.tone, pulse: base.pulse };
|
|
246
|
+
}
|
|
247
|
+
return SESSION_STATUS_UNKNOWN;
|
|
248
|
+
}
|
|
249
|
+
/** Status class: tone modifier + the transient-pulse flag. The dot is a child element, so the
|
|
250
|
+
* modifier tints the words and the dot together. */
|
|
251
|
+
export function sessionStatusClass(status) {
|
|
252
|
+
return `my-session-card__status my-session-card__status--${status.tone}${status.pulse ? " is-pulse" : ""}`;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Whether the card wears the design's STALE treatment (dashed border, muted values) — true exactly
|
|
256
|
+
* when the link is down. Kept here rather than in the binding so both bindings, and any product
|
|
257
|
+
* that wants to pre-compute it, agree on one rule.
|
|
258
|
+
*/
|
|
259
|
+
export function sessionCardIsStale(status) {
|
|
260
|
+
return status.key === "disconnected";
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The card's stale treatment, including a product's own claim. A product may ADD staleness (its
|
|
264
|
+
* data is old for a reason the card cannot see); it may NOT take away the staleness a down link
|
|
265
|
+
* implies — `stale={false}` on a disconnected session would paint it as a live, solid-bordered
|
|
266
|
+
* card, which is exactly the lie this treatment exists to prevent.
|
|
267
|
+
*/
|
|
268
|
+
export function sessionCardStale(status, productClaim) {
|
|
269
|
+
return sessionCardIsStale(status) || productClaim === true;
|
|
270
|
+
}
|
|
271
|
+
/** Every word the derivation reserves, mapped to the status key that earns it. An override may
|
|
272
|
+
* not borrow another key's word — that is how a product would launder a claim it never made. */
|
|
273
|
+
const RESERVED_STATUS_WORDS = new Map([
|
|
274
|
+
["working", "working"],
|
|
275
|
+
["idle", "idle"],
|
|
276
|
+
["active", "active"],
|
|
277
|
+
["spawning", "spawning"],
|
|
278
|
+
["stopping", "stopping"],
|
|
279
|
+
["stopped", "stopped"],
|
|
280
|
+
["failed", "failed"],
|
|
281
|
+
["paused", "paused"],
|
|
282
|
+
["disconnected", "disconnected"],
|
|
283
|
+
["unknown", "unknown"],
|
|
284
|
+
["context high", "context-high"],
|
|
285
|
+
["context critical", "context-critical"],
|
|
286
|
+
]);
|
|
287
|
+
/** Fold a candidate word to the form the reserved map is keyed by (the derived labels carry a
|
|
288
|
+
* trailing ellipsis on the transient states). */
|
|
289
|
+
function normalizeStatusWord(word) {
|
|
290
|
+
return word.trim().toLowerCase().replace(/(…|\.{3})$/, "").trim();
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The ACTIVITY vocabulary invariant 2 is about, guarded more tightly than the rest of the reserved
|
|
294
|
+
* words: these two are refused ANYWHERE in an override ("currently idle", "working now"), not just
|
|
295
|
+
* as the whole of it, unless the derivation itself produced that activity. Every other reserved
|
|
296
|
+
* word is an exact-match refusal — the atom polices the vocabulary the invariant names, and does
|
|
297
|
+
* not try to police arbitrary prose (a product determined to editorialize can always find other
|
|
298
|
+
* words; what it cannot do is state the specific claim the wire never made).
|
|
299
|
+
*/
|
|
300
|
+
const ACTIVITY_WORD_PATTERNS = [
|
|
301
|
+
{ key: "idle", re: /\bidle\b/i },
|
|
302
|
+
{ key: "working", re: /\bworking\b/i },
|
|
303
|
+
];
|
|
304
|
+
/**
|
|
305
|
+
* The words beside the dot. A product may substitute its own honest phrasing for the derived
|
|
306
|
+
* label — the tone, the pulse and the stale treatment still come from the derivation.
|
|
307
|
+
*
|
|
308
|
+
* Three refusals:
|
|
309
|
+
* · a BLANK override falls back to the derived label, rather than leaving the status as colour
|
|
310
|
+
* alone (token rule #7);
|
|
311
|
+
* · the `unknown` status ignores the override entirely. There is no alternative honest wording
|
|
312
|
+
* for "the product told us nothing" — nothing was claimed, so there is nothing to reword;
|
|
313
|
+
* · an override that states another status's claim is ignored. `statusLabel="idle"` — or
|
|
314
|
+
* `"currently idle"` — on a session whose activity was never reported would state a claim no
|
|
315
|
+
* wire made; invariant 2 is structural here, not a matter of trusting the caller. The two
|
|
316
|
+
* ACTIVITY words are refused anywhere in the override; every other reserved word is refused
|
|
317
|
+
* only as the whole of it. Rewording is still free: anything outside that vocabulary
|
|
318
|
+
* (`"wake unavailable"`, `"stopped (exit 1)"`, …) passes.
|
|
319
|
+
*
|
|
320
|
+
* Documented limit: a status the context escalation has taken over (`context high` /
|
|
321
|
+
* `context critical`) can no longer use an activity word either, even if the wire did report one —
|
|
322
|
+
* the rendered status is the context claim, and the product can word it another way.
|
|
323
|
+
*/
|
|
324
|
+
export function sessionStatusText(status, override) {
|
|
325
|
+
if (status.key === "unknown")
|
|
326
|
+
return status.label;
|
|
327
|
+
if (typeof override !== "string" || override.trim().length === 0)
|
|
328
|
+
return status.label;
|
|
329
|
+
const reserved = RESERVED_STATUS_WORDS.get(normalizeStatusWord(override));
|
|
330
|
+
if (reserved !== undefined && reserved !== status.key)
|
|
331
|
+
return status.label;
|
|
332
|
+
for (const { key, re } of ACTIVITY_WORD_PATTERNS) {
|
|
333
|
+
if (status.key !== key && re.test(override))
|
|
334
|
+
return status.label;
|
|
335
|
+
}
|
|
336
|
+
return override;
|
|
337
|
+
}
|
|
338
|
+
// ════════════════════════════════════════════════════════════════════════════════════════
|
|
339
|
+
// card container, identity line, subline
|
|
340
|
+
// ════════════════════════════════════════════════════════════════════════════════════════
|
|
341
|
+
/** Container class — the WHOLE class attribute the card's root carries, including any extra
|
|
342
|
+
* classes a product passes, so neither binding composes a class string of its own. `selected` and
|
|
343
|
+
* `stale` are INDEPENDENT: a selected session can also be disconnected, so they are two
|
|
344
|
+
* modifiers, never one enum. */
|
|
345
|
+
export function sessionCardClass(input) {
|
|
346
|
+
const i = isObject(input) ? input : {};
|
|
347
|
+
const extra = typeof i.extra === "string" ? i.extra.trim() : "";
|
|
348
|
+
return (`my-session-card${i.selected === true ? " is-selected" : ""}${i.stale === true ? " is-stale" : ""}` +
|
|
349
|
+
(extra.length > 0 ? ` ${extra}` : ""));
|
|
350
|
+
}
|
|
351
|
+
/** What an unnameable session's avatar shows — a question mark, not a fabricated initial. */
|
|
352
|
+
export const SESSION_AVATAR_UNKNOWN = "?";
|
|
353
|
+
/** Avatar initial: the first alphanumeric of the display name, uppercased; `"?"` when there is
|
|
354
|
+
* none. (Merges the two divergent copies the product carried — the rail card's "first char,
|
|
355
|
+
* em-dash fallback" and derive.ts's "first alphanumeric, ?-fallback" — on the latter, which
|
|
356
|
+
* cannot emit punctuation or whitespace as an initial.) */
|
|
357
|
+
export function sessionAvatarInitial(name) {
|
|
358
|
+
const m = String(name ?? "").match(/[a-z0-9]/i);
|
|
359
|
+
return m ? m[0].toUpperCase() : SESSION_AVATAR_UNKNOWN;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The card's meta subline — `role · model · duration` on the design card, but deliberately a free
|
|
363
|
+
* list: the spec's own states also use it for `role · model · queued: 1 ON-DONE` and
|
|
364
|
+
* `role · last seen 00:12 ago`. Absent/blank parts COLLAPSE, so there is never a dangling
|
|
365
|
+
* separator, and an all-absent list yields `""` (the binding then omits the element entirely
|
|
366
|
+
* rather than rendering an empty row).
|
|
367
|
+
*/
|
|
368
|
+
export function sessionSubline(parts) {
|
|
369
|
+
return (Array.isArray(parts) ? parts : [])
|
|
370
|
+
.map((p) => (typeof p === "string" ? p.trim() : ""))
|
|
371
|
+
.filter((p) => p.length > 0)
|
|
372
|
+
.join(SESSION_CARD_SEP);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The most nodes the strip will draw. The strip is a fixed-width row of 13px nodes, so beyond this
|
|
376
|
+
* they cannot be told apart anyway — and a runaway count from a malformed wire value must not be
|
|
377
|
+
* able to build an unbounded node list and lock the render. The LABEL beside the strip always
|
|
378
|
+
* states the TRUE count, so the bound costs no honesty: the strip is a bounded illustration, the
|
|
379
|
+
* number is the claim.
|
|
380
|
+
*/
|
|
381
|
+
export const SPINE_MAX_NODES = 24;
|
|
382
|
+
/**
|
|
383
|
+
* Nodes for the strip: one filled node per completed distill, plus the hollow live tip.
|
|
384
|
+
* An UNREPORTED `distills` yields `[]` — no strip, no fabricated "0 distills" (invariant 1). A
|
|
385
|
+
* REPORTED 0 is a real reading and yields the tip alone. Non-integer/negative counts are floored,
|
|
386
|
+
* and the list is capped at `SPINE_MAX_NODES` so a runaway value cannot lock the render (the
|
|
387
|
+
* strip's label still states the true count).
|
|
388
|
+
*/
|
|
389
|
+
export function sessionSpineNodes(distills) {
|
|
390
|
+
if (!isReading(distills))
|
|
391
|
+
return [];
|
|
392
|
+
const n = Math.min(SPINE_MAX_NODES - 1, Math.max(0, Math.floor(distills)));
|
|
393
|
+
const nodes = [];
|
|
394
|
+
for (let i = 0; i < n; i++)
|
|
395
|
+
nodes.push({ tip: false });
|
|
396
|
+
nodes.push({ tip: true });
|
|
397
|
+
return nodes;
|
|
398
|
+
}
|
|
399
|
+
/** Compact token count: `41.2k`, `1.3M`, `412`. */
|
|
400
|
+
function compactTokens(n) {
|
|
401
|
+
if (n >= 1_000_000)
|
|
402
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
403
|
+
if (n >= 1_000)
|
|
404
|
+
return `${(n / 1_000).toFixed(1)}k`;
|
|
405
|
+
return `${Math.round(n)}`;
|
|
406
|
+
}
|
|
407
|
+
/** What an unreported token saving renders as. */
|
|
408
|
+
export const SPINE_SAVED_UNKNOWN = CTX_UNKNOWN_TEXT;
|
|
409
|
+
/**
|
|
410
|
+
* The strip's right-hand value. Absent ⇒ `"—"` (invariant 1). A saving is a REDUCTION, so it
|
|
411
|
+
* carries a real minus sign (U+2212); a reported 0 is unsigned; a negative input (context grew)
|
|
412
|
+
* is stated as growth rather than silently re-signed.
|
|
413
|
+
*/
|
|
414
|
+
export function sessionSpineSavedText(savedTok) {
|
|
415
|
+
if (!isReading(savedTok))
|
|
416
|
+
return SPINE_SAVED_UNKNOWN;
|
|
417
|
+
if (savedTok === 0)
|
|
418
|
+
return "0 tok";
|
|
419
|
+
return `${savedTok > 0 ? "−" : "+"}${compactTokens(Math.abs(savedTok))} tok`;
|
|
420
|
+
}
|
|
421
|
+
/** The strip's left-hand label: `spine · 3 distills` (singular at 1). */
|
|
422
|
+
export function sessionSpineLabel(distills) {
|
|
423
|
+
const n = Math.max(0, Math.floor(distills));
|
|
424
|
+
return `spine${SESSION_CARD_SEP}${n} ${n === 1 ? "distill" : "distills"}`;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* The whole strip, or `undefined` when the product reported no distill count — the card then
|
|
428
|
+
* renders no strip at all. `spine.savedTok` is independent: a reported distill count with an
|
|
429
|
+
* unreported saving still draws the strip, with `"—"` for the value.
|
|
430
|
+
*/
|
|
431
|
+
export function sessionSpineSummary(spine) {
|
|
432
|
+
if (!spine || !isReading(spine.distills))
|
|
433
|
+
return undefined;
|
|
434
|
+
return {
|
|
435
|
+
nodes: sessionSpineNodes(spine.distills),
|
|
436
|
+
label: sessionSpineLabel(spine.distills),
|
|
437
|
+
value: sessionSpineSavedText(spine.savedTok),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/** Class for one spine node — filled distill vs. the hollow live tip. */
|
|
441
|
+
export function spineNodeClass(node) {
|
|
442
|
+
return `my-session-card__spine-node${node.tip ? " my-session-card__spine-node--tip" : ""}`;
|
|
443
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/** The one class that carries the always-heritage-dark surface (token rule 3). */
|
|
2
|
+
export declare const TERM_CLASS = "my-term";
|
|
3
|
+
/**
|
|
4
|
+
* Every structural class the pane renders. Named here — not typed literally in a binding — so the
|
|
5
|
+
* Preact and React bindings cannot drift, and so the stylesheet guard can enumerate the real
|
|
6
|
+
* surface instead of a hand-copied list.
|
|
7
|
+
*/
|
|
8
|
+
export declare const TERM_CLASSES: {
|
|
9
|
+
readonly pane: "my-term";
|
|
10
|
+
readonly titlebar: "my-term__titlebar";
|
|
11
|
+
readonly lights: "my-term__lights";
|
|
12
|
+
readonly light: "my-term__light";
|
|
13
|
+
readonly lightRed: "my-term__light my-term__light--r";
|
|
14
|
+
readonly lightAmber: "my-term__light my-term__light--a";
|
|
15
|
+
readonly lightGreen: "my-term__light my-term__light--g";
|
|
16
|
+
readonly noiseToggle: "my-term__noise";
|
|
17
|
+
readonly staticTitle: "my-term__title";
|
|
18
|
+
readonly titleRight: "my-term__tb-right";
|
|
19
|
+
readonly turn: "my-term__turn";
|
|
20
|
+
readonly turnDot: "my-term__turn-dot";
|
|
21
|
+
readonly idle: "my-term__idle";
|
|
22
|
+
readonly stopKey: "my-term__stop-key";
|
|
23
|
+
readonly body: "my-term__body";
|
|
24
|
+
readonly banner: "my-term__banner";
|
|
25
|
+
readonly bannerDot: "my-term__banner-dot";
|
|
26
|
+
readonly state: "my-term__state";
|
|
27
|
+
readonly label: "my-term__label";
|
|
28
|
+
readonly head: "my-term__head";
|
|
29
|
+
readonly expand: "my-term__expand";
|
|
30
|
+
readonly detail: "my-term__detail";
|
|
31
|
+
readonly more: "my-term__more";
|
|
32
|
+
readonly segment: "my-term__hist";
|
|
33
|
+
readonly segmentCaption: "my-term__hist-cap";
|
|
34
|
+
readonly caret: "my-term__caret";
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Transcript row kinds. Generic on purpose — this package knows nothing about any product's
|
|
38
|
+
* event wire format; a consumer maps its own stream onto these.
|
|
39
|
+
* - `boundary` — a wake/session marker; also the segmentation anchor (`currentWakeRows`).
|
|
40
|
+
* - `local` — a CLIENT-SYNTHETIC row (e.g. an interrupt marker). Deliberately its own kind so
|
|
41
|
+
* UI-originated rows can never masquerade as `system` output from the far side.
|
|
42
|
+
*/
|
|
43
|
+
export type TermRowKind = "assistant" | "user" | "tool" | "result" | "system" | "boundary" | "local" | "dim";
|
|
44
|
+
export declare const TERM_ROW_KINDS: readonly TermRowKind[];
|
|
45
|
+
export interface TermRow {
|
|
46
|
+
kind: TermRowKind;
|
|
47
|
+
/** The collapsed row's text. */
|
|
48
|
+
text: string;
|
|
49
|
+
/** Optional leading label (`assistant`, a tool name, …). */
|
|
50
|
+
label?: string;
|
|
51
|
+
/** Optional full body revealed by the row's expand affordance. */
|
|
52
|
+
detail?: string;
|
|
53
|
+
/** Stable key for expand state; the row index is used when absent. */
|
|
54
|
+
id?: string;
|
|
55
|
+
/** Noise rows are hidden unless noise is shown (see `noiseShow` / `visibleRows`). */
|
|
56
|
+
noise?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One foreign transcript segment — another session's rows sharing this transcript. Shown ONLY
|
|
60
|
+
* behind the disclosure, captioned with its own immutable id and visually separated; never blended
|
|
61
|
+
* into the selected session's log.
|
|
62
|
+
*/
|
|
63
|
+
export interface TranscriptSegment {
|
|
64
|
+
id: string;
|
|
65
|
+
/** Overrides the default `session {id}` caption. */
|
|
66
|
+
caption?: string;
|
|
67
|
+
rows: readonly TermRow[];
|
|
68
|
+
}
|
|
69
|
+
/** Row class derivation — base + kind modifier. */
|
|
70
|
+
export declare function termRowClass(kind: TermRowKind): string;
|
|
71
|
+
/** A row is expandable exactly when the caller gave it a body to reveal. */
|
|
72
|
+
export declare function isExpandable(row: TermRow): boolean;
|
|
73
|
+
/** Label on a row's expand affordance, per its current state. */
|
|
74
|
+
export declare const ROW_EXPAND_LABEL = "(expand)";
|
|
75
|
+
export declare const ROW_COLLAPSE_LABEL = "(collapse)";
|
|
76
|
+
export declare function expandLabel(expanded: boolean): string;
|
|
77
|
+
/** Stable expand key for a single row — its own id, else its position. */
|
|
78
|
+
export declare function rowKey(row: TermRow, index: number): string;
|
|
79
|
+
/**
|
|
80
|
+
* Dedupe preferred keys into unique ones, preserving the FIRST occurrence verbatim so a caller's
|
|
81
|
+
* own id keeps identifying the same thing. Repeats take a `#n` disambiguator; the loop (rather than
|
|
82
|
+
* a counter) also covers a literal id that collides with a disambiguator already handed out.
|
|
83
|
+
*/
|
|
84
|
+
export declare function uniqueKeys(preferred: readonly string[]): string[];
|
|
85
|
+
/**
|
|
86
|
+
* Collision-free keys for one list of rows. `id` is honored so expand state survives rows being
|
|
87
|
+
* prepended, but ids are NOT trusted to be unique: a repeated id would otherwise make two rows
|
|
88
|
+
* expand together and hand the renderer duplicate sibling keys.
|
|
89
|
+
*/
|
|
90
|
+
export declare function rowKeys(rows: readonly TermRow[]): string[];
|
|
91
|
+
/**
|
|
92
|
+
* Identity for each foreign segment: its own id where it has one, its position otherwise, deduped.
|
|
93
|
+
* Using the id (not the position) is what keeps a segment's expand state attached to the segment
|
|
94
|
+
* when the list is reordered.
|
|
95
|
+
*/
|
|
96
|
+
export declare function segmentKeys(segments: readonly TranscriptSegment[]): string[];
|
|
97
|
+
/** The row scopes a pane renders. Each is a distinct namespace for expand state. */
|
|
98
|
+
export declare const ROW_SCOPE: {
|
|
99
|
+
readonly transcript: "t";
|
|
100
|
+
readonly local: "l";
|
|
101
|
+
readonly history: "h";
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Namespace a list's row keys under a scope so expand state can NEVER leak between the transcript,
|
|
105
|
+
* the client-synthetic local rows and a history segment.
|
|
106
|
+
*
|
|
107
|
+
* A naive `${scope}:${key}` prefix is not enough: a transcript row whose id is literally `local:a`
|
|
108
|
+
* would then share state with the local row `a`. Every part is escaped before being joined, so the
|
|
109
|
+
* mapping from (scope parts, row key) to string is injective — two different tuples cannot produce
|
|
110
|
+
* the same key, whatever ids the caller supplies.
|
|
111
|
+
*/
|
|
112
|
+
export declare function scopedRowKeys(scope: readonly string[], rows: readonly TermRow[]): string[];
|
|
113
|
+
/** Index of the LAST `boundary` row, or -1 when the transcript carries none. */
|
|
114
|
+
export declare function lastBoundaryIndex(rows: readonly TermRow[]): number;
|
|
115
|
+
/**
|
|
116
|
+
* Only the current wake: from the LAST `boundary` row (that row included, as the "you woke here"
|
|
117
|
+
* header) onward. With NO boundary the whole transcript IS one wake, so every row is returned —
|
|
118
|
+
* segmentation never silently blanks a log that simply carries no markers.
|
|
119
|
+
*/
|
|
120
|
+
export declare function currentWakeRows(rows: readonly TermRow[]): readonly TermRow[];
|
|
121
|
+
/**
|
|
122
|
+
* The renderer option is INVERTED relative to the label: the noise FILTER being enabled means noise
|
|
123
|
+
* is HIDDEN, and the title line then reads `noise on` (the filter is on).
|
|
124
|
+
*/
|
|
125
|
+
export declare function noiseShow(noiseFilterEnabled: boolean): boolean;
|
|
126
|
+
/** Drop `noise` rows unless noise is being shown. */
|
|
127
|
+
export declare function visibleRows(rows: readonly TermRow[], showNoise: boolean): readonly TermRow[];
|
|
128
|
+
/**
|
|
129
|
+
* The transcript's input. Six arms, four of which are DISTINCT unavailable states — collapsing any
|
|
130
|
+
* of them would make the pane claim knowledge it does not have.
|
|
131
|
+
*/
|
|
132
|
+
export type TermSource = {
|
|
133
|
+
kind: "loading";
|
|
134
|
+
} | {
|
|
135
|
+
kind: "missing";
|
|
136
|
+
} | {
|
|
137
|
+
kind: "unaddressable";
|
|
138
|
+
} | {
|
|
139
|
+
kind: "failed";
|
|
140
|
+
} | {
|
|
141
|
+
kind: "ready";
|
|
142
|
+
rows: readonly TermRow[];
|
|
143
|
+
} | {
|
|
144
|
+
kind: "stale";
|
|
145
|
+
rows: readonly TermRow[];
|
|
146
|
+
};
|
|
147
|
+
export declare const TERM_LOADING_COPY = "Loading transcript\u2026";
|
|
148
|
+
/** ONE neutral string for "the read succeeded and there is no transcript" — it never speculates
|
|
149
|
+
* about lifecycle ("not started yet", "already cleaned up", …). */
|
|
150
|
+
export declare const TERM_MISSING_COPY = "No transcript is available for this session.";
|
|
151
|
+
export declare const TERM_UNADDRESSABLE_COPY = "This transcript can't be attributed to this session.";
|
|
152
|
+
export declare const TERM_FAILED_COPY = "The transcript could not be loaded.";
|
|
153
|
+
/** Stale banner. NO retry/reconnect claim — the design card's "connection lost · retrying (2/5)"
|
|
154
|
+
* promises a recovery loop this component does not run. */
|
|
155
|
+
export declare const TERM_STALE_COPY = "Transcript disconnected \u2014 showing the last received log.";
|
|
156
|
+
/** Rendered when a live log has zero rows. Reachable ONLY from `ready`/`stale` (see
|
|
157
|
+
* `transcriptView`) — a `loading` pane never renders it. */
|
|
158
|
+
export declare const TERM_NO_EVENTS_COPY = "(no events)";
|
|
159
|
+
/**
|
|
160
|
+
* HONESTY INVARIANT (binding, do not reword): the wake-unavailable banner says exactly that. Never
|
|
161
|
+
* "reconnecting", never "retrying (n/m)", never a spinner or countdown implying an automatic
|
|
162
|
+
* recovery is under way. The component makes no recovery attempt, so it must not look like one.
|
|
163
|
+
*/
|
|
164
|
+
export declare const TERM_WAKE_UNAVAILABLE_COPY = "wake unavailable";
|
|
165
|
+
export type TermView = {
|
|
166
|
+
kind: "log";
|
|
167
|
+
stale: boolean;
|
|
168
|
+
} | {
|
|
169
|
+
kind: "state";
|
|
170
|
+
copy: string;
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* HONESTY INVARIANT (binding): only `ready`/`stale` render a log; `stale` additionally flags the
|
|
174
|
+
* disconnected banner. `loading` renders the loading copy and NEVER `(no events)` — an unfinished
|
|
175
|
+
* read is not an empty transcript. `missing` / `unaddressable` / `failed` are three separate
|
|
176
|
+
* unavailable states with three separate messages.
|
|
177
|
+
*/
|
|
178
|
+
export declare function transcriptView(source: TermSource): TermView;
|
|
179
|
+
/** The source's rows when it carries any (`ready`/`stale`), else `[]`. */
|
|
180
|
+
export declare function sourceRows(source: TermSource): readonly TermRow[];
|
|
181
|
+
/** `{name} · tail · noise {on|off}`; a nameless transcript falls back to `transcript`. */
|
|
182
|
+
export declare function termTitleText(name: string | undefined, noiseFilterEnabled: boolean): string;
|
|
183
|
+
export declare const TURN_IN_FLIGHT_COPY = "turn in flight";
|
|
184
|
+
export declare const TURN_IDLE_COPY = "idle \u00B7 no turn in flight";
|
|
185
|
+
/**
|
|
186
|
+
* HONESTY INVARIANT: the turn caption rides supplied truth only. `undefined` (the caller does not
|
|
187
|
+
* know) renders NOTHING — the pane never guesses a turn state, and in particular never shows "idle"
|
|
188
|
+
* merely because it was told nothing.
|
|
189
|
+
*/
|
|
190
|
+
export declare function turnCaption(turnInFlight: boolean | undefined): string | null;
|
|
191
|
+
/**
|
|
192
|
+
* NEUTRAL history-toggle copy. Transcript files append generations in FILE order, so a foreign
|
|
193
|
+
* group can include NEWER sessions when the selected one is an older retained session — "earlier
|
|
194
|
+
* sessions" would be untruthful. The copy therefore only states that other sessions share this
|
|
195
|
+
* transcript.
|
|
196
|
+
*/
|
|
197
|
+
export declare function historyToggleLabel(show: boolean, count: number): string;
|
|
198
|
+
/** Caption for one foreign transcript segment (gap: never blended into the selected log). */
|
|
199
|
+
export declare function historySegmentCaption(id: string): string;
|
|
200
|
+
/**
|
|
201
|
+
* Focused-terminal Ctrl-C → stop-turn. Requires: `c` + ctrl, NOT a key-repeat (holding Ctrl-C must
|
|
202
|
+
* not fire a burst), NO active text selection (so copy still works), and the pane focused.
|
|
203
|
+
*/
|
|
204
|
+
export declare function shouldStopOnKey(e: {
|
|
205
|
+
key?: unknown;
|
|
206
|
+
ctrlKey?: unknown;
|
|
207
|
+
repeat?: unknown;
|
|
208
|
+
} | null, selectionText: string, paneHasFocus: boolean): boolean;
|
|
209
|
+
/**
|
|
210
|
+
* DEVIATION FROM THE DESIGN CARD (deliberate, carried over from the shipped implementation this was
|
|
211
|
+
* extracted from): the card draws a light-surface "Stop turn" button beside the send bar. The
|
|
212
|
+
* shipped pane instead puts the stop control in the terminal's own title bar, right of the turn
|
|
213
|
+
* indicator, labelled `◼ stop ^C`. Two reasons it stays that way: the control belongs to the
|
|
214
|
+
* transcript it interrupts (the send bar can be disabled while a turn is still running), and the
|
|
215
|
+
* card's own caption already documents the `^C` keybinding, which the label surfaces. The colour
|
|
216
|
+
* treatment follows token rule 3 — inside the terminal the control wears the --my-term-* palette,
|
|
217
|
+
* not the app's danger red, which flips with the app theme.
|
|
218
|
+
*/
|
|
219
|
+
export declare const TERM_STOP_LABEL = "\u25FC stop";
|
|
220
|
+
export declare const TERM_STOP_KEY_HINT = "^C";
|
|
221
|
+
/** Stop control class; `is-prominent` is a VISUAL weight only, never a state claim. */
|
|
222
|
+
export declare function stopButtonClass(prominent: boolean): string;
|