@polderlabs/bizar 5.0.0 → 5.0.2

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.
Files changed (97) hide show
  1. package/bizar-dash/dist/assets/{icons-CFqu2M-c.js → icons-Bo0iH9EC.js} +166 -121
  2. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
  4. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
  6. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
  7. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
  8. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js → mobile-DD-FZrTC.js} +1 -1
  9. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js.map → mobile-DD-FZrTC.js.map} +1 -1
  10. package/bizar-dash/dist/index.html +4 -4
  11. package/bizar-dash/dist/mobile.html +3 -3
  12. package/bizar-dash/node_modules/.package-lock.json +6 -0
  13. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  14. package/bizar-dash/package-lock.json +6 -0
  15. package/bizar-dash/src/server/api.mjs +7 -0
  16. package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
  17. package/bizar-dash/src/server/memory-store.mjs +46 -0
  18. package/bizar-dash/src/server/providers-store.mjs +11 -4
  19. package/bizar-dash/src/server/routes/_shared.mjs +2 -2
  20. package/bizar-dash/src/server/routes/doctor.mjs +71 -0
  21. package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
  22. package/bizar-dash/src/server/routes/memory.mjs +3 -0
  23. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
  24. package/bizar-dash/src/server/routes/schedules.mjs +55 -0
  25. package/bizar-dash/src/server/serve-info.mjs +172 -0
  26. package/bizar-dash/src/web/App.tsx +48 -11
  27. package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
  28. package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
  29. package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
  30. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
  31. package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
  32. package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
  33. package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
  34. package/bizar-dash/src/web/components/Topbar.tsx +11 -0
  35. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
  36. package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
  37. package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
  38. package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
  39. package/bizar-dash/src/web/lib/types.ts +134 -0
  40. package/bizar-dash/src/web/styles/chat.css +8 -5
  41. package/bizar-dash/src/web/styles/main.css +77 -2
  42. package/bizar-dash/src/web/styles/settings.css +265 -0
  43. package/bizar-dash/src/web/views/Chat.tsx +15 -1
  44. package/bizar-dash/src/web/views/Doctor.tsx +317 -0
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
  46. package/bizar-dash/src/web/views/Schedules.tsx +94 -12
  47. package/bizar-dash/src/web/views/Settings.tsx +76 -46
  48. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
  49. package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
  50. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
  51. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
  52. package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
  53. package/bizar-dash/tests/autosave.test.tsx +276 -0
  54. package/bizar-dash/tests/chat-composer.test.tsx +140 -0
  55. package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
  56. package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
  57. package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
  58. package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
  59. package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
  60. package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
  61. package/bizar-dash/tests/minimax-models.test.mjs +123 -0
  62. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
  63. package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
  64. package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
  65. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  66. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  67. package/bizar-dash/tests/settings-nav.test.tsx +126 -0
  68. package/cli/commands/dash.mjs +5 -1
  69. package/cli/commands/minimax.mjs +5 -2
  70. package/cli/tests/minimax-cli.test.mjs +79 -0
  71. package/config/agents/frigg.md +1 -1
  72. package/config/agents/heimdall.md +1 -1
  73. package/config/agents/mimir.md +1 -1
  74. package/config/agents/quick.md +1 -1
  75. package/config/agents/semble-search.md +1 -1
  76. package/config/agents/vor.md +1 -1
  77. package/config/opencode.json +28 -21
  78. package/config/opencode.json.template +8 -1
  79. package/package.json +1 -1
  80. package/plugins/bizar/index.ts +77 -0
  81. package/plugins/bizar/src/compaction.d.mts +48 -0
  82. package/plugins/bizar/src/compaction.mjs +192 -0
  83. package/plugins/bizar/tests/compaction.test.ts +264 -0
  84. package/templates/schedules/daily-backup.json +12 -0
  85. package/templates/schedules/daily-cleanup.json +12 -0
  86. package/templates/schedules/hourly-health-check.json +12 -0
  87. package/templates/schedules/webhook-on-push.json +13 -0
  88. package/templates/schedules/weekly-digest.json +13 -0
  89. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +0 -1
  90. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +0 -16
  91. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +0 -1
  92. package/bizar-dash/dist/assets/main-ZAfGKENE.css +0 -1
  93. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +0 -2
  94. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +0 -1
  95. package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
  96. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
  97. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
@@ -0,0 +1,144 @@
1
+ /**
2
+ * bizar-dash/tests/schedules-templates.test.mjs
3
+ *
4
+ * Verify schedule template loading and from-template endpoint.
5
+ */
6
+ import { describe, it } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
13
+ const PROJECT_ROOT = join(__dirname, '..', '..');
14
+ const TEMPLATES_DIR = join(PROJECT_ROOT, 'templates', 'schedules');
15
+
16
+ describe('Schedule templates', () => {
17
+ it('templates/schedules directory exists', () => {
18
+ assert.ok(existsSync(TEMPLATES_DIR), 'Missing templates/schedules directory');
19
+ });
20
+
21
+ it('at least 4 template files exist', () => {
22
+ const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'));
23
+ assert.ok(files.length >= 4, `Expected >=4 templates, found ${files.length}: ${files.join(', ')}`);
24
+ });
25
+
26
+ it('each template is valid JSON with required fields', () => {
27
+ const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'));
28
+ for (const file of files) {
29
+ const content = readFileSync(join(TEMPLATES_DIR, file), 'utf8');
30
+ const template = JSON.parse(content);
31
+ assert.ok(template.id, `${file}: missing id`);
32
+ assert.ok(template.name, `${file}: missing name`);
33
+ assert.ok(template.description, `${file}: missing description`);
34
+ assert.ok(template.type, `${file}: missing type`);
35
+ assert.ok(template.action, `${file}: missing action`);
36
+ assert.strictEqual(template.source, undefined, `${file}: should NOT have source field (added at load time)`);
37
+ }
38
+ });
39
+
40
+ it('daily-backup.json is a valid cron schedule template', () => {
41
+ const content = readFileSync(join(TEMPLATES_DIR, 'daily-backup.json'), 'utf8');
42
+ const t = JSON.parse(content);
43
+ assert.strictEqual(t.type, 'cron');
44
+ assert.strictEqual(t.schedule, '0 3 * * *');
45
+ assert.strictEqual(t.action.type, 'command');
46
+ assert.ok(t.action.target.includes('bizar backup'));
47
+ });
48
+
49
+ it('weekly-digest.json is a valid agent template', () => {
50
+ const content = readFileSync(join(TEMPLATES_DIR, 'weekly-digest.json'), 'utf8');
51
+ const t = JSON.parse(content);
52
+ assert.strictEqual(t.type, 'cron');
53
+ assert.strictEqual(t.action.type, 'agent');
54
+ assert.ok(t.action.prompt);
55
+ });
56
+
57
+ it('hourly-health-check.json is a valid interval template', () => {
58
+ const content = readFileSync(join(TEMPLATES_DIR, 'hourly-health-check.json'), 'utf8');
59
+ const t = JSON.parse(content);
60
+ assert.strictEqual(t.type, 'interval');
61
+ assert.strictEqual(t.action.type, 'command');
62
+ });
63
+
64
+ it('webhook-on-push.json has webhook action', () => {
65
+ const content = readFileSync(join(TEMPLATES_DIR, 'webhook-on-push.json'), 'utf8');
66
+ const t = JSON.parse(content);
67
+ assert.strictEqual(t.action.type, 'webhook');
68
+ assert.ok(t.action.target);
69
+ });
70
+ });
71
+
72
+ describe('loadTemplates (unit)', () => {
73
+ // Re-implement loadTemplates here to test in isolation
74
+ function loadTemplates() {
75
+ if (!existsSync(TEMPLATES_DIR)) return [];
76
+ return readdirSync(TEMPLATES_DIR)
77
+ .filter((f) => f.endsWith('.json'))
78
+ .map((f) => {
79
+ const content = JSON.parse(readFileSync(join(TEMPLATES_DIR, f), 'utf8'));
80
+ return { ...content, source: 'template', templateFile: f };
81
+ });
82
+ }
83
+
84
+ it('returns array with source=template and templateFile set', () => {
85
+ const templates = loadTemplates();
86
+ assert.ok(Array.isArray(templates));
87
+ assert.ok(templates.length > 0);
88
+ for (const t of templates) {
89
+ assert.strictEqual(t.source, 'template', 'each template should have source=template');
90
+ assert.ok(t.templateFile.endsWith('.json'), 'templateFile should be a .json file');
91
+ }
92
+ });
93
+
94
+ it('missing templates dir returns empty array', () => {
95
+ // Use a path that definitely doesn't exist
96
+ const fakeDir = join(TEMPLATES_DIR, '..', 'schedules-does-not-exist');
97
+ function loadFromFake() {
98
+ if (!existsSync(fakeDir)) return [];
99
+ return readdirSync(fakeDir).filter((f) => f.endsWith('.json'));
100
+ }
101
+ const result = loadFromFake();
102
+ assert.deepStrictEqual(result, []);
103
+ });
104
+ });
105
+
106
+ describe('from-template endpoint contract', () => {
107
+ it('template payload can be used to create a valid schedule payload', () => {
108
+ const content = readFileSync(join(TEMPLATES_DIR, 'daily-backup.json'), 'utf8');
109
+ const template = JSON.parse(content);
110
+
111
+ // Simulate what the route does
112
+ const schedulePayload = {
113
+ name: template.name,
114
+ type: template.type,
115
+ schedule: template.schedule,
116
+ timezone: template.timezone || 'UTC',
117
+ action: template.action,
118
+ enabled: true,
119
+ };
120
+
121
+ assert.ok(schedulePayload.name);
122
+ assert.ok(['cron', 'interval', 'once'].includes(schedulePayload.type));
123
+ assert.ok(schedulePayload.schedule);
124
+ assert.ok(schedulePayload.action);
125
+ assert.strictEqual(schedulePayload.enabled, true);
126
+ });
127
+
128
+ it('custom name can override template name', () => {
129
+ const content = readFileSync(join(TEMPLATES_DIR, 'daily-backup.json'), 'utf8');
130
+ const template = JSON.parse(content);
131
+ const customName = 'My Custom Backup';
132
+
133
+ const schedulePayload = {
134
+ name: customName || template.name,
135
+ type: template.type,
136
+ schedule: template.schedule,
137
+ timezone: template.timezone || 'UTC',
138
+ action: template.action,
139
+ enabled: true,
140
+ };
141
+
142
+ assert.strictEqual(schedulePayload.name, customName);
143
+ });
144
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * tests/settings-layout.test.tsx
3
+ *
4
+ * v4.9.0 — Integration tests for the settings sidebar layout mode.
5
+ * Verifies that when settingsMode is active, the sidebar shows SettingsNav
6
+ * and clicking sections updates the active section.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
9
+ import { render, screen } from '@testing-library/react';
10
+ import userEvent from '@testing-library/user-event';
11
+ import React from 'react';
12
+ import { Sidebar } from '../src/web/components/Sidebar';
13
+ import type { TabDef } from '../src/web/components/Topbar';
14
+ import { LayoutDashboard, MessageSquare, Sliders } from 'lucide-react';
15
+
16
+ const TABS: TabDef[] = [
17
+ { id: 'overview', label: 'Overview', icon: LayoutDashboard },
18
+ { id: 'chat', label: 'Chat', icon: MessageSquare },
19
+ { id: 'settings', label: 'Settings', icon: Sliders },
20
+ ];
21
+
22
+ describe('Sidebar — settingsMode', () => {
23
+ const onTabChange = vi.fn();
24
+ const onSettingsSectionChange = vi.fn();
25
+ const onExitSettings = vi.fn();
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ it('renders normal tab rail when settingsMode is false', () => {
32
+ render(
33
+ <Sidebar
34
+ tabs={TABS}
35
+ activeTab="overview"
36
+ onTabChange={onTabChange}
37
+ settingsMode={false}
38
+ />,
39
+ );
40
+ expect(screen.getByRole('tablist')).toBeInTheDocument();
41
+ expect(screen.getByRole('tab', { name: /overview/i })).toBeInTheDocument();
42
+ expect(screen.getByRole('tab', { name: /chat/i })).toBeInTheDocument();
43
+ });
44
+
45
+ it('renders SettingsNav instead of tab rail when settingsMode is true', () => {
46
+ render(
47
+ <Sidebar
48
+ tabs={TABS}
49
+ activeTab="settings"
50
+ onTabChange={onTabChange}
51
+ settingsMode={true}
52
+ settingsActiveSection={null}
53
+ onSettingsSectionChange={onSettingsSectionChange}
54
+ onExitSettings={onExitSettings}
55
+ />,
56
+ );
57
+ // Should NOT show the normal tab rail
58
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
59
+ // Should show the back button
60
+ expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument();
61
+ // Should show section groups (use selector to avoid duplicate text matches)
62
+ expect(screen.getByText('General', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
63
+ expect(screen.getByText('Core', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
64
+ });
65
+
66
+ it('shows Back button that calls onExitSettings', async () => {
67
+ const user = userEvent.setup();
68
+ render(
69
+ <Sidebar
70
+ tabs={TABS}
71
+ activeTab="settings"
72
+ onTabChange={onTabChange}
73
+ settingsMode={true}
74
+ settingsActiveSection={null}
75
+ onSettingsSectionChange={onSettingsSectionChange}
76
+ onExitSettings={onExitSettings}
77
+ />,
78
+ );
79
+ await user.click(screen.getByText('Back'));
80
+ expect(onExitSettings).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it('highlights the active section when settingsActiveSection is set', () => {
84
+ render(
85
+ <Sidebar
86
+ tabs={TABS}
87
+ activeTab="settings"
88
+ onTabChange={onTabChange}
89
+ settingsMode={true}
90
+ settingsActiveSection="theme"
91
+ onSettingsSectionChange={onSettingsSectionChange}
92
+ onExitSettings={onExitSettings}
93
+ />,
94
+ );
95
+ const themeBtn = screen.getByRole('button', { name: /theme/i });
96
+ expect(themeBtn).toHaveClass('settings-nav-item-active');
97
+ });
98
+
99
+ it('calls onSettingsSectionChange when a section is clicked', async () => {
100
+ const user = userEvent.setup();
101
+ render(
102
+ <Sidebar
103
+ tabs={TABS}
104
+ activeTab="settings"
105
+ onTabChange={onTabChange}
106
+ settingsMode={true}
107
+ settingsActiveSection={null}
108
+ onSettingsSectionChange={onSettingsSectionChange}
109
+ onExitSettings={onExitSettings}
110
+ />,
111
+ );
112
+ await user.click(screen.getByRole('button', { name: /memory/i }));
113
+ expect(onSettingsSectionChange).toHaveBeenCalledWith('memory');
114
+ });
115
+
116
+ it('calls onTabChange when a normal sidebar tab is clicked', async () => {
117
+ const user = userEvent.setup();
118
+ render(
119
+ <Sidebar
120
+ tabs={TABS}
121
+ activeTab="overview"
122
+ onTabChange={onTabChange}
123
+ settingsMode={false}
124
+ />,
125
+ );
126
+ await user.click(screen.getByRole('tab', { name: /chat/i }));
127
+ expect(onTabChange).toHaveBeenCalledWith('chat');
128
+ });
129
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * tests/settings-mode-wiring.test.tsx
3
+ *
4
+ * v4.9.0 — Wiring tests for settings mode.
5
+ * Verifies:
6
+ * 1. Topbar Settings tab calls onTabChange('settings') when clicked
7
+ * 2. Sidebar renders SettingsNav when settingsMode is true
8
+ * 3. Sidebar renders normal tab rail when settingsMode is false
9
+ */
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { render, screen } from '@testing-library/react';
12
+ import userEvent from '@testing-library/user-event';
13
+ import React from 'react';
14
+ import { Sidebar } from '../src/web/components/Sidebar';
15
+ import { Topbar, TABS } from '../src/web/components/Topbar';
16
+ import type { TabDef } from '../src/web/components/Topbar';
17
+
18
+ // Minimal tabs for testing
19
+ const TEST_TABS: TabDef[] = TABS.slice(0, 4);
20
+
21
+ describe('Settings mode wiring', () => {
22
+ describe('Topbar', () => {
23
+ it('calls onTabChange with "settings" when the Settings tab is clicked', async () => {
24
+ const onTabChange = vi.fn();
25
+ const user = userEvent.setup();
26
+
27
+ render(
28
+ <Topbar
29
+ activeTab="overview"
30
+ onTabChange={onTabChange}
31
+ wsStatus="connected"
32
+ version="v4.9.0"
33
+ activeProject={null}
34
+ projects={[]}
35
+ onProjectChange={vi.fn()}
36
+ onProjectsRefresh={vi.fn()}
37
+ onOpenSearch={vi.fn()}
38
+ />,
39
+ );
40
+
41
+ // Find and click the Settings tab
42
+ const settingsTab = screen.getByRole('tab', { name: /settings/i });
43
+ await user.click(settingsTab);
44
+
45
+ expect(onTabChange).toHaveBeenCalledWith('settings');
46
+ });
47
+
48
+ it('calls onTabChange with "overview" when the Overview tab is clicked', async () => {
49
+ const onTabChange = vi.fn();
50
+ const user = userEvent.setup();
51
+
52
+ render(
53
+ <Topbar
54
+ activeTab="settings"
55
+ onTabChange={onTabChange}
56
+ wsStatus="connected"
57
+ version="v4.9.0"
58
+ activeProject={null}
59
+ projects={[]}
60
+ onProjectChange={vi.fn()}
61
+ onProjectsRefresh={vi.fn()}
62
+ onOpenSearch={vi.fn()}
63
+ />,
64
+ );
65
+
66
+ const overviewTab = screen.getByRole('tab', { name: /overview/i });
67
+ await user.click(overviewTab);
68
+
69
+ expect(onTabChange).toHaveBeenCalledWith('overview');
70
+ });
71
+ });
72
+
73
+ describe('Sidebar', () => {
74
+ const onTabChange = vi.fn();
75
+ const onSettingsSectionChange = vi.fn();
76
+ const onExitSettings = vi.fn();
77
+
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ });
81
+
82
+ it('renders SettingsNav when settingsMode is true', () => {
83
+ render(
84
+ <Sidebar
85
+ tabs={TEST_TABS}
86
+ activeTab="settings"
87
+ onTabChange={onTabChange}
88
+ settingsMode={true}
89
+ settingsActiveSection={null}
90
+ onSettingsSectionChange={onSettingsSectionChange}
91
+ onExitSettings={onExitSettings}
92
+ />,
93
+ );
94
+
95
+ // SettingsNav should be visible (renders the back button)
96
+ expect(screen.getByRole('button', { name: /exit settings/i })).toBeInTheDocument();
97
+ // Normal tab rail should NOT be visible
98
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
99
+ });
100
+
101
+ it('renders normal tab rail when settingsMode is false', () => {
102
+ render(
103
+ <Sidebar
104
+ tabs={TEST_TABS}
105
+ activeTab="overview"
106
+ onTabChange={onTabChange}
107
+ settingsMode={false}
108
+ />,
109
+ );
110
+
111
+ // Tab rail should be visible
112
+ expect(screen.getByRole('tablist')).toBeInTheDocument();
113
+ // SettingsNav should NOT be visible
114
+ expect(screen.queryByRole('button', { name: /back/i })).not.toBeInTheDocument();
115
+ });
116
+
117
+ it('exits settings mode when back button is clicked', async () => {
118
+ const user = userEvent.setup();
119
+ render(
120
+ <Sidebar
121
+ tabs={TEST_TABS}
122
+ activeTab="settings"
123
+ onTabChange={onTabChange}
124
+ settingsMode={true}
125
+ settingsActiveSection={null}
126
+ onSettingsSectionChange={onSettingsSectionChange}
127
+ onExitSettings={onExitSettings}
128
+ />,
129
+ );
130
+
131
+ await user.click(screen.getByRole('button', { name: /exit settings/i }));
132
+ expect(onExitSettings).toHaveBeenCalledTimes(1);
133
+ });
134
+
135
+ it('calls onTabChange when a tab is clicked while in settingsMode=false', async () => {
136
+ const user = userEvent.setup();
137
+ render(
138
+ <Sidebar
139
+ tabs={TEST_TABS}
140
+ activeTab="overview"
141
+ onTabChange={onTabChange}
142
+ settingsMode={false}
143
+ />,
144
+ );
145
+
146
+ // Click the Chat tab
147
+ await user.click(screen.getByRole('tab', { name: /chat/i }));
148
+ expect(onTabChange).toHaveBeenCalledWith('chat');
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * tests/settings-nav.test.tsx
3
+ *
4
+ * v4.9.0 — Component tests for SettingsNav sidebar component.
5
+ */
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
7
+ import { render, screen } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+ import React from 'react';
10
+ import { SettingsNav } from '../src/web/components/SettingsNav';
11
+
12
+ describe('SettingsNav', () => {
13
+ const onSectionChange = vi.fn();
14
+ const onExitSettings = vi.fn();
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ it('renders the back button', () => {
21
+ render(
22
+ <SettingsNav
23
+ activeSection={null}
24
+ onSectionChange={onSectionChange}
25
+ onExitSettings={onExitSettings}
26
+ />,
27
+ );
28
+ expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument();
29
+ });
30
+
31
+ it('calls onExitSettings when back button is clicked', async () => {
32
+ const user = userEvent.setup();
33
+ render(
34
+ <SettingsNav
35
+ activeSection={null}
36
+ onSectionChange={onSectionChange}
37
+ onExitSettings={onExitSettings}
38
+ />,
39
+ );
40
+ await user.click(screen.getByText('Back'));
41
+ expect(onExitSettings).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it('renders section groups with group-label class', () => {
45
+ render(
46
+ <SettingsNav
47
+ activeSection={null}
48
+ onSectionChange={onSectionChange}
49
+ onExitSettings={onExitSettings}
50
+ />,
51
+ );
52
+ expect(screen.getByText('General', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
53
+ expect(screen.getByText('Core', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
54
+ expect(screen.getByText('Experience', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
55
+ expect(screen.getByText('Data', { selector: '.settings-nav-group-label' })).toBeInTheDocument();
56
+ });
57
+
58
+ it('renders section items within each group', () => {
59
+ render(
60
+ <SettingsNav
61
+ activeSection={null}
62
+ onSectionChange={onSectionChange}
63
+ onExitSettings={onExitSettings}
64
+ />,
65
+ );
66
+ expect(screen.getByRole('button', { name: /theme/i })).toBeInTheDocument();
67
+ expect(screen.getByRole('button', { name: /env vars/i })).toBeInTheDocument();
68
+ expect(screen.getByRole('button', { name: /memory/i })).toBeInTheDocument();
69
+ expect(screen.getByRole('button', { name: /system llm/i })).toBeInTheDocument();
70
+ expect(screen.getByRole('button', { name: /updates/i })).toBeInTheDocument();
71
+ expect(screen.getByRole('button', { name: /skills/i })).toBeInTheDocument();
72
+ expect(screen.getByRole('button', { name: /headroom/i })).toBeInTheDocument();
73
+ expect(screen.getByRole('button', { name: /backup/i })).toBeInTheDocument();
74
+ });
75
+
76
+ it('calls onSectionChange with correct id when a section is clicked', async () => {
77
+ const user = userEvent.setup();
78
+ render(
79
+ <SettingsNav
80
+ activeSection={null}
81
+ onSectionChange={onSectionChange}
82
+ onExitSettings={onExitSettings}
83
+ />,
84
+ );
85
+ await user.click(screen.getByRole('button', { name: /theme/i }));
86
+ expect(onSectionChange).toHaveBeenCalledWith('theme');
87
+ });
88
+
89
+ it('highlights the active section', () => {
90
+ render(
91
+ <SettingsNav
92
+ activeSection="theme"
93
+ onSectionChange={onSectionChange}
94
+ onExitSettings={onExitSettings}
95
+ />,
96
+ );
97
+ const themeBtn = screen.getByRole('button', { name: /theme/i });
98
+ expect(themeBtn).toHaveClass('settings-nav-item-active');
99
+ });
100
+
101
+ it('toggles off the active section when clicking it again', async () => {
102
+ const user = userEvent.setup();
103
+ render(
104
+ <SettingsNav
105
+ activeSection="theme"
106
+ onSectionChange={onSectionChange}
107
+ onExitSettings={onExitSettings}
108
+ />,
109
+ );
110
+ await user.click(screen.getByRole('button', { name: /theme/i }));
111
+ expect(onSectionChange).toHaveBeenCalledWith(null); // toggles off
112
+ });
113
+
114
+ it('renders chevron on the active section', () => {
115
+ render(
116
+ <SettingsNav
117
+ activeSection="theme"
118
+ onSectionChange={onSectionChange}
119
+ onExitSettings={onExitSettings}
120
+ />,
121
+ );
122
+ const themeBtn = screen.getByRole('button', { name: /theme/i });
123
+ // The chevron is inside the button as an SVG (aria-hidden), so we just verify the button exists
124
+ expect(themeBtn).toBeInTheDocument();
125
+ });
126
+ });
@@ -129,7 +129,11 @@ export async function runDash(dashArgs) {
129
129
  switch (sub) {
130
130
  case 'start':
131
131
  if (subOpts.bg) {
132
- await dashModule.startInBackground(['start', ...(subOpts.subArgs || [])]);
132
+ // Pass --port, --host, --bind through to the child process
133
+ const bgArgs = ['start', ...(subOpts.subArgs || [])];
134
+ if (subOpts.port) bgArgs.push('--port', String(subOpts.port));
135
+ if (process.env.BIZAR_DASHBOARD_BIND) bgArgs.push('--bind', process.env.BIZAR_DASHBOARD_BIND);
136
+ await dashModule.startInBackground(bgArgs);
133
137
  } else {
134
138
  await dashModule.start(subOpts);
135
139
  }
@@ -148,8 +148,11 @@ async function runMinimaxCommand(minimaxArgs) {
148
148
  for (const m of data.models || []) {
149
149
  const five = m.current_interval_remaining_percent;
150
150
  const week = m.current_weekly_remaining_percent;
151
- const fiveBar = '█'.repeat(Math.round(five / 5)) + '░'.repeat(20 - Math.round(five / 5));
152
- const weekBar = '█'.repeat(Math.round(week / 5)) + '░'.repeat(20 - Math.round(week / 5));
151
+ // Bar shows CONSUMED as filled (inverted from remaining)
152
+ const fiveConsumed = 100 - five;
153
+ const weekConsumed = 100 - week;
154
+ const fiveBar = '█'.repeat(Math.round(fiveConsumed / 5)) + '░'.repeat(20 - Math.round(fiveConsumed / 5));
155
+ const weekBar = '█'.repeat(Math.round(weekConsumed / 5)) + '░'.repeat(20 - Math.round(weekConsumed / 5));
153
156
  const fiveColor = five >= 75 ? chalk.green : five >= 25 ? chalk.yellow : chalk.red;
154
157
  const weekColor = week >= 75 ? chalk.green : week >= 25 ? chalk.yellow : chalk.red;
155
158
  console.log(` ${chalk.bold(m.model_name)}`);
@@ -0,0 +1,79 @@
1
+ /**
2
+ * cli/tests/minimax-cli.test.mjs
3
+ *
4
+ * Tests for MiniMax CLI bar rendering.
5
+ * Verifies bar shows CONSUMED quota as filled blocks (not remaining).
6
+ */
7
+
8
+ import { test, describe } from 'node:test';
9
+ import assert from 'node:assert';
10
+
11
+ // ── Bar rendering helper (mirrors minimax.mjs logic) ──────────────────────────
12
+
13
+ /**
14
+ * Render a quota bar string.
15
+ * @param {number} remainingPct - percentage of quota remaining (0-100)
16
+ * @returns {string} bar string like '██░░░░░░░░░░░░░░░░░' (20 chars total)
17
+ */
18
+ function renderBar(remainingPct) {
19
+ const consumed = 100 - remainingPct;
20
+ const filled = Math.round(consumed / 5);
21
+ const empty = 20 - filled;
22
+ return '█'.repeat(filled) + '░'.repeat(empty);
23
+ }
24
+
25
+ // ── Tests ───────────────────────────────────────────────────────────────────
26
+
27
+ describe('MiniMax CLI bar rendering', () => {
28
+
29
+ test('91% remaining → 9% consumed → 2 filled blocks (9/5 rounds to 2)', () => {
30
+ const bar = renderBar(91);
31
+ const filledCount = (bar.match(/█/g) || []).length;
32
+ assert.strictEqual(filledCount, 2, `Expected 2 filled blocks for 91% remaining, got ${filledCount}`);
33
+ assert.strictEqual(bar.length, 20, `Bar should be exactly 20 chars`);
34
+ });
35
+
36
+ test('100% remaining → 0% consumed → 0 filled blocks', () => {
37
+ const bar = renderBar(100);
38
+ const filledCount = (bar.match(/█/g) || []).length;
39
+ assert.strictEqual(filledCount, 0, `Expected 0 filled blocks for 100% remaining`);
40
+ });
41
+
42
+ test('0% remaining → 100% consumed → 20 filled blocks', () => {
43
+ const bar = renderBar(0);
44
+ const filledCount = (bar.match(/█/g) || []).length;
45
+ assert.strictEqual(filledCount, 20, `Expected 20 filled blocks for 0% remaining`);
46
+ });
47
+
48
+ test('75% remaining → 25% consumed → 5 filled blocks', () => {
49
+ const bar = renderBar(75);
50
+ const filledCount = (bar.match(/█/g) || []).length;
51
+ assert.strictEqual(filledCount, 5, `Expected 5 filled blocks for 75% remaining`);
52
+ });
53
+
54
+ test('25% remaining → 75% consumed → 15 filled blocks', () => {
55
+ const bar = renderBar(25);
56
+ const filledCount = (bar.match(/█/g) || []).length;
57
+ assert.strictEqual(filledCount, 15, `Expected 15 filled blocks for 25% remaining`);
58
+ });
59
+
60
+ test('bar is always exactly 20 characters', () => {
61
+ for (const remaining of [0, 10, 25, 50, 75, 90, 100]) {
62
+ const bar = renderBar(remaining);
63
+ assert.strictEqual(bar.length, 20, `Bar for ${remaining}% remaining should be 20 chars, got ${bar.length}`);
64
+ }
65
+ });
66
+
67
+ test('filled blocks increase as remaining decreases', () => {
68
+ const bars = [100, 91, 75, 50, 25, 10, 0].map(r => ({
69
+ remaining: r,
70
+ filled: (renderBar(r).match(/█/g) || []).length
71
+ }));
72
+ for (let i = 1; i < bars.length; i++) {
73
+ assert.ok(
74
+ bars[i].filled >= bars[i-1].filled,
75
+ `${bars[i].remaining}% remaining (${bars[i].filled} blocks) should have >= filled blocks than ${bars[i-1].remaining}% remaining (${bars[i-1].filled} blocks)`
76
+ );
77
+ }
78
+ });
79
+ });
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Frigg — Read-only codebase Q&A. Answers questions about the project with file references, never modifies anything. Routes to no one.
3
3
  mode: subagent
4
- model: opencode/deepseek-v4-flash-free
4
+ model: minimax/MiniMax-M2.7
5
5
  color: "#f472b6"
6
6
  permission:
7
7
  read: allow
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Heimdall — Simple, routine, and deterministic tasks using DeepSeek. Quick edits, mechanical work, file operations. The ever-watchful guardian.
3
3
  mode: subagent
4
- model: opencode/deepseek-v4-flash-free
4
+ model: minimax/MiniMax-M2.7
5
5
  color: "#10b981"
6
6
  permission:
7
7
  read: allow