Skip to content

feat(tui): draw a two-provider pool as two panes, side by side - #406

Merged
MagicalTux merged 7 commits into
KarpelesLab:masterfrom
jgautheron:tui-provider-panes
Sep 20, 2026
Merged

MagicalTux merged 7 commits into
KarpelesLab:masterfrom
jgautheron:tui-provider-panes

Conversation

@jgautheron

@jgautheron jgautheron commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

A pool holding Claude and Codex accounts is one table today. Since #392 each row names its provider, which tells the two apart, but the table still grows by the sum of both pools, one person's two subscriptions (usually the same address) sit side by side, and ► marks only one pool's current account: the manager's single currentIndex names whichever provider moved last, so the other pool shows none, and s on one side takes the marker away from the other.

Anthropic and Codex panes

What changed

  1. Provider column budget. Since fix(tui): name each row's provider once the pool serves more than one #392 a mixed pool's rows ran 2 columns past the budget (Anthropic is 9, 7 were reserved), cutting the last tag.
  2. Unreported subscriptions draw empty Ses/Wk instead of Tok/Req.
  3. One ► per pool, from each provider's cursor. Status gains currentIndexes so attach mode can tell same-named accounts apart.
  4. Panes. Split only when both keep every bar; width goes to readable bars, then whole names, then wider bars. A Codex pane with no 5h window drops Ses.

The account row's width budget reserved an 8-column type cell whatever the
column held. Since KarpelesLab#392 a mixed pool writes the provider there, padded to
the longest label present, and `Anthropic` is 9, so every row in a mixed
pool ran 2 columns past the budget. fitLine then cut the tail: the reset
label of the last bar, or the whole `$` tag behind a `⊘ Fable`. It only
shows on a row that carries every reserved tag itself, which is how the
budget's other slack hid it.

The row and the budget now take the width from one helper.
…ding

The row category keyed on readings alone, so an OAuth account that had not
answered a request or a probe yet drew the API-key `Tok`/`Req` pair, and was
budgeted with the metered rows: its empty bars sat at a different width from
the subscription rows around it. A subscription meters Ses/Wk whether or not
it has reported yet, so the category now also follows the auth kind.
A pool holding Claude and Codex accounts rotates each provider on a cursor
of its own, but ► read the manager's single `currentIndex`, which names
whichever pool moved last. The other pool showed no current account at all,
and switching one side with `s` took the marker away from the other.

A mixed pool now marks the account each provider's cursor names, the same
answer `currentAccounts` already gives in /teamclaude/status. That can be a
shared API-key account, since a key serves either pool. The set is read once
per frame. A single-provider pool is unchanged.

Attach mode reads the same answer from the status payload. A name alone
cannot tell a shared API key from a Codex login at the same address, so the
payload now also carries `currentIndexes`, each pool's current account by
position in `accounts`. Attach mode prefers it, falls back to the name
matched on provider, and against a server that sends neither marks its one
cursor as before.
A pool holding Claude and Codex accounts is one table today, each row naming
its provider, so the table grows by the sum of both pools, and one person's
two subscriptions, usually the same address, sit next to each other told
apart by a single column.

The dashboard now draws one pane per provider, Anthropic on the left and
Codex on the right, so the table is as tall as the larger pool. The pane
titles take the spacer line the table always had, so the split costs no
height. A pane drops the type column, since its title names the provider,
reserves route cells only for its own provider's routes, and caps its bars
narrower. Each pane is laid out with the per-category budget the full table
uses.

Width is dealt out in the order a pane spends it: bars wide enough for
their reset label, whole names, then wider bars. Both panes reach one step
before either starts the next, a step that cannot be met in full is shared
in proportion to what each pane still wants, so a pane with three bars and
a `⊘ Fable` tag does not cut its names while the other pads. The table
splits only when both panes reach the first step and keep every bar the
rows draw in one column; otherwise it stays one column, now grouped by
provider, which is also the order selection walks. Like the bars in one
column, the split can change when a `⊘` tag appears near that width.

A Codex plan need not state a five-hour limit. When the Codex accounts in a
list have all reported and none states one, the list draws no Ses bar and
starts at Wk. An account that has not reported yet keeps the column, so it
does not come and go at startup.
MagicalTux and others added 3 commits September 20, 2026 08:36
…tatus field

The panes change what an operator sees on a wide terminal and the status
payload gains a field, but neither was documented. usage.md now says when the
account table splits into one pane per provider (two providers, 127 columns or
more, every bar still drawable), that each pool has its own current marker, when
the Ses bar is dropped, and what currentIndexes holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@MagicalTux
MagicalTux merged commit 0a96c25 into KarpelesLab:master Sep 20, 2026
5 checks passed
MagicalTux added a commit to RuslanTar/teamclaude that referenced this pull request Sep 20, 2026
The per-account switch-threshold tag moves into the row code KarpelesLab#406 introduced:
_renderAcct still appends it, after master's reset-credit tag, and its width
is now reserved in _listLayout's per-category `fixed` budget next to the
blocked-family and spend tags. Because `fixed` feeds `span` and the pane
`stages`, a side-by-side pane is sized to hold the tag or the split falls back
to one column. accountBadges keeps master's `now` as its fourth argument, with
the fleet threshold pair following it; SHARED_CONSTS and the sync-accounts
config mirror carry both sides' entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MagicalTux added a commit to rikbrown/teamclaude that referenced this pull request Sep 20, 2026
…s save, carry it to attach

After KarpelesLab#406 the list is grouped by provider before anything else, so _displayOrder now sorts by provider, then local-upstream-last, then displayOrder, then manager index, and a move that would cross a provider group is a no-op that neither renumbers nor saves. The reorder save is debounced so a held arrow key is one locked config write, flushed on leaving the screen and in stop(). syncAccountsFromDisk mirrors a disk displayOrder onto the in-memory config entry, since the save stencil is {...diskAcct, ...live} and the stale key otherwise reverted a hand edit on the next save. getStatus emits displayOrder per account so `teamclaude attach` draws the same order as the server's own TUI. Tests cover the refused cross-group move as rendered, the single write per run of moves, the status field and the reload-save-reload round trip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MagicalTux added a commit that referenced this pull request Sep 20, 2026
* feat(tui): set the order the account list draws in

New accounts land at the bottom of the list, and the only way to move one
was to hand-edit the `accounts` array in the config — which is the one edit
that array does not tolerate. A manager index addresses an account in route
pins, session pins, `currentIndex`, `TC_ACCT` and the disable/switch CLI
paths, so permuting the array silently repoints every one of them at a
different account.

So the rows get a sort key instead. `displayOrder` on an account is
presentation and nothing else: each account keeps the slot it has held since
startup, and _displayOrder sorts the rows by the field before drawing them.
Settings → Reorder accounts opens the list with ←→ moving the selected
ACCOUNT rather than the cursor, each move saved as it is made.

Emphatically not `priority`, which is one field over and answers a different
question: which account rotation spends next. A fleet usually carries one
non-default value there — a deliberately deprioritised local backend — and
deriving it from where a row sits on screen would re-rank routing as a side
effect of tidying the display.

Locally-served accounts are left out of it. #376 draws those last because a
translating proxy in front of another vendor is infrastructure rather than a
seat to rotate between, and that is a category rather than a preference, so
it stays ahead of the arrangement: _displayOrder still partitions on
isLocalUpstream first, those accounts hold no `displayOrder`, and the
reorder cursor steps over the rows it cannot move.

An account with no value has never been placed, and sorts after every
account that has one. That is where a new account already appeared, so the
answer to "where does the one I just logged in with go" does not change with
the feature and there is nothing to migrate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(tui): hold the reorder to the rows, and to nothing else

Most of what this feature must do is leave things alone, so most of these
assert what did not move: the account array is not permuted, no manager
index changes, the current account stays current, an index standing in for a
route pin still names the account it named, and `priority` is never written
— on the account, on its config entry, or in anything the save puts on disk.

A locally-served account gets two of its own. It stays at the end of the
list however the rows above it are arranged, it is never given a
`displayOrder`, and the reorder cursor does not stop on its row, since ←→
there would do nothing.

The round trip goes through mergeAccountsForSave rather than around it. That
function merges an in-memory entry over its disk row, and a field it did not
know about is exactly the kind that gets dropped there, so a reorder that
vanished on restart would have looked like the TUI never saved it. The two
ends of the same trip — makeAccount normalising what it reads and
syncAccountsFromDisk carrying a hand edit onto a running account — are
asserted against the real AccountManager rather than the harness stand-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the account list has an order of its own now

The distinction worth writing down is the one the code spends its comments
on: this sets the order the list is DRAWN in, and rotation order is still
`priority` alone. Said in accounts.md where the settings screen is
described, in usage.md where its keys are, and in the config table, where
the two fields now sit one row apart and the reader is entitled to ask what
the difference is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tui): keep account reordering inside provider groups, debounce its save, carry it to attach

After #406 the list is grouped by provider before anything else, so _displayOrder now sorts by provider, then local-upstream-last, then displayOrder, then manager index, and a move that would cross a provider group is a no-op that neither renumbers nor saves. The reorder save is debounced so a held arrow key is one locked config write, flushed on leaving the screen and in stop(). syncAccountsFromDisk mirrors a disk displayOrder onto the in-memory config entry, since the save stencil is {...diskAcct, ...live} and the stale key otherwise reverted a hand edit on the next save. getStatus emits displayOrder per account so `teamclaude attach` draws the same order as the server's own TUI. Tests cover the refused cross-group move as rendered, the single write per run of moves, the status field and the reload-save-reload round trip.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mark Karpeles <magicaltux@gmail.com>
@MagicalTux MagicalTux mentioned this pull request Sep 20, 2026
MagicalTux added a commit that referenced this pull request Sep 20, 2026
Thirty-six commits since 1.1.20. Several change what a client sees on a
failure, so read the first section before upgrading a shared deployment.

Behaviour changes
  #439 a 401 from upstream is no longer relayed to the client: the request
       fails over like a 403, and an API-key account or an OAuth account with
       no refresh token leaves rotation (it used to be picked again on every
       request). With nothing left the client gets the proxy's own error
  #439 a path under /teamclaude/ that no control route claims — a typo, or the
       wrong verb — answers 404 locally instead of being forwarded upstream
       under a fleet credential
  #429 the synthetic 429's retry-after is the real reset of the windows
       blocking the request's candidate accounts, not a flat 60s, and the
       message counts only those candidates; #408 names accounts that need a
       re-login instead of calling them "at quota"
  #438 session pins are per conversation (session id plus a digest of the
       first message), so a session's subagents spread across accounts.
       `sessions.items[].id` in status is the composite key, load is counted
       per conversation, and a persisted concurrency cap re-learns
  #378 a `thread: continue` bound for a per-account third-party upstream is
       refused with the 400 Anthropic gives, so the client resends the whole
       conversation; `messageThreads: true` opts a relay out
  #434 a 429 whose x-codex-* headers show a spent account-wide window holds
       the account like an Anthropic rejection; a spent model-scoped bucket
       only moves the request
  #437 a Codex response head is awaited for five minutes (Anthropic unchanged)
  #411 idle keep-alive connections are held 120s on both listeners
  #389 with session distribution on, requests carrying no session id rotate on
       a cursor of their own instead of all resting on the current account
  #405 `defaultClientMode` ("mitm" | "base-url") sets what `run` and `env` do
       without a flag; `--mitm` / `--no-mitm` decide per launch, and in
       base-URL mode `env` unsets a stale proxy export naming this proxy
  #439 route `--bucket` is validated; an array `switchThreshold` reads as the
       default with one line saying so

Features
  #419 an MCP management endpoint at POST /teamclaude/mcp, off unless
       `proxy.mcp` is "read" or "full"; a named client key is read-only even
       in full mode, and with no proxy key it serves only this machine
  #428 per-account `switchThreshold`, a number or a per-bucket table
  #406 a Claude+Codex pool is drawn as two panes on a wide terminal, each with
       its own current marker; #392 names the provider in a mixed list; #418
       lets the operator arrange the list (`displayOrder`); #376 draws
       loopback-served accounts last; #435 shows the percentage beside a bar's
       countdown; #394 shows the running version in the header
  #430 free Codex rate-limit reset credits in status, the TUI and the dashboard
  #385 `proxy.terminalOnly` tunnels chatgpt.com so ChatGPT Desktop stays out

Fixes
  #404 a TUI paint can no longer block the proxy (stdout non-blocking, frames
       dropped while the terminal is behind); #410 a dead terminal no longer
       takes the proxy with it, and SIGHUP shuts down cleanly
  #433 token usage is booked from Codex Responses streams
  #386 #387 #388 the Codex five-hour window is read from the model-scoped
       family and the usage probe, and extra limits are named from their entries
  #431 a headerless 429 that follows the request is retried once
  #432 #439 startup and collaborator log lines reach the TUI's activity pane
       and log file instead of the covered terminal
  #415 #403 #439 reload mirrors `priority`, `disabled`, `stripRequestFields`
       onto the config entry, and a reload during a removal does not re-add it
  #439 the Host check uses the address actually bound; sx.org calls time out
  #381 the dashboard polls status before asking for a key
  #403 session outcome accounting classifies the decoded path; account names in
       daemon log lines are sanitised

Tooling
  #371 #372 #373 `npm run typecheck` (tsc over the JS sources) in CI, with a
       strict-mode ratchet: per-file strict diagnostics may not grow past the
       pre-merge commit (2006 at introduction, 1735 now)
  #401 docker workflow actions bumped

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
rikbrown added a commit to rikbrown/teamclaude that referenced this pull request Sep 29, 2026
Upstream's two-pane layout (KarpelesLab#406) capped a pane's bars at 12 columns and,
once both panes had every stage they asked for, shared the spare width
between them. On an ultrawide that drew 12-column bars at each end of a
gap of hundreds of columns: the Codex pane landed mid-screen, and the fleet
panel, which follows what the rows occupy, landed at the far edge.

Pane bars now grow to BAR_MAX, as the list's do, and width past the last
stage stays unused on the right, so the panes and the panel sit together.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MagicalTux pushed a commit that referenced this pull request Oct 3, 2026
The two-pane layout (#406) capped a pane's bars at 12 columns and, once
both panes had every stage they asked for, shared the spare width between
them. On an ultrawide that drew 12-column bars at each end of a gap of
hundreds of columns, with the Codex pane landing mid-screen.

Pane bars now grow to BAR_MAX, as the list's do, and width past the last
stage stays unused on the right, so the two panes sit together.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
rikbrown added a commit to rikbrown/teamclaude that referenced this pull request Oct 7, 2026
Three things, and a fourth found on the way.

THE FLEET SITS ALONGSIDE NOW. `[f]` was a swap: rows or aggregate, never both.
On a wide terminal that was a false choice, so it is a cycle — split, full, off
— starting at split, with the pools drawn as a panel to the right of the table.
Below about a hundred columns there is no room for one and split degrades to
rows only; never to fleet-only, because a dashboard whose default view contains
no accounts is a bad first thing to see.

The panel's column follows what the rows ACTUALLY occupy rather than what was
reserved for them. Rows stop growing once the name column holds the longest name
and every bar is at BAR_MAX, so on this fleet's 695-column terminal the table
ends near column 160 and a panel pinned to the reservation sat 480 columns away
from the thing it describes.

The threshold for splitting at all is derived from the row budget rather than
written down, because `fixed` grows with every general route, blocked-family tag
and money tag on screen. A constant would drift from it and put a panel beside
rows squeezed to two columns of bar — a row that has kept its shape and lost its
meaning.

WHAT STOPS EACH ROUTE. A route has no quota of its own; it spends its members'
buckets, so "how much has the fable route left" is a min over three of them, not
a bar. Each route now gets one line naming the bucket that binds first — highest
utilisation among the family weekly (family routes only), the shared weekly and
the 5h — over that route's members alone. Highest utilisation rather than
soonest exhaustion on purpose: it needs no sampled history, so the line is
honest from the first frame rather than blank for ninety minutes.

A SEAT NO ROUTE REACHES IS NOT CAPACITY. Same error as counting a disabled seat,
one step removed: quota that exists and that nothing will ever spend. Such a seat
leaves the figures and stays in the tally, exactly as an unpriceable one does.
Read off the RESOLVED membership `getRoutes()` returns, never raw config — a
route listing no accounts means every account, not none.

Deliberately narrow: the rule applies per provider pool, and only to a pool some
route actually reaches. An ordinary Anthropic-only routing table names no Codex
seat, and read fleet-wide that blanked the entire Codex block the moment one
route existed. Where no route mentions a pool, routing is saying nothing about
it rather than refusing it.

It errs toward understating, which is the safe direction: a model matching no
route at all still falls back to plain rotation, so a seat outside every route
is not strictly unreachable. Understating spendable capacity risks adding an
account nobody needed; overstating it risks being stopped mid-week by quota the
dashboard promised.

AND THE FOURTH THING, which is a live bug and predates all of this. The row
budget's `fixed` never counted the provider column. It assumed the 7 columns a
single-provider pool spends, while a mixed pool draws the widest provider label
— `Anthropic`, 9 — so EVERY ROW OF A MIXED FLEET has been composed two columns
past the terminal at every width, and fitLine has been eating the tail of each
one silently. That is KarpelesLab#228 and KarpelesLab#234 a third time, in the one place neither of
them thought to look. The row and the budget go through `_typeColW` now, so
there is no second spelling of that width to drift.

`routeFamily` moved from tui.js into quota-summary.js, so the route readout and
the family bars resolve a family from one copy of the glob logic rather than
two. `AccountManager.routeMembership()` is split out of `getRoutes` for the
sampler: `getRoutes` clears expired quota windows as a side effect of testing
availability, which is not something a request-path sampler should do, and two
quota-probe tests said so.

ALONGSIDE UPSTREAM'S PANES. Upstream now draws a two-provider pool as two
panes (KarpelesLab#406), laid out by _listLayout, which replaces the inline row budget
this change first split out. The rows here are that engine run against the
width the panel leaves (_accountLines), and the split's floor is the single
list's first _listLayout stage (_fleetSplit, renamed from _splitLayout, which
upstream now uses for the panes). The panel keeps its place: panes are an
arrangement of the same bars, so the rows draw as panes only when the column
beside the panel still fits both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants