ccm-account-manager 1.13.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/app.js ADDED
@@ -0,0 +1,800 @@
1
+ // ccm's face: a split-flap departure board for Claude Code accounts.
2
+ // Views: board (accounts) · departures (sessions for this folder) · doctor.
3
+
4
+ import { Screen, Canvas } from './term.js';
5
+ import { Cascade, WORDMARK, drawMeter, METER_TILES } from './flap.js';
6
+ import { INK, INK2, MUTED, AMBER, GOOD, CRITICAL, hueOf, meterColor } from './theme.js';
7
+ import { listProfiles, registerProfile, refreshIdentity } from '../registry.js';
8
+ import { prepareProfileDir, hasDefaultLogin, importDefaultInto, removeProfile } from '../profiles.js';
9
+ import { refreshWtIfInstalled } from '../wt.js';
10
+ import { loadCache, refreshAll, headroom, ERROR_HINTS, DEFAULT_MAX_AGE_MS, isStale } from '../usage.js';
11
+ import { isRunning, launchProfile } from '../launch.js';
12
+ import { authState } from '../oauth.js';
13
+ import { sortByHeadroom } from '../picker.js';
14
+ import os from 'node:os';
15
+ import { slugForPath, listSessions, copySessionTo } from '../sessions.js';
16
+ import { listAccountServers, copyServer } from '../mcp.js';
17
+ import { collectDoctor } from '../doctor.js';
18
+ import { timeAgo, timeUntil } from '../util.js';
19
+
20
+ const S = {
21
+ ink: { fg: INK }, inkB: { fg: INK, bold: true }, ink2: { fg: INK2 },
22
+ muted: { fg: MUTED }, seam: { fg: MUTED, dim: true },
23
+ amber: { fg: AMBER }, amberB: { fg: AMBER, bold: true },
24
+ good: { fg: GOOD }, crit: { fg: CRITICAL, bold: true },
25
+ };
26
+
27
+ function sessionWindows(usage) {
28
+ const find = (pred) => (usage?.windows ?? []).find(pred);
29
+ return {
30
+ fiveH: find((w) => w.label.startsWith('session')),
31
+ week: find((w) => w.label === 'week (all models)'),
32
+ };
33
+ }
34
+
35
+ function isStaleUsage(usage) {
36
+ return !!usage && (usage.staleError || isStale(usage));
37
+ }
38
+
39
+ function chipFor(p, usage, isBest) {
40
+ if (p.loggedOut) return { text: '✕ LOGGED OUT', style: S.crit };
41
+ if (isRunning(p.name)) return { text: '● IN SESSION', style: S.good };
42
+ // Never assert FULL / MOST HEADROOM off data we couldn't refresh.
43
+ if (isStaleUsage(usage)) return { text: '⟳ STALE', style: S.muted };
44
+ const worst = Math.max(0, ...(usage?.windows ?? []).map((w) => w.percent));
45
+ if (worst >= 95) return { text: '■ FULL', style: S.crit };
46
+ if (worst >= 80) return { text: '▲ ALMOST FULL', style: S.amber };
47
+ if (isBest) return { text: '✦ MOST HEADROOM', style: S.good };
48
+ return null;
49
+ }
50
+
51
+ // Pure board renderer — headless-testable.
52
+ export function renderBoard(state, w, h) {
53
+ const c = new Canvas(w, h);
54
+ const { profiles, cache, sel, clock, msg, spin } = state;
55
+
56
+ WORDMARK.forEach((row, i) => c.put(2, i, row, i === 1 ? S.seam : S.ink));
57
+ c.put(w - 2 - clock.length, 0, clock, S.amberB);
58
+ if (spin) c.put(w - 12, 1, 'FETCHING…', S.muted);
59
+ else if (msg) c.put(w - 2 - msg.length, 1, msg, S.ink2);
60
+ c.put(2, 3, [...'CLAUDE CODE ACCOUNT BOARD'].join(' '), S.amber);
61
+ c.put(2, 4, '─'.repeat(w - 4), S.seam);
62
+
63
+ if (!profiles.length) {
64
+ c.put(4, 7, 'NO ACCOUNTS ON THE BOARD', S.inkB);
65
+ c.put(4, 9, 'Press ', S.ink2);
66
+ c.put(10, 9, 'a', S.amberB);
67
+ c.put(12, 9, 'to add your first account.', S.ink2);
68
+ }
69
+
70
+ const rowH = h >= 6 + profiles.length * 4 + 2 ? 4 : 3;
71
+ let y = 6;
72
+ const bestName = profiles.length > 1 && headroom(cache[profiles[0]?.name]) != null ? profiles[0].name : null;
73
+ for (const [i, p] of profiles.entries()) {
74
+ if (y + 3 > h - 2) break;
75
+ const usage = cache[p.name];
76
+ const selMark = i === sel;
77
+ c.put(2, y, selMark ? '▌' : ' ', S.amberB);
78
+ c.put(4, y, '●', { fg: hueOf(p.color) });
79
+ c.put(6, y, p.name.toUpperCase().padEnd(13), selMark ? S.inkB : S.ink);
80
+ c.put(20, y, (p.email ?? 'not logged in').padEnd(28), selMark ? S.ink2 : S.muted);
81
+ if (p.plan) c.put(49, y, p.plan.toUpperCase(), S.muted);
82
+ const chip = chipFor(p, usage, p.name === bestName);
83
+ if (chip) c.put(w - 2 - chip.text.length, y, chip.text, chip.style);
84
+
85
+ const { fiveH, week } = sessionWindows(usage);
86
+ const stale = isStaleUsage(usage);
87
+ const meterRow = (yy, label, win) => {
88
+ c.put(6, yy, label, S.muted);
89
+ if (!win) {
90
+ c.put(11, yy, usage?.error ? (ERROR_HINTS[usage.error] ?? usage.error) : 'no usage data — press r', S.seam);
91
+ return;
92
+ }
93
+ // Stale meters render in the recessive seam tone, not the live fill/amber.
94
+ const end = drawMeter(c, 11, yy, win.percent, { fg: stale ? MUTED : meterColor(win.percent) }, S.seam);
95
+ c.put(end + 1, yy, `${String(Math.round(win.percent)).padStart(3)}%`, stale ? S.muted : S.ink2);
96
+ c.put(end + 7, yy, 'RESETS', S.muted);
97
+ c.put(end + 14, yy, timeUntil(win.resetsAt).toUpperCase(), stale ? S.muted : S.amber);
98
+ };
99
+ meterRow(y + 1, '5H', fiveH);
100
+ meterRow(y + 2, 'WK', week);
101
+ if (stale && usage?.fetchedAt) {
102
+ const asOf = `as of ${timeAgo(new Date(usage.fetchedAt).toISOString())}`;
103
+ c.put(w - 2 - asOf.length, y + 1, asOf, S.muted);
104
+ }
105
+ y += rowH;
106
+ }
107
+
108
+ c.put(2, h - 2, '─'.repeat(w - 4), S.seam);
109
+ drawKeybar(c, h - 1, [['↑↓', 'select'], ['enter', 'launch'], ['l', 'login'], ['a', 'add'], ['-', 'remove'], ['s', 'departures'], ['x', 'mcp'], ['r', 'refresh'], ['d', 'doctor'], ['q', 'quit']]);
110
+ return c;
111
+ }
112
+
113
+ function drawKeybar(c, y, keys) {
114
+ let x = 2;
115
+ for (const [k, label] of keys) {
116
+ c.put(x, y, k, S.amber);
117
+ x += k.length + 1;
118
+ c.put(x, y, label, S.muted);
119
+ x += label.length + 2;
120
+ }
121
+ }
122
+
123
+ function shortDir(dir, max = 60) {
124
+ if (!dir) return '?';
125
+ const short = dir.replace(/\\+$/, '').startsWith(os.homedir()) ? '~' + dir.slice(os.homedir().length) : dir;
126
+ return short.length > max ? '…' + short.slice(-(max - 1)) : short;
127
+ }
128
+
129
+ // Rows for the departures view. This-folder scope: a flat session list.
130
+ // All-folders scope: sessions grouped under collapsible folder headers,
131
+ // groups ordered by their newest session. openDirs = Set of expanded dirs
132
+ // (null → only the newest folder starts open).
133
+ export function buildSessionRows(sessions, scope, openDirs = null) {
134
+ if (scope !== 'all') return (sessions ?? []).map((s) => ({ type: 'session', s }));
135
+ const groups = new Map();
136
+ for (const s of sessions ?? []) {
137
+ const dir = shortDir(s.cwd ?? s.slug);
138
+ if (!groups.has(dir)) groups.set(dir, []);
139
+ groups.get(dir).push(s);
140
+ }
141
+ const ordered = [...groups.entries()].sort((a, b) => b[1][0].mtime - a[1][0].mtime);
142
+ const open = openDirs ?? new Set(ordered.slice(0, 1).map(([dir]) => dir));
143
+ const rows = [];
144
+ for (const [dir, list] of ordered) {
145
+ rows.push({ type: 'group', dir, count: list.length, latest: list[0].mtime, open: open.has(dir) });
146
+ if (open.has(dir)) for (const s of list) rows.push({ type: 'session', s, grouped: true });
147
+ }
148
+ return rows;
149
+ }
150
+
151
+ function drawSessionRow(c, y, w, row, selMark) {
152
+ const s = row.s;
153
+ const x = row.grouped ? 5 : 2;
154
+ c.put(x, y, selMark ? '▌' : ' ', S.amberB);
155
+ c.put(x + 2, y, s.source.kind === 'profile' ? '●' : '○', s.source.kind === 'profile' ? { fg: hueOf(s.source.color) } : S.muted);
156
+ c.put(x + 4, y, timeAgo(new Date(s.mtime).toISOString()).padEnd(11), S.ink2);
157
+ c.put(x + 16, y, s.source.label.padEnd(10), S.muted);
158
+ const titleW = Math.max(0, w - x - 40);
159
+ if (s.title) c.put(x + 27, y, s.title.slice(0, titleW), selMark ? S.ink : S.ink2);
160
+ else c.put(x + 27, y, 'untitled', S.seam);
161
+ c.put(w - 11, y, s.id.slice(0, 8), selMark ? S.ink2 : S.seam);
162
+ }
163
+
164
+ function drawGroupRow(c, y, w, row, selMark) {
165
+ c.put(2, y, selMark ? '▌' : ' ', S.amberB);
166
+ c.put(4, y, row.open ? '▾' : '▸', S.amber);
167
+ c.put(6, y, row.dir.slice(0, w - 46), selMark ? S.inkB : S.ink);
168
+ const meta = `${row.count} SESSION${row.count === 1 ? '' : 'S'} · ${timeAgo(new Date(row.latest).toISOString()).toUpperCase()}`;
169
+ c.put(w - 2 - meta.length, y, meta, S.muted);
170
+ }
171
+
172
+ export function renderSessions(state, w, h) {
173
+ const c = new Canvas(w, h);
174
+ const all = state.scope === 'all';
175
+ const rows = state.rows ?? [];
176
+ const total = rows.filter((r) => r.type === 'session').length + (all ? 0 : 0);
177
+ c.put(2, 0, 'DEPARTURES', S.amberB);
178
+ c.put(14, 0, all ? `ALL FOLDERS · ${state.total ?? total} SESSIONS` : state.cwd.toUpperCase(), S.muted);
179
+ c.put(w - 2 - state.clock.length, 0, state.clock, S.amberB);
180
+ c.put(2, 1, '─'.repeat(w - 4), S.seam);
181
+ if (!rows.length) {
182
+ c.put(4, 3, all ? 'NO SESSIONS ON ANY ACCOUNT' : 'NO SESSIONS FOR THIS FOLDER', S.inkB);
183
+ c.put(4, 5, all ? 'Sessions appear here once you have worked with Claude Code.'
184
+ : 'Press a to see every folder, or work with Claude Code here first.', S.ink2);
185
+ }
186
+ const viewH = h - 6;
187
+ const top = state.scrollTop ?? 0;
188
+ for (let i = 0; i < viewH && top + i < rows.length; i++) {
189
+ const row = rows[top + i];
190
+ const y = 3 + i;
191
+ const selMark = top + i === state.sel;
192
+ if (row.type === 'group') drawGroupRow(c, y, w, row, selMark);
193
+ else drawSessionRow(c, y, w, row, selMark);
194
+ }
195
+ if (top > 0) c.put(w - 4, 2, '▲', S.muted);
196
+ if (top + viewH < rows.length) c.put(w - 4, h - 3, `▼ ${rows.length - top - viewH}`, S.muted);
197
+ c.put(2, h - 2, '─'.repeat(w - 4), S.seam);
198
+ drawKeybar(c, h - 1, [['↑↓', 'select'], ['enter', all ? 'resume / fold' : 'resume'], ['m', 'transfer'], ['a', all ? 'this folder' : 'all folders'], ['esc', 'board']]);
199
+ return c;
200
+ }
201
+
202
+ export function renderDoctor(state, w, h) {
203
+ const c = new Canvas(w, h);
204
+ c.put(2, 0, 'DOCTOR', S.amberB);
205
+ c.put(w - 2 - state.clock.length, 0, state.clock, S.amberB);
206
+ c.put(2, 1, '─'.repeat(w - 4), S.seam);
207
+ if (!state.doctor) {
208
+ c.put(4, 3, 'RUNNING CHECKS…', S.ink2);
209
+ } else {
210
+ const lamp = { ok: ['●', S.good], warn: ['▲', S.amber], err: ['■', S.crit] };
211
+ let y = 3;
212
+ let lastGroup = '';
213
+ for (const e of state.doctor.entries) {
214
+ if (y > h - 3) break;
215
+ if (e.group && e.group !== lastGroup) {
216
+ c.put(4, y, e.group.toUpperCase(), S.inkB);
217
+ y += 1;
218
+ }
219
+ lastGroup = e.group;
220
+ const [g, st] = lamp[e.level];
221
+ c.put(e.group ? 6 : 4, y, g, st);
222
+ c.put(e.group ? 8 : 6, y, e.msg.slice(0, w - 10), S.ink2);
223
+ y += 1;
224
+ }
225
+ }
226
+ c.put(2, h - 2, '─'.repeat(w - 4), S.seam);
227
+ drawKeybar(c, h - 1, [['r', 'run again'], ['esc', 'board']]);
228
+ return c;
229
+ }
230
+
231
+ const baseName = (p) => (p ? p.split(/[\\/]/).filter(Boolean).pop() : '');
232
+
233
+ function drawMcpAccount(c, y, w, row) {
234
+ c.put(2, y, '●', { fg: hueOf(row.color) });
235
+ c.put(4, y, row.name.toUpperCase(), S.inkB);
236
+ const meta = row.count ? `${row.count} SERVER${row.count === 1 ? '' : 'S'}` : 'SHARED ONLY';
237
+ c.put(w - 2 - meta.length, y, meta, S.muted);
238
+ }
239
+
240
+ function drawMcpServer(c, y, w, row, selMark) {
241
+ c.put(4, y, selMark ? '▌' : ' ', S.amberB);
242
+ c.put(6, y, '›', S.muted);
243
+ c.put(8, y, row.name.slice(0, 26).padEnd(26), selMark ? S.inkB : S.ink);
244
+ const tag = row.scope === 'local' ? `local · ${baseName(row.project)}` : 'user · everywhere';
245
+ c.put(36, y, tag.slice(0, Math.max(0, w - 40)), selMark ? S.amber : S.muted);
246
+ }
247
+
248
+ // MCP view: each account with its own (user/local) servers; pick one to copy
249
+ // into another account. Only server rows are selectable.
250
+ export function renderMcp(state, w, h) {
251
+ const c = new Canvas(w, h);
252
+ const rows = state.mcpRows ?? [];
253
+ c.put(2, 0, 'MCP SERVERS', S.amberB);
254
+ if (state.msg) c.put(14, 0, state.msg.slice(0, w - 26), S.ink2);
255
+ else c.put(14, 0, 'COPY BETWEEN ACCOUNTS', S.muted);
256
+ c.put(w - 2 - state.clock.length, 0, state.clock, S.amberB);
257
+ c.put(2, 1, '─'.repeat(w - 4), S.seam);
258
+ const servers = rows.filter((r) => r.type === 'server').length;
259
+ if (!servers) {
260
+ c.put(4, 3, 'NO ACCOUNT-SPECIFIC MCP SERVERS', S.inkB);
261
+ c.put(4, 5, 'Servers you add with `claude mcp add` show up here, ready to copy.', S.ink2);
262
+ c.put(4, 6, 'Shared servers are already injected into every account.', S.ink2);
263
+ }
264
+ const viewH = h - 6;
265
+ const top = state.scrollTop ?? 0;
266
+ for (let i = 0; i < viewH && top + i < rows.length; i++) {
267
+ const row = rows[top + i];
268
+ const y = 3 + i;
269
+ const selMark = top + i === state.sel;
270
+ if (row.type === 'account') drawMcpAccount(c, y, w, row);
271
+ else drawMcpServer(c, y, w, row, selMark);
272
+ }
273
+ if (top > 0) c.put(w - 4, 2, '▲', S.muted);
274
+ if (top + viewH < rows.length) c.put(w - 4, h - 3, `▼ ${rows.length - top - viewH}`, S.muted);
275
+ c.put(2, h - 2, '─'.repeat(w - 4), S.seam);
276
+ drawKeybar(c, h - 1, [['↑↓', 'select'], ['enter', 'copy →'], ['esc', 'board']]);
277
+ return c;
278
+ }
279
+
280
+ function drawBox(c, w, h, ow, oh) {
281
+ const x0 = Math.max(1, ((w - ow) / 2) | 0);
282
+ const y0 = Math.max(1, ((h - oh) / 2) | 0);
283
+ for (let y = y0; y < y0 + oh; y++) c.put(x0, y, ' '.repeat(ow));
284
+ c.put(x0, y0, '╭' + '─'.repeat(ow - 2) + '╮', S.amber);
285
+ for (let y = y0 + 1; y < y0 + oh - 1; y++) {
286
+ c.put(x0, y, '│', S.amber);
287
+ c.put(x0 + ow - 1, y, '│', S.amber);
288
+ }
289
+ c.put(x0, y0 + oh - 1, '╰' + '─'.repeat(ow - 2) + '╯', S.amber);
290
+ return { x0, y0 };
291
+ }
292
+
293
+ function drawOverlay(c, w, h, state) {
294
+ const o = state.overlay;
295
+ if (o.kind === 'transfer') {
296
+ const { x0, y0 } = drawBox(c, w, h, 44, o.targets.length + 4);
297
+ c.put(x0 + 2, y0 + 1, `TRANSFER ${o.session.id.slice(0, 8)} TO`, S.amberB);
298
+ o.targets.forEach((t, i) => {
299
+ const y = y0 + 2 + i;
300
+ c.put(x0 + 2, y, i === o.sel ? '▌' : ' ', S.amberB);
301
+ c.put(x0 + 4, y, '●', { fg: hueOf(t.color) });
302
+ c.put(x0 + 6, y, t.name.toUpperCase(), i === o.sel ? S.inkB : S.ink);
303
+ });
304
+ return;
305
+ }
306
+ if (o.kind === 'mcp-target') {
307
+ const { x0, y0 } = drawBox(c, w, h, 48, o.targets.length + 4);
308
+ c.put(x0 + 2, y0 + 1, `COPY ${o.server.name.toUpperCase()} TO`.slice(0, 44), S.amberB);
309
+ o.targets.forEach((t, i) => {
310
+ const y = y0 + 2 + i;
311
+ c.put(x0 + 2, y, i === o.sel ? '▌' : ' ', S.amberB);
312
+ c.put(x0 + 4, y, '●', { fg: hueOf(t.color) });
313
+ c.put(x0 + 6, y, t.name.toUpperCase(), i === o.sel ? S.inkB : S.ink);
314
+ });
315
+ return;
316
+ }
317
+ if (o.kind === 'mcp-scope') {
318
+ const { x0, y0 } = drawBox(c, w, h, 56, o.options.length + 5);
319
+ c.put(x0 + 2, y0 + 1, `${o.server.name.toUpperCase()} → ${o.to.toUpperCase()} — SCOPE`.slice(0, 52), S.amberB);
320
+ o.options.forEach((opt, i) => {
321
+ const y = y0 + 3 + i;
322
+ c.put(x0 + 2, y, i === o.sel ? '▌' : ' ', S.amberB);
323
+ c.put(x0 + 4, y, opt.label.slice(0, 50), i === o.sel ? S.inkB : S.ink);
324
+ });
325
+ c.put(x0 + 2, y0 + o.options.length + 3, 'enter copy · esc back', S.seam);
326
+ return;
327
+ }
328
+ if (o.kind === 'remove-confirm') {
329
+ const { x0, y0 } = drawBox(c, w, h, 54, 7);
330
+ c.put(x0 + 2, y0 + 1, `REMOVE ${o.name.toUpperCase()}?`, S.crit);
331
+ c.put(x0 + 2, y0 + 3, `● ${o.email ?? 'not logged in'}`.slice(0, 50), { fg: hueOf(o.color) });
332
+ c.put(x0 + 2, y0 + 4, 'deletes its login, sessions & history — cannot be undone', S.ink2);
333
+ c.put(x0 + 2, y0 + 6, 'y remove · esc cancel', S.seam);
334
+ return;
335
+ }
336
+ if (o.kind === 'add-name') {
337
+ const { x0, y0 } = drawBox(c, w, h, 52, o.error ? 7 : 6);
338
+ c.put(x0 + 2, y0 + 1, 'NEW ACCOUNT — NAME', S.amberB);
339
+ c.put(x0 + 2, y0 + 3, '> ', S.muted);
340
+ c.put(x0 + 4, y0 + 3, o.value, S.inkB);
341
+ c.put(x0 + 4 + o.value.length, y0 + 3, '█', S.amber);
342
+ if (o.error) c.put(x0 + 2, y0 + 4, o.error.slice(0, 48), S.crit);
343
+ c.put(x0 + 2, y0 + (o.error ? 5 : 4), 'letters, digits, - and _ · enter next · esc cancel', S.seam);
344
+ return;
345
+ }
346
+ if (o.kind === 'add-method') {
347
+ const { x0, y0 } = drawBox(c, w, h, 58, o.options.length + 5);
348
+ c.put(x0 + 2, y0 + 1, `ADD ${o.name.toUpperCase()} — HOW?`, S.amberB);
349
+ o.options.forEach((opt, i) => {
350
+ const y = y0 + 3 + i;
351
+ c.put(x0 + 2, y, i === o.sel ? '▌' : ' ', S.amberB);
352
+ c.put(x0 + 4, y, opt.label, i === o.sel ? S.inkB : S.ink);
353
+ });
354
+ c.put(x0 + 2, y0 + o.options.length + 3, 'enter confirm · esc back', S.seam);
355
+ }
356
+ }
357
+
358
+ class App {
359
+ constructor() {
360
+ this.screen = new Screen();
361
+ this.view = 'board';
362
+ this.sel = 0;
363
+ this.msg = null;
364
+ this.spin = false;
365
+ this.cascade = new Cascade();
366
+ this.profiles = [];
367
+ this.cache = {};
368
+ this.sessions = null;
369
+ this.doctor = null;
370
+ this.overlay = null;
371
+ this.cwd = process.cwd();
372
+ this.scope = 'here';
373
+ this.rows = [];
374
+ this.openDirs = null;
375
+ this.scrollTop = 0;
376
+ this.lastClock = '';
377
+ }
378
+
379
+ clock() {
380
+ return new Date().toTimeString().slice(0, 8);
381
+ }
382
+
383
+ run() {
384
+ return new Promise((resolve) => {
385
+ this.done = resolve;
386
+ this.loadData();
387
+ this.screen.onKey = (k, raw) => { try { this.key(k, raw); } catch { this.quit(1); } };
388
+ this.screen.onResize = () => this.render();
389
+ this.screen.enter();
390
+ this.timer = setInterval(() => this.tick(), 100);
391
+ this.render();
392
+ this.refresh(true); // fetch live usage the moment the board opens
393
+ });
394
+ }
395
+
396
+ quit(code = 0) {
397
+ clearInterval(this.timer);
398
+ this.screen.leave();
399
+ this.done(code);
400
+ }
401
+
402
+ loadData() {
403
+ this.cache = loadCache();
404
+ this.profiles = sortByHeadroom(listProfiles(), this.cache)
405
+ .map((p) => ({ ...p, loggedOut: authState(p.name) === 'logged-out' }));
406
+ this.sel = Math.min(this.sel, Math.max(0, this.profiles.length - 1));
407
+ }
408
+
409
+ async refresh(force = true) {
410
+ this.spin = true;
411
+ this.render();
412
+ try { await refreshAll(this.profiles.map((p) => p.name), force ? 0 : DEFAULT_MAX_AGE_MS); } catch {}
413
+ this.spin = false;
414
+ this.loadData();
415
+ this.cascade = new Cascade();
416
+ this.render();
417
+ }
418
+
419
+ loadSessions() {
420
+ // listSessions filters out SDK/subagent transcripts, matching /resume.
421
+ this.sessions = listSessions(this.scope === 'all' ? null : slugForPath(this.cwd))
422
+ .slice(0, this.scope === 'all' ? 200 : 40);
423
+ this.rebuildRows();
424
+ this.sel = 0;
425
+ this.scrollTop = 0;
426
+ }
427
+
428
+ rebuildRows(keepDir = null) {
429
+ this.rows = buildSessionRows(this.sessions, this.scope, this.openDirs);
430
+ if (this.openDirs === null && this.scope === 'all') {
431
+ this.openDirs = new Set(this.rows.filter((r) => r.type === 'group' && r.open).map((r) => r.dir));
432
+ }
433
+ if (keepDir != null) {
434
+ const i = this.rows.findIndex((r) => r.type === 'group' && r.dir === keepDir);
435
+ if (i >= 0) this.sel = i;
436
+ }
437
+ this.sel = Math.min(this.sel, Math.max(0, this.rows.length - 1));
438
+ this.ensureVisible();
439
+ }
440
+
441
+ ensureVisible() {
442
+ const viewH = Math.max(1, this.screen.size.h - 6);
443
+ if (this.sel < this.scrollTop) this.scrollTop = this.sel;
444
+ if (this.sel >= this.scrollTop + viewH) this.scrollTop = this.sel - viewH + 1;
445
+ }
446
+
447
+ toggleGroup(row) {
448
+ if (this.openDirs.has(row.dir)) this.openDirs.delete(row.dir);
449
+ else this.openDirs.add(row.dir);
450
+ this.rebuildRows(row.dir);
451
+ this.render();
452
+ }
453
+
454
+ groupOf(index) {
455
+ for (let i = index; i >= 0; i--) if (this.rows[i].type === 'group') return this.rows[i];
456
+ return null;
457
+ }
458
+
459
+ launch(name, args = [], opts = {}) {
460
+ // A logged-out account would dead-end at Claude's login screen — surface it
461
+ // on the board and require an explicit "log in" (l) instead of launching blind.
462
+ if (opts.intent !== 'login' && authState(name) === 'logged-out') {
463
+ this.msg = `${name.toUpperCase()} LOGGED OUT — PRESS L TO SIGN IN`;
464
+ return this.render();
465
+ }
466
+ clearInterval(this.timer);
467
+ this.screen.leave();
468
+ launchProfile(name, args, opts);
469
+ this.screen.enter();
470
+ this.timer = setInterval(() => this.tick(), 100);
471
+ this.msg = `SESSION ENDED ${this.clock().slice(0, 5)}`;
472
+ this.view = 'board';
473
+ this.sel = 0;
474
+ this.loadData();
475
+ this.cascade = new Cascade();
476
+ this.render();
477
+ }
478
+
479
+ login(name) {
480
+ this.launch(name, [], { intent: 'login' });
481
+ }
482
+
483
+ key(k, raw) {
484
+ if (k === 'ctrl-c') return this.quit(0);
485
+ if (this.overlay) return this.keyOverlay(k, raw);
486
+ if (this.view === 'board') return this.keyBoard(k);
487
+ if (this.view === 'sessions') return this.keySessions(k);
488
+ if (this.view === 'doctor') return this.keyDoctor(k);
489
+ if (this.view === 'mcp') return this.keyMcp(k);
490
+ }
491
+
492
+ move(delta, count) {
493
+ if (!count) return;
494
+ this.sel = (this.sel + delta + count) % count;
495
+ this.render();
496
+ }
497
+
498
+ keyBoard(k) {
499
+ this.msg = null;
500
+ if (k === 'q' || k === 'esc') return this.quit(0);
501
+ if (k === 'up' || k === 'k') return this.move(-1, this.profiles.length);
502
+ if (k === 'down' || k === 'j' || k === 'tab') return this.move(1, this.profiles.length);
503
+ if (k === 'r') return this.refresh(true);
504
+ if (k === 'a' || k === '+') {
505
+ this.overlay = { kind: 'add-name', value: '', error: null };
506
+ return this.render();
507
+ }
508
+ if ((k === '-' || k === 'delete' || k === 'backspace') && this.profiles[this.sel]) {
509
+ return this.confirmRemove(this.profiles[this.sel]);
510
+ }
511
+ if (k === 's' || k === 'm') {
512
+ this.view = 'sessions';
513
+ this.loadSessions();
514
+ this.cascade = new Cascade();
515
+ return this.render();
516
+ }
517
+ if (k === 'd') {
518
+ this.view = 'doctor';
519
+ this.doctor = null;
520
+ this.render();
521
+ collectDoctor().then((d) => { this.doctor = d; if (this.view === 'doctor') this.render(); });
522
+ return;
523
+ }
524
+ if (k === 'x') {
525
+ this.view = 'mcp';
526
+ this.loadMcp();
527
+ this.cascade = new Cascade();
528
+ return this.render();
529
+ }
530
+ if (k === 'l' && this.profiles[this.sel]) return this.login(this.profiles[this.sel].name);
531
+ if (k === 'enter' && this.profiles[this.sel]) return this.launch(this.profiles[this.sel].name);
532
+ if (/^[1-9]$/.test(k) && +k <= this.profiles.length) return this.launch(this.profiles[+k - 1].name);
533
+ }
534
+
535
+ keySessions(k) {
536
+ if (k === 'esc' || k === 'q') { this.view = 'board'; this.sel = 0; return this.render(); }
537
+ if (k === 'up' || k === 'k') { this.move(-1, this.rows.length); return this.ensureVisible(), this.render(); }
538
+ if (k === 'down' || k === 'j') { this.move(1, this.rows.length); return this.ensureVisible(), this.render(); }
539
+ if (k === 'a') {
540
+ this.scope = this.scope === 'all' ? 'here' : 'all';
541
+ this.openDirs = null;
542
+ this.loadSessions();
543
+ this.cascade = new Cascade();
544
+ return this.render();
545
+ }
546
+ const row = this.rows[this.sel];
547
+ if (!row) return;
548
+ if (row.type === 'group') {
549
+ if (k === 'enter' || (k === 'right' && !row.open) || (k === 'left' && row.open)) return this.toggleGroup(row);
550
+ return;
551
+ }
552
+ const s = row.s;
553
+ if (k === 'left' && row.grouped) {
554
+ const g = this.groupOf(this.sel);
555
+ if (g) return this.toggleGroup(g);
556
+ }
557
+ if (k === 'enter') {
558
+ if (s.source.kind === 'profile') return this.launch(s.source.label, ['--resume', s.id], { cwd: s.cwd });
559
+ return this.openTransfer(s); // default-dir sessions board via a profile
560
+ }
561
+ if (k === 'm') return this.openTransfer(s);
562
+ }
563
+
564
+ keyDoctor(k) {
565
+ if (k === 'esc' || k === 'q') { this.view = 'board'; return this.render(); }
566
+ if (k === 'r') {
567
+ this.doctor = null;
568
+ this.render();
569
+ collectDoctor().then((d) => { this.doctor = d; if (this.view === 'doctor') this.render(); });
570
+ }
571
+ }
572
+
573
+ loadMcp() {
574
+ const rows = [];
575
+ for (const p of listProfiles()) {
576
+ const own = listAccountServers(p.name);
577
+ rows.push({ type: 'account', name: p.name, color: p.color, count: own.length });
578
+ for (const s of own) {
579
+ rows.push({ type: 'server', account: p.name, color: p.color, name: s.name, scope: s.scope, project: s.project ?? null });
580
+ }
581
+ }
582
+ this.mcpRows = rows;
583
+ const first = rows.findIndex((r) => r.type === 'server');
584
+ this.sel = first < 0 ? 0 : first;
585
+ this.scrollTop = 0;
586
+ }
587
+
588
+ moveMcp(delta) {
589
+ const rows = this.mcpRows ?? [];
590
+ if (!rows.length) return;
591
+ let i = this.sel;
592
+ for (let n = 0; n < rows.length; n++) {
593
+ i = (i + delta + rows.length) % rows.length;
594
+ if (rows[i].type === 'server') { this.sel = i; break; }
595
+ }
596
+ this.ensureVisible();
597
+ this.render();
598
+ }
599
+
600
+ keyMcp(k) {
601
+ if (k === 'esc' || k === 'q') { this.view = 'board'; this.sel = 0; return this.render(); }
602
+ if (k === 'up' || k === 'k') return this.moveMcp(-1);
603
+ if (k === 'down' || k === 'j') return this.moveMcp(1);
604
+ const row = (this.mcpRows ?? [])[this.sel];
605
+ if (!row || row.type !== 'server') return;
606
+ if (k === 'enter' || k === 'c') return this.openMcpCopy(row);
607
+ }
608
+
609
+ openMcpCopy(server) {
610
+ const targets = listProfiles().filter((p) => p.name !== server.account);
611
+ if (!targets.length) { this.msg = 'ADD A SECOND ACCOUNT TO COPY MCP SERVERS'; return this.render(); }
612
+ this.overlay = { kind: 'mcp-target', server, targets, sel: 0 };
613
+ this.render();
614
+ }
615
+
616
+ keyMcpTarget(k) {
617
+ const o = this.overlay;
618
+ if (k === 'esc' || k === 'q') { this.overlay = null; return this.render(); }
619
+ if (k === 'up' || k === 'k') { o.sel = (o.sel - 1 + o.targets.length) % o.targets.length; return this.render(); }
620
+ if (k === 'down' || k === 'j') { o.sel = (o.sel + 1) % o.targets.length; return this.render(); }
621
+ if (k === 'enter') {
622
+ const to = o.targets[o.sel].name;
623
+ // Default the local project from the source (local server) or current folder.
624
+ const project = o.server.scope === 'local' ? o.server.project : this.cwd;
625
+ const options = [
626
+ { scope: 'user', label: 'user · every folder for this account' },
627
+ { scope: 'local', project, label: `local · only in ${baseName(project)}` },
628
+ ];
629
+ this.overlay = { kind: 'mcp-scope', server: o.server, to, options, sel: o.server.scope === 'local' ? 1 : 0 };
630
+ return this.render();
631
+ }
632
+ }
633
+
634
+ keyMcpScope(k) {
635
+ const o = this.overlay;
636
+ if (k === 'esc' || k === 'q') {
637
+ this.overlay = { kind: 'mcp-target', server: o.server, targets: listProfiles().filter((p) => p.name !== o.server.account), sel: 0 };
638
+ return this.render();
639
+ }
640
+ if (k === 'up' || k === 'k') { o.sel = (o.sel - 1 + o.options.length) % o.options.length; return this.render(); }
641
+ if (k === 'down' || k === 'j') { o.sel = (o.sel + 1) % o.options.length; return this.render(); }
642
+ if (k === 'enter') {
643
+ const opt = o.options[o.sel];
644
+ const res = copyServer({
645
+ from: o.server.account, name: o.server.name,
646
+ sourceScope: o.server.scope, sourceProject: o.server.project,
647
+ to: o.to, targetScope: opt.scope, targetProject: opt.project ?? null,
648
+ });
649
+ this.overlay = null;
650
+ if (res.error) { this.msg = res.error.toUpperCase(); return this.render(); }
651
+ const where = opt.scope === 'local' ? `LOCAL · ${baseName(opt.project)}` : 'USER';
652
+ this.msg = `${res.replaced ? 'REPLACED' : 'COPIED'} ${o.server.name.toUpperCase()} → ${o.to.toUpperCase()} (${where})`;
653
+ this.loadMcp();
654
+ this.cascade = new Cascade();
655
+ return this.render();
656
+ }
657
+ }
658
+
659
+ openTransfer(session) {
660
+ const targets = listProfiles().filter((p) => !(session.source.kind === 'profile' && p.name === session.source.label));
661
+ if (!targets.length) return;
662
+ this.overlay = { kind: 'transfer', session, targets, sel: 0 };
663
+ this.render();
664
+ }
665
+
666
+ keyOverlay(k, raw) {
667
+ const o = this.overlay;
668
+ if (o.kind === 'add-name') return this.keyAddName(k, raw);
669
+ if (o.kind === 'add-method') return this.keyAddMethod(k);
670
+ if (o.kind === 'remove-confirm') return this.keyRemove(k);
671
+ if (o.kind === 'mcp-target') return this.keyMcpTarget(k);
672
+ if (o.kind === 'mcp-scope') return this.keyMcpScope(k);
673
+ if (k === 'esc' || k === 'q') { this.overlay = null; return this.render(); }
674
+ if (k === 'up' || k === 'k') { o.sel = (o.sel - 1 + o.targets.length) % o.targets.length; return this.render(); }
675
+ if (k === 'down' || k === 'j') { o.sel = (o.sel + 1) % o.targets.length; return this.render(); }
676
+ if (k === 'enter') {
677
+ const target = o.targets[o.sel].name;
678
+ copySessionTo(o.session, target, o.session.slug ?? slugForPath(this.cwd));
679
+ this.overlay = null;
680
+ return this.launch(target, ['--resume', o.session.id], { cwd: o.session.cwd });
681
+ }
682
+ }
683
+
684
+ keyAddName(k, raw) {
685
+ const o = this.overlay;
686
+ if (k === 'esc') { this.overlay = null; return this.render(); }
687
+ if (k === 'backspace') { o.value = o.value.slice(0, -1); o.error = null; return this.render(); }
688
+ if (k === 'enter') {
689
+ const options = [{ id: 'login', label: 'LOG IN TO A NEW ACCOUNT' }];
690
+ if (hasDefaultLogin()) options.push({ id: 'import', label: 'USE THE CURRENT ~/.claude LOGIN (SESSIONS TOO)' });
691
+ this.overlay = { kind: 'add-method', name: o.value, options, sel: 0 };
692
+ return this.render();
693
+ }
694
+ if (raw && /^[A-Za-z0-9_-]$/.test(raw) && o.value.length < 32) {
695
+ o.value += raw;
696
+ o.error = null;
697
+ return this.render();
698
+ }
699
+ }
700
+
701
+ confirmRemove(p) {
702
+ if (isRunning(p.name)) {
703
+ this.msg = `${p.name.toUpperCase()} HAS A RUNNING SESSION — CLOSE IT FIRST`;
704
+ return this.render();
705
+ }
706
+ this.overlay = { kind: 'remove-confirm', name: p.name, color: p.color, email: p.email };
707
+ return this.render();
708
+ }
709
+
710
+ keyRemove(k) {
711
+ const o = this.overlay;
712
+ if (k === 'esc' || k === 'q' || k === 'n') { this.overlay = null; return this.render(); }
713
+ if (k === 'y' || k === 'enter') return this.executeRemove(o.name);
714
+ }
715
+
716
+ executeRemove(name) {
717
+ try {
718
+ removeProfile(name);
719
+ } catch (e) {
720
+ this.overlay = null;
721
+ this.msg = e.message.toUpperCase();
722
+ return this.render();
723
+ }
724
+ this.overlay = null;
725
+ refreshWtIfInstalled();
726
+ this.loadData();
727
+ this.msg = `REMOVED ${name.toUpperCase()}`;
728
+ this.cascade = new Cascade();
729
+ return this.render();
730
+ }
731
+
732
+ keyAddMethod(k) {
733
+ const o = this.overlay;
734
+ if (k === 'esc') {
735
+ this.overlay = { kind: 'add-name', value: o.name, error: null };
736
+ return this.render();
737
+ }
738
+ if (k === 'up' || k === 'k') { o.sel = (o.sel - 1 + o.options.length) % o.options.length; return this.render(); }
739
+ if (k === 'down' || k === 'j') { o.sel = (o.sel + 1) % o.options.length; return this.render(); }
740
+ if (k === 'enter') return this.executeAdd(o.name, o.options[o.sel].id);
741
+ }
742
+
743
+ executeAdd(name, method) {
744
+ try {
745
+ registerProfile(name);
746
+ } catch (e) {
747
+ this.overlay = { kind: 'add-name', value: name, error: e.message.toUpperCase() };
748
+ return this.render();
749
+ }
750
+ this.overlay = null;
751
+ prepareProfileDir(name);
752
+ if (method === 'import') {
753
+ importDefaultInto(name);
754
+ const p = refreshIdentity(name);
755
+ refreshWtIfInstalled();
756
+ this.loadData();
757
+ this.msg = `ADDED ${name.toUpperCase()}${p?.email ? ' · ' + p.email : ''}`;
758
+ this.cascade = new Cascade();
759
+ return this.render();
760
+ }
761
+ // fresh login: run Claude Code once so the user can /login, then read
762
+ // the identity it wrote on the way out.
763
+ this.launch(name);
764
+ const p = refreshIdentity(name);
765
+ refreshWtIfInstalled();
766
+ this.loadData();
767
+ this.msg = p?.email ? `ADDED ${name.toUpperCase()} · ${p.email}` : `NO LOGIN YET — BOARD ${name.toUpperCase()} AND RUN /login`;
768
+ this.render();
769
+ }
770
+
771
+ tick() {
772
+ const { w, h } = this.screen.size;
773
+ if (this.cascade.active(w, h)) return this.render();
774
+ const clk = this.clock();
775
+ if (clk !== this.lastClock) { this.lastClock = clk; this.render(); }
776
+ }
777
+
778
+ render() {
779
+ const { w, h } = this.screen.size;
780
+ const state = {
781
+ profiles: this.profiles, cache: this.cache, sel: this.sel, clock: this.clock(),
782
+ msg: this.msg, spin: this.spin, rows: this.rows, total: this.sessions?.length,
783
+ scrollTop: this.scrollTop, doctor: this.doctor, mcpRows: this.mcpRows,
784
+ cwd: this.cwd, scope: this.scope, overlay: this.overlay,
785
+ };
786
+ const c = this.view === 'sessions' ? renderSessions(state, w, h)
787
+ : this.view === 'doctor' ? renderDoctor(state, w, h)
788
+ : this.view === 'mcp' ? renderMcp(state, w, h)
789
+ : renderBoard(state, w, h);
790
+ if (this.overlay) drawOverlay(c, w, h, state);
791
+ const now = Date.now();
792
+ this.screen.frame(c.toAnsi(this.cascade.active(w, h, now) ? this.cascade.scrambler(now) : null));
793
+ }
794
+ }
795
+
796
+ export function runTui() {
797
+ return new App().run();
798
+ }
799
+
800
+ export { App }; // for headless state-transition tests