@genee/omp-opsx-addon 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/pipe-core.ts +75 -5
- package/lib/pipe-push.ts +118 -4
- package/lib/usage-poller.ts +110 -25
- package/lib/usage-render.ts +103 -80
- package/lib/usage-widget.ts +16 -12
- package/package.json +1 -1
package/lib/pipe-core.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
// The broker-id is the primary session id (`ctx.sessionManager.getSessionId()`).
|
|
12
12
|
// This module implements the CORE only: directory layout, presence heartbeat,
|
|
13
13
|
// roster mirroring, the file mbox protocol (with the single shared `consume()`
|
|
14
|
-
// primitive), four LLM tools, and background GC.
|
|
15
|
-
//
|
|
14
|
+
// primitive), four LLM tools, and background GC. `to.broker: "all"` is reserved
|
|
15
|
+
// for online-peer fanout (never a literal mbox/all/ slot). The `deliver` field
|
|
16
|
+
// is defined, validated, and stored — NEVER injected (injection is the scope of
|
|
16
17
|
// opsx-pipe-push).
|
|
17
18
|
import { promises as fs } from 'fs';
|
|
18
19
|
import { join } from 'path';
|
|
@@ -237,6 +238,13 @@ export interface SendInput {
|
|
|
237
238
|
export async function sendMessage(root: string, input: SendInput): Promise<PipeMessage> {
|
|
238
239
|
assertBrokerId(input.fromBroker, 'from.broker');
|
|
239
240
|
assertBrokerId(input.toBroker, 'to.broker');
|
|
241
|
+
// "all" is reserved for broadcast fanout (see {@link broadcastMessage});
|
|
242
|
+
// never land a literal mbox/all/ dead-letter slot.
|
|
243
|
+
if (input.toBroker === 'all') {
|
|
244
|
+
throw new Error(
|
|
245
|
+
'to.broker "all" is reserved for broadcast fanout; refuse to write mbox/all/',
|
|
246
|
+
);
|
|
247
|
+
}
|
|
240
248
|
assertBody(input.body);
|
|
241
249
|
if (input.toSession !== undefined && typeof input.toSession !== 'string') {
|
|
242
250
|
throw new Error('to.session must be a string when provided');
|
|
@@ -265,6 +273,38 @@ export async function sendMessage(root: string, input: SendInput): Promise<PipeM
|
|
|
265
273
|
return msg;
|
|
266
274
|
}
|
|
267
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Fan out one outbound body to every currently-online broker except the
|
|
278
|
+
* sender. Online = presence mtime within {@link PRESENCE_TTL_MS} (same filter
|
|
279
|
+
* as opsx_pipe_list). Each recipient gets its own msg-id / mbox file; nothing
|
|
280
|
+
* is written to a literal `mbox/all/` slot. `to.session` is rejected (a
|
|
281
|
+
* broadcast has no single last-mile session).
|
|
282
|
+
*/
|
|
283
|
+
export async function broadcastMessage(
|
|
284
|
+
root: string,
|
|
285
|
+
input: Omit<SendInput, 'toBroker' | 'toSession' | 'id'>,
|
|
286
|
+
): Promise<PipeMessage[]> {
|
|
287
|
+
assertBrokerId(input.fromBroker, 'from.broker');
|
|
288
|
+
assertBody(input.body);
|
|
289
|
+
const online = await listBrokers(root);
|
|
290
|
+
const targets = online.map((b) => b.broker).filter((id) => id !== input.fromBroker);
|
|
291
|
+
const out: PipeMessage[] = [];
|
|
292
|
+
for (const toBroker of targets) {
|
|
293
|
+
out.push(
|
|
294
|
+
await sendMessage(root, {
|
|
295
|
+
fromBroker: input.fromBroker,
|
|
296
|
+
toBroker,
|
|
297
|
+
deliver: input.deliver,
|
|
298
|
+
type: input.type,
|
|
299
|
+
body: input.body,
|
|
300
|
+
replyTo: input.replyTo,
|
|
301
|
+
ts: input.ts,
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
268
308
|
// Process-internal idempotency: consumed msg-ids per (root, broker). Cross-
|
|
269
309
|
// process, the atomic rename guarantees a single consumer wins a given file;
|
|
270
310
|
// worst case a redelivered id surfaces once per process (at-least-once).
|
|
@@ -659,11 +699,18 @@ export function registerPipeCore(pi: ExtensionAPI): void {
|
|
|
659
699
|
name: 'opsx_pipe_send',
|
|
660
700
|
label: 'Pipe: send message',
|
|
661
701
|
description:
|
|
662
|
-
'Fire-and-forget a text message to another broker\'s mbox (same semantics as hub send). Validates the deliver field { mode: steer|followUp|nextTurn, triggerTurn } (defaults followUp/true); core stores but never injects it. body is capped at 32KB.',
|
|
702
|
+
'Fire-and-forget a text message to another broker\'s mbox (same semantics as hub send). `to.broker: "all"` fans out to every currently-online peer (excludes self; no mbox/all/ dead letter). Validates the deliver field { mode: steer|followUp|nextTurn, triggerTurn } (defaults followUp/true); core stores but never injects it. body is capped at 32KB.',
|
|
663
703
|
parameters: z.object({
|
|
664
704
|
to: z.object({
|
|
665
|
-
broker: z
|
|
666
|
-
|
|
705
|
+
broker: z
|
|
706
|
+
.string()
|
|
707
|
+
.describe(
|
|
708
|
+
'Target broker id (a primary session id; see opsx_pipe_list), or "all" to fan out to every other online broker',
|
|
709
|
+
),
|
|
710
|
+
session: z
|
|
711
|
+
.string()
|
|
712
|
+
.optional()
|
|
713
|
+
.describe('Optional target agent/session for hub relay (not allowed with broker "all")'),
|
|
667
714
|
}),
|
|
668
715
|
deliver: z
|
|
669
716
|
.object({
|
|
@@ -685,6 +732,29 @@ export function registerPipeCore(pi: ExtensionAPI): void {
|
|
|
685
732
|
// explicitly. normalizeDeliver re-validates deliver at runtime.
|
|
686
733
|
const p = params as SendToolParams;
|
|
687
734
|
const state = await ensureBrokerStarted(ctx);
|
|
735
|
+
if (p.to.broker === 'all') {
|
|
736
|
+
if (p.to.session !== undefined) {
|
|
737
|
+
throw new Error('to.session is not supported with to.broker "all"');
|
|
738
|
+
}
|
|
739
|
+
const messages = await broadcastMessage(state.root, {
|
|
740
|
+
fromBroker: state.brokerId,
|
|
741
|
+
deliver: p.deliver,
|
|
742
|
+
type: p.type,
|
|
743
|
+
body: p.body,
|
|
744
|
+
replyTo: p.replyTo,
|
|
745
|
+
});
|
|
746
|
+
const targets = messages.map((m) => m.to.broker);
|
|
747
|
+
return textResult(
|
|
748
|
+
messages.length === 0
|
|
749
|
+
? 'Broadcast to all: no other online brokers.'
|
|
750
|
+
: `Broadcast ${messages.length} message(s) to: ${targets.join(', ')}`,
|
|
751
|
+
{
|
|
752
|
+
broadcast: true,
|
|
753
|
+
ids: messages.map((m) => m.id),
|
|
754
|
+
to: targets,
|
|
755
|
+
},
|
|
756
|
+
);
|
|
757
|
+
}
|
|
688
758
|
const msg = await sendMessage(state.root, {
|
|
689
759
|
fromBroker: state.brokerId,
|
|
690
760
|
toBroker: p.to.broker,
|
package/lib/pipe-push.ts
CHANGED
|
@@ -15,6 +15,16 @@
|
|
|
15
15
|
//
|
|
16
16
|
// No self-built queue: steer/followUp/nextTurn ordering and idle wake-ups
|
|
17
17
|
// are entirely the harness's native deliverAs/triggerTurn semantics.
|
|
18
|
+
//
|
|
19
|
+
// One deliberate deviation: while the session is mid-turn AND parked in an
|
|
20
|
+
// interruptible wait (hub `wait`, or hub `logs` with follow — the only tool
|
|
21
|
+
// calls the harness aborts on a steer, see the hub tool's interruptible
|
|
22
|
+
// predicate), a followUp is upgraded to steer. A followUp otherwise drains
|
|
23
|
+
// only at the turn's yield boundary (minutes away when the model is parked
|
|
24
|
+
// waiting on peers); a steer aborts the in-flight wait and is fed to the
|
|
25
|
+
// model before its next request. Every other busy state (model streaming,
|
|
26
|
+
// bash, ask, extension tools) keeps native followUp semantics — a steer
|
|
27
|
+
// there cannot preempt the running tool anyway.
|
|
18
28
|
import { promises as fs, watch } from 'fs';
|
|
19
29
|
import { join } from 'path';
|
|
20
30
|
import type { ExtensionAPI, ExtensionContext } from '@oh-my-pi/pi-coding-agent';
|
|
@@ -103,6 +113,44 @@ function isPipeMessage(value: unknown): value is PipeMessage {
|
|
|
103
113
|
return typeof from.broker === 'string' && typeof to.broker === 'string';
|
|
104
114
|
}
|
|
105
115
|
|
|
116
|
+
// ── busy-wait detection (followUp → steer upgrade) ───────────────────
|
|
117
|
+
|
|
118
|
+
/** In-flight tool call tracked for upgrade purposes: the only predicate that
|
|
119
|
+
* matters is whether a steer aborts it mid-flight. The harness's hub tool
|
|
120
|
+
* declares exactly two such calls (its `interruptible` predicate):
|
|
121
|
+
* `hub {op:"wait"}` and `hub {op:"logs", follow:true}`. */
|
|
122
|
+
interface TrackedTool {
|
|
123
|
+
toolName: string;
|
|
124
|
+
args?: unknown;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Per-broker view of session liveness, fed by turn_start/turn_end and
|
|
128
|
+
* tool_execution_start/end events. A broker is "busy-waiting" when a turn
|
|
129
|
+
* is open AND at least one in-flight tool call is an interruptible wait. */
|
|
130
|
+
export interface BusyState {
|
|
131
|
+
inTurn: boolean;
|
|
132
|
+
tools: Map<string, TrackedTool>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isInterruptibleWait(tool: TrackedTool): boolean {
|
|
136
|
+
if (tool.toolName !== 'hub') return false;
|
|
137
|
+
const args = (tool.args ?? {}) as { op?: unknown; follow?: unknown };
|
|
138
|
+
if (args.op === 'wait') return true;
|
|
139
|
+
return args.op === 'logs' && args.follow === true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True while the session is mid-turn and parked in an interruptible wait:
|
|
143
|
+
* the one busy state where delivering as steer (instead of followUp) makes
|
|
144
|
+
* the message reach the model before the turn yields. */
|
|
145
|
+
export function isBusyWaiting(state: BusyState): boolean {
|
|
146
|
+
if (!state.inTurn) return false;
|
|
147
|
+
|
|
148
|
+
for (const tool of state.tools.values()) {
|
|
149
|
+
if (isInterruptibleWait(tool)) return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
106
154
|
// ── per-broker runtime state ─────────────────────────────────────────
|
|
107
155
|
|
|
108
156
|
interface PushState {
|
|
@@ -110,6 +158,8 @@ interface PushState {
|
|
|
110
158
|
root: string;
|
|
111
159
|
/** Stop-injection flag; default false = injection enabled. */
|
|
112
160
|
muted: boolean;
|
|
161
|
+
/** Session liveness for the followUp→steer upgrade (see isBusyWaiting). */
|
|
162
|
+
busy: BusyState;
|
|
113
163
|
watcher?: WatchHandle;
|
|
114
164
|
/** True once the watcher failed; the 30s tick then carries all scans. */
|
|
115
165
|
degraded: boolean;
|
|
@@ -136,9 +186,18 @@ function setOwner(brokerId: string | undefined): void {
|
|
|
136
186
|
* Inject one message via pi.sendMessage (custom entry, never spoofed as a
|
|
137
187
|
* human). deliver.mode maps 1:1 to deliverAs; triggerTurn is passed through;
|
|
138
188
|
* missing deliver defaults to followUp/true (core normalizeDeliver).
|
|
189
|
+
*
|
|
190
|
+
* Upgrade: a followUp landing while the session is busy-waiting (mid-turn,
|
|
191
|
+
* parked in an interruptible hub wait) is delivered as steer instead. The
|
|
192
|
+
* steer aborts the wait so the model sees this message at its next request
|
|
193
|
+
* rather than after the whole turn yields. steer/nextTurn pass through
|
|
194
|
+
* untouched — a sender that asked for steer wants it everywhere; nextTurn is
|
|
195
|
+
* explicitly deferred by the sender.
|
|
139
196
|
*/
|
|
140
|
-
async function injectMessage(msg: PipeMessage, send: SendFn): Promise<void> {
|
|
197
|
+
async function injectMessage(msg: PipeMessage, state: PushState, send: SendFn): Promise<void> {
|
|
141
198
|
const deliver = normalizeDeliver(msg.deliver);
|
|
199
|
+
const upgraded = deliver.mode === 'followUp' && isBusyWaiting(state.busy);
|
|
200
|
+
const deliverAs: DeliverMode = upgraded ? 'steer' : deliver.mode;
|
|
142
201
|
await Promise.resolve(
|
|
143
202
|
send(
|
|
144
203
|
{
|
|
@@ -153,9 +212,10 @@ async function injectMessage(msg: PipeMessage, send: SendFn): Promise<void> {
|
|
|
153
212
|
ts: msg.ts,
|
|
154
213
|
...(msg.replyTo ? { replyTo: msg.replyTo } : {}),
|
|
155
214
|
deliver,
|
|
215
|
+
...(upgraded ? { upgradedToSteer: true } : {}),
|
|
156
216
|
},
|
|
157
217
|
},
|
|
158
|
-
{ deliverAs
|
|
218
|
+
{ deliverAs, triggerTurn: deliver.triggerTurn },
|
|
159
219
|
),
|
|
160
220
|
);
|
|
161
221
|
}
|
|
@@ -196,7 +256,7 @@ export async function scanOnce(state: PushState, send: SendFn): Promise<{ scanne
|
|
|
196
256
|
if (!msg) continue; // corrupt: removed by the claim step, never injected
|
|
197
257
|
state.seen.add(id);
|
|
198
258
|
try {
|
|
199
|
-
await injectMessage(msg, send);
|
|
259
|
+
await injectMessage(msg, state, send);
|
|
200
260
|
injected += 1;
|
|
201
261
|
} catch {
|
|
202
262
|
// Injection failed: do NOT claim — the file stays for the next
|
|
@@ -227,7 +287,7 @@ export async function scanOnce(state: PushState, send: SendFn): Promise<{ scanne
|
|
|
227
287
|
// file is valid (consume parsed it) — deliver it now.
|
|
228
288
|
state.seen.add(m.id);
|
|
229
289
|
try {
|
|
230
|
-
await injectMessage(m, send);
|
|
290
|
+
await injectMessage(m, state, send);
|
|
231
291
|
injected += 1;
|
|
232
292
|
} catch {
|
|
233
293
|
state.seen.delete(m.id);
|
|
@@ -294,6 +354,7 @@ export async function startPipePush(
|
|
|
294
354
|
brokerId,
|
|
295
355
|
root,
|
|
296
356
|
muted: false,
|
|
357
|
+
busy: { inTurn: false, tools: new Map() },
|
|
297
358
|
degraded: false,
|
|
298
359
|
seen: new Set(),
|
|
299
360
|
scanChain: Promise.resolve(),
|
|
@@ -357,6 +418,14 @@ export function isMuted(brokerId: string): boolean {
|
|
|
357
418
|
return states.get(brokerId)?.muted ?? false;
|
|
358
419
|
}
|
|
359
420
|
|
|
421
|
+
/** Resolve the push state for the ExtensionContext's session, if any.
|
|
422
|
+
* Subagent sessions share the process but never own a PushState, so their
|
|
423
|
+
* turn/tool events are ignored (no state → no-op). */
|
|
424
|
+
function stateForCtx(ctx: ExtensionContext): PushState | undefined {
|
|
425
|
+
const brokerId = ctx.sessionManager?.getSessionId?.() ?? '';
|
|
426
|
+
return brokerId ? states.get(brokerId) : undefined;
|
|
427
|
+
}
|
|
428
|
+
|
|
360
429
|
// ── plugin wiring ────────────────────────────────────────────────────
|
|
361
430
|
|
|
362
431
|
export function registerPipePush(pi: ExtensionAPI): void {
|
|
@@ -424,6 +493,36 @@ export function registerPipePush(pi: ExtensionAPI): void {
|
|
|
424
493
|
/* best-effort */
|
|
425
494
|
}
|
|
426
495
|
});
|
|
496
|
+
|
|
497
|
+
// Busy-wait tracking: only the owning primary session has PushState, so
|
|
498
|
+
// subagent turn/tool events (different session id) are no-ops. turn_end
|
|
499
|
+
// clears the whole tools map in case a tool_execution_end was missed
|
|
500
|
+
// (e.g. abort mid-batch).
|
|
501
|
+
pi.on('turn_start', (_event, ctx) => {
|
|
502
|
+
const state = stateForCtx(ctx);
|
|
503
|
+
if (!state) return;
|
|
504
|
+
state.busy.inTurn = true;
|
|
505
|
+
});
|
|
506
|
+
pi.on('turn_end', (_event, ctx) => {
|
|
507
|
+
const state = stateForCtx(ctx);
|
|
508
|
+
if (!state) return;
|
|
509
|
+
state.busy.inTurn = false;
|
|
510
|
+
state.busy.tools.clear();
|
|
511
|
+
});
|
|
512
|
+
pi.on('tool_execution_start', (event, ctx) => {
|
|
513
|
+
const state = stateForCtx(ctx);
|
|
514
|
+
if (!state) return;
|
|
515
|
+
const e = event as { toolCallId?: string; toolName?: string; args?: unknown };
|
|
516
|
+
if (!e.toolCallId || !e.toolName) return;
|
|
517
|
+
state.busy.tools.set(e.toolCallId, { toolName: e.toolName, args: e.args });
|
|
518
|
+
});
|
|
519
|
+
pi.on('tool_execution_end', (event, ctx) => {
|
|
520
|
+
const state = stateForCtx(ctx);
|
|
521
|
+
if (!state) return;
|
|
522
|
+
const e = event as { toolCallId?: string };
|
|
523
|
+
if (!e.toolCallId) return;
|
|
524
|
+
state.busy.tools.delete(e.toolCallId);
|
|
525
|
+
});
|
|
427
526
|
}
|
|
428
527
|
|
|
429
528
|
// ── test-only reset ──────────────────────────────────────────────────
|
|
@@ -441,6 +540,20 @@ export function _getStateForTest(brokerId: string): PushState | undefined {
|
|
|
441
540
|
return states.get(brokerId);
|
|
442
541
|
}
|
|
443
542
|
|
|
543
|
+
/** Test seam: overwrite busy-wait tracking for a broker (scanOnce upgrade tests). */
|
|
544
|
+
export function _setBusyForTest(
|
|
545
|
+
brokerId: string,
|
|
546
|
+
busy: { inTurn: boolean; tools?: Array<{ toolCallId: string; toolName: string; args?: unknown }> },
|
|
547
|
+
): void {
|
|
548
|
+
const state = states.get(brokerId);
|
|
549
|
+
if (!state) throw new Error(`_setBusyForTest: no state for ${brokerId}`);
|
|
550
|
+
state.busy.inTurn = busy.inTurn;
|
|
551
|
+
state.busy.tools.clear();
|
|
552
|
+
for (const t of busy.tools ?? []) {
|
|
553
|
+
state.busy.tools.set(t.toolCallId, { toolName: t.toolName, args: t.args });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
444
557
|
/** Test seam: await the settled scan chain (doorbell/tick scans are async). */
|
|
445
558
|
export async function _flushForTest(brokerId: string): Promise<void> {
|
|
446
559
|
const state = states.get(brokerId);
|
|
@@ -457,6 +570,7 @@ export async function _makeStateForTest(brokerId: string, cwd: string): Promise<
|
|
|
457
570
|
brokerId,
|
|
458
571
|
root,
|
|
459
572
|
muted: false,
|
|
573
|
+
busy: { inTurn: false, tools: new Map() },
|
|
460
574
|
degraded: true, // no watcher in this harness
|
|
461
575
|
seen: new Set(),
|
|
462
576
|
scanChain: Promise.resolve(),
|
package/lib/usage-poller.ts
CHANGED
|
@@ -177,10 +177,29 @@ const unconfirmedProviders = new Set<string>();
|
|
|
177
177
|
* reads would re-add the provider to {@link unconfirmedProviders} and hammer it.
|
|
178
178
|
*/
|
|
179
179
|
const confirmedThisProcess = new Set<string>();
|
|
180
|
+
/**
|
|
181
|
+
* Timestamp of the most recent fetch that actually delivered a report per
|
|
182
|
+
* provider (canonical id → epoch ms). This — NOT `lastFetchedByProvider`,
|
|
183
|
+
* which a successful-but-empty fetch also advances for TTL — is the local
|
|
184
|
+
* timestamp used by the locked fetch-file LWW merge, so a null fetch after a
|
|
185
|
+
* prior confirmation can never pair a stale in-memory report with a fresh
|
|
186
|
+
* attempt timestamp and clobber a newer report another broker just wrote.
|
|
187
|
+
*/
|
|
188
|
+
const confirmedAtByProvider = new Map<string, number>();
|
|
180
189
|
/** Canonical ids of providers covered by a direct fetcher (the fresh source). */
|
|
181
190
|
const directFetcherIds = new Set<string>();
|
|
182
191
|
let cachedReports: UsageReport[] = [];
|
|
183
192
|
let cachedHealth: Map<string, ProviderHealth> = new Map();
|
|
193
|
+
/**
|
|
194
|
+
* Most recently computed logged-in provider universe (start sequence + every
|
|
195
|
+
* data tick). The 1s render path (`getSharedUsage`) has no auth context of its
|
|
196
|
+
* own, so it filters the on-disk union through THIS cached set: reports for
|
|
197
|
+
* providers this broker is not logged into never leak into the widget even
|
|
198
|
+
* though the shared fetch file retains them for other brokers. `null` = poller
|
|
199
|
+
* never started (tests / direct callers) → no filtering, matching the legacy
|
|
200
|
+
* unfiltered behavior.
|
|
201
|
+
*/
|
|
202
|
+
let lastLoggedInSet: ReadonlySet<string> | null = null;
|
|
184
203
|
/** Single interval: renders every tick AND checks absolute elapsed time for the data refresh. */
|
|
185
204
|
let renderTimer: NodeJS.Timeout | null = null;
|
|
186
205
|
/**
|
|
@@ -653,16 +672,51 @@ function writeRealtimeDisk(deltas: Record<string, ProviderUsageTotals>): boolean
|
|
|
653
672
|
}
|
|
654
673
|
|
|
655
674
|
/**
|
|
656
|
-
* Fetch write path
|
|
657
|
-
*
|
|
675
|
+
* Fetch write path — a LOCKED read-modify-write (same lock protocol as
|
|
676
|
+
* {@link writeRealtimeDisk}): inside the lock we re-read the freshest on-disk
|
|
677
|
+
* fetch file and merge this process's contributions on top.
|
|
678
|
+
*
|
|
679
|
+
* Merge scope is ONLY the providers THIS process actually CONFIRMED with a
|
|
680
|
+
* fresh report (`confirmedThisProcess`). `lastFetchedByProvider` also holds
|
|
681
|
+
* entries for fetches that succeeded WITHOUT a report (TTL advancement only —
|
|
682
|
+
* those never land in `confirmedThisProcess`) and entries seeded from disk;
|
|
683
|
+
* iterating it would write phantom timestamp-only entries and overwrite
|
|
684
|
+
* possibly-newer disk reports with stale in-memory ones.
|
|
685
|
+
*
|
|
686
|
+
* Per confirmed provider, last-writer-wins by millisecond timestamp: this
|
|
687
|
+
* process's report replaces (or inserts) the disk entry only when its
|
|
688
|
+
* CONFIRMED timestamp (`confirmedAtByProvider` — the fetch that actually
|
|
689
|
+
* delivered THIS report; a later null fetch does not advance it) is `>=` the
|
|
690
|
+
* disk timestamp for that provider; the `fetchedByProvider` entry is written
|
|
691
|
+
* ONLY when the report is actually written. Providers present on disk but not
|
|
692
|
+
* written this cycle
|
|
693
|
+
* (other brokers' providers, unconfirmed fetches, seeded reports) are
|
|
694
|
+
* preserved verbatim — the shared file is the cross-broker UNION and is never
|
|
695
|
+
* pruned (see {@link pruneDisconnected}, in-memory only). `fetchedAt` is the
|
|
696
|
+
* max across disk and this process's timestamps. Never reads or rewrites the
|
|
697
|
+
* realtime aggregates.
|
|
658
698
|
*/
|
|
659
699
|
function writeFetchDisk(): boolean {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
700
|
+
return writePayloadLocked(resolveFetchFile(), () => {
|
|
701
|
+
const disk = readFetchDisk() ?? { fetchedAt: 0, reports: [] as UsageReport[], fetchedByProvider: {} as Record<string, number> };
|
|
702
|
+
const fetchedByProvider: Record<string, number> = { ...(disk.fetchedByProvider ?? {}) };
|
|
703
|
+
// Base: disk reports keyed by CANONICAL provider id (readFetchDisk
|
|
704
|
+
// canonicalizes disk ids; normalize defensively for the map key).
|
|
705
|
+
const reportsById = new Map<string, UsageReport>();
|
|
706
|
+
for (const r of disk.reports) reportsById.set(canonicalizeProvider(r.provider), r);
|
|
707
|
+
|
|
708
|
+
let fetchedAt = disk.fetchedAt ?? 0;
|
|
709
|
+
for (const provider of confirmedThisProcess) {
|
|
710
|
+
const localTs = confirmedAtByProvider.get(provider) ?? 0;
|
|
711
|
+
const diskTs = fetchedByProvider[provider] ?? 0;
|
|
712
|
+
if (localTs < diskTs) continue; // disk is newer — keep its report/timestamp
|
|
713
|
+
const report = cachedReports.find((r) => r.provider === provider);
|
|
714
|
+
if (!report) continue; // confirmed but no in-memory report: defensive skip
|
|
715
|
+
reportsById.set(provider, report);
|
|
716
|
+
fetchedByProvider[provider] = localTs;
|
|
717
|
+
if (localTs > fetchedAt) fetchedAt = localTs;
|
|
718
|
+
}
|
|
719
|
+
return JSON.stringify({ fetchedAt, reports: [...reportsById.values()], fetchedByProvider }, null, 2);
|
|
666
720
|
});
|
|
667
721
|
}
|
|
668
722
|
|
|
@@ -825,30 +879,36 @@ function seedFromDisk(loggedInProviders?: ReadonlySet<string>): void {
|
|
|
825
879
|
for (const r of reports) lastFetchedByProvider.set(r.provider, disk.fetchedAt);
|
|
826
880
|
}
|
|
827
881
|
}
|
|
828
|
-
/**
|
|
882
|
+
/**
|
|
883
|
+
* Trim THIS process's in-memory caches to the logged-in universe:
|
|
884
|
+
* dirtyProviders / lastAttemptByProvider / lastFetchedByProvider /
|
|
885
|
+
* unconfirmedProviders / confirmedThisProcess(+confirmedAtByProvider) /
|
|
886
|
+
* cachedReports(+health) for any
|
|
887
|
+
* provider NOT in `loggedIn`. Memory/display semantics ONLY — the shared fetch
|
|
888
|
+
* file is the cross-broker UNION of provider reports, so this MUST NOT write
|
|
889
|
+
* back or delete any disk entry (another broker may own the pruned provider).
|
|
890
|
+
*/
|
|
829
891
|
function pruneDisconnected(loggedIn: ReadonlySet<string>): void {
|
|
830
|
-
let removed = false;
|
|
831
892
|
for (const key of dirtyProviders) {
|
|
832
|
-
if (!loggedIn.has(key))
|
|
893
|
+
if (!loggedIn.has(key)) dirtyProviders.delete(key);
|
|
833
894
|
}
|
|
834
895
|
for (const key of lastAttemptByProvider.keys()) {
|
|
835
|
-
if (!loggedIn.has(key))
|
|
896
|
+
if (!loggedIn.has(key)) lastAttemptByProvider.delete(key);
|
|
836
897
|
}
|
|
837
898
|
for (const key of lastFetchedByProvider.keys()) {
|
|
838
|
-
if (!loggedIn.has(key))
|
|
899
|
+
if (!loggedIn.has(key)) lastFetchedByProvider.delete(key);
|
|
839
900
|
}
|
|
840
901
|
for (const key of unconfirmedProviders) {
|
|
841
|
-
if (!loggedIn.has(key))
|
|
902
|
+
if (!loggedIn.has(key)) unconfirmedProviders.delete(key);
|
|
842
903
|
}
|
|
843
904
|
for (const key of confirmedThisProcess) {
|
|
844
|
-
if (!loggedIn.has(key)) {
|
|
905
|
+
if (!loggedIn.has(key)) {
|
|
906
|
+
confirmedThisProcess.delete(key);
|
|
907
|
+
confirmedAtByProvider.delete(key);
|
|
908
|
+
}
|
|
845
909
|
}
|
|
846
|
-
|
|
847
|
-
removed = removed || next.length !== cachedReports.length;
|
|
848
|
-
cachedReports = next;
|
|
910
|
+
cachedReports = cachedReports.filter((r) => loggedIn.has(r.provider));
|
|
849
911
|
cachedHealth = buildHealthMap(cachedReports);
|
|
850
|
-
// Persist only when something was actually dropped — the 5s sync tick must
|
|
851
|
-
if (removed) writeFetchDisk();
|
|
852
912
|
}
|
|
853
913
|
/** Merge `incoming` into `existing` by canonical provider id (incoming wins). */
|
|
854
914
|
function mergeReports(existing: UsageReport[], incoming: UsageReport[]): UsageReport[] {
|
|
@@ -914,6 +974,11 @@ async function fetchProvider(
|
|
|
914
974
|
// Fresh report landed → the provider's health is CONFIRMED.
|
|
915
975
|
unconfirmedProviders.delete(canonical);
|
|
916
976
|
confirmedThisProcess.add(canonical);
|
|
977
|
+
// The timestamp bound to THIS report — the LWW clock for the
|
|
978
|
+
// locked fetch-file merge. NOT updated by the null-report branch
|
|
979
|
+
// below (which advances lastFetchedByProvider for TTL only), so a
|
|
980
|
+
// later empty fetch cannot overwrite a newer disk report.
|
|
981
|
+
confirmedAtByProvider.set(canonical, fetchedAt);
|
|
917
982
|
} else {
|
|
918
983
|
// Fetch produced nothing for this provider: any existing report is
|
|
919
984
|
// UNCONFIRMED (possibly stale) — keep it for display, but flag it so
|
|
@@ -1061,12 +1126,26 @@ export async function startSharedPoller(
|
|
|
1061
1126
|
|
|
1062
1127
|
// One-time migration from the legacy single cache file (idempotent).
|
|
1063
1128
|
migrateLegacyCache();
|
|
1064
|
-
const loggedInSet = new Set(getLoggedInProviders(authStorage, directFetchers));
|
|
1065
1129
|
setDirectFetcherCoverage(directFetchers);
|
|
1066
|
-
|
|
1067
|
-
|
|
1130
|
+
// Logged-in universe: recomputed at start AND on every data tick — it
|
|
1131
|
+
// is a cheap local enumeration (authStorage.list + local credential
|
|
1132
|
+
// probes, no network), so a provider whose credentials appear AFTER
|
|
1133
|
+
// poller start (late authStorage, env credential, runtime login)
|
|
1134
|
+
// enters the universe next tick instead of being pruned forever and
|
|
1135
|
+
// oscillating against the broker that owns it. Each recompute also
|
|
1136
|
+
// refreshes `lastLoggedInSet`, the cached filter the 1s render path
|
|
1137
|
+
// (getSharedUsage) applies to the on-disk union.
|
|
1138
|
+
const computeLoggedIn = (): Set<string> => {
|
|
1139
|
+
const set = new Set(getLoggedInProviders(authStorage, directFetchers));
|
|
1140
|
+
lastLoggedInSet = set;
|
|
1141
|
+
return set;
|
|
1142
|
+
};
|
|
1143
|
+
const initialLoggedIn = computeLoggedIn();
|
|
1144
|
+
syncFromDisk(initialLoggedIn);
|
|
1145
|
+
seedFromDisk(initialLoggedIn);
|
|
1068
1146
|
|
|
1069
1147
|
const tick = async (): Promise<void> => {
|
|
1148
|
+
const loggedInSet = computeLoggedIn();
|
|
1070
1149
|
syncFromDisk(loggedInSet);
|
|
1071
1150
|
evaluateDirty(loggedInSet);
|
|
1072
1151
|
await fetchDue(authStorage, directFetchers, getApiKey);
|
|
@@ -1145,7 +1224,7 @@ export async function startSharedPoller(
|
|
|
1145
1224
|
const fresh = [...lastFetchedByProvider.values()].some((t) => Date.now() - t < idleDirtyMs);
|
|
1146
1225
|
if (fresh) return;
|
|
1147
1226
|
|
|
1148
|
-
evaluateDirty(
|
|
1227
|
+
evaluateDirty(computeLoggedIn());
|
|
1149
1228
|
await fetchDue(authStorage, directFetchers, getApiKey);
|
|
1150
1229
|
})();
|
|
1151
1230
|
try {
|
|
@@ -1156,7 +1235,11 @@ export async function startSharedPoller(
|
|
|
1156
1235
|
}
|
|
1157
1236
|
|
|
1158
1237
|
export function getSharedUsage(): SharedUsageState | null {
|
|
1159
|
-
|
|
1238
|
+
// Filter the on-disk union through THIS broker's most recently computed
|
|
1239
|
+
// logged-in set so providers owned by other brokers never surface as
|
|
1240
|
+
// columns/placeholders here (their reports stay on disk). Null (poller
|
|
1241
|
+
// never started / direct test calls) → unfiltered, legacy behavior.
|
|
1242
|
+
syncFromDisk(lastLoggedInSet ?? undefined);
|
|
1160
1243
|
// Return state once we have data OR an in-flight fetch (so the UI can show
|
|
1161
1244
|
// a loading hint even before the first report lands).
|
|
1162
1245
|
if (cachedReports.length === 0 && lastFetchedByProvider.size === 0 && pendingProviders.size === 0) return null;
|
|
@@ -1360,9 +1443,11 @@ export function _resetForTest(): void {
|
|
|
1360
1443
|
pendingProviders.clear();
|
|
1361
1444
|
unconfirmedProviders.clear();
|
|
1362
1445
|
confirmedThisProcess.clear();
|
|
1446
|
+
confirmedAtByProvider.clear();
|
|
1363
1447
|
directFetcherIds.clear();
|
|
1364
1448
|
cachedReports = [];
|
|
1365
1449
|
cachedHealth = new Map();
|
|
1450
|
+
lastLoggedInSet = null;
|
|
1366
1451
|
lastFlushedBySession.clear();
|
|
1367
1452
|
lastSyncedTotals = new Map();
|
|
1368
1453
|
perSecondReadCache = null;
|
package/lib/usage-render.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { visibleWidth, truncateToWidth } from '@oh-my-pi/pi-tui';
|
|
2
1
|
import { resolveUsedFraction, getProviderDefinition, type UsageLimit, type UsageReport } from '@oh-my-pi/pi-ai';
|
|
2
|
+
import { visibleWidth } from '@oh-my-pi/pi-tui';
|
|
3
3
|
import { summarizeReport, quotaPolicyFor } from './usage-resolver.js';
|
|
4
4
|
|
|
5
5
|
// Hardcoded ANSI retained for `ansiPainter` — the RPC/print fallback path and
|
|
@@ -29,8 +29,8 @@ export interface PainterTheme {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Decouples "which ANSI/theme color wraps a span" from the validated
|
|
33
|
-
* layout
|
|
32
|
+
* Decouples "which ANSI/theme color wraps a span" from the validated single-row
|
|
33
|
+
* natural-width layout kernel. `ansiPainter` reproduces the pre-refactor hardcoded ANSI; a
|
|
34
34
|
* `themePainter(theme)` routes every status color through the host theme so the
|
|
35
35
|
* widget follows the active palette. Provider brand identity colors stay raw
|
|
36
36
|
* ANSI under both painters (the host theme exposes no arbitrary-hue token — see
|
|
@@ -48,13 +48,46 @@ export interface Painter {
|
|
|
48
48
|
header(label: string, brandAnsi: string): string;
|
|
49
49
|
/** Numeric balance text (e.g. "余额 ¥18.06"), text-colored. */
|
|
50
50
|
balance(text: string): string;
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Waveform layer paint — btop row gradient over the provider's brand hue.
|
|
53
|
+
* `brandAnsi` is the raw brand SGR ('' → accent fallback, like header).
|
|
54
|
+
* Both rows share the hue family with a fixed, theme-independent step:
|
|
55
|
+
* 'bottom' a curated 256-color bright shade of the hue (aixterm bright /
|
|
56
|
+
* bold when the code isn't a mapped 16-color SGR), 'top' the plain brand.
|
|
57
|
+
*/
|
|
58
|
+
wave(brandAnsi: string, layer: 'top' | 'bottom', text: string): string;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
const pctValue = (p: number): string => `${Math.round(p * 100)}%`;
|
|
56
62
|
const pctColor = (p: number): PainterColor => (p >= 0.85 ? 'error' : p >= 0.6 ? 'warning' : 'success');
|
|
57
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Curated 256-color bottom-row shade per 16-color brand hue (chart only —
|
|
66
|
+
* headers keep the raw brand SGR). The 256 palette's 16–255 range renders as
|
|
67
|
+
* FIXED rgb in every terminal, so the bottom/top step is guaranteed visible
|
|
68
|
+
* regardless of the active 16-color theme. Each shade is anchored on the
|
|
69
|
+
* de-facto standard dark-theme value of that hue (TokyoNight family) and is
|
|
70
|
+
* the SAME ink one purity/brightness step up — same hue direction, no hot
|
|
71
|
+
* magenta/red jumps that would break the "one color" read.
|
|
72
|
+
*/
|
|
73
|
+
const WAVE_SHADES: Record<string, string> = {
|
|
74
|
+
[X]: '38;5;204', // red #ff5f87 (TN #f7768e, denser)
|
|
75
|
+
[G]: '38;5;114', // green #87d787 (TN #9ece6a, denser)
|
|
76
|
+
[BLUE]: '38;5;75', // blue #5fafff (TN #7aa2f7, denser)
|
|
77
|
+
[PURPLE]: '38;5;177', // purple #d787ff (TN #bb9af7, denser)
|
|
78
|
+
[CYAN]: '38;5;81', // cyan #5fd7ff (TN #7dcfff, denser)
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Aixterm bright variant of a 16-color brand code (`\x1b[3Nm` → `\x1b[9Nm`); undefined when not derivable. */
|
|
82
|
+
const brightVariant = (code: string): string | undefined => {
|
|
83
|
+
const m = code.match(/^\x1b\[3([0-7])m$/);
|
|
84
|
+
return m ? `\x1b[9${m[1]}m` : undefined;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Bottom-row shade of a raw hue: curated 256 bright shade, aixterm bright, bold as the last step. */
|
|
88
|
+
const bottomShade = (hue: string): string =>
|
|
89
|
+
WAVE_SHADES[hue] ? `\x1b[${WAVE_SHADES[hue]}m` : brightVariant(hue) ?? BOLD + hue;
|
|
90
|
+
|
|
58
91
|
/** Pre-refactor hardcoded ANSI — the RPC/print fallback and the test baseline. */
|
|
59
92
|
export const ansiPainter: Painter = {
|
|
60
93
|
pct: (p) => {
|
|
@@ -65,21 +98,7 @@ export const ansiPainter: Painter = {
|
|
|
65
98
|
sep: ` ${D}│${R} `,
|
|
66
99
|
header: (label, brandAnsi) => c(brandAnsi ? BOLD + brandAnsi : BOLD, label),
|
|
67
100
|
balance: (text) => c(WHITE, text),
|
|
68
|
-
|
|
69
|
-
c(
|
|
70
|
-
color === 'error'
|
|
71
|
-
? X
|
|
72
|
-
: color === 'warning'
|
|
73
|
-
? Y
|
|
74
|
-
: color === 'success'
|
|
75
|
-
? G
|
|
76
|
-
: color === 'dim'
|
|
77
|
-
? D
|
|
78
|
-
: color === 'accent'
|
|
79
|
-
? CYAN
|
|
80
|
-
: WHITE,
|
|
81
|
-
text,
|
|
82
|
-
),
|
|
101
|
+
wave: (brandAnsi, layer, text) => c(layer === 'bottom' ? bottomShade(brandAnsi || CYAN) : brandAnsi || CYAN, text),
|
|
83
102
|
};
|
|
84
103
|
|
|
85
104
|
/** Status colors via the active host theme; brand headers keep raw ANSI. */
|
|
@@ -90,7 +109,12 @@ export function themePainter(theme: PainterTheme): Painter {
|
|
|
90
109
|
sep: ` ${theme.fg('dim', '│')} `,
|
|
91
110
|
header: (label, brandAnsi) => (brandAnsi ? c(brandAnsi, label) : theme.fg('accent', label)),
|
|
92
111
|
balance: (text) => theme.fg('text', text),
|
|
93
|
-
|
|
112
|
+
wave: (brandAnsi, layer, text) =>
|
|
113
|
+
brandAnsi
|
|
114
|
+
? c(layer === 'bottom' ? bottomShade(brandAnsi) : brandAnsi, text)
|
|
115
|
+
: layer === 'bottom'
|
|
116
|
+
? BOLD + theme.fg('accent', text)
|
|
117
|
+
: theme.fg('accent', text),
|
|
94
118
|
};
|
|
95
119
|
}
|
|
96
120
|
|
|
@@ -243,13 +267,6 @@ const windowSortKey = (l: UsageLimit): number => {
|
|
|
243
267
|
return 50;
|
|
244
268
|
};
|
|
245
269
|
|
|
246
|
-
/** Right-align a cell: pad short lines; leave ≥-width lines intact (colWidth = max). */
|
|
247
|
-
const padRight = (s: string, width: number): string => {
|
|
248
|
-
const v = visibleWidth(s);
|
|
249
|
-
if (v >= width) return s;
|
|
250
|
-
return s + ' '.repeat(width - v);
|
|
251
|
-
};
|
|
252
|
-
|
|
253
270
|
// ── column builder ───────────────────────────────────────────────────
|
|
254
271
|
|
|
255
272
|
/**
|
|
@@ -266,9 +283,9 @@ const padRight = (s: string, width: number): string => {
|
|
|
266
283
|
* soonest reset countdown (any revived bucket unblocks).
|
|
267
284
|
* - balance (DeepSeek): remaining ≤ 0 → countdown / `耗尽`
|
|
268
285
|
*
|
|
269
|
-
* `undefined` when there is nothing to show.
|
|
270
|
-
* `renderUsageReports`
|
|
271
|
-
* per-card truncation.
|
|
286
|
+
* `undefined` when there is nothing to show. Emits natural-width lines only —
|
|
287
|
+
* `renderUsageReports` joins blocks into the single-row layout, `ProviderCard`
|
|
288
|
+
* owns per-card truncation.
|
|
272
289
|
*/
|
|
273
290
|
export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, loading = false): string[] | undefined => {
|
|
274
291
|
if (r.limits.length === 0) return;
|
|
@@ -360,9 +377,13 @@ export const buildPlaceholderColumn = (
|
|
|
360
377
|
];
|
|
361
378
|
};
|
|
362
379
|
|
|
363
|
-
// ──
|
|
380
|
+
// ── single-row table layout ───────────────────────────────────────────
|
|
364
381
|
|
|
365
|
-
|
|
382
|
+
/** Right-pad a painted line to `width` visible cells (ANSI-safe via visibleWidth). */
|
|
383
|
+
const padRight = (s: string, width: number): string => {
|
|
384
|
+
const v = visibleWidth(s);
|
|
385
|
+
return v >= width ? s : s + ' '.repeat(width - v);
|
|
386
|
+
};
|
|
366
387
|
|
|
367
388
|
export function renderUsageReports(
|
|
368
389
|
reports: UsageReport[],
|
|
@@ -382,62 +403,70 @@ export function renderUsageReports(
|
|
|
382
403
|
if (!reports?.length && placeholders.length === 0) return [];
|
|
383
404
|
const loading = loadingProviders ?? new Set<string>();
|
|
384
405
|
|
|
385
|
-
// Build each provider column (header + usage / placeholder).
|
|
386
|
-
//
|
|
387
|
-
//
|
|
406
|
+
// Build each provider column (header + usage / placeholder). Each block
|
|
407
|
+
// aligns its OWN lines to its own natural width (header/body/waveform
|
|
408
|
+
// share that block's width so the separators stack vertically) — blocks
|
|
409
|
+
// are never padded to a cross-block shared width, so one block's content
|
|
410
|
+
// changes never resize the others.
|
|
388
411
|
interface ProviderColumn {
|
|
389
412
|
provider: string;
|
|
390
413
|
col: string[];
|
|
414
|
+
width: number;
|
|
391
415
|
}
|
|
392
416
|
const cols: ProviderColumn[] = (reports ?? []).map((r) => {
|
|
393
417
|
const col =
|
|
394
418
|
r.limits.length === 0
|
|
395
419
|
? buildEmptyReportColumn(r.provider, painter, loading.has(r.provider))
|
|
396
420
|
: buildColumn(r, painter, loading.has(r.provider));
|
|
397
|
-
if (!col) return { provider: r.provider, col: [] };
|
|
421
|
+
if (!col) return { provider: r.provider, col: [], width: 0 };
|
|
398
422
|
// Second-level estimate override replaces the usage line (balance style).
|
|
399
423
|
const est = usageEstimates?.get(r.provider);
|
|
400
424
|
if (est && col.length >= 2) col[1] = painter.balance(est);
|
|
401
|
-
return { provider: r.provider, col };
|
|
425
|
+
return { provider: r.provider, col, width: 0 };
|
|
402
426
|
});
|
|
403
427
|
for (const id of placeholders) {
|
|
404
428
|
cols.push({
|
|
405
429
|
provider: id,
|
|
406
430
|
col: buildPlaceholderColumn(id, painter, loading.has(id), exhaustedProviders?.has(id)),
|
|
431
|
+
width: 0,
|
|
407
432
|
});
|
|
408
433
|
}
|
|
409
434
|
const valid = cols.filter((c) => c.col.length > 0);
|
|
410
435
|
if (valid.length === 0) return [];
|
|
411
436
|
|
|
412
|
-
// Uniform block width: every provider block is padded to the widest column,
|
|
413
|
-
// so usage columns align in a clean grid (no ragged per-provider widths).
|
|
414
|
-
const natural = valid.map((c) => Math.max(...c.col.map((l) => visibleWidth(l))));
|
|
415
|
-
const blockWidth = Math.max(...natural);
|
|
416
|
-
const waveCols = Math.min(WAVEFORM_COLS, blockWidth);
|
|
417
|
-
|
|
418
|
-
// Multiple providers: pad every row to the uniform block width so the grid
|
|
419
|
-
// aligns (a lone provider keeps its natural width — nothing to align with).
|
|
420
|
-
const pad = valid.length > 1;
|
|
421
437
|
for (const c of valid) {
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
438
|
+
// Per-provider consumption waveform at the FIXED WAVEFORM_COLS width:
|
|
439
|
+
// every chart renders 20 Braille columns regardless of the block's
|
|
440
|
+
// text width (no cross-block alignment). A track that exists but
|
|
441
|
+
// has no samples renders as a blank placeholder strip, keeping the
|
|
442
|
+
// block height stable.
|
|
425
443
|
if (consumptionTracks) {
|
|
426
444
|
const track = consumptionTracks.get(c.provider);
|
|
427
|
-
if (track) c.col.push(...renderConsumptionLines(track.samples, painter,
|
|
445
|
+
if (track) c.col.push(...renderConsumptionLines(track.samples, painter, WAVEFORM_COLS, providerLabel(c.provider).color));
|
|
428
446
|
}
|
|
429
|
-
|
|
447
|
+
// Per-block alignment: with multiple blocks, pad every line of THIS
|
|
448
|
+
// block to its own widest line (text or the fixed-width waveform) so
|
|
449
|
+
// the block's separators stack vertically across header/body/waveform
|
|
450
|
+
// rows. Blocks are NOT padded to each other — each keeps its own
|
|
451
|
+
// natural width, so one block's content changes never resize others.
|
|
452
|
+
// A single block has no separators to align, so it stays byte-exact
|
|
453
|
+
// (RPC/print baseline).
|
|
454
|
+
c.width = Math.max(...c.col.map((l) => visibleWidth(l)));
|
|
455
|
+
if (valid.length > 1) c.col = c.col.map((l) => padRight(l, c.width));
|
|
430
456
|
}
|
|
431
457
|
// Single row, never wrapped: every provider block sits on one line at its
|
|
432
|
-
// natural
|
|
433
|
-
// squeezing content or breaking to a second
|
|
434
|
-
// roomy over compact).
|
|
458
|
+
// own natural width, joined by the fixed `painter.sep` — the row extends
|
|
459
|
+
// beyond `maxWidth` rather than squeezing content or breaking to a second
|
|
460
|
+
// line (user preference: roomy over compact). Blocks shorter than the
|
|
461
|
+
// tallest contribute a cell padded to that block's own width on missing
|
|
462
|
+
// rows, so every separator keeps its column.
|
|
463
|
+
const multi = valid.length > 1;
|
|
435
464
|
const n = Math.max(...valid.map((c) => c.col.length));
|
|
436
465
|
const out: string[] = [];
|
|
437
466
|
for (let line = 0; line < n; line++) {
|
|
438
467
|
const cells = valid.map((c) => {
|
|
439
|
-
|
|
440
|
-
return
|
|
468
|
+
if (line < c.col.length) return c.col[line];
|
|
469
|
+
return multi ? ' '.repeat(c.width) : '';
|
|
441
470
|
});
|
|
442
471
|
out.push(cells.join(painter.sep));
|
|
443
472
|
}
|
|
@@ -469,13 +498,6 @@ export function composeChar(leftRows: number, rightRows: number): string {
|
|
|
469
498
|
return String.fromCharCode(code);
|
|
470
499
|
}
|
|
471
500
|
|
|
472
|
-
/** Named-color band for a stock consumption level (`1 - remaining`): remaining ≥ 50% → success, ≥ 20% → warning, else error. */
|
|
473
|
-
export function consumptionColor(c: number): PainterColor {
|
|
474
|
-
if (c <= 0.5) return 'success';
|
|
475
|
-
if (c <= 0.8) return 'warning';
|
|
476
|
-
return 'error';
|
|
477
|
-
}
|
|
478
|
-
|
|
479
501
|
/** One provider's consumption track feeding its embedded mini chart. */
|
|
480
502
|
export interface ConsumptionTrack {
|
|
481
503
|
/** Per-interval consumption increments (raw values), newest last. */
|
|
@@ -498,12 +520,17 @@ export interface ConsumptionTrack {
|
|
|
498
520
|
* window yields a blank placeholder strip, never `undefined`, so every
|
|
499
521
|
* provider column keeps a stable chart area and the layout never jumps.
|
|
500
522
|
*
|
|
501
|
-
* Color is
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
523
|
+
* Color is a btop-style ROW gradient bound to the vertical position only —
|
|
524
|
+
* never to the data. The chart paints the provider's brand hue in two layers
|
|
525
|
+
* with a fixed step: the bottom line takes a curated 256-color bright shade
|
|
526
|
+
* of the hue (fixed rgb — visible under any 16-color theme; aixterm bright /
|
|
527
|
+
* bold when the code isn't mapped), the top line the plain brand — visibly
|
|
528
|
+
* stronger at the base, never washed out. Sliding samples reshape the
|
|
529
|
+
* envelope while every
|
|
530
|
+
* pixel's color stays put — the per-column intensity heat bands this
|
|
531
|
+
* replaces flipped green/yellow/red as the window moved, reading as flicker.
|
|
532
|
+
* Providers without a curated brand hue fall back to the theme accent.
|
|
533
|
+
* Quota urgency still reads in the usage text ("5h 0%", balance).
|
|
507
534
|
*
|
|
508
535
|
* Geometry unchanged: 1px per sample (half-char step), right-aligned; each
|
|
509
536
|
* character merges its two half-column samples with max and renders both
|
|
@@ -514,6 +541,7 @@ export function renderConsumptionLines(
|
|
|
514
541
|
samples: readonly number[],
|
|
515
542
|
painter: Painter = ansiPainter,
|
|
516
543
|
cols: number = WAVEFORM_COLS,
|
|
544
|
+
brandAnsi = '',
|
|
517
545
|
): string[] {
|
|
518
546
|
const n = samples.length;
|
|
519
547
|
const totalPx = cols * 2;
|
|
@@ -564,14 +592,10 @@ export function renderConsumptionLines(
|
|
|
564
592
|
return plot[m - 1] / max;
|
|
565
593
|
};
|
|
566
594
|
|
|
567
|
-
// Each half-column keeps its OWN interpolated height (two samples share one
|
|
568
|
-
// char, possibly at different heights — the pair-into-one-char geometry);
|
|
569
|
-
// only COLOR is blended (terminals can't half-tint a char, so the char
|
|
570
|
-
// takes the mean intensity of its two half-columns).
|
|
571
|
-
const accentChar = Math.floor((totalPx - 1) / 2);
|
|
572
595
|
// Two-line strip: full scale = 8px (2 Braille rows × 4 dot rows). Each
|
|
573
|
-
// half-column keeps its OWN interpolated height;
|
|
574
|
-
// (
|
|
596
|
+
// half-column keeps its OWN interpolated height; COLOR is ROW-bound only
|
|
597
|
+
// (bottom = 256 bright shade of the brand hue, top = plain brand) and
|
|
598
|
+
// never varies per column.
|
|
575
599
|
const rows = 2;
|
|
576
600
|
const lines = new Array<string>(rows).fill('');
|
|
577
601
|
for (let c = 0; c < cols; c++) {
|
|
@@ -587,10 +611,9 @@ export function renderConsumptionLines(
|
|
|
587
611
|
// never vanish under the dynamic scale.
|
|
588
612
|
const hL = l > 0 ? Math.max(1, Math.round(8 * l)) : 0;
|
|
589
613
|
const hR = r > 0 ? Math.max(1, Math.round(8 * r)) : 0;
|
|
590
|
-
const color = c === accentChar ? 'accent' : consumptionColor((l + r) / 2);
|
|
591
614
|
// Row 0 = top (px 4..8), row 1 = bottom (px 0..4) of each column.
|
|
592
|
-
lines[0] += painter.
|
|
593
|
-
lines[1] += painter.
|
|
615
|
+
lines[0] += painter.wave(brandAnsi, 'top', composeChar(Math.max(0, hL - 4), Math.max(0, hR - 4)));
|
|
616
|
+
lines[1] += painter.wave(brandAnsi, 'bottom', composeChar(Math.min(4, hL), Math.min(4, hR)));
|
|
594
617
|
}
|
|
595
618
|
return lines;
|
|
596
619
|
}
|
package/lib/usage-widget.ts
CHANGED
|
@@ -9,10 +9,11 @@ import { buildColumn, renderUsageReports, themePainter, type Painter, type Consu
|
|
|
9
9
|
* `render(width)` returns the compact 2-line column (header + horizontal
|
|
10
10
|
* window chips / reset countdown), each clamped to ≤ `width` via
|
|
11
11
|
* `truncateToWidth`. Today the host drives the whole status strip through
|
|
12
|
-
* `UsageTable`, which reuses the shared `renderUsageReports`
|
|
13
|
-
* rather than assembling cards directly — but
|
|
14
|
-
* per-provider overlay (expand / detail / quick
|
|
15
|
-
* already renders in isolation, without
|
|
12
|
+
* `UsageTable`, which reuses the shared `renderUsageReports` single-row
|
|
13
|
+
* natural-width layout kernel rather than assembling cards directly — but
|
|
14
|
+
* this seam lets a future per-provider overlay (expand / detail / quick
|
|
15
|
+
* action) build on a unit that already renders in isolation, without
|
|
16
|
+
* reweaving the render tree.
|
|
16
17
|
*
|
|
17
18
|
* No keyboard interaction is implemented this round: `handleInput` / `dispose`
|
|
18
19
|
* are kept as empty extension points per the design non-goal.
|
|
@@ -44,9 +45,10 @@ export class ProviderCard implements Component {
|
|
|
44
45
|
* Root pi-tui `Component` the host mounts in its hook-widget tree. A single
|
|
45
46
|
* `UsageTable` owns the whole multi-provider layout (header + window rows for
|
|
46
47
|
* every report): the host only vertically stacks hook widgets, so per-provider
|
|
47
|
-
* widgets would collapse into a vertical list and lose the compact
|
|
48
|
-
* columns. The layout brain is the validated
|
|
49
|
-
* same code the RPC/print `string[]`
|
|
48
|
+
* widgets would collapse into a vertical list and lose the compact
|
|
49
|
+
* single-row natural-width columns. The layout brain is the validated
|
|
50
|
+
* `renderUsageReports` kernel — the same code the RPC/print `string[]`
|
|
51
|
+
* fallback runs — only the `Painter` differs.
|
|
50
52
|
*/
|
|
51
53
|
export class UsageTable implements Component {
|
|
52
54
|
readonly #reports: UsageReport[];
|
|
@@ -99,11 +101,13 @@ export class UsageTable implements Component {
|
|
|
99
101
|
* resolved lazily against the theme the host passes at mount time, so the widget
|
|
100
102
|
* follows the active palette; a later poll/resize re-runs `setWidget` with the
|
|
101
103
|
* current reports (mirroring the dashboard precedent), which re-injects the then-
|
|
102
|
-
* current theme. Returns a `Container`-free `UsageTable` directly:
|
|
103
|
-
*
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
104
|
+
* current theme. Returns a `Container`-free `UsageTable` directly: rows extend
|
|
105
|
+
* at their natural width (the kernel never wraps — a row can run past `width`),
|
|
106
|
+
* and `ProviderCard.render`'s `truncateToWidth` is the only truncation seam.
|
|
107
|
+
* Wrapping each row in `pi-tui`'s `Text` would both drop the single-row
|
|
108
|
+
* natural-width blank separators (Text renders empty input as `[]`) and repad
|
|
109
|
+
* every line to the full width — losing byte parity with the RPC/print path
|
|
110
|
+
* for zero structural benefit.
|
|
107
111
|
*/
|
|
108
112
|
export function createUsageWidget(
|
|
109
113
|
reports: UsageReport[],
|
package/package.json
CHANGED