@polderlabs/bizar 4.4.11 → 4.4.13

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.
@@ -0,0 +1,908 @@
1
+ // src/views/MiniMaxUsage.tsx — v4.5.0
2
+ //
3
+ // Token Plan usage tracking dashboard.
4
+ //
5
+ // Renders the user's current 5-hour rolling + weekly remaining quota
6
+ // per model, fetched from https://www.minimax.io/v1/token_plan/remains.
7
+ // The Subscription Key is read from opencode's canonical auth store
8
+ // (`~/.local/share/opencode/auth.json`) — same place opencode's
9
+ // `/connect` command writes to — so the user only enters the key once
10
+ // and both opencode + the Bizar dashboard pick it up.
11
+ //
12
+ // The view has three states:
13
+ // 1. Onboarding wizard (first time, no key configured) — guided flow
14
+ // that takes the user from "get a key" → paste → save → dashboard
15
+ // 2. Live dashboard (key configured) — per-model 5h + weekly usage
16
+ // 3. Banner only (key invalid or auth failure) — inline error
17
+
18
+ import { useCallback, useEffect, useState } from 'react';
19
+ import {
20
+ Coins,
21
+ RefreshCw,
22
+ AlertTriangle,
23
+ CheckCircle2,
24
+ KeyRound,
25
+ Send,
26
+ Loader2,
27
+ Calendar,
28
+ Activity,
29
+ Save,
30
+ Eye,
31
+ EyeOff,
32
+ Sparkles,
33
+ X,
34
+ ExternalLink,
35
+ ShieldCheck,
36
+ CircleCheck,
37
+ } from 'lucide-react';
38
+ import { Card, CardTitle, CardMeta } from '../components/Card';
39
+ import { Button } from '../components/Button';
40
+ import { Spinner } from '../components/Spinner';
41
+ import { useToast } from '../components/Toast';
42
+ import { api } from '../lib/api';
43
+ import { cn } from '../lib/utils';
44
+ import type { Settings, Snapshot } from '../lib/types';
45
+ // Settings is passed for forward-compat with the App's view-props shape
46
+ // even though the read path no longer needs it.
47
+
48
+ type Props = {
49
+ snapshot: Snapshot;
50
+ settings: Settings;
51
+ activeTab: string;
52
+ setActiveTab: (id: string) => void;
53
+ refreshSnapshot: () => Promise<void>;
54
+ };
55
+
56
+ // Suppress unused-var — `settings` is kept in the prop signature so
57
+ // App.tsx can pass a single shape; the read path doesn't need it now
58
+ // that the key comes from opencode's auth.json.
59
+ void (null as unknown as Settings | null);
60
+
61
+ type RemainsModel = {
62
+ model_name: string;
63
+ current_interval_remaining_percent: number;
64
+ current_weekly_remaining_percent: number;
65
+ current_interval_consumed_percent?: number;
66
+ weekly_consumed_percent?: number;
67
+ current_interval_status: number;
68
+ current_weekly_status: number;
69
+ current_interval_total_count: number;
70
+ current_interval_usage_count: number;
71
+ current_weekly_total_count: number;
72
+ current_weekly_usage_count: number;
73
+ endTimeISO?: string;
74
+ weeklyEndTimeISO?: string;
75
+ intervalResetInMs?: number;
76
+ weeklyResetInMs?: number;
77
+ intervalResetInHuman?: string;
78
+ weeklyResetInHuman?: string;
79
+ intervalUsed?: number;
80
+ intervalTotal?: number;
81
+ weeklyUsed?: number;
82
+ weeklyTotal?: number;
83
+ };
84
+
85
+ type RemainsSnapshot = {
86
+ ok: boolean;
87
+ cached?: boolean;
88
+ fetchedAt?: number;
89
+ apiKeyHint?: string;
90
+ keySource?: string;
91
+ groupId?: string;
92
+ baseUrl?: string;
93
+ models?: RemainsModel[];
94
+ baseResp?: { status_code: number; status_msg: string };
95
+ error?: string;
96
+ message?: string;
97
+ };
98
+
99
+ type Status = {
100
+ configured: boolean;
101
+ apiKeyHint: string;
102
+ source: string;
103
+ groupId: string;
104
+ tokenBaseUrl: string;
105
+ chatBaseUrl: string;
106
+ knownModels: string[];
107
+ keyPatternValid: boolean | null;
108
+ cache: { fetchedAt: number; apiKeyHint: string; modelCount: number } | null;
109
+ };
110
+
111
+ type OnboardingState = {
112
+ dismissedAt: number | null;
113
+ hiddenModels: string[];
114
+ };
115
+
116
+ type TestResult = {
117
+ ok: boolean;
118
+ error?: string;
119
+ message?: string;
120
+ model?: string;
121
+ content?: string;
122
+ reasoning?: string;
123
+ finishReason?: string;
124
+ usage?: {
125
+ total_tokens?: number;
126
+ prompt_tokens?: number;
127
+ completion_tokens?: number;
128
+ prompt_tokens_details?: { cached_tokens?: number };
129
+ };
130
+ status?: number;
131
+ raw?: unknown;
132
+ };
133
+
134
+ export function MiniMaxUsage({ snapshot, settings, setActiveTab, refreshSnapshot }: Props) {
135
+ const toast = useToast();
136
+ const [status, setStatus] = useState<Status | null>(null);
137
+ const [remains, setRemains] = useState<RemainsSnapshot | null>(null);
138
+ const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
139
+ const [loading, setLoading] = useState(true);
140
+ const [refreshing, setRefreshing] = useState(false);
141
+ const [error, setError] = useState<string | null>(null);
142
+ const [testResult, setTestResult] = useState<TestResult | null>(null);
143
+ const [testing, setTesting] = useState(false);
144
+ // Onboarding wizard state.
145
+ const [wizardStep, setWizardStep] = useState(0); // 0..3 (welcome → key → verify → done)
146
+ const [wizardVerifying, setWizardVerifying] = useState(false);
147
+ const [wizardVerifyResult, setWizardVerifyResult] = useState<TestResult | null>(null);
148
+ // Inline key config — saves a trip to Settings.
149
+ const [keyDraft, setKeyDraft] = useState('');
150
+ const [showKey, setShowKey] = useState(false);
151
+ const [savingKey, setSavingKey] = useState(false);
152
+
153
+ const load = useCallback(async () => {
154
+ setLoading(true);
155
+ setError(null);
156
+ try {
157
+ const [s, r, o] = await Promise.all([
158
+ api.get<Status>('/minimax/status'),
159
+ api.get<RemainsSnapshot>('/minimax/remains'),
160
+ api.get<OnboardingState>('/minimax/onboarding'),
161
+ ]);
162
+ setStatus(s);
163
+ setRemains(r);
164
+ setOnboarding(o);
165
+ setKeyDraft(''); // never prefill — security
166
+ // If we just configured a key (dismissedAt is set but not configured)
167
+ // or the user already dismissed, do not show the wizard.
168
+ if (s.configured) setWizardStep(4); // skip past wizard
169
+ } catch (err) {
170
+ setError((err as Error).message || 'Failed to load MiniMax data');
171
+ } finally {
172
+ setLoading(false);
173
+ }
174
+ }, []);
175
+
176
+ // Dismiss the onboarding wizard (so it doesn't show again until the
177
+ // user clears their key).
178
+ const dismissWizard = useCallback(async () => {
179
+ try {
180
+ await api.post('/minimax/onboarding', { dismissedAt: Date.now() });
181
+ setOnboarding((o) => o ? { ...o, dismissedAt: Date.now() } : o);
182
+ } catch { /* best-effort */ }
183
+ }, []);
184
+
185
+ // Wizard step 2: test the key against the real API before saving.
186
+ const verifyKeyBeforeSave = useCallback(async () => {
187
+ if (!keyDraft.trim()) {
188
+ toast.error('Paste a Subscription Key first.');
189
+ return;
190
+ }
191
+ setWizardVerifying(true);
192
+ setWizardVerifyResult(null);
193
+ try {
194
+ // Temporarily save to the real auth.json, probe, then if the
195
+ // probe fails the user can re-paste. The probe endpoint is the
196
+ // chat completions surface (cheaper than remains + a real-world
197
+ // check that the key actually works for the dashboard's purpose).
198
+ const r = await api.post<TestResult>('/minimax/test', {
199
+ prompt: 'Reply with a single word: pong',
200
+ model: 'MiniMax-M3',
201
+ maxTokens: 16,
202
+ });
203
+ setWizardVerifyResult(r);
204
+ if (r.ok) {
205
+ setWizardStep(3); // success
206
+ toast.success('Key works. Saving…');
207
+ // Auto-save: the user already proved the key works.
208
+ try {
209
+ await api.post('/minimax/onboarding/save-key', {
210
+ key: keyDraft.trim(),
211
+ groupId: 'default',
212
+ });
213
+ clearRemainsCacheClient();
214
+ toast.success('Subscription Key saved.');
215
+ } catch (err) {
216
+ toast.error(`Save failed: ${(err as Error).message}`);
217
+ }
218
+ void load();
219
+ } else {
220
+ toast.error(`Verification failed: ${r.message ?? r.error ?? 'unknown'}`);
221
+ }
222
+ } catch (err) {
223
+ toast.error(`Test request failed: ${(err as Error).message}`);
224
+ } finally {
225
+ setWizardVerifying(false);
226
+ }
227
+ }, [keyDraft, toast, load]);
228
+
229
+ useEffect(() => { load(); }, [load]);
230
+
231
+ // Persist the key into opencode's auth.json. The server-side route
232
+ // `POST /api/minimax/onboarding/save-key` writes to
233
+ // `~/.local/share/opencode/auth.json` (the canonical store the
234
+ // opencode `/connect` command writes to). The dashboard reads it
235
+ // back on the next status fetch.
236
+ const saveKey = useCallback(async () => {
237
+ if (!keyDraft.trim()) {
238
+ toast.error('Paste a Subscription Key first.');
239
+ return;
240
+ }
241
+ setSavingKey(true);
242
+ try {
243
+ const r = await api.post<{ ok: boolean; path?: string; apiKeyHint?: string; error?: string; message?: string }>(
244
+ '/minimax/onboarding/save-key',
245
+ { key: keyDraft.trim(), groupId: 'default' },
246
+ );
247
+ if (!r.ok) {
248
+ toast.error(`Save failed: ${r.message ?? r.error ?? 'unknown'}`);
249
+ return;
250
+ }
251
+ setKeyDraft('');
252
+ clearRemainsCacheClient();
253
+ toast.success('Subscription Key saved. Loading quota…');
254
+ void load();
255
+ } catch (err) {
256
+ toast.error(`Save failed: ${(err as Error).message}`);
257
+ } finally {
258
+ setSavingKey(false);
259
+ }
260
+ }, [keyDraft, toast, load]);
261
+
262
+ // Clears the on-disk + in-memory cache via the dashboard's
263
+ // /api/minimax/cache DELETE endpoint. Wrapped in a helper so the
264
+ // success path can await the round-trip.
265
+ const clearRemainsCacheClient = useCallback(async () => {
266
+ try { await api.del('/minimax/cache'); } catch { /* best-effort */ }
267
+ }, []);
268
+
269
+ const refresh = useCallback(async () => {
270
+ setRefreshing(true);
271
+ setError(null);
272
+ try {
273
+ await api.post('/minimax/remains/refresh');
274
+ await load();
275
+ toast.success('Refreshed');
276
+ } catch (err) {
277
+ toast.error(`Refresh failed: ${(err as Error).message}`);
278
+ } finally {
279
+ setRefreshing(false);
280
+ }
281
+ }, [load, toast]);
282
+
283
+ const sendTest = useCallback(async () => {
284
+ setTesting(true);
285
+ setTestResult(null);
286
+ try {
287
+ const r = await api.post<TestResult>('/minimax/test', {
288
+ prompt: 'Reply with a single word: pong',
289
+ model: 'MiniMax-M3',
290
+ maxTokens: 16,
291
+ });
292
+ setTestResult(r);
293
+ if (r.ok) {
294
+ toast.success(`Test ok — used ${r.usage?.total_tokens ?? '?'} tokens`);
295
+ } else {
296
+ toast.error(`Test failed: ${r.message ?? r.error ?? 'unknown'}`);
297
+ }
298
+ } catch (err) {
299
+ toast.error(`Test request failed: ${(err as Error).message}`);
300
+ } finally {
301
+ setTesting(false);
302
+ }
303
+ }, [toast]);
304
+
305
+ if (loading && !status) {
306
+ return (
307
+ <div className="view-container">
308
+ <Spinner /> Loading MiniMax data…
309
+ </div>
310
+ );
311
+ }
312
+
313
+ if (!status) {
314
+ return (
315
+ <div className="view-container">
316
+ <div className="error-card">
317
+ <AlertTriangle size={20} />
318
+ <div>
319
+ <strong>Couldn't load MiniMax status</strong>
320
+ <pre>{error ?? 'unknown error'}</pre>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ );
325
+ }
326
+
327
+ // First-run onboarding: show the wizard when no key is configured and
328
+ // the user hasn't dismissed it. The wizard writes the key to opencode's
329
+ // auth store (same place `/connect` writes), then the dashboard reads
330
+ // it back via /minimax/status and the live dashboard takes over.
331
+ const showWizard = !status.configured
332
+ && (onboarding?.dismissedAt == null)
333
+ && wizardStep < 4;
334
+ if (showWizard) {
335
+ return <OnboardingWizard
336
+ step={wizardStep}
337
+ setStep={setWizardStep}
338
+ keyDraft={keyDraft}
339
+ setKeyDraft={setKeyDraft}
340
+ showKey={showKey}
341
+ setShowKey={setShowKey}
342
+ verifying={wizardVerifying}
343
+ verifyResult={wizardVerifyResult}
344
+ onVerify={verifyKeyBeforeSave}
345
+ onSkip={async () => {
346
+ await dismissWizard();
347
+ toast.success("Skipped. You can paste the key anytime from this page.");
348
+ }}
349
+ onManualKeySave={saveKey}
350
+ savingKey={savingKey}
351
+ status={status}
352
+ />;
353
+ }
354
+
355
+ return (
356
+ <div className="view-container view-minimax-usage">
357
+ {/* ── Header ─────────────────────────────────────────────────── */}
358
+ <header className="view-header">
359
+ <div className="view-header-titles">
360
+ <h1 className="view-title">
361
+ <Coins size={20} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
362
+ MiniMax Token Plan
363
+ </h1>
364
+ <p className="view-subtitle">
365
+ Remaining 5-hour + weekly quota per model. Fetches live from{' '}
366
+ <code>www.minimax.io/v1/token_plan/remains</code>.
367
+ </p>
368
+ </div>
369
+ <div className="view-header-actions">
370
+ <Button
371
+ variant="secondary"
372
+ size="sm"
373
+ onClick={() => setActiveTab('settings')}
374
+ title="Configure your Subscription Key"
375
+ >
376
+ <KeyRound size={14} /> {status.configured ? `Key ${status.apiKeyHint}` : 'Add key'}
377
+ </Button>
378
+ <Button variant="secondary" size="sm" onClick={refresh} disabled={refreshing}>
379
+ {refreshing ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
380
+ Refresh
381
+ </Button>
382
+ </div>
383
+ </header>
384
+
385
+ {/* ── Status banners ─────────────────────────────────────────── */}
386
+ {remains && !remains.ok && (
387
+ <div className="banner banner-err">
388
+ <AlertTriangle size={16} />
389
+ <span>
390
+ <strong>Couldn't load quota</strong>: {remains.message ?? remains.error ?? 'unknown error'}
391
+ {status.source && (
392
+ <> · source: <code>{status.source}</code></>
393
+ )}
394
+ </span>
395
+ </div>
396
+ )}
397
+ {status.configured && status.keyPatternValid === false && (
398
+ <div className="banner banner-warn">
399
+ <AlertTriangle size={16} />
400
+ <span>
401
+ The configured Subscription Key has an unexpected format (expected
402
+ <code> sk-cp-</code> / <code>sk-ant-</code> / <code>sk-or-</code> prefix).
403
+ Re-save it from this page.
404
+ </span>
405
+ </div>
406
+ )}
407
+
408
+ {/* ── Subscription Key config (inline) ──────────────────── */}
409
+ {(
410
+ <Card id="minimax-key">
411
+ <CardTitle>
412
+ <KeyRound size={14} /> Subscription Key
413
+ </CardTitle>
414
+ <CardMeta>
415
+ Get it from{' '}
416
+ <a
417
+ href="https://platform.minimax.io/user-center/payment/token-plan"
418
+ target="_blank"
419
+ rel="noreferrer"
420
+ className="minimax-link"
421
+ >
422
+ platform.minimax.io/user-center/payment/token-plan
423
+ </a>
424
+ . Stored in <code>~/.local/share/opencode/auth.json</code>;
425
+ never sent to any other host. Status:{' '}
426
+ {status.configured ? (
427
+ <strong className="is-ok">configured ({status.apiKeyHint})</strong>
428
+ ) : (
429
+ <strong className="is-warn">not configured</strong>
430
+ )}
431
+ .
432
+ </CardMeta>
433
+ <div className="minimax-key-row">
434
+ <div className="minimax-key-input-wrap">
435
+ <KeyRound size={12} className="minimax-key-icon" />
436
+ <input
437
+ type={showKey ? 'text' : 'password'}
438
+ value={keyDraft}
439
+ onChange={(e) => setKeyDraft(e.target.value)}
440
+ placeholder={status.configured ? '•••• paste a new key to replace' : 'eyJhbGciOi...'}
441
+ spellCheck={false}
442
+ autoComplete="off"
443
+ className="minimax-key-input"
444
+ />
445
+ <button
446
+ type="button"
447
+ onClick={() => setShowKey((v) => !v)}
448
+ className="minimax-key-toggle"
449
+ aria-label={showKey ? 'Hide key' : 'Show key'}
450
+ title={showKey ? 'Hide key' : 'Show key'}
451
+ >
452
+ {showKey ? <EyeOff size={13} /> : <Eye size={13} />}
453
+ </button>
454
+ </div>
455
+ <Button onClick={saveKey} disabled={savingKey || !keyDraft.trim()} size="sm">
456
+ {savingKey ? <Loader2 size={14} className="spin" /> : <Save size={14} />}
457
+ Save key
458
+ </Button>
459
+ </div>
460
+ </Card>
461
+ )}
462
+
463
+ {remains && !remains.ok && (
464
+ <div className="banner banner-err">
465
+ <AlertTriangle size={16} />
466
+ <span>
467
+ <strong>Couldn't load quota</strong>: {remains.message ?? remains.error ?? 'unknown error'}
468
+ </span>
469
+ </div>
470
+ )}
471
+
472
+ {/* ── Summary stats ─────────────────────────────────────────── */}
473
+ {remains?.ok && remains.models && (
474
+ <>
475
+ <div className="minimax-stats-row">
476
+ <Stat
477
+ label="Models tracked"
478
+ value={String(remains.models.length)}
479
+ hint="Distinct model quotas returned by the API"
480
+ />
481
+ <Stat
482
+ label="Last fetched"
483
+ value={remains.fetchedAt ? relativeTime(remains.fetchedAt) : '—'}
484
+ hint="Refreshed on demand + every 60s"
485
+ />
486
+ <Stat
487
+ label="Group"
488
+ value={remains.groupId ?? '—'}
489
+ hint="Usually 'default' for an individual team"
490
+ />
491
+ <Stat
492
+ label="API key"
493
+ value={remains.apiKeyHint ?? '—'}
494
+ hint="Masked; the real key never leaves auth.json"
495
+ />
496
+ </div>
497
+
498
+ {/* ── Per-model quota cards ──────────────────────────────── */}
499
+ <div className="minimax-models-grid">
500
+ {remains.models.map((m) => (
501
+ <ModelQuotaCard key={m.model_name} model={m} />
502
+ ))}
503
+ </div>
504
+ </>
505
+ )}
506
+
507
+ {/* ── Test prompt card ─────────────────────────────────────── */}
508
+ {status.configured && (
509
+ <Card id="minimax-test">
510
+ <CardTitle>
511
+ <Send size={14} /> Test the API key
512
+ </CardTitle>
513
+ <CardMeta>
514
+ Sends a one-shot chat completion to{' '}
515
+ <code>{status.chatBaseUrl}/chat/completions</code> to verify the
516
+ key works and surface live token usage.
517
+ </CardMeta>
518
+ <div className="minimax-test-actions">
519
+ <Button onClick={sendTest} disabled={testing} variant="primary" size="sm">
520
+ {testing ? <Loader2 size={14} className="spin" /> : <Send size={14} />}
521
+ Send test prompt
522
+ </Button>
523
+ <code className="minimax-test-prompt">"Reply with a single word: pong"</code>
524
+ </div>
525
+ {testResult && (
526
+ <div className={cn('minimax-test-result', testResult.ok ? 'ok' : 'err')}>
527
+ {testResult.ok ? <CheckCircle2 size={14} /> : <AlertTriangle size={14} />}
528
+ <div className="minimax-test-body">
529
+ {testResult.ok ? (
530
+ <>
531
+ <div className="minimax-test-line">
532
+ <strong>model:</strong> <code>{testResult.model}</code> ·{' '}
533
+ <strong>finish:</strong> {testResult.finishReason ?? '—'}
534
+ </div>
535
+ {testResult.content && (
536
+ <div className="minimax-test-content">
537
+ <code>{testResult.content}</code>
538
+ </div>
539
+ )}
540
+ {testResult.usage && (
541
+ <div className="minimax-test-usage">
542
+ <strong>usage:</strong> total{' '}
543
+ <code>{testResult.usage.total_tokens ?? '?'}</code> · prompt{' '}
544
+ <code>{testResult.usage.prompt_tokens ?? '?'}</code> · completion{' '}
545
+ <code>{testResult.usage.completion_tokens ?? '?'}</code>
546
+ {testResult.usage.prompt_tokens_details?.cached_tokens != null && (
547
+ <> · cached <code>{testResult.usage.prompt_tokens_details.cached_tokens}</code></>
548
+ )}
549
+ </div>
550
+ )}
551
+ </>
552
+ ) : (
553
+ <pre>{testResult.message ?? testResult.error ?? 'unknown error'}</pre>
554
+ )}
555
+ </div>
556
+ </div>
557
+ )}
558
+ </Card>
559
+ )}
560
+ </div>
561
+ );
562
+ }
563
+
564
+ // ─── Sub-components ────────────────────────────────────────────────────────
565
+
566
+ function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
567
+ return (
568
+ <div className="stat">
569
+ <div className="stat-label">{label}</div>
570
+ <div className="stat-value">{value}</div>
571
+ {hint && <div className="stat-hint">{hint}</div>}
572
+ </div>
573
+ );
574
+ }
575
+
576
+ function ModelQuotaCard({ model }: { model: RemainsModel }) {
577
+ const fivePct = clamp(model.current_interval_remaining_percent ?? 0, 0, 100);
578
+ const weekPct = clamp(model.current_weekly_remaining_percent ?? 0, 0, 100);
579
+ const fiveConsumed = clamp(100 - fivePct, 0, 100);
580
+ const weekConsumed = clamp(100 - weekPct, 0, 100);
581
+ return (
582
+ <Card id={`minimax-model-${model.model_name}`} className="minimax-model-card">
583
+ <div className="minimax-model-head">
584
+ <div className="minimax-model-name">{model.model_name}</div>
585
+ <div
586
+ className={cn(
587
+ 'minimax-model-status',
588
+ fivePct < 25 || weekPct < 25 ? 'is-warn' : 'is-ok'
589
+ )}
590
+ >
591
+ {model.current_interval_status === 1 ? 'active' : 'idle'} ·{' '}
592
+ {model.current_weekly_status === 1 ? 'week active' : 'week idle'}
593
+ </div>
594
+ </div>
595
+
596
+ <div className="minimax-quota-rows">
597
+ <QuotaBar
598
+ icon={<Activity size={12} />}
599
+ label="5-hour rolling"
600
+ remainingPct={fivePct}
601
+ consumedPct={fiveConsumed}
602
+ used={model.current_interval_usage_count ?? 0}
603
+ total={model.current_interval_total_count ?? 0}
604
+ resetIn={model.intervalResetInHuman}
605
+ resetISO={model.endTimeISO}
606
+ />
607
+ <QuotaBar
608
+ icon={<Calendar size={12} />}
609
+ label="Weekly"
610
+ remainingPct={weekPct}
611
+ consumedPct={weekConsumed}
612
+ used={model.current_weekly_usage_count ?? 0}
613
+ total={model.current_weekly_total_count ?? 0}
614
+ resetIn={model.weeklyResetInHuman}
615
+ resetISO={model.weeklyEndTimeISO}
616
+ />
617
+ </div>
618
+ </Card>
619
+ );
620
+ }
621
+
622
+ function QuotaBar({
623
+ icon,
624
+ label,
625
+ remainingPct,
626
+ consumedPct,
627
+ used,
628
+ total,
629
+ resetIn,
630
+ resetISO,
631
+ }: {
632
+ icon: React.ReactNode;
633
+ label: string;
634
+ remainingPct: number;
635
+ consumedPct: number;
636
+ used: number;
637
+ total: number;
638
+ resetIn?: string;
639
+ resetISO?: string;
640
+ }) {
641
+ const tone = remainingPct >= 75 ? 'good' : remainingPct >= 25 ? 'warn' : 'low';
642
+ return (
643
+ <div className={`minimax-quota-row is-${tone}`}>
644
+ <div className="minimax-quota-head">
645
+ <span className="minimax-quota-label">
646
+ {icon} {label}
647
+ </span>
648
+ <span className="minimax-quota-remaining">{remainingPct}% remaining</span>
649
+ </div>
650
+ <div className="minimax-bar">
651
+ <div
652
+ className="minimax-bar-fill"
653
+ style={{ width: `${consumedPct}%` }}
654
+ aria-label={`${consumedPct}% consumed`}
655
+ />
656
+ </div>
657
+ <div className="minimax-quota-meta">
658
+ <span>
659
+ <strong>{used}</strong> / {total || '—'} requests
660
+ </span>
661
+ <span title={resetISO}>
662
+ {resetIn ? `resets in ${resetIn}` : '—'}
663
+ </span>
664
+ </div>
665
+ </div>
666
+ );
667
+ }
668
+
669
+ function clamp(n: number, lo: number, hi: number) {
670
+ return Math.max(lo, Math.min(hi, n));
671
+ }
672
+
673
+ function relativeTime(ts: number): string {
674
+ const ms = Date.now() - ts;
675
+ if (ms < 5_000) return 'just now';
676
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`;
677
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
678
+ return `${Math.floor(ms / 3_600_000)}h ago`;
679
+ }
680
+
681
+ // ─── Onboarding wizard ──────────────────────────────────────────────────
682
+
683
+ type OnboardingProps = {
684
+ step: number;
685
+ setStep: (n: number) => void;
686
+ keyDraft: string;
687
+ setKeyDraft: (s: string) => void;
688
+ showKey: boolean;
689
+ setShowKey: React.Dispatch<React.SetStateAction<boolean>>;
690
+ verifying: boolean;
691
+ verifyResult: TestResult | null;
692
+ onVerify: () => void;
693
+ onSkip: () => void;
694
+ onManualKeySave: () => void;
695
+ savingKey: boolean;
696
+ status: Status;
697
+ };
698
+
699
+ /**
700
+ * 4-step first-run wizard:
701
+ * 0. Welcome — what the page does, what the user needs.
702
+ * 1. Get a key — link to platform.minimax.io + how to find it.
703
+ * 2. Paste — input with show/hide + "Test key" button.
704
+ * 3. Done — confirms the save + "View your quota" CTA.
705
+ *
706
+ * The wizard writes to opencode's canonical auth store at
707
+ * ~/.local/share/opencode/auth.json (via /api/minimax/onboarding/save-key).
708
+ */
709
+ function OnboardingWizard({
710
+ step, setStep, keyDraft, setKeyDraft, showKey, setShowKey,
711
+ verifying, verifyResult, onVerify, onSkip, onManualKeySave,
712
+ savingKey, status,
713
+ }: OnboardingProps) {
714
+ return (
715
+ <div className="view-container view-minimax-onboarding">
716
+ <header className="view-header">
717
+ <div className="view-header-titles">
718
+ <h1 className="view-title">
719
+ <Sparkles size={20} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
720
+ Set up MiniMax Token Plan tracking
721
+ </h1>
722
+ <p className="view-subtitle">
723
+ We'll read your Subscription Key from opencode's auth store and show your remaining
724
+ 5-hour + weekly quota for every MiniMax model.
725
+ </p>
726
+ </div>
727
+ <div className="view-header-actions">
728
+ <Button variant="ghost" size="sm" onClick={onSkip}>
729
+ <X size={14} /> Skip for now
730
+ </Button>
731
+ </div>
732
+ </header>
733
+
734
+ {/* ── Stepper ────────────────────────────────────────────── */}
735
+ <div className="minimax-wizard-stepper">
736
+ {(['Welcome', 'Get a key', 'Paste & test', 'Done']).map((label, i) => (
737
+ <div
738
+ key={label}
739
+ className={cn(
740
+ 'minimax-wizard-step',
741
+ i === step && 'is-current',
742
+ i < step && 'is-done',
743
+ i > step && 'is-todo',
744
+ )}
745
+ >
746
+ <div className="minimax-wizard-step-bullet">
747
+ {i < step ? <CircleCheck size={14} /> : i + 1}
748
+ </div>
749
+ <div className="minimax-wizard-step-label">{label}</div>
750
+ </div>
751
+ ))}
752
+ </div>
753
+
754
+ {/* ── Step content ────────────────────────────────────────── */}
755
+ <Card id="minimax-wizard-card">
756
+ {step === 0 && (
757
+ <div className="minimax-wizard-body">
758
+ <h3 className="minimax-wizard-title">Welcome</h3>
759
+ <p className="minimax-wizard-prose">
760
+ BizarHarness can show you how much of your MiniMax Token Plan
761
+ quota you have left — both the <strong>5-hour rolling</strong>
762
+ and the <strong>weekly</strong> windows — for each model. To
763
+ do that it needs your Subscription Key. The key never leaves
764
+ this machine.
765
+ </p>
766
+ <ul className="minimax-wizard-list">
767
+ <li>Stored in <code>~/.local/share/opencode/auth.json</code> (same place opencode's <code>/connect</code> writes).</li>
768
+ <li>Read fresh on every dashboard load — you can revoke it any time from the MiniMax console.</li>
769
+ <li>Used only for <code>GET /v1/token_plan/remains</code> and the chat-completions probe; never logged in full.</li>
770
+ </ul>
771
+ <div className="minimax-wizard-actions">
772
+ <Button onClick={() => setStep(1)} variant="primary" size="md">
773
+ Get started <span style={{ marginLeft: 6 }}>→</span>
774
+ </Button>
775
+ </div>
776
+ </div>
777
+ )}
778
+
779
+ {step === 1 && (
780
+ <div className="minimax-wizard-body">
781
+ <h3 className="minimax-wizard-title">Get your Subscription Key</h3>
782
+ <p className="minimax-wizard-prose">
783
+ Go to the MiniMax console and copy your Subscription Key. The
784
+ key starts with <code>sk-cp-</code> (or <code>sk-ant-</code>{' '}
785
+ if you're using the Anthropic-format surface). It's about
786
+ 120 characters long.
787
+ </p>
788
+ <ol className="minimax-wizard-list">
789
+ <li>Open{' '}
790
+ <a
791
+ href="https://platform.minimax.io/user-center/payment/token-plan"
792
+ target="_blank"
793
+ rel="noreferrer"
794
+ className="minimax-wizard-link"
795
+ >
796
+ <ExternalLink size={12} /> platform.minimax.io/user-center/payment/token-plan
797
+ </a>
798
+ </li>
799
+ <li>Click <strong>Copy Subscription Key</strong>.</li>
800
+ <li>Come back here and paste it in the next step.</li>
801
+ </ol>
802
+ <p className="minimax-wizard-prose minimax-wizard-prose--muted">
803
+ Already have the key? You can also set the env var{' '}
804
+ <code>MINIMAX_API_KEY</code> or add it to{' '}
805
+ <code>opencode.json</code> under{' '}
806
+ <code>provider.minimax.options.apiKey</code>.
807
+ </p>
808
+ <div className="minimax-wizard-actions">
809
+ <Button variant="secondary" size="md" onClick={() => setStep(0)}>← Back</Button>
810
+ <Button variant="primary" size="md" onClick={() => setStep(2)}>
811
+ I have my key <span style={{ marginLeft: 6 }}>→</span>
812
+ </Button>
813
+ </div>
814
+ </div>
815
+ )}
816
+
817
+ {step === 2 && (
818
+ <div className="minimax-wizard-body">
819
+ <h3 className="minimax-wizard-title">Paste &amp; test</h3>
820
+ <p className="minimax-wizard-prose">
821
+ Paste your Subscription Key below. We'll test it against the
822
+ real API (one tiny chat call) and then save it to opencode's
823
+ auth store. If the test fails you'll see exactly why.
824
+ </p>
825
+ <div className="minimax-key-row">
826
+ <div className="minimax-key-input-wrap">
827
+ <KeyRound size={12} className="minimax-key-icon" />
828
+ <input
829
+ type={showKey ? 'text' : 'password'}
830
+ value={keyDraft}
831
+ onChange={(e) => setKeyDraft(e.target.value)}
832
+ placeholder="eyJhbGciOi... or sk-cp-..."
833
+ spellCheck={false}
834
+ autoComplete="off"
835
+ className="minimax-key-input"
836
+ autoFocus
837
+ />
838
+ <button
839
+ type="button"
840
+ onClick={() => setShowKey((v) => !v)}
841
+ className="minimax-key-toggle"
842
+ aria-label={showKey ? 'Hide key' : 'Show key'}
843
+ title={showKey ? 'Hide key' : 'Show key'}
844
+ >
845
+ {showKey ? <EyeOff size={13} /> : <Eye size={13} />}
846
+ </button>
847
+ </div>
848
+ </div>
849
+ {verifyResult && !verifyResult.ok && (
850
+ <div className="minimax-wizard-error">
851
+ <AlertTriangle size={14} />
852
+ <span>
853
+ <strong>Verification failed:</strong> {verifyResult.message ?? verifyResult.error}
854
+ </span>
855
+ </div>
856
+ )}
857
+ <div className="minimax-wizard-actions">
858
+ <Button variant="secondary" size="md" onClick={() => setStep(1)}>← Back</Button>
859
+ <Button
860
+ variant="primary"
861
+ size="md"
862
+ onClick={onVerify}
863
+ disabled={verifying || !keyDraft.trim()}
864
+ >
865
+ {verifying ? <Loader2 size={14} className="spin" /> : <ShieldCheck size={14} />}
866
+ {verifying ? 'Testing…' : 'Test &amp; save key'}
867
+ </Button>
868
+ </div>
869
+ <p className="minimax-wizard-prose minimax-wizard-prose--muted">
870
+ Don't want to test first?{' '}
871
+ <a
872
+ href="#"
873
+ onClick={(e) => { e.preventDefault(); onManualKeySave(); }}
874
+ className="minimax-wizard-link"
875
+ >
876
+ Save without testing
877
+ </a>
878
+ </p>
879
+ </div>
880
+ )}
881
+
882
+ {step === 3 && (
883
+ <div className="minimax-wizard-body">
884
+ <h3 className="minimax-wizard-title">
885
+ <CircleCheck size={18} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
886
+ All set
887
+ </h3>
888
+ <p className="minimax-wizard-prose">
889
+ The key works and is saved. Reloading the dashboard so you
890
+ can see your live 5-hour + weekly quota.
891
+ </p>
892
+ <div className="minimax-wizard-actions">
893
+ <Button variant="primary" size="md" onClick={() => window.location.reload()}>
894
+ View your quota →
895
+ </Button>
896
+ </div>
897
+ </div>
898
+ )}
899
+
900
+ {/* ── Status footer ─────────────────────────────────────────── */}
901
+ <div className="minimax-wizard-footer">
902
+ <ShieldCheck size={11} />
903
+ <span>Key is written to <code>~/.local/share/opencode/auth.json</code> with mode 0600.</span>
904
+ </div>
905
+ </Card>
906
+ </div>
907
+ );
908
+ }