dual-brain 7.0.2 → 7.1.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.
@@ -29,6 +29,8 @@ import { dispatch, detectRuntime, dispatchDualBrain } from '../src/dispatch.mjs'
29
29
  import { loadRepoCache } from '../src/repo.mjs';
30
30
  import { loadSession, saveSession, formatSessionCard } from '../src/session.mjs';
31
31
 
32
+ import { box, bar, badge, menu, separator } from '../src/tui.mjs';
33
+
32
34
  // ─── Helpers ─────────────────────────────────────────────────────────────────
33
35
 
34
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -61,13 +63,11 @@ Commands:
61
63
  remember "preference" Save a project-scoped preference
62
64
  forget "preference" Remove a preference by fuzzy match
63
65
 
64
- Interactive REPL (entered after init or npx dual-brain with no args):
65
- <task description> Dispatch a task directly
66
- go <task> Same as dual-brain go
67
- status / auth / init Run commands without exiting
68
- auth setup Re-run API key setup
69
- help Show this help
70
- exit / quit / q Exit the REPL
66
+ Interactive mode (entered with no args on a TTY):
67
+ Shows dashboard screen with menu-driven navigation.
68
+ [g] Go dispatch a task
69
+ [s] Status, [p] Profile, [a] Auth, [d] Diagnostics
70
+ [c] Command mode (REPL), [q] Exit
71
71
 
72
72
  Options:
73
73
  --version Print version
@@ -84,7 +84,7 @@ Options:
84
84
  */
85
85
  function printAuthTable(auth) {
86
86
  const W = 55; // inner width (wide enough for source labels)
87
- const bar = '═'.repeat(W);
87
+ const hbar = '═'.repeat(W);
88
88
  const pad = (s) => {
89
89
  const visible = s.replace(/[̀-ͯ]/g, ''); // strip combining chars for length
90
90
  return s + ' '.repeat(Math.max(0, W - visible.length));
@@ -104,52 +104,14 @@ function printAuthTable(auth) {
104
104
  ? ` ${auth.openai.masked}`
105
105
  : ` run: dual-brain auth setup`;
106
106
 
107
- console.log(`╔${bar}╗`);
107
+ console.log(`╔${hbar}╗`);
108
108
  console.log(`║${pad(' Auth Status')}║`);
109
- console.log(`╠${bar}╣`);
109
+ console.log(`╠${hbar}╣`);
110
110
  console.log(`║${pad(claudeLine1)}║`);
111
111
  console.log(`║${pad(claudeLine2)}║`);
112
112
  console.log(`║${pad(openaiLine1)}║`);
113
113
  console.log(`║${pad(openaiLine2)}║`);
114
- console.log(`╚${bar}╝`);
115
- }
116
-
117
- // ─── Card command (default) ──────────────────────────────────────────────────
118
-
119
- async function cmdCard() {
120
- const cwd = process.cwd();
121
- const { homedir } = await import('node:os');
122
- const globalPath = join(homedir(), '.config', 'dual-brain', 'profile.json');
123
- const projectPath = join(cwd, '.dualbrain', 'profile.json');
124
-
125
- if (!existsSync(projectPath) && !existsSync(globalPath)) {
126
- console.log('Welcome to dual-brain! Let\'s set up your profile.\n');
127
- await cmdInit();
128
- return;
129
- }
130
-
131
- const repo = loadRepoCache(cwd);
132
- const session = loadSession(cwd);
133
- const health = getHealth(cwd);
134
- const card = formatSessionCard(session, repo, health);
135
- console.log(card);
136
-
137
- // Auth status warnings (non-blocking)
138
- const auth = await detectAuth();
139
- const warnings = [];
140
- if (!auth.claude.found) warnings.push('Claude auth not found — run: dual-brain auth setup');
141
- if (!auth.openai.found) warnings.push('OpenAI auth not found — run: dual-brain auth setup');
142
- if (warnings.length > 0) {
143
- console.log('\nAuth warnings:');
144
- for (const w of warnings) console.log(` ⚠ ${w}`);
145
- }
146
-
147
- // Environment info
148
- const env = detectEnvironment();
149
- if (env.isReplit || env.hasReplitTools) {
150
- const envLabel = env.hasReplitTools ? 'Replit + replit-tools' : 'Replit';
151
- console.log(`\nRuntime: ${envLabel}`);
152
- }
114
+ console.log(`╚${hbar}╝`);
153
115
  }
154
116
 
155
117
  // ─── Commands ─────────────────────────────────────────────────────────────────
@@ -521,63 +483,514 @@ function cmdForget(text) {
521
483
  console.log('Preference removed (if matched).');
522
484
  }
523
485
 
524
- // ─── Interactive REPL ────────────────────────────────────────────────────────
486
+ // ─── Screen helpers ───────────────────────────────────────────────────────────
525
487
 
526
- async function startRepl(rl) {
527
- // rl may have been created by cmdCard/cmdInit reuse it.
528
- // If not provided, create a fresh one.
529
- const rlOwned = !rl;
530
- if (!rl) rl = createInterface({ input: process.stdin, output: process.stdout });
488
+ function profileExists() {
489
+ const { homedir } = { homedir: () => process.env.HOME || '/root' };
490
+ const cwd = process.cwd();
491
+ const globalPath = join(process.env.HOME || '/root', '.config', 'dual-brain', 'profile.json');
492
+ const projectPath = join(cwd, '.dualbrain', 'profile.json');
493
+ return existsSync(projectPath) || existsSync(globalPath);
494
+ }
531
495
 
532
- console.log('\nType a task or command. Type "help" for commands, "exit" to quit.\n');
496
+ // ─── Screen: welcomeScreen ────────────────────────────────────────────────────
533
497
 
534
- const prompt = () => {
535
- rl.question('dual-brain> ', async (input) => {
536
- const line = input.trim();
537
- if (!line) { prompt(); return; }
498
+ async function welcomeScreen(rl, ask) {
499
+ const version = readVersion();
500
+ console.log(box(`🧠 Dual-Brain v${version} — First-Time Setup`, [
501
+ 'Let\'s configure your AI providers.',
502
+ ]));
503
+ console.log('');
538
504
 
539
- if (line === 'exit' || line === 'quit' || line === 'q') {
540
- if (rlOwned) rl.close();
541
- return;
542
- }
505
+ // --- Claude provider selection ---
506
+ console.log(separator('Claude (Anthropic)'));
507
+ console.log(' (1) $20/mo Pro');
508
+ console.log(' (2) $100/mo Max 5x');
509
+ console.log(' (3) $200/mo Max 20x');
510
+ console.log(' (4) API key only');
511
+ console.log(' (5) Skip — don\'t use Claude');
512
+ const claudeChoice = (await ask('> ')).trim();
513
+
514
+ let claudePlan = null;
515
+ let claudeEnabled = true;
516
+ if (claudeChoice === '1') { claudePlan = 'pro'; }
517
+ else if (claudeChoice === '2') { claudePlan = 'max5'; }
518
+ else if (claudeChoice === '3') { claudePlan = 'max20'; }
519
+ else if (claudeChoice === '4') {
520
+ claudePlan = 'api';
521
+ // Ask for API key immediately
522
+ const key = (await ask('Paste your Anthropic API key: ')).trim();
523
+ if (key) {
524
+ const { saveAuthKey } = await import('../src/profile.mjs').then(m => m).catch(() => ({}));
525
+ // Inline: set env var for this session, profile will persist
526
+ process.env.ANTHROPIC_API_KEY = key;
527
+ console.log('✓ Claude API key set for this session');
528
+ }
529
+ } else if (claudeChoice === '5') {
530
+ claudeEnabled = false;
531
+ claudePlan = null;
532
+ } else {
533
+ // Default: pro
534
+ claudePlan = 'pro';
535
+ }
536
+
537
+ console.log('');
543
538
 
539
+ // --- OpenAI provider selection ---
540
+ console.log(separator('OpenAI (ChatGPT/Codex)'));
541
+ console.log(' (1) $20/mo Plus');
542
+ console.log(' (2) $100/mo Pro');
543
+ console.log(' (3) $200/mo Pro (higher limits)');
544
+ console.log(' (4) API key only');
545
+ console.log(' (5) Skip — don\'t use OpenAI');
546
+ const openaiChoice = (await ask('> ')).trim();
547
+
548
+ let openaiPlan = null;
549
+ let openaiEnabled = true;
550
+ if (openaiChoice === '1') { openaiPlan = 'plus'; }
551
+ else if (openaiChoice === '2') { openaiPlan = 'pro'; }
552
+ else if (openaiChoice === '3') { openaiPlan = 'pro200'; }
553
+ else if (openaiChoice === '4') {
554
+ openaiPlan = 'api';
555
+ const key = (await ask('Paste your OpenAI API key: ')).trim();
556
+ if (key) {
557
+ process.env.OPENAI_API_KEY = key;
558
+ console.log('✓ OpenAI API key set for this session');
559
+ }
560
+ } else if (openaiChoice === '5') {
561
+ openaiEnabled = false;
562
+ openaiPlan = null;
563
+ } else {
564
+ openaiPlan = 'plus';
565
+ }
566
+
567
+ console.log('');
568
+
569
+ // --- Optimization mode ---
570
+ console.log(separator('Optimization'));
571
+ console.log(' (1) Save usage — prefer cheaper models');
572
+ console.log(' (2) Balanced — best model per tier (recommended)');
573
+ console.log(' (3) Quality first — always use best available');
574
+ const modeChoice = (await ask('> ')).trim();
575
+
576
+ let mode = 'balanced';
577
+ if (modeChoice === '1') { mode = 'cost-saver'; }
578
+ else if (modeChoice === '3') { mode = 'quality-first'; }
579
+
580
+ // --- Build and save profile ---
581
+ const cwd = process.cwd();
582
+ const existingProfile = loadProfile(cwd);
583
+ const profile = {
584
+ ...existingProfile,
585
+ mode,
586
+ providers: {
587
+ claude: {
588
+ enabled: claudeEnabled,
589
+ plan: claudePlan || 'pro',
590
+ },
591
+ openai: {
592
+ enabled: openaiEnabled,
593
+ plan: openaiPlan || 'plus',
594
+ },
595
+ },
596
+ };
597
+ saveProfile(profile, { cwd });
598
+
599
+ // --- Detect environment for summary ---
600
+ const env = detectEnvironment();
601
+ const auth = await detectAuth();
602
+
603
+ const summaryLines = [
604
+ `Mode: ${mode}`,
605
+ claudeEnabled
606
+ ? `Claude: ${claudePlan} plan ${auth.claude.found ? badge('connected') : badge('missing')}`
607
+ : 'Claude: disabled',
608
+ openaiEnabled
609
+ ? `OpenAI: ${openaiPlan} plan ${auth.openai.found ? badge('connected') : badge('missing')}`
610
+ : 'OpenAI: disabled',
611
+ env.isReplit ? '🌀 Replit environment detected' : '',
612
+ ].filter(Boolean);
613
+
614
+ console.log('');
615
+ console.log(box('Setup Complete', summaryLines));
616
+ console.log('');
617
+
618
+ return { next: 'dashboard' };
619
+ }
620
+
621
+ // ─── Screen: dashboardScreen ──────────────────────────────────────────────────
622
+
623
+ async function dashboardScreen(rl, ask) {
624
+ const cwd = process.cwd();
625
+ const version = readVersion();
626
+ const profile = loadProfile(cwd);
627
+ const auth = await detectAuth();
628
+ const env = detectEnvironment();
629
+
630
+ // Build status lines for box
631
+ const claudeStatus = auth.claude.found ? `🟢 Claude ${badge('connected')}` : `🔴 Claude ${badge('missing')}`;
632
+ const openaiStatus = auth.openai.found ? `🟢 OpenAI ${badge('connected')}` : `🔴 OpenAI ${badge('missing')}`;
633
+ const envLabel = env.hasReplitTools ? 'Replit + replit-tools' : env.isReplit ? 'Replit' : 'local';
634
+
635
+ // Enforcement check
636
+ let guardCount = 0;
637
+ try {
638
+ const settingsFile = join(cwd, '.claude', 'settings.json');
639
+ if (existsSync(settingsFile)) {
640
+ const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
641
+ const preToolUse = settings?.hooks?.PreToolUse ?? [];
642
+ const guardCmd = 'bash .claude/hooks/head-guard.sh';
643
+ const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
644
+ const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
645
+ const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
646
+ const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
647
+ const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
648
+ guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
649
+ }
650
+ } catch { /* ignore */ }
651
+
652
+ const authSummary = (auth.claude.found && auth.openai.found)
653
+ ? 'both providers connected'
654
+ : auth.claude.found
655
+ ? 'Claude connected, OpenAI missing'
656
+ : auth.openai.found
657
+ ? 'OpenAI connected, Claude missing'
658
+ : 'no providers connected';
659
+
660
+ const dashLines = [
661
+ `${claudeStatus} ${openaiStatus}`,
662
+ `🌀 ${envLabel}`,
663
+ '',
664
+ `✓ Profile: ${profile.mode} · ${profile.providers?.claude?.enabled && profile.providers?.openai?.enabled ? 'dual' : 'solo'} mode`,
665
+ `✓ Enforcement: ${guardCount} guards active`,
666
+ `✓ Auth: ${authSummary}`,
667
+ ];
668
+
669
+ console.log(box(`🧠 Dual-Brain v${version}`, dashLines));
670
+ console.log('');
671
+ console.log(menu([
672
+ { key: 'g', label: 'Go — dispatch a task', section: 'Actions' },
673
+ { key: 's', label: 'Status — detailed provider info', section: 'Actions' },
674
+ { key: 'p', label: 'Profile & preferences', section: 'Settings' },
675
+ { key: 'a', label: 'Auth management', section: 'Settings' },
676
+ { key: 'd', label: 'Diagnostics', section: 'Settings' },
677
+ { key: 'c', label: 'Command mode (REPL)', section: 'Session' },
678
+ { key: 'q', label: 'Exit', section: 'Session' },
679
+ ]));
680
+ console.log('');
681
+
682
+ const choice = (await ask(' Choice: ')).trim().toLowerCase();
683
+
684
+ if (choice === 'g') {
685
+ const taskDesc = (await ask(' Task description: ')).trim();
686
+ if (taskDesc) {
544
687
  try {
545
- if (line === 'help') {
546
- printHelp();
547
- } else if (line === 'status') {
548
- await cmdStatus([]);
549
- } else if (line === 'auth setup' || line === 'auth-setup') {
550
- await cmdAuthSetup(rl);
551
- } else if (line === 'auth') {
552
- await cmdAuth([], rl);
553
- } else if (line.startsWith('go ')) {
554
- await cmdGo(line.slice(3).trim().split(/\s+/));
555
- } else if (line.startsWith('remember ')) {
556
- cmdRemember(line.slice(9).trim());
557
- } else if (line.startsWith('forget ')) {
558
- cmdForget(line.slice(7).trim());
559
- } else if (line.startsWith('hot ')) {
560
- cmdHot(line.slice(4).trim());
561
- } else if (line.startsWith('cool ')) {
562
- cmdCool(line.slice(5).trim());
563
- } else if (line === 'init') {
564
- await cmdInit(rl);
565
- } else {
566
- // Treat as a task description → go
567
- await cmdGo([line]);
568
- }
688
+ await cmdGo([taskDesc]);
569
689
  } catch (e) {
570
- process.stderr.write(`Error: ${e.message}\n`);
690
+ console.error(`Error: ${e.message}`);
571
691
  }
692
+ }
693
+ return { next: 'dashboard' };
694
+ }
572
695
 
573
- prompt(); // loop back
574
- });
575
- };
696
+ if (choice === 's') {
697
+ await cmdStatus([]);
698
+ await ask('\n Press Enter to return to dashboard...');
699
+ return { next: 'dashboard' };
700
+ }
701
+
702
+ if (choice === 'p') { return { next: 'profile' }; }
703
+ if (choice === 'a') { return { next: 'auth' }; }
704
+ if (choice === 'd') { return { next: 'diagnostics' }; }
705
+ if (choice === 'c') { return { next: 'repl' }; }
706
+ if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
707
+
708
+ // Unknown choice — stay on dashboard
709
+ return { next: 'dashboard' };
710
+ }
711
+
712
+ // ─── Screen: authScreen ───────────────────────────────────────────────────────
713
+
714
+ async function authScreen(rl, ask) {
715
+ const auth = await detectAuth();
716
+
717
+ const authLines = [
718
+ 'Claude:',
719
+ ];
720
+
721
+ if (auth.claude.found) {
722
+ authLines.push(` source: ${auth.claude.source} ${badge('connected')}`);
723
+ authLines.push(` key: ${auth.claude.masked}`);
724
+ } else {
725
+ authLines.push(` not configured ${badge('missing')}`);
726
+ }
727
+
728
+ authLines.push('');
729
+ authLines.push('OpenAI:');
730
+
731
+ if (auth.openai.found) {
732
+ authLines.push(` source: ${auth.openai.source} ${badge('connected')}`);
733
+ authLines.push(` key: ${auth.openai.masked}`);
734
+ } else {
735
+ authLines.push(` not configured ${badge('missing')}`);
736
+ }
737
+
738
+ console.log(box('🔑 Auth Management', authLines));
739
+ console.log('');
740
+ console.log(menu([
741
+ { key: 'a', label: 'Add API key', section: '' },
742
+ { key: 't', label: 'Test keys', section: '' },
743
+ { key: 'b', label: 'Back to dashboard', section: '' },
744
+ ]));
745
+ console.log('');
746
+
747
+ const choice = (await ask(' Choice: ')).trim().toLowerCase();
748
+
749
+ if (choice === 'a') {
750
+ await setupAuth(rl);
751
+ return { next: 'auth' }; // refresh
752
+ }
753
+
754
+ if (choice === 't') {
755
+ console.log('\n Testing auth...');
756
+ const authNow = await detectAuth();
757
+ console.log(` Claude: ${authNow.claude.found ? 'OK — ' + authNow.claude.source : 'NOT FOUND'}`);
758
+ console.log(` OpenAI: ${authNow.openai.found ? 'OK — ' + authNow.openai.source : 'NOT FOUND'}`);
759
+ await ask('\n Press Enter to continue...');
760
+ return { next: 'auth' };
761
+ }
762
+
763
+ if (choice === 'b' || choice === 'back') { return { next: 'dashboard' }; }
764
+
765
+ return { next: 'auth' };
766
+ }
767
+
768
+ // ─── Screen: profileScreen ────────────────────────────────────────────────────
576
769
 
577
- prompt();
770
+ async function profileScreen(rl, ask) {
771
+ const cwd = process.cwd();
772
+ const profile = loadProfile(cwd);
773
+ const prefs = getActivePreferences(cwd);
774
+
775
+ const profileLines = [
776
+ `Mode: ${profile.mode}`,
777
+ `Claude plan: ${profile.providers?.claude?.enabled ? (profile.providers?.claude?.plan || 'n/a') : 'disabled'}`,
778
+ `OpenAI plan: ${profile.providers?.openai?.enabled ? (profile.providers?.openai?.plan || 'n/a') : 'disabled'}`,
779
+ `Solo brain: ${isSoloBrain(profile) ? 'yes' : 'no'}`,
780
+ `Head model: ${getHeadModel(profile)}`,
781
+ '',
782
+ `Preferences (${prefs.length}):`,
783
+ ...prefs.map(p => ` [${p.scope}] ${p.text}`),
784
+ ...(prefs.length === 0 ? [' (none)'] : []),
785
+ ];
786
+
787
+ console.log(box('Profile & Preferences', profileLines));
788
+ console.log('');
789
+ console.log(menu([
790
+ { key: '1', label: 'Switch to cost-saver mode', section: 'Mode' },
791
+ { key: '2', label: 'Switch to balanced mode', section: 'Mode' },
792
+ { key: '3', label: 'Switch to quality-first mode',section: 'Mode' },
793
+ { key: 'r', label: 'Add preference', section: 'Preferences' },
794
+ { key: 'f', label: 'Remove preference', section: 'Preferences' },
795
+ { key: 'b', label: 'Back to dashboard', section: '' },
796
+ ]));
797
+ console.log('');
798
+
799
+ const choice = (await ask(' Choice: ')).trim().toLowerCase();
800
+
801
+ if (choice === '1' || choice === '2' || choice === '3') {
802
+ const modeMap = { '1': 'cost-saver', '2': 'balanced', '3': 'quality-first' };
803
+ profile.mode = modeMap[choice];
804
+ saveProfile(profile, { cwd });
805
+ console.log(` Mode set to: ${profile.mode}`);
806
+ return { next: 'profile' };
807
+ }
808
+
809
+ if (choice === 'r') {
810
+ const text = (await ask(' Preference text: ')).trim();
811
+ if (text) cmdRemember(text);
812
+ return { next: 'profile' };
813
+ }
814
+
815
+ if (choice === 'f') {
816
+ const text = (await ask(' Preference to remove (fuzzy): ')).trim();
817
+ if (text) cmdForget(text);
818
+ return { next: 'profile' };
819
+ }
578
820
 
579
- // Return a promise that resolves when rl closes (exit/quit)
580
- return new Promise(resolve => rl.on('close', resolve));
821
+ if (choice === 'b' || choice === 'back') { return { next: 'dashboard' }; }
822
+
823
+ return { next: 'profile' };
824
+ }
825
+
826
+ // ─── Screen: diagnosticsScreen ────────────────────────────────────────────────
827
+
828
+ async function diagnosticsScreen(rl, ask) {
829
+ const cwd = process.cwd();
830
+ const version = readVersion();
831
+ const env = detectEnvironment();
832
+ const rt = await detectRuntime();
833
+
834
+ // Enforcement check
835
+ let guardCount = 0;
836
+ let guardDetails = 'NOT INSTALLED';
837
+ try {
838
+ const settingsFile = join(cwd, '.claude', 'settings.json');
839
+ if (existsSync(settingsFile)) {
840
+ const settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
841
+ const preToolUse = settings?.hooks?.PreToolUse ?? [];
842
+ const guardCmd = 'bash .claude/hooks/head-guard.sh';
843
+ const tierCmd = 'node .claude/hooks/enforce-tier.mjs';
844
+ const hasEdit = preToolUse.some(e => e.matcher === 'Edit' && e.hooks?.some(h => h.command === guardCmd));
845
+ const hasWrite = preToolUse.some(e => e.matcher === 'Write' && e.hooks?.some(h => h.command === guardCmd));
846
+ const hasBash = preToolUse.some(e => e.matcher === 'Bash' && e.hooks?.some(h => h.command === guardCmd));
847
+ const hasAgent = preToolUse.some(e => e.matcher === 'Agent' && e.hooks?.some(h => h.command === tierCmd));
848
+ guardCount = [hasEdit, hasWrite, hasBash, hasAgent].filter(Boolean).length;
849
+ guardDetails = guardCount === 4
850
+ ? `${guardCount}/4 guards active (Edit, Write, Bash, Agent)`
851
+ : `${guardCount}/4 guards — run: dual-brain install`;
852
+ }
853
+ } catch { guardDetails = 'unknown (could not read settings)'; }
854
+
855
+ // Hook health: check if hook files exist
856
+ const hooksDir = join(cwd, '.claude', 'hooks');
857
+ const expectedHooks = [
858
+ 'head-guard.sh', 'enforce-tier.mjs', 'budget-balancer.mjs',
859
+ 'session-report.mjs', 'quality-gate.mjs', 'health-check.mjs',
860
+ ];
861
+ const hookStatus = expectedHooks.map(h => {
862
+ const present = existsSync(join(hooksDir, h));
863
+ return ` ${present ? '✓' : '✗'} ${h}`;
864
+ });
865
+
866
+ const diagLines = [
867
+ `Version: ${version}`,
868
+ `Enforcement: ${guardDetails}`,
869
+ '',
870
+ 'Environment:',
871
+ ` Replit: ${env.isReplit ? 'yes' : 'no'}`,
872
+ ` replit-tools:${env.hasReplitTools ? 'yes' : 'no'}`,
873
+ ` CI: ${env.isCI ? 'yes' : 'no'}`,
874
+ '',
875
+ 'Runtime:',
876
+ ` claude CLI: ${rt.claudeAvailable ? 'available' : 'not found'}`,
877
+ ` codex CLI: ${rt.codexAvailable ? 'available' : 'not found'}`,
878
+ ` runtime: ${rt.runtime}`,
879
+ '',
880
+ 'Hook files:',
881
+ ...hookStatus,
882
+ ];
883
+
884
+ console.log(box('Diagnostics', diagLines));
885
+ console.log('');
886
+ console.log(menu([
887
+ { key: 'h', label: 'Run health check', section: '' },
888
+ { key: 'i', label: 'Install hooks', section: '' },
889
+ { key: 'b', label: 'Back to dashboard', section: '' },
890
+ ]));
891
+ console.log('');
892
+
893
+ const choice = (await ask(' Choice: ')).trim().toLowerCase();
894
+
895
+ if (choice === 'h') {
896
+ const hookScript = join(cwd, '.claude', 'hooks', 'health-check.mjs');
897
+ if (existsSync(hookScript)) {
898
+ try {
899
+ execSync(`node "${hookScript}"`, { stdio: 'inherit', cwd });
900
+ } catch { /* hook exits non-zero on issues — already printed output */ }
901
+ } else {
902
+ console.log(' health-check.mjs not found — run: dual-brain install');
903
+ }
904
+ await ask('\n Press Enter to continue...');
905
+ return { next: 'diagnostics' };
906
+ }
907
+
908
+ if (choice === 'i') {
909
+ await cmdInstall();
910
+ return { next: 'diagnostics' };
911
+ }
912
+
913
+ if (choice === 'b' || choice === 'back') { return { next: 'dashboard' }; }
914
+
915
+ return { next: 'diagnostics' };
916
+ }
917
+
918
+ // ─── Screen: replScreen ───────────────────────────────────────────────────────
919
+
920
+ async function replScreen(rl, ask) {
921
+ console.log('\nCommand mode. Type a task or command. "help" for commands, "back" to return.\n');
922
+
923
+ while (true) {
924
+ const input = (await ask('dual-brain> ')).trim();
925
+ const line = input;
926
+
927
+ if (!line) continue;
928
+
929
+ if (line === 'back' || line === 'exit' || line === 'quit' || line === 'q') {
930
+ return { next: 'dashboard' };
931
+ }
932
+
933
+ try {
934
+ if (line === 'help') {
935
+ printHelp();
936
+ } else if (line === 'status') {
937
+ await cmdStatus([]);
938
+ } else if (line === 'auth setup' || line === 'auth-setup') {
939
+ await cmdAuthSetup(rl);
940
+ } else if (line === 'auth') {
941
+ await cmdAuth([], rl);
942
+ } else if (line.startsWith('go ')) {
943
+ await cmdGo(line.slice(3).trim().split(/\s+/));
944
+ } else if (line.startsWith('remember ')) {
945
+ cmdRemember(line.slice(9).trim());
946
+ } else if (line.startsWith('forget ')) {
947
+ cmdForget(line.slice(7).trim());
948
+ } else if (line.startsWith('hot ')) {
949
+ cmdHot(line.slice(4).trim());
950
+ } else if (line.startsWith('cool ')) {
951
+ cmdCool(line.slice(5).trim());
952
+ } else if (line === 'init') {
953
+ await cmdInit(rl);
954
+ } else if (line === 'dashboard') {
955
+ return { next: 'dashboard' };
956
+ } else {
957
+ // Treat as a task description → go
958
+ await cmdGo([line]);
959
+ }
960
+ } catch (e) {
961
+ process.stderr.write(`Error: ${e.message}\n`);
962
+ }
963
+ }
964
+ }
965
+
966
+ // ─── Screen state machine ─────────────────────────────────────────────────────
967
+
968
+ const SCREENS = {
969
+ welcome: welcomeScreen,
970
+ dashboard: dashboardScreen,
971
+ auth: authScreen,
972
+ profile: profileScreen,
973
+ diagnostics: diagnosticsScreen,
974
+ repl: replScreen,
975
+ };
976
+
977
+ async function runScreens(startScreen = 'dashboard') {
978
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
979
+ const ask = (q) => new Promise(res => rl.question(q, res));
980
+
981
+ let current = startScreen;
982
+ while (current && current !== 'exit') {
983
+ const screen = SCREENS[current];
984
+ if (!screen) break;
985
+ try {
986
+ const result = await screen(rl, ask);
987
+ current = result?.next || 'exit';
988
+ } catch (e) {
989
+ console.error(`Error: ${e.message}`);
990
+ current = 'dashboard'; // recover to dashboard on error
991
+ }
992
+ }
993
+ rl.close();
581
994
  }
582
995
 
583
996
  // ─── Entry point ─────────────────────────────────────────────────────────────
@@ -589,21 +1002,30 @@ async function main() {
589
1002
  if (cmd === '--help' || cmd === '-h') { printHelp(); return; }
590
1003
  if (cmd === '--version' || cmd === '-v') { console.log(readVersion()); return; }
591
1004
 
592
- // Interactive-only commands: enter REPL after completing (only when TTY)
1005
+ // Interactive-only commands: enter screen state machine (only when TTY)
593
1006
  const isInteractive = process.stdin.isTTY;
594
1007
 
595
1008
  if (!cmd) {
596
- await cmdCard();
597
- if (isInteractive) await startRepl();
1009
+ if (isInteractive) {
1010
+ // Check if first-run (no profile) welcome screen, else dashboard
1011
+ const startScreen = profileExists() ? 'dashboard' : 'welcome';
1012
+ await runScreens(startScreen);
1013
+ } else {
1014
+ // Non-TTY: print status card and exit
1015
+ const cwd = process.cwd();
1016
+ const repo = loadRepoCache(cwd);
1017
+ const session = loadSession(cwd);
1018
+ const health = getHealth(cwd);
1019
+ const card = formatSessionCard(session, repo, health);
1020
+ console.log(card);
1021
+ }
598
1022
  return;
599
1023
  }
600
1024
 
601
1025
  if (cmd === 'init') {
602
1026
  if (isInteractive) {
603
- // Create the shared rl upfront so init wizard and REPL share it
604
- const rl = createInterface({ input: process.stdin, output: process.stdout });
605
- await cmdInit(rl);
606
- await startRepl(rl);
1027
+ // Run welcome wizard then dashboard
1028
+ await runScreens('welcome');
607
1029
  } else {
608
1030
  await cmdInit();
609
1031
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "7.0.2",
3
+ "version": "7.1.0",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/profile.mjs CHANGED
@@ -177,14 +177,22 @@ async function detectAuth() {
177
177
  // --- Claude: check .dualbrain/auth.json (before env var) ---
178
178
  if (!results.claude.found) {
179
179
  const storedAuth = loadAuthKeys();
180
- if (storedAuth.claude?.key) {
181
- const expired = storedAuth.claude.expiresAt && new Date(storedAuth.claude.expiresAt) <= new Date();
182
- if (!expired) {
183
- results.claude.found = true;
184
- results.claude.source = '.dualbrain/auth.json';
185
- results.claude.masked = _maskCredential(storedAuth.claude.key);
186
- process.env.ANTHROPIC_API_KEY = storedAuth.claude.key;
187
- }
180
+ const claudeKeys = storedAuth.claude || [];
181
+ const activeClaudeKey = getActiveKey('claude');
182
+ if (activeClaudeKey) {
183
+ results.claude.found = true;
184
+ results.claude.source = '.dualbrain/auth.json';
185
+ results.claude.masked = _maskCredential(activeClaudeKey.key);
186
+ process.env.ANTHROPIC_API_KEY = activeClaudeKey.key;
187
+ // Report all keys with masked values
188
+ const now = new Date();
189
+ results.claude.keys = claudeKeys.map(k => ({
190
+ label: k.label || 'unlabeled',
191
+ masked: _maskCredential(k.key),
192
+ priority: k.priority || 99,
193
+ enabled: k.enabled !== false,
194
+ expired: !!(k.expiresAt && new Date(k.expiresAt) <= now),
195
+ }));
188
196
  }
189
197
  }
190
198
 
@@ -225,14 +233,22 @@ async function detectAuth() {
225
233
  // --- OpenAI: check .dualbrain/auth.json (before env var) ---
226
234
  if (!results.openai.found) {
227
235
  const storedAuth = loadAuthKeys();
228
- if (storedAuth.openai?.key) {
229
- const expired = storedAuth.openai.expiresAt && new Date(storedAuth.openai.expiresAt) <= new Date();
230
- if (!expired) {
231
- results.openai.found = true;
232
- results.openai.source = '.dualbrain/auth.json';
233
- results.openai.masked = _maskCredential(storedAuth.openai.key);
234
- process.env.OPENAI_API_KEY = storedAuth.openai.key;
235
- }
236
+ const openaiKeys = storedAuth.openai || [];
237
+ const activeOpenaiKey = getActiveKey('openai');
238
+ if (activeOpenaiKey) {
239
+ results.openai.found = true;
240
+ results.openai.source = '.dualbrain/auth.json';
241
+ results.openai.masked = _maskCredential(activeOpenaiKey.key);
242
+ process.env.OPENAI_API_KEY = activeOpenaiKey.key;
243
+ // Report all keys with masked values
244
+ const now = new Date();
245
+ results.openai.keys = openaiKeys.map(k => ({
246
+ label: k.label || 'unlabeled',
247
+ masked: _maskCredential(k.key),
248
+ priority: k.priority || 99,
249
+ enabled: k.enabled !== false,
250
+ expired: !!(k.expiresAt && new Date(k.expiresAt) <= now),
251
+ }));
236
252
  }
237
253
  }
238
254
 
@@ -252,14 +268,82 @@ async function detectAuth() {
252
268
 
253
269
  const AUTH_FILE = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'auth.json');
254
270
 
271
+ /**
272
+ * Migrate old single-key format to array format transparently.
273
+ * Old: { claude: { key, savedAt, expiresAt }, openai: { ... } }
274
+ * New: { claude: [{ key, label, savedAt, expiresAt, priority, enabled }], openai: [...] }
275
+ * @param {object} auth
276
+ * @returns {object} migrated auth object
277
+ */
278
+ function _migrateAuthFormat(auth) {
279
+ const migrated = {};
280
+ for (const [provider, value] of Object.entries(auth)) {
281
+ if (Array.isArray(value)) {
282
+ // Already new format
283
+ migrated[provider] = value;
284
+ } else if (value && typeof value === 'object' && value.key) {
285
+ // Old single-key format — wrap in array
286
+ migrated[provider] = [
287
+ {
288
+ key: value.key,
289
+ label: 'primary',
290
+ savedAt: value.savedAt || new Date().toISOString(),
291
+ expiresAt: value.expiresAt || null,
292
+ priority: 1,
293
+ enabled: true,
294
+ },
295
+ ];
296
+ } else {
297
+ migrated[provider] = value;
298
+ }
299
+ }
300
+ return migrated;
301
+ }
302
+
303
+ /**
304
+ * Load .dualbrain/auth.json, migrating old single-key format to array format.
305
+ * @param {string} [cwd]
306
+ * @returns {object} auth object with arrays per provider
307
+ */
255
308
  function loadAuthKeys(cwd) {
256
309
  try {
257
- return JSON.parse(readFileSync(AUTH_FILE(cwd), 'utf8'));
310
+ const raw = JSON.parse(readFileSync(AUTH_FILE(cwd), 'utf8'));
311
+ return _migrateAuthFormat(raw);
258
312
  } catch {
259
313
  return {};
260
314
  }
261
315
  }
262
316
 
317
+ /**
318
+ * Returns the highest-priority, non-expired, enabled key for a provider.
319
+ * @param {string} provider
320
+ * @param {string} [cwd]
321
+ * @returns {{ key: string, label: string, priority: number, enabled: boolean, expiresAt: string|null }|null}
322
+ */
323
+ function getActiveKey(provider, cwd) {
324
+ const auth = loadAuthKeys(cwd);
325
+ const keys = auth[provider] || [];
326
+ const now = new Date();
327
+
328
+ const valid = keys
329
+ .filter(k => k.enabled)
330
+ .filter(k => !k.expiresAt || new Date(k.expiresAt) > now)
331
+ .sort((a, b) => (a.priority || 99) - (b.priority || 99));
332
+
333
+ return valid[0] || null;
334
+ }
335
+
336
+ /**
337
+ * Append a new key to the provider's array in .dualbrain/auth.json.
338
+ * Injects the highest-priority valid key into process.env.
339
+ * @param {string} provider
340
+ * @param {string} key
341
+ * @param {object} [opts]
342
+ * @param {string} [opts.label]
343
+ * @param {string|null} [opts.expiresAt]
344
+ * @param {number} [opts.priority]
345
+ * @param {string} [opts.cwd]
346
+ */
263
347
  function saveAuthKey(provider, key, opts = {}) {
264
348
  const cwd = opts.cwd || process.cwd();
265
349
  const authFile = AUTH_FILE(cwd);
@@ -267,16 +351,97 @@ function saveAuthKey(provider, key, opts = {}) {
267
351
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
268
352
 
269
353
  const auth = loadAuthKeys(cwd);
270
- auth[provider] = {
354
+ if (!Array.isArray(auth[provider])) auth[provider] = [];
355
+
356
+ // Determine default label and priority
357
+ const existing = auth[provider];
358
+ const defaultLabel = `key-${existing.length + 1}`;
359
+ const defaultPriority = existing.length > 0
360
+ ? Math.max(...existing.map(k => k.priority || 1)) + 1
361
+ : 1;
362
+
363
+ existing.push({
271
364
  key,
365
+ label: opts.label || defaultLabel,
272
366
  savedAt: new Date().toISOString(),
273
367
  expiresAt: opts.expiresAt || null,
274
- };
368
+ priority: opts.priority !== undefined ? opts.priority : defaultPriority,
369
+ enabled: true,
370
+ });
371
+
372
+ writeFileSync(authFile, JSON.stringify(auth, null, 2));
373
+
374
+ // Inject highest-priority valid key into process.env for this session
375
+ const active = getActiveKey(provider, cwd);
376
+ if (active) {
377
+ if (provider === 'claude') process.env.ANTHROPIC_API_KEY = active.key;
378
+ if (provider === 'openai') process.env.OPENAI_API_KEY = active.key;
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Remove a key by index from the provider's array.
384
+ * @param {string} provider
385
+ * @param {number} index
386
+ * @param {string} [cwd]
387
+ */
388
+ function removeAuthKey(provider, index, cwd) {
389
+ const authFile = AUTH_FILE(cwd);
390
+ const dir = dirname(authFile);
391
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
392
+
393
+ const auth = loadAuthKeys(cwd);
394
+ if (!Array.isArray(auth[provider])) return;
395
+
396
+ auth[provider].splice(index, 1);
275
397
  writeFileSync(authFile, JSON.stringify(auth, null, 2));
398
+ }
399
+
400
+ /**
401
+ * Mark a key as enabled:false (used during failover when a key hits rate limits).
402
+ * @param {string} provider
403
+ * @param {number} index
404
+ * @param {string} [cwd]
405
+ */
406
+ function disableKey(provider, index, cwd) {
407
+ const authFile = AUTH_FILE(cwd);
408
+ const dir = dirname(authFile);
409
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
410
+
411
+ const auth = loadAuthKeys(cwd);
412
+ if (!Array.isArray(auth[provider]) || !auth[provider][index]) return;
413
+
414
+ auth[provider][index].enabled = false;
415
+ writeFileSync(authFile, JSON.stringify(auth, null, 2));
416
+ }
417
+
418
+ /**
419
+ * Called when the active key hits a rate limit. Disables the current active key
420
+ * temporarily and returns the next valid key, or null if none available.
421
+ * @param {string} provider
422
+ * @param {string} [cwd]
423
+ * @returns {{ key: string, label: string, priority: number, enabled: boolean, expiresAt: string|null }|null}
424
+ */
425
+ function rotateToNextKey(provider, cwd) {
426
+ const auth = loadAuthKeys(cwd);
427
+ const keys = auth[provider] || [];
428
+ const now = new Date();
429
+
430
+ // Find current active key index
431
+ const sortedValid = keys
432
+ .map((k, i) => ({ ...k, _idx: i }))
433
+ .filter(k => k.enabled)
434
+ .filter(k => !k.expiresAt || new Date(k.expiresAt) > now)
435
+ .sort((a, b) => (a.priority || 99) - (b.priority || 99));
436
+
437
+ if (sortedValid.length === 0) return null;
438
+
439
+ // Disable the current active key
440
+ const currentIdx = sortedValid[0]._idx;
441
+ disableKey(provider, currentIdx, cwd);
276
442
 
277
- // Inject into process.env for this session so dispatch can use it
278
- if (provider === 'claude') process.env.ANTHROPIC_API_KEY = key;
279
- if (provider === 'openai') process.env.OPENAI_API_KEY = key;
443
+ // Reload and get the next valid key
444
+ return getActiveKey(provider, cwd);
280
445
  }
281
446
 
282
447
  /**
@@ -298,7 +463,9 @@ async function setupAuth(rl) {
298
463
  if (choice === '1') {
299
464
  const key = (await ask('Paste your Anthropic API key: ')).trim();
300
465
  if (key && (key.startsWith('sk-ant-') || key.startsWith('sk-'))) {
301
- const expiryStr = (await ask('Set key expiry? (enter days, or press Enter to skip)\n> ')).trim();
466
+ const labelStr = (await ask('Label for this key (or Enter for "key-1"): ')).trim();
467
+ const label = labelStr || undefined;
468
+ const expiryStr = (await ask('Set expiry in days (or Enter to skip): ')).trim();
302
469
  let expiresAt = null;
303
470
  if (expiryStr && /^\d+$/.test(expiryStr)) {
304
471
  const d = new Date();
@@ -306,7 +473,7 @@ async function setupAuth(rl) {
306
473
  expiresAt = d.toISOString();
307
474
  console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
308
475
  }
309
- saveAuthKey('claude', key, { expiresAt });
476
+ saveAuthKey('claude', key, { expiresAt, label });
310
477
  console.log('✓ Claude API key saved');
311
478
  } else {
312
479
  console.log('Invalid key format. Expected sk-ant-... or sk-...');
@@ -326,7 +493,9 @@ async function setupAuth(rl) {
326
493
  if (choice === '1') {
327
494
  const key = (await ask('Paste your OpenAI API key: ')).trim();
328
495
  if (key && key.startsWith('sk-')) {
329
- const expiryStr = (await ask('Set key expiry? (enter days, or press Enter to skip)\n> ')).trim();
496
+ const labelStr = (await ask('Label for this key (or Enter for "key-1"): ')).trim();
497
+ const label = labelStr || undefined;
498
+ const expiryStr = (await ask('Set expiry in days (or Enter to skip): ')).trim();
330
499
  let expiresAt = null;
331
500
  if (expiryStr && /^\d+$/.test(expiryStr)) {
332
501
  const d = new Date();
@@ -334,7 +503,7 @@ async function setupAuth(rl) {
334
503
  expiresAt = d.toISOString();
335
504
  console.log(`✓ Key expires in ${expiryStr} days (${d.toISOString().slice(0, 10)})`);
336
505
  }
337
- saveAuthKey('openai', key, { expiresAt });
506
+ saveAuthKey('openai', key, { expiresAt, label });
338
507
  console.log('✓ OpenAI API key saved');
339
508
  } else {
340
509
  console.log('Invalid key format. Expected sk-...');
@@ -714,4 +883,5 @@ export {
714
883
  detectPlans, syncPreferencesToMemory,
715
884
  detectAuth, detectEnvironment,
716
885
  setupAuth, saveAuthKey, loadAuthKeys,
886
+ getActiveKey, removeAuthKey, disableKey, rotateToNextKey,
717
887
  };
package/src/tui.mjs ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * tui.mjs — Zero-dependency terminal UI renderer for the dual-brain CLI.
3
+ * All functions return strings; callers use console.log to print.
4
+ */
5
+
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ // ─── Unicode / ASCII mode ─────────────────────────────────────────────────────
9
+
10
+ export const useUnicode =
11
+ process.env.DUALBRAIN_ASCII !== '1' && process.stdout.isTTY !== false;
12
+
13
+ const CH = useUnicode
14
+ ? { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║', ts: '╠', te: '╣', fill: '█', empty: '░' }
15
+ : { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|', ts: '+', te: '+', fill: '#', empty: '.' };
16
+
17
+ // ─── ANSI / emoji helpers ─────────────────────────────────────────────────────
18
+
19
+ /** Strip ANSI escape codes from a string. */
20
+ export function stripAnsi(str) {
21
+ // eslint-disable-next-line no-control-regex
22
+ return String(str).replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
23
+ }
24
+
25
+ /**
26
+ * Visible display length of a string.
27
+ * Strips ANSI codes and counts each emoji as 2 columns wide.
28
+ */
29
+ export function visibleLength(str) {
30
+ const plain = stripAnsi(String(str));
31
+ let len = 0;
32
+ for (const ch of plain) {
33
+ const cp = ch.codePointAt(0);
34
+ // Emoji / wide symbol ranges (covers most common emoji)
35
+ if (
36
+ (cp >= 0x1f300 && cp <= 0x1faff) || // Misc symbols, emoji
37
+ (cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols
38
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // Variation selectors
39
+ (cp >= 0x1f1e0 && cp <= 0x1f1ff) || // Flags
40
+ cp === 0x20e3 // Combining enclosing keycap
41
+ ) {
42
+ len += 2;
43
+ } else {
44
+ len += 1;
45
+ }
46
+ }
47
+ return len;
48
+ }
49
+
50
+ /**
51
+ * Right-pad `str` with spaces so that its visible width equals `width`.
52
+ * Accounts for emoji (2-wide) and ANSI codes.
53
+ */
54
+ export function pad(str, width) {
55
+ const vl = visibleLength(str);
56
+ const spaces = Math.max(0, width - vl);
57
+ return String(str) + ' '.repeat(spaces);
58
+ }
59
+
60
+ // ─── box ─────────────────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Renders a Unicode (or ASCII) box with a title bar.
64
+ * @param {string} title
65
+ * @param {string[]} lines
66
+ * @param {{ width?: number }} opts
67
+ * @returns {string}
68
+ */
69
+ export function box(title, lines = [], opts = {}) {
70
+ const inner = opts.width ?? 56;
71
+ const total = inner + 2; // 2 spaces padding on each side counted inside border
72
+
73
+ const top = CH.tl + CH.h.repeat(total) + CH.tr;
74
+ const divider = CH.ts + CH.h.repeat(total) + CH.te;
75
+ const bottom = CH.bl + CH.h.repeat(total) + CH.br;
76
+
77
+ // Title row: 2-space left pad
78
+ const titleContent = ' ' + title;
79
+ const titleRow = CH.v + pad(titleContent, total) + CH.v;
80
+
81
+ const bodyRows = lines.map(line => {
82
+ const content = ' ' + line;
83
+ return CH.v + pad(content, total) + CH.v;
84
+ });
85
+
86
+ return [top, titleRow, divider, ...bodyRows, bottom].join('\n');
87
+ }
88
+
89
+ // ─── bar ─────────────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Renders a percentage bar.
93
+ * @param {number} percent 0–100
94
+ * @param {number} width bar width in chars (default 20)
95
+ * @param {{ label?: string }} opts
96
+ * @returns {string}
97
+ */
98
+ export function bar(percent, width = 20, opts = {}) {
99
+ const pct = Math.max(0, Math.min(100, percent));
100
+ const filled = Math.round((pct / 100) * width);
101
+ const empty = width - filled;
102
+
103
+ const track = CH.fill.repeat(filled) + CH.empty.repeat(empty);
104
+ const pctStr = String(Math.round(pct)).padStart(3) + '%';
105
+ const label = opts.label ? ` ${opts.label}` : '';
106
+
107
+ return `${track} ${pctStr}${label}`;
108
+ }
109
+
110
+ // ─── badge ────────────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Returns a status badge emoji/symbol.
114
+ * @param {string} status
115
+ * @returns {string}
116
+ */
117
+ export function badge(status) {
118
+ const map = {
119
+ healthy: '🟢',
120
+ degraded: '🟡',
121
+ hot: '🔴',
122
+ probing: '🟠',
123
+ connected: '✅',
124
+ missing: '❌',
125
+ warning: '⚠️',
126
+ };
127
+ return map[status] ?? '❓';
128
+ }
129
+
130
+ // ─── separator ───────────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Returns a section separator line.
134
+ * @param {string} label
135
+ * @returns {string}
136
+ */
137
+ export function separator(label = '') {
138
+ const dash = useUnicode ? '─' : '-';
139
+ return label
140
+ ? ` ${dash}${dash}${dash} ${label}`
141
+ : ` ${dash}${dash}${dash}`;
142
+ }
143
+
144
+ // ─── menu ────────────────────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Renders a numbered/lettered menu grouped by section.
148
+ * @param {{ key: string, label: string, section?: string }[]} options
149
+ * @param {object} opts (reserved)
150
+ * @returns {string}
151
+ */
152
+ export function menu(options, opts = {}) {
153
+ const rows = [];
154
+ let lastSection = Symbol('none');
155
+
156
+ for (const opt of options) {
157
+ const section = opt.section ?? '';
158
+ if (section !== lastSection) {
159
+ if (section) {
160
+ rows.push(separator(section));
161
+ } else {
162
+ rows.push(separator());
163
+ }
164
+ lastSection = section;
165
+ }
166
+ rows.push(` [${opt.key}] ${opt.label}`);
167
+ }
168
+
169
+ return rows.join('\n');
170
+ }
171
+
172
+ // ─── Self-test ────────────────────────────────────────────────────────────────
173
+
174
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
175
+ console.log(box('🧠 Dual-Brain v7.0.2', [
176
+ '🟢 Claude ✅ 🟢 OpenAI ✅',
177
+ '🌀 Replit + replit-tools',
178
+ ]));
179
+ console.log(bar(75, 20, { label: 'Claude' }));
180
+ console.log(bar(25, 20, { label: 'OpenAI' }));
181
+ console.log(menu([
182
+ { key: 'c', label: 'Continue last session', section: 'Sessions' },
183
+ { key: 'n', label: 'New session', section: 'Sessions' },
184
+ { key: 'a', label: 'Auth management', section: 'Settings' },
185
+ { key: 'p', label: 'Profile settings', section: 'Settings' },
186
+ { key: 's', label: 'Exit to shell', section: '' },
187
+ ]));
188
+ }