@rikcodes/teamclaude 1.1.20-rik.13 → 1.1.20-rik.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.13",
3
+ "version": "1.1.20-rik.14",
4
4
  "description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -214,6 +214,13 @@ function makeAccount(acct, index) {
214
214
  hasClaudeMax: acct.hasClaudeMax ?? null,
215
215
  hasClaudePro: acct.hasClaudePro ?? null,
216
216
  priority: acct.priority || 0,
217
+ // Where this account sits in the list the TUI draws, arranged by the
218
+ // operator from the settings screen. Presentation only: nothing but
219
+ // _displayOrder in tui.js reads it, and it is emphatically NOT `priority`
220
+ // above — that one decides which account rotation spends next, and the two
221
+ // answer different questions about the same fleet. `null` means never
222
+ // placed, which sorts after every account that has been.
223
+ displayOrder: Number.isFinite(acct.displayOrder) ? acct.displayOrder : null,
217
224
  disabled: acct.disabled || false,
218
225
  maxUsage: acct.maxUsage ?? null,
219
226
  upstream: acct.upstream || null,
@@ -94,6 +94,11 @@ export async function syncAccountsFromDisk(diskConfig, memConfig, accountManager
94
94
  }
95
95
  if (diskAcct.name && mgr.name !== diskAcct.name) mgr.name = diskAcct.name;
96
96
  if (diskAcct.priority != null && mgr.priority !== diskAcct.priority) mgr.priority = diskAcct.priority;
97
+ // A list position edited on disk applies on reload for the same reason a
98
+ // priority edit does, and `null` rather than `??` so deleting the field
99
+ // puts the account back among the unplaced instead of leaving it stuck at
100
+ // the position it last held. Display only — see makeAccount.
101
+ mgr.displayOrder = Number.isFinite(diskAcct.displayOrder) ? diskAcct.displayOrder : null;
97
102
  // A cap edit applies live for the same reason priority does: it is an
98
103
  // operator decision about a running fleet, and waiting for a restart to
99
104
  // honour a budget defeats the budget.
package/src/tui.js CHANGED
@@ -213,15 +213,27 @@ const BAR_MAX = 20;
213
213
  // terminal lays the table out exactly as it did before the column could grow.
214
214
  const NAME_MIN = 12;
215
215
 
216
- // Clear space the version label needs on each side before it is drawn at all.
217
- // Below that it reads as a collision with the title or the port block.
218
- const HEAD_GAP = 2;
219
-
220
216
  // Narrowest label still worth drawing: an ellipsis and three columns of build.
221
- // Under that the header goes back to naming no build at all, which at those
217
+ // Under that the footer goes back to naming no build at all, which at those
222
218
  // widths is the honest answer.
223
219
  const HEAD_LABEL_MIN = 4;
224
220
 
221
+ // Clear space the footer keeps between the last key hint and the build label.
222
+ // Any closer and the label reads as one more hint, which is the one thing it
223
+ // must not look like — there is no key that does it.
224
+ const FOOT_GAP = 2;
225
+
226
+ // Where an account sits in the list the operator arranged — the sort key behind
227
+ // _displayOrder, written by _doMoveAccount and by nothing else.
228
+ //
229
+ // An account with no `displayOrder` has never been placed: every account on a
230
+ // config that predates the field, and every account added since the last
231
+ // arrangement. Those sort after every account that has one, which is where a
232
+ // new account already appeared back when this list was raw array order — so
233
+ // the answer to "where does the one I just logged in with go" does not change
234
+ // with the feature, and there is nothing to migrate.
235
+ const listRank = (/** @type {any} */ a) => (Number.isFinite(a?.displayOrder) ? a.displayOrder : Infinity);
236
+
225
237
  // Which pair of bars a row draws: the subscription buckets (Ses/Wk, plus the
226
238
  // S7/F7 family bars) when any unified reading exists, else the metered Tok/Req
227
239
  // pair an API-key account reports. The account row budget is drawn per
@@ -288,7 +300,7 @@ export function fitLine(s, w) {
288
300
  /** The build label at `max` columns, or '' when nothing legible fits.
289
301
  *
290
302
  * Build metadata is spent before anything is cut. A checkout labels itself
291
- * `<version>+<sha>`, and of the two it is the version the header is read for;
303
+ * `<version>+<sha>`, and of the two it is the version the footer is read for;
292
304
  * the sha only says which build of it. Shortening the sha instead would be
293
305
  * worse than losing it — four hex digits name no commit, and are read as if
294
306
  * they did.
@@ -299,7 +311,7 @@ export function fitLine(s, w) {
299
311
  * Sliced by code unit against a display-width budget — a version or a sha is
300
312
  * ASCII, and a label arriving over the wire is measured again by the caller
301
313
  * before it is placed, so a wide glyph costs the label its slot rather than
302
- * the header its width.
314
+ * the footer its width.
303
315
  * @param {string} label
304
316
  * @param {number} max */
305
317
  export function fitHeadLabel(label, max) {
@@ -490,7 +502,7 @@ export class TUI {
490
502
  this.mode = 'normal'; // normal | select | add | input | settings | pick
491
503
  this.pick = null; // active list picker (routes editor accounts/bucket/color)
492
504
  this.pickReturn = 'routes'; // mode to fall back to when the picker closes
493
- this.selAction = null; // switch | remove | toggle
505
+ this.selAction = null; // switch | remove | toggle | reorder
494
506
  this.selIdx = 0;
495
507
  this.selRoute = null; // in switch mode: null = global default, else a getRoutes() entry to pin
496
508
  this.selReturn = 'normal'; // mode to fall back to when select mode closes
@@ -900,6 +912,18 @@ export class TUI {
900
912
  });
901
913
  }
902
914
 
915
+ // Two rows is the least that can be arranged. Below that the row would open
916
+ // a screen on which no key does anything.
917
+ if (this._displayOrder().length > 1) {
918
+ fields.push({
919
+ id: 'orderAccounts',
920
+ label: 'Reorder accounts',
921
+ hint: 'Enter to arrange',
922
+ value: () => dim('—'),
923
+ enter: () => { this.mode = 'select'; this.selAction = 'reorder'; this.selIdx = this._displayOrder()[0] ?? 0; this.selReturn = 'settings'; },
924
+ });
925
+ }
926
+
903
927
  fields.push({
904
928
  id: 'upstreamProxy',
905
929
  label: 'Upstream proxy',
@@ -1063,11 +1087,22 @@ export class TUI {
1063
1087
  // can only ask for the default account — leaves these keys alone.
1064
1088
  else if ((k === 'tab' || k === 'right') && this.selAction === 'switch' && !this.remote) this._cycleSelRoute(+1);
1065
1089
  else if (k === 'left' && this.selAction === 'switch' && !this.remote) this._cycleSelRoute(-1);
1090
+ // ←→ in reorder mode move the ACCOUNT, not the cursor. ↑↓ already walk the
1091
+ // rows, so this is the pair left over, and ←→ is what "change the thing the
1092
+ // cursor is on" already means on the settings screen this is opened from.
1093
+ // The cursor does not move with the key: `selIdx` names the account being
1094
+ // dragged, and the row it marks travels with it.
1095
+ else if ((k === 'left' || k === 'h') && this.selAction === 'reorder') this._doMoveAccount(-1);
1096
+ else if ((k === 'right' || k === 'l') && this.selAction === 'reorder') this._doMoveAccount(+1);
1066
1097
  else if (k === 'enter') {
1067
1098
  if (this.selAction === 'switch') {
1068
1099
  this._doSwitchSelection();
1069
1100
  } else if (this.selAction === 'toggle') {
1070
1101
  this._doToggleDisabled(this.selIdx);
1102
+ } else if (this.selAction === 'reorder') {
1103
+ // Every move has already been saved, so Enter only means "done" — and
1104
+ // it has to be caught here, ahead of the remove branch below, which is
1105
+ // what an unlisted action falls into.
1071
1106
  } else {
1072
1107
  this._doRemove(this.selIdx);
1073
1108
  }
@@ -1481,6 +1516,50 @@ export class TUI {
1481
1516
  this._addLog(`${next ? 'Disabled' : 'Enabled'} account "${acct.name}"`);
1482
1517
  }
1483
1518
 
1519
+ /** Move the selected account `delta` rows through the list as drawn.
1520
+ *
1521
+ * The array is NOT permuted. `selIdx` is a manager index, and so are route
1522
+ * pins, session pins, `currentIndex`, `TC_ACCT` and the disable/switch CLI
1523
+ * paths — reordering `am.accounts` would silently repoint every one of them
1524
+ * at a different account. So the rows are sorted by a field instead and each
1525
+ * account keeps the slot it has held since startup.
1526
+ *
1527
+ * It is not `priority` either. That field is rotation preference and only
1528
+ * one account in a fleet usually carries a non-default value — a
1529
+ * deliberately deprioritised local backend, say. Deriving it from where a
1530
+ * row sits on screen would re-rank the fleet as a side effect of tidying the
1531
+ * display, which is a routing change nobody asked for.
1532
+ *
1533
+ * Every displayed account is renumbered from its new position rather than
1534
+ * only the two that moved: before the first move there are no numbers to
1535
+ * insert between, and a dense 0..n-1 is the form that reads in a hand-edited
1536
+ * config. Local backends are excluded throughout — _displayOrder filters
1537
+ * them out before this ever sees them, so they hold no position and their
1538
+ * array slots are simply skipped over.
1539
+ *
1540
+ * @param {number} delta rows to travel: -1 up the list, +1 down it
1541
+ */
1542
+ async _doMoveAccount(delta) {
1543
+ const order = this._displayOrder();
1544
+ const from = order.indexOf(this.selIdx);
1545
+ const to = from + delta;
1546
+ if (from < 0 || to < 0 || to >= order.length) return; // already at the end it was pushed against
1547
+ order.splice(to, 0, ...order.splice(from, 1));
1548
+ order.forEach((/** @type {number} */ mgrIdx, /** @type {number} */ pos) => {
1549
+ this.am.accounts[mgrIdx].displayOrder = pos;
1550
+ // Onto this account's own entry: a manager index is not a config index
1551
+ // (account-pairing.js), and an account whose entry the config no longer
1552
+ // holds keeps its position on screen with nothing to persist.
1553
+ const cfgIdx = configIndexFor(this.config.accounts, this.am.accounts, mgrIdx);
1554
+ if (cfgIdx >= 0) this.config.accounts[cfgIdx].displayOrder = pos;
1555
+ });
1556
+ // Nothing is logged on success. The row visibly moves, which is the whole
1557
+ // feedback a drag needs, and a held arrow key would otherwise push the
1558
+ // activity pane out from under the list being arranged.
1559
+ try { await this.saveConfig(this.config); }
1560
+ catch (/** @type {any} */ e) { this._addLog(`Failed to save: ${e.message}`); }
1561
+ }
1562
+
1484
1563
  // ── rendering ──────────────────────────────────────
1485
1564
 
1486
1565
  render({ force = false } = {}) {
@@ -1565,44 +1644,15 @@ export class TUI {
1565
1644
  // mode): what is on screen is the last snapshot, not the current state.
1566
1645
  const live = this.am.connected === false ? red('▼') : green('▲');
1567
1646
  const right = `${sessStr}Port ${port} ${live} `;
1568
- // In attach mode the dashboard names the server's build, not this process's,
1569
- // so the account manager's answer wins. It arrives sanitized (applyStatus)
1570
- // and starts empty, which keeps the label hidden until the first poll rather
1571
- // than briefly showing the local checkout's version as if it were the
1572
- // server's. A local AccountManager has neither property.
1573
- const label = this.am.versionLabel ?? this.versionLabel;
1574
- const upd = this.am.updateAvailable ?? this.updateAvailable;
1575
- const lw = vw(left), rw = vw(right);
1576
- // Columns left between the two blocks, and what the label may take of them.
1577
- // The marker is budgeted before the label is cut, so a shortened label and
1578
- // its marker still fit the room they were measured against.
1579
- const room = W - lw - rw;
1580
- const markerW = upd ? 2 : 0;
1581
- const text = label ? fitHeadLabel(label, room - 2 * HEAD_GAP - markerW) : '';
1582
- const mid = text ? dim(text) + (upd ? ` ${green('▲')}` : '') : '';
1583
- const mw = vw(mid);
1584
- // Centred on the line, not in the gap between the two blocks, so the label
1585
- // holds still as the session segment comes and goes.
1586
- const start = Math.floor((W - mw) / 2);
1587
- // Load-bearing, not cosmetic: both padding runs below would be negative
1588
- // without it, and ' '.repeat(-1) throws. Satisfying it also means the mid
1589
- // branch can never produce the over-wide line the other branch can, so the
1590
- // two are not interchangeable.
1591
- const midFits = mw > 0 && start - lw >= HEAD_GAP && (W - rw) - (start + mw) >= HEAD_GAP;
1592
- // Line-centring is a position, not a fit. The two blocks are different
1593
- // widths, so a label small enough for the gap can still be pushed inside
1594
- // one of them by where the centre of the LINE falls — and that, not width,
1595
- // is what used to drop the label every time the session segment grew,
1596
- // leaving a header that silently stopped naming the build it exists to
1597
- // name. Centre it in the GAP instead, which is exact by construction: the
1598
- // two runs below sum to `room`. The label moves when sessions come and go,
1599
- // which is the price; being able to read it is what that buys.
1600
- const gapPad = mw > 0 && room - mw >= 2 * HEAD_GAP ? Math.floor((room - mw) / 2) : -1;
1601
- lines.push(midFits
1602
- ? left + ' '.repeat(start - lw) + mid + ' '.repeat(W - rw - start - mw) + right
1603
- : gapPad >= 0
1604
- ? left + ' '.repeat(gapPad) + mid + ' '.repeat(room - mw - gapPad) + right
1605
- : left + ' '.repeat(Math.max(1, W - lw - rw)) + right);
1647
+ // Title, padding, port block — and nothing between them. The build this
1648
+ // checkout runs is named once, in the corner of the footer.
1649
+ //
1650
+ // That padding run is the whole of the line's arithmetic now, and it is
1651
+ // floored rather than trusted: a wide session segment on a narrow terminal
1652
+ // can leave the two blocks alone wider than the line, and ' '.repeat(-1)
1653
+ // throws. There the header overruns and fitLine takes its tail, as it has
1654
+ // since long before there was a label to lose.
1655
+ lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
1606
1656
  lines.push(' ' + dim('─'.repeat(W - 2)));
1607
1657
 
1608
1658
  const footerH = 2;
@@ -1798,11 +1848,21 @@ export class TUI {
1798
1848
  * Display only. `selIdx`, `currentIndex`, session pins and route entries all
1799
1849
  * stay manager indices, so nothing about selection or routing moves with the
1800
1850
  * rows — see _keySelect, which walks this order but still stores an index.
1851
+ *
1852
+ * Which is also why the operator's arrangement is a sort key and not a
1853
+ * permutation of `am.accounts`: see _doMoveAccount.
1801
1854
  */
1802
1855
  _displayOrder() {
1803
1856
  return this.am.accounts
1804
1857
  .map((/** @type {any} */ _, /** @type {number} */ i) => i)
1805
- .filter(i => !isLocalUpstream(this.am.accounts[i]));
1858
+ .filter(i => !isLocalUpstream(this.am.accounts[i]))
1859
+ .sort((/** @type {number} */ a, /** @type {number} */ b) => {
1860
+ const ra = listRank(this.am.accounts[a]);
1861
+ const rb = listRank(this.am.accounts[b]);
1862
+ // Infinity !== Infinity is false, so two unplaced accounts fall through
1863
+ // to the index rather than subtracting to NaN.
1864
+ return ra === rb ? a - b : ra - rb;
1865
+ });
1806
1866
  }
1807
1867
 
1808
1868
  /** Manager indices of the local backends, in config order. */
@@ -2052,9 +2112,10 @@ export class TUI {
2052
2112
  lines.push(row(byId('blocklist')));
2053
2113
  lines.push('');
2054
2114
  // ── Accounts
2055
- lines.push(bold(' Accounts') + dim(' — add (import / API key) or remove an account'));
2115
+ lines.push(bold(' Accounts') + dim(' — add (import / API key), remove, or set the order they list in'));
2056
2116
  lines.push(row(byId('addAccount')));
2057
2117
  if (byId('removeAccount')) lines.push(row(byId('removeAccount')));
2118
+ if (byId('orderAccounts')) lines.push(row(byId('orderAccounts')));
2058
2119
  lines.push('');
2059
2120
  // ── Network
2060
2121
  // Drawn before the sx.org block, which returns early when sx is unavailable:
@@ -2377,12 +2438,26 @@ export class TUI {
2377
2438
  }
2378
2439
  }
2379
2440
 
2380
- /** @param {number} [W] columns to compose against; only the drain line uses it */
2441
+ /** The footer line: the mode's key hints from the left, the build label set
2442
+ * against the right edge.
2443
+ * @param {number} [W] columns to compose against */
2381
2444
  _renderFooter(W = process.stdout.columns || 80) {
2382
2445
  // A restart drain outranks every mode: the keys this line would otherwise
2383
2446
  // advertise are all refused while one runs (see _key), and how far the
2384
2447
  // drain has got is the only thing on screen worth the row.
2448
+ //
2449
+ // It is also the one footer drawn without the build label or its update
2450
+ // marker. The line lives for at most the deadline, the build cannot change
2451
+ // under it, and when the restart is an update the label names the build
2452
+ // being replaced and the marker points at the thing already happening — so
2453
+ // those columns go to the escape hatch, which is what the line is read for.
2385
2454
  if (this._restartDrain) return this._restartDrainFooter(this._restartDrain, W);
2455
+ return this._footerWithVersion(this._footerHints(), W);
2456
+ }
2457
+
2458
+ /** What the keyboard does on the screen the operator is looking at. Composed
2459
+ * without regard to width: fitting it to the line is _renderFooter's job. */
2460
+ _footerHints() {
2386
2461
  switch (this.mode) {
2387
2462
  case 'normal':
2388
2463
  return this.remote
@@ -2408,6 +2483,12 @@ export class TUI {
2408
2483
  : 'default';
2409
2484
  return ` ${dim('↑↓')} select ${dim('←→')} target: ${target} ${bold('Enter')} pin ${bold('Esc')} cancel`;
2410
2485
  }
2486
+ // Both keys leave, because each move has already been saved: there is
2487
+ // no pending change for one to commit and the other to throw away, and
2488
+ // offering "cancel" would promise an undo this screen does not have.
2489
+ if (this.selAction === 'reorder') {
2490
+ return ` ${dim('↑↓')} select ${dim('←→')} move ${bold('Enter')}/${bold('Esc')} done`;
2491
+ }
2411
2492
  const act = this.selAction === 'toggle' ? 'enable/disable' : 'remove';
2412
2493
  return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
2413
2494
  }
@@ -2420,6 +2501,55 @@ export class TUI {
2420
2501
  }
2421
2502
  }
2422
2503
 
2504
+ /**
2505
+ * `hints` with the build label — and, when one is waiting, an update marker —
2506
+ * set against the right edge of a W-column line. This is the only place the
2507
+ * display names either: the header carries its title and the port block and
2508
+ * nothing else.
2509
+ *
2510
+ * Composed to exactly W, never less. The paint loop pads every short line out
2511
+ * to the terminal width and truncates the tail of every long one (fitLine), so
2512
+ * a label merely appended is pushed out of the corner it was put in, and a
2513
+ * line built past W has that same corner eaten. Both failures are silent, and
2514
+ * both look like the label was never drawn at all.
2515
+ *
2516
+ * The hints are never cut to make room. They are the only thing on this line
2517
+ * that says what the keyboard does: a build the operator cannot read costs
2518
+ * them nothing, a key they cannot read costs them the screen. So the label is
2519
+ * fitted to whatever the hints leave over — fitHeadLabel spends the build sha
2520
+ * first and then the head of the version — and it is dropped whole when what
2521
+ * is left will not carry it.
2522
+ *
2523
+ * @param {string} hints
2524
+ * @param {number} W
2525
+ */
2526
+ _footerWithVersion(hints, W) {
2527
+ // In attach mode the dashboard names the server's build, not this
2528
+ // process's, so the account manager's answer wins for both. It arrives
2529
+ // sanitized (applyStatus) and starts empty, which keeps the corner blank
2530
+ // until the first poll rather than briefly naming the local checkout as if
2531
+ // it were the server's. A local AccountManager has neither property.
2532
+ // `??` on purpose: '' is that deliberate blank, not a miss.
2533
+ const label = this.am.versionLabel ?? this.versionLabel;
2534
+ const upd = this.am.updateAvailable ?? this.updateAvailable;
2535
+ // The marker rides with the label and is never drawn without it. Alone in
2536
+ // the corner a bare glyph names nothing it could be an update TO, and reads
2537
+ // as one more key hint — the one thing this end of the line must not look
2538
+ // like. It would also be the wrong answer for an attached dashboard that
2539
+ // has not polled yet: no server label, but this process's update flag.
2540
+ if (!label) return hints;
2541
+ const hw = vw(hints);
2542
+ // Budgeted before the label is cut, so a shortened label and its marker
2543
+ // still fit the room they were measured against.
2544
+ const markerW = upd ? 2 : 0;
2545
+ // One column of margin at the edge — what the rule under the header and
2546
+ // the header's port block both leave.
2547
+ const text = fitHeadLabel(label, W - hw - FOOT_GAP - markerW - 1);
2548
+ if (!text) return hints;
2549
+ const marker = upd ? ` ${green('▲')}` : '';
2550
+ return `${hints}${' '.repeat(W - hw - vw(text) - markerW - 1)}${dim(text)}${marker} `;
2551
+ }
2552
+
2423
2553
  /**
2424
2554
  * The footer while this process drains to a restart: how long the wait has
2425
2555
  * run against the bound it cannot exceed, what is still holding it, and the