Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ While the config is being rewritten — by the server rotating a refresh token,
| `logRetentionHours` | Age at which `logDir` files are deleted, default `72`. Swept once at startup and every ten minutes after, and only names the logger wrote itself are considered. `0` keeps everything forever |
| `blockedModels` | Array of model glob patterns (e.g. `["*fable*"]`) whose requests are rejected with a fast, non-retryable `400` instead of being forwarded — avoids a model no account can serve getting rate-limited upstream and hanging the pipeline. Empty (the default) blocks nothing. Edits apply live via `POST /teamclaude/reload` |
| `sessionTitles` | [Session titles in the activity log](usage.md#session-titles-in-the-activity-log) (off by default). Object: `{ enabled, width, projectsDir }` — name each activity row after the Claude Code session that sent it, instead of showing six hex characters of its id. `width` is the columns the label gets (default 18; while enabled every row pays it, so the columns after it stay aligned). `projectsDir` overrides `~/.claude/projects`. Display only: no selection code reads it. Toggled from the settings screen (**g** → Session titles); every field is applied live on config reload |
| `quotaBarPercent` | Draw the utilization percentage beside the reset countdown on each TUI quota bar — `97% · 2h30m` — on bars wide enough for both (`true`, the default). `false` keeps the countdown alone, which is how the bars read before the percentage was added. Display only, and only in the TUI: `teamclaude status` and the dashboard carry their own percentage regardless. Toggled from the settings screen (**g** → Bar percentage); applied live on config reload |
| `stormRamp` | Optional [storm-control](routing.md#storm-control) tuning (on by default). Object: `{ enabled, startConc, stepConc, stepMs, windowMs }` |
| `expiryRouting` | Optional [expiry-pressure routing](routing.md#expiry-pressure-routing) (off by default). Object: `{ enabled, tolerance, preempt }` — prefer accounts whose governing weekly quota is ample and resets soonest. **The key name is provisional** — see [#176](https://github.com/KarpelesLab/teamclaude/issues/176) |
| `routes` | Optional list of [routing rules](routing.md#model-routes) that pin model patterns to specific accounts |
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Warning: "me@example.com" is disabled, so requests will not route to it until th
| `d` | Enable/disable an account |
| `p` | Refresh quota on all accounts (one-shot probe of the zero-spend usage endpoint) |
| `R` | Reload accounts from config |
| `g` | Settings (threshold, quota probe, routing, add/remove/reorder accounts, sx.org) |
| `g` | Settings (threshold, quota probe, quota-bar contents, routing, add/remove/reorder accounts, sx.org) |
| `q` | Quit |

In selection mode, use `j`/`k` or the arrow keys to navigate, `Enter` to confirm, `Esc` to cancel.
Expand Down
1 change: 1 addition & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function createDefaultConfig() {
holdSeconds: 0,
distributeSessions: false,
sessionTitles: { enabled: false, width: 18 },
quotaBarPercent: true,
eventLogging: 'hide',
defaultClientMode: 'mitm',
blockedModels: [],
Expand Down
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ async function serverCommand() {
// Both are read per request off this object (server.js) and the TUI already
// persists them; without this a hand edit or another writer waited for a restart.
config.eventLogging = diskConfig.eventLogging || 'hide';
// Read by the TUI on every frame, so a hand edit lands on the next reload.
config.quotaBarPercent = diskConfig.quotaBarPercent !== false;
// Read by `run`/`env` from disk, but the TUI settings screen shows it live.
config.defaultClientMode = diskConfig.defaultClientMode === 'base-url' ? 'base-url' : 'mitm';
config.blockedModels = Array.isArray(diskConfig.blockedModels) ? diskConfig.blockedModels : [];
Expand Down Expand Up @@ -514,6 +516,7 @@ async function serverCommand() {
// screen too; the server reads them live from `config`, but without this
// the edit never reached disk and was silently undone by the next start.
if (config.eventLogging != null) diskConfig.eventLogging = config.eventLogging;
if (config.quotaBarPercent != null) diskConfig.quotaBarPercent = config.quotaBarPercent;
if (config.defaultClientMode != null) diskConfig.defaultClientMode = config.defaultClientMode;
if (config.blockedModels != null) diskConfig.blockedModels = config.blockedModels;
if (config.sessionTitles != null) diskConfig.sessionTitles = config.sessionTitles;
Expand Down
45 changes: 38 additions & 7 deletions src/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -437,9 +437,11 @@ function barColor(ratio, resetTs, windowMs, threshold) {
* The label (e.g. "Ses 2h30m" or "45%") is drawn on top of the bar.
* windowMs is the bucket's rolling-window length; when known, the color tracks
* burn rate instead of raw fill. threshold is the routing switch threshold, at
* or above which the bar goes red regardless of pace.
* or above which the bar goes red regardless of pace. showPct false drops the
* percentage wherever a countdown can stand in its place (config
* `quotaBarPercent`); with no countdown the percentage is the label either way.
*/
export function bar(ratio, w = 10, resetTs, windowMs, threshold) {
export function bar(ratio, w = 10, resetTs, windowMs, threshold, showPct = true) {
const rst = formatReset(resetTs);

if (ratio == null || isNaN(ratio)) {
Expand Down Expand Up @@ -467,7 +469,7 @@ export function bar(ratio, w = 10, resetTs, windowMs, threshold) {
// both values in this order — the ` · ` between them is the dashboard's
// (src/dashboard.js).
const pct = (ratio * 100).toFixed(0) + '%';
const both = rst ? `${pct} · ${rst}` : '';
const both = showPct && rst ? `${pct} · ${rst}` : '';
const label = both && vw(both) <= w ? both : (rst || pct);
const text = label.slice(0, w);
const pad = w - text.length;
Expand Down Expand Up @@ -904,6 +906,16 @@ export class TUI {
enter: () => this._promptInput('Quota probe seconds (0=off, min 30)', v => this._doSetProbe(v.trim())),
});

fields.push({
id: 'quotaBarPercent',
label: 'Bar percentage',
hint: '←→ toggle',
value: () => (this.config.quotaBarPercent !== false ? green('on') : gray('off')),
left: () => this._toggleQuotaBarPercent(),
right: () => this._toggleQuotaBarPercent(),
enter: () => this._toggleQuotaBarPercent(),
});

fields.push({
id: 'eventlog',
label: 'Event logging',
Expand Down Expand Up @@ -1412,6 +1424,17 @@ export class TUI {
if (this.running) this.render();
}

async _toggleQuotaBarPercent() {
// Absent means on, so the first toggle from a config that predates the key
// has to write `false` — hence the comparison rather than a negation.
const on = this.config.quotaBarPercent === false;
this.config.quotaBarPercent = on;
try { await this.saveConfig(this.config); }
catch (/** @type {any} */ e) { this._addLog(`Failed to save: ${e.message}`); }
this._addLog(`Quota bar percentage: ${on ? 'on' : 'off'}`);
if (this.running) this.render();
}

async _cycleEventLogging(dir = 1) {
// Claude Code telemetry display/handling: show → hide → block → show.
const order = ['show', 'hide', 'block'];
Expand Down Expand Up @@ -2247,17 +2270,21 @@ export class TUI {
const weeklyFirst = !shortBar && rowCategory(a) === 'unified';
if (weeklyFirst) [l1, r1, t1, w1, th1] = [l2, r2, t2, w2, th2];

let line = ` ${sel}${cur} ${startSlot}${name} ${type}${status} ${l1} ${bar(r1, bw, t1, w1, th1)}`;
// Keep the optional chaining: _renderAcct is called on instances built
// without a config, and it read none before this line existed.
const pctInBar = this.config?.quotaBarPercent !== false;

let line = ` ${sel}${cur} ${startSlot}${name} ${type}${status} ${l1} ${bar(r1, bw, t1, w1, th1, pctInBar)}`;
if (showBoth) {
if (!weeklyFirst) line += ` ${l2} ${bar(r2, bw, t2, w2, th2)}`;
if (!weeklyFirst) line += ` ${l2} ${bar(r2, bw, t2, w2, th2, pctInBar)}`;
// Sonnet weekly bar — only shown when the usage probe has populated it. A
// leading ► (in place of a padding space) marks a Sonnet route on this account.
if (showFamily && q.unified7dSonnet != null) {
line += `${famLead('sonnet')}${familyMark('sonnet')}S7 ${bar(q.unified7dSonnet, bw, q.unified7dSonnetReset, SEVEN_DAY_MS, limFor('unified7dSonnet'))}`;
line += `${famLead('sonnet')}${familyMark('sonnet')}S7 ${bar(q.unified7dSonnet, bw, q.unified7dSonnetReset, SEVEN_DAY_MS, limFor('unified7dSonnet'), pctInBar)}`;
}
// Fable weekly bar — only shown when the usage probe has populated it.
if (showFamily && q.unified7dFable != null) {
line += `${famLead('fable')}${familyMark('fable')}F7 ${bar(q.unified7dFable, bw, q.unified7dFableReset, SEVEN_DAY_MS, limFor('unified7dFable'))}`;
line += `${famLead('fable')}${familyMark('fable')}F7 ${bar(q.unified7dFable, bw, q.unified7dFableReset, SEVEN_DAY_MS, limFor('unified7dFable'), pctInBar)}`;
}
}
// Explicit "disabled for these models" tag (issue #85): a family the account
Expand Down Expand Up @@ -2318,6 +2345,10 @@ export class TUI {
lines.push(bold(' Quota probe') + dim(' — refresh idle accounts from the usage endpoint'));
lines.push(row(byId('probe')));
lines.push('');
// ── Quota bars
lines.push(bold(' Quota bars') + dim(' — what the bar on each account row carries'));
lines.push(row(byId('quotaBarPercent')));
lines.push('');
// ── Activity log
lines.push(bold(' Activity log') + dim(' — what to do with Claude Code\'s telemetry'));
lines.push(row(byId('eventlog')));
Expand Down
33 changes: 31 additions & 2 deletions test/tui-bar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ test('no width from BAR_MIN up produces a half-drawn countdown', () => {
}
});

// The percentage is the newer of the two fields, and the one an operator can
// send back: `quotaBarPercent: false` restores the countdown-only label.
test('the percentage can be turned off, leaving the countdown alone', () => {
assert.equal(plain(bar(0.97, 20, inTwoAndAHalfHours(), undefined, undefined, false)), '2h30m');
});

// With no countdown there is nothing else for the bar to say, so the switch
// does not empty it.
test('turning the percentage off leaves it on a bar with no countdown', () => {
assert.equal(plain(bar(0.45, 10, undefined, undefined, undefined, false)), '45%');
});

test('with no countdown to show, the percentage is the label', () => {
assert.equal(plain(bar(0.45, 10)), '45%');
assert.equal(plain(bar(0.45, 10, Date.now() - 60_000)), '45%');
Expand All @@ -148,13 +160,13 @@ test('a bar with no reading at all is unchanged', () => {
// The row renderer has to hand the live threshold to every bar it draws, or the
// clamp above never reaches the screen. Rendered, not called directly: the
// argument list is the thing under test.
function renderRow(quota, threshold = 0.98) {
function renderRow(quota, threshold = 0.98, config = {}) {
const am = new AccountManager(
[{ name: 'acct@example.com', type: 'oauth', accessToken: 't', refreshToken: 'r', expiresAt: Date.now() + 3600_000 }],
threshold);
Object.assign(am.accounts[0].quota, quota);
const tui = new TUI({
accountManager: am, config: { proxy: { port: 1 }, accounts: [], routes: [] }, sx: null,
accountManager: am, config: { proxy: { port: 1 }, accounts: [], routes: [], ...config }, sx: null,
saveConfig: async () => {}, syncAccounts: async () => 0, onQuit: () => {}, probeQuota: () => {},
});
const cols = Object.getOwnPropertyDescriptor(process.stdout, 'columns');
Expand All @@ -175,6 +187,23 @@ function renderRow(quota, threshold = 0.98) {
return drawn;
}

// The switch is read once per row and handed to every bar on it, so a row with
// the family bars drawn has four call sites to get wrong.
test('the row drops the percentage from every bar when the switch is off', () => {
const h = 3600_000;
const quota = {
unified5h: 0.42, unified5hReset: Date.now() + 2.5 * h,
unified7d: 0.31, unified7dReset: Date.now() + 3 * 24 * h,
unified7dSonnet: 0.22, unified7dSonnetReset: Date.now() + 3 * 24 * h,
unified7dFable: 0.11, unified7dFableReset: Date.now() + 3 * 24 * h,
};
assert.match(plain(renderRow(quota)), /31% \u00b7 3d/);
const off = plain(renderRow(quota, 0.98, { quotaBarPercent: false }));
assert.doesNotMatch(off, /%/);
// The countdowns stay: the percentage is the only field the switch removes.
assert.match(off, /2h30m/);
});

test('a spent session bucket renders red on the row, not just in bar()', () => {
const h = 3600_000;
// 99% of a 5h bucket with a minute left on the window: pace says green, and
Expand Down
19 changes: 19 additions & 0 deletions test/tui-settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,22 @@ test('a short sx.org key is not shown whole by its mask', () => {
row.enter();
assert.equal(tui.inputSecret, true);
});

// A config written before the key existed has no value for it, and the row
// reads that as on. The first toggle from there has to reach disk as `false`,
// or the switch appears to do nothing until the setting is toggled twice.
test('the quota-bar percentage toggles off from a config that never had the key', async () => {
const saved = [];
const tui = makeTUI();
tui.saveConfig = async c => { saved.push(c.quotaBarPercent); };
const row = tui._settingsFields().find(f => f.id === 'quotaBarPercent');
assert.equal(stripAnsi(row.value()), 'on');

await row.right();
assert.equal(tui.config.quotaBarPercent, false);
assert.equal(stripAnsi(tui._settingsFields().find(f => f.id === 'quotaBarPercent').value()), 'off');

await row.right();
assert.equal(tui.config.quotaBarPercent, true);
assert.deepEqual(saved, [false, true]);
});
Loading