teamclaude-cloud 1.4.1

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/src/tui.js ADDED
@@ -0,0 +1,843 @@
1
+ import { importCredentials, fetchProfile } from './oauth.js';
2
+
3
+ // ── ANSI helpers ─────────────────────────────────────────────
4
+
5
+ const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'.split('');
6
+ const ESC = '\x1b[';
7
+ const RESET = `${ESC}0m`;
8
+ const BOLD = `${ESC}1m`;
9
+ const DIM = `${ESC}2m`;
10
+
11
+ const bold = s => `${BOLD}${s}${RESET}`;
12
+ const dim = s => `${DIM}${s}${RESET}`;
13
+ const fg = (c, s) => `${ESC}${c}m${s}${RESET}`;
14
+ const green = s => fg(32, s);
15
+ const yellow = s => fg(33, s);
16
+ const red = s => fg(31, s);
17
+ const cyan = s => fg(36, s);
18
+ const gray = s => fg(90, s);
19
+
20
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
21
+ const strip = s => s.replace(ANSI_RE, '');
22
+ const vw = s => strip(s).length;
23
+
24
+ function rpad(s, w) {
25
+ const gap = w - vw(s);
26
+ return gap > 0 ? s + ' '.repeat(gap) : s;
27
+ }
28
+
29
+ /** Truncate a string with ANSI codes to exactly w visible characters, then reset. */
30
+ function truncate(s, w) {
31
+ let visible = 0;
32
+ let out = '';
33
+ let i = 0;
34
+ while (i < s.length && visible < w) {
35
+ if (s[i] === '\x1b') {
36
+ const end = s.indexOf('m', i);
37
+ if (end >= 0) { out += s.slice(i, end + 1); i = end + 1; continue; }
38
+ }
39
+ out += s[i];
40
+ visible++;
41
+ i++;
42
+ }
43
+ return out + RESET;
44
+ }
45
+
46
+ /** Fit a line to exactly w columns: truncate if too long, pad if too short. */
47
+ function fitLine(s, w) {
48
+ const v = vw(s);
49
+ if (v > w) return truncate(s, w);
50
+ if (v < w) return s + ' '.repeat(w - v);
51
+ return s;
52
+ }
53
+
54
+ function formatReset(resetTs) {
55
+ if (!resetTs) return '';
56
+ const ms = resetTs - Date.now();
57
+ if (ms <= 0) return '';
58
+ const mins = Math.ceil(ms / 60000);
59
+ if (mins < 60) return `${mins}m`;
60
+ const hrs = Math.floor(mins / 60);
61
+ const rm = mins % 60;
62
+ if (hrs < 24) return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`;
63
+ const days = Math.floor(hrs / 24);
64
+ const rh = hrs % 24;
65
+ return rh > 0 ? `${days}d${rh}h` : `${days}d`;
66
+ }
67
+
68
+ /**
69
+ * Render a progress bar using background colors with text overlaid.
70
+ * The label (e.g. "Ses 2h30m" or "45%") is drawn on top of the bar.
71
+ */
72
+ function bar(ratio, w = 10, resetTs) {
73
+ const rst = formatReset(resetTs);
74
+
75
+ if (ratio == null || isNaN(ratio)) {
76
+ // No data — dim background, show label or dash
77
+ const label = rst || '-';
78
+ const text = label.slice(0, w);
79
+ const pad = w - text.length;
80
+ const lp = Math.floor(pad / 2);
81
+ const rp = pad - lp;
82
+ return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
83
+ }
84
+
85
+ ratio = Math.max(0, Math.min(1, ratio));
86
+ const f = Math.round(ratio * w);
87
+ // Background colors: 42=green, 43=yellow, 41=red; 100=bright black (gray) for empty
88
+ const bg = ratio < 0.7 ? 42 : ratio < 0.9 ? 43 : 41;
89
+
90
+ // Always show usage %, and append the reset countdown when it fits.
91
+ const pct = (ratio * 100).toFixed(0) + '%';
92
+ const label = (rst && pct.length + 1 + rst.length <= w) ? `${pct} ${rst}` : pct;
93
+ const text = label.slice(0, w);
94
+ const pad = w - text.length;
95
+ const lp = Math.floor(pad / 2);
96
+ const rp = pad - lp;
97
+ const chars = (' '.repeat(lp) + text + ' '.repeat(rp));
98
+
99
+ // Split chars into filled (colored bg) and empty (gray bg) portions
100
+ const filled = chars.slice(0, f);
101
+ const empty = chars.slice(f);
102
+
103
+ let out = '';
104
+ if (filled) out += `${ESC}${bg};97m${filled}`;
105
+ if (empty) out += `${ESC}100;37m${empty}`;
106
+ out += RESET;
107
+ return out;
108
+ }
109
+
110
+ function timestamp() {
111
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
112
+ }
113
+
114
+ // ── TUI class ────────────────────────────────────────────────
115
+
116
+ export class TUI {
117
+ constructor({ accountManager, config, saveConfig, syncAccounts, refreshQuota, onQuit }) {
118
+ this.am = accountManager;
119
+ this.config = config;
120
+ this.saveConfig = saveConfig;
121
+ this.syncAccounts = syncAccounts;
122
+ this.refreshQuota = refreshQuota; // optional: forced fleet quota re-measure (R)
123
+ this.onQuit = onQuit;
124
+
125
+ this.log = []; // completed activity entries
126
+ this.active = new Map(); // in-flight requests
127
+ this.mode = 'normal'; // normal | select (delete-confirm) | add | input | order
128
+ this.selIdx = 0; // cursor POSITION over the display list (render hint)
129
+ this.selAcct = null; // cursor ANCHOR: the selected account OBJECT (see _selected)
130
+ this.orderAccount = null; // the account being moved while in 'order' mode
131
+ this.inputPrompt = '';
132
+ this.inputBuf = '';
133
+ this.inputCb = null;
134
+ this.frame = 0;
135
+ this.running = false;
136
+ this.timer = null;
137
+ this._origLog = null;
138
+ this._origErr = null;
139
+ }
140
+
141
+ // ── lifecycle ──────────────────────────────────────
142
+
143
+ start() {
144
+ this.running = true;
145
+ process.stdout.write(`${ESC}?1049h${ESC}?25l`);
146
+ process.stdin.setRawMode(true);
147
+ process.stdin.resume();
148
+ process.stdin.setEncoding('utf8');
149
+ this._dataHandler = d => this._onData(d);
150
+ this._resizeHandler = () => this.render();
151
+ process.stdin.on('data', this._dataHandler);
152
+ process.stdout.on('resize', this._resizeHandler);
153
+
154
+ // Redirect console to activity log
155
+ this._origLog = console.log;
156
+ this._origErr = console.error;
157
+ console.log = (...a) => this._addLog(a.join(' '));
158
+ console.error = (...a) => this._addLog(a.join(' '));
159
+
160
+ this.render();
161
+ this.timer = setInterval(() => {
162
+ this.frame = (this.frame + 1) % SPINNER.length;
163
+ this.render();
164
+ }, 500);
165
+ }
166
+
167
+ stop() {
168
+ this.running = false;
169
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
170
+ if (this._origLog) { console.log = this._origLog; console.error = this._origErr; }
171
+ process.stdin.removeListener('data', this._dataHandler);
172
+ process.stdout.removeListener('resize', this._resizeHandler);
173
+ process.stdout.write(`${ESC}?25h${ESC}?1049l`);
174
+ try { process.stdin.setRawMode(false); } catch {}
175
+ process.stdin.pause();
176
+ }
177
+
178
+ // ── server hooks ───────────────────────────────────
179
+
180
+ onRequestStart(id, info) {
181
+ this.active.set(id, { ...info, t: timestamp(), started: Date.now(), account: null });
182
+ this.render();
183
+ }
184
+
185
+ onRequestRouted(id, info) {
186
+ const r = this.active.get(id);
187
+ if (r) r.account = info.account;
188
+ }
189
+
190
+ onRequestEnd(id, info) {
191
+ const r = this.active.get(id);
192
+ this.active.delete(id);
193
+ const dur = r ? ((Date.now() - r.started) / 1000).toFixed(1) : '?';
194
+ const acct = info.account || r?.account || '?';
195
+ this._addLog(`${info.method} ${info.path} → ${acct} (${info.status}, ${dur}s)`);
196
+ }
197
+
198
+ _addLog(msg) {
199
+ msg = msg.replace(/^\[TeamClaude\]\s*/, '');
200
+ this.log.unshift({ t: timestamp(), msg });
201
+ if (this.log.length > 200) this.log.length = 200;
202
+ if (this.running) this.render();
203
+ }
204
+
205
+ // ── input handling ─────────────────────────────────
206
+
207
+ _onData(d) {
208
+ if (d === '\x1b[A') return this._key('up');
209
+ if (d === '\x1b[B') return this._key('down');
210
+ if (d === '\x1b') return this._key('esc');
211
+ if (d === '\r' || d === '\n') return this._key('enter');
212
+ if (d === '\x03') return this._key('ctrl-c');
213
+ if (d === '\x7f' || d === '\x08') return this._key('bs');
214
+ if (d.length === 1 && d >= ' ') return this._key(d);
215
+ }
216
+
217
+ _key(k) {
218
+ if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }
219
+
220
+ switch (this.mode) {
221
+ case 'normal': this._keyNormal(k); break;
222
+ case 'select': this._keySelect(k); break;
223
+ case 'add': this._keyAdd(k); break;
224
+ case 'input': this._keyInput(k); break;
225
+ case 'order': this._keyOrder(k); break;
226
+ }
227
+ this.render();
228
+ }
229
+
230
+ /**
231
+ * The cursor-selected ACCOUNT OBJECT. The display list re-sorts live — the
232
+ * auto group follows quota data, which background responses/probes update at
233
+ * any time — so a bare display index is racy: the row under the cursor can
234
+ * become a *different account* between two keypresses. Anchoring the
235
+ * selection on the object means a reorder moves the highlight with the
236
+ * account and can never retarget a pending action (notably delete) onto a
237
+ * neighbor. Falls back to the remembered position when the anchored account
238
+ * is gone (deleted / synced away), and keeps `selIdx` synced for rendering.
239
+ */
240
+ _selected() {
241
+ const list = this._displayList();
242
+ if (list.length === 0) { this.selAcct = null; return null; }
243
+ if (this.selAcct) {
244
+ const pos = list.indexOf(this.selAcct);
245
+ if (pos >= 0) { this.selIdx = pos; return this.selAcct; }
246
+ }
247
+ this.selIdx = Math.min(Math.max(0, this.selIdx), list.length - 1);
248
+ this.selAcct = list[this.selIdx];
249
+ return this.selAcct;
250
+ }
251
+
252
+ /** Move the cursor by dir (±1) over the CURRENT display order, re-anchoring the object. */
253
+ _moveSel(dir) {
254
+ const list = this._displayList();
255
+ if (list.length === 0) return;
256
+ this._selected(); // sync selIdx to the anchored account's current position
257
+ this.selIdx = Math.min(Math.max(0, this.selIdx + dir), list.length - 1);
258
+ this.selAcct = list[this.selIdx];
259
+ }
260
+
261
+ _keyNormal(k) {
262
+ if (k === 'q') { this.stop(); this.onQuit?.(); return; }
263
+ if (k === 'a') { this.mode = 'add'; return; }
264
+ if (k === 'R') { this._doSync(); return; }
265
+
266
+ if (this.am.accounts.length === 0) return;
267
+
268
+ // ↑/↓ (or k/j) move a selection cursor over the account list right here in the
269
+ // default view — no need to enter a sub-mode first.
270
+ if (k === 'up' || k === 'k') this._moveSel(-1);
271
+ else if (k === 'down' || k === 'j') this._moveSel(+1);
272
+ // The action keys act DIRECTLY on the cursor-selected account (object-anchored).
273
+ else if (k === 's') { const a = this._selected(); if (a) { this.am.currentIndex = a.index; this._addLog(`Switched to "${a.name}"`); } }
274
+ else if (k === 'e') { const a = this._selected(); if (a) this._doToggleEnabled(a.index); }
275
+ else if (k === 'o') { const a = this._selected(); if (a) { this.orderAccount = a; this.mode = 'order'; } }
276
+ // Delete keeps an explicit confirmation (it's destructive): the cursor account
277
+ // is pre-selected (anchored) and Enter in select mode confirms.
278
+ else if (k === 'd') { this._selected(); this.mode = 'select'; }
279
+ }
280
+
281
+ // Select mode is now the DELETE confirmation only — switch / enable-disable /
282
+ // order act directly on the normal-mode ↑/↓ cursor. Here ↑/↓ let you re-pick
283
+ // before confirming; Enter deletes the selected account, Esc cancels.
284
+ _keySelect(k) {
285
+ if (k === 'up' || k === 'k') this._moveSel(-1);
286
+ else if (k === 'down' || k === 'j') this._moveSel(+1);
287
+ else if (k === 'enter') {
288
+ // Act on the ANCHORED account object, resolved to its live array index at
289
+ // action time (reindex-safe, and immune to a display reorder that happened
290
+ // after the cursor was placed).
291
+ const acct = this._selected();
292
+ if (acct) this._doRemove(this.am.accounts.indexOf(acct));
293
+ this.mode = 'normal';
294
+ }
295
+ else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
296
+ }
297
+
298
+ _keyOrder(k) {
299
+ if (!this.orderAccount || !this.am.accounts.includes(this.orderAccount)) {
300
+ this.mode = 'normal'; this.orderAccount = null; return;
301
+ }
302
+ if (k === 'up' || k === 'k') {
303
+ this._moveOrder(this.orderAccount, -1); // sync mutate; save is coalesced inside
304
+ this._followOrderAccount();
305
+ } else if (k === 'down' || k === 'j') {
306
+ this._moveOrder(this.orderAccount, +1);
307
+ this._followOrderAccount();
308
+ } else if (k === 'a') {
309
+ // Auto: reset the ENTIRE order — every rank is cleared and the whole
310
+ // fleet returns to automatic use-or-lose routing (weekly reset soonest
311
+ // drained first). The list re-sorts to that drain order immediately.
312
+ this._applyRanking([]);
313
+ this._addLog('Order reset: all accounts on auto (weekly-reset order)');
314
+ this._followOrderAccount();
315
+ } else if (k === 'c') {
316
+ // Clear: un-rank just the grabbed account (one keypress instead of moving
317
+ // it down past the bottom of the ranked group).
318
+ this._setAutoOrder(this.orderAccount);
319
+ this._followOrderAccount();
320
+ } else if (k === 'enter' || k === 'esc' || k === 'q') {
321
+ this.mode = 'normal'; this.orderAccount = null;
322
+ }
323
+ }
324
+
325
+ /** Keep the cursor anchored on the account being ordered as the list re-sorts. */
326
+ _followOrderAccount() {
327
+ this.selAcct = this.orderAccount;
328
+ this.selIdx = Math.max(0, this._displayList().indexOf(this.orderAccount));
329
+ }
330
+
331
+ _keyAdd(k) {
332
+ if (k === 'i') { this._doImport(); this.mode = 'normal'; }
333
+ else if (k === 'k') {
334
+ this.mode = 'input';
335
+ this.inputPrompt = 'API key';
336
+ this.inputBuf = '';
337
+ this.inputCb = v => { if (v) this._doAddKey(v); };
338
+ }
339
+ else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
340
+ }
341
+
342
+ _keyInput(k) {
343
+ if (k === 'enter') {
344
+ const cb = this.inputCb;
345
+ const v = this.inputBuf;
346
+ this.mode = 'normal'; this.inputCb = null; this.inputBuf = '';
347
+ cb?.(v);
348
+ }
349
+ else if (k === 'esc') { this.mode = 'normal'; this.inputCb = null; this.inputBuf = ''; }
350
+ else if (k === 'bs') { this.inputBuf = this.inputBuf.slice(0, -1); }
351
+ else if (k.length === 1) { this.inputBuf += k; }
352
+ }
353
+
354
+ // ── account operations ─────────────────────────────
355
+
356
+ async _doSync() {
357
+ try {
358
+ const count = await this.syncAccounts();
359
+ if (count > 0) {
360
+ this._addLog(`Synced ${count} new account(s) from config`);
361
+ } else {
362
+ this._addLog('Config reloaded, credentials refreshed');
363
+ }
364
+ } catch (e) {
365
+ this._addLog(`Sync failed: ${e.message}`);
366
+ }
367
+ // Reload also re-measures the WHOLE fleet's quota, not just the account
368
+ // list: the displayed usage drifts silently (spend from other devices or
369
+ // sessions never flows through this proxy), so R doubles as a "give me
370
+ // fresh numbers now" action. Runs after the config sync so probes use any
371
+ // just-refreshed credentials.
372
+ if (this.refreshQuota) {
373
+ try {
374
+ this._addLog('Re-measuring quota for all accounts...');
375
+ const r = await this.refreshQuota();
376
+ if (r === -1 || r == null) {
377
+ this._addLog('Quota refresh skipped — no request has flowed through the proxy yet');
378
+ } else if (r.measured === r.targets) {
379
+ this._addLog(`Quota re-measured for all ${r.measured} account(s)`);
380
+ } else {
381
+ // Honest partial result — some probes were skipped (expired token
382
+ // that would not refresh, auth error) or failed. Never report a
383
+ // blanket success while accounts silently kept stale numbers.
384
+ this._addLog(`Quota re-measured for ${r.measured}/${r.targets} account(s) — the rest failed or were skipped`);
385
+ }
386
+ } catch (e) {
387
+ this._addLog(`Quota refresh failed: ${e.message}`);
388
+ }
389
+ }
390
+ }
391
+
392
+ async _doImport() {
393
+ try {
394
+ this._addLog('Importing credentials...');
395
+ const creds = await importCredentials('~/.claude/.credentials.json');
396
+ const profile = await fetchProfile(creds.accessToken);
397
+ const profileOk = profile && !profile.error;
398
+
399
+ if (!profileOk) {
400
+ this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
401
+ }
402
+
403
+ let name;
404
+ if (profile?.email) {
405
+ name = profile.email;
406
+ const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
407
+ if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
408
+ } else {
409
+ // Pick the first FREE account-N (not `count + 1`, which collides after a
410
+ // delete, e.g. delete account-1 then the next import reuses account-2).
411
+ let n = 1;
412
+ do { name = `account-${n++}`; } while (this.config.accounts.some(a => a.name === name));
413
+ }
414
+
415
+ const entry = {
416
+ name, type: 'oauth', source: 'import',
417
+ accountUuid: profile?.accountUuid || null,
418
+ accessToken: creds.accessToken,
419
+ refreshToken: creds.refreshToken,
420
+ expiresAt: creds.expiresAt,
421
+ };
422
+
423
+ // Deduplicate: match by UUID first, then by name
424
+ let idx = profile?.accountUuid
425
+ ? this.config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
426
+ : -1;
427
+ if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
428
+
429
+ if (idx >= 0) {
430
+ // Preserve manual routing settings across a re-import (the new entry
431
+ // omits them, so a re-import would otherwise re-enable a disabled
432
+ // account / clear its priority).
433
+ const prev = this.config.accounts[idx];
434
+ if (prev.enabled !== undefined) entry.enabled = prev.enabled;
435
+ if (prev.priority !== undefined) entry.priority = prev.priority;
436
+ this.config.accounts[idx] = entry;
437
+ // Update the running account manager entry, matched by IDENTITY (the
438
+ // previous entry's UUID first, then name) — NOT the config index `idx`,
439
+ // which can point at a different live account when a tokenless config entry
440
+ // was skipped at load (config.accounts is not index-aligned with
441
+ // accountManager.accounts).
442
+ const amAcct = (prev.accountUuid && this.am.accounts.find(a => a.accountUuid === prev.accountUuid))
443
+ || this.am.accounts.find(a => a.name === prev.name);
444
+ if (amAcct) {
445
+ amAcct.credential = creds.accessToken;
446
+ amAcct.refreshToken = creds.refreshToken;
447
+ amAcct.expiresAt = creds.expiresAt;
448
+ amAcct.accountUuid = entry.accountUuid;
449
+ amAcct.name = name;
450
+ if (amAcct.status === 'error') amAcct.status = 'active';
451
+ } else {
452
+ // The matched config entry had no live AccountManager account (it was
453
+ // skipped at load — e.g. previously tokenless). Now that we have fresh
454
+ // credentials, add it so the running server can actually use it.
455
+ this.am.addAccount(entry);
456
+ }
457
+ this._addLog(`Updated account "${name}"`);
458
+ } else {
459
+ this.config.accounts.push(entry);
460
+ this.am.addAccount(entry);
461
+ this._addLog(`Imported account "${name}"`);
462
+ }
463
+
464
+ await this.saveConfig(this.config);
465
+ } catch (e) {
466
+ this._addLog(`Import failed: ${e.message}`);
467
+ }
468
+ }
469
+
470
+ async _doAddKey(apiKey) {
471
+ // First FREE api-N — a `count + 1` scheme collides after a delete (e.g. add
472
+ // api-1, api-2; delete api-1; the next add would reuse api-2). A unique name is
473
+ // the identity key for credential-less API-key accounts, so it must not clash.
474
+ let n = 1, name;
475
+ do { name = `api-${n++}`; } while (this.config.accounts.some(a => a.name === name));
476
+ this.config.accounts.push({ name, type: 'apikey', apiKey });
477
+ this.am.addAccount({ name, type: 'apikey', apiKey });
478
+ await this.saveConfig(this.config);
479
+ this._addLog(`Added API key account "${name}"`);
480
+ }
481
+
482
+ async _doRemove(idx) {
483
+ if (idx < 0 || idx >= this.am.accounts.length) return;
484
+ const acct = this.am.accounts[idx];
485
+ const name = acct.name;
486
+ const uuid = acct.accountUuid;
487
+ this.am.removeAccount(idx);
488
+ // Splice the config entry by IDENTITY, not by the AccountManager index —
489
+ // config.accounts can hold more entries than AccountManager (tokenless accounts
490
+ // are skipped at load), so the AM index may delete a different config entry.
491
+ // Two-phase (UUID first, then name): a single `uuid===c || name===c` predicate
492
+ // could match an earlier same-name entry before the real UUID match.
493
+ let cfgIdx = uuid ? this.config.accounts.findIndex(c => c.accountUuid === uuid) : -1;
494
+ if (cfgIdx < 0) cfgIdx = this.config.accounts.findIndex(c => c.name === name);
495
+ if (cfgIdx >= 0) this.config.accounts.splice(cfgIdx, 1);
496
+ // Drop a cursor anchor that pointed at the removed account; _selected()
497
+ // falls back to the remembered position on the next keypress/render.
498
+ if (this.selAcct && !this.am.accounts.includes(this.selAcct)) this.selAcct = null;
499
+ if (this.selIdx >= this.am.accounts.length) this.selIdx = Math.max(0, this.am.accounts.length - 1);
500
+ await this.saveConfig(this.config);
501
+ this._addLog(`Deleted account "${name}"`);
502
+ }
503
+
504
+ async _doToggleEnabled(idx) {
505
+ const amAcct = this.am.accounts[idx];
506
+ if (!amAcct) return;
507
+ const newEnabled = amAcct.enabled === false; // currently disabled → enable, else disable
508
+ // Mutate the live AccountManager (excludes/includes it in rotation, drains
509
+ // waiters on enable). Persist the flag onto the matching config entry found
510
+ // by identity — config.accounts can hold more entries than AccountManager
511
+ // (tokenless accounts are skipped at load), so the display index may not map
512
+ // 1:1 onto config.accounts. Matching by UUID/name avoids corrupting a
513
+ // different config entry.
514
+ this.am.setEnabled(amAcct, newEnabled);
515
+ // Match the config entry UUID-first, then name — an OR match could persist the
516
+ // flag onto the wrong entry when a UUID and a name resolve to different accounts.
517
+ const cfg = (amAcct.accountUuid && this.config.accounts.find(a => a.accountUuid === amAcct.accountUuid))
518
+ || this.config.accounts.find(a => a.name === amAcct.name);
519
+ if (cfg) cfg.enabled = newEnabled;
520
+ await this.saveConfig(this.config);
521
+ this._addLog(`${newEnabled ? 'Enabled' : 'Disabled'} "${amAcct.name}"`);
522
+ }
523
+
524
+ // ── ordering (priority expressed as a movable rank, not a typed number) ──────
525
+ //
526
+ // The user sets preference by moving accounts up/down, not by entering a number.
527
+ // Ranked accounts get contiguous priorities 0,1,2,… (0 = most preferred and
528
+ // shown as "#1"); accounts left unranked keep priority null and are routed by
529
+ // use-or-lose (soonest reset, then least used). So pinning a few accounts to an
530
+ // explicit order leaves the rest on the auto rotation.
531
+
532
+ /** Ranked accounts (priority set), in order: priority asc, then array index. */
533
+ _rankedSorted() {
534
+ return this.am.accounts
535
+ .filter(a => a.priority != null)
536
+ .sort((a, b) => (a.priority - b.priority) || (a.index - b.index));
537
+ }
538
+
539
+ /**
540
+ * Display order: ranked accounts first (in rank order), then unranked in
541
+ * their ACTUAL automatic drain order (weekly reset soonest first — the same
542
+ * comparator selection uses), so the list shows what "auto" will do next.
543
+ * autoCompare returns 0 on ties and Array.sort is stable, so accounts with
544
+ * no quota data keep their array order (the previous display behavior).
545
+ */
546
+ _displayList() {
547
+ const ranked = this._rankedSorted();
548
+ const set = new Set(ranked);
549
+ const auto = this.am.accounts.filter(a => !set.has(a)).sort((a, b) => this.am.autoCompare(a, b));
550
+ return [...ranked, ...auto];
551
+ }
552
+
553
+ /** 1-based rank position among the ranked accounts, or null if unranked. */
554
+ _rankOf(account) {
555
+ if (account.priority == null) return null;
556
+ return this._rankedSorted().indexOf(account) + 1;
557
+ }
558
+
559
+ /**
560
+ * Move an account up (dir < 0, more preferred) or down (dir > 0) in the order.
561
+ * Moving an unranked account up ranks it at the bottom of the ranked group;
562
+ * moving the last ranked account down un-ranks it (back to use-or-lose). After
563
+ * the move, ranked accounts are renumbered to contiguous priorities 0..n-1 (also
564
+ * normalizing any duplicate/legacy values), and every changed account is
565
+ * persisted onto its config entry (matched UUID-first, then name).
566
+ */
567
+ _moveOrder(account, dir) {
568
+ if (!this.am.accounts.includes(account)) return;
569
+ const ranked = this._rankedSorted();
570
+ const r = ranked.indexOf(account);
571
+
572
+ if (dir < 0) { // up — more preferred
573
+ if (r === -1) ranked.push(account); // unranked → lowest ranked
574
+ else if (r > 0) { const t = ranked[r - 1]; ranked[r - 1] = ranked[r]; ranked[r] = t; }
575
+ else return; // already at the top
576
+ } else { // down — less preferred
577
+ if (r === -1) return; // unranked: nothing below
578
+ else if (r < ranked.length - 1) { const t = ranked[r + 1]; ranked[r + 1] = ranked[r]; ranked[r] = t; }
579
+ else ranked.splice(r, 1); // last ranked → un-rank
580
+ }
581
+
582
+ this._applyRanking(ranked);
583
+ }
584
+
585
+ /**
586
+ * Un-rank an account back to "auto" (automatic use-or-lose routing: weekly
587
+ * reset soonest first). A no-op when it's already unranked — but the
588
+ * renumber below still normalizes any legacy/duplicate priorities.
589
+ */
590
+ _setAutoOrder(account) {
591
+ if (!this.am.accounts.includes(account)) return;
592
+ this._applyRanking(this._rankedSorted().filter(a => a !== account));
593
+ }
594
+
595
+ /**
596
+ * Persist a new ranked order: reassign contiguous priorities (ranked → its
597
+ * index; everyone else → null), mutating the live AccountManager and the
598
+ * config in lockstep, then schedule a coalesced save.
599
+ */
600
+ _applyRanking(ranked) {
601
+ const set = new Set(ranked);
602
+ for (const a of this.am.accounts) {
603
+ const want = set.has(a) ? ranked.indexOf(a) : null;
604
+ if (a.priority === want) continue;
605
+ this.am.setPriority(a, want);
606
+ // Persist onto the config entry by identity (UUID-first, then name) — the
607
+ // display index may not map 1:1 onto config.accounts. Write `null` (not a
608
+ // deleted key) to clear, so the saveConfig `{...diskAcct, ...live}` merge
609
+ // can't let a stale disk priority survive.
610
+ const cfg = (a.accountUuid && this.config.accounts.find(c => c.accountUuid === a.accountUuid))
611
+ || this.config.accounts.find(c => c.name === a.name);
612
+ if (cfg) cfg.priority = want;
613
+ }
614
+ // Persist via the coalescing saver: rapid ↑/↓ presses mutate synchronously but
615
+ // share a single in-flight write whose final pass reflects the latest order, so
616
+ // an out-of-order completion can't persist a stale snapshot.
617
+ this._scheduleSave();
618
+ }
619
+
620
+ /**
621
+ * Serialize config writes triggered by rapid order moves. Only one save runs at
622
+ * a time; further changes during it set `_saveDirty`, and the loop runs one more
623
+ * pass with the latest `this.config` when the current write finishes — so the
624
+ * last write always reflects the newest state (no lost-update race).
625
+ */
626
+ _scheduleSave() {
627
+ this._saveDirty = true;
628
+ if (this._saving) return;
629
+ this._saving = (async () => {
630
+ while (this._saveDirty) {
631
+ this._saveDirty = false;
632
+ try { await this.saveConfig(this.config); }
633
+ catch (e) { this._addLog(`Save failed: ${e.message}`); }
634
+ }
635
+ this._saving = null;
636
+ })();
637
+ }
638
+
639
+ // ── rendering ──────────────────────────────────────
640
+
641
+ render() {
642
+ if (!this.running) return;
643
+ const W = process.stdout.columns || 80;
644
+ const H = process.stdout.rows || 24;
645
+
646
+ if (W < 40 || H < 8) {
647
+ process.stdout.write(`${ESC}H${ESC}2JTerminal too small (need 40x8+)\r\n`);
648
+ return;
649
+ }
650
+
651
+ const lines = [];
652
+
653
+ // ── Header
654
+ const left = bold(' TeamClaude');
655
+ const port = this.config.proxy?.port || 3456;
656
+ const right = `Port ${port} ${green('▲')} `;
657
+ lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
658
+ lines.push(' ' + dim('─'.repeat(W - 2)));
659
+
660
+ // ── Accounts
661
+ if (this.am.accounts.length === 0) {
662
+ lines.push('');
663
+ lines.push(yellow(' No accounts configured. Press [a] to add one.'));
664
+ } else {
665
+ lines.push('');
666
+ // Three quota bars (Ses / Wk / Fbl) when the terminal is wide enough,
667
+ // two (Ses / Wk) on mid widths, one on narrow ones.
668
+ const showThree = W >= 92;
669
+ const showBoth = W >= 70;
670
+ // The +4 in each offset reserves the width of the leading row-number
671
+ // column (" NN." + its separator), so adding it doesn't push the bars
672
+ // past the terminal edge and clip the rightmost (Fbl) bar.
673
+ const bw = showThree
674
+ ? Math.max(5, Math.min(20, Math.floor((W - 66) / 3)))
675
+ : showBoth
676
+ ? Math.max(5, Math.min(20, Math.floor((W - 60) / 2)))
677
+ : Math.max(5, Math.min(20, W - 49));
678
+
679
+ // Sync the cursor position to the anchored account before drawing — the
680
+ // display order may have changed since the last frame (quota updates
681
+ // re-sort the auto group), and the highlight must follow the account.
682
+ this._selected();
683
+ const display = this._displayList();
684
+ for (let pos = 0; pos < display.length; pos++) {
685
+ lines.push(this._renderAcct(display[pos], pos, bw, showBoth, showThree));
686
+ }
687
+ }
688
+
689
+ // ── Activity header
690
+ lines.push('');
691
+ const ac = this.active.size;
692
+ const acTag = ac > 0 ? ` ${cyan(ac + ' active')}` : '';
693
+ const aHdr = ` Activity${acTag} `;
694
+ lines.push(aHdr + dim('─'.repeat(Math.max(1, W - vw(aHdr)))));
695
+
696
+ // Active requests
697
+ const now = Date.now();
698
+ for (const [, r] of this.active) {
699
+ const el = ((now - r.started) / 1000).toFixed(1);
700
+ const sp = cyan(SPINNER[this.frame]);
701
+ const a = r.account ? ` → ${r.account}` : '';
702
+ lines.push(` ${sp} ${gray(r.t)} ${r.method} ${r.path}${a} ${dim(`(${el}s...)`)}`);
703
+ }
704
+
705
+ // Completed log
706
+ const footerH = 2;
707
+ const space = Math.max(0, H - lines.length - footerH);
708
+ for (let i = 0; i < space && i < this.log.length; i++) {
709
+ lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
710
+ }
711
+
712
+ // Pad to fill
713
+ while (lines.length < H - footerH) lines.push('');
714
+
715
+ // ── Footer
716
+ lines.push(' ' + dim('─'.repeat(W - 2)));
717
+ lines.push(this._renderFooter());
718
+
719
+ // Write buffer
720
+ let buf = `${ESC}H`;
721
+ for (let i = 0; i < H; i++) {
722
+ buf += fitLine(lines[i] || '', W);
723
+ if (i < H - 1) buf += '\r\n';
724
+ }
725
+ // Show cursor only in input mode
726
+ buf += this.mode === 'input' ? `${ESC}?25h` : `${ESC}?25l`;
727
+ process.stdout.write(buf);
728
+ }
729
+
730
+ _renderAcct(a, pos, bw, showBoth, showThree = false) {
731
+ const isCur = a === this.am.accounts[this.am.currentIndex];
732
+ // Highlight the selection in both select and order modes; in order mode the
733
+ // grabbed account gets a distinct move marker.
734
+ const isSel = (this.mode === 'normal' || this.mode === 'select' || this.mode === 'order') && pos === this.selIdx;
735
+ const isMoving = this.mode === 'order' && a === this.orderAccount;
736
+
737
+ // Prefix: selection / move marker + current marker
738
+ const sel = isMoving ? cyan('⇅') : isSel ? cyan('>') : ' ';
739
+ const cur = isCur ? green('►') : ' ';
740
+
741
+ // Row number — the account's 1-based position in the displayed order, so
742
+ // the list is easy to reference at a glance. Right-aligned to 2 cols (fleet
743
+ // sizes are small; a 3rd digit just widens the gutter for 100+ accounts).
744
+ const num = gray(String(pos + 1).padStart(2) + '.');
745
+
746
+ // Name (bold if selected)
747
+ const rawName = a.name.slice(0, 12).padEnd(12);
748
+ const name = isSel ? bold(rawName) : rawName;
749
+
750
+ // Type
751
+ const type = gray(a.type.padEnd(7));
752
+
753
+ // Status — a manually-disabled account reads "disabled" regardless of its
754
+ // underlying quota status, since it's out of rotation either way.
755
+ let status;
756
+ if (a.enabled === false) {
757
+ status = gray('disabled');
758
+ } else {
759
+ switch (a.status) {
760
+ case 'active': status = isCur ? green('active') : 'active'; break;
761
+ case 'throttled': status = yellow('throttled'); break;
762
+ case 'exhausted': status = red('exhausted'); break;
763
+ case 'error': status = red('error'); break;
764
+ default: status = a.status || 'ready';
765
+ }
766
+ }
767
+ status = rpad(status, 10);
768
+
769
+ // Quota ratios — labelled by account TYPE, not by which data happens to be
770
+ // present. An OAuth (Claude Max) account always shows Ses/Wk (with "-" when
771
+ // not yet measured); an API-key account shows Tok/Req. Keying on the data
772
+ // (unified5h != null) mislabels an unmeasured OAuth account as Tok/Req.
773
+ const q = a.quota;
774
+ let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
775
+ let r3 = null, l3 = null, t3 = null;
776
+
777
+ if (a.type === 'oauth') {
778
+ r1 = q.unified5h;
779
+ r2 = q.unified7d;
780
+ t1 = q.unified5hReset;
781
+ t2 = q.unified7dReset;
782
+ // Third bar: the model-scoped weekly window (7d_oi — the top-model weekly
783
+ // limit shown as "Fable" in Claude's usage UI). Prefer 7d_oi explicitly so
784
+ // another window appearing first can't hide the Fable quota; an unknown
785
+ // label still renders, tagged by its suffix, so a renamed header keeps
786
+ // showing.
787
+ const mw = q.modelWeekly && (
788
+ ('7d_oi' in q.modelWeekly && ['7d_oi', q.modelWeekly['7d_oi']])
789
+ || Object.entries(q.modelWeekly)[0]);
790
+ l3 = 'Fbl';
791
+ if (mw) {
792
+ const [label, win] = mw;
793
+ if (label !== '7d_oi') l3 = (label.slice(3) + ' ').slice(0, 3);
794
+ r3 = win.utilization;
795
+ t3 = win.reset;
796
+ }
797
+ } else {
798
+ l1 = 'Tok';
799
+ l2 = 'Req';
800
+ r1 = (q.tokensLimit != null && q.tokensRemaining != null)
801
+ ? 1 - q.tokensRemaining / q.tokensLimit : null;
802
+ r2 = (q.requestsLimit != null && q.requestsRemaining != null)
803
+ ? 1 - q.requestsRemaining / q.requestsLimit : null;
804
+ t1 = q.resetsAt ? new Date(q.resetsAt).getTime() : null;
805
+ t2 = t1;
806
+ }
807
+
808
+ let line = ` ${sel}${cur} ${num} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
809
+ if (showBoth) {
810
+ line += ` ${l2} ${bar(r2, bw, t2)}`;
811
+ }
812
+ if (showThree) {
813
+ // API-key accounts have no third metric — pad the slot so the rank
814
+ // badges stay column-aligned across mixed account types.
815
+ line += l3 ? ` ${l3} ${bar(r3, bw, t3)}` : ' '.repeat(6 + bw);
816
+ }
817
+ // Order badge: ranked accounts show their 1-based position (#1 = most
818
+ // preferred). While ordering, unranked accounts are labelled "auto" so the
819
+ // two groups (pinned order vs use-or-lose) are visible. Appended last so it
820
+ // never disrupts the fixed-width columns / quota bars.
821
+ const rank = this._rankOf(a);
822
+ if (rank != null) line += ` ${dim('#' + rank)}`;
823
+ else if (this.mode === 'order') line += ` ${dim('auto')}`;
824
+ return line;
825
+ }
826
+
827
+ _renderFooter() {
828
+ switch (this.mode) {
829
+ case 'normal':
830
+ return ` ${dim('↑↓')} select ${bold('s')}witch ${bold('e')}nable/disable ${bold('o')}rder ${bold('d')}elete ${bold('a')}dd ${bold('R')}eload ${bold('q')}uit`;
831
+ case 'select':
832
+ return ` ${dim('↑↓')} select ${bold('Enter')} delete ${bold('Esc')} cancel`;
833
+ case 'order':
834
+ return ` ${dim('↑↓')} move (up = preferred) ${bold('a')}uto-all (reset order, weekly-reset) ${bold('c')}lear rank ${bold('Enter')}/${bold('Esc')} done`;
835
+ case 'add':
836
+ return ` ${bold('i')}mport Claude Code ${bold('k')} API key ${bold('Esc')} cancel`;
837
+ case 'input':
838
+ return ` ${this.inputPrompt}: ${this.inputBuf}█`;
839
+ default:
840
+ return '';
841
+ }
842
+ }
843
+ }