@rikcodes/teamclaude 1.1.13-rik.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,1634 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { importCredentials, fetchProfile } from './oauth.js';
3
+ import { sameIdentity, findUpsertTarget } from './identity.js';
4
+ import { parseProxyUrl, proxyToUrl, describeProxy, resolveUpstreamProxy, setUpstreamProxy, getUpstreamProxy } from './upstream-proxy.js';
5
+
6
+ // ── ANSI helpers ─────────────────────────────────────────────
7
+
8
+ const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'.split('');
9
+
10
+ // Repaint cadence.
11
+ //
12
+ // The spinner is drawn only alongside in-flight requests, so animating it while
13
+ // the proxy is idle wakes the process twice a second to redraw a frame nobody
14
+ // can tell apart from the last one. On a laptop that is enough to keep the
15
+ // machine from going to sleep (#134), which is a poor trade for animating
16
+ // nothing. Tick fast only while there is something to animate; otherwise tick
17
+ // slowly, just often enough that elapsed times and quota countdowns stay honest.
18
+ const SPIN_MS = 500;
19
+ const IDLE_TICK_MS = 5_000;
20
+ // Even when the composed frame is unchanged, repaint occasionally: the terminal
21
+ // is shared state, and anything that writes over it (a stray warning, a resumed
22
+ // job) would otherwise leave the screen corrupted until the next real change.
23
+ const FORCE_REPAINT_MS = 60_000;
24
+ const ESC = '\x1b[';
25
+ const RESET = `${ESC}0m`;
26
+ const BOLD = `${ESC}1m`;
27
+ const DIM = `${ESC}2m`;
28
+ const REV = `${ESC}7m`; // reverse video — used for the BIOS-style settings cursor
29
+
30
+ const bold = s => `${BOLD}${s}${RESET}`;
31
+ const dim = s => `${DIM}${s}${RESET}`;
32
+ const fg = (c, s) => `${ESC}${c}m${s}${RESET}`;
33
+ const green = s => fg(32, s);
34
+ const yellow = s => fg(33, s);
35
+ const red = s => fg(31, s);
36
+ const cyan = s => fg(36, s);
37
+ const gray = s => fg(90, s);
38
+
39
+ // Named foreground colors selectable per route (config `color`). Bright variants
40
+ // let a user distinguish several routes at a glance.
41
+ const NAMED_FG = {
42
+ red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37,
43
+ brightred: 91, brightgreen: 92, brightyellow: 93, brightblue: 94,
44
+ brightmagenta: 95, brightcyan: 96,
45
+ };
46
+ // Ordered list of the plain names, offered in the editor prompt / help.
47
+ const ROUTE_COLOR_NAMES = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan'];
48
+ const isRouteColor = name => Object.prototype.hasOwnProperty.call(NAMED_FG, String(name || '').toLowerCase());
49
+ // A paint function for a route's color, falling back to cyan for blank/unknown.
50
+ const routeColorFn = name => {
51
+ const code = NAMED_FG[String(name || '').toLowerCase()];
52
+ return code ? (s => fg(code, s)) : cyan;
53
+ };
54
+
55
+ // Per-session coloring for the activity log: a stable color derived from the
56
+ // session id lets you tell concurrent sessions apart at a glance. Palette avoids
57
+ // red (error) and gray (timestamps); includes bright variants for separation.
58
+ const SESSION_FG = [36, 35, 34, 33, 94, 95, 96, 93, 92];
59
+ const SESSION_ID_LEN = 6; // first 6 hex chars — plenty to distinguish a handful
60
+ function sessionColorCode(sid) {
61
+ let h = 0;
62
+ for (let i = 0; i < sid.length; i++) h = (h * 31 + sid.charCodeAt(i)) >>> 0;
63
+ return SESSION_FG[h % SESSION_FG.length];
64
+ }
65
+ // Fixed-width colored short id (blank-padded when there's no session, e.g. a
66
+ // telemetry request), so the activity column stays aligned.
67
+ const sessionTag = sid =>
68
+ sid ? fg(sessionColorCode(sid), sid.slice(0, SESSION_ID_LEN)) : ' '.repeat(SESSION_ID_LEN);
69
+
70
+ // Which quota-family bar (F7/S7) a route binds to, or null for a general route.
71
+ // Auto routes are named 'fable'/'sonnet'; a configured route is classified by its
72
+ // globs so e.g. `*fable*` sits next to the F7 bar.
73
+ const routeFamily = route => {
74
+ const hay = `${route.name} ${(route.match || []).join(' ')}`.toLowerCase();
75
+ if (/fable/.test(hay)) return 'fable';
76
+ if (/sonnet/.test(hay)) return 'sonnet';
77
+ return null;
78
+ };
79
+
80
+ // The inline ► for a route on an account: bold when it's the route's manual pin,
81
+ // plain when an eligible member, dim when the member is currently ineligible. The
82
+ // route's own color is kept in every case so the marker stays identifiable.
83
+ const routeGlyph = (paint, eligible, pinned) =>
84
+ pinned ? bold(paint('►')) : eligible ? paint('►') : dim(paint('►'));
85
+
86
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
87
+ const strip = s => s.replace(ANSI_RE, '');
88
+ const vw = s => strip(s).length;
89
+
90
+ function rpad(s, w) {
91
+ const gap = w - vw(s);
92
+ return gap > 0 ? s + ' '.repeat(gap) : s;
93
+ }
94
+
95
+ // Split a comma-separated input (route globs / account names) into trimmed,
96
+ // non-empty tokens. Shared by the routes editor prompts.
97
+ function splitCsv(value) {
98
+ return (value || '').split(',').map(s => s.trim()).filter(Boolean);
99
+ }
100
+
101
+ /** Truncate a string with ANSI codes to exactly w visible characters, then reset. */
102
+ function truncate(s, w) {
103
+ let visible = 0;
104
+ let out = '';
105
+ let i = 0;
106
+ while (i < s.length && visible < w) {
107
+ if (s[i] === '\x1b') {
108
+ const end = s.indexOf('m', i);
109
+ if (end >= 0) { out += s.slice(i, end + 1); i = end + 1; continue; }
110
+ }
111
+ out += s[i];
112
+ visible++;
113
+ i++;
114
+ }
115
+ return out + RESET;
116
+ }
117
+
118
+ /** Fit a line to exactly w columns: truncate if too long, pad if too short. */
119
+ function fitLine(s, w) {
120
+ const v = vw(s);
121
+ if (v > w) return truncate(s, w);
122
+ if (v < w) return s + ' '.repeat(w - v);
123
+ return s;
124
+ }
125
+
126
+ function formatReset(resetTs) {
127
+ if (!resetTs) return '';
128
+ const ms = resetTs - Date.now();
129
+ if (ms <= 0) return '';
130
+ const mins = Math.ceil(ms / 60000);
131
+ if (mins < 60) return `${mins}m`;
132
+ const hrs = Math.floor(mins / 60);
133
+ const rm = mins % 60;
134
+ if (hrs < 24) return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`;
135
+ const days = Math.floor(hrs / 24);
136
+ const rh = hrs % 24;
137
+ return rh > 0 ? `${days}d${rh}h` : `${days}d`;
138
+ }
139
+
140
+ /**
141
+ * Render a progress bar using background colors with text overlaid.
142
+ * The label (e.g. "Ses 2h30m" or "45%") is drawn on top of the bar.
143
+ */
144
+ export function bar(ratio, w = 10, resetTs) {
145
+ const rst = formatReset(resetTs);
146
+
147
+ if (ratio == null || isNaN(ratio)) {
148
+ // No data — dim background, show label or dash
149
+ const label = rst || '-';
150
+ const text = label.slice(0, w);
151
+ const pad = w - text.length;
152
+ const lp = Math.floor(pad / 2);
153
+ const rp = pad - lp;
154
+ return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
155
+ }
156
+
157
+ ratio = Math.max(0, Math.min(1, ratio));
158
+ const f = Math.round(ratio * w);
159
+ // Background colors: 42=green, 43=yellow, 41=red; 100=bright black (gray) for empty
160
+ const bg = ratio < 0.7 ? 42 : ratio < 0.9 ? 43 : 41;
161
+
162
+ // Build the label to overlay: show reset time if available, else percentage
163
+ const pct = (ratio * 100).toFixed(0) + '%';
164
+ const label = rst || pct;
165
+ const text = label.slice(0, w);
166
+ const pad = w - text.length;
167
+ const lp = Math.floor(pad / 2);
168
+ const rp = pad - lp;
169
+ const chars = (' '.repeat(lp) + text + ' '.repeat(rp));
170
+
171
+ // Split chars into filled (colored bg) and empty (gray bg) portions
172
+ const filled = chars.slice(0, f);
173
+ const empty = chars.slice(f);
174
+
175
+ // Black label on green/yellow: terminal themes commonly render those
176
+ // backgrounds light, and bright-white text disappears on them. White stays
177
+ // on red, which is dark in practically every palette.
178
+ const fgc = bg === 41 ? 97 : 30;
179
+
180
+ let out = '';
181
+ if (filled) out += `${ESC}${bg};${fgc}m${filled}`;
182
+ if (empty) out += `${ESC}100;37m${empty}`;
183
+ out += RESET;
184
+ return out;
185
+ }
186
+
187
+ function timestamp() {
188
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
189
+ }
190
+
191
+ // ── TUI class ────────────────────────────────────────────────
192
+
193
+ export class TUI {
194
+ constructor({ accountManager, config, saveConfig, syncAccounts, onQuit, sx = null, probeQuota = null, activityLogPath = null,
195
+ // Attach mode: the accounts belong to a server in another process, reached
196
+ // over its control plane. Everything that would mutate local state is off,
197
+ // and a switch becomes a request (applySwitch) instead of an assignment.
198
+ remote = false, applySwitch = null,
199
+ // Injectable so the import path can be exercised without a real credentials
200
+ // file or a live profile call.
201
+ readCredentials = importCredentials, readProfile = fetchProfile }) {
202
+ this.am = accountManager;
203
+ this.remote = remote;
204
+ this.applySwitch = applySwitch;
205
+ this.config = config;
206
+ this.saveConfig = saveConfig;
207
+ this.syncAccounts = syncAccounts;
208
+ this.onQuit = onQuit;
209
+ this.sx = sx; // sx.org proxy manager (may be null)
210
+ this.sxBalance = null; // last fetched sx.org balance, for the settings screen
211
+ this.probeQuota = probeQuota; // on-demand fleet-wide quota refresh (may be null)
212
+ this.activityLogPath = activityLogPath;
213
+ this._readCredentials = readCredentials;
214
+ this._readProfile = readProfile;
215
+ this._activityStream = null;
216
+
217
+ this.log = []; // completed activity entries
218
+ this.active = new Map(); // in-flight requests
219
+ this.mode = 'normal'; // normal | select | add | input | settings | pick
220
+ this.pick = null; // active list picker (routes editor accounts/bucket/color)
221
+ this.pickReturn = 'routes'; // mode to fall back to when the picker closes
222
+ this.selAction = null; // switch | remove | toggle
223
+ this.selIdx = 0;
224
+ this.selRoute = null; // in switch mode: null = global default, else a getRoutes() entry to pin
225
+ this.selReturn = 'normal'; // mode to fall back to when select mode closes
226
+ this.setIdx = 0; // cursor row on the settings screen (BIOS-style nav)
227
+ this.blockIdx = 0; // cursor row on the blocked-models editor
228
+ this.inputPrompt = '';
229
+ this.inputBuf = '';
230
+ this.inputCb = null;
231
+ this.inputReturn = 'normal'; // mode to fall back to when an input is cancelled
232
+ this.frame = 0;
233
+ this.running = false;
234
+ this.timer = null;
235
+ // Injectable so a test can drive the repaint tick by hand instead of
236
+ // sleeping through real 500ms/5s intervals.
237
+ this._setTimeout = setTimeout;
238
+ this._origLog = null;
239
+ this._origErr = null;
240
+ }
241
+
242
+ // ── lifecycle ──────────────────────────────────────
243
+
244
+ start() {
245
+ this.running = true;
246
+ if (this.activityLogPath) {
247
+ this._activityStream = createWriteStream(this.activityLogPath, { flags: 'a' });
248
+ this._activityStream.on('error', err => {
249
+ // Swallow write errors — can't log them to the TUI without recursion
250
+ this._activityStream = null;
251
+ process.stderr.write(`[TeamClaude] activity log error: ${err.message}\n`);
252
+ });
253
+ }
254
+ process.stdout.write(`${ESC}?1049h${ESC}?25l`);
255
+ process.stdin.setRawMode(true);
256
+ process.stdin.resume();
257
+ process.stdin.setEncoding('utf8');
258
+ this._dataHandler = d => this._onData(d);
259
+ // A resize reflows the terminal itself, so the cached frame says nothing
260
+ // about what is on screen — always repaint.
261
+ this._resizeHandler = () => this.render({ force: true });
262
+ process.stdin.on('data', this._dataHandler);
263
+ process.stdout.on('resize', this._resizeHandler);
264
+
265
+ // Redirect console to activity log
266
+ this._origLog = console.log;
267
+ this._origErr = console.error;
268
+ console.log = (...a) => this._addLog(a.join(' '));
269
+ console.error = (...a) => this._addLog(a.join(' '));
270
+
271
+ this._lastFrame = null; // entering the alt screen always paints
272
+ this.render();
273
+ this._scheduleTick();
274
+ }
275
+
276
+ /** Fast while something is animating, slow when there is nothing to animate. */
277
+ _tickDelay() { return this.active.size > 0 ? SPIN_MS : IDLE_TICK_MS; }
278
+
279
+ _scheduleTick() {
280
+ if (!this.running) return;
281
+ this.timer = this._setTimeout(() => {
282
+ if (!this.running) return;
283
+ // Only advance the spinner when it is actually on screen; otherwise the
284
+ // frame counter would change every tick and defeat the repaint dedupe.
285
+ if (this.active.size > 0) this.frame = (this.frame + 1) % SPINNER.length;
286
+ this.render();
287
+ this._scheduleTick();
288
+ }, this._tickDelay());
289
+ this.timer.unref?.();
290
+ }
291
+
292
+ /**
293
+ * Re-arm the tick after the animating/idle state changes, so a request
294
+ * arriving during an idle tick starts animating now rather than up to
295
+ * IDLE_TICK_MS later.
296
+ */
297
+ _retick() {
298
+ if (!this.running) return;
299
+ if (this.timer) clearTimeout(this.timer);
300
+ this._scheduleTick();
301
+ }
302
+
303
+ stop() {
304
+ this.running = false;
305
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
306
+ if (this._origLog) { console.log = this._origLog; console.error = this._origErr; }
307
+ if (this._activityStream) { this._activityStream.end(); this._activityStream = null; }
308
+ process.stdin.removeListener('data', this._dataHandler);
309
+ process.stdout.removeListener('resize', this._resizeHandler);
310
+ process.stdout.write(`${ESC}?25h${ESC}?1049l`);
311
+ try { process.stdin.setRawMode(false); } catch {}
312
+ process.stdin.pause();
313
+ }
314
+
315
+ // ── server hooks ───────────────────────────────────
316
+
317
+ onRequestStart(id, info) {
318
+ this.active.set(id, { ...info, t: timestamp(), started: Date.now(), account: null });
319
+ this.render();
320
+ if (this.active.size === 1) this._retick(); // idle → animating
321
+ }
322
+
323
+ onRequestModel(id, info) {
324
+ const r = this.active.get(id);
325
+ if (r && info.model) { r.model = info.model; this.render(); }
326
+ }
327
+
328
+ onRequestRouted(id, info) {
329
+ const r = this.active.get(id);
330
+ if (r) r.account = info.account;
331
+ }
332
+
333
+ onRequestEnd(id, info) {
334
+ const r = this.active.get(id);
335
+ this.active.delete(id);
336
+ const dur = r ? ((Date.now() - r.started) / 1000).toFixed(1) : '?';
337
+ const acct = info.account || r?.account || '?';
338
+ const model = info.model ? ` (${info.model})` : ''; // shown when the request named a model
339
+ const sid = info.sessionId || r?.sessionId || null;
340
+ const pin = (info.pinned || r?.pinned) ? dim(' [pin]') : '';
341
+ this._addLog(`${sessionTag(sid)} ${info.method} ${info.path}${model} → ${acct}${pin} (${info.status}, ${dur}s)`);
342
+ if (this.active.size === 0) this._retick(); // animating → idle
343
+ }
344
+
345
+ _addLog(msg) {
346
+ msg = msg.replace(/^\[TeamClaude\]\s*/, '');
347
+ const t = timestamp();
348
+ this.log.unshift({ t, msg });
349
+ if (this.log.length > 200) this.log.length = 200;
350
+ if (this._activityStream) this._activityStream.write(`${t} ${strip(msg)}\n`);
351
+ if (this.running) this.render();
352
+ }
353
+
354
+ // ── input handling ─────────────────────────────────
355
+
356
+ _onData(d) {
357
+ if (d === '\x1b[A') return this._key('up');
358
+ if (d === '\x1b[B') return this._key('down');
359
+ if (d === '\x1b[C') return this._key('right');
360
+ if (d === '\x1b[D') return this._key('left');
361
+ if (d === '\x1b') return this._key('esc');
362
+ if (d === '\r' || d === '\n') return this._key('enter');
363
+ if (d === '\t') return this._key('tab');
364
+ if (d === '\x03') return this._key('ctrl-c');
365
+ if (d === '\x7f' || d === '\x08') return this._key('bs');
366
+ if (d.length === 1 && d >= ' ') return this._key(d);
367
+ }
368
+
369
+ _key(k) {
370
+ if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }
371
+
372
+ switch (this.mode) {
373
+ case 'normal': this._keyNormal(k); break;
374
+ case 'select': this._keySelect(k); break;
375
+ case 'add': this._keyAdd(k); break;
376
+ case 'input': this._keyInput(k); break;
377
+ case 'settings': this._keySettings(k); break;
378
+ case 'routes': this._keyRoutes(k); break;
379
+ case 'pick': this._keyPick(k); break;
380
+ case 'blocklist': this._keyBlocklist(k); break;
381
+ }
382
+ this.render();
383
+ }
384
+
385
+ _keyNormal(k) {
386
+ if (k === 'q') { this.stop(); this.onQuit?.(); }
387
+ else if (k === 's' && this.am.accounts.length > 0) {
388
+ // currentIndex is -1 when nothing is marked current (attach mode, when the
389
+ // server names an account that has since gone); start at the top instead.
390
+ this.mode = 'select'; this.selAction = 'switch'; this.selIdx = Math.max(0, this.am.currentIndex); this.selRoute = null; this.selReturn = 'normal';
391
+ }
392
+ else if (k === 'R') { this._doSync(); }
393
+ // The keys below all edit local state or call out to Anthropic, neither of
394
+ // which attach mode can do — the server owns both.
395
+ else if (this.remote) { /* nothing else is available here */ }
396
+ else if (k === 'd' && this.am.accounts.length > 0) {
397
+ this.mode = 'select'; this.selAction = 'toggle'; this.selIdx = this.am.currentIndex; this.selReturn = 'normal';
398
+ }
399
+ else if (k === 'p' && this.am.accounts.length > 0) { this._doProbe(); }
400
+ else if (k === 'g') { this.mode = 'settings'; this.setIdx = 0; this._loadSxBalance(); }
401
+ }
402
+
403
+ // Navigable rows on the settings screen, top to bottom. Both the renderer and
404
+ // the key handler build this list so the cursor and the display stay in sync.
405
+ // Rows are conditional (sx.org rows only when that build feature is present),
406
+ // so always index through the returned array — never hard-code positions.
407
+ _settingsFields() {
408
+ const fields = [];
409
+
410
+ fields.push({
411
+ id: 'threshold',
412
+ label: 'Switch threshold',
413
+ hint: '←→ ±1%',
414
+ value: () => {
415
+ const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
416
+ return green(`${Math.round(thr * 100)}%`);
417
+ },
418
+ left: () => this._nudgeThreshold(-1),
419
+ right: () => this._nudgeThreshold(+1),
420
+ enter: () => this._promptInput('Switch threshold % (1-100)', v => this._doSetThreshold(v.trim())),
421
+ });
422
+
423
+ fields.push({
424
+ id: 'probe',
425
+ label: 'Quota probe',
426
+ hint: '←→ ±30s',
427
+ value: () => {
428
+ const probe = this.config.quotaProbeSeconds || 0;
429
+ return probe > 0 ? green(`${probe}s`) : gray('off (passive)');
430
+ },
431
+ left: () => this._nudgeProbe(-30),
432
+ right: () => this._nudgeProbe(+30),
433
+ enter: () => this._promptInput('Quota probe seconds (0=off, min 30)', v => this._doSetProbe(v.trim())),
434
+ });
435
+
436
+ fields.push({
437
+ id: 'eventlog',
438
+ label: 'Event logging',
439
+ hint: '←→ cycle',
440
+ value: () => {
441
+ const m = this.config.eventLogging || 'hide';
442
+ return m === 'show' ? green('show')
443
+ : m === 'block' ? red('block')
444
+ : gray('hide');
445
+ },
446
+ left: () => this._cycleEventLogging(-1),
447
+ right: () => this._cycleEventLogging(+1),
448
+ enter: () => this._cycleEventLogging(+1),
449
+ });
450
+
451
+ fields.push({
452
+ id: 'routes',
453
+ label: 'Manage routing',
454
+ hint: 'Enter to open',
455
+ value: () => {
456
+ const n = (this.config.routes || []).length;
457
+ return n ? green(`${n} route${n === 1 ? '' : 's'}`) : gray('none');
458
+ },
459
+ enter: () => { this.mode = 'routes'; this.routeIdx = 0; },
460
+ });
461
+
462
+ fields.push({
463
+ id: 'blocklist',
464
+ label: 'Blocked models',
465
+ hint: 'Enter to edit',
466
+ value: () => {
467
+ const n = (this.config.blockedModels || []).length;
468
+ return n ? red(`${n} blocked`) : gray('none');
469
+ },
470
+ enter: () => { this.mode = 'blocklist'; this.blockIdx = 0; },
471
+ });
472
+
473
+ fields.push({
474
+ id: 'addAccount',
475
+ label: 'Add account',
476
+ hint: 'Enter to open',
477
+ value: () => {
478
+ const n = this.am.accounts.length;
479
+ return n ? green(`${n} account${n === 1 ? '' : 's'}`) : gray('none');
480
+ },
481
+ enter: () => { this.mode = 'add'; },
482
+ });
483
+
484
+ if (this.am.accounts.length > 0) {
485
+ fields.push({
486
+ id: 'removeAccount',
487
+ label: 'Remove account',
488
+ hint: 'Enter to pick',
489
+ value: () => dim('—'),
490
+ enter: () => { this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0; this.selReturn = 'settings'; },
491
+ });
492
+ }
493
+
494
+ fields.push({
495
+ id: 'upstreamProxy',
496
+ label: 'Upstream proxy',
497
+ hint: 'Enter to set',
498
+ value: () => {
499
+ const { proxy, source } = getUpstreamProxy();
500
+ if (!proxy) return dim('(direct)');
501
+ // Name the environment when that is where it came from: a value the
502
+ // operator did not put in the config, silently in force, is exactly the
503
+ // thing that is hard to account for later.
504
+ const via = source.startsWith('env:') ? gray(` (${source.slice(4)})`) : '';
505
+ return green(describeProxy(proxy)) + via;
506
+ },
507
+ enter: () => this._promptInput('Upstream proxy (host:port, or blank for direct)', v => this._doSetUpstreamProxy(v.trim())),
508
+ });
509
+
510
+ if (this.sx) {
511
+ fields.push({
512
+ id: 'sxmode',
513
+ label: 'sx.org mode',
514
+ hint: '←→ cycle',
515
+ value: () => {
516
+ const mode = this.sx.getMode();
517
+ return mode === 'always' ? green('always')
518
+ : mode === '429' ? cyan('on 429 only')
519
+ : gray('off');
520
+ },
521
+ left: () => this._cycleSxMode(-1),
522
+ right: () => this._cycleSxMode(+1),
523
+ enter: () => this._cycleSxMode(+1),
524
+ });
525
+
526
+ fields.push({
527
+ id: 'sxkey',
528
+ label: 'sx.org API key',
529
+ hint: 'Enter to set',
530
+ value: () => {
531
+ const key = this.config.sx?.apiKey;
532
+ return key ? key.slice(0, 4) + '…' + key.slice(-4) : dim('(not set)');
533
+ },
534
+ enter: () => this._promptInput('sx.org API key', v => this._doSetSxKey(v.trim())),
535
+ });
536
+
537
+ if (this.config.sx?.apiKey) {
538
+ fields.push({
539
+ id: 'sxclear',
540
+ label: 'Clear sx.org key',
541
+ hint: 'Enter to clear',
542
+ value: () => dim('—'),
543
+ enter: () => this._doClearSxKey(),
544
+ });
545
+ }
546
+ }
547
+
548
+ return fields;
549
+ }
550
+
551
+ _keySettings(k) {
552
+ const fields = this._settingsFields();
553
+ const n = fields.length;
554
+ if (n > 0 && this.setIdx >= n) this.setIdx = n - 1;
555
+ const f = fields[this.setIdx];
556
+
557
+ if (k === 'up' || k === 'k') this.setIdx = (this.setIdx - 1 + n) % n;
558
+ else if (k === 'down' || k === 'j') this.setIdx = (this.setIdx + 1) % n;
559
+ else if (k === 'left') f?.left?.();
560
+ else if (k === 'right') f?.right?.();
561
+ else if (k === 'enter') f?.enter?.();
562
+ else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
563
+ }
564
+
565
+ // Open the text-input prompt and return to the settings screen afterward.
566
+ _promptInput(prompt, cb) {
567
+ this.mode = 'input';
568
+ this.inputReturn = 'settings';
569
+ this.inputPrompt = prompt;
570
+ this.inputBuf = '';
571
+ this.inputCb = v => { if (v) cb(v); };
572
+ }
573
+
574
+ _nudgeThreshold(deltaPct) {
575
+ const cur = Math.round((this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98) * 100);
576
+ const next = Math.max(1, Math.min(100, cur + deltaPct));
577
+ if (next !== cur) this._doSetThreshold(String(next));
578
+ }
579
+
580
+ _nudgeProbe(deltaSec) {
581
+ const cur = this.config.quotaProbeSeconds || 0;
582
+ const next = Math.max(0, cur + deltaSec);
583
+ if (next !== cur) this._doSetProbe(String(next));
584
+ }
585
+
586
+ async _doSetThreshold(input) {
587
+ const pct = Number(input);
588
+ if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
589
+ this._addLog('Invalid threshold — enter 1–100'); this.mode = 'settings'; if (this.running) this.render(); return;
590
+ }
591
+ const v = Math.round(pct) / 100;
592
+ this.config.switchThreshold = v;
593
+ this.am.switchThreshold = v; // apply to the running rotation immediately
594
+ try { await this.saveConfig(this.config); }
595
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
596
+ this._addLog(`Switch threshold set to ${Math.round(v * 100)}%`);
597
+ this.mode = 'settings';
598
+ if (this.running) this.render();
599
+ }
600
+
601
+ async _doSetProbe(input) {
602
+ let secs = parseInt(input, 10);
603
+ if (Number.isNaN(secs) || secs < 0) {
604
+ this._addLog('Invalid interval — enter 0 (off) or seconds'); this.mode = 'settings'; if (this.running) this.render(); return;
605
+ }
606
+ if (secs > 0 && secs < 30) secs = 30; // match the CLI minimum (don't hammer the usage endpoint)
607
+ this.config.quotaProbeSeconds = secs;
608
+ try { await this.saveConfig(this.config); }
609
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
610
+ // syncAccounts re-reads disk config and reschedules the running prober live.
611
+ try { await this.syncAccounts(); }
612
+ catch (e) { this._addLog(`Reload failed: ${e.message}`); }
613
+ this._addLog(secs > 0 ? `Quota probe every ${secs}s` : 'Quota probe disabled');
614
+ this.mode = 'settings';
615
+ if (this.running) this.render();
616
+ }
617
+
618
+ _keySelect(k) {
619
+ const len = this.am.accounts.length;
620
+ if (k === 'up' || k === 'k') this.selIdx = Math.max(0, this.selIdx - 1);
621
+ else if (k === 'down' || k === 'j') this.selIdx = Math.min(len - 1, this.selIdx + 1);
622
+ // Tab / ←→ (switch only): cycle which route the pick applies to. null = the
623
+ // global default account; each getRoutes() entry = a per-route manual pin.
624
+ // ↑↓ move within the account list, so ←→ are free to move across targets.
625
+ // Pins are runtime state of the server's rotation, so attach mode — which
626
+ // can only ask for the default account — leaves these keys alone.
627
+ else if ((k === 'tab' || k === 'right') && this.selAction === 'switch' && !this.remote) this._cycleSelRoute(+1);
628
+ else if (k === 'left' && this.selAction === 'switch' && !this.remote) this._cycleSelRoute(-1);
629
+ else if (k === 'enter') {
630
+ if (this.selAction === 'switch') {
631
+ this._doSwitchSelection();
632
+ } else if (this.selAction === 'toggle') {
633
+ this._doToggleDisabled(this.selIdx);
634
+ } else {
635
+ this._doRemove(this.selIdx);
636
+ }
637
+ if (this.mode === 'select') this.mode = this.selReturn;
638
+ }
639
+ else if (k === 'esc' || k === 'q') { this.mode = this.selReturn; }
640
+ }
641
+
642
+ // Step the switch-mode pin target by `dir` through [default, ...routes],
643
+ // wrapping at both ends. A route that vanished between renders (an autocreated
644
+ // family route whose quota expired) leaves us at the default rather than
645
+ // stranding the cursor.
646
+ _cycleSelRoute(dir) {
647
+ const routes = this.am.getRoutes();
648
+ const cycle = [null, ...routes];
649
+ const at = this.selRoute ? routes.findIndex(r => r.name === this.selRoute.name) + 1 : 0;
650
+ const from = at < 1 ? 0 : at; // findIndex -1 → 0 → treat as the default entry
651
+ this.selRoute = cycle[(from + dir + cycle.length) % cycle.length];
652
+ }
653
+
654
+ // Apply an Enter in switch mode: with no route selected this sets the global
655
+ // default account; with a route selected it pins/unpins that route to the
656
+ // highlighted account. On a rejected pin we stay in select mode so the user can
657
+ // retry, rather than silently returning to normal.
658
+ _doSwitchSelection() {
659
+ const acct = this.am.accounts[this.selIdx];
660
+ // The list can shrink under the cursor between polls in attach mode. Say so
661
+ // rather than swallowing the keypress.
662
+ if (!acct) { this.mode = 'normal'; this._addLog('That account is no longer listed'); return; }
663
+ // Attach mode: the rotation lives in another process, so this is a request
664
+ // whose result the next poll reflects, not a local assignment.
665
+ if (this.applySwitch) { this.mode = 'normal'; this._doSwitchRemote(acct); return; }
666
+ if (this.selRoute === null) {
667
+ this.am.currentIndex = this.selIdx;
668
+ this._addLog(`Switched to "${acct.name}"`);
669
+ this.mode = 'normal';
670
+ return;
671
+ }
672
+ const name = this.selRoute.name;
673
+ if (this.am.getRoutePin(name) === acct) {
674
+ this.am.clearRoutePin(name); // Enter on the current pin toggles it off
675
+ this._addLog(`Unpinned route "${name}"`);
676
+ this.mode = 'normal';
677
+ return;
678
+ }
679
+ const res = this.am.setRoutePin(name, this.selIdx);
680
+ if (res.ok) {
681
+ this._addLog(`Pinned "${acct.name}" for route "${name}"`);
682
+ this.mode = 'normal';
683
+ } else {
684
+ this._addLog(`Can't pin: ${res.reason}`); // stay in select mode to retry
685
+ }
686
+ }
687
+
688
+ // Ask the running server to switch. A failure is reported as one, so the
689
+ // dashboard never implies a switch that the server refused.
690
+ async _doSwitchRemote(acct) {
691
+ try {
692
+ const res = await this.applySwitch(acct.name);
693
+ // The server resolves the name it was given and echoes what it settled on;
694
+ // prefer that over what was highlighted here. `eligible: false` means the
695
+ // switch applied to an account that cannot currently serve requests, which
696
+ // the row already shows but is worth stating at the moment it is chosen.
697
+ const name = res?.account || acct.name;
698
+ if (res?.eligible === false) {
699
+ // The server knows WHY — disabled, out of quota, outranked by a
700
+ // higher-priority account — so quote it rather than restating the
701
+ // generic case. Control characters and length are clamped: this string
702
+ // arrives over the wire and is drawn into a fixed-width frame.
703
+ // The server's reasons are phrased to follow "<name> is ...", so they are
704
+ // composed that way here too.
705
+ const given = typeof res.reason === 'string' ? res.reason.replace(/\p{C}/gu, ' ').trim().slice(0, 60) : '';
706
+ this._addLog(`Switched to "${name}" — ${given ? `it is ${given}` : 'it cannot serve requests right now'}`);
707
+ } else {
708
+ this._addLog(`Switched to "${name}"`);
709
+ }
710
+ } catch (e) {
711
+ this._addLog(`Switch failed: ${e.message}`);
712
+ }
713
+ if (this.running) this.render();
714
+ }
715
+
716
+ // The add chooser is opened from the settings screen (g → Add account), so
717
+ // every exit path returns there.
718
+ _keyAdd(k) {
719
+ if (k === 'i') { this._doImport(); this.mode = 'settings'; }
720
+ else if (k === 'k') {
721
+ this.mode = 'input';
722
+ this.inputReturn = 'settings';
723
+ this.inputPrompt = 'API key';
724
+ this.inputBuf = '';
725
+ this.inputCb = v => { if (v) this._doAddKey(v); };
726
+ }
727
+ else if (k === 'esc' || k === 'q') { this.mode = 'settings'; }
728
+ }
729
+
730
+ _keyInput(k) {
731
+ if (k === 'enter') {
732
+ const cb = this.inputCb;
733
+ const v = this.inputBuf;
734
+ this.mode = this.inputReturn; this.inputCb = null; this.inputBuf = '';
735
+ cb?.(v);
736
+ }
737
+ else if (k === 'esc') { this.mode = this.inputReturn; this.inputCb = null; this.inputBuf = ''; }
738
+ else if (k === 'bs') { this.inputBuf = this.inputBuf.slice(0, -1); }
739
+ else if (k.length === 1) { this.inputBuf += k; }
740
+ }
741
+
742
+ // ── account operations ─────────────────────────────
743
+
744
+ // On-demand fleet-wide quota refresh (the `p` key): probe every OAuth
745
+ // account's zero-spend usage endpoint once, whether or not the periodic
746
+ // probe is enabled. Fire-and-forget; progress lands in the activity log.
747
+ async _doProbe() {
748
+ if (!this.probeQuota) { this._addLog('Quota probe unavailable'); return; }
749
+ if (this._probing) return; // one refresh at a time
750
+ const n = this.am.accounts.filter(a => a.type === 'oauth' && a.credential).length;
751
+ if (n === 0) { this._addLog('No OAuth accounts to probe'); return; }
752
+ this._probing = true;
753
+ this._addLog(`Refreshing quota on ${n} account${n === 1 ? '' : 's'}...`);
754
+ try {
755
+ await this.probeQuota();
756
+ this._addLog('Quota refresh complete');
757
+ } catch (e) {
758
+ this._addLog(`Quota refresh failed: ${e.message}`);
759
+ } finally {
760
+ this._probing = false;
761
+ }
762
+ }
763
+
764
+ async _doSync() {
765
+ try {
766
+ const count = await this.syncAccounts();
767
+ if (count > 0) {
768
+ this._addLog(`Synced ${count} new account(s) from config`);
769
+ } else {
770
+ this._addLog('Config reloaded, credentials refreshed');
771
+ }
772
+ } catch (e) {
773
+ this._addLog(`Sync failed: ${e.message}`);
774
+ }
775
+ }
776
+
777
+ // ── Network settings ───────────────────────────────
778
+
779
+ /**
780
+ * Set (or clear) the egress proxy live.
781
+ *
782
+ * Applied to the running process as well as saved, so the next request uses it
783
+ * without a restart — the operator is usually here BECAUSE requests are
784
+ * failing, and "set it, then restart to find out" is a poor loop to be in.
785
+ * An empty value clears it back to a direct connection; an explicit `false`
786
+ * survives in the config as "ignore the environment too".
787
+ */
788
+ async _doSetUpstreamProxy(value) {
789
+ let parsed;
790
+ try {
791
+ parsed = parseProxyUrl(value);
792
+ } catch (e) {
793
+ this._addLog(`Invalid proxy: ${e.message}`);
794
+ this.mode = 'settings';
795
+ return;
796
+ }
797
+
798
+ if (parsed) this.config.upstreamProxy = proxyToUrl(parsed);
799
+ else delete this.config.upstreamProxy;
800
+
801
+ try { await this.saveConfig(this.config); }
802
+ catch (e) { this._addLog(`Failed to save proxy setting: ${e.message}`); }
803
+
804
+ const resolved = setUpstreamProxy(resolveUpstreamProxy(this.config));
805
+ if (resolved.proxy) this._addLog(`Upstream proxy set to ${describeProxy(resolved.proxy)}`);
806
+ else this._addLog('Upstream proxy cleared — connecting directly');
807
+ this.mode = 'settings';
808
+ }
809
+
810
+ // ── sx.org settings ────────────────────────────────
811
+
812
+ _loadSxBalance() {
813
+ this.sxBalance = null;
814
+ if (!this.sx?.apiKey) return;
815
+ this.sx.getBalance()
816
+ .then(b => { this.sxBalance = b; if (this.running) this.render(); })
817
+ .catch(() => {});
818
+ }
819
+
820
+ _sxModeLabel(m) { return m === 'always' ? 'always' : m === '429' ? 'on 429 only' : 'off'; }
821
+
822
+ async _doSetSxKey(key) {
823
+ const mode = this.config.sx?.mode || 'always';
824
+ this.config.sx = { apiKey: key, mode };
825
+ try { await this.saveConfig(this.config); }
826
+ catch (e) { this._addLog(`Failed to save sx.org key: ${e.message}`); }
827
+ this._addLog('sx.org: configuring...');
828
+ const r = await this.sx.configure(key, mode);
829
+ if (r.ok && r.proxy) this._addLog(`sx.org key saved — proxy ${r.proxy.host}:${r.proxy.port} (mode: ${this._sxModeLabel(mode)})`);
830
+ else if (r.ok) this._addLog(`sx.org key saved (mode: ${this._sxModeLabel(mode)})`);
831
+ else this._addLog(`sx.org error: ${r.error}`);
832
+ this._loadSxBalance();
833
+ this.mode = 'settings';
834
+ if (this.running) this.render();
835
+ }
836
+
837
+ // Cycle off → on-429 → always (dir +1) or the reverse (dir -1). Keeps the API
838
+ // key, so the user can disable sx.org without deconfiguring it.
839
+ async _cycleSxMode(dir = 1) {
840
+ const order = ['off', '429', 'always'];
841
+ const next = order[(order.indexOf(this.sx.getMode()) + dir + order.length) % order.length];
842
+ this.config.sx = { ...(this.config.sx || {}), mode: next };
843
+ try { await this.saveConfig(this.config); }
844
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
845
+ const r = await this.sx.setMode(next);
846
+ this._addLog(`sx.org mode: ${this._sxModeLabel(next)}${r.ok ? '' : ` — ${r.error}`}`);
847
+ if (next !== 'off') this._loadSxBalance();
848
+ if (this.running) this.render();
849
+ }
850
+
851
+ async _cycleEventLogging(dir = 1) {
852
+ // Claude Code telemetry display/handling: show → hide → block → show.
853
+ const order = ['show', 'hide', 'block'];
854
+ const cur = this.config.eventLogging || 'hide';
855
+ const next = order[(order.indexOf(cur) + dir + order.length) % order.length];
856
+ this.config.eventLogging = next; // shared config object; the server reads it live
857
+ try { await this.saveConfig(this.config); }
858
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
859
+ this._addLog(`Event logging: ${next}`);
860
+ if (this.running) this.render();
861
+ }
862
+
863
+ async _doClearSxKey() {
864
+ this.config.sx = null;
865
+ try { await this.saveConfig(this.config); }
866
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
867
+ this.sx.disable();
868
+ this.sxBalance = null;
869
+ this._addLog('sx.org key cleared');
870
+ if (this.running) this.render();
871
+ }
872
+
873
+ async _doImport() {
874
+ try {
875
+ this._addLog('Importing credentials...');
876
+ const creds = await this._readCredentials('~/.claude/.credentials.json');
877
+ const profile = await this._readProfile(creds.accessToken);
878
+ const profileOk = profile && !profile.error;
879
+
880
+ if (!profileOk) {
881
+ this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
882
+ }
883
+
884
+ let name;
885
+ if (profile?.email) {
886
+ name = profile.email;
887
+ const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
888
+ if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
889
+ } else {
890
+ const n = this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
891
+ name = `account-${n}`;
892
+ }
893
+
894
+ const entry = {
895
+ name, type: 'oauth', source: 'import',
896
+ accountUuid: profile?.accountUuid || null,
897
+ orgUuid: profile?.orgUuid || null,
898
+ orgName: profile?.orgName || null,
899
+ accessToken: creds.accessToken,
900
+ refreshToken: creds.refreshToken,
901
+ expiresAt: creds.expiresAt,
902
+ };
903
+
904
+ // Same rule as the login path: a name match counts only where it is not
905
+ // standing in for a different account+org. Both organizations of one person
906
+ // carry the same email-derived name, and overwriting on that match drops an
907
+ // account here AND rewrites the running one's identity below.
908
+ const idx = findUpsertTarget(this.config.accounts, entry);
909
+
910
+ if (idx >= 0) {
911
+ const prev = this.config.accounts[idx];
912
+ this.config.accounts[idx] = { ...prev, ...entry, name: prev.name };
913
+ // Update the running account manager entry
914
+ const amAcct = this.am.accounts.find(a => sameIdentity(a, entry)) || this.am.accounts[idx];
915
+ if (amAcct) {
916
+ amAcct.credential = creds.accessToken;
917
+ amAcct.refreshToken = creds.refreshToken;
918
+ amAcct.expiresAt = creds.expiresAt;
919
+ amAcct.accountUuid = entry.accountUuid;
920
+ amAcct.orgUuid = entry.orgUuid;
921
+ amAcct.orgName = entry.orgName;
922
+ if (amAcct.status === 'error') amAcct.status = 'active';
923
+ }
924
+ this._addLog(`Updated account "${prev.name}"`);
925
+ } else {
926
+ // New org for this person: disambiguate colliding email names with " (org)".
927
+ if (profile?.accountUuid) {
928
+ const orgLbl = a => a.orgName || (a.orgUuid ? a.orgUuid.slice(0, 8) : 'org');
929
+ const collisions = this.config.accounts.filter(
930
+ a => a.accountUuid === entry.accountUuid && !sameIdentity(a, entry)
931
+ );
932
+ if (collisions.length > 0) {
933
+ for (const c of collisions) {
934
+ if (!c.name.includes(' (')) c.name = `${c.name} (${orgLbl(c)})`;
935
+ }
936
+ entry.name = `${name} (${orgLbl(entry)})`;
937
+ }
938
+ }
939
+ this.config.accounts.push(entry);
940
+ this.am.addAccount(entry);
941
+ this._addLog(`Imported account "${entry.name}"`);
942
+ }
943
+
944
+ await this.saveConfig(this.config);
945
+ } catch (e) {
946
+ this._addLog(`Import failed: ${e.message}`);
947
+ }
948
+ }
949
+
950
+ async _doAddKey(apiKey) {
951
+ const n = this.config.accounts.filter(a => a.name.startsWith('api-')).length + 1;
952
+ const name = `api-${n}`;
953
+ this.config.accounts.push({ name, type: 'apikey', apiKey });
954
+ this.am.addAccount({ name, type: 'apikey', apiKey });
955
+ await this.saveConfig(this.config);
956
+ this._addLog(`Added API key account "${name}"`);
957
+ }
958
+
959
+ async _doRemove(idx) {
960
+ if (idx < 0 || idx >= this.am.accounts.length) return;
961
+ const name = this.am.accounts[idx].name;
962
+ this.am.removeAccount(idx);
963
+ this.config.accounts.splice(idx, 1);
964
+ if (this.selIdx >= this.am.accounts.length) this.selIdx = Math.max(0, this.am.accounts.length - 1);
965
+ await this.saveConfig(this.config);
966
+ this._addLog(`Removed account "${name}"`);
967
+ }
968
+
969
+ async _doToggleDisabled(idx) {
970
+ if (idx < 0 || idx >= this.am.accounts.length) return;
971
+ const acct = this.am.accounts[idx];
972
+ const next = !acct.disabled;
973
+ this.am.setDisabled(idx, next); // re-enabling also clears a stuck error state
974
+ // Write an explicit boolean (not delete): saveConfig merges over the on-disk
975
+ // entry, so a `delete` would leave a stale `disabled: true` from disk intact.
976
+ if (this.config.accounts[idx]) this.config.accounts[idx].disabled = next;
977
+ await this.saveConfig(this.config);
978
+ this._addLog(`${next ? 'Disabled' : 'Enabled'} account "${acct.name}"`);
979
+ }
980
+
981
+ // ── rendering ──────────────────────────────────────
982
+
983
+ render({ force = false } = {}) {
984
+ if (!this.running) return;
985
+ // Guard against re-entry: clearing an expired quota logs, and _addLog calls
986
+ // render() again — without this the nested call would render twice.
987
+ if (this._rendering) return;
988
+ this._rendering = true;
989
+ try {
990
+ this._render(force);
991
+ } finally {
992
+ this._rendering = false;
993
+ }
994
+ }
995
+
996
+ /**
997
+ * Write `buf` to the terminal unless it is byte-identical to what is already
998
+ * there. An idle proxy composes the same screen every tick, and writing it
999
+ * again costs a wake-up and a terminal round trip to change nothing.
1000
+ */
1001
+ _paint(buf, force) {
1002
+ const stale = Date.now() - (this._lastPaintAt || 0) >= FORCE_REPAINT_MS;
1003
+ if (!force && !stale && buf === this._lastFrame) return;
1004
+ this._lastFrame = buf;
1005
+ this._lastPaintAt = Date.now();
1006
+ process.stdout.write(buf);
1007
+ }
1008
+
1009
+ _render(force = false) {
1010
+ // Reset the display the instant a quota window (e.g. 5-hour session) expires,
1011
+ // instead of waiting for the next request to clear it.
1012
+ this.am.refreshExpiredQuotas();
1013
+ const W = process.stdout.columns || 80;
1014
+ const H = process.stdout.rows || 24;
1015
+
1016
+ if (W < 40 || H < 8) {
1017
+ this._paint(`${ESC}H${ESC}2JTerminal too small (need 40x8+)\r\n`, force);
1018
+ return;
1019
+ }
1020
+
1021
+ const lines = [];
1022
+
1023
+ // ── Header
1024
+ const left = bold(' TeamClaude');
1025
+ const port = this.config.proxy?.port || 3456;
1026
+ const sess = this.am.sessionStats();
1027
+ const sessStr = (sess.active || sess.known)
1028
+ ? `${sess.active} sess${this.am.distributeSessions ? green(' dist') : ''} `
1029
+ : '';
1030
+ // ▼ marks a dashboard that lost contact with the server it polls (attach
1031
+ // mode): what is on screen is the last snapshot, not the current state.
1032
+ const live = this.am.connected === false ? red('▼') : green('▲');
1033
+ const right = `${sessStr}Port ${port} ${live} `;
1034
+ lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
1035
+ lines.push(' ' + dim('─'.repeat(W - 2)));
1036
+
1037
+ const footerH = 2;
1038
+ // While a prompt is open (mode 'input') keep showing the screen it was
1039
+ // launched from, so e.g. adding a route stays on the routes screen rather
1040
+ // than flashing back to the main dashboard with just the footer prompt.
1041
+ // The add-account chooser is a settings flow, so it keeps the settings
1042
+ // screen behind its footer too (select-to-remove, by contrast, needs the
1043
+ // dashboard: the account table IS the selection UI).
1044
+ const view = this.mode === 'input' ? this.inputReturn
1045
+ : this.mode === 'add' ? 'settings'
1046
+ : this.mode;
1047
+ if (view === 'settings') {
1048
+ this._renderSettings(lines);
1049
+ } else if (view === 'routes') {
1050
+ this._renderRoutes(lines);
1051
+ } else if (view === 'pick') {
1052
+ this._renderPick(lines);
1053
+ } else if (view === 'blocklist') {
1054
+ this._renderBlocklist(lines);
1055
+ } else {
1056
+ // ── Accounts
1057
+ if (this.am.accounts.length === 0) {
1058
+ lines.push('');
1059
+ // Attach mode cannot add an account, and pointing at a key that does
1060
+ // nothing here would be worse than saying only what is known.
1061
+ lines.push(yellow(this.remote
1062
+ ? ' The server reports no accounts.'
1063
+ : ' No accounts configured. Press [g] → Add account.'));
1064
+ } else {
1065
+ lines.push('');
1066
+ const showBoth = W >= 70;
1067
+ const bw = showBoth
1068
+ ? Math.max(5, Math.min(20, Math.floor((W - 56) / 2)))
1069
+ : Math.max(5, Math.min(20, W - 45));
1070
+
1071
+ // Routes drive the inline markers; general (non-family) routes get a stable
1072
+ // column each at the row start so the marker's position identifies the route.
1073
+ const routes = this.am.getRoutes();
1074
+ const genRoutes = routes.filter(r => routeFamily(r) === null);
1075
+ // The single account each secondary bucket currently routes to (null = none
1076
+ // can serve it right now). Marked next to that account's F7/S7 bar — the
1077
+ // secondary-quota analogue of ► marking the default route's current account.
1078
+ const anyFable = this.am.accounts.some(a => a.quota.unified7dFable != null);
1079
+ const anySonnet = this.am.accounts.some(a => a.quota.unified7dSonnet != null);
1080
+ const familyTarget = {
1081
+ fable: anyFable ? this.am.previewRouteIndex('claude-fable-5') : null,
1082
+ sonnet: anySonnet ? this.am.previewRouteIndex('claude-sonnet-4-6') : null,
1083
+ };
1084
+ for (let i = 0; i < this.am.accounts.length; i++) {
1085
+ lines.push(this._renderAcct(i, bw, showBoth, routes, genRoutes, familyTarget));
1086
+ }
1087
+ }
1088
+
1089
+ // Routing is surfaced inline on each account row (see _renderAcct): a colored
1090
+ // ► marks a route the account serves — next to the F7/S7 bar for a Fable/Sonnet
1091
+ // route, at the row start for a general route — bold when it's the route's pin.
1092
+
1093
+ // ── Activity header. Attach mode sees no request traffic — the server logs
1094
+ // that in its own process — so the pane is named for what it does hold:
1095
+ // messages from the actions taken here.
1096
+ lines.push('');
1097
+ const ac = this.active.size;
1098
+ const acTag = ac > 0 ? ` ${cyan(ac + ' active')}` : '';
1099
+ const aHdr = this.remote ? ' Messages ' : ` Activity${acTag} `;
1100
+ lines.push(aHdr + dim('─'.repeat(Math.max(1, W - vw(aHdr)))));
1101
+
1102
+ // Active requests
1103
+ const now = Date.now();
1104
+ for (const [, r] of this.active) {
1105
+ const el = ((now - r.started) / 1000).toFixed(1);
1106
+ const sp = cyan(SPINNER[this.frame]);
1107
+ const m = r.model ? dim(` (${r.model})`) : ''; // filled in as soon as the model is peeked from the stream
1108
+ const pin = r.pinned ? dim(' [pin]') : '';
1109
+ const a = r.account ? ` → ${r.account}${pin}` : '';
1110
+ lines.push(` ${sp} ${gray(r.t)} ${sessionTag(r.sessionId)} ${r.method} ${r.path}${m}${a} ${dim(`(${el}s...)`)}`);
1111
+ }
1112
+
1113
+ // Completed log
1114
+ const space = Math.max(0, H - lines.length - footerH);
1115
+ for (let i = 0; i < space && i < this.log.length; i++) {
1116
+ lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
1117
+ }
1118
+ } // end non-settings body
1119
+
1120
+ // Pad to fill
1121
+ while (lines.length < H - footerH) lines.push('');
1122
+
1123
+ // ── Footer
1124
+ lines.push(' ' + dim('─'.repeat(W - 2)));
1125
+ lines.push(this._renderFooter());
1126
+
1127
+ // Write buffer
1128
+ let buf = `${ESC}H`;
1129
+ for (let i = 0; i < H; i++) {
1130
+ buf += fitLine(lines[i] || '', W);
1131
+ if (i < H - 1) buf += '\r\n';
1132
+ }
1133
+ // Show cursor only in input mode
1134
+ buf += this.mode === 'input' ? `${ESC}?25h` : `${ESC}?25l`;
1135
+ this._paint(buf, force);
1136
+ }
1137
+
1138
+ _renderAcct(idx, bw, showBoth, routes = this.am.getRoutes(), genRoutes = routes.filter(r => routeFamily(r) === null), familyTarget = {}) {
1139
+ const a = this.am.accounts[idx];
1140
+ const isCur = idx === this.am.currentIndex;
1141
+ const isSel = this.mode === 'select' && idx === this.selIdx;
1142
+
1143
+ // Prefix: selection marker + current marker
1144
+ const sel = isSel ? cyan('>') : ' ';
1145
+ const cur = isCur ? green('►') : ' ';
1146
+
1147
+ // General-route markers: one fixed column per general route (stable order), so
1148
+ // the same route always sits in the same slot across accounts. A member shows
1149
+ // its colored ►, others a blank. Family routes (fable/sonnet) are drawn by the
1150
+ // F7/S7 bars below instead.
1151
+ const memberOf = (route) => route.accounts.find(x => x.name === a.name);
1152
+ const startCells = genRoutes.map(r => {
1153
+ const m = memberOf(r);
1154
+ return m ? routeGlyph(routeColorFn(r.color), m.eligible, r.pinned === a.name) : ' ';
1155
+ });
1156
+ const startSlot = genRoutes.length ? `${startCells.join('')} ` : '';
1157
+
1158
+ // Family (Fable/Sonnet) marker for this account's F7/S7 bar: a single ► on the
1159
+ // one account that bucket currently routes to — the secondary-quota analogue of
1160
+ // the default route's ►, not one marker per eligible account. Every account
1161
+ // meters the bucket, so "membership" is meaningless here; only the live routing
1162
+ // target matters. Bold when that target is the route's manual pin; the route's
1163
+ // configured color is honored, else cyan.
1164
+ const familyMark = (fam) => {
1165
+ if (familyTarget[fam] !== idx) return ' ';
1166
+ const r = routes.find(x => routeFamily(x) === fam);
1167
+ const pinned = r ? r.pinned === a.name : false;
1168
+ return routeGlyph(routeColorFn(r?.color), true, pinned);
1169
+ };
1170
+
1171
+ // Name (bold if selected)
1172
+ const rawName = a.name.slice(0, 12).padEnd(12);
1173
+ const name = isSel ? bold(rawName) : rawName;
1174
+
1175
+ // Type
1176
+ const type = gray(a.type.padEnd(7));
1177
+
1178
+ // Status — a disabled account is shown as such regardless of its quota state.
1179
+ let status;
1180
+ if (a.disabled) {
1181
+ status = gray('disabled');
1182
+ } else switch (a.status) {
1183
+ case 'active': status = isCur ? green('active') : 'active'; break;
1184
+ case 'throttled': status = yellow('throttled'); break;
1185
+ case 'exhausted': status = red('exhausted'); break;
1186
+ case 'error': status = red('error'); break;
1187
+ default: status = a.status || 'ready';
1188
+ }
1189
+ status = rpad(status, 10);
1190
+
1191
+ // Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
1192
+ const q = a.quota;
1193
+ let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
1194
+
1195
+ if (q.unified5h != null || q.unified7d != null || q.unified7dSonnet != null || q.unified7dFable != null) {
1196
+ r1 = q.unified5h;
1197
+ r2 = q.unified7d;
1198
+ t1 = q.unified5hReset;
1199
+ t2 = q.unified7dReset;
1200
+ } else {
1201
+ l1 = 'Tok';
1202
+ l2 = 'Req';
1203
+ r1 = (q.tokensLimit != null && q.tokensRemaining != null)
1204
+ ? 1 - q.tokensRemaining / q.tokensLimit : null;
1205
+ r2 = (q.requestsLimit != null && q.requestsRemaining != null)
1206
+ ? 1 - q.requestsRemaining / q.requestsLimit : null;
1207
+ t1 = q.resetsAt ? new Date(q.resetsAt).getTime() : null;
1208
+ t2 = t1;
1209
+ }
1210
+
1211
+ let line = ` ${sel}${cur} ${startSlot}${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
1212
+ if (showBoth) {
1213
+ line += ` ${l2} ${bar(r2, bw, t2)}`;
1214
+ // Sonnet weekly bar — only shown when the usage probe has populated it. A
1215
+ // leading ► (in place of a padding space) marks a Sonnet route on this account.
1216
+ if (q.unified7dSonnet != null) {
1217
+ line += ` ${familyMark('sonnet')}S7 ${bar(q.unified7dSonnet, bw, q.unified7dSonnetReset)}`;
1218
+ }
1219
+ // Fable weekly bar — only shown when the usage probe has populated it.
1220
+ if (q.unified7dFable != null) {
1221
+ line += ` ${familyMark('fable')}F7 ${bar(q.unified7dFable, bw, q.unified7dFableReset)}`;
1222
+ }
1223
+ }
1224
+ // Explicit "disabled for these models" tag (issue #85): a family whose own
1225
+ // weekly bucket is over the switch threshold can't serve that model even
1226
+ // while the account is otherwise active. A spent shared 5h blocks everything
1227
+ // and is already conveyed by the Ses bar + status, so it's not repeated here.
1228
+ const th = this.am.switchThreshold;
1229
+ const blocked = [];
1230
+ if (q.unified7dSonnet != null && q.unified7dSonnet >= th) blocked.push('Sonnet');
1231
+ if (q.unified7dFable != null && q.unified7dFable >= th) blocked.push('Fable');
1232
+ if (blocked.length) line += ` ${red('⊘ ' + blocked.join(' '))}`;
1233
+ return line;
1234
+ }
1235
+
1236
+ _renderSettings(lines) {
1237
+ const fields = this._settingsFields();
1238
+ if (this.setIdx >= fields.length) this.setIdx = Math.max(0, fields.length - 1);
1239
+ const selId = fields[this.setIdx]?.id;
1240
+ const byId = id => fields.find(f => f.id === id);
1241
+
1242
+ // Render a navigable setting row with a BIOS-style highlight bar on the
1243
+ // cursor row. Read-only info rows pass field=null and never highlight.
1244
+ const row = field => {
1245
+ const selected = field && field.id === selId;
1246
+ const label = (field ? field.label : '').padEnd(16);
1247
+ const value = field ? field.value() : '';
1248
+ if (selected) {
1249
+ const hint = field.hint ? ` ${dim(field.hint)}` : '';
1250
+ const inner = rpad(` ${label} ${strip(value)} `, 34);
1251
+ return ` ${cyan('▸')}${REV}${inner}${RESET}${hint}`;
1252
+ }
1253
+ return ` ${dim(label)} ${value}`;
1254
+ };
1255
+ // A plain read-only info line (not selectable), aligned with the rows above.
1256
+ const info = (label, value) => ` ${dim(label.padEnd(16))} ${value}`;
1257
+
1258
+ lines.push('');
1259
+ // ── Rotation
1260
+ lines.push(bold(' Rotation') + dim(' — switch accounts when quota crosses the threshold'));
1261
+ lines.push(row(byId('threshold')));
1262
+ lines.push('');
1263
+ // ── Quota probe
1264
+ lines.push(bold(' Quota probe') + dim(' — refresh idle accounts from the usage endpoint'));
1265
+ lines.push(row(byId('probe')));
1266
+ lines.push('');
1267
+ // ── Activity log
1268
+ lines.push(bold(' Activity log') + dim(' — what to do with Claude Code\'s telemetry'));
1269
+ lines.push(row(byId('eventlog')));
1270
+ lines.push('');
1271
+ // ── Routing
1272
+ lines.push(bold(' Routing') + dim(' — pin model families to specific accounts, or block them outright'));
1273
+ lines.push(row(byId('routes')));
1274
+ lines.push(row(byId('blocklist')));
1275
+ lines.push('');
1276
+ // ── Accounts
1277
+ lines.push(bold(' Accounts') + dim(' — add (import / API key) or remove an account'));
1278
+ lines.push(row(byId('addAccount')));
1279
+ if (byId('removeAccount')) lines.push(row(byId('removeAccount')));
1280
+ lines.push('');
1281
+ // ── Network
1282
+ // Drawn before the sx.org block, which returns early when sx is unavailable:
1283
+ // this setting is the one a host behind a corporate proxy needs, and it must
1284
+ // not disappear along with an unrelated integration.
1285
+ lines.push(bold(' Network') + dim(' — how this machine reaches Anthropic'));
1286
+ lines.push(row(byId('upstreamProxy')));
1287
+ lines.push(dim(' Set when the machine has no direct route out (HTTPS_PROXY is'));
1288
+ lines.push(dim(' picked up automatically). Applies to requests, login and refresh.'));
1289
+ lines.push('');
1290
+ // ── sx.org
1291
+ lines.push(bold(' sx.org proxy') + dim(' — route upstream via a residential IP (429 workaround)'));
1292
+ lines.push('');
1293
+ if (!this.sx) { lines.push(yellow(' Unavailable in this build.')); return; }
1294
+ const key = this.config.sx?.apiKey;
1295
+ const mode = this.sx.getMode();
1296
+ const p = this.sx.getProxy?.();
1297
+ const proxyStr = mode === 'off' ? gray('—')
1298
+ : this.sx.isProvisioned() ? green(`${p.host}:${p.port}`)
1299
+ : key ? yellow('not provisioned')
1300
+ : gray('no key');
1301
+ const b = this.sxBalance;
1302
+ lines.push(row(byId('sxmode')));
1303
+ lines.push(row(byId('sxkey')));
1304
+ lines.push(info('Proxy', proxyStr));
1305
+ lines.push(info('Balance', b ? green('$' + Number(b.balance).toFixed(4)) : dim('…')));
1306
+ if (byId('sxclear')) lines.push(row(byId('sxclear')));
1307
+ lines.push('');
1308
+ lines.push(dim(' always tunnel ALL upstream traffic through sx.org'));
1309
+ lines.push(dim(' on 429 only retry through sx.org after a 429 (fresh IP)'));
1310
+ lines.push(dim(' off never use sx.org (API key is kept)'));
1311
+ lines.push('');
1312
+ lines.push(dim(' TLS stays end-to-end; residential traffic is metered by sx.org.'));
1313
+ }
1314
+
1315
+ // ── routes editor ──────────────────────────────────
1316
+
1317
+ _keyRoutes(k) {
1318
+ const routes = this.config.routes || [];
1319
+ const n = routes.length;
1320
+ if (this.routeIdx >= n) this.routeIdx = Math.max(0, n - 1);
1321
+ if ((k === 'up' || k === 'k') && n) this.routeIdx = (this.routeIdx - 1 + n) % n;
1322
+ else if ((k === 'down' || k === 'j') && n) this.routeIdx = (this.routeIdx + 1) % n;
1323
+ else if (k === 'a') this._routeEdit(null);
1324
+ else if (k === 'e' && n) this._routeEdit(routes[this.routeIdx]);
1325
+ else if (k === 'd' && n) this._routeDelete(this.routeIdx);
1326
+ else if (k === 'esc' || k === 'q') { this.mode = 'settings'; this.setIdx = 0; }
1327
+ }
1328
+
1329
+ // Prompt for one route field, prefilled, returning to the routes screen.
1330
+ // Unlike _promptInput this passes empty values through (so optional fields can
1331
+ // be left blank) and lets the caller chain the next prompt.
1332
+ _routePrompt(label, prefill, cb) {
1333
+ this.mode = 'input';
1334
+ this.inputReturn = 'routes';
1335
+ this.inputPrompt = label;
1336
+ this.inputBuf = prefill || '';
1337
+ this.inputCb = v => cb((v || '').trim());
1338
+ }
1339
+
1340
+ // A modal list picker used by the routes editor so fixed-choice fields are
1341
+ // selected rather than typed. `multi` gives a checkbox multi-select (Space
1342
+ // toggles, Enter confirms the set); otherwise it's single-select (Enter picks
1343
+ // the highlighted row). `cb` receives the chosen value(s). Esc/q cancels
1344
+ // without calling cb — which, like the text prompts, abandons the whole edit.
1345
+ _openPicker({ title, hint, items, multi, selected, cb }) {
1346
+ this.mode = 'pick';
1347
+ this.pickReturn = 'routes';
1348
+ this.pick = {
1349
+ title, hint, items, multi, cb,
1350
+ idx: multi ? 0 : Math.max(0, items.findIndex(it => it.value === (selected || ''))),
1351
+ sel: new Set(multi ? (selected || []) : []),
1352
+ };
1353
+ }
1354
+
1355
+ // Checklist of the loaded accounts. Preselects the route's current members;
1356
+ // selecting none means "all accounts" (route.accounts is then omitted).
1357
+ _pickAccounts(preselected, cb) {
1358
+ this._openPicker({
1359
+ title: 'Route accounts',
1360
+ hint: 'Space toggles — none selected = all accounts',
1361
+ multi: true,
1362
+ selected: preselected,
1363
+ items: this.am.accounts.map(a => ({ label: a.name, value: a.name })),
1364
+ cb,
1365
+ });
1366
+ }
1367
+
1368
+ // Which weekly quota bucket meters the route (auto = pick by model family).
1369
+ _pickBucket(current, cb) {
1370
+ this._openPicker({
1371
+ title: 'Quota bucket',
1372
+ hint: 'weekly bucket this route is metered against',
1373
+ multi: false,
1374
+ selected: current,
1375
+ items: [
1376
+ { label: 'auto (by model family)', value: '' },
1377
+ { label: 'unified7d (shared weekly)', value: 'unified7d' },
1378
+ { label: 'unified7dFable', value: 'unified7dFable' },
1379
+ { label: 'unified7dSonnet', value: 'unified7dSonnet' },
1380
+ ],
1381
+ cb,
1382
+ });
1383
+ }
1384
+
1385
+ // The dashboard marker color for the route (default = plain cyan).
1386
+ _pickColor(current, cb) {
1387
+ this._openPicker({
1388
+ title: 'Marker color',
1389
+ hint: 'highlights this route on the dashboard',
1390
+ multi: false,
1391
+ selected: current,
1392
+ items: [
1393
+ { label: 'default', value: '' },
1394
+ ...ROUTE_COLOR_NAMES.map(c => ({ label: c, value: c, paint: routeColorFn(c) })),
1395
+ ],
1396
+ cb,
1397
+ });
1398
+ }
1399
+
1400
+ _keyPick(k) {
1401
+ const p = this.pick;
1402
+ if (!p) { this.mode = this.pickReturn; return; }
1403
+ const len = p.items.length;
1404
+ if (k === 'up' || k === 'k') p.idx = Math.max(0, p.idx - 1);
1405
+ else if (k === 'down' || k === 'j') p.idx = Math.min(len - 1, p.idx + 1);
1406
+ else if (p.multi && (k === ' ' || k === 'x')) {
1407
+ const v = p.items[p.idx]?.value;
1408
+ if (v != null) { p.sel.has(v) ? p.sel.delete(v) : p.sel.add(v); }
1409
+ }
1410
+ else if (k === 'enter') {
1411
+ const cb = p.cb;
1412
+ this.pick = null;
1413
+ this.mode = this.pickReturn;
1414
+ if (p.multi) cb?.(p.items.filter(it => p.sel.has(it.value)).map(it => it.value));
1415
+ else cb?.(p.items[p.idx]?.value ?? '');
1416
+ }
1417
+ else if (k === 'esc' || k === 'q') { this.pick = null; this.mode = this.pickReturn; }
1418
+ }
1419
+
1420
+ _renderPick(lines) {
1421
+ const p = this.pick;
1422
+ if (!p) return;
1423
+ lines.push('');
1424
+ lines.push(bold(' ' + p.title) + (p.hint ? dim(' — ' + p.hint) : ''));
1425
+ lines.push('');
1426
+ if (!p.items.length) {
1427
+ lines.push(gray(' (no accounts loaded — a route with none set serves all)'));
1428
+ return;
1429
+ }
1430
+ p.items.forEach((it, i) => {
1431
+ const cur = i === p.idx;
1432
+ const cursor = cur ? cyan('▸') : ' ';
1433
+ const mark = p.multi
1434
+ ? (p.sel.has(it.value) ? green('[x]') : dim('[ ]'))
1435
+ : (cur ? cyan('◉') : dim('◯'));
1436
+ const paint = it.paint || (s => s);
1437
+ lines.push(` ${cursor} ${mark} ${paint(cur ? bold(it.label) : it.label)}`);
1438
+ });
1439
+ }
1440
+
1441
+ // Guided add/edit: name → glob(s) → accounts → bucket → save. `orig` is the
1442
+ // existing route being edited, or null when adding.
1443
+ _routeEdit(orig) {
1444
+ const draft = {
1445
+ match: (orig ? (Array.isArray(orig.match) ? orig.match : [orig.match]) : []).join(', '),
1446
+ accounts: (orig?.accounts || []).join(', '),
1447
+ bucket: orig?.bucket || '',
1448
+ color: orig?.color || '',
1449
+ };
1450
+ this._routePrompt('Route name', orig?.name || '', name => {
1451
+ if (!name) { this._addLog('Route name required — cancelled'); this.mode = 'routes'; return; }
1452
+ draft.name = name;
1453
+ this._routePrompt('Model glob(s), comma-separated (e.g. *fable*)', draft.match, match => {
1454
+ if (!match) { this._addLog('At least one glob required — cancelled'); this.mode = 'routes'; return; }
1455
+ draft.match = match;
1456
+ // Accounts, bucket and color are all fixed-choice, so they're pickers
1457
+ // rather than typed fields — no free text, and no giant account-name hint
1458
+ // that used to spill off the footer (issue #130). Only name and glob stay
1459
+ // typed, since those are arbitrary strings.
1460
+ this._pickAccounts(splitCsv(draft.accounts), accts => {
1461
+ draft.accounts = accts.join(', ');
1462
+ this._pickBucket(draft.bucket, bucket => {
1463
+ draft.bucket = bucket;
1464
+ this._pickColor(draft.color, color => {
1465
+ draft.color = color;
1466
+ this._routeSave(draft, orig);
1467
+ });
1468
+ });
1469
+ });
1470
+ });
1471
+ });
1472
+ }
1473
+
1474
+ async _routeSave(draft, orig) {
1475
+ const route = { name: draft.name, match: splitCsv(draft.match) };
1476
+ const accounts = splitCsv(draft.accounts);
1477
+ if (accounts.length) route.accounts = accounts;
1478
+ if (draft.bucket) route.bucket = draft.bucket;
1479
+ if (draft.color) {
1480
+ if (isRouteColor(draft.color)) route.color = draft.color.toLowerCase();
1481
+ else this._addLog(`Unknown color "${draft.color}" — using default`);
1482
+ }
1483
+
1484
+ this.config.routes = this.config.routes || [];
1485
+ const at = orig ? this.config.routes.indexOf(orig)
1486
+ : this.config.routes.findIndex(r => r.name === route.name);
1487
+ if (at >= 0) this.config.routes[at] = route; else this.config.routes.push(route);
1488
+
1489
+ this.am.setRoutes(this.config.routes); // apply to the running rotation immediately
1490
+ try { await this.saveConfig(this.config); this._addLog(`Route "${route.name}" saved`); }
1491
+ catch (e) { this._addLog(`Failed to save route: ${e.message}`); }
1492
+ this.mode = 'routes';
1493
+ this.routeIdx = at >= 0 ? at : this.config.routes.length - 1;
1494
+ if (this.running) this.render();
1495
+ }
1496
+
1497
+ async _routeDelete(idx) {
1498
+ const routes = this.config.routes || [];
1499
+ const r = routes[idx];
1500
+ if (!r) return;
1501
+ routes.splice(idx, 1);
1502
+ this.am.setRoutes(routes);
1503
+ try { await this.saveConfig(this.config); this._addLog(`Route "${r.name}" deleted`); }
1504
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
1505
+ this.routeIdx = Math.max(0, Math.min(idx, routes.length - 1));
1506
+ if (this.running) this.render();
1507
+ }
1508
+
1509
+ _keyBlocklist(k) {
1510
+ const list = this.config.blockedModels || [];
1511
+ const n = list.length;
1512
+ if (this.blockIdx >= n) this.blockIdx = Math.max(0, n - 1);
1513
+ if ((k === 'up' || k === 'k') && n) this.blockIdx = (this.blockIdx - 1 + n) % n;
1514
+ else if ((k === 'down' || k === 'j') && n) this.blockIdx = (this.blockIdx + 1) % n;
1515
+ else if (k === 'a') this._blocklistAdd();
1516
+ else if (k === 'd' && n) this._blocklistDelete(this.blockIdx);
1517
+ else if (k === 'esc' || k === 'q') { this.mode = 'settings'; this.setIdx = 0; }
1518
+ }
1519
+
1520
+ // Prompt for a model glob and add it to the blocklist, staying on the editor.
1521
+ _blocklistAdd() {
1522
+ this.mode = 'input';
1523
+ this.inputReturn = 'blocklist';
1524
+ this.inputPrompt = 'Block model glob (e.g. *fable*)';
1525
+ this.inputBuf = '';
1526
+ this.inputCb = v => this._doBlocklistAdd((v || '').trim());
1527
+ }
1528
+
1529
+ async _doBlocklistAdd(pat) {
1530
+ if (!pat) { this._addLog('Blocklist add cancelled'); return; }
1531
+ this.config.blockedModels = this.config.blockedModels || [];
1532
+ if (this.config.blockedModels.includes(pat)) { this._addLog(`"${pat}" already blocked`); return; }
1533
+ this.config.blockedModels.push(pat);
1534
+ this.blockIdx = this.config.blockedModels.length - 1;
1535
+ try { await this.saveConfig(this.config); this._addLog(`Blocked model "${pat}"`); }
1536
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
1537
+ if (this.running) this.render();
1538
+ }
1539
+
1540
+ async _blocklistDelete(idx) {
1541
+ const list = this.config.blockedModels || [];
1542
+ const pat = list[idx];
1543
+ if (pat == null) return;
1544
+ list.splice(idx, 1);
1545
+ this.blockIdx = Math.max(0, Math.min(idx, list.length - 1));
1546
+ try { await this.saveConfig(this.config); this._addLog(`Unblocked "${pat}"`); }
1547
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
1548
+ if (this.running) this.render();
1549
+ }
1550
+
1551
+ _renderBlocklist(lines) {
1552
+ const list = this.config.blockedModels || [];
1553
+ lines.push('');
1554
+ lines.push(bold(' Blocked models') + dim(' — requests whose model matches a glob are rejected, not forwarded'));
1555
+ lines.push('');
1556
+ if (!list.length) {
1557
+ lines.push(gray(' Nothing blocked. Press [a] to add a glob (e.g. *fable*).'));
1558
+ } else {
1559
+ list.forEach((pat, i) => {
1560
+ const sel = i === this.blockIdx;
1561
+ const cursor = sel ? cyan('▸') : ' ';
1562
+ lines.push(` ${cursor} ${red('✗')} ${sel ? bold(pat) : pat}`);
1563
+ });
1564
+ }
1565
+ }
1566
+
1567
+ _renderRoutes(lines) {
1568
+ const routes = this.config.routes || [];
1569
+ lines.push('');
1570
+ lines.push(bold(' Routes') + dim(' — pin model globs to specific accounts (first match wins)'));
1571
+ lines.push('');
1572
+ if (!routes.length) {
1573
+ lines.push(gray(' No routes configured. Press [a] to add one.'));
1574
+ } else {
1575
+ routes.forEach((r, i) => {
1576
+ const sel = i === this.routeIdx;
1577
+ const cursor = sel ? cyan('▸') : ' ';
1578
+ const match = (Array.isArray(r.match) ? r.match : [r.match]).join(', ');
1579
+ const accts = (r.accounts && r.accounts.length) ? r.accounts.join(' ') : dim('(all accounts)');
1580
+ const bucket = r.bucket ? dim(` [${r.bucket}]`) : '';
1581
+ const name = rpad(r.name || '(unnamed)', 14);
1582
+ lines.push(` ${cursor} ${sel ? bold(name) : name} ${cyan(rpad(match, 22))} ${dim('→')} ${accts}${bucket}`);
1583
+ });
1584
+ }
1585
+ // Auto-detected routes (read-only) for context — a family metered separately
1586
+ // with no configured route. Pin one by adding a route with the same glob.
1587
+ const auto = this.am.getRoutes().filter(r => r.autocreated);
1588
+ if (auto.length) {
1589
+ lines.push('');
1590
+ lines.push(dim(' Auto-detected (not saved):'));
1591
+ for (const r of auto) {
1592
+ lines.push(dim(` ${r.match.join(', ')} → ${r.accounts.map(a => a.name).join(' ')}`));
1593
+ }
1594
+ }
1595
+ }
1596
+
1597
+ _renderFooter() {
1598
+ switch (this.mode) {
1599
+ case 'normal':
1600
+ return this.remote
1601
+ ? ` ${bold('s')}witch ${bold('R')}eload ${bold('q')}uit`
1602
+ : ` ${bold('s')}witch ${bold('d')}isable ${bold('p')}robe quota ${bold('R')}eload ${bold('g')} settings ${bold('q')}uit`;
1603
+ case 'settings':
1604
+ return ` ${dim('↑↓')} navigate ${dim('←→')} change ${bold('Enter')} edit ${bold('Esc')} back`;
1605
+ case 'routes':
1606
+ return ` ${dim('↑↓')} select ${bold('a')}dd ${bold('e')}dit ${bold('d')}elete ${bold('Esc')} back`;
1607
+ case 'pick':
1608
+ return this.pick?.multi
1609
+ ? ` ${dim('↑↓')} move ${bold('Space')} toggle ${bold('Enter')} confirm ${bold('Esc')} cancel`
1610
+ : ` ${dim('↑↓')} move ${bold('Enter')} select ${bold('Esc')} cancel`;
1611
+ case 'blocklist':
1612
+ return ` ${dim('↑↓')} select ${bold('a')}dd ${bold('d')}elete ${bold('Esc')} back`;
1613
+ case 'select': {
1614
+ if (this.selAction === 'switch' && this.remote) {
1615
+ return ` ${dim('↑↓')} select ${bold('Enter')} switch ${bold('Esc')} cancel`;
1616
+ }
1617
+ if (this.selAction === 'switch') {
1618
+ const target = this.selRoute
1619
+ ? routeColorFn(this.selRoute.color)(`route ${this.selRoute.name}`)
1620
+ : 'default';
1621
+ return ` ${dim('↑↓')} select ${dim('←→')} target: ${target} ${bold('Enter')} pin ${bold('Esc')} cancel`;
1622
+ }
1623
+ const act = this.selAction === 'toggle' ? 'enable/disable' : 'remove';
1624
+ return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
1625
+ }
1626
+ case 'add':
1627
+ return ` ${bold('i')}mport Claude Code ${bold('k')} API key ${bold('Esc')} cancel`;
1628
+ case 'input':
1629
+ return ` ${this.inputPrompt}: ${this.inputBuf}█`;
1630
+ default:
1631
+ return '';
1632
+ }
1633
+ }
1634
+ }