@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/LICENSE +21 -0
- package/README.md +122 -0
- package/package.json +43 -0
- package/src/account-manager.js +1459 -0
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +125 -0
- package/src/claude-env.js +65 -0
- package/src/config.js +146 -0
- package/src/crash-log.js +27 -0
- package/src/egress-guard.js +132 -0
- package/src/identity.js +96 -0
- package/src/index.js +1873 -0
- package/src/json-format-stream.js +63 -0
- package/src/mitm.js +336 -0
- package/src/model.js +276 -0
- package/src/oauth.js +459 -0
- package/src/prober.js +158 -0
- package/src/request-log.js +32 -0
- package/src/resolve-accounts.js +43 -0
- package/src/server.js +1319 -0
- package/src/service.js +241 -0
- package/src/session-tracker.js +133 -0
- package/src/status-renderer.js +316 -0
- package/src/sx.js +218 -0
- package/src/terminal-title.js +31 -0
- package/src/tool-pair-sanitize.js +193 -0
- package/src/tui-remote.js +274 -0
- package/src/tui.js +1634 -0
- package/src/updater.js +177 -0
- package/src/upstream-fetch.js +267 -0
- package/src/upstream-proxy.js +214 -0
- package/src/warmer.js +237 -0
- package/src/x509.js +166 -0
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
|
+
}
|