@karpeleslab/teamclaude 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
24
24
  - **Enable/disable accounts** — temporarily pause an account without removing it (`teamclaude disable`/`enable`, or `d` in the TUI); re-enabling also clears a stuck error state
25
25
  - **Quota persistence** — observed quota survives restarts (saved to a sibling state file), so rotation state isn't lost on restart; stale windows are discarded automatically
26
26
  - **Optional quota probe** — off by default; when enabled, periodically refreshes idle accounts' quota from the usage endpoint (no message spend), and surfaces the Sonnet weekly bucket
27
- - **Optional MITM proxy mode** — `teamclaude run --mitm` routes claude via an HTTPS forward proxy with a local CA so even hardcoded `api.anthropic.com` endpoints (e.g. the Claude Design MCP) get the real token injected
27
+ - **MITM proxy mode (default)** — `teamclaude run` routes claude via an HTTPS forward proxy with a local CA so even hardcoded `api.anthropic.com` endpoints (e.g. the Claude Design MCP) get the real token injected; pass `--no-mitm` for base-URL routing only
28
28
  - **Optional sx.org proxy mode** — off by default; set an [sx.org](https://sx.org) API key in the TUI settings screen (`g`) and TeamClaude auto-provisions a residential proxy to change the egress IP and work around IP-based `429`s. Three modes (`m` to cycle): **always** (route all upstream traffic), **on 429 only** (stay direct, fail over to the proxy after a 429), or **off** (keep the key but don't use it). TLS stays end-to-end with Anthropic (the proxy only relays ciphertext)
29
29
  - **Request logging** — optional full request/response logging for debugging
30
30
  - **Zero dependencies** — uses only Node.js built-in modules
@@ -138,6 +138,12 @@ teamclaude run
138
138
 
139
139
  `run` probes the proxy first: if it's up, Claude Code is routed through it; if it's **not** running, `claude` is launched directly so nothing breaks.
140
140
 
141
+ Since **1.1.0**, `run` defaults to [MITM forward-proxy mode](#mitm-proxy-mode-default) so even hardcoded `api.anthropic.com` endpoints (e.g. the Claude Design MCP) are intercepted. To keep the previous base-URL-only behavior, pass `--no-mitm`:
142
+
143
+ ```bash
144
+ teamclaude run --no-mitm
145
+ ```
146
+
141
147
  Or manually set the environment:
142
148
 
143
149
  ```bash
@@ -255,14 +261,18 @@ You can also set the interval live from the TUI settings screen (`g` → `p`), a
255
261
 
256
262
  It reads each OAuth account's utilization from Anthropic's usage endpoint (`/api/oauth/usage`), which reports quota **without consuming any message quota**. Minimum interval is 30s. Changing it takes effect on a running server immediately (no restart). When enabled, it also surfaces the **Sonnet 7-day** bucket as an extra bar in the TUI / `status` (when your plan exposes it).
257
263
 
258
- ### MITM proxy mode (optional, off by default)
264
+ ### MITM proxy mode (default)
259
265
 
260
- The normal reverse-proxy only intercepts what `ANTHROPIC_BASE_URL` covers. Some Claude Code features (e.g. the **Claude Design MCP**) use a **hardcoded** `https://api.anthropic.com` URL that ignores that variable, so they bypass the proxy. MITM proxy mode captures those too.
266
+ The plain reverse-proxy only intercepts what `ANTHROPIC_BASE_URL` covers. Some Claude Code features (e.g. the **Claude Design MCP**) use a **hardcoded** `https://api.anthropic.com` URL that ignores that variable, so they bypass the proxy. MITM proxy mode captures those too, which is why it's the default for `teamclaude run` (and the shell alias):
267
+
268
+ ```bash
269
+ teamclaude run -- <claude args...>
270
+ ```
261
271
 
262
- Run claude with the `--mitm` flag:
272
+ To opt out and route via `ANTHROPIC_BASE_URL` only, pass `--no-mitm`:
263
273
 
264
274
  ```bash
265
- teamclaude run --mitm -- <claude args...>
275
+ teamclaude run --no-mitm -- <claude args...>
266
276
  ```
267
277
 
268
278
  That launches claude pointed at teamclaude as an **HTTPS forward proxy** (`HTTPS_PROXY`) and trusts a locally-generated CA (`NODE_EXTRA_CA_CERTS`). For an intercepted host, teamclaude **dials the real upstream first, mirrors its negotiated ALPN** (HTTP/2 or HTTP/1.1), then terminates TLS toward claude with the same protocol and relays the traffic **as transparently as possible** — rewriting only what it must:
@@ -271,7 +281,7 @@ That launches claude pointed at teamclaude as an **HTTPS forward proxy** (`HTTPS
271
281
  - the **`account_uuid`** inside `metadata.user_id` → the active account's UUID (so the body agrees with the injected token);
272
282
  - and it reads `anthropic-ratelimit-*` from responses for quota.
273
283
 
274
- Everything else is copied byte-for-byte (HTTP/2 is handled with a built-in HPACK codec so the only header changed is the auth one). Any host other than the upstream is blind-tunnelled. The server accepts *both* base-URL and proxy clients at once, so instances launched with and without `--mitm` can share one server.
284
+ Everything else is copied byte-for-byte (HTTP/2 is handled with a built-in HPACK codec so the only header changed is the auth one). Any host other than the upstream is blind-tunnelled. The server accepts *both* base-URL and proxy clients at once, so instances launched with and without `--no-mitm` can share one server.
275
285
 
276
286
  Trust model:
277
287
  - The CA is generated locally, stored in the config dir, and trusted **only** by the claude process you launch via `teamclaude run` (through `NODE_EXTRA_CA_CERTS`) — it is **never** added to your system trust store. The leaf private key is `0600`; the CA private key is never written to disk.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/teamclaude",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -23,6 +23,14 @@
23
23
  "multi-account"
24
24
  ],
25
25
  "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/KarpelesLab/teamclaude.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/KarpelesLab/teamclaude/issues"
32
+ },
33
+ "homepage": "https://github.com/KarpelesLab/teamclaude#readme",
26
34
  "engines": {
27
35
  "node": ">=18.0.0"
28
36
  },
package/src/index.js CHANGED
@@ -444,13 +444,18 @@ async function envCommand() {
444
444
  async function runCommand() {
445
445
  const config = await loadOrCreateConfig();
446
446
 
447
- // Args after 'run'. teamclaude flags (e.g. --mitm) are recognized only before
448
- // an optional `--` separator; everything after `--` goes verbatim to claude.
447
+ // Args after 'run'. teamclaude flags (e.g. --no-mitm) are recognized only
448
+ // before an optional `--` separator; everything after `--` goes verbatim to
449
+ // claude. MITM forward-proxy mode is the default so hardcoded api.anthropic.com
450
+ // endpoints are intercepted too; --no-mitm opts back into base-URL-only routing.
451
+ // --mitm is still accepted (now a no-op) for backward compatibility.
449
452
  const rest = args.slice(1);
450
453
  const sep = rest.indexOf('--');
451
454
  const tcFlags = sep >= 0 ? rest.slice(0, sep) : rest;
452
- const useMitm = tcFlags.includes('--mitm');
453
- const claudeArgs = sep >= 0 ? rest.slice(sep + 1) : rest.filter(a => a !== '--mitm');
455
+ const useMitm = !tcFlags.includes('--no-mitm');
456
+ const claudeArgs = sep >= 0
457
+ ? rest.slice(sep + 1)
458
+ : rest.filter(a => a !== '--mitm' && a !== '--no-mitm');
454
459
 
455
460
  // Route through the proxy only when it's actually up; otherwise launch claude
456
461
  // directly so a stopped proxy doesn't break `claude`. This is what lets the
@@ -564,7 +569,7 @@ async function accountsCommand() {
564
569
  a.refreshToken = newTokens.refreshToken;
565
570
  a.expiresAt = newTokens.expiresAt;
566
571
  configDirty = true;
567
- } catch (err) {
572
+ } catch {
568
573
  // refresh failed — fetchProfile will report the specific error
569
574
  }
570
575
  }));
@@ -884,10 +889,11 @@ Commands:
884
889
  login OAuth login via browser
885
890
  login --api Add an API key account
886
891
  env Print env vars to use with Claude
887
- run [--mitm] [-- args...]
888
- Run Claude Code through the proxy (direct if it's down);
889
- --mitm routes via an HTTPS forward proxy + local CA so even
890
- hardcoded api.anthropic.com endpoints are intercepted
892
+ run [--no-mitm] [-- args...]
893
+ Run Claude Code through the proxy (direct if it's down).
894
+ Routes via an HTTPS forward proxy + local CA by default, so
895
+ even hardcoded api.anthropic.com endpoints are intercepted;
896
+ --no-mitm uses base-URL routing only
891
897
  alias Print a shell alias so plain 'claude' routes via the proxy
892
898
  (--install to write it to your shell rc; --uninstall to remove)
893
899
  status Show proxy & account status (live)
@@ -909,10 +915,10 @@ Options:
909
915
  --json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
910
916
  --log-to DIR Log full requests/responses to DIR (server, one file per request)
911
917
  --headless Run the server without the interactive TUI (for backgrounding)
912
- --mitm (run) route claude via the HTTPS forward proxy + local CA
918
+ --no-mitm (run) skip the forward proxy; route via ANTHROPIC_BASE_URL only
913
919
 
914
920
  The server always accepts both base-URL and proxy/CONNECT clients, so instances
915
- launched with and without --mitm can share one server.
921
+ launched with and without --no-mitm can share one server.
916
922
 
917
923
  A running server re-syncs accounts from config on POST /teamclaude/reload
918
924
  (local only). add/login/enable/disable/priority trigger it automatically.
@@ -1117,7 +1123,7 @@ function argValue(flag) {
1117
1123
  return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
1118
1124
  }
1119
1125
 
1120
- // Hostname of the configured upstream (the host MITM-intercepts under `run --mitm`).
1126
+ // Hostname of the configured upstream (the host MITM-intercepts under `run`).
1121
1127
  function upstreamHost(config) {
1122
1128
  try { return new URL(config.upstream || 'https://api.anthropic.com').hostname; }
1123
1129
  catch { return 'api.anthropic.com'; }
package/src/oauth.js CHANGED
@@ -149,15 +149,15 @@ export async function fetchProfile(accessToken) {
149
149
  }
150
150
 
151
151
  // Normalize one usage bucket from the /api/oauth/usage payload into
152
- // { utilization: 0-1, resetAt: ms-epoch }. Tolerant of field-name and
153
- // percentage/fraction and seconds/ms variations across payload versions.
152
+ // { utilization: 0-1, resetAt: ms-epoch }. The endpoint reports utilization
153
+ // as a percentage in the 0-100 range, so 1 means 1%, not 100%.
154
154
  export function normalizeUsageBucket(bucket) {
155
155
  if (!bucket || typeof bucket !== 'object') return null;
156
156
 
157
157
  const rawPct = bucket.used_percentage ?? bucket.utilization ?? bucket.usedPercentage;
158
158
  const parsedPct = typeof rawPct === 'number' ? rawPct : parseFloat(rawPct);
159
159
  const utilization = Number.isFinite(parsedPct)
160
- ? (parsedPct > 1 ? parsedPct / 100 : parsedPct)
160
+ ? parsedPct / 100
161
161
  : null;
162
162
 
163
163
  const rawReset = bucket.resets_at ?? bucket.resetsAt ?? bucket.reset_at ?? bucket.resetAt;
package/src/tui.js CHANGED
@@ -8,6 +8,7 @@ const ESC = '\x1b[';
8
8
  const RESET = `${ESC}0m`;
9
9
  const BOLD = `${ESC}1m`;
10
10
  const DIM = `${ESC}2m`;
11
+ const REV = `${ESC}7m`; // reverse video — used for the BIOS-style settings cursor
11
12
 
12
13
  const bold = s => `${BOLD}${s}${RESET}`;
13
14
  const dim = s => `${DIM}${s}${RESET}`;
@@ -129,9 +130,11 @@ export class TUI {
129
130
  this.mode = 'normal'; // normal | select | add | input | settings
130
131
  this.selAction = null; // switch | remove
131
132
  this.selIdx = 0;
133
+ this.setIdx = 0; // cursor row on the settings screen (BIOS-style nav)
132
134
  this.inputPrompt = '';
133
135
  this.inputBuf = '';
134
136
  this.inputCb = null;
137
+ this.inputReturn = 'normal'; // mode to fall back to when an input is cancelled
135
138
  this.frame = 0;
136
139
  this.running = false;
137
140
  this.timer = null;
@@ -208,6 +211,8 @@ export class TUI {
208
211
  _onData(d) {
209
212
  if (d === '\x1b[A') return this._key('up');
210
213
  if (d === '\x1b[B') return this._key('down');
214
+ if (d === '\x1b[C') return this._key('right');
215
+ if (d === '\x1b[D') return this._key('left');
211
216
  if (d === '\x1b') return this._key('esc');
212
217
  if (d === '\r' || d === '\n') return this._key('enter');
213
218
  if (d === '\x03') return this._key('ctrl-c');
@@ -241,33 +246,118 @@ export class TUI {
241
246
  }
242
247
  else if (k === 'a') { this.mode = 'add'; }
243
248
  else if (k === 'R') { this._doSync(); }
244
- else if (k === 'g' && this.sx) { this.mode = 'settings'; this._loadSxBalance(); }
249
+ else if (k === 'g' && this.sx) { this.mode = 'settings'; this.setIdx = 0; this._loadSxBalance(); }
250
+ }
251
+
252
+ // Navigable rows on the settings screen, top to bottom. Both the renderer and
253
+ // the key handler build this list so the cursor and the display stay in sync.
254
+ // Rows are conditional (sx.org rows only when that build feature is present),
255
+ // so always index through the returned array — never hard-code positions.
256
+ _settingsFields() {
257
+ const fields = [];
258
+
259
+ fields.push({
260
+ id: 'threshold',
261
+ label: 'Switch threshold',
262
+ hint: '←→ ±1%',
263
+ value: () => {
264
+ const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
265
+ return green(`${Math.round(thr * 100)}%`);
266
+ },
267
+ left: () => this._nudgeThreshold(-1),
268
+ right: () => this._nudgeThreshold(+1),
269
+ enter: () => this._promptInput('Switch threshold % (1-100)', v => this._doSetThreshold(v.trim())),
270
+ });
271
+
272
+ fields.push({
273
+ id: 'probe',
274
+ label: 'Quota probe',
275
+ hint: '←→ ±30s',
276
+ value: () => {
277
+ const probe = this.config.quotaProbeSeconds || 0;
278
+ return probe > 0 ? green(`${probe}s`) : gray('off (passive)');
279
+ },
280
+ left: () => this._nudgeProbe(-30),
281
+ right: () => this._nudgeProbe(+30),
282
+ enter: () => this._promptInput('Quota probe seconds (0=off, min 30)', v => this._doSetProbe(v.trim())),
283
+ });
284
+
285
+ if (this.sx) {
286
+ fields.push({
287
+ id: 'sxmode',
288
+ label: 'sx.org mode',
289
+ hint: '←→ cycle',
290
+ value: () => {
291
+ const mode = this.sx.getMode();
292
+ return mode === 'always' ? green('always')
293
+ : mode === '429' ? cyan('on 429 only')
294
+ : gray('off');
295
+ },
296
+ left: () => this._cycleSxMode(-1),
297
+ right: () => this._cycleSxMode(+1),
298
+ enter: () => this._cycleSxMode(+1),
299
+ });
300
+
301
+ fields.push({
302
+ id: 'sxkey',
303
+ label: 'sx.org API key',
304
+ hint: 'Enter to set',
305
+ value: () => {
306
+ const key = this.config.sx?.apiKey;
307
+ return key ? key.slice(0, 4) + '…' + key.slice(-4) : dim('(not set)');
308
+ },
309
+ enter: () => this._promptInput('sx.org API key', v => this._doSetSxKey(v.trim())),
310
+ });
311
+
312
+ if (this.config.sx?.apiKey) {
313
+ fields.push({
314
+ id: 'sxclear',
315
+ label: 'Clear sx.org key',
316
+ hint: 'Enter to clear',
317
+ value: () => dim('—'),
318
+ enter: () => this._doClearSxKey(),
319
+ });
320
+ }
321
+ }
322
+
323
+ return fields;
245
324
  }
246
325
 
247
326
  _keySettings(k) {
248
- if (k === 't') {
249
- this.mode = 'input';
250
- this.inputPrompt = 'Switch threshold % (1-100)';
251
- this.inputBuf = '';
252
- this.inputCb = v => { if (v) this._doSetThreshold(v.trim()); };
253
- }
254
- else if (k === 'p') {
255
- this.mode = 'input';
256
- this.inputPrompt = 'Quota probe seconds (0=off, min 30)';
257
- this.inputBuf = '';
258
- this.inputCb = v => { if (v) this._doSetProbe(v.trim()); };
259
- }
260
- else if (k === 'k') {
261
- this.mode = 'input';
262
- this.inputPrompt = 'sx.org API key';
263
- this.inputBuf = '';
264
- this.inputCb = v => { if (v) this._doSetSxKey(v.trim()); };
265
- }
266
- else if (k === 'm') { this._doCycleSxMode(); }
267
- else if (k === 'x') { this._doClearSxKey(); }
327
+ const fields = this._settingsFields();
328
+ const n = fields.length;
329
+ if (n > 0 && this.setIdx >= n) this.setIdx = n - 1;
330
+ const f = fields[this.setIdx];
331
+
332
+ if (k === 'up' || k === 'k') this.setIdx = (this.setIdx - 1 + n) % n;
333
+ else if (k === 'down' || k === 'j') this.setIdx = (this.setIdx + 1) % n;
334
+ else if (k === 'left') f?.left?.();
335
+ else if (k === 'right') f?.right?.();
336
+ else if (k === 'enter') f?.enter?.();
268
337
  else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
269
338
  }
270
339
 
340
+ // Open the text-input prompt and return to the settings screen afterward.
341
+ _promptInput(prompt, cb) {
342
+ this.mode = 'input';
343
+ this.inputReturn = 'settings';
344
+ this.inputPrompt = prompt;
345
+ this.inputBuf = '';
346
+ this.inputCb = v => { if (v) cb(v); };
347
+ }
348
+
349
+ _nudgeThreshold(deltaPct) {
350
+ const cur = Math.round((this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98) * 100);
351
+ const next = Math.max(1, Math.min(100, cur + deltaPct));
352
+ if (next !== cur) this._doSetThreshold(String(next));
353
+ }
354
+
355
+ _nudgeProbe(deltaSec) {
356
+ const cur = this.config.quotaProbeSeconds || 0;
357
+ const next = Math.max(0, cur + deltaSec);
358
+ if (next !== cur) this._doSetProbe(String(next));
359
+ }
360
+
271
361
  async _doSetThreshold(input) {
272
362
  const pct = Number(input);
273
363
  if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
@@ -322,6 +412,7 @@ export class TUI {
322
412
  if (k === 'i') { this._doImport(); this.mode = 'normal'; }
323
413
  else if (k === 'k') {
324
414
  this.mode = 'input';
415
+ this.inputReturn = 'normal';
325
416
  this.inputPrompt = 'API key';
326
417
  this.inputBuf = '';
327
418
  this.inputCb = v => { if (v) this._doAddKey(v); };
@@ -333,10 +424,10 @@ export class TUI {
333
424
  if (k === 'enter') {
334
425
  const cb = this.inputCb;
335
426
  const v = this.inputBuf;
336
- this.mode = 'normal'; this.inputCb = null; this.inputBuf = '';
427
+ this.mode = this.inputReturn; this.inputCb = null; this.inputBuf = '';
337
428
  cb?.(v);
338
429
  }
339
- else if (k === 'esc') { this.mode = 'normal'; this.inputCb = null; this.inputBuf = ''; }
430
+ else if (k === 'esc') { this.mode = this.inputReturn; this.inputCb = null; this.inputBuf = ''; }
340
431
  else if (k === 'bs') { this.inputBuf = this.inputBuf.slice(0, -1); }
341
432
  else if (k.length === 1) { this.inputBuf += k; }
342
433
  }
@@ -383,11 +474,11 @@ export class TUI {
383
474
  if (this.running) this.render();
384
475
  }
385
476
 
386
- // Cycle off → on-429 → always. Keeps the API key, so the user can disable
387
- // sx.org without deconfiguring it.
388
- async _doCycleSxMode() {
477
+ // Cycle off → on-429 → always (dir +1) or the reverse (dir -1). Keeps the API
478
+ // key, so the user can disable sx.org without deconfiguring it.
479
+ async _cycleSxMode(dir = 1) {
389
480
  const order = ['off', '429', 'always'];
390
- const next = order[(order.indexOf(this.sx.getMode()) + 1) % order.length];
481
+ const next = order[(order.indexOf(this.sx.getMode()) + dir + order.length) % order.length];
391
482
  this.config.sx = { ...(this.config.sx || {}), mode: next };
392
483
  try { await this.saveConfig(this.config); }
393
484
  catch (e) { this._addLog(`Failed to save: ${e.message}`); }
@@ -672,37 +763,53 @@ export class TUI {
672
763
  }
673
764
 
674
765
  _renderSettings(lines) {
766
+ const fields = this._settingsFields();
767
+ if (this.setIdx >= fields.length) this.setIdx = Math.max(0, fields.length - 1);
768
+ const selId = fields[this.setIdx]?.id;
769
+ const byId = id => fields.find(f => f.id === id);
770
+
771
+ // Render a navigable setting row with a BIOS-style highlight bar on the
772
+ // cursor row. Read-only info rows pass field=null and never highlight.
773
+ const row = field => {
774
+ const selected = field && field.id === selId;
775
+ const label = (field ? field.label : '').padEnd(16);
776
+ const value = field ? field.value() : '';
777
+ if (selected) {
778
+ const hint = field.hint ? ` ${dim(field.hint)}` : '';
779
+ const inner = rpad(` ${label} ${strip(value)} `, 34);
780
+ return ` ${cyan('▸')}${REV}${inner}${RESET}${hint}`;
781
+ }
782
+ return ` ${dim(label)} ${value}`;
783
+ };
784
+ // A plain read-only info line (not selectable), aligned with the rows above.
785
+ const info = (label, value) => ` ${dim(label.padEnd(16))} ${value}`;
786
+
675
787
  lines.push('');
676
788
  // ── Rotation
677
- const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
678
789
  lines.push(bold(' Rotation') + dim(' — switch accounts when quota crosses the threshold'));
679
- lines.push(` Switch at: ${green(`${Math.round(thr * 100)}%`)} ${dim('utilization')}`);
790
+ lines.push(row(byId('threshold')));
680
791
  lines.push('');
681
792
  // ── Quota probe
682
- const probe = this.config.quotaProbeSeconds || 0;
683
793
  lines.push(bold(' Quota probe') + dim(' — refresh idle accounts from the usage endpoint'));
684
- lines.push(` Interval: ${probe > 0 ? green(`${probe}s`) : gray('off (passive)')}`);
794
+ lines.push(row(byId('probe')));
685
795
  lines.push('');
686
796
  // ── sx.org
687
797
  lines.push(bold(' sx.org proxy') + dim(' — route upstream via a residential IP (429 workaround)'));
688
798
  lines.push('');
689
799
  if (!this.sx) { lines.push(yellow(' Unavailable in this build.')); return; }
690
800
  const key = this.config.sx?.apiKey;
691
- const masked = key ? key.slice(0, 4) + '…' + key.slice(-4) : dim('(not set)');
692
801
  const mode = this.sx.getMode();
693
- const modeStr = mode === 'always' ? green('always')
694
- : mode === '429' ? cyan('on 429 only')
695
- : gray('off');
696
802
  const p = this.sx.getProxy?.();
697
803
  const proxyStr = mode === 'off' ? gray('—')
698
804
  : this.sx.isProvisioned() ? green(`${p.host}:${p.port}`)
699
805
  : key ? yellow('not provisioned')
700
806
  : gray('no key');
701
807
  const b = this.sxBalance;
702
- lines.push(` Mode: ${modeStr}`);
703
- lines.push(` API key: ${masked}`);
704
- lines.push(` Proxy: ${proxyStr}`);
705
- lines.push(` Balance: ${b ? green('$' + Number(b.balance).toFixed(4)) : dim('…')}`);
808
+ lines.push(row(byId('sxmode')));
809
+ lines.push(row(byId('sxkey')));
810
+ lines.push(info('Proxy', proxyStr));
811
+ lines.push(info('Balance', b ? green('$' + Number(b.balance).toFixed(4)) : dim('…')));
812
+ if (byId('sxclear')) lines.push(row(byId('sxclear')));
706
813
  lines.push('');
707
814
  lines.push(dim(' always tunnel ALL upstream traffic through sx.org'));
708
815
  lines.push(dim(' on 429 only retry through sx.org after a 429 (fresh IP)'));
@@ -716,7 +823,7 @@ export class TUI {
716
823
  case 'normal':
717
824
  return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('d')}isable ${bold('R')}eload ${bold('g')} settings ${bold('q')}uit`;
718
825
  case 'settings':
719
- return ` ${bold('t')} threshold ${bold('p')} probe ${bold('m')} sx-mode ${bold('k')} sx-key ${bold('x')} clear-key ${bold('Esc')} back`;
826
+ return ` ${dim('↑↓')} navigate ${dim('←→')} change ${bold('Enter')} edit ${bold('Esc')} back`;
720
827
  case 'select': {
721
828
  const act = this.selAction === 'switch' ? 'switch'
722
829
  : this.selAction === 'toggle' ? 'enable/disable'