tmux-ide 2.9.0-beta.20 → 2.9.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/bin/cli.js +100 -8
  2. package/package.json +3 -2
  3. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  4. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  5. package/packages/daemon/dist/command-center/server.js +7 -0
  6. package/packages/daemon/dist/lib/app-config.js +4 -2
  7. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  8. package/packages/daemon/dist/lib/soak-verdict.js +185 -35
  9. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  10. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  11. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  12. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  13. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  14. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  15. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  16. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  17. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  18. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  19. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  20. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  21. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  22. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  23. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  24. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  25. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  26. package/packages/daemon/src/command-center/server.ts +8 -0
  27. package/packages/daemon/src/lib/app-config.ts +11 -3
  28. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  29. package/packages/daemon/src/lib/soak-verdict.ts +314 -23
  30. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  31. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  32. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  33. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  34. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  35. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  36. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  37. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +1 -0
  38. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +9 -2
  39. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  40. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  41. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  42. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  43. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  44. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  45. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  46. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  47. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  48. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  49. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
@@ -1,3 +1,10 @@
1
+ import {
2
+ MEMORY_KEYS,
3
+ RESOURCE_KEYS,
4
+ type DiagnosticsResult,
5
+ type DiagnosticsDelta,
6
+ } from "./soak-diagnostics.ts";
7
+
1
8
  /**
2
9
  * Pure analysis for the daemon soak harness (`packages/daemon/scripts/soak-daemon.mjs`).
3
10
  *
@@ -8,6 +15,8 @@
8
15
  */
9
16
 
10
17
  export interface SoakSample {
18
+ readonly diagnostics?: DiagnosticsResult;
19
+ readonly diagnosticDelta?: DiagnosticsDelta | null;
11
20
  /** Milliseconds since the epoch when the sample was taken. */
12
21
  readonly at: number;
13
22
  /** Seconds since the soak started (the linear-fit abscissa). */
@@ -40,6 +49,8 @@ export interface Percentiles {
40
49
  readonly count: number;
41
50
  readonly p50: number | null;
42
51
  readonly max: number | null;
52
+ /** Logarithmic bucket index → count; p50 is an upper bound; ≤5% or 1ms error below overflow (which uses max). */
53
+ readonly buckets?: Readonly<Record<string, number>>;
43
54
  }
44
55
 
45
56
  export interface SoakThresholds {
@@ -117,12 +128,24 @@ export function percentile(values: readonly number[], fraction: number): number
117
128
  return sorted[rank]!;
118
129
  }
119
130
 
131
+ /** At most 228 buckets for probes capped at 60 seconds, plus an overflow bucket. */
120
132
  export function percentiles(values: readonly number[]): Percentiles {
121
- return {
122
- count: values.length,
123
- p50: percentile(values, 0.5),
124
- max: values.length === 0 ? null : Math.max(...values),
125
- };
133
+ const buckets: Record<string, number> = {};
134
+ let max: number | null = null;
135
+ for (const value of values) {
136
+ if (!Number.isFinite(value) || value < 0) continue;
137
+ max = Math.max(max ?? 0, value);
138
+ const index = value <= 1 ? 0 : Math.min(227, Math.ceil(Math.log(value) / Math.log(1.05)));
139
+ buckets[index] = (buckets[index] ?? 0) + 1;
140
+ }
141
+ return mergePercentiles([
142
+ {
143
+ count: Object.values(buckets).reduce((a, b) => a + b, 0),
144
+ p50: null,
145
+ max,
146
+ buckets,
147
+ },
148
+ ]);
126
149
  }
127
150
 
128
151
  /**
@@ -151,6 +174,7 @@ export interface SoakCheck {
151
174
  }
152
175
 
153
176
  export interface SoakSummary {
177
+ readonly diagnostics: ReturnType<typeof summarizeDiagnostics>;
154
178
  readonly samples: number;
155
179
  readonly durationSeconds: number;
156
180
  readonly rssStartMiB: number | null;
@@ -217,22 +241,130 @@ function maxValue(
217
241
  return max;
218
242
  }
219
243
 
220
- /**
221
- * Merge per-interval percentile records into a whole-run estimate. Exact
222
- * per-sample series are not retained across intervals, so the p50 here is the
223
- * count-weighted median of interval medians and the max is the true max.
224
- */
244
+ /** Merge bounded histograms, never a median of medians. Legacy p50 is unmeasured. */
225
245
  export function mergePercentiles(records: readonly Percentiles[]): Percentiles {
226
- const weighted: number[] = [];
227
- let max: number | null = null;
246
+ const buckets: Record<string, number> = {};
228
247
  let count = 0;
248
+ let max: number | null = null;
249
+ let missing = false;
229
250
  for (const record of records) {
230
- if (record.p50 === null || record.count === 0) continue;
231
251
  count += record.count;
232
- for (let index = 0; index < record.count; index += 1) weighted.push(record.p50);
233
- if (record.max !== null && (max === null || record.max > max)) max = record.max;
252
+ if (record.count && !record.buckets) missing = true;
253
+ if (record.max !== null) max = Math.max(max ?? 0, record.max);
254
+ for (const [key, value] of Object.entries(record.buckets ?? {}))
255
+ buckets[key] = (buckets[key] ?? 0) + value;
256
+ }
257
+ let cumulative = 0;
258
+ let p50: number | null = null;
259
+ if (!missing && count) {
260
+ for (const key of Object.keys(buckets)
261
+ .map(Number)
262
+ .sort((a, b) => a - b)) {
263
+ cumulative += buckets[key]!;
264
+ if (cumulative >= Math.ceil(count / 2)) {
265
+ p50 = key === 227 ? max : Math.min(max ?? Infinity, 1.05 ** key);
266
+ break;
267
+ }
268
+ }
234
269
  }
235
- return { count, p50: percentile(weighted, 0.5), max };
270
+ return { count, p50, max, buckets };
271
+ }
272
+
273
+ /** Run-level evidence must be supplied explicitly; absent evidence cannot qualify. */
274
+ export interface SoakEvidence {
275
+ readonly requestedSeconds: number;
276
+ readonly observedSeconds: number;
277
+ readonly completed: boolean;
278
+ readonly failures: Readonly<Record<string, number>>;
279
+ readonly loadCompleted: boolean;
280
+ readonly telemetryComplete: boolean;
281
+ readonly logCoverageComplete: boolean;
282
+ readonly reconnectAttempts: number;
283
+ readonly reconnectAcknowledged: number;
284
+ readonly shutdownClean: boolean;
285
+ readonly recordRetired: boolean;
286
+ readonly cleanupComplete: boolean;
287
+ readonly resourceTrendPolicyComplete: boolean;
288
+ readonly warmupSeconds: number;
289
+ readonly trailingSeconds: number;
290
+ }
291
+
292
+ export function soakTrends(
293
+ samples: readonly SoakSample[],
294
+ warmupSeconds: number,
295
+ trailingSeconds: number,
296
+ ) {
297
+ const end = samples.at(-1)?.elapsedSeconds ?? 0;
298
+ const fit = (window: readonly SoakSample[]) => ({
299
+ memoryMiBPerHour: Object.fromEntries(
300
+ MEMORY_KEYS.map((key) => [
301
+ key,
302
+ growthPerHour(window, (s) => {
303
+ const value = s.diagnostics?.sample?.memory[key];
304
+ return value === undefined ? null : value / 1024 ** 2;
305
+ }),
306
+ ]),
307
+ ),
308
+ activeResourcesPerHour: Object.fromEntries(
309
+ RESOURCE_KEYS.map((key) => [
310
+ key,
311
+ growthPerHour(window, (s) => s.diagnostics?.sample?.activeResources?.[key] ?? null),
312
+ ]),
313
+ ),
314
+ diagnosticCpuPercentPerHour: growthPerHour(window, (s) =>
315
+ s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.cpuPercent : null,
316
+ ),
317
+ eventLoopUtilizationPerHour: growthPerHour(window, (s) =>
318
+ s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.eventLoopUtilization : null,
319
+ ),
320
+ rssMiBPerHour: growthPerHour(window, (s) => (s.rssKiB === null ? null : s.rssKiB / 1024)),
321
+ cpuPercentPerHour: growthPerHour(window, (s) =>
322
+ s.cpuDeltaSeconds === null || s.intervalSeconds <= 0
323
+ ? null
324
+ : (100 * s.cpuDeltaSeconds) / s.intervalSeconds,
325
+ ),
326
+ spawnsPerMinutePerHour: growthPerHour(window, (s) =>
327
+ s.intervalSeconds <= 0 ? null : (60 * s.tmuxSpawns) / s.intervalSeconds,
328
+ ),
329
+ });
330
+ return {
331
+ whole: fit(samples),
332
+ afterWarmup: fit(samples.filter((s) => s.elapsedSeconds >= warmupSeconds)),
333
+ trailing: fit(
334
+ samples.filter((s) => s.elapsedSeconds >= Math.max(warmupSeconds, end - trailingSeconds)),
335
+ ),
336
+ };
337
+ }
338
+
339
+ /** Descriptive only: heap changes can reflect GC and do not prove retention. */
340
+ export function summarizeDiagnostics(samples: readonly SoakSample[]) {
341
+ const describe = (pick: (s: SoakSample) => number | null) => ({
342
+ start: firstValue(samples, pick),
343
+ end: lastValue(samples, pick),
344
+ max: maxValue(samples, pick),
345
+ measuredSamples: samples.filter((s) => pick(s) !== null).length,
346
+ });
347
+ return {
348
+ validSamples: samples.filter((s) => s.diagnostics?.status === "ok").length,
349
+ unsupportedResourceSamples: samples.filter(
350
+ (s) => s.diagnostics?.status === "ok" && s.diagnostics.sample.activeResources === null,
351
+ ).length,
352
+ memoryBytes: Object.fromEntries(
353
+ MEMORY_KEYS.map((key) => [key, describe((s) => s.diagnostics?.sample?.memory[key] ?? null)]),
354
+ ),
355
+ activeResources: Object.fromEntries(
356
+ RESOURCE_KEYS.map((key) => [
357
+ key,
358
+ describe((s) => s.diagnostics?.sample?.activeResources?.[key] ?? null),
359
+ ]),
360
+ ),
361
+ cpuPercent: describe((s) =>
362
+ s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.cpuPercent : null,
363
+ ),
364
+ eventLoopUtilization: describe((s) =>
365
+ s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.eventLoopUtilization : null,
366
+ ),
367
+ };
236
368
  }
237
369
 
238
370
  export function summarizeSoak(samples: readonly SoakSample[]): SoakSummary {
@@ -260,6 +392,7 @@ export function summarizeSoak(samples: readonly SoakSample[]): SoakSummary {
260
392
  disconnects += sample.unexpectedDisconnects;
261
393
  }
262
394
  return {
395
+ diagnostics: summarizeDiagnostics(samples),
263
396
  samples: samples.length,
264
397
  durationSeconds,
265
398
  rssStartMiB: firstValue(samples, rss),
@@ -306,9 +439,14 @@ function boundCheck(
306
439
  export function evaluateSoak(
307
440
  samples: readonly SoakSample[],
308
441
  thresholds: SoakThresholds = DEFAULT_SOAK_THRESHOLDS,
442
+ evidence?: SoakEvidence,
309
443
  ): SoakVerdict {
310
444
  const summary = summarizeSoak(samples);
311
- const enoughForFit = samples.length >= thresholds.minSamplesForFit;
445
+ const enoughForFit =
446
+ samples.length >= thresholds.minSamplesForFit &&
447
+ (!evidence ||
448
+ samples.filter((sample) => sample.elapsedSeconds >= evidence.warmupSeconds).length >=
449
+ thresholds.minSamplesForFit);
312
450
  const checks: SoakCheck[] = [
313
451
  enoughForFit
314
452
  ? boundCheck(
@@ -322,7 +460,7 @@ export function evaluateSoak(
322
460
  ok: null,
323
461
  observed: summary.rssGrowthMiBPerHour,
324
462
  bound: thresholds.maxRssGrowthMiBPerHour,
325
- detail: `needs ${thresholds.minSamplesForFit} samples, have ${samples.length}`,
463
+ detail: `needs ${thresholds.minSamplesForFit} samples and, when declared, post-warmup coverage; have ${samples.length} total`,
326
464
  },
327
465
  boundCheck(
328
466
  "fd-drift",
@@ -343,19 +481,19 @@ export function evaluateSoak(
343
481
  ok: null,
344
482
  observed: summary.fdGrowthPerHour,
345
483
  bound: thresholds.maxFdGrowthPerHour,
346
- detail: `needs ${thresholds.minSamplesForFit} samples, have ${samples.length}`,
484
+ detail: `needs ${thresholds.minSamplesForFit} samples and, when declared, post-warmup coverage; have ${samples.length} total`,
347
485
  },
348
486
  boundCheck(
349
487
  "ping-rtt-p50",
350
488
  summary.pingRttMs.p50,
351
489
  thresholds.maxPingRttP50Ms,
352
- "events-client ping round-trip p50 ms",
490
+ "WebSocket transport control RTT p50 upper bound ms (not semantic handler latency)",
353
491
  ),
354
492
  boundCheck(
355
493
  "receipt-latency-p50",
356
494
  summary.receiptLatencyMs.p50,
357
495
  thresholds.maxReceiptLatencyP50Ms,
358
- "wait agent-status receipt latency p50 ms after the flip",
496
+ "wait agent-status receipt latency p50 upper bound ms after the flip",
359
497
  ),
360
498
  boundCheck(
361
499
  "observer-gaps",
@@ -382,6 +520,126 @@ export function evaluateSoak(
382
520
  "events-client drops outside scheduled reconnects",
383
521
  ),
384
522
  ];
523
+ const coverage = (id: string, complete: boolean | undefined, detail: string) =>
524
+ checks.push({
525
+ id,
526
+ ok: complete === true ? true : null,
527
+ observed: complete === true ? 1 : null,
528
+ bound: 1,
529
+ detail,
530
+ });
531
+ coverage(
532
+ "duration-complete",
533
+ evidence?.completed && evidence.observedSeconds >= evidence.requestedSeconds,
534
+ "requested duration reached without interruption",
535
+ );
536
+ coverage(
537
+ "telemetry-complete",
538
+ evidence?.telemetryComplete &&
539
+ samples.every((s) =>
540
+ [s.rssKiB, s.cpuSeconds, s.openFds].every(
541
+ (value) => value !== null && Number.isFinite(value),
542
+ ),
543
+ ),
544
+ "all required samples and metrics present",
545
+ );
546
+ coverage(
547
+ "diagnostics-coverage",
548
+ samples.length > 0 &&
549
+ samples.every(
550
+ (s) => s.diagnostics?.status === "ok" && s.diagnostics.sample.activeResources !== null,
551
+ ),
552
+ "owner diagnostics and supported resource counts required at every sample; missing/malformed/404 remain inconclusive",
553
+ );
554
+ coverage(
555
+ "diagnostics-deltas",
556
+ samples.length > 1 &&
557
+ samples.every((s, i) =>
558
+ i === 0
559
+ ? s.diagnosticDelta?.status === "missing-baseline"
560
+ : s.diagnosticDelta?.status === "ok" && s.diagnosticDelta.eventLoopUtilization !== null,
561
+ ),
562
+ "adjacent monotonic cumulative counters required after first baseline",
563
+ );
564
+ checks.push(
565
+ boundCheck(
566
+ "diagnostics-identity",
567
+ samples.filter(
568
+ (s) =>
569
+ s.diagnostics?.status === "identity-mismatch" ||
570
+ s.diagnosticDelta?.status === "identity-mismatch",
571
+ ).length,
572
+ 0,
573
+ "diagnostics must match original daemon identity and PID",
574
+ ),
575
+ );
576
+ coverage(
577
+ "load-complete",
578
+ evidence?.loadCompleted,
579
+ "each declared workload completed at least once",
580
+ );
581
+ coverage(
582
+ "log-coverage",
583
+ evidence?.logCoverageComplete,
584
+ "continuous log stream, bookmark, no gap or replay ambiguity",
585
+ );
586
+ coverage("run-evidence", evidence !== undefined, "explicit run and teardown evidence supplied");
587
+ coverage(
588
+ "resource-trend-policy",
589
+ evidence?.resourceTrendPolicyComplete,
590
+ "heap/resources and CPU/spawn trend acceptance policy awaiting calibration and review",
591
+ );
592
+ if (evidence) {
593
+ const requiredFailures = [
594
+ "health",
595
+ "flip",
596
+ "promotion",
597
+ "send",
598
+ "receipt",
599
+ "daemonMissing",
600
+ "daemonExit",
601
+ "ack",
602
+ "pingDeadline",
603
+ "loop",
604
+ "sample",
605
+ ];
606
+ for (const name of new Set([...requiredFailures, ...Object.keys(evidence.failures)])) {
607
+ const count = evidence.failures[name];
608
+ checks.push(
609
+ boundCheck(
610
+ name,
611
+ typeof count === "number" && Number.isFinite(count) && count >= 0 ? count : null,
612
+ 0,
613
+ "explicit run failure count",
614
+ ),
615
+ );
616
+ }
617
+ coverage(
618
+ "reconnect-coverage",
619
+ evidence.reconnectAttempts > 1 &&
620
+ evidence.reconnectAttempts === evidence.reconnectAcknowledged,
621
+ "initial subscription and at least one reconnect acknowledged",
622
+ );
623
+ for (const [id, ok] of [
624
+ ["shutdown-clean", evidence.shutdownClean],
625
+ ["record-retired", evidence.recordRetired],
626
+ ["cleanup-complete", evidence.cleanupComplete],
627
+ ] as const)
628
+ checks.push({ id, ok, observed: ok ? 0 : 1, bound: 0, detail: "observed teardown outcome" });
629
+ const trends = soakTrends(samples, evidence.warmupSeconds, evidence.trailingSeconds);
630
+ for (const [window, metrics] of Object.entries(trends)) {
631
+ if (window === "whole") continue;
632
+ const fit = metrics.rssMiBPerHour;
633
+ checks.push(
634
+ boundCheck(
635
+ `rss-${window}`,
636
+ fit && fit.points >= thresholds.minSamplesForFit ? fit.slope : null,
637
+ thresholds.maxRssGrowthMiBPerHour,
638
+ "predeclared window RSS slope MiB/h",
639
+ ),
640
+ );
641
+ }
642
+ }
385
643
  const failed = checks.some((check) => check.ok === false);
386
644
  const unmeasured = checks.some((check) => check.ok === null);
387
645
  return {
@@ -468,11 +726,23 @@ export function formatSoakReport(result: SoakVerdict): string {
468
726
  ["tmux spawns", `${summary.tmuxSpawnsTotal} total ${num(summary.tmuxSpawnsPerMinute, 2)}/min`],
469
727
  [
470
728
  "ping rtt ms",
471
- `p50 ${num(summary.pingRttMs.p50, 2)} max ${num(summary.pingRttMs.max, 2)} n ${summary.pingRttMs.count}`,
729
+ `p50 <= ${num(summary.pingRttMs.p50, 2)} max ${num(summary.pingRttMs.max, 2)} n ${summary.pingRttMs.count}`,
472
730
  ],
473
731
  [
474
732
  "receipt ms",
475
- `p50 ${num(summary.receiptLatencyMs.p50, 0)} max ${num(summary.receiptLatencyMs.max, 0)} n ${summary.receiptLatencyMs.count}`,
733
+ `p50 <= ${num(summary.receiptLatencyMs.p50, 0)} max ${num(summary.receiptLatencyMs.max, 0)} n ${summary.receiptLatencyMs.count}`,
734
+ ],
735
+ [
736
+ "owner diagnostics",
737
+ `${summary.diagnostics.validSamples}/${summary.samples} valid; resources unsupported ${summary.diagnostics.unsupportedResourceSamples}`,
738
+ ],
739
+ [
740
+ "heap used MiB",
741
+ `start ${num(summary.diagnostics.memoryBytes.heapUsed?.start === null || summary.diagnostics.memoryBytes.heapUsed?.start === undefined ? null : summary.diagnostics.memoryBytes.heapUsed.start / 1024 ** 2)} end ${num(summary.diagnostics.memoryBytes.heapUsed?.end === null || summary.diagnostics.memoryBytes.heapUsed?.end === undefined ? null : summary.diagnostics.memoryBytes.heapUsed.end / 1024 ** 2)} (descriptive; GC-sensitive)`,
742
+ ],
743
+ [
744
+ "diagnostic CPU",
745
+ `${num(summary.diagnostics.cpuPercent.start)} → ${num(summary.diagnostics.cpuPercent.end)} % interval; policy pending`,
476
746
  ],
477
747
  ["observer gaps", String(summary.observerGapWarnings)],
478
748
  ["daemon restarts", String(summary.daemonRestarts)],
@@ -492,3 +762,24 @@ export function formatSoakReport(result: SoakVerdict): string {
492
762
  lines.push(`verdict: ${result.verdict.toUpperCase()}`);
493
763
  return lines.join("\n");
494
764
  }
765
+
766
+ /** A revision acknowledges exactly the interest set sent in that subscribe. */
767
+ export function validSoakAck(frame: unknown, revision: number): boolean {
768
+ if (!frame || typeof frame !== "object") return false;
769
+ const ack = frame as Record<string, unknown>;
770
+ return (
771
+ ack.type === "resource.interests-ack" &&
772
+ ack.interestRevision === revision &&
773
+ Array.isArray(ack.unavailableInterests) &&
774
+ ack.unavailableInterests.length === 0
775
+ );
776
+ }
777
+
778
+ /** Unsolicited/stale control payloads cannot discharge the current probe. */
779
+ export function correlatedPongRtt(
780
+ probe: { readonly id: string; readonly at: number },
781
+ payload: string,
782
+ now: number,
783
+ ): number | null {
784
+ return payload === probe.id && Number.isFinite(now) && now >= probe.at ? now - probe.at : null;
785
+ }
@@ -0,0 +1,43 @@
1
+ import type { RendererNeutralColor } from "@tmux-ide/contracts";
2
+ import type { ResolvedThemeMode } from "./theme-mode.ts";
3
+
4
+ function color(red: number, green: number, blue: number): RendererNeutralColor {
5
+ return { space: "srgb", red, green, blue, alpha: 255 };
6
+ }
7
+
8
+ export function parseTerminalHostColor(
9
+ value: string | null | undefined,
10
+ ): RendererNeutralColor | null {
11
+ if (!value) return null;
12
+ const normalized = value.trim().toLowerCase();
13
+ const hex = /^#([\da-f]{3}|[\da-f]{6})$/u.exec(normalized)?.[1];
14
+ if (hex) {
15
+ const expanded =
16
+ hex.length === 3 ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` : hex;
17
+ return color(
18
+ Number.parseInt(expanded.slice(0, 2), 16),
19
+ Number.parseInt(expanded.slice(2, 4), 16),
20
+ Number.parseInt(expanded.slice(4, 6), 16),
21
+ );
22
+ }
23
+ // OSC palette replies may use X11's rgb:RR/GG/BB form with one to four
24
+ // hexadecimal digits per channel. Scale each channel to a byte rather than
25
+ // truncating high-fidelity replies.
26
+ const x11 = /^rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})$/u.exec(normalized);
27
+ if (!x11) return null;
28
+ const channel = (part: string): number => {
29
+ const maximum = 16 ** part.length - 1;
30
+ return Math.round((Number.parseInt(part, 16) / maximum) * 255);
31
+ };
32
+ return color(channel(x11[1]!), channel(x11[2]!), channel(x11[3]!));
33
+ }
34
+
35
+ /** Only a reported default background determines host appearance. */
36
+ export function terminalHostMode(background: string | null | undefined): ResolvedThemeMode | null {
37
+ const value = parseTerminalHostColor(background);
38
+ return value
39
+ ? 0.299 * value.red + 0.587 * value.green + 0.114 * value.blue > 127.5
40
+ ? "light"
41
+ : "dark"
42
+ : null;
43
+ }
@@ -0,0 +1,180 @@
1
+ /** Quantized sRGB contrast; Oklab matrices: https://bottosson.github.io/posts/oklab/ . */
2
+ const LINEAR = Float64Array.from({ length: 256 }, (_, n) => {
3
+ const c = n / 255;
4
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
5
+ });
6
+ const luminance = (rgb: number): number =>
7
+ 0.2126 * LINEAR[(rgb >>> 16) & 255]! +
8
+ 0.7152 * LINEAR[(rgb >>> 8) & 255]! +
9
+ 0.0722 * LINEAR[rgb & 255]!;
10
+ export function packedContrastRatio(a: number, b: number): number {
11
+ const x = luminance(a) + 0.05,
12
+ y = luminance(b) + 0.05;
13
+ return Math.max(x, y) / Math.min(x, y);
14
+ }
15
+ function byte(c: number): number {
16
+ return Math.round(
17
+ Math.max(0, Math.min(1, c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)) * 255,
18
+ );
19
+ }
20
+ function candidate(L: number, a: number, b: number): number {
21
+ // Reduce chroma along the same hue ray until inside sRGB, then quantize.
22
+ let lo = 0,
23
+ hi = 1,
24
+ result = -1;
25
+ for (let i = 0; i < 10; i++) {
26
+ const scale = i === 0 ? 1 : (lo + hi) / 2;
27
+ const l = (L + 0.3963377774 * a * scale + 0.2158037573 * b * scale) ** 3;
28
+ const m = (L - 0.1055613458 * a * scale - 0.0638541728 * b * scale) ** 3;
29
+ const s = (L - 0.0894841775 * a * scale - 1.291485548 * b * scale) ** 3;
30
+ const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
31
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
32
+ const blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
33
+ if (Math.min(r, g, blue) >= 0 && Math.max(r, g, blue) <= 1) {
34
+ result = (byte(r) << 16) | (byte(g) << 8) | byte(blue);
35
+ if (i === 0) return result;
36
+ lo = scale;
37
+ } else hi = scale;
38
+ }
39
+ return result < 0 ? byte(L ** 3) * 0x10101 : result;
40
+ }
41
+ /** Leave passing pairs exact. Search both lightness directions with a verified endpoint fallback. */
42
+ export function correctContrastForeground(fg: number, bg: number): number {
43
+ if (packedContrastRatio(fg, bg) >= 4.5) return fg;
44
+ const r = LINEAR[(fg >>> 16) & 255]!,
45
+ g = LINEAR[(fg >>> 8) & 255]!,
46
+ b = LINEAR[fg & 255]!;
47
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
48
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
49
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
50
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
51
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
52
+ const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
53
+ let best = 0,
54
+ distance = Infinity;
55
+ for (const end of [0, 1]) {
56
+ let passing = end,
57
+ failing = L;
58
+ let rgb = end === 0 ? 0 : 0xffffff;
59
+ if (packedContrastRatio(rgb, bg) < 4.5) continue;
60
+ for (let i = 0; i < 12; i++) {
61
+ const mid = (passing + failing) / 2;
62
+ const trial = candidate(mid, a, bb);
63
+ if (packedContrastRatio(trial, bg) >= 4.5) {
64
+ passing = mid;
65
+ rgb = trial;
66
+ } else failing = mid;
67
+ }
68
+ if (Math.abs(passing - L) < distance) {
69
+ best = rgb;
70
+ distance = Math.abs(passing - L);
71
+ }
72
+ }
73
+ return best;
74
+ }
75
+
76
+ /** Cheap high-color-churn fallback: linear-sRGB blend to the nearer passing endpoint. */
77
+ export function fastContrastForeground(fg: number, bg: number): number {
78
+ if (packedContrastRatio(fg, bg) >= 4.5) return fg;
79
+ const y = luminance(fg),
80
+ base = luminance(bg);
81
+ const darkTarget = (base + 0.05) / 4.5 - 0.05;
82
+ const lightTarget = (base + 0.05) * 4.5 - 0.05;
83
+ const darkMix = darkTarget >= 0 ? (y - darkTarget) / y : Infinity;
84
+ const lightMix = lightTarget <= 1 ? (lightTarget - y) / (1 - y) : Infinity;
85
+ const end = darkMix <= lightMix ? 0 : 1;
86
+ // A small quantization margin usually avoids endpoint fallback. Always verify.
87
+ const amount = Math.min(1, Math.max(0, Math.min(darkMix, lightMix)) + 0.012);
88
+ const r = LINEAR[(fg >>> 16) & 255]!,
89
+ g = LINEAR[(fg >>> 8) & 255]!,
90
+ b = LINEAR[fg & 255]!;
91
+ const result =
92
+ (byte(r + (end - r) * amount) << 16) |
93
+ (byte(g + (end - g) * amount) << 8) |
94
+ byte(b + (end - b) * amount);
95
+ return packedContrastRatio(result, bg) >= 4.5 ? result : end === 0 ? 0 : 0xffffff;
96
+ }
97
+
98
+ export function createContrastPairCache(limit = 4096) {
99
+ const pairs = new Map<number, number>();
100
+ let remaining = Infinity;
101
+ return {
102
+ beginFrame() {
103
+ remaining = 128;
104
+ },
105
+ get size() {
106
+ return pairs.size;
107
+ },
108
+ correct(fg: number, bg: number): number {
109
+ const key = fg * 0x1000000 + bg;
110
+ const cached = pairs.get(key);
111
+ if (cached !== undefined) return cached;
112
+ const corrected =
113
+ packedContrastRatio(fg, bg) >= 4.5
114
+ ? fg
115
+ : remaining-- > 0
116
+ ? correctContrastForeground(fg, bg)
117
+ : fastContrastForeground(fg, bg);
118
+ if (pairs.size >= limit) pairs.clear();
119
+ pairs.set(key, corrected);
120
+ return corrected;
121
+ },
122
+ };
123
+ }
124
+
125
+ export interface ContrastBuffer {
126
+ readonly buffers: {
127
+ char: Uint32Array;
128
+ fg: Uint16Array;
129
+ bg: Uint16Array;
130
+ attributes: Uint32Array;
131
+ };
132
+ }
133
+ const read = (c: Uint16Array, i: number) =>
134
+ ((c[i]! & 255) << 16) | ((c[i + 1]! & 255) << 8) | (c[i + 2]! & 255);
135
+ /** Final composed surface only: no canonical state or scheduling; allocation-free cache hits. */
136
+ export function createAutomaticContrastPass() {
137
+ const cache = createContrastPairCache();
138
+ return (buffer: ContrastBuffer): void => {
139
+ cache.beginFrame();
140
+ const { char, fg, bg, attributes } = buffer.buffers;
141
+ for (let cell = 0; cell < char.length; cell++) {
142
+ const cp = char[cell]!,
143
+ attr = attributes[cell]!,
144
+ i = cell * 4;
145
+ // Preserve concealment, whitespace and pixel-art coverage. Grapheme IDs and
146
+ // wide continuations are opaque and retained byte-for-byte.
147
+ if (
148
+ attr & 64 ||
149
+ cp === 0 ||
150
+ cp === 32 ||
151
+ cp === 160 ||
152
+ (cp >= 0x2580 && cp <= 0x259f) ||
153
+ (cp >= 0x2800 && cp <= 0x28ff)
154
+ )
155
+ continue;
156
+ if ((fg[i + 3]! & 255) !== 255 || (bg[i + 3]! & 255) !== 255) continue;
157
+ const foreground = attr & 32 ? bg : fg;
158
+ const background = attr & 32 ? fg : bg;
159
+ const original = read(foreground, i);
160
+ const base = read(background, i);
161
+ // DIM is emulator-dependent. Materialize a deterministic 50% sRGB blend
162
+ // before correction and clear only the final composed DIM bit.
163
+ let visible = original;
164
+ if (attr & 2) {
165
+ visible =
166
+ (Math.round(((original >>> 16) + (base >>> 16)) / 2) << 16) |
167
+ (Math.round((((original >>> 8) & 255) + ((base >>> 8) & 255)) / 2) << 8) |
168
+ Math.round(((original & 255) + (base & 255)) / 2);
169
+ attributes[cell] = attr & ~2;
170
+ }
171
+ const corrected = cache.correct(visible, base);
172
+ if (corrected === original && !(attr & 2)) continue;
173
+ // RGB intent is zero. Indexed/default metadata must not override correction.
174
+ foreground[i] = corrected >>> 16;
175
+ foreground[i + 1] = (corrected >>> 8) & 255;
176
+ foreground[i + 2] = corrected & 255;
177
+ foreground[i + 3] = 255;
178
+ }
179
+ };
180
+ }