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/README.md +320 -0
- package/package.json +38 -0
- package/src/account-manager.js +1271 -0
- package/src/cloud.js +618 -0
- package/src/config.js +148 -0
- package/src/index.js +1726 -0
- package/src/oauth.js +347 -0
- package/src/server.js +1267 -0
- package/src/tui.js +843 -0
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
|
+
}
|