bee-ladybug 0.2.0 → 0.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.
@@ -9,7 +9,7 @@
9
9
  import { UIOverlay } from './UIOverlay.js';
10
10
  import { buildAssistantSnapshot, normalizeAssistant } from './AiAssistant.js';
11
11
 
12
- export const BEE_LADYBUG_VERSION = '0.2.0';
12
+ export const BEE_LADYBUG_VERSION = '0.4.0';
13
13
 
14
14
  export const CORE_DEFAULTS = Object.freeze({
15
15
  toggleKey: 'F2',
@@ -35,7 +35,8 @@ export const WELL_KNOWN_TYPES = Object.freeze([
35
35
  'error',
36
36
  'state',
37
37
  'sys',
38
- 'clear'
38
+ 'clear',
39
+ 'telemetry'
39
40
  ]);
40
41
 
41
42
  /**
@@ -93,8 +94,10 @@ export class BeeLadybugCore {
93
94
  this.#onKeyDown = (event) => this.#handleKey(event);
94
95
  this.#onTick = (now) => this.#tick(now);
95
96
  this.#started = false;
96
- this.#assistant = null;
97
+ this.#assistants = new Map();
98
+ this.#activeAssistant = null;
97
99
  this.#askBusy = false;
100
+ this.#adapters = new Map();
98
101
 
99
102
  this.ui = null;
100
103
  if (cfg.ui !== false && typeof document !== 'undefined') {
@@ -124,8 +127,13 @@ export class BeeLadybugCore {
124
127
  #onKeyDown;
125
128
  #onTick;
126
129
  #started;
127
- #assistant;
130
+ /** @type {Map<string, { name: string, complete: Function }>} */
131
+ #assistants;
132
+ /** @type {string | null} */
133
+ #activeAssistant;
128
134
  #askBusy;
135
+ /** @type {Map<string, { name: string, label: string, enabled: boolean, enable: Function, disable: Function }>} */
136
+ #adapters;
129
137
 
130
138
  /**
131
139
  * Public adapter contract. `type` is a channel name; `payload` is raw data.
@@ -159,51 +167,86 @@ export class BeeLadybugCore {
159
167
  }
160
168
 
161
169
  get hasAssistant() {
162
- return Boolean(this.#assistant);
170
+ return Boolean(this.#activeProvider());
163
171
  }
164
172
 
165
173
  get assistantBusy() {
166
174
  return this.#askBusy;
167
175
  }
168
176
 
177
+ /** Registered provider names, insertion order. */
178
+ getAssistants() {
179
+ return [...this.#assistants.keys()];
180
+ }
181
+
169
182
  /**
170
- * Plug in any model. BeeLadybug does not call OpenAI/Ollama itself.
183
+ * Add a model without dropping the others. Same `name` updates that slot.
184
+ * The first registration becomes active; later ones do not steal focus.
171
185
  * @param {{ name?: string, complete: (input: { question: string, snapshot: object }) => unknown }} provider
172
186
  */
173
- setAssistant(provider) {
174
- const next = normalizeAssistant(provider);
175
- if (!next) {
187
+ registerAssistant(provider) {
188
+ return this.#putAssistant(provider, { activate: !this.#activeAssistant, via: 'registerAssistant' });
189
+ }
190
+
191
+ /**
192
+ * @param {string} name
193
+ */
194
+ setActiveAssistant(name) {
195
+ const key = String(name || '');
196
+ if (!this.#assistants.has(key)) {
176
197
  this.sendData('warn', {
177
198
  source: 'ai',
178
- message: 'setAssistant() needs { complete(ctx) }. No vendor is bundled.'
199
+ message: `No assistant named "${key}". bee.registerAssistant({ name, complete }) first.`
179
200
  });
180
201
  return this;
181
202
  }
182
- this.#assistant = next;
183
- this.sendData('state', { source: 'ai', key: 'ai.assist', value: next.name });
184
- this.sendData('log', {
185
- source: 'ai',
186
- message: `assistant ready (${next.name})`
187
- });
203
+ this.#activeAssistant = key;
204
+ this.sendData('state', { source: 'ai', key: 'ai.assist', value: key });
205
+ this.sendData('log', { source: 'ai', message: `assistant active (${key})` });
188
206
  return this;
189
207
  }
190
208
 
191
- clearAssistant() {
192
- this.#assistant = null;
193
- this.sendData('state', { source: 'ai', key: 'ai.assist', value: 'off' });
209
+ /**
210
+ * Register and activate one provider. Does not remove the others.
211
+ * @param {{ name?: string, complete: (input: { question: string, snapshot: object }) => unknown }} provider
212
+ */
213
+ setAssistant(provider) {
214
+ return this.#putAssistant(provider, { activate: true, via: 'setAssistant' });
215
+ }
216
+
217
+ /**
218
+ * @param {string} [name] omit to drop every provider
219
+ */
220
+ clearAssistant(name) {
221
+ if (name == null || name === '') {
222
+ this.#assistants.clear();
223
+ this.#activeAssistant = null;
224
+ } else {
225
+ const key = String(name);
226
+ this.#assistants.delete(key);
227
+ if (this.#activeAssistant === key) {
228
+ this.#activeAssistant = this.#assistants.keys().next().value ?? null;
229
+ }
230
+ }
231
+ this.sendData('state', {
232
+ source: 'ai',
233
+ key: 'ai.assist',
234
+ value: this.#activeAssistant ?? 'off'
235
+ });
194
236
  return this;
195
237
  }
196
238
 
197
239
  /**
198
- * Send recent telemetry to the registered assistant and print the answer.
240
+ * Send recent telemetry to the active assistant and print the answer.
199
241
  * @param {string} [question]
200
242
  * @returns {Promise<string|null>}
201
243
  */
202
244
  async ask(question = 'Diagnose the current telemetry.') {
203
- if (!this.#assistant) {
245
+ const assistant = this.#activeProvider();
246
+ if (!assistant) {
204
247
  this.sendData('warn', {
205
248
  source: 'ai',
206
- message: 'No assistant. bee.setAssistant({ name, complete }) then ASK AI.'
249
+ message: 'No assistant. bee.registerAssistant({ name, complete }) then ASK AI.'
207
250
  });
208
251
  return null;
209
252
  }
@@ -217,10 +260,10 @@ export class BeeLadybugCore {
217
260
  this.sendData('log', { source: 'ai', message: `ask: ${q}` });
218
261
  try {
219
262
  const snapshot = buildAssistantSnapshot(this, q);
220
- const raw = await this.#assistant.complete({ question: q, snapshot });
263
+ const raw = await assistant.complete({ question: q, snapshot });
221
264
  const text = String(raw ?? '').trim() || '(empty answer)';
222
265
  this.sendData('log', { source: 'ai', message: text });
223
- this.sendData('state', { source: 'ai', key: 'ai.assist', value: this.#assistant.name });
266
+ this.sendData('state', { source: 'ai', key: 'ai.assist', value: assistant.name });
224
267
  return text;
225
268
  } catch (err) {
226
269
  this.sendData('error', {
@@ -230,7 +273,7 @@ export class BeeLadybugCore {
230
273
  this.sendData('state', {
231
274
  source: 'ai',
232
275
  key: 'ai.assist',
233
- value: this.#assistant?.name ?? 'error'
276
+ value: assistant?.name ?? 'error'
234
277
  });
235
278
  return null;
236
279
  } finally {
@@ -238,6 +281,117 @@ export class BeeLadybugCore {
238
281
  }
239
282
  }
240
283
 
284
+ #activeProvider() {
285
+ return this.#activeAssistant ? this.#assistants.get(this.#activeAssistant) ?? null : null;
286
+ }
287
+
288
+ /**
289
+ * Register a toggleable diagnostic adapter. The core stores hooks only —
290
+ * it does not import or special-case adapter modules.
291
+ * @param {string} name
292
+ * @param {{ enable: () => void, disable: () => void, label?: string }} hooks
293
+ */
294
+ registerAdapter(name, hooks = {}) {
295
+ const key = String(name || '').trim();
296
+ if (!key || typeof hooks.enable !== 'function' || typeof hooks.disable !== 'function') {
297
+ this.sendData('warn', {
298
+ source: 'core',
299
+ message: 'registerAdapter() needs a name and { enable, disable }.'
300
+ });
301
+ return this;
302
+ }
303
+ const label = String(hooks.label || key).trim() || key;
304
+ this.#adapters.set(key, {
305
+ name: key,
306
+ label,
307
+ enabled: true,
308
+ enable: hooks.enable,
309
+ disable: hooks.disable
310
+ });
311
+ this.sendData('sys', { op: 'adapter', name: key, registered: true, label, enabled: true });
312
+ return this;
313
+ }
314
+
315
+ /**
316
+ * @param {string} name
317
+ */
318
+ unregisterAdapter(name) {
319
+ const key = String(name || '').trim();
320
+ if (!key || !this.#adapters.has(key)) return this;
321
+ this.#adapters.delete(key);
322
+ this.sendData('sys', { op: 'adapter', name: key, registered: false });
323
+ return this;
324
+ }
325
+
326
+ /** Registered diagnostic adapters, insertion order. */
327
+ getAdapters() {
328
+ return [...this.#adapters.values()].map((entry) => ({
329
+ name: entry.name,
330
+ label: entry.label,
331
+ enabled: entry.enabled
332
+ }));
333
+ }
334
+
335
+ /**
336
+ * @param {string} name
337
+ */
338
+ toggleAdapter(name) {
339
+ const entry = this.#adapters.get(String(name || '').trim());
340
+ if (!entry) return this;
341
+ return this.setAdapterEnabled(entry.name, !entry.enabled);
342
+ }
343
+
344
+ /**
345
+ * @param {string} name
346
+ * @param {boolean} enabled
347
+ */
348
+ setAdapterEnabled(name, enabled) {
349
+ const entry = this.#adapters.get(String(name || '').trim());
350
+ if (!entry) return this;
351
+ const next = Boolean(enabled);
352
+ if (entry.enabled === next) return this;
353
+ try {
354
+ if (next) entry.enable();
355
+ else entry.disable();
356
+ entry.enabled = next;
357
+ } catch (err) {
358
+ console.warn('[BeeLadybug] adapter toggle error', err);
359
+ }
360
+ this.sendData('sys', {
361
+ op: 'adapter',
362
+ name: entry.name,
363
+ enabled: entry.enabled
364
+ });
365
+ return this;
366
+ }
367
+
368
+ /**
369
+ * @param {{ name?: string, complete: Function }} provider
370
+ * @param {{ activate: boolean, via: string }} opts
371
+ */
372
+ #putAssistant(provider, opts) {
373
+ const next = normalizeAssistant(provider);
374
+ if (!next) {
375
+ this.sendData('warn', {
376
+ source: 'ai',
377
+ message: `${opts.via}() needs { complete(ctx) }. No vendor is bundled.`
378
+ });
379
+ return this;
380
+ }
381
+ this.#assistants.set(next.name, next);
382
+ if (opts.activate) this.#activeAssistant = next.name;
383
+ this.sendData('state', {
384
+ source: 'ai',
385
+ key: 'ai.assist',
386
+ value: this.#activeAssistant ?? next.name
387
+ });
388
+ this.sendData('log', {
389
+ source: 'ai',
390
+ message: `assistant ready (${next.name})`
391
+ });
392
+ return this;
393
+ }
394
+
241
395
 
242
396
  start() {
243
397
  if (this.#started) return this;
@@ -280,8 +434,10 @@ export class BeeLadybugCore {
280
434
  this.#history.clear();
281
435
  this.#hud.clear();
282
436
  this.#latest.clear();
283
- this.#assistant = null;
437
+ this.#assistants.clear();
438
+ this.#activeAssistant = null;
284
439
  this.#askBusy = false;
440
+ this.#adapters.clear();
285
441
  this.ui?.destroy();
286
442
  this.ui = null;
287
443
  return this;
@@ -307,6 +463,10 @@ export class BeeLadybugCore {
307
463
  return this.visible ? this.hide() : this.show();
308
464
  }
309
465
 
466
+ /**
467
+ * Stops the simulated clock only. Overlay RAF keeps running.
468
+ * Adapters must listen for `sys` packets with `op: 'freeze'`.
469
+ */
310
470
  freeze() {
311
471
  if (this.frozen) return this;
312
472
  this.frozen = true;
@@ -365,7 +525,9 @@ export class BeeLadybugCore {
365
525
  logCount: this.#logs.length,
366
526
  hud: this.getHud(),
367
527
  mode: this.#modeLabel(),
368
- assistant: this.#assistant ? this.#assistant.name : null
528
+ assistant: this.#activeAssistant,
529
+ assistants: this.getAssistants(),
530
+ adapters: this.getAdapters()
369
531
  };
370
532
  }
371
533
 
@@ -477,6 +639,13 @@ export class BeeLadybugCore {
477
639
  return;
478
640
  }
479
641
 
642
+ if (type === 'telemetry') {
643
+ const name = String(payload.kind ?? payload.name ?? 'telemetry');
644
+ const value = numeric(payload.duration ?? payload.value);
645
+ if (value != null) this.#pushHistory(name, value);
646
+ return;
647
+ }
648
+
480
649
  if (isConsoleType(type) || packet.level) {
481
650
  this.#pushLog(packet);
482
651
  }
@@ -99,8 +99,14 @@ const STYLES = `
99
99
  border: 1px solid rgba(110, 231, 255, 0.35);
100
100
  padding: 1px 5px;
101
101
  }
102
- .bl-mode {
102
+ .bl-mode-wrap {
103
103
  margin-left: auto;
104
+ display: inline-flex;
105
+ align-items: center;
106
+ gap: 5px;
107
+ position: relative;
108
+ }
109
+ .bl-mode {
104
110
  font-size: 10px;
105
111
  font-weight: 700;
106
112
  letter-spacing: 0.08em;
@@ -108,6 +114,50 @@ const STYLES = `
108
114
  }
109
115
  .bl-mode[data-mode="FREEZE"] { color: #ff3b4e; }
110
116
  .bl-mode[data-mode^="SLOW"] { color: #ffd36a; }
117
+ .bl-info {
118
+ appearance: none;
119
+ position: relative;
120
+ flex: 0 0 auto;
121
+ width: 15px;
122
+ height: 15px;
123
+ padding: 0;
124
+ border: 1px solid rgba(139, 145, 163, 0.7);
125
+ border-radius: 50%;
126
+ background: transparent;
127
+ color: #8b91a3;
128
+ font: 700 9px ui-monospace, Consolas, monospace;
129
+ line-height: 1;
130
+ cursor: help;
131
+ }
132
+ .bl-info:hover,
133
+ .bl-info:focus-visible {
134
+ color: #ffd36a;
135
+ border-color: #ffd36a;
136
+ outline: none;
137
+ }
138
+ .bl-info-tip {
139
+ display: none;
140
+ position: absolute;
141
+ top: calc(100% + 6px);
142
+ right: 0;
143
+ width: 228px;
144
+ padding: 8px 9px;
145
+ background: #0c0e14;
146
+ border: 1px solid rgba(255, 211, 106, 0.45);
147
+ color: #e9edf5;
148
+ font: 400 10px/1.45 ui-monospace, Consolas, monospace;
149
+ letter-spacing: 0;
150
+ text-transform: none;
151
+ z-index: 3;
152
+ pointer-events: none;
153
+ white-space: normal;
154
+ text-align: left;
155
+ }
156
+ .bl-info:hover .bl-info-tip,
157
+ .bl-info:focus .bl-info-tip,
158
+ .bl-info:focus-visible .bl-info-tip {
159
+ display: block;
160
+ }
111
161
  .bl-stats {
112
162
  display: grid;
113
163
  grid-template-columns: 1fr 1fr;
@@ -189,6 +239,66 @@ const STYLES = `
189
239
  border-color: #ff3b4e;
190
240
  color: #fff;
191
241
  }
242
+ .bl-providers {
243
+ display: inline-flex;
244
+ align-items: center;
245
+ gap: 3px;
246
+ }
247
+ .bl-providers:empty {
248
+ display: none;
249
+ }
250
+ .bl-adapters {
251
+ display: inline-flex;
252
+ align-items: center;
253
+ gap: 3px;
254
+ }
255
+ .bl-adapters:empty {
256
+ display: none;
257
+ }
258
+ .bl-adapter {
259
+ appearance: none;
260
+ background: #16181f;
261
+ color: #8b91a3;
262
+ border: 1px solid rgba(139, 145, 163, 0.45);
263
+ font: 700 9px ui-monospace, Consolas, monospace;
264
+ letter-spacing: 0.06em;
265
+ padding: 5px 7px;
266
+ cursor: pointer;
267
+ }
268
+ .bl-adapter[data-hot="true"] {
269
+ color: #fff6d8;
270
+ border-color: #ffd36a;
271
+ }
272
+ .bl-adapter[data-flash="true"] {
273
+ animation: bl-adapter-flash 0.55s ease;
274
+ }
275
+ @keyframes bl-adapter-flash {
276
+ 0% {
277
+ background: #ffd36a;
278
+ color: #05060a;
279
+ border-color: #ffd36a;
280
+ }
281
+ 100% {
282
+ background: #16181f;
283
+ }
284
+ }
285
+ .bl-provider {
286
+ appearance: none;
287
+ width: 22px;
288
+ height: 22px;
289
+ padding: 0;
290
+ background: #16181f;
291
+ color: #fff6d8;
292
+ border: 1px solid rgba(255, 211, 106, 0.45);
293
+ font: 700 8px ui-monospace, Consolas, monospace;
294
+ letter-spacing: 0.04em;
295
+ cursor: pointer;
296
+ }
297
+ .bl-provider[data-hot="true"] {
298
+ background: #6ee7ff;
299
+ border-color: #6ee7ff;
300
+ color: #05060a;
301
+ }
192
302
  .bl-hint {
193
303
  margin-left: auto;
194
304
  font-size: 9px;
@@ -196,6 +306,12 @@ const STYLES = `
196
306
  }
197
307
  @media (prefers-reduced-motion: reduce) {
198
308
  .bl-scan { display: none; }
309
+ .bl-adapter[data-flash="true"] {
310
+ animation: none;
311
+ background: #ffd36a;
312
+ color: #05060a;
313
+ border-color: #ffd36a;
314
+ }
199
315
  }
200
316
  `;
201
317
 
@@ -213,6 +329,10 @@ export class UIOverlay {
213
329
  this.#stickBottom = true;
214
330
  this.#lastLogId = 0;
215
331
  this.#mounted = false;
332
+ this.#providerSig = '';
333
+ this.#adapterSig = '';
334
+ this.#flashAt = new Map();
335
+ this.#flashTimers = new Map();
216
336
  }
217
337
 
218
338
  #host;
@@ -221,6 +341,10 @@ export class UIOverlay {
221
341
  #stickBottom;
222
342
  #lastLogId;
223
343
  #mounted;
344
+ #providerSig;
345
+ #adapterSig;
346
+ #flashAt;
347
+ #flashTimers;
224
348
 
225
349
  /** @param {'auto' | HTMLElement} [target] */
226
350
  mount(target = 'auto') {
@@ -247,7 +371,13 @@ export class UIOverlay {
247
371
  <span class="bl-mark" aria-hidden="true"></span>
248
372
  <span class="bl-title">BEE LADYBUG</span>
249
373
  <span class="bl-badge">CORE</span>
250
- <span class="bl-mode" data-el="mode">LIVE</span>
374
+ <span class="bl-mode-wrap">
375
+ <span class="bl-mode" data-el="mode">LIVE</span>
376
+ <button class="bl-info" type="button" aria-describedby="bl-freeze-tip" aria-label="Freeze stops the simulated clock, not this overlay">
377
+ <span aria-hidden="true">ⓘ</span>
378
+ <span class="bl-info-tip" id="bl-freeze-tip">Freeze does not stop this overlay. It only stops the simulated clock. Adapters must listen for the sys event (op: freeze).</span>
379
+ </button>
380
+ </span>
251
381
  </div>
252
382
  <div class="bl-stats">
253
383
  <div>HOST <b data-el="hostFps">—</b></div>
@@ -269,6 +399,8 @@ export class UIOverlay {
269
399
  <button class="bl-btn" data-act="freeze" type="button">F4 STOP</button>
270
400
  <button class="bl-btn" data-act="live" type="button">1x LIVE</button>
271
401
  <button class="bl-btn" data-act="ask" type="button">ASK AI</button>
402
+ <span class="bl-providers" data-el="providers"></span>
403
+ <span class="bl-adapters" data-el="adapters"></span>
272
404
  <span class="bl-hint">F2 overlay</span>
273
405
  </div>
274
406
  </div>
@@ -286,7 +418,9 @@ export class UIOverlay {
286
418
  graphName: this.#root.querySelector('[data-el="graphName"]'),
287
419
  graphStats: this.#root.querySelector('[data-el="graphStats"]'),
288
420
  hud: this.#root.querySelector('[data-el="hud"]'),
289
- console: this.#root.querySelector('[data-el="console"]')
421
+ console: this.#root.querySelector('[data-el="console"]'),
422
+ providers: this.#root.querySelector('[data-el="providers"]'),
423
+ adapters: this.#root.querySelector('[data-el="adapters"]')
290
424
  };
291
425
 
292
426
  this.#root.querySelector('.bl-foot').addEventListener('click', (event) => {
@@ -297,6 +431,8 @@ export class UIOverlay {
297
431
  else if (act === 'freeze') this.core.toggleFreeze();
298
432
  else if (act === 'live') this.core.restoreRealtime();
299
433
  else if (act === 'ask') this.core.ask();
434
+ else if (act === 'assist') this.core.setActiveAssistant(btn.getAttribute('data-name'));
435
+ else if (act === 'adapter') this.core.toggleAdapter(btn.getAttribute('data-name'));
300
436
  });
301
437
 
302
438
  this.#els.console.addEventListener('scroll', () => {
@@ -318,6 +454,10 @@ export class UIOverlay {
318
454
  this.#root = null;
319
455
  this.#els = null;
320
456
  this.#mounted = false;
457
+ this.#providerSig = '';
458
+ this.#adapterSig = '';
459
+ this.#clearFlashTimers();
460
+ this.#flashAt.clear();
321
461
  return this;
322
462
  }
323
463
 
@@ -336,6 +476,7 @@ export class UIOverlay {
336
476
  onPacket(packet) {
337
477
  if (!this.#els || !packet) return;
338
478
  if (packet.type === 'sys') return;
479
+ this.#flashAdapterButton(packet);
339
480
  if (packet.level || packet.type === 'log' || packet.type === 'warn' || packet.type === 'error') {
340
481
  this.#appendLine(packet);
341
482
  }
@@ -366,6 +507,8 @@ export class UIOverlay {
366
507
  this.#paintGraph(primary);
367
508
  this.#renderHud(state.hud);
368
509
  this.#syncButtons(state);
510
+ this.#syncProviders(state);
511
+ this.#syncAdapters(state);
369
512
 
370
513
  if (this.#stickBottom) {
371
514
  this.#els.console.scrollTop = this.#els.console.scrollHeight;
@@ -424,6 +567,80 @@ export class UIOverlay {
424
567
  }
425
568
  }
426
569
 
570
+ #syncProviders(state) {
571
+ const wrap = this.#els.providers;
572
+ if (!wrap) return;
573
+ const names = Array.isArray(state.assistants) ? state.assistants : [];
574
+ const active = state.assistant ?? '';
575
+ const sig = `${names.join('\0')}\0${active}`;
576
+ if (sig === this.#providerSig) return;
577
+ this.#providerSig = sig;
578
+ let html = '';
579
+ for (let i = 0; i < names.length; i++) {
580
+ const name = names[i];
581
+ const hot = name === active ? 'true' : 'false';
582
+ html += `<button class="bl-provider" data-act="assist" data-name="${escapeHtml(name)}" data-hot="${hot}" type="button" title="${escapeHtml(name)}">${escapeHtml(abbrevName(name))}</button>`;
583
+ }
584
+ wrap.innerHTML = html;
585
+ }
586
+
587
+ #syncAdapters(state) {
588
+ const wrap = this.#els.adapters;
589
+ if (!wrap) return;
590
+ const list = Array.isArray(state.adapters) ? state.adapters : [];
591
+ const sig = list.map((entry) => `${entry.name}\0${entry.label}\0${entry.enabled ? 1 : 0}`).join('\n');
592
+ if (sig === this.#adapterSig) return;
593
+ this.#adapterSig = sig;
594
+ let html = '';
595
+ for (let i = 0; i < list.length; i++) {
596
+ const entry = list[i];
597
+ const hot = entry.enabled ? 'true' : 'false';
598
+ html += `<button class="bl-adapter" data-act="adapter" data-name="${escapeHtml(entry.name)}" data-hot="${hot}" type="button" title="${escapeHtml(entry.label)}">${escapeHtml(entry.label)}</button>`;
599
+ }
600
+ wrap.innerHTML = html;
601
+ }
602
+
603
+ #flashAdapterButton(packet) {
604
+ if (packet.type !== 'warn' && packet.type !== 'telemetry') return;
605
+ const wrap = this.#els.adapters;
606
+ if (!wrap || !this.core || typeof this.core.getAdapters !== 'function') return;
607
+ const name = packet.source;
608
+ if (!this.core.getAdapters().some((entry) => entry.name === name)) return;
609
+
610
+ const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
611
+ ? CSS.escape(name)
612
+ : name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
613
+ const btn = wrap.querySelector(`[data-name="${escaped}"]`);
614
+ if (!btn) return;
615
+
616
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
617
+ const last = this.#flashAt.get(name) ?? 0;
618
+ if (now - last < 1500) return;
619
+ this.#flashAt.set(name, now);
620
+
621
+ btn.dataset.flash = 'false';
622
+ void btn.offsetWidth;
623
+ btn.dataset.flash = 'true';
624
+
625
+ const prev = this.#flashTimers.get(name);
626
+ if (prev && typeof clearTimeout === 'function') clearTimeout(prev);
627
+ if (typeof setTimeout !== 'function') return;
628
+ const timer = setTimeout(() => {
629
+ this.#flashTimers.delete(name);
630
+ if (btn.dataset.flash === 'true') btn.dataset.flash = 'false';
631
+ }, 560);
632
+ this.#flashTimers.set(name, timer);
633
+ }
634
+
635
+ #clearFlashTimers() {
636
+ if (typeof clearTimeout !== 'function') {
637
+ this.#flashTimers.clear();
638
+ return;
639
+ }
640
+ for (const timer of this.#flashTimers.values()) clearTimeout(timer);
641
+ this.#flashTimers.clear();
642
+ }
643
+
427
644
  #paintGraph(series) {
428
645
  const canvas = this.#els.graph;
429
646
  const ctx = canvas.getContext('2d');
@@ -475,6 +692,11 @@ export class UIOverlay {
475
692
  }
476
693
  }
477
694
 
695
+ function abbrevName(name) {
696
+ const s = String(name || '?');
697
+ return s.slice(0, 2).toUpperCase();
698
+ }
699
+
478
700
  function fmtHud(value) {
479
701
  if (value == null) return '—';
480
702
  if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);