@genee/omp-opsx-addon 0.4.0 → 0.6.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/index.ts +94 -90
- package/lib/usage-render.ts +65 -44
- package/package.json +1 -1
package/index.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
// ── subagent usage tracking ───────────────────────────────────────────
|
|
2
|
-
// The
|
|
3
|
-
// the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// the
|
|
7
|
-
//
|
|
2
|
+
// The harness forwards every task-subagent message as a SubagentEventPayload
|
|
3
|
+
// { id, event } frame on the "task:subagent:event" channel, emitted to the
|
|
4
|
+
// parent session's eventBus (plus the root observability bus, which is not
|
|
5
|
+
// reachable from extensions). Each plugin instance subscribes its OWN session
|
|
6
|
+
// bus via the official ExtensionAPI handle `pi.events` — the EventBus the
|
|
7
|
+
// harness injects as ConcreteExtensionAPI's `events` field (loader.ts). The
|
|
8
|
+
// root instance's bus carries depth-1 frames, a depth-1 instance's bus carries
|
|
9
|
+
// depth-2 frames, and so on, so every nesting depth is covered while each
|
|
10
|
+
// frame is consumed exactly once. Frames funnel into the module-level recorder
|
|
11
|
+
// singleton below, attributed to the root session id.
|
|
8
12
|
//
|
|
9
|
-
//
|
|
10
|
-
// official ExtensionAPI. We access the eventBus through internal properties as a
|
|
11
|
-
// pragmatic workaround. The channel string is hardcoded from harness source:
|
|
13
|
+
// The channel string is hardcoded from harness source:
|
|
12
14
|
// src/task/types.ts:59 -> "task:subagent:event"
|
|
13
15
|
//
|
|
14
|
-
// NOTE: This is a hack that depends on internal harness implementation details.
|
|
15
|
-
// Future harness versions may break this if internal properties change.
|
|
16
|
-
//
|
|
17
16
|
// Reference:
|
|
18
17
|
// - emitSubagentFrame: src/task/executor.ts:1431-1432
|
|
19
18
|
// - SubagentEventPayload: src/task/types.ts:82-84
|
|
@@ -23,42 +22,18 @@ const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event";
|
|
|
23
22
|
// Track unsubscribers for cleanup on session_shutdown
|
|
24
23
|
const subagentEventUnsubscribers = new Map<string, () => void>();
|
|
25
24
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (val && typeof val === 'object') {
|
|
35
|
-
const obj = val as Record<string, unknown>;
|
|
36
|
-
if (typeof obj.eventBus !== 'undefined') {
|
|
37
|
-
return obj.eventBus;
|
|
38
|
-
}
|
|
39
|
-
if (key === 'eventBus' || key === '_eventBus') {
|
|
40
|
-
return val;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
25
|
+
// Official session-bus handle accessor. `ExtensionAPI` declares
|
|
26
|
+
// `events: EventBus` and the harness injects the session bus as
|
|
27
|
+
// ConcreteExtensionAPI's `events` field, so this is a typed read; the runtime
|
|
28
|
+
// shape check only defends against older hosts whose dist predates the field.
|
|
29
|
+
function getPluginEventBus(pi: ExtensionAPI): ExtensionAPI['events'] | undefined {
|
|
30
|
+
const events = pi.events;
|
|
31
|
+
if (events && typeof events === 'object' && typeof events.on === 'function') {
|
|
32
|
+
return events;
|
|
43
33
|
}
|
|
44
34
|
return undefined;
|
|
45
35
|
}
|
|
46
36
|
|
|
47
|
-
// Helper to subscribe to eventBus channel
|
|
48
|
-
function subscribeToEventBusChannel(
|
|
49
|
-
eventBus: unknown,
|
|
50
|
-
channel: string,
|
|
51
|
-
handler: (data: unknown) => void,
|
|
52
|
-
): () => void {
|
|
53
|
-
const bus = eventBus as Record<string, unknown>;
|
|
54
|
-
const onMethod = bus.on as ((channel: string, handler: (data: unknown) => void) => () => void) | undefined;
|
|
55
|
-
if (typeof onMethod === 'function') {
|
|
56
|
-
return onMethod.call(bus, channel, handler);
|
|
57
|
-
}
|
|
58
|
-
// Return no-op if eventBus doesn't support subscription
|
|
59
|
-
return () => {};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
37
|
// Process subagent event payload
|
|
63
38
|
function handleSubagentEvent(payload: unknown, primarySessionId: string): void {
|
|
64
39
|
try {
|
|
@@ -540,19 +515,25 @@ function formatFamilyTierMarkdown(sel: SelectionContext): string {
|
|
|
540
515
|
return rows.join('\n');
|
|
541
516
|
}
|
|
542
517
|
|
|
543
|
-
// ──
|
|
544
|
-
//
|
|
545
|
-
// for every session, including
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
518
|
+
// ── process-wide usage recorder singleton ──────────────────────────
|
|
519
|
+
// This IS a process-wide singleton: the harness rebinds the same evaluated
|
|
520
|
+
// plugin module to a fresh ExtensionAPI instance for every session, including
|
|
521
|
+
// in-process task subagents (task/executor.ts), WITHOUT re-evaluating the
|
|
522
|
+
// module graph — so every instance shares the recorder created here. Only the
|
|
523
|
+
// owning session (has a UI, or is the first headless session) re-keys it via
|
|
524
|
+
// setSessionId; rebind instances never re-key and never write usage through
|
|
525
|
+
// their own message_* handlers (gated by recordsOwnUsage below). Subagent
|
|
526
|
+
// consumption reaches this recorder exclusively through the bus-subscription
|
|
527
|
+
// mechanism (see file header):
|
|
528
|
+
// - Each instance subscribes its own session bus (pi.events) to
|
|
529
|
+
// TASK_SUBAGENT_EVENT_CHANNEL ("task:subagent:event"); depth-N frames land
|
|
530
|
+
// on the depth-(N-1) session's bus, so all depths are covered.
|
|
531
|
+
// - Subscription callbacks attribute frames to the recorder's root session id
|
|
532
|
+
// with per-subagent stream keys (stream keys only — the recorder is never
|
|
533
|
+
// re-keyed, so the owner's cumulative baseline survives and streams don't
|
|
534
|
+
// double-count).
|
|
535
|
+
// Session totals persist into the shared usage cache, which every process
|
|
536
|
+
// aggregates on read.
|
|
556
537
|
const sessionUsageRecorder: SessionUsageRecorder = createSessionUsageRecorder({
|
|
557
538
|
write: writeSessionUsage,
|
|
558
539
|
remove: removeSessionUsage,
|
|
@@ -697,6 +678,16 @@ export default (pi: ExtensionAPI) => {
|
|
|
697
678
|
applyRoleModelOverrides(pi.pi?.settings, sel, warn);
|
|
698
679
|
};
|
|
699
680
|
|
|
681
|
+
// Instance-level usage-record claim: true only while this instance's
|
|
682
|
+
// session owns the process-wide recorder (claimsOwnership in session_start
|
|
683
|
+
// below). Rebind instances (in-process task subagents) keep it false —
|
|
684
|
+
// their message_* handlers must not write the default stream, because that
|
|
685
|
+
// consumption is captured exactly once by the parent instance's bus
|
|
686
|
+
// subscription with a subagent stream key. Closure variable, not
|
|
687
|
+
// module-level: rebind reuses the evaluated module, so a module-level flag
|
|
688
|
+
// would be overwritten by whichever instance starts last.
|
|
689
|
+
let recordsOwnUsage = false;
|
|
690
|
+
|
|
700
691
|
// ── shared usage poller + widget ──────────────────────────────
|
|
701
692
|
pi.on('session_start', async (_event, ctx) => {
|
|
702
693
|
try {
|
|
@@ -722,30 +713,38 @@ export default (pi: ExtensionAPI) => {
|
|
|
722
713
|
if (claimsOwnership) {
|
|
723
714
|
sessionUsageRecorder.setSessionId(ctx.sessionManager?.getSessionId?.() ?? '');
|
|
724
715
|
}
|
|
725
|
-
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
);
|
|
745
|
-
if (unsubscribe) {
|
|
746
|
-
subagentEventUnsubscribers.set(sessionId, unsubscribe);
|
|
716
|
+
recordsOwnUsage = claimsOwnership;
|
|
717
|
+
// Every instance subscribes its OWN session bus (pi.events) to the
|
|
718
|
+
// subagent channel — record ownership and subscription rights are
|
|
719
|
+
// decoupled: the root instance's bus carries depth-1 frames, a
|
|
720
|
+
// depth-1 instance's bus carries depth-2 frames, and each frame is
|
|
721
|
+
// consumed exactly once (see file header). Callbacks attribute
|
|
722
|
+
// frames to the recorder singleton's root session id (the module
|
|
723
|
+
// graph is shared across rebinds), falling back to this session's
|
|
724
|
+
// own id only before the owner has claimed.
|
|
725
|
+
const sessionId = ctx.sessionManager?.getSessionId?.() ?? '';
|
|
726
|
+
const pluginEventBus = getPluginEventBus(pi);
|
|
727
|
+
if (pluginEventBus) {
|
|
728
|
+
// Clean up any existing subscription for this session to prevent leaks
|
|
729
|
+
const existingUnsubscribe = subagentEventUnsubscribers.get(sessionId);
|
|
730
|
+
if (existingUnsubscribe) {
|
|
731
|
+
try {
|
|
732
|
+
existingUnsubscribe();
|
|
733
|
+
} catch {
|
|
734
|
+
// Silently ignore errors in unsubscribe
|
|
747
735
|
}
|
|
748
736
|
}
|
|
737
|
+
const unsubscribe = pluginEventBus.on(TASK_SUBAGENT_EVENT_CHANNEL, (payload) => {
|
|
738
|
+
handleSubagentEvent(payload, sessionUsageRecorder.sessionId() || sessionId);
|
|
739
|
+
});
|
|
740
|
+
subagentEventUnsubscribers.set(sessionId, unsubscribe);
|
|
741
|
+
getLogger().info(
|
|
742
|
+
`[omp-opsx-addon] subagent usage capture subscribed (sid=${sessionId}, channel=${TASK_SUBAGENT_EVENT_CHANNEL})`,
|
|
743
|
+
);
|
|
744
|
+
} else {
|
|
745
|
+
getLogger().warn(
|
|
746
|
+
'[omp-opsx-addon] pi.events handle unavailable — subagent usage capture disabled (subagent consumption will not be recorded)',
|
|
747
|
+
);
|
|
749
748
|
}
|
|
750
749
|
const auth = ctx.modelRegistry?.authStorage;
|
|
751
750
|
const getApiKey = ctx.modelRegistry?.getApiKeyForProvider
|
|
@@ -981,12 +980,14 @@ export default (pi: ExtensionAPI) => {
|
|
|
981
980
|
// PRIMARY usage source, so the waveform reflects in-progress consumption
|
|
982
981
|
// and stays dense (message_end alone is sparse). `record` is monotonic MAX,
|
|
983
982
|
// so repeated chunk calls never double-count; the final message_end value
|
|
984
|
-
// (equal or higher) is a harmless re-record.
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
983
|
+
// (equal or higher) is a harmless re-record. Only the recorder-owning
|
|
984
|
+
// instance writes here: task-subagent rebind instances skip these handlers,
|
|
985
|
+
// because their consumption reaches the recorder through the parent
|
|
986
|
+
// instance's bus subscription instead (subagent-specific stream keys,
|
|
987
|
+
// merged into the primary waveform).
|
|
988
988
|
pi.on('message_update', async (event) => {
|
|
989
989
|
try {
|
|
990
|
+
if (!recordsOwnUsage) return;
|
|
990
991
|
const ev = event.assistantMessageEvent;
|
|
991
992
|
if ('partial' in ev) {
|
|
992
993
|
const usage = assistantUsageOf(ev.partial);
|
|
@@ -1000,6 +1001,7 @@ export default (pi: ExtensionAPI) => {
|
|
|
1000
1001
|
// nonzero streamed total (same monotonic MAX, no double count).
|
|
1001
1002
|
pi.on('message_end', async (event) => {
|
|
1002
1003
|
try {
|
|
1004
|
+
if (!recordsOwnUsage) return;
|
|
1003
1005
|
const usage = assistantUsageOf(event.message);
|
|
1004
1006
|
if (usage) sessionUsageRecorder.record(canonicalizeProvider(usage.provider), usage.usage);
|
|
1005
1007
|
} catch {
|
|
@@ -1033,13 +1035,15 @@ export default (pi: ExtensionAPI) => {
|
|
|
1033
1035
|
}
|
|
1034
1036
|
}
|
|
1035
1037
|
// Always clean up eventBus subscription, even if no usage was recorded
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1038
|
+
if (sid) {
|
|
1039
|
+
const cleanupUnsubscribe = subagentEventUnsubscribers.get(sid);
|
|
1040
|
+
if (cleanupUnsubscribe) {
|
|
1041
|
+
try {
|
|
1042
|
+
cleanupUnsubscribe();
|
|
1043
|
+
subagentEventUnsubscribers.delete(sid);
|
|
1044
|
+
} catch {
|
|
1045
|
+
/* best-effort */
|
|
1046
|
+
}
|
|
1043
1047
|
}
|
|
1044
1048
|
}
|
|
1045
1049
|
} catch {
|
package/lib/usage-render.ts
CHANGED
|
@@ -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
|
|
|
@@ -424,7 +448,7 @@ export function renderUsageReports(
|
|
|
424
448
|
// placeholder strip, keeping the column height stable.
|
|
425
449
|
if (consumptionTracks) {
|
|
426
450
|
const track = consumptionTracks.get(c.provider);
|
|
427
|
-
if (track) c.col.push(...renderConsumptionLines(track.samples, painter, waveCols));
|
|
451
|
+
if (track) c.col.push(...renderConsumptionLines(track.samples, painter, waveCols, providerLabel(c.provider).color));
|
|
428
452
|
}
|
|
429
453
|
if (pad) c.col = c.col.map((l) => padRight(l, blockWidth));
|
|
430
454
|
}
|
|
@@ -469,13 +493,6 @@ export function composeChar(leftRows: number, rightRows: number): string {
|
|
|
469
493
|
return String.fromCharCode(code);
|
|
470
494
|
}
|
|
471
495
|
|
|
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
496
|
/** One provider's consumption track feeding its embedded mini chart. */
|
|
480
497
|
export interface ConsumptionTrack {
|
|
481
498
|
/** Per-interval consumption increments (raw values), newest last. */
|
|
@@ -498,12 +515,17 @@ export interface ConsumptionTrack {
|
|
|
498
515
|
* window yields a blank placeholder strip, never `undefined`, so every
|
|
499
516
|
* provider column keeps a stable chart area and the layout never jumps.
|
|
500
517
|
*
|
|
501
|
-
* Color is
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
518
|
+
* Color is a btop-style ROW gradient bound to the vertical position only —
|
|
519
|
+
* never to the data. The chart paints the provider's brand hue in two layers
|
|
520
|
+
* with a fixed step: the bottom line takes a curated 256-color bright shade
|
|
521
|
+
* of the hue (fixed rgb — visible under any 16-color theme; aixterm bright /
|
|
522
|
+
* bold when the code isn't mapped), the top line the plain brand — visibly
|
|
523
|
+
* stronger at the base, never washed out. Sliding samples reshape the
|
|
524
|
+
* envelope while every
|
|
525
|
+
* pixel's color stays put — the per-column intensity heat bands this
|
|
526
|
+
* replaces flipped green/yellow/red as the window moved, reading as flicker.
|
|
527
|
+
* Providers without a curated brand hue fall back to the theme accent.
|
|
528
|
+
* Quota urgency still reads in the usage text ("5h 0%", balance).
|
|
507
529
|
*
|
|
508
530
|
* Geometry unchanged: 1px per sample (half-char step), right-aligned; each
|
|
509
531
|
* character merges its two half-column samples with max and renders both
|
|
@@ -514,6 +536,7 @@ export function renderConsumptionLines(
|
|
|
514
536
|
samples: readonly number[],
|
|
515
537
|
painter: Painter = ansiPainter,
|
|
516
538
|
cols: number = WAVEFORM_COLS,
|
|
539
|
+
brandAnsi = '',
|
|
517
540
|
): string[] {
|
|
518
541
|
const n = samples.length;
|
|
519
542
|
const totalPx = cols * 2;
|
|
@@ -564,14 +587,10 @@ export function renderConsumptionLines(
|
|
|
564
587
|
return plot[m - 1] / max;
|
|
565
588
|
};
|
|
566
589
|
|
|
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
590
|
// Two-line strip: full scale = 8px (2 Braille rows × 4 dot rows). Each
|
|
573
|
-
// half-column keeps its OWN interpolated height;
|
|
574
|
-
// (
|
|
591
|
+
// half-column keeps its OWN interpolated height; COLOR is ROW-bound only
|
|
592
|
+
// (bottom = 256 bright shade of the brand hue, top = plain brand) and
|
|
593
|
+
// never varies per column.
|
|
575
594
|
const rows = 2;
|
|
576
595
|
const lines = new Array<string>(rows).fill('');
|
|
577
596
|
for (let c = 0; c < cols; c++) {
|
|
@@ -582,12 +601,14 @@ export function renderConsumptionLines(
|
|
|
582
601
|
for (let li = 0; li < rows; li++) lines[li] += blank;
|
|
583
602
|
continue;
|
|
584
603
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
604
|
+
// Minimum visible dot: any non-zero consumption keeps ≥1px no matter
|
|
605
|
+
// how small its ratio to the window max — a low-rate second must
|
|
606
|
+
// never vanish under the dynamic scale.
|
|
607
|
+
const hL = l > 0 ? Math.max(1, Math.round(8 * l)) : 0;
|
|
608
|
+
const hR = r > 0 ? Math.max(1, Math.round(8 * r)) : 0;
|
|
588
609
|
// Row 0 = top (px 4..8), row 1 = bottom (px 0..4) of each column.
|
|
589
|
-
lines[0] += painter.
|
|
590
|
-
lines[1] += painter.
|
|
610
|
+
lines[0] += painter.wave(brandAnsi, 'top', composeChar(Math.max(0, hL - 4), Math.max(0, hR - 4)));
|
|
611
|
+
lines[1] += painter.wave(brandAnsi, 'bottom', composeChar(Math.min(4, hL), Math.min(4, hR)));
|
|
591
612
|
}
|
|
592
613
|
return lines;
|
|
593
614
|
}
|
package/package.json
CHANGED