@karpeleslab/teamclaude 1.0.0

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/src/tui.js ADDED
@@ -0,0 +1,388 @@
1
+ import { importCredentials } from './oauth.js';
2
+
3
+ // ── ANSI helpers ─────────────────────────────────────────────
4
+
5
+ const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'.split('');
6
+ const ESC = '\x1b[';
7
+ const RESET = `${ESC}0m`;
8
+ const BOLD = `${ESC}1m`;
9
+ const DIM = `${ESC}2m`;
10
+
11
+ const bold = s => `${BOLD}${s}${RESET}`;
12
+ const dim = s => `${DIM}${s}${RESET}`;
13
+ const fg = (c, s) => `${ESC}${c}m${s}${RESET}`;
14
+ const green = s => fg(32, s);
15
+ const yellow = s => fg(33, s);
16
+ const red = s => fg(31, s);
17
+ const cyan = s => fg(36, s);
18
+ const gray = s => fg(90, s);
19
+
20
+ const strip = s => s.replace(/\x1b\[[0-9;]*m/g, '');
21
+ const vw = s => strip(s).length;
22
+
23
+ function rpad(s, w) {
24
+ const gap = w - vw(s);
25
+ return gap > 0 ? s + ' '.repeat(gap) : s;
26
+ }
27
+
28
+ function bar(ratio, w = 10) {
29
+ if (ratio == null || isNaN(ratio)) return gray('░'.repeat(w)) + ' - ';
30
+ ratio = Math.max(0, Math.min(1, ratio));
31
+ 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}`;
35
+ }
36
+
37
+ function timestamp() {
38
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
39
+ }
40
+
41
+ // ── TUI class ────────────────────────────────────────────────
42
+
43
+ export class TUI {
44
+ constructor({ accountManager, config, saveConfig, onQuit }) {
45
+ this.am = accountManager;
46
+ this.config = config;
47
+ this.saveConfig = saveConfig;
48
+ this.onQuit = onQuit;
49
+
50
+ this.log = []; // completed activity entries
51
+ this.active = new Map(); // in-flight requests
52
+ this.mode = 'normal'; // normal | select | add | input
53
+ this.selAction = null; // switch | remove
54
+ this.selIdx = 0;
55
+ this.inputPrompt = '';
56
+ this.inputBuf = '';
57
+ this.inputCb = null;
58
+ this.frame = 0;
59
+ this.running = false;
60
+ this.timer = null;
61
+ this._origLog = null;
62
+ this._origErr = null;
63
+ }
64
+
65
+ // ── lifecycle ──────────────────────────────────────
66
+
67
+ start() {
68
+ this.running = true;
69
+ process.stdout.write(`${ESC}?1049h${ESC}?25l`);
70
+ process.stdin.setRawMode(true);
71
+ process.stdin.resume();
72
+ process.stdin.setEncoding('utf8');
73
+ this._dataHandler = d => this._onData(d);
74
+ this._resizeHandler = () => this.render();
75
+ process.stdin.on('data', this._dataHandler);
76
+ process.stdout.on('resize', this._resizeHandler);
77
+
78
+ // Redirect console to activity log
79
+ this._origLog = console.log;
80
+ this._origErr = console.error;
81
+ console.log = (...a) => this._addLog(a.join(' '));
82
+ console.error = (...a) => this._addLog(a.join(' '));
83
+
84
+ this.render();
85
+ this.timer = setInterval(() => {
86
+ this.frame = (this.frame + 1) % SPINNER.length;
87
+ this.render();
88
+ }, 500);
89
+ }
90
+
91
+ stop() {
92
+ this.running = false;
93
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
94
+ if (this._origLog) { console.log = this._origLog; console.error = this._origErr; }
95
+ process.stdin.removeListener('data', this._dataHandler);
96
+ process.stdout.removeListener('resize', this._resizeHandler);
97
+ process.stdout.write(`${ESC}?25h${ESC}?1049l`);
98
+ try { process.stdin.setRawMode(false); } catch {}
99
+ process.stdin.pause();
100
+ }
101
+
102
+ // ── server hooks ───────────────────────────────────
103
+
104
+ onRequestStart(id, info) {
105
+ this.active.set(id, { ...info, t: timestamp(), started: Date.now(), account: null });
106
+ this.render();
107
+ }
108
+
109
+ onRequestRouted(id, info) {
110
+ const r = this.active.get(id);
111
+ if (r) r.account = info.account;
112
+ }
113
+
114
+ onRequestEnd(id, info) {
115
+ const r = this.active.get(id);
116
+ this.active.delete(id);
117
+ const dur = r ? ((Date.now() - r.started) / 1000).toFixed(1) : '?';
118
+ const acct = info.account || r?.account || '?';
119
+ this._addLog(`${info.method} ${info.path} → ${acct} (${info.status}, ${dur}s)`);
120
+ }
121
+
122
+ _addLog(msg) {
123
+ msg = msg.replace(/^\[TeamClaude\]\s*/, '');
124
+ this.log.unshift({ t: timestamp(), msg });
125
+ if (this.log.length > 200) this.log.length = 200;
126
+ if (this.running) this.render();
127
+ }
128
+
129
+ // ── input handling ─────────────────────────────────
130
+
131
+ _onData(d) {
132
+ if (d === '\x1b[A') return this._key('up');
133
+ if (d === '\x1b[B') return this._key('down');
134
+ if (d === '\x1b') return this._key('esc');
135
+ if (d === '\r' || d === '\n') return this._key('enter');
136
+ if (d === '\x03') return this._key('ctrl-c');
137
+ if (d === '\x7f' || d === '\x08') return this._key('bs');
138
+ if (d.length === 1 && d >= ' ') return this._key(d);
139
+ }
140
+
141
+ _key(k) {
142
+ if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }
143
+
144
+ switch (this.mode) {
145
+ case 'normal': this._keyNormal(k); break;
146
+ case 'select': this._keySelect(k); break;
147
+ case 'add': this._keyAdd(k); break;
148
+ case 'input': this._keyInput(k); break;
149
+ }
150
+ this.render();
151
+ }
152
+
153
+ _keyNormal(k) {
154
+ if (k === 'q') { this.stop(); this.onQuit?.(); }
155
+ else if (k === 's' && this.am.accounts.length > 0) {
156
+ this.mode = 'select'; this.selAction = 'switch'; this.selIdx = this.am.currentIndex;
157
+ }
158
+ else if (k === 'r' && this.am.accounts.length > 0) {
159
+ this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0;
160
+ }
161
+ else if (k === 'a') { this.mode = 'add'; }
162
+ }
163
+
164
+ _keySelect(k) {
165
+ const len = this.am.accounts.length;
166
+ if (k === 'up' || k === 'k') this.selIdx = Math.max(0, this.selIdx - 1);
167
+ else if (k === 'down' || k === 'j') this.selIdx = Math.min(len - 1, this.selIdx + 1);
168
+ else if (k === 'enter') {
169
+ if (this.selAction === 'switch') {
170
+ this.am.currentIndex = this.selIdx;
171
+ this._addLog(`Switched to "${this.am.accounts[this.selIdx].name}"`);
172
+ } else {
173
+ this._doRemove(this.selIdx);
174
+ }
175
+ this.mode = 'normal';
176
+ }
177
+ else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
178
+ }
179
+
180
+ _keyAdd(k) {
181
+ if (k === 'i') { this._doImport(); this.mode = 'normal'; }
182
+ else if (k === 'k') {
183
+ this.mode = 'input';
184
+ this.inputPrompt = 'API key';
185
+ this.inputBuf = '';
186
+ this.inputCb = v => { if (v) this._doAddKey(v); };
187
+ }
188
+ else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
189
+ }
190
+
191
+ _keyInput(k) {
192
+ if (k === 'enter') {
193
+ const cb = this.inputCb;
194
+ const v = this.inputBuf;
195
+ this.mode = 'normal'; this.inputCb = null; this.inputBuf = '';
196
+ cb?.(v);
197
+ }
198
+ else if (k === 'esc') { this.mode = 'normal'; this.inputCb = null; this.inputBuf = ''; }
199
+ else if (k === 'bs') { this.inputBuf = this.inputBuf.slice(0, -1); }
200
+ else if (k.length === 1) { this.inputBuf += k; }
201
+ }
202
+
203
+ // ── account operations ─────────────────────────────
204
+
205
+ async _doImport() {
206
+ try {
207
+ const creds = await importCredentials('~/.claude/.credentials.json');
208
+ const n = this.config.accounts.filter(a => a.name.startsWith('max-')).length + 1;
209
+ const name = `max-${n}`;
210
+ const entry = {
211
+ name, type: 'oauth',
212
+ accessToken: creds.accessToken,
213
+ refreshToken: creds.refreshToken,
214
+ expiresAt: creds.expiresAt,
215
+ };
216
+ this.config.accounts.push(entry);
217
+ this.am.addAccount(entry);
218
+ await this.saveConfig(this.config);
219
+ this._addLog(`Imported account "${name}"`);
220
+ } catch (e) {
221
+ this._addLog(`Import failed: ${e.message}`);
222
+ }
223
+ }
224
+
225
+ async _doAddKey(apiKey) {
226
+ const n = this.config.accounts.filter(a => a.name.startsWith('api-')).length + 1;
227
+ const name = `api-${n}`;
228
+ this.config.accounts.push({ name, type: 'apikey', apiKey });
229
+ this.am.addAccount({ name, type: 'apikey', apiKey });
230
+ await this.saveConfig(this.config);
231
+ this._addLog(`Added API key account "${name}"`);
232
+ }
233
+
234
+ async _doRemove(idx) {
235
+ if (idx < 0 || idx >= this.am.accounts.length) return;
236
+ const name = this.am.accounts[idx].name;
237
+ this.am.removeAccount(idx);
238
+ this.config.accounts.splice(idx, 1);
239
+ if (this.selIdx >= this.am.accounts.length) this.selIdx = Math.max(0, this.am.accounts.length - 1);
240
+ await this.saveConfig(this.config);
241
+ this._addLog(`Removed account "${name}"`);
242
+ }
243
+
244
+ // ── rendering ──────────────────────────────────────
245
+
246
+ render() {
247
+ if (!this.running) return;
248
+ const W = process.stdout.columns || 80;
249
+ const H = process.stdout.rows || 24;
250
+
251
+ if (W < 40 || H < 8) {
252
+ process.stdout.write(`${ESC}H${ESC}2JTerminal too small (need 40x8+)\r\n`);
253
+ return;
254
+ }
255
+
256
+ const lines = [];
257
+
258
+ // ── Header
259
+ const left = bold(' TeamClaude');
260
+ const port = this.config.proxy?.port || 3456;
261
+ const right = `Port ${port} ${green('▲')} `;
262
+ lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
263
+ lines.push(' ' + dim('─'.repeat(W - 2)));
264
+
265
+ // ── Accounts
266
+ if (this.am.accounts.length === 0) {
267
+ lines.push('');
268
+ lines.push(yellow(' No accounts configured. Press [a] to add one.'));
269
+ } else {
270
+ lines.push('');
271
+ const showBoth = W >= 70;
272
+ const bw = showBoth
273
+ ? Math.max(5, Math.min(20, Math.floor((W - 56) / 2)))
274
+ : Math.max(5, Math.min(20, W - 45));
275
+
276
+ for (let i = 0; i < this.am.accounts.length; i++) {
277
+ lines.push(this._renderAcct(i, bw, showBoth));
278
+ }
279
+ }
280
+
281
+ // ── Activity header
282
+ lines.push('');
283
+ const ac = this.active.size;
284
+ const acTag = ac > 0 ? ` ${cyan(ac + ' active')}` : '';
285
+ const aHdr = ` Activity${acTag} `;
286
+ lines.push(aHdr + dim('─'.repeat(Math.max(1, W - vw(aHdr)))));
287
+
288
+ // Active requests
289
+ const now = Date.now();
290
+ for (const [, r] of this.active) {
291
+ const el = ((now - r.started) / 1000).toFixed(1);
292
+ const sp = cyan(SPINNER[this.frame]);
293
+ const a = r.account ? ` → ${r.account}` : '';
294
+ lines.push(` ${sp} ${gray(r.t)} ${r.method} ${r.path}${a} ${dim(`(${el}s...)`)}`);
295
+ }
296
+
297
+ // Completed log
298
+ const footerH = 2;
299
+ const space = Math.max(0, H - lines.length - footerH);
300
+ for (let i = 0; i < space && i < this.log.length; i++) {
301
+ lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
302
+ }
303
+
304
+ // Pad to fill
305
+ while (lines.length < H - footerH) lines.push('');
306
+
307
+ // ── Footer
308
+ lines.push(' ' + dim('─'.repeat(W - 2)));
309
+ lines.push(this._renderFooter());
310
+
311
+ // Write buffer
312
+ let buf = `${ESC}H`;
313
+ for (let i = 0; i < H; i++) {
314
+ buf += rpad(lines[i] || '', W);
315
+ if (i < H - 1) buf += '\r\n';
316
+ }
317
+ // Show cursor only in input mode
318
+ buf += this.mode === 'input' ? `${ESC}?25h` : `${ESC}?25l`;
319
+ process.stdout.write(buf);
320
+ }
321
+
322
+ _renderAcct(idx, bw, showBoth) {
323
+ const a = this.am.accounts[idx];
324
+ const isCur = idx === this.am.currentIndex;
325
+ const isSel = this.mode === 'select' && idx === this.selIdx;
326
+
327
+ // Prefix: selection marker + current marker
328
+ const sel = isSel ? cyan('>') : ' ';
329
+ const cur = isCur ? green('►') : ' ';
330
+
331
+ // Name (bold if selected)
332
+ const rawName = a.name.slice(0, 12).padEnd(12);
333
+ const name = isSel ? bold(rawName) : rawName;
334
+
335
+ // Type
336
+ const type = gray(a.type.padEnd(7));
337
+
338
+ // Status
339
+ let status;
340
+ switch (a.status) {
341
+ case 'active': status = isCur ? green('active') : 'active'; break;
342
+ case 'throttled': status = yellow('throttled'); break;
343
+ case 'exhausted': status = red('exhausted'); break;
344
+ case 'error': status = red('error'); break;
345
+ default: status = a.status || 'ready';
346
+ }
347
+ status = rpad(status, 10);
348
+
349
+ // Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
350
+ const q = a.quota;
351
+ let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ';
352
+
353
+ if (q.unified5h != null || q.unified7d != null) {
354
+ r1 = q.unified5h;
355
+ r2 = q.unified7d;
356
+ } else {
357
+ l1 = 'Tok';
358
+ l2 = 'Req';
359
+ r1 = (q.tokensLimit != null && q.tokensRemaining != null)
360
+ ? 1 - q.tokensRemaining / q.tokensLimit : null;
361
+ r2 = (q.requestsLimit != null && q.requestsRemaining != null)
362
+ ? 1 - q.requestsRemaining / q.requestsLimit : null;
363
+ }
364
+
365
+ let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw)}`;
366
+ if (showBoth) {
367
+ line += ` ${l2} ${bar(r2, bw)}`;
368
+ }
369
+ return line;
370
+ }
371
+
372
+ _renderFooter() {
373
+ switch (this.mode) {
374
+ case 'normal':
375
+ return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('q')}uit`;
376
+ case 'select': {
377
+ const act = this.selAction === 'switch' ? 'switch' : 'remove';
378
+ return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
379
+ }
380
+ case 'add':
381
+ return ` ${bold('i')}mport Claude Code ${bold('k')} API key ${bold('Esc')} cancel`;
382
+ case 'input':
383
+ return ` ${this.inputPrompt}: ${this.inputBuf}█`;
384
+ default:
385
+ return '';
386
+ }
387
+ }
388
+ }