@rikcodes/teamclaude 1.1.22-rik.1 → 1.1.22-rik.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.22-rik.1",
3
+ "version": "1.1.22-rik.3",
4
4
  "description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -107,6 +107,9 @@ export function createDefaultConfig() {
107
107
  distributeSessions: false,
108
108
  sessionTitles: { enabled: false, width: 18 },
109
109
  quotaBarPercent: true,
110
+ // Tokens-per-second readouts in the TUI. Off unless asked for: with it off
111
+ // the proxy does no throughput work at all (see OutputTracker).
112
+ throughputMeter: false,
110
113
  projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
111
114
  eventLogging: 'hide',
112
115
  defaultClientMode: 'mitm',
package/src/index.js CHANGED
@@ -532,6 +532,9 @@ async function serverCommand() {
532
532
  config.messageThreads = fleetThreads;
533
533
  // Read by the TUI on every frame, so a hand edit lands on the next reload.
534
534
  config.quotaBarPercent = diskConfig.quotaBarPercent !== false;
535
+ // Read by the server per request and by the TUI per frame, so the reload
536
+ // is the whole application: the next request is timed, or is not.
537
+ config.throughputMeter = diskConfig.throughputMeter === true;
535
538
  // Read by `run`/`env` from disk, but the TUI settings screen shows it live.
536
539
  config.defaultClientMode = diskConfig.defaultClientMode === 'base-url' ? 'base-url' : 'mitm';
537
540
  // The fleet switch for spending Codex reset credits. The redeemer reads it
@@ -632,6 +635,7 @@ async function serverCommand() {
632
635
  // the edit never reached disk and was silently undone by the next start.
633
636
  if (config.eventLogging != null) diskConfig.eventLogging = config.eventLogging;
634
637
  if (config.quotaBarPercent != null) diskConfig.quotaBarPercent = config.quotaBarPercent;
638
+ if (config.throughputMeter != null) diskConfig.throughputMeter = config.throughputMeter;
635
639
  if (config.defaultClientMode != null) diskConfig.defaultClientMode = config.defaultClientMode;
636
640
  if (config.autoRedeemResets != null) diskConfig.autoRedeemResets = config.autoRedeemResets;
637
641
  if (config.blockedModels != null) diskConfig.blockedModels = config.blockedModels;
@@ -671,6 +675,9 @@ async function serverCommand() {
671
675
  onRequestStart: (id, info) => tui.onRequestStart(id, info),
672
676
  onRequestModel: (id, info) => tui.onRequestModel(id, info),
673
677
  onRequestRouted: (id, info) => tui.onRequestRouted(id, info),
678
+ // Fired from inside the stream reader, and only with `throughputMeter` on,
679
+ // so it is bound once here rather than wrapped in one more call.
680
+ onRequestProgress: tui.onRequestProgress.bind(tui),
674
681
  onRequestEnd: (id, info) => tui.onRequestEnd(id, info),
675
682
  };
676
683
  }
package/src/server.js CHANGED
@@ -23,6 +23,7 @@ import { forwardRefusal, guardedLookup, FORBIDDEN_FORWARD } from './forward-targ
23
23
  import { renderDashboardHtml, dashboardCsp } from './dashboard.js';
24
24
  import { createUsageRecorder, resolveUsageDimensions, usageDimensionHeaderNames } from './client-usage.js';
25
25
  import { responsesEventUsage, isResponsesBody, normalizeResponsesUsage } from './responses-usage.js';
26
+ import { OutputTracker } from './throughput.js';
26
27
  import { classificationPath } from './classification-path.js';
27
28
  import { serveManagementMcp } from './mcp-tools.js';
28
29
  import { codexSpentWindows, isAccountWideCodexWindow } from './codex-quota.js';
@@ -1245,7 +1246,10 @@ export function clientSessionId(headers) {
1245
1246
  * @param {Object} opts.accountManager
1246
1247
  * @param {string} opts.upstream
1247
1248
  * @param {string|null} [opts.logDir]
1248
- * @param {Object} [opts.hooks] activity callbacks (onRequestStart, onRequestEnd, ...), all optional
1249
+ * @param {Object} [opts.hooks] activity callbacks (onRequestStart, onRequestEnd, ...), all optional.
1250
+ * onRequestProgress(reqId, { chars, at }) is called once per output event of
1251
+ * a streamed response, and only while `config.throughputMeter` is on: it runs
1252
+ * inside the stream reader, so it must stay O(1) and never render.
1249
1253
  * @param {Object|null} [opts.sx]
1250
1254
  * @param {number} [opts.holdMs]
1251
1255
  * @param {Object} [opts.config] the live config object; read per request, never copied
@@ -1507,7 +1511,8 @@ export function createProxyRequestListener({ accountManager, upstream, logDir =
1507
1511
 
1508
1512
  // stripOverage is sampled once here, at dispatch: retries and holds of
1509
1513
  // this request keep it, and a reload applies to subsequent requests.
1510
- const ctx = { account: null, status: null, tried: new Set(), reauthed: new Set(), model, advisorModel, streamRequested: parseRequestStream(body), fleetMessageThreads: config?.messageThreads === true, pinnedIndex, provider, holdBudgetMs: holdMs, pinKey, client, delivered: false, abandoned: false, onUsage: usageRecorder.onUsage, stripHeaders, stripOverage: shouldStripOverageHeaders(config), logLevel: resolveLogLevel(config), logMaxBodyBytes: resolveLogMaxBodyBytes(config) };
1514
+ const output = hideActivity ? null : outputTrackerFor(config, hooks, reqId);
1515
+ const ctx = { account: null, status: null, tried: new Set(), reauthed: new Set(), model, advisorModel, streamRequested: parseRequestStream(body), fleetMessageThreads: config?.messageThreads === true, pinnedIndex, provider, holdBudgetMs: holdMs, pinKey, client, delivered: false, abandoned: false, onUsage: usageRecorder.onUsage, output, stripHeaders, stripOverage: shouldStripOverageHeaders(config), logLevel: resolveLogLevel(config), logMaxBodyBytes: resolveLogMaxBodyBytes(config) };
1511
1516
  // Hold the session "in flight" across the WHOLE request (incl. retries and
1512
1517
  // a multi-minute streaming completion) so it stays counted as active and
1513
1518
  // never expires mid-request.
@@ -1563,7 +1568,9 @@ export function createProxyRequestListener({ accountManager, upstream, logDir =
1563
1568
  // marked open would send the outer catch to call that same throwing hook
1564
1569
  // a second time for one request.
1565
1570
  openEntry = null;
1566
- if (!hideActivity) hooks.onRequestEnd?.(reqId, { method: req.method, path: req.url, account: ctx.account, status: ctx.status, model: ctx.model, sessionId, pinned: ctx.pinnedIndex != null, client });
1571
+ // With the meter on, the end also carries the settled output count and
1572
+ // when the output started and stopped; off, the entry is what it was.
1573
+ if (!hideActivity) hooks.onRequestEnd?.(reqId, { method: req.method, path: req.url, account: ctx.account, status: ctx.status, model: ctx.model, sessionId, pinned: ctx.pinnedIndex != null, client, ...output?.summary() });
1567
1574
  }
1568
1575
  } catch (err) {
1569
1576
  reportFailure('[TeamClaude] Unhandled error:', err);
@@ -1604,6 +1611,23 @@ export function createProxyRequestListener({ accountManager, upstream, logDir =
1604
1611
  };
1605
1612
  }
1606
1613
 
1614
+ /**
1615
+ * The throughput meter's tracker for one request, or null while the meter is off
1616
+ * (`throughputMeter`, opt-in). Sampled at dispatch off the live config, like
1617
+ * the telemetry mode, so the settings toggle lands on the next request. Off,
1618
+ * there is no tracker, and the stream reader's whole cost is testing for one.
1619
+ * A consumer with no progress hook (the headless activity log) gets none
1620
+ * either way: nothing would read it.
1621
+ *
1622
+ * @param {any} config the live config
1623
+ * @param {any} hooks
1624
+ * @param {number} reqId
1625
+ */
1626
+ function outputTrackerFor(config, hooks, reqId) {
1627
+ const onProgress = hooks.onRequestProgress;
1628
+ return config?.throughputMeter === true && typeof onProgress === 'function' ? new OutputTracker(reqId, onProgress) : null;
1629
+ }
1630
+
1607
1631
  /**
1608
1632
  * Report a failure without depending on the console to survive it.
1609
1633
  *
@@ -3110,6 +3134,10 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
3110
3134
  }
3111
3135
  let upstreamRes;
3112
3136
  let admittedLoad = 0;
3137
+ // For the throughput meter: this attempt is being sent. A later attempt
3138
+ // overwrites it, so the time that survives is the answering attempt's, and
3139
+ // a buffered answer is timed from here rather than from the client's ask.
3140
+ ctx.output?.dispatched();
3113
3141
  try {
3114
3142
  upstreamRes = await upstreamFetch(upstreamUrl, {
3115
3143
  method,
@@ -3669,7 +3697,7 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
3669
3697
  const l = getLog();
3670
3698
  const bw = l ? l.bodyWriter('RESPONSE BODY (streamed)', contentType) : null;
3671
3699
  try {
3672
- await streamResponse(upstreamBody, res, account.index, accountManager, bw, ctx.onUsage, ctx.pinKey, ctx.model);
3700
+ await streamResponse(upstreamBody, res, account.index, accountManager, bw, ctx.onUsage, ctx.pinKey, ctx.model, ctx.output);
3673
3701
  // Reached only when the stream completed. A stream that dies upstream
3674
3702
  // throws out of streamResponse, so it never marks itself delivered —
3675
3703
  // which is the failure the token counters cannot see, since a stream
@@ -3684,7 +3712,7 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
3684
3712
  l?.end();
3685
3713
  } else {
3686
3714
  const buf = Buffer.from(await upstreamRes.arrayBuffer());
3687
- extractUsageFromBody(buf, account.index, accountManager, ctx.onUsage, ctx.pinKey, ctx.model);
3715
+ extractUsageFromBody(buf, account.index, accountManager, ctx.onUsage, ctx.pinKey, ctx.model, ctx.output);
3688
3716
  const l = getLog();
3689
3717
  if (l) { l.body('RESPONSE BODY', buf, contentType); l.end(); }
3690
3718
  res.end(buf);
@@ -3855,8 +3883,9 @@ export function readWithIdleTimeout(reader, ms) {
3855
3883
 
3856
3884
  /**
3857
3885
  * Stream an SSE response to the client, parsing usage data along the way.
3886
+ * `output` is the throughput meter's tracker for this response, when it is on.
3858
3887
  */
3859
- export async function streamResponse(webStream, res, accountIndex, accountManager, bodyWriter, onUsage = null, pinKey = null, model = null) {
3888
+ export async function streamResponse(webStream, res, accountIndex, accountManager, bodyWriter, onUsage = null, pinKey = null, model = null, output = null) {
3860
3889
  const reader = webStream.getReader();
3861
3890
  // A client that leaves while upstream is silent must not hold the pending
3862
3891
  // read — and with it the upstream socket and its admission permit — until
@@ -3876,7 +3905,7 @@ export async function streamResponse(webStream, res, accountIndex, accountManage
3876
3905
  // remembers that it did — the incremental counters would book the turn again
3877
3906
  // if a second terminal event arrived. See parseSSEDataLine.
3878
3907
  const responsesTurn = { settled: false };
3879
- const usage = createSseLineScanner(line => parseSSEDataLine(line, accountIndex, accountManager, onUsage, merged, responsesTurn));
3908
+ const usage = createSseLineScanner(line => parseSSEDataLine(line, accountIndex, accountManager, onUsage, merged, responsesTurn, output));
3880
3909
 
3881
3910
  try {
3882
3911
  while (true) {
@@ -4032,8 +4061,9 @@ export function createSseLineScanner(onLine, maxChars = SSE_MAX_LINE_CHARS) {
4032
4061
  * @param {((inputTokens: number, outputTokens: number) => void)|null} [onUsage]
4033
4062
  * @param {Record<string, any>|null} [merged]
4034
4063
  * @param {{settled: boolean}|null} [responsesTurn] this stream's "already booked" flag
4064
+ * @param {OutputTracker|null} [output] the throughput meter's tracker, when it is on
4035
4065
  */
4036
- function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, merged = null, responsesTurn = null) {
4066
+ function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, merged = null, responsesTurn = null, output = null) {
4037
4067
  if (!line.startsWith('data: ')) return;
4038
4068
 
4039
4069
  try {
@@ -4046,6 +4076,7 @@ function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, me
4046
4076
  accountManager.updateUsage(accountIndex, 0, data.usage.output_tokens);
4047
4077
  onUsage?.(0, data.usage.output_tokens || 0);
4048
4078
  if (merged) Object.assign(merged, data.usage);
4079
+ output?.settle(data.usage.output_tokens);
4049
4080
  } else if (!responsesTurn?.settled) {
4050
4081
  // Both sides settle at once here, so unlike the Anthropic branches above
4051
4082
  // this is a single incremental update rather than one per side — and it
@@ -4059,14 +4090,20 @@ function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, me
4059
4090
  accountManager.updateUsage(accountIndex, usage.input_tokens, usage.output_tokens);
4060
4091
  onUsage?.(usage.input_tokens, usage.output_tokens);
4061
4092
  if (merged) Object.assign(merged, usage);
4093
+ output?.settle(usage.output_tokens);
4062
4094
  }
4063
4095
  }
4096
+ // The same parsed event, read for generated text. After the usage branches,
4097
+ // so a progress hook that throws costs only its own line: the catch below
4098
+ // swallows it, and the accounting for this line has already run.
4099
+ output?.event(data);
4064
4100
  } catch {
4065
4101
  // not valid JSON, skip
4066
4102
  }
4067
4103
  }
4068
4104
 
4069
- function extractUsageFromBody(buffer, accountIndex, accountManager, onUsage = null, pinKey = null, model = null) {
4105
+ /** @param {OutputTracker|null} [output] the throughput meter's tracker, when it is on */
4106
+ function extractUsageFromBody(buffer, accountIndex, accountManager, onUsage = null, pinKey = null, model = null, output = null) {
4070
4107
  try {
4071
4108
  const json = JSON.parse(buffer.toString());
4072
4109
  if (json.usage) {
@@ -4083,6 +4120,9 @@ function extractUsageFromBody(buffer, accountIndex, accountManager, onUsage = nu
4083
4120
  accountManager.updateUsage(accountIndex, usage.input_tokens, usage.output_tokens);
4084
4121
  onUsage?.(usage.input_tokens || 0, usage.output_tokens || 0);
4085
4122
  accountManager.recordTokenUsage(accountIndex, pinKey, model, usage);
4123
+ // Buffered, so there is no generation interval to time: the tracker's
4124
+ // first and last token stay null and the reader times the whole request.
4125
+ output?.settle(usage.output_tokens);
4086
4126
  }
4087
4127
  } catch {
4088
4128
  // not JSON or no usage
package/src/speedo.js ADDED
@@ -0,0 +1,252 @@
1
+ // The fleet speedo: a car-style dial drawn in Braille for the TUI.
2
+ //
3
+ // Braille because each cell is a 2×4 grid of dots, which is eight times the
4
+ // resolution of a character grid, and a terminal cell is about twice as tall as
5
+ // it is wide, so the dots come out close to square and a circle drawn in them
6
+ // comes out round. Every Braille character is one display column wide.
7
+ //
8
+ // The arc sweeps 240°: zero at the lower left, full scale at the lower right,
9
+ // half scale straight up. The needle pivots at the hub, and the reading sits
10
+ // under the hub with its unit below it. The needle is never drawn over the
11
+ // reading: it stops at the text's edge and picks up past it.
12
+ //
13
+ // The arc is lit in one colour up to the reading and dim past it. No green,
14
+ // yellow and red zones: the scale follows the fleet's own recent peak, so the
15
+ // top of it is "busier than lately", not a limit, and a zone would change
16
+ // colour the moment the scale stepped up under the same reading.
17
+ //
18
+ // Pure: a reading and a size in, lines out. Colour comes from the painter the
19
+ // caller hands over, so the dial is drawn with the same SGR helpers as the rest
20
+ // of the frame.
21
+
22
+ import { formatRate } from './throughput.js';
23
+
24
+ /** Rows the dial is drawn in at the least, and at the most. Six is the smallest
25
+ * that keeps an arc, a reading and its unit apart; past ten the dial only grows
26
+ * wider, and the top block has better uses for the columns. */
27
+ export const SPEEDO_MIN_H = 6;
28
+ export const SPEEDO_MAX_H = 10;
29
+
30
+ const DEG = Math.PI / 180;
31
+ const START = 210 * DEG; // where zero sits, counter-clockwise from 3 o'clock
32
+ const SWEEP = 240 * DEG; // clockwise from START to full scale
33
+ // Half the arc's stroke, in dots. A one-dot stroke leaves gaps where the circle
34
+ // crosses the dot grid at a shallow angle.
35
+ const HALF_STROKE = 0.75;
36
+ // Clear dots between the needle's tip and the inside of the arc.
37
+ const TIP_GAP = 3.5;
38
+
39
+ // Braille dot bits by [row][column] within a cell (Unicode's dots 1-8).
40
+ const DOT_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
41
+ const BRAILLE_BLANK = 0x2800;
42
+
43
+ const SGR = (/** @type {string} */ code) => (/** @type {string} */ s) => `\x1b[${code}m${s}\x1b[0m`;
44
+ /** @typedef {{ cyan: (s: string) => string, dim: (s: string) => string, bold: (s: string) => string }} Painter */
45
+ /** @type {Painter} */
46
+ const DEFAULT_PAINT = { cyan: SGR('36'), dim: SGR('2'), bold: SGR('1') };
47
+
48
+ /** @typedef {'blank'|'arc'|'arc-dim'|'needle'|'value'|'unit'|'label'} CellKind */
49
+ /** @typedef {{ ch: string, kind: CellKind }} Cell */
50
+
51
+ /**
52
+ * The radius, in dots, of the largest arc a dial `height` rows tall holds. The
53
+ * arc reaches R above the hub and R·sin 30° below it, and the bottom row is kept
54
+ * for the scale labels.
55
+ * @param {number} height
56
+ */
57
+ function radiusFor(height) {
58
+ return Math.floor((4 * (height - 1) - 2.5) / 1.5);
59
+ }
60
+
61
+ /**
62
+ * The columns a dial `height` rows tall is drawn in: the arc's width, a column
63
+ * either side, rounded up to odd so the hub sits in the middle of a column and
64
+ * the needle stands straight at half scale.
65
+ * @param {number} height
66
+ */
67
+ export function speedoWidth(height) {
68
+ const w = radiusFor(height) + 2;
69
+ return w % 2 ? w : w + 1;
70
+ }
71
+
72
+ /** Where `angle` falls along the sweep, 0 at zero and 1 at full scale, or null
73
+ * outside it (the gap at the bottom).
74
+ * @param {number} angle radians, counter-clockwise from 3 o'clock */
75
+ function sweepFraction(angle) {
76
+ let delta = (START - angle) % (2 * Math.PI);
77
+ if (delta < 0) delta += 2 * Math.PI;
78
+ const f = delta / SWEEP;
79
+ return f <= 1 + 1e-9 ? Math.min(1, f) : null;
80
+ }
81
+
82
+ /** A scale end as it is labelled: `500`, `1k`, `20k`, `1M`. The scale is a
83
+ * 1-2-5 step, so it divides evenly and `1.0k` would only be noise.
84
+ * @param {number} n */
85
+ export function formatScale(n) {
86
+ if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
87
+ if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`;
88
+ return String(Math.round(n));
89
+ }
90
+
91
+
92
+ /**
93
+ * The dial as a grid of cells, before any colour: what each cell shows and what
94
+ * it belongs to. Exposed so the tests can find the needle without parsing SGR.
95
+ *
96
+ * @param {{ rate: number, max: number, width: number, height: number }} opts
97
+ * @returns {Cell[][]} `height` rows of `width` cells
98
+ */
99
+ export function speedoCells({ rate, max, width, height }) {
100
+ width = Math.max(0, Math.floor(width));
101
+ height = Math.max(0, Math.floor(height));
102
+ /** @type {Cell[][]} */
103
+ const cells = Array.from({ length: height }, () => Array.from({ length: width }, () => ({ ch: ' ', kind: /** @type {CellKind} */ ('blank') })));
104
+ if (!width || !height) return cells;
105
+
106
+ const frac = max > 0 && rate > 0 ? Math.min(1, rate / max) : 0;
107
+ const R = Math.min(radiusFor(height), width - 2);
108
+ const Wp = width * 2;
109
+ const Hp = height * 4;
110
+ // Dot coordinates: x to the right, y down, a dot's centre at +0.5. With an
111
+ // odd width, `width` dots across is the middle of the middle column.
112
+ const cx = width;
113
+ const cy = R + 1.5;
114
+
115
+ // 0 nothing, 1 arc, 2 needle.
116
+ const layer = new Uint8Array(Wp * Hp);
117
+ if (R >= 3) {
118
+ for (let py = 0; py < Hp; py++) {
119
+ for (let px = 0; px < Wp; px++) {
120
+ const dx = px + 0.5 - cx;
121
+ const dy = py + 0.5 - cy;
122
+ if (Math.abs(Math.hypot(dx, dy) - R) > HALF_STROKE) continue;
123
+ if (sweepFraction(Math.atan2(-dy, dx)) !== null) layer[py * Wp + px] = 1;
124
+ }
125
+ }
126
+ const angle = START - frac * SWEEP;
127
+ // Snapped, because the hub sits on the line between the middle column's two
128
+ // dots: cos(90°) comes out a hair either side of zero, and an upright
129
+ // needle would zigzag between them.
130
+ const snap = (/** @type {number} */ v) => (Math.abs(v) < 1e-9 ? 0 : v);
131
+ const ux = snap(Math.cos(angle));
132
+ const uy = snap(Math.sin(angle));
133
+ const len = R - TIP_GAP;
134
+ for (let t = 0; t <= len; t += 0.25) {
135
+ const px = Math.floor(cx + t * ux);
136
+ const py = Math.floor(cy - t * uy);
137
+ if (px >= 0 && px < Wp && py >= 0 && py < Hp) layer[py * Wp + px] = 2;
138
+ }
139
+ }
140
+
141
+ // The reading under the hub, its unit under that, the scale ends on the
142
+ // bottom row beneath the arc's two ends. Rows are clamped so a short dial
143
+ // still keeps the reading.
144
+ const hubRow = Math.floor(cy / 4);
145
+ const valueRow = Math.max(0, Math.min(hubRow + 1, height - 2));
146
+ const unitRow = valueRow + 1;
147
+ const labelRow = height - 1;
148
+ const centre = width / 2;
149
+ const colFor = (/** @type {string} */ text) => Math.round(centre - text.length / 2);
150
+
151
+ /** @type {{ row: number, col: number, text: string, kind: CellKind }[]} */
152
+ const texts = [];
153
+ const value = formatRate(rate);
154
+ texts.push({ row: valueRow, col: colFor(value), text: value, kind: 'value' });
155
+ if (unitRow < height) texts.push({ row: unitRow, col: colFor('tok/s'), text: 'tok/s', kind: 'unit' });
156
+
157
+ // Nothing but text in the box around the reading: the needle stops at its
158
+ // edge, a column clear of the widest line.
159
+ const boxFrom = Math.min(...texts.map(t => t.col)) - 1;
160
+ const boxTo = Math.max(...texts.map(t => t.col + t.text.length)) + 1;
161
+ const inBox = (/** @type {number} */ r, /** @type {number} */ c) => r >= valueRow && r <= unitRow && c >= boxFrom && c < boxTo;
162
+
163
+ // The scale ends, both or neither: a lone `0` says nothing. Each must sit in
164
+ // the dial and clear of any text already on its row.
165
+ if (R >= 3 && labelRow > valueRow) {
166
+ const endX = R * Math.cos(30 * DEG);
167
+ const place = (/** @type {string} */ text, /** @type {number} */ dotX) => {
168
+ const col = Math.round(dotX / 2 - text.length / 2);
169
+ return { row: labelRow, col: Math.max(0, Math.min(width - text.length, col)), text, kind: /** @type {CellKind} */ ('label') };
170
+ };
171
+ const ends = [place('0', cx - endX), place(formatScale(max), cx + endX)];
172
+ const clear = (/** @type {{ row: number, col: number, text: string }} */ a) =>
173
+ a.col >= 0 && a.col + a.text.length <= width
174
+ && texts.every(t => t.row !== a.row || a.col + a.text.length + 1 <= t.col || t.col + t.text.length + 1 <= a.col);
175
+ if (ends[0].col + ends[0].text.length + 1 <= ends[1].col && ends.every(clear)) texts.push(...ends);
176
+ }
177
+
178
+ for (let r = 0; r < height; r++) {
179
+ for (let c = 0; c < width; c++) {
180
+ if (inBox(r, c)) continue;
181
+ let bits = 0;
182
+ let needle = false;
183
+ let fsum = 0;
184
+ let fn = 0;
185
+ for (let dy = 0; dy < 4; dy++) {
186
+ for (let dx = 0; dx < 2; dx++) {
187
+ const px = c * 2 + dx;
188
+ const py = r * 4 + dy;
189
+ const v = layer[py * Wp + px];
190
+ if (!v) continue;
191
+ bits |= DOT_BITS[dy][dx];
192
+ if (v === 2) needle = true;
193
+ else {
194
+ const f = sweepFraction(Math.atan2(-(py + 0.5 - cy), px + 0.5 - cx));
195
+ if (f !== null) { fsum += f; fn++; }
196
+ }
197
+ }
198
+ }
199
+ if (!bits) continue;
200
+ const ch = String.fromCodePoint(BRAILLE_BLANK + bits);
201
+ if (needle) { cells[r][c] = { ch, kind: 'needle' }; continue; }
202
+ const f = fn ? fsum / fn : 0;
203
+ cells[r][c] = { ch, kind: frac > 0 && f <= frac ? 'arc' : 'arc-dim' };
204
+ }
205
+ }
206
+
207
+ for (const t of texts) {
208
+ if (t.row < 0 || t.row >= height) continue;
209
+ for (let i = 0; i < t.text.length; i++) {
210
+ const c = t.col + i;
211
+ if (c >= 0 && c < width) cells[t.row][c] = { ch: t.text[i], kind: t.kind };
212
+ }
213
+ }
214
+ return cells;
215
+ }
216
+
217
+ /**
218
+ * The dial as `height` lines of exactly `width` display columns each.
219
+ *
220
+ * Colour runs are closed on every change of style, so no cell's colour can
221
+ * bleed into the next, or past the dial into whatever follows it on the line.
222
+ *
223
+ * @param {{ rate: number, max: number, width: number, height: number, paint?: Painter }} opts
224
+ * @returns {string[]}
225
+ */
226
+ export function renderSpeedo({ rate, max, width, height, paint = DEFAULT_PAINT }) {
227
+ // Cyan is the dashboard's accent for what is live — the spinner, the active
228
+ // count — and the lit arc is exactly that.
229
+ /** @type {Record<CellKind, (s: string) => string>} */
230
+ const style = {
231
+ blank: (s) => s,
232
+ arc: (s) => paint.cyan(s),
233
+ 'arc-dim': (s) => paint.dim(s),
234
+ needle: (s) => paint.bold(s),
235
+ value: (s) => paint.bold(s),
236
+ unit: (s) => paint.dim(s),
237
+ label: (s) => paint.dim(s),
238
+ };
239
+ return speedoCells({ rate, max, width, height }).map(row => {
240
+ let out = '';
241
+ let run = '';
242
+ /** @type {Cell|null} */
243
+ let head = null;
244
+ const flush = () => { if (head) out += style[head.kind](run); run = ''; };
245
+ for (const cell of row) {
246
+ if (!head || cell.kind !== head.kind) { flush(); head = cell; }
247
+ run += cell.ch;
248
+ }
249
+ flush();
250
+ return out;
251
+ });
252
+ }