Skip to content
Open
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
41 changes: 40 additions & 1 deletion src/account-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class AccountManager {
refreshToken: acct.refreshToken || null,
expiresAt: acct.expiresAt || null,
status: 'active',
quota: emptyQuota(),
quota: acct.savedQuota ? { ...emptyQuota(), ...acct.savedQuota } : emptyQuota(),
usage: {
totalInputTokens: 0,
totalOutputTokens: 0,
Expand Down Expand Up @@ -206,6 +206,27 @@ export class AccountManager {
}
}

/**
* Export serializable quota state for a single account — safe to persist to config.
*/
exportQuota(accountIndex) {
const account = this.accounts[accountIndex];
if (!account) return null;
const q = account.quota;
return {
unified5h: q.unified5h,
unified7d: q.unified7d,
unified5hReset: q.unified5hReset,
unified7dReset: q.unified7dReset,
unifiedStatus: q.unifiedStatus,
tokensLimit: q.tokensLimit,
tokensRemaining: q.tokensRemaining,
requestsLimit: q.requestsLimit,
requestsRemaining: q.requestsRemaining,
resetsAt: q.resetsAt,
};
}

/**
* Update cumulative token usage from response body data.
*/
Expand Down Expand Up @@ -326,6 +347,24 @@ export class AccountManager {
}
}

/**
* Move an account from one index to another, updating currentIndex accordingly.
*/
moveAccount(from, to) {
if (from === to || from < 0 || to < 0 ||
from >= this.accounts.length || to >= this.accounts.length) return;
const [account] = this.accounts.splice(from, 1);
this.accounts.splice(to, 0, account);
this.accounts.forEach((a, i) => a.index = i);
if (this.currentIndex === from) {
this.currentIndex = to;
} else if (from < to) {
if (this.currentIndex > from && this.currentIndex <= to) this.currentIndex--;
} else {
if (this.currentIndex >= to && this.currentIndex < from) this.currentIndex++;
}
}

/**
* Return a status summary of all accounts (safe to expose, no credentials).
*/
Expand Down
39 changes: 36 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,25 @@ async function serverCommand() {
}
}).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
});

async function persistQuotaState() {
await atomicConfigUpdate(diskConfig => {
for (const account of accountManager.accounts) {
const cfgIdx = findConfigAccount(diskConfig, account);
if (cfgIdx >= 0) {
diskConfig.accounts[cfgIdx].savedQuota = accountManager.exportQuota(account.index);
}
}
});
}

const quotaSaveInterval = setInterval(
() => persistQuotaState().catch(err =>
console.error(`[TeamClaude] Failed to save quota state: ${err.message}`)
),
60_000
);

const port = config.proxy.port;
const useTUI = process.stdout.isTTY && process.stdin.isTTY;

Expand Down Expand Up @@ -152,7 +171,13 @@ async function serverCommand() {
if (!diskConfig) return 0;
return syncAccountsFromDisk(diskConfig, config, accountManager);
},
onQuit: () => { server.close(() => process.exit(0)); },
onQuit: async () => {
clearInterval(quotaSaveInterval);
await persistQuotaState().catch(err =>
console.error(`[TeamClaude] Failed to save quota on quit: ${err.message}`)
);
server.close(() => process.exit(0));
},
});
hooks = {
onRequestStart: (id, info) => tui.onRequestStart(id, info),
Expand Down Expand Up @@ -190,12 +215,20 @@ async function serverCommand() {
});

if (!tui) {
process.on('SIGINT', () => {
process.on('SIGINT', async () => {
console.log('\n[TeamClaude] Shutting down...');
clearInterval(quotaSaveInterval);
await persistQuotaState().catch(err =>
console.error(`[TeamClaude] Failed to save quota on shutdown: ${err.message}`)
);
server.close(() => process.exit(0));
});
process.on('SIGTERM', () => {
process.on('SIGTERM', async () => {
console.log('\n[TeamClaude] Shutting down...');
clearInterval(quotaSaveInterval);
await persistQuotaState().catch(err =>
console.error(`[TeamClaude] Failed to save quota on shutdown: ${err.message}`)
);
server.close(() => process.exit(0));
});
}
Expand Down
69 changes: 62 additions & 7 deletions src/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export class TUI {
this.mode = 'normal'; // normal | select | add | input
this.selAction = null; // switch | remove
this.selIdx = 0;
this.reorderGrabbed = null;
this.reorderOrigOrder = null;
this.reorderOrigCurrentIdx = null;
this.inputPrompt = '';
this.inputBuf = '';
this.inputCb = null;
Expand Down Expand Up @@ -216,10 +219,11 @@ export class TUI {
if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }

switch (this.mode) {
case 'normal': this._keyNormal(k); break;
case 'select': this._keySelect(k); break;
case 'add': this._keyAdd(k); break;
case 'input': this._keyInput(k); break;
case 'normal': this._keyNormal(k); break;
case 'select': this._keySelect(k); break;
case 'reorder': this._keyReorder(k); break;
case 'add': this._keyAdd(k); break;
case 'input': this._keyInput(k); break;
}
this.render();
}
Expand All @@ -234,6 +238,10 @@ export class TUI {
}
else if (k === 'a') { this.mode = 'add'; }
else if (k === 'R') { this._doSync(); }
else if (k === 'o' && this.am.accounts.length > 1) {
this.mode = 'reorder'; this.selIdx = this.am.currentIndex;
this.reorderGrabbed = null; this.reorderOrigOrder = null;
}
}

_keySelect(k) {
Expand All @@ -252,6 +260,36 @@ export class TUI {
else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
}

_keyReorder(k) {
const len = this.am.accounts.length;
if (this.reorderGrabbed === null) {
if (k === 'up' || k === 'k') this.selIdx = Math.max(0, this.selIdx - 1);
else if (k === 'down' || k === 'j') this.selIdx = Math.min(len - 1, this.selIdx + 1);
else if (k === 'enter') {
this.reorderGrabbed = this.selIdx;
this.reorderOrigOrder = [...this.am.accounts];
this.reorderOrigCurrentIdx = this.am.currentIndex;
}
else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
} else {
if ((k === 'up' || k === 'k') && this.selIdx > 0) {
this.am.moveAccount(this.selIdx, this.selIdx - 1);
this.selIdx--;
} else if ((k === 'down' || k === 'j') && this.selIdx < len - 1) {
this.am.moveAccount(this.selIdx, this.selIdx + 1);
this.selIdx++;
} else if (k === 'enter') {
this._doSaveReorder();
this.mode = 'normal'; this.reorderGrabbed = null; this.reorderOrigOrder = null;
} else if (k === 'esc' || k === 'q') {
this.am.accounts.splice(0, this.am.accounts.length, ...this.reorderOrigOrder);
this.am.accounts.forEach((a, i) => a.index = i);
this.am.currentIndex = this.reorderOrigCurrentIdx;
this.mode = 'normal'; this.reorderGrabbed = null; this.reorderOrigOrder = null;
}
}
}

_keyAdd(k) {
if (k === 'i') { this._doImport(); this.mode = 'normal'; }
else if (k === 'k') {
Expand Down Expand Up @@ -369,6 +407,17 @@ export class TUI {
this._addLog(`Removed account "${name}"`);
}

async _doSaveReorder() {
const reordered = this.am.accounts.map(a =>
this.config.accounts.find(c =>
(a.accountUuid && c.accountUuid === a.accountUuid) || c.name === a.name
)
).filter(Boolean);
this.config.accounts = reordered;
await this.saveConfig(this.config);
this._addLog('Account order saved');
}

// ── rendering ──────────────────────────────────────

render() {
Expand Down Expand Up @@ -450,10 +499,11 @@ export class TUI {
_renderAcct(idx, bw, showBoth) {
const a = this.am.accounts[idx];
const isCur = idx === this.am.currentIndex;
const isSel = this.mode === 'select' && idx === this.selIdx;
const isSel = (this.mode === 'select' || this.mode === 'reorder') && idx === this.selIdx;
const isGrabbed = this.mode === 'reorder' && this.reorderGrabbed !== null && idx === this.selIdx;

// Prefix: selection marker + current marker
const sel = isSel ? cyan('>') : ' ';
const sel = isGrabbed ? cyan('↕') : isSel ? cyan('>') : ' ';
const cur = isCur ? green('►') : ' ';

// Name (bold if selected)
Expand Down Expand Up @@ -504,11 +554,16 @@ export class TUI {
_renderFooter() {
switch (this.mode) {
case 'normal':
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('R')}eload ${bold('q')}uit`;
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('o')}rder ${bold('R')}eload ${bold('q')}uit`;
case 'select': {
const act = this.selAction === 'switch' ? 'switch' : 'remove';
return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
}
case 'reorder':
if (this.reorderGrabbed === null) {
return ` ${dim('↑↓')} select ${bold('Enter')} grab ${bold('Esc')} cancel`;
}
return ` ${dim('↑↓')} move ${bold('Enter')} confirm ${bold('Esc')} cancel`;
case 'add':
return ` ${bold('i')}mport Claude Code ${bold('k')} API key ${bold('Esc')} cancel`;
case 'input':
Expand Down