@karpeleslab/teamclaude 1.0.3 → 1.0.5

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
@@ -10,8 +10,9 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
10
10
 
11
11
  - **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
12
12
  - **Auto-retry on 429** — if an account is rate-limited, transparently retries with the next one
13
- - **Interactive TUI** — real-time dashboard with quota bars, activity log, and keyboard controls
14
- - **OAuth token refresh** — proactively refreshes expiring tokens and persists them to config
13
+ - **Interactive TUI** — real-time dashboard with color-coded quota bars showing reset countdowns, activity log, and keyboard controls
14
+ - **OAuth token refresh** — proactively refreshes expiring tokens, intercepts client token renewals, and persists them to config
15
+ - **Hot-reload accounts** — add accounts via `import` or `login` while the server is running, press **R** to pick them up
15
16
  - **Request logging** — optional full request/response logging for debugging
16
17
  - **Zero dependencies** — uses only Node.js built-in modules
17
18
 
@@ -23,12 +24,11 @@ Requires Node.js 18+.
23
24
  # Install
24
25
  npm install -g @karpeleslab/teamclaude
25
26
 
26
- # Import your current Claude Code credentials
27
- teamclaude import
27
+ # Add your first account (opens browser for OAuth)
28
+ teamclaude login
28
29
 
29
- # Import a second account (log into it in Claude Code first, then import)
30
- # claude /login
31
- # teamclaude import
30
+ # Add a second account
31
+ teamclaude login
32
32
 
33
33
  # Start the proxy
34
34
  teamclaude server
@@ -37,27 +37,37 @@ teamclaude server
37
37
  teamclaude run
38
38
  ```
39
39
 
40
+ You can also import existing Claude Code credentials instead of logging in:
41
+
42
+ ```bash
43
+ claude /login # Log into an account in Claude Code
44
+ teamclaude import # Import its credentials
45
+ ```
46
+
40
47
  ## Adding Accounts
41
48
 
42
- ### Import from Claude Code (recommended)
49
+ ### OAuth Login (recommended)
43
50
 
44
- The easiest way to add accounts. Log into each account in Claude Code, then import:
51
+ The easiest way to add accounts opens your browser for authentication:
45
52
 
46
53
  ```bash
47
- # Log into your first account in Claude Code
48
- claude /login
54
+ teamclaude login
55
+ ```
56
+
57
+ Uses the same OAuth flow as Claude Code. Auto-detects the account email and subscription tier. Logging in with the same account again updates its credentials.
49
58
 
50
- # Import it
51
- teamclaude import
59
+ You can add accounts while the server is running — press **R** in the TUI to reload.
52
60
 
53
- # Switch to another account in Claude Code
54
- claude /login
61
+ ### Import from Claude Code
55
62
 
56
- # Import that one too
57
- teamclaude import
63
+ If you already have Claude Code set up, you can import its credentials directly:
64
+
65
+ ```bash
66
+ claude /login # Log into an account in Claude Code
67
+ teamclaude import # Import its credentials
58
68
  ```
59
69
 
60
- Each import auto-detects the account email and subscription tier. Re-importing the same account updates its credentials.
70
+ Re-importing the same account updates its credentials.
61
71
 
62
72
  ### API Key
63
73
 
@@ -67,16 +77,6 @@ For Anthropic API key accounts (billed via Console):
67
77
  teamclaude login --api
68
78
  ```
69
79
 
70
- ### OAuth Login (experimental)
71
-
72
- Direct browser-based OAuth login without needing Claude Code:
73
-
74
- ```bash
75
- teamclaude login
76
- ```
77
-
78
- > **Note:** OAuth login is currently experimental. Tokens obtained this way may not work for proxying `/v1/messages` requests. Use `teamclaude import` as the reliable method.
79
-
80
80
  ## Usage
81
81
 
82
82
  ### Start the proxy server
@@ -88,7 +88,7 @@ teamclaude server
88
88
  When running from a TTY, shows an interactive TUI with:
89
89
  - Account table with session/weekly quota progress bars
90
90
  - Real-time activity log with request tracking
91
- - Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **q**uit
91
+ - Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **R**eload, **q**uit
92
92
 
93
93
  Falls back to plain log output when not a TTY (e.g. running as a service).
94
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/teamclaude",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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,7 @@ export class AccountManager {
23
23
  index,
24
24
  name: acct.name,
25
25
  type: acct.type,
26
+ accountUuid: acct.accountUuid || null,
26
27
  credential: acct.accessToken || acct.apiKey,
27
28
  refreshToken: acct.refreshToken || null,
28
29
  expiresAt: acct.expiresAt || null,
@@ -268,42 +269,21 @@ export class AccountManager {
268
269
  }
269
270
 
270
271
  /**
271
- * Capture a fresh token from a client request or intercepted token refresh.
272
- * Updates the first OAuth account whose credential matches the old token,
273
- * or the first expired/error OAuth account if none match.
274
- *
275
- * @param {string} accessToken - The new access token
276
- * @param {string} [refreshToken] - New refresh token (if available from intercepted refresh)
277
- * @param {number} [expiresAt] - Token expiry timestamp
272
+ * Update a specific account's OAuth tokens (e.g. after intercepting a token refresh).
278
273
  */
279
- captureClientToken(accessToken, refreshToken, expiresAt) {
280
- if (!accessToken) return;
281
-
282
- // Check if any account already has this exact token
283
- const existing = this.accounts.find(a => a.type === 'oauth' && a.credential === accessToken);
284
- if (existing) {
285
- // Update expiry/refresh if we have better info
286
- if (expiresAt && expiresAt > (existing.expiresAt || 0)) existing.expiresAt = expiresAt;
287
- if (refreshToken) existing.refreshToken = refreshToken;
288
- return;
289
- }
290
-
291
- // Find the best OAuth account to update: prefer expired/error accounts
292
- const candidate = this.accounts.find(a =>
293
- a.type === 'oauth' && (a.status === 'error' || isTokenExpiringSoon(a.expiresAt, 0))
294
- ) || this.accounts.find(a => a.type === 'oauth');
295
-
296
- if (!candidate) return;
297
-
298
- candidate.credential = accessToken;
299
- if (refreshToken) candidate.refreshToken = refreshToken;
300
- candidate.expiresAt = expiresAt || Date.now() + 3600 * 1000;
301
- if (candidate.status === 'error') candidate.status = 'active';
302
- console.log(`[TeamClaude] Captured fresh token for account "${candidate.name}"`);
303
- this._onTokenRefresh?.(candidate.index, {
274
+ updateAccountTokens(accountIndex, { accessToken, refreshToken, expiresAt }) {
275
+ const account = this.accounts[accountIndex];
276
+ if (!account || account.type !== 'oauth') return;
277
+
278
+ account.credential = accessToken;
279
+ if (refreshToken) account.refreshToken = refreshToken;
280
+ account.expiresAt = expiresAt;
281
+ if (account.status === 'error') account.status = 'active';
282
+ console.log(`[TeamClaude] Updated tokens for account "${account.name}"`);
283
+ this._onTokenRefresh?.(accountIndex, {
304
284
  accessToken,
305
- refreshToken: candidate.refreshToken,
306
- expiresAt: candidate.expiresAt,
285
+ refreshToken: account.refreshToken,
286
+ expiresAt: account.expiresAt,
307
287
  });
308
288
  }
309
289
 
@@ -316,6 +296,7 @@ export class AccountManager {
316
296
  index,
317
297
  name: acctData.name,
318
298
  type: acctData.type,
299
+ accountUuid: acctData.accountUuid || null,
319
300
  credential: acctData.accessToken || acctData.apiKey,
320
301
  refreshToken: acctData.refreshToken || null,
321
302
  expiresAt: acctData.expiresAt || null,
package/src/config.js CHANGED
@@ -46,3 +46,15 @@ export async function saveConfig(config) {
46
46
  await mkdir(dirname(path), { recursive: true });
47
47
  await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
48
48
  }
49
+
50
+ /**
51
+ * Atomically update the config: re-reads from disk, calls updater(config),
52
+ * then saves. Returns the updated config. This prevents overwriting changes
53
+ * made by other processes (e.g. `teamclaude import` while the server runs).
54
+ */
55
+ export async function atomicConfigUpdate(updater) {
56
+ const config = await loadConfig() || createDefaultConfig();
57
+ await updater(config);
58
+ await saveConfig(config);
59
+ return config;
60
+ }
package/src/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
4
  import { createInterface } from 'node:readline';
5
- import { loadOrCreateConfig, saveConfig, getConfigPath } from './config.js';
5
+ import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath } from './config.js';
6
6
  import { AccountManager } from './account-manager.js';
7
7
  import { createProxyServer } from './server.js';
8
8
  import { importCredentials, loginOAuth, fetchProfile } from './oauth.js';
@@ -15,29 +15,36 @@ switch (command) {
15
15
  case 'server':
16
16
  await serverCommand();
17
17
  break;
18
+ case 'run':
19
+ await runCommand();
20
+ break;
18
21
  case 'import':
19
22
  await importCommand();
23
+ process.exit(0);
20
24
  break;
21
25
  case 'login':
22
26
  await loginCommand();
27
+ process.exit(0);
23
28
  break;
24
29
  case 'env':
25
30
  await envCommand();
26
- break;
27
- case 'run':
28
- await runCommand();
31
+ process.exit(0);
29
32
  break;
30
33
  case 'status':
31
34
  await statusCommand();
35
+ process.exit(0);
32
36
  break;
33
37
  case 'accounts':
34
38
  await accountsCommand();
39
+ process.exit(0);
35
40
  break;
36
41
  case 'remove':
37
42
  await removeCommand();
43
+ process.exit(0);
38
44
  break;
39
45
  case 'api':
40
46
  await apiCommand();
47
+ process.exit(0);
41
48
  break;
42
49
  case 'help':
43
50
  case '--help':
@@ -82,15 +89,21 @@ async function serverCommand() {
82
89
  const threshold = config.switchThreshold || 0.98;
83
90
  const accountManager = new AccountManager(accounts, threshold);
84
91
 
85
- // Persist refreshed tokens back to config
92
+ // Persist refreshed tokens back to config (re-read from disk to avoid clobbering
93
+ // accounts added externally, e.g. by `teamclaude import` while server is running)
86
94
  accountManager.onTokenRefresh((idx, newTokens) => {
87
- const cfgAcct = config.accounts[idx];
88
- if (cfgAcct) {
89
- cfgAcct.accessToken = newTokens.accessToken;
90
- cfgAcct.refreshToken = newTokens.refreshToken;
91
- cfgAcct.expiresAt = newTokens.expiresAt;
92
- saveConfig(config).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
93
- }
95
+ const account = accountManager.accounts[idx];
96
+ if (!account) return;
97
+ atomicConfigUpdate(diskConfig => {
98
+ syncNewAccountsFromDisk(diskConfig, config, accountManager);
99
+ // Match by UUID first, then by name — index may have shifted
100
+ const cfgIdx = findConfigAccount(diskConfig, account);
101
+ if (cfgIdx >= 0) {
102
+ diskConfig.accounts[cfgIdx].accessToken = newTokens.accessToken;
103
+ diskConfig.accounts[cfgIdx].refreshToken = newTokens.refreshToken;
104
+ diskConfig.accounts[cfgIdx].expiresAt = newTokens.expiresAt;
105
+ }
106
+ }).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
94
107
  });
95
108
  const port = config.proxy.port;
96
109
  const useTUI = process.stdout.isTTY && process.stdin.isTTY;
@@ -100,7 +113,24 @@ async function serverCommand() {
100
113
 
101
114
  if (useTUI) {
102
115
  tui = new TUI({
103
- accountManager, config, saveConfig,
116
+ accountManager, config,
117
+ saveConfig: () => atomicConfigUpdate(diskConfig => {
118
+ syncNewAccountsFromDisk(diskConfig, config, accountManager);
119
+ // Write in-memory accounts back, preserving extra disk-only fields
120
+ diskConfig.accounts = config.accounts.map(a => {
121
+ const diskAcct = diskConfig.accounts.find(
122
+ d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
123
+ );
124
+ return diskAcct ? { ...diskAcct, ...a } : a;
125
+ });
126
+ }),
127
+ syncAccounts: async () => {
128
+ const diskConfig = await loadConfig();
129
+ if (!diskConfig) return 0;
130
+ const before = accountManager.accounts.length;
131
+ syncNewAccountsFromDisk(diskConfig, config, accountManager);
132
+ return accountManager.accounts.length - before;
133
+ },
104
134
  onQuit: () => { server.close(() => process.exit(0)); },
105
135
  });
106
136
  hooks = {
@@ -559,6 +589,38 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
559
589
  console.log(`Saved to ${getConfigPath()}`);
560
590
  }
561
591
 
592
+ // ── config sync helpers ─────────────────────────────────────
593
+
594
+ /**
595
+ * Find a config account entry matching an in-memory account (by UUID, then name).
596
+ */
597
+ function findConfigAccount(diskConfig, account) {
598
+ if (account.accountUuid) {
599
+ const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
600
+ if (idx >= 0) return idx;
601
+ }
602
+ return diskConfig.accounts.findIndex(a => a.name === account.name);
603
+ }
604
+
605
+ /**
606
+ * Detect accounts added to disk config by external processes and add them
607
+ * to the running AccountManager + in-memory config.
608
+ */
609
+ function syncNewAccountsFromDisk(diskConfig, memConfig, accountManager) {
610
+ for (const diskAcct of diskConfig.accounts) {
611
+ const knownByUuid = diskAcct.accountUuid &&
612
+ memConfig.accounts.some(a => a.accountUuid === diskAcct.accountUuid);
613
+ const knownByName = memConfig.accounts.some(a => a.name === diskAcct.name);
614
+
615
+ if (!knownByUuid && !knownByName) {
616
+ // New account discovered on disk — add to running server
617
+ memConfig.accounts.push(diskAcct);
618
+ accountManager.addAccount(diskAcct);
619
+ console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
620
+ }
621
+ }
622
+ }
623
+
562
624
  // ── helpers ─────────────────────────────────────────────────
563
625
 
564
626
  async function resolveAccounts(config) {
package/src/server.js CHANGED
@@ -118,13 +118,16 @@ async function handleTokenRefresh(req, res, accountManager, hooks) {
118
118
 
119
119
  const responseBody = await upstreamRes.text();
120
120
 
121
- // Capture tokens from successful refresh
121
+ // Capture tokens from successful refresh — update the current account directly
122
122
  if (upstreamRes.ok) {
123
123
  try {
124
124
  const tokens = JSON.parse(responseBody);
125
125
  if (tokens.access_token) {
126
- accountManager.captureClientToken(tokens.access_token, tokens.refresh_token,
127
- tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000));
126
+ accountManager.updateAccountTokens(accountManager.currentIndex, {
127
+ accessToken: tokens.access_token,
128
+ refreshToken: tokens.refresh_token,
129
+ expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
130
+ });
128
131
  }
129
132
  } catch {}
130
133
  }
@@ -226,12 +229,6 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
226
229
  headers['x-api-key'] = account.credential;
227
230
  }
228
231
 
229
- // Capture fresh Bearer tokens from the client to keep stored credentials up to date
230
- const clientBearer = req.headers['authorization']?.match(/^Bearer (.+)/i)?.[1];
231
- if (clientBearer) {
232
- accountManager.captureClientToken(clientBearer);
233
- }
234
-
235
232
  const upstreamUrl = `${upstream}${req.url}`;
236
233
  const method = req.method;
237
234
 
@@ -332,7 +329,7 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
332
329
  writeRequestLog(logDir, reqId, logSections);
333
330
  }
334
331
 
335
- if (retryCount < maxRetries) {
332
+ if (retryCount < maxRetries && !res.headersSent) {
336
333
  account.status = 'error';
337
334
  return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
338
335
  }
package/src/tui.js CHANGED
@@ -17,7 +17,8 @@ const red = s => fg(31, s);
17
17
  const cyan = s => fg(36, s);
18
18
  const gray = s => fg(90, s);
19
19
 
20
- const strip = s => s.replace(/\x1b\[[0-9;]*m/g, '');
20
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
21
+ const strip = s => s.replace(ANSI_RE, '');
21
22
  const vw = s => strip(s).length;
22
23
 
23
24
  function rpad(s, w) {
@@ -25,13 +26,85 @@ function rpad(s, w) {
25
26
  return gap > 0 ? s + ' '.repeat(gap) : s;
26
27
  }
27
28
 
28
- function bar(ratio, w = 10) {
29
- if (ratio == null || isNaN(ratio)) return gray('░'.repeat(w)) + ' - ';
29
+ /** Truncate a string with ANSI codes to exactly w visible characters, then reset. */
30
+ function truncate(s, w) {
31
+ let visible = 0;
32
+ let out = '';
33
+ let i = 0;
34
+ while (i < s.length && visible < w) {
35
+ if (s[i] === '\x1b') {
36
+ const end = s.indexOf('m', i);
37
+ if (end >= 0) { out += s.slice(i, end + 1); i = end + 1; continue; }
38
+ }
39
+ out += s[i];
40
+ visible++;
41
+ i++;
42
+ }
43
+ return out + RESET;
44
+ }
45
+
46
+ /** Fit a line to exactly w columns: truncate if too long, pad if too short. */
47
+ function fitLine(s, w) {
48
+ const v = vw(s);
49
+ if (v > w) return truncate(s, w);
50
+ if (v < w) return s + ' '.repeat(w - v);
51
+ return s;
52
+ }
53
+
54
+ function formatReset(resetTs) {
55
+ if (!resetTs) return '';
56
+ const ms = resetTs - Date.now();
57
+ if (ms <= 0) return '';
58
+ const mins = Math.ceil(ms / 60000);
59
+ if (mins < 60) return `${mins}m`;
60
+ const hrs = Math.floor(mins / 60);
61
+ const rm = mins % 60;
62
+ if (hrs < 24) return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`;
63
+ const days = Math.floor(hrs / 24);
64
+ const rh = hrs % 24;
65
+ return rh > 0 ? `${days}d${rh}h` : `${days}d`;
66
+ }
67
+
68
+ /**
69
+ * Render a progress bar using background colors with text overlaid.
70
+ * The label (e.g. "Ses 2h30m" or "45%") is drawn on top of the bar.
71
+ */
72
+ function bar(ratio, w = 10, resetTs) {
73
+ const rst = formatReset(resetTs);
74
+
75
+ if (ratio == null || isNaN(ratio)) {
76
+ // No data — dim background, show label or dash
77
+ const label = rst || '-';
78
+ const text = label.slice(0, w);
79
+ const pad = w - text.length;
80
+ const lp = Math.floor(pad / 2);
81
+ const rp = pad - lp;
82
+ return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
83
+ }
84
+
30
85
  ratio = Math.max(0, Math.min(1, ratio));
31
86
  const f = Math.round(ratio * w);
32
- const c = ratio < 0.7 ? 32 : ratio < 0.9 ? 33 : 31;
33
- const pct = (ratio * 100).toFixed(0).padStart(3) + '%';
34
- return `${ESC}${c}m${'█'.repeat(f)}${ESC}90m${'░'.repeat(w - f)}${RESET} ${pct}`;
87
+ // Background colors: 42=green, 43=yellow, 41=red; 100=bright black (gray) for empty
88
+ const bg = ratio < 0.7 ? 42 : ratio < 0.9 ? 43 : 41;
89
+
90
+ // Build the label to overlay: show reset time if available, else percentage
91
+ const pct = (ratio * 100).toFixed(0) + '%';
92
+ const label = rst || pct;
93
+ const text = label.slice(0, w);
94
+ const pad = w - text.length;
95
+ const lp = Math.floor(pad / 2);
96
+ const rp = pad - lp;
97
+ const chars = (' '.repeat(lp) + text + ' '.repeat(rp));
98
+
99
+ // Split chars into filled (colored bg) and empty (gray bg) portions
100
+ const filled = chars.slice(0, f);
101
+ const empty = chars.slice(f);
102
+
103
+ let out = '';
104
+ if (filled) out += `${ESC}${bg};97m${filled}`;
105
+ if (empty) out += `${ESC}100;37m${empty}`;
106
+ out += RESET;
107
+ return out;
35
108
  }
36
109
 
37
110
  function timestamp() {
@@ -41,10 +114,11 @@ function timestamp() {
41
114
  // ── TUI class ────────────────────────────────────────────────
42
115
 
43
116
  export class TUI {
44
- constructor({ accountManager, config, saveConfig, onQuit }) {
117
+ constructor({ accountManager, config, saveConfig, syncAccounts, onQuit }) {
45
118
  this.am = accountManager;
46
119
  this.config = config;
47
120
  this.saveConfig = saveConfig;
121
+ this.syncAccounts = syncAccounts;
48
122
  this.onQuit = onQuit;
49
123
 
50
124
  this.log = []; // completed activity entries
@@ -159,6 +233,7 @@ export class TUI {
159
233
  this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0;
160
234
  }
161
235
  else if (k === 'a') { this.mode = 'add'; }
236
+ else if (k === 'R') { this._doSync(); }
162
237
  }
163
238
 
164
239
  _keySelect(k) {
@@ -202,6 +277,19 @@ export class TUI {
202
277
 
203
278
  // ── account operations ─────────────────────────────
204
279
 
280
+ async _doSync() {
281
+ try {
282
+ const count = await this.syncAccounts();
283
+ if (count > 0) {
284
+ this._addLog(`Synced ${count} new account(s) from config`);
285
+ } else {
286
+ this._addLog('Config reloaded, no new accounts');
287
+ }
288
+ } catch (e) {
289
+ this._addLog(`Sync failed: ${e.message}`);
290
+ }
291
+ }
292
+
205
293
  async _doImport() {
206
294
  try {
207
295
  const creds = await importCredentials('~/.claude/.credentials.json');
@@ -311,7 +399,7 @@ export class TUI {
311
399
  // Write buffer
312
400
  let buf = `${ESC}H`;
313
401
  for (let i = 0; i < H; i++) {
314
- buf += rpad(lines[i] || '', W);
402
+ buf += fitLine(lines[i] || '', W);
315
403
  if (i < H - 1) buf += '\r\n';
316
404
  }
317
405
  // Show cursor only in input mode
@@ -348,11 +436,13 @@ export class TUI {
348
436
 
349
437
  // Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
350
438
  const q = a.quota;
351
- let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ';
439
+ let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
352
440
 
353
441
  if (q.unified5h != null || q.unified7d != null) {
354
442
  r1 = q.unified5h;
355
443
  r2 = q.unified7d;
444
+ t1 = q.unified5hReset;
445
+ t2 = q.unified7dReset;
356
446
  } else {
357
447
  l1 = 'Tok';
358
448
  l2 = 'Req';
@@ -360,11 +450,13 @@ export class TUI {
360
450
  ? 1 - q.tokensRemaining / q.tokensLimit : null;
361
451
  r2 = (q.requestsLimit != null && q.requestsRemaining != null)
362
452
  ? 1 - q.requestsRemaining / q.requestsLimit : null;
453
+ t1 = q.resetsAt ? new Date(q.resetsAt).getTime() : null;
454
+ t2 = t1;
363
455
  }
364
456
 
365
- let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw)}`;
457
+ let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
366
458
  if (showBoth) {
367
- line += ` ${l2} ${bar(r2, bw)}`;
459
+ line += ` ${l2} ${bar(r2, bw, t2)}`;
368
460
  }
369
461
  return line;
370
462
  }
@@ -372,7 +464,7 @@ export class TUI {
372
464
  _renderFooter() {
373
465
  switch (this.mode) {
374
466
  case 'normal':
375
- return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('q')}uit`;
467
+ return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('R')}eload ${bold('q')}uit`;
376
468
  case 'select': {
377
469
  const act = this.selAction === 'switch' ? 'switch' : 'remove';
378
470
  return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;