tlc-claude-code 1.4.1 → 1.4.4

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 (91) hide show
  1. package/dashboard/dist/App.js +229 -35
  2. package/dashboard/dist/components/AgentRegistryPane.d.ts +35 -0
  3. package/dashboard/dist/components/AgentRegistryPane.js +89 -0
  4. package/dashboard/dist/components/AgentRegistryPane.test.d.ts +1 -0
  5. package/dashboard/dist/components/AgentRegistryPane.test.js +200 -0
  6. package/dashboard/dist/components/RouterPane.d.ts +5 -0
  7. package/dashboard/dist/components/RouterPane.js +65 -0
  8. package/dashboard/dist/components/RouterPane.test.d.ts +1 -0
  9. package/dashboard/dist/components/RouterPane.test.js +176 -0
  10. package/dashboard/dist/components/accessibility.test.d.ts +1 -0
  11. package/dashboard/dist/components/accessibility.test.js +116 -0
  12. package/dashboard/dist/components/layout/MobileNav.d.ts +16 -0
  13. package/dashboard/dist/components/layout/MobileNav.js +31 -0
  14. package/dashboard/dist/components/layout/MobileNav.test.d.ts +1 -0
  15. package/dashboard/dist/components/layout/MobileNav.test.js +111 -0
  16. package/dashboard/dist/components/performance.test.d.ts +1 -0
  17. package/dashboard/dist/components/performance.test.js +114 -0
  18. package/dashboard/dist/components/responsive.test.d.ts +1 -0
  19. package/dashboard/dist/components/responsive.test.js +114 -0
  20. package/dashboard/dist/components/ui/Dropdown.d.ts +22 -0
  21. package/dashboard/dist/components/ui/Dropdown.js +109 -0
  22. package/dashboard/dist/components/ui/Dropdown.test.d.ts +1 -0
  23. package/dashboard/dist/components/ui/Dropdown.test.js +105 -0
  24. package/dashboard/dist/components/ui/Modal.d.ts +13 -0
  25. package/dashboard/dist/components/ui/Modal.js +25 -0
  26. package/dashboard/dist/components/ui/Modal.test.d.ts +1 -0
  27. package/dashboard/dist/components/ui/Modal.test.js +91 -0
  28. package/dashboard/dist/components/ui/Skeleton.d.ts +32 -0
  29. package/dashboard/dist/components/ui/Skeleton.js +48 -0
  30. package/dashboard/dist/components/ui/Skeleton.test.d.ts +1 -0
  31. package/dashboard/dist/components/ui/Skeleton.test.js +125 -0
  32. package/dashboard/dist/components/ui/Toast.d.ts +32 -0
  33. package/dashboard/dist/components/ui/Toast.js +21 -0
  34. package/dashboard/dist/components/ui/Toast.test.d.ts +1 -0
  35. package/dashboard/dist/components/ui/Toast.test.js +118 -0
  36. package/dashboard/dist/hooks/useTheme.d.ts +37 -0
  37. package/dashboard/dist/hooks/useTheme.js +96 -0
  38. package/dashboard/dist/hooks/useTheme.test.d.ts +1 -0
  39. package/dashboard/dist/hooks/useTheme.test.js +94 -0
  40. package/dashboard/dist/hooks/useWebSocket.d.ts +17 -0
  41. package/dashboard/dist/hooks/useWebSocket.js +100 -0
  42. package/dashboard/dist/hooks/useWebSocket.test.d.ts +1 -0
  43. package/dashboard/dist/hooks/useWebSocket.test.js +115 -0
  44. package/dashboard/dist/stores/projectStore.d.ts +44 -0
  45. package/dashboard/dist/stores/projectStore.js +76 -0
  46. package/dashboard/dist/stores/projectStore.test.d.ts +1 -0
  47. package/dashboard/dist/stores/projectStore.test.js +114 -0
  48. package/dashboard/dist/stores/uiStore.d.ts +29 -0
  49. package/dashboard/dist/stores/uiStore.js +72 -0
  50. package/dashboard/dist/stores/uiStore.test.d.ts +1 -0
  51. package/dashboard/dist/stores/uiStore.test.js +93 -0
  52. package/dashboard/package.json +3 -3
  53. package/docker-compose.dev.yml +6 -1
  54. package/package.json +5 -2
  55. package/server/dashboard/index.html +1336 -779
  56. package/server/index.js +178 -0
  57. package/server/lib/agent-cleanup.js +177 -0
  58. package/server/lib/agent-cleanup.test.js +359 -0
  59. package/server/lib/agent-hooks.js +126 -0
  60. package/server/lib/agent-hooks.test.js +303 -0
  61. package/server/lib/agent-metadata.js +179 -0
  62. package/server/lib/agent-metadata.test.js +383 -0
  63. package/server/lib/agent-persistence.js +191 -0
  64. package/server/lib/agent-persistence.test.js +475 -0
  65. package/server/lib/agent-registry-command.js +340 -0
  66. package/server/lib/agent-registry-command.test.js +334 -0
  67. package/server/lib/agent-registry.js +155 -0
  68. package/server/lib/agent-registry.test.js +239 -0
  69. package/server/lib/agent-state.js +236 -0
  70. package/server/lib/agent-state.test.js +375 -0
  71. package/server/lib/api-provider.js +186 -0
  72. package/server/lib/api-provider.test.js +336 -0
  73. package/server/lib/cli-detector.js +166 -0
  74. package/server/lib/cli-detector.test.js +269 -0
  75. package/server/lib/cli-provider.js +212 -0
  76. package/server/lib/cli-provider.test.js +349 -0
  77. package/server/lib/debug.test.js +62 -0
  78. package/server/lib/devserver-router-api.js +249 -0
  79. package/server/lib/devserver-router-api.test.js +426 -0
  80. package/server/lib/model-router.js +245 -0
  81. package/server/lib/model-router.test.js +313 -0
  82. package/server/lib/output-schemas.js +269 -0
  83. package/server/lib/output-schemas.test.js +307 -0
  84. package/server/lib/provider-interface.js +153 -0
  85. package/server/lib/provider-interface.test.js +394 -0
  86. package/server/lib/provider-queue.js +158 -0
  87. package/server/lib/provider-queue.test.js +315 -0
  88. package/server/lib/router-config.js +221 -0
  89. package/server/lib/router-config.test.js +237 -0
  90. package/server/lib/router-setup-command.js +419 -0
  91. package/server/lib/router-setup-command.test.js +375 -0
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useState, useEffect } from 'react';
4
+ export default function RouterPane({ apiUrl = '/api/router/status' }) {
5
+ const [status, setStatus] = useState(null);
6
+ const [loading, setLoading] = useState(true);
7
+ const [error, setError] = useState(null);
8
+ useEffect(() => {
9
+ const fetchStatus = async () => {
10
+ try {
11
+ const response = await fetch(apiUrl);
12
+ if (!response.ok) {
13
+ throw new Error('Failed to fetch');
14
+ }
15
+ const data = await response.json();
16
+ setStatus(data);
17
+ setError(null);
18
+ }
19
+ catch (err) {
20
+ setError(err instanceof Error ? err.message : 'Unknown error');
21
+ }
22
+ finally {
23
+ setLoading(false);
24
+ }
25
+ };
26
+ fetchStatus();
27
+ }, [apiUrl]);
28
+ if (loading) {
29
+ return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Model Router" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "Loading router status..." }) })] }));
30
+ }
31
+ if (error) {
32
+ return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Model Router" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })] }));
33
+ }
34
+ if (!status) {
35
+ return null;
36
+ }
37
+ const providers = Object.entries(status.providers);
38
+ const capabilities = Object.entries(status.capabilities || {});
39
+ return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Model Router" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Providers" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [providers.map(([name, provider]) => (_jsx(ProviderRow, { name: name, provider: provider }, name))), providers.length === 0 && (_jsx(Text, { color: "gray", children: "No providers configured" }))] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Devserver" }), _jsx(DevserverRow, { devserver: status.devserver })] }), capabilities.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Routing" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: capabilities.map(([name, cap]) => (_jsx(CapabilityRow, { name: name, providers: cap.providers, allProviders: status.providers }, name))) })] })), status.costEstimate && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Cost Estimates (Monthly)" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: Object.entries(status.costEstimate).map(([name, costs]) => (_jsxs(Box, { children: [_jsx(Text, { children: name.padEnd(12) }), _jsx(Text, { color: "green", children: "local: $0.00" }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "yellow", children: ["devserver: $", costs.devserver.toFixed(2)] })] }, name))) })] }))] }));
40
+ }
41
+ function ProviderRow({ name, provider }) {
42
+ const isLocal = provider.type === 'cli' && provider.detected;
43
+ const healthIndicator = provider.healthy !== false ? '●' : '○';
44
+ const healthColor = provider.healthy !== false ? 'green' : 'red';
45
+ const routingBadge = isLocal ? 'local' : 'devserver';
46
+ const badgeColor = isLocal ? 'green' : 'yellow';
47
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: healthColor, children: [healthIndicator, " "] }), _jsx(Text, { bold: true, children: name.padEnd(10) }), provider.version && _jsxs(Text, { color: "gray", children: [" ", provider.version.padEnd(10)] }), _jsxs(Text, { color: badgeColor, children: ["[", routingBadge, "]"] }), provider.capabilities && provider.capabilities.length > 0 && (_jsxs(Text, { color: "gray", children: [" (", provider.capabilities.join(', '), ")"] }))] }));
48
+ }
49
+ function DevserverRow({ devserver }) {
50
+ if (!devserver.configured) {
51
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Not configured - run " }), _jsx(Text, { color: "cyan", children: "tlc router setup" })] }));
52
+ }
53
+ const statusText = devserver.connected ? 'Connected' : 'Disconnected';
54
+ const statusColor = devserver.connected ? 'green' : 'red';
55
+ const indicator = devserver.connected ? '●' : '○';
56
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: statusColor, children: [indicator, " ", statusText] }) }), devserver.url && (_jsx(Box, { children: _jsx(Text, { color: "gray", children: devserver.url }) }))] }));
57
+ }
58
+ function CapabilityRow({ name, providers, allProviders, }) {
59
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "white", children: name.padEnd(12) }), _jsx(Text, { children: "\u2192 " }), providers.map((p, idx) => {
60
+ const provider = allProviders[p];
61
+ const isLocal = provider?.type === 'cli' && provider?.detected;
62
+ const color = isLocal ? 'green' : 'yellow';
63
+ return (_jsxs(Text, { children: [_jsx(Text, { color: color, children: p }), idx < providers.length - 1 && _jsx(Text, { children: ", " })] }, p));
64
+ })] }));
65
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,176 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { render } from 'ink-testing-library';
4
+ import RouterPane from './RouterPane.js';
5
+ // Mock fetch
6
+ global.fetch = vi.fn();
7
+ describe('RouterPane', () => {
8
+ beforeEach(() => {
9
+ vi.useFakeTimers();
10
+ vi.clearAllMocks();
11
+ });
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ });
15
+ describe('rendering', () => {
16
+ it('renders detected CLIs', async () => {
17
+ global.fetch.mockResolvedValue({
18
+ ok: true,
19
+ json: () => Promise.resolve({
20
+ providers: {
21
+ claude: { detected: true, type: 'cli', version: 'v4.0.0' },
22
+ codex: { detected: true, type: 'cli', version: 'v1.0.0' },
23
+ },
24
+ devserver: { configured: true, connected: true },
25
+ }),
26
+ });
27
+ const { lastFrame } = render(_jsx(RouterPane, {}));
28
+ await vi.runAllTimersAsync();
29
+ const output = lastFrame();
30
+ expect(output).toContain('claude');
31
+ expect(output).toContain('codex');
32
+ });
33
+ it('shows CLI versions', async () => {
34
+ global.fetch.mockResolvedValue({
35
+ ok: true,
36
+ json: () => Promise.resolve({
37
+ providers: {
38
+ claude: { detected: true, type: 'cli', version: 'v4.0.0' },
39
+ },
40
+ devserver: { configured: false },
41
+ }),
42
+ });
43
+ const { lastFrame } = render(_jsx(RouterPane, {}));
44
+ await vi.runAllTimersAsync();
45
+ const output = lastFrame();
46
+ expect(output).toContain('v4.0.0');
47
+ });
48
+ it('shows devserver connected status', async () => {
49
+ global.fetch.mockResolvedValue({
50
+ ok: true,
51
+ json: () => Promise.resolve({
52
+ providers: {},
53
+ devserver: { configured: true, connected: true, url: 'https://dev.example.com' },
54
+ }),
55
+ });
56
+ const { lastFrame } = render(_jsx(RouterPane, {}));
57
+ await vi.runAllTimersAsync();
58
+ const output = lastFrame();
59
+ expect(output).toContain('Connected');
60
+ });
61
+ it('shows devserver disconnected status', async () => {
62
+ global.fetch.mockResolvedValue({
63
+ ok: true,
64
+ json: () => Promise.resolve({
65
+ providers: {},
66
+ devserver: { configured: true, connected: false },
67
+ }),
68
+ });
69
+ const { lastFrame } = render(_jsx(RouterPane, {}));
70
+ await vi.runAllTimersAsync();
71
+ const output = lastFrame();
72
+ expect(output).toContain('Disconnected');
73
+ });
74
+ it('renders routing table', async () => {
75
+ global.fetch.mockResolvedValue({
76
+ ok: true,
77
+ json: () => Promise.resolve({
78
+ providers: {
79
+ claude: { detected: true, type: 'cli', capabilities: ['review'] },
80
+ deepseek: { detected: false, type: 'api', capabilities: ['review'] },
81
+ },
82
+ devserver: { configured: true },
83
+ capabilities: {
84
+ review: { providers: ['claude', 'deepseek'] },
85
+ },
86
+ }),
87
+ });
88
+ const { lastFrame } = render(_jsx(RouterPane, {}));
89
+ await vi.runAllTimersAsync();
90
+ const output = lastFrame();
91
+ expect(output).toContain('review');
92
+ });
93
+ it('shows local vs devserver badges', async () => {
94
+ global.fetch.mockResolvedValue({
95
+ ok: true,
96
+ json: () => Promise.resolve({
97
+ providers: {
98
+ claude: { detected: true, type: 'cli' },
99
+ deepseek: { detected: false, type: 'api' },
100
+ },
101
+ devserver: { configured: true },
102
+ }),
103
+ });
104
+ const { lastFrame } = render(_jsx(RouterPane, {}));
105
+ await vi.runAllTimersAsync();
106
+ const output = lastFrame();
107
+ expect(output).toContain('local');
108
+ });
109
+ it('shows cost estimates', async () => {
110
+ global.fetch.mockResolvedValue({
111
+ ok: true,
112
+ json: () => Promise.resolve({
113
+ providers: {
114
+ deepseek: { detected: false, type: 'api' },
115
+ },
116
+ devserver: { configured: true },
117
+ costEstimate: {
118
+ review: { local: 0, devserver: 1.5 },
119
+ },
120
+ }),
121
+ });
122
+ const { lastFrame } = render(_jsx(RouterPane, {}));
123
+ await vi.runAllTimersAsync();
124
+ const output = lastFrame();
125
+ expect(output).toContain('$1.50');
126
+ });
127
+ it('health indicators show status', async () => {
128
+ global.fetch.mockResolvedValue({
129
+ ok: true,
130
+ json: () => Promise.resolve({
131
+ providers: {
132
+ claude: { detected: true, type: 'cli', healthy: true },
133
+ codex: { detected: false, type: 'cli', healthy: false },
134
+ },
135
+ devserver: { configured: true },
136
+ }),
137
+ });
138
+ const { lastFrame } = render(_jsx(RouterPane, {}));
139
+ await vi.runAllTimersAsync();
140
+ const output = lastFrame();
141
+ // Health indicators should be present (● or similar)
142
+ expect(output).toBeDefined();
143
+ });
144
+ it('shows configure hint', async () => {
145
+ global.fetch.mockResolvedValue({
146
+ ok: true,
147
+ json: () => Promise.resolve({
148
+ providers: {},
149
+ devserver: { configured: false },
150
+ }),
151
+ });
152
+ const { lastFrame } = render(_jsx(RouterPane, {}));
153
+ await vi.runAllTimersAsync();
154
+ const output = lastFrame();
155
+ expect(output).toContain('Router');
156
+ });
157
+ });
158
+ describe('loading state', () => {
159
+ it('handles loading state', () => {
160
+ global.fetch.mockImplementation(() => new Promise(() => { }) // Never resolves
161
+ );
162
+ const { lastFrame } = render(_jsx(RouterPane, {}));
163
+ const output = lastFrame();
164
+ expect(output).toContain('Loading');
165
+ });
166
+ });
167
+ describe('error state', () => {
168
+ it('handles error state', async () => {
169
+ global.fetch.mockRejectedValue(new Error('Network error'));
170
+ const { lastFrame } = render(_jsx(RouterPane, {}));
171
+ await vi.runAllTimersAsync();
172
+ const output = lastFrame();
173
+ expect(output).toContain('Error');
174
+ });
175
+ });
176
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, Box } from 'ink';
3
+ import { describe, it, expect } from 'vitest';
4
+ import { render } from 'ink-testing-library';
5
+ // Import components to test accessibility
6
+ import { Button } from './ui/Button.js';
7
+ import { Modal } from './ui/Modal.js';
8
+ import { Dropdown } from './ui/Dropdown.js';
9
+ import { Toast } from './ui/Toast.js';
10
+ import { Input } from './ui/Input.js';
11
+ import { CommandPalette } from './CommandPalette.js';
12
+ describe('Accessibility', () => {
13
+ describe('Keyboard Navigation', () => {
14
+ it('Button is keyboard accessible', () => {
15
+ const { lastFrame } = render(_jsx(Button, { children: "Click me" }));
16
+ expect(lastFrame()).toContain('Click me');
17
+ // Buttons should render and be focusable
18
+ });
19
+ it('Modal shows keyboard hints', () => {
20
+ const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, closeable: true, children: _jsx(Text, { children: "Content" }) }));
21
+ expect(lastFrame()).toMatch(/esc/i);
22
+ });
23
+ it('Dropdown shows navigation hints', () => {
24
+ const { lastFrame } = render(_jsx(Dropdown, { options: [{ value: '1', label: 'Option 1' }], onSelect: () => { }, isOpen: true }));
25
+ expect(lastFrame()).toMatch(/↑|↓|enter|esc/i);
26
+ });
27
+ it('CommandPalette shows navigation hints', () => {
28
+ const { lastFrame } = render(_jsx(CommandPalette, { commands: [{ id: '1', name: 'Test', description: 'Test command' }], onSelect: () => { } }));
29
+ expect(lastFrame()).toMatch(/↑|↓|j|k|enter|esc/i);
30
+ });
31
+ });
32
+ describe('Focus Indicators', () => {
33
+ it('Button shows focus state', () => {
34
+ const { lastFrame: focused } = render(_jsx(Button, { isFocused: true, children: "Focused" }));
35
+ const { lastFrame: unfocused } = render(_jsx(Button, { isFocused: false, children: "Unfocused" }));
36
+ // Both should render, focused may have different styling
37
+ expect(focused()).toContain('Focused');
38
+ expect(unfocused()).toContain('Unfocused');
39
+ });
40
+ it('Input shows focus state', () => {
41
+ const { lastFrame } = render(_jsx(Input, { placeholder: "Type here", focus: true }));
42
+ expect(lastFrame()).toBeDefined();
43
+ });
44
+ });
45
+ describe('Screen Reader Support', () => {
46
+ it('Modal has title for identification', () => {
47
+ const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, title: "Important Dialog", children: _jsx(Text, { children: "Content" }) }));
48
+ expect(lastFrame()).toContain('Important Dialog');
49
+ });
50
+ it('Toast shows variant type', () => {
51
+ const { lastFrame } = render(_jsx(Toast, { variant: "error", message: "Something went wrong" }));
52
+ expect(lastFrame()).toMatch(/error|✕|✖/i);
53
+ });
54
+ it('Toast shows message content', () => {
55
+ const { lastFrame } = render(_jsx(Toast, { variant: "success", message: "Operation completed" }));
56
+ expect(lastFrame()).toContain('Operation completed');
57
+ });
58
+ });
59
+ describe('Color Contrast', () => {
60
+ it('Primary text is visible (not dimmed by default)', () => {
61
+ const { lastFrame } = render(_jsx(Text, { children: "Primary text" }));
62
+ expect(lastFrame()).toContain('Primary text');
63
+ });
64
+ it('Muted text uses dimColor', () => {
65
+ const { lastFrame } = render(_jsx(Text, { dimColor: true, children: "Muted text" }));
66
+ expect(lastFrame()).toContain('Muted text');
67
+ });
68
+ it('Error state uses red color', () => {
69
+ const { lastFrame } = render(_jsx(Toast, { variant: "error", message: "Error" }));
70
+ // Should contain error indicator
71
+ expect(lastFrame()).toMatch(/✕|error/i);
72
+ });
73
+ it('Success state uses green color', () => {
74
+ const { lastFrame } = render(_jsx(Toast, { variant: "success", message: "Success" }));
75
+ expect(lastFrame()).toMatch(/✓|success/i);
76
+ });
77
+ });
78
+ describe('Logical Tab Order', () => {
79
+ it('Modal content is contained', () => {
80
+ const { lastFrame } = render(_jsxs(Modal, { isOpen: true, onClose: () => { }, title: "Dialog", children: [_jsx(Text, { children: "First element" }), _jsx(Text, { children: "Second element" })] }));
81
+ const output = lastFrame() || '';
82
+ const firstIdx = output.indexOf('First element');
83
+ const secondIdx = output.indexOf('Second element');
84
+ // First should appear before second
85
+ expect(firstIdx).toBeLessThan(secondIdx);
86
+ });
87
+ it('Dropdown options in logical order', () => {
88
+ const { lastFrame } = render(_jsx(Dropdown, { options: [
89
+ { value: '1', label: 'First' },
90
+ { value: '2', label: 'Second' },
91
+ { value: '3', label: 'Third' },
92
+ ], onSelect: () => { }, isOpen: true }));
93
+ const output = lastFrame() || '';
94
+ const firstIdx = output.indexOf('First');
95
+ const secondIdx = output.indexOf('Second');
96
+ const thirdIdx = output.indexOf('Third');
97
+ expect(firstIdx).toBeLessThan(secondIdx);
98
+ expect(secondIdx).toBeLessThan(thirdIdx);
99
+ });
100
+ });
101
+ describe('Reduced Motion Support', () => {
102
+ it('Skeleton uses static characters (no animation in terminal)', () => {
103
+ // In terminal, we use static shimmer characters
104
+ // Animation is simulated through character choice, not actual motion
105
+ const { lastFrame } = render(_jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591" }) }));
106
+ expect(lastFrame()).toContain('░');
107
+ });
108
+ });
109
+ describe('Icon-Only Buttons', () => {
110
+ it('Button with icon still has text for context', () => {
111
+ const { lastFrame } = render(_jsx(Button, { leftIcon: "+", children: "Add Item" }));
112
+ expect(lastFrame()).toContain('Add Item');
113
+ expect(lastFrame()).toContain('+');
114
+ });
115
+ });
116
+ });
@@ -0,0 +1,16 @@
1
+ export interface NavItem {
2
+ key: string;
3
+ label: string;
4
+ icon: string;
5
+ badge?: number;
6
+ }
7
+ export interface MobileNavProps {
8
+ items: NavItem[];
9
+ activeKey: string;
10
+ onNavigate: (key: string) => void;
11
+ compact?: boolean;
12
+ maxItems?: number;
13
+ isTTY?: boolean;
14
+ }
15
+ export declare function MobileNav({ items, activeKey, onNavigate, compact, maxItems, isTTY, }: MobileNavProps): import("react/jsx-runtime").JSX.Element;
16
+ export default MobileNav;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useState } from 'react';
4
+ export function MobileNav({ items, activeKey, onNavigate, compact = false, maxItems = 5, isTTY = true, }) {
5
+ const [focusIndex, setFocusIndex] = useState(items.findIndex(i => i.key === activeKey));
6
+ // Handle keyboard navigation
7
+ useInput((input, key) => {
8
+ if (!isTTY)
9
+ return;
10
+ if (key.leftArrow || input === 'h') {
11
+ setFocusIndex(prev => Math.max(0, prev - 1));
12
+ }
13
+ if (key.rightArrow || input === 'l') {
14
+ setFocusIndex(prev => Math.min(items.length - 1, prev + 1));
15
+ }
16
+ if (key.return) {
17
+ const item = items[focusIndex];
18
+ if (item) {
19
+ onNavigate(item.key);
20
+ }
21
+ }
22
+ }, { isActive: isTTY });
23
+ const visibleItems = items.slice(0, maxItems);
24
+ const hasMore = items.length > maxItems;
25
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1, justifyContent: "space-around", children: [visibleItems.map((item, idx) => {
26
+ const isActive = item.key === activeKey;
27
+ const isFocused = idx === focusIndex;
28
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginX: 1, children: [isActive && _jsx(Text, { color: "cyan", children: "\u25CF" }), !isActive && _jsx(Text, { children: " " }), _jsx(Text, { color: isActive ? 'cyan' : isFocused ? 'white' : 'gray', children: item.icon }), item.badge !== undefined && item.badge > 0 && (_jsx(Text, { color: "red", bold: true, children: item.badge })), !compact && (_jsx(Text, { color: isActive ? 'cyan' : isFocused ? 'white' : 'gray', dimColor: !isActive && !isFocused, children: item.label.length > 8 ? item.label.slice(0, 6) + '..' : item.label }))] }, item.key));
29
+ }), hasMore && (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginX: 1, children: [_jsx(Text, { color: "gray", children: "\u22EF" }), !compact && _jsx(Text, { color: "gray", children: "more" })] }))] }));
30
+ }
31
+ export default MobileNav;
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render } from 'ink-testing-library';
4
+ import { MobileNav } from './MobileNav.js';
5
+ const sampleItems = [
6
+ { key: 'projects', label: 'Projects', icon: '📁' },
7
+ { key: 'tasks', label: 'Tasks', icon: '📋' },
8
+ { key: 'chat', label: 'Chat', icon: '💬' },
9
+ { key: 'logs', label: 'Logs', icon: '📜' },
10
+ { key: 'settings', label: 'Settings', icon: '⚙️' },
11
+ ];
12
+ describe('MobileNav', () => {
13
+ describe('Rendering', () => {
14
+ it('renders navigation items', () => {
15
+ const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
16
+ expect(lastFrame()).toContain('Projects');
17
+ expect(lastFrame()).toContain('Tasks');
18
+ });
19
+ it('renders item icons', () => {
20
+ const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
21
+ expect(lastFrame()).toContain('📁');
22
+ expect(lastFrame()).toContain('📋');
23
+ });
24
+ it('renders item labels', () => {
25
+ const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
26
+ expect(lastFrame()).toContain('Projects');
27
+ expect(lastFrame()).toContain('Logs');
28
+ });
29
+ });
30
+ describe('Active State', () => {
31
+ it('shows active indicator on current item', () => {
32
+ const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "tasks", onNavigate: () => { } }));
33
+ // Active item should have some visual indicator
34
+ expect(lastFrame()).toContain('Tasks');
35
+ });
36
+ it('highlights active item differently', () => {
37
+ const { lastFrame: activeProjects } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
38
+ const { lastFrame: activeTasks } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "tasks", onNavigate: () => { } }));
39
+ // Both should render but with different active states
40
+ expect(activeProjects()).toContain('Projects');
41
+ expect(activeTasks()).toContain('Tasks');
42
+ });
43
+ });
44
+ describe('Navigation', () => {
45
+ it('calls onNavigate when item selected', () => {
46
+ const onNavigate = vi.fn();
47
+ render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: onNavigate }));
48
+ expect(onNavigate).toBeDefined();
49
+ });
50
+ });
51
+ describe('Compact Mode', () => {
52
+ it('can hide labels in compact mode', () => {
53
+ const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { }, compact: true }));
54
+ // Icons should still show
55
+ expect(lastFrame()).toContain('📁');
56
+ });
57
+ it('shows only icons when compact', () => {
58
+ const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { }, compact: true }));
59
+ // Should render icons
60
+ expect(lastFrame()).toMatch(/📁|📋|💬|📜|⚙️/);
61
+ });
62
+ });
63
+ describe('Max Items', () => {
64
+ it('limits visible items', () => {
65
+ const manyItems = Array.from({ length: 10 }, (_, i) => ({
66
+ key: `item${i}`,
67
+ label: `Item ${i}`,
68
+ icon: '●',
69
+ }));
70
+ const { lastFrame } = render(_jsx(MobileNav, { items: manyItems, activeKey: "item0", onNavigate: () => { }, maxItems: 5 }));
71
+ // Should show limited items or "more" indicator
72
+ expect(lastFrame()).toBeDefined();
73
+ });
74
+ it('shows overflow menu when items exceed max', () => {
75
+ const manyItems = Array.from({ length: 8 }, (_, i) => ({
76
+ key: `item${i}`,
77
+ label: `Item ${i}`,
78
+ icon: '●',
79
+ }));
80
+ const { lastFrame } = render(_jsx(MobileNav, { items: manyItems, activeKey: "item0", onNavigate: () => { }, maxItems: 4 }));
81
+ // Should show "more" or overflow indicator
82
+ expect(lastFrame()).toMatch(/more|\.{3}|»|⋯/i);
83
+ });
84
+ });
85
+ describe('Badge Support', () => {
86
+ it('shows badge on item', () => {
87
+ const itemsWithBadge = [
88
+ { key: 'tasks', label: 'Tasks', icon: '📋', badge: 3 },
89
+ ...sampleItems.slice(1),
90
+ ];
91
+ const { lastFrame } = render(_jsx(MobileNav, { items: itemsWithBadge, activeKey: "tasks", onNavigate: () => { } }));
92
+ expect(lastFrame()).toContain('3');
93
+ });
94
+ });
95
+ describe('Layout', () => {
96
+ it('renders in horizontal layout', () => {
97
+ const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { } }));
98
+ // All items should be on roughly same line (horizontal)
99
+ const output = lastFrame() || '';
100
+ // Check that items appear to be side by side
101
+ expect(output).toContain('Projects');
102
+ expect(output).toContain('Tasks');
103
+ });
104
+ });
105
+ describe('Keyboard Navigation', () => {
106
+ it('accepts isTTY prop for keyboard support', () => {
107
+ const { lastFrame } = render(_jsx(MobileNav, { items: sampleItems, activeKey: "projects", onNavigate: () => { }, isTTY: true }));
108
+ expect(lastFrame()).toBeDefined();
109
+ });
110
+ });
111
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,114 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { Suspense, lazy } from 'react';
3
+ import { Text, Box } from 'ink';
4
+ import { describe, it, expect } from 'vitest';
5
+ import { render } from 'ink-testing-library';
6
+ describe('Performance', () => {
7
+ describe('Code Splitting', () => {
8
+ it('App renders without full component tree', () => {
9
+ // In terminal UI, we test that minimal components render quickly
10
+ const { lastFrame } = render(_jsx(Text, { children: "App Shell" }));
11
+ expect(lastFrame()).toContain('App Shell');
12
+ });
13
+ it('Skeleton placeholder renders immediately', () => {
14
+ // Skeleton should render fast as loading state
15
+ const { lastFrame } = render(_jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591" }) }));
16
+ expect(lastFrame()).toContain('░');
17
+ });
18
+ });
19
+ describe('Lazy Loading', () => {
20
+ it('Suspense fallback renders while loading', () => {
21
+ const LazyComponent = lazy(() => Promise.resolve({
22
+ default: () => _jsx(Text, { children: "Loaded" }),
23
+ }));
24
+ const { lastFrame } = render(_jsx(Suspense, { fallback: _jsx(Text, { children: "Loading..." }), children: _jsx(LazyComponent, {}) }));
25
+ // Should show either loading or loaded
26
+ const output = lastFrame() || '';
27
+ expect(output.length).toBeGreaterThan(0);
28
+ });
29
+ it('Non-critical content can be deferred', () => {
30
+ // Pattern: render critical content first
31
+ const CriticalContent = () => _jsx(Text, { children: "Critical: Dashboard" });
32
+ const DeferredContent = () => _jsx(Text, { children: "Deferred: Analytics" });
33
+ const { lastFrame } = render(_jsxs(Box, { flexDirection: "column", children: [_jsx(CriticalContent, {}), _jsx(Suspense, { fallback: _jsx(Text, { children: "Loading analytics..." }), children: _jsx(DeferredContent, {}) })] }));
34
+ expect(lastFrame()).toContain('Critical: Dashboard');
35
+ });
36
+ });
37
+ describe('Bundle Size Indicators', () => {
38
+ it('Components use minimal dependencies', () => {
39
+ // Verify components render with basic ink primitives
40
+ const { lastFrame } = render(_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, children: "Header" }), _jsx(Text, { children: "Content" }), _jsx(Text, { dimColor: true, children: "Footer" })] }));
41
+ expect(lastFrame()).toContain('Header');
42
+ expect(lastFrame()).toContain('Content');
43
+ expect(lastFrame()).toContain('Footer');
44
+ });
45
+ it('No heavy external libraries required for basic render', () => {
46
+ // Basic components should render with just ink
47
+ const { lastFrame } = render(_jsx(Box, { borderStyle: "single", padding: 1, children: _jsx(Text, { children: "Bordered box" }) }));
48
+ expect(lastFrame()).toContain('Bordered box');
49
+ });
50
+ });
51
+ describe('Render Performance', () => {
52
+ it('Simple component renders quickly', () => {
53
+ const start = performance.now();
54
+ const { lastFrame } = render(_jsx(Text, { children: "Simple text" }));
55
+ const duration = performance.now() - start;
56
+ expect(lastFrame()).toContain('Simple text');
57
+ // Should render in reasonable time (< 100ms for simple component)
58
+ expect(duration).toBeLessThan(100);
59
+ });
60
+ it('List renders without blocking', () => {
61
+ const items = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
62
+ const start = performance.now();
63
+ const { lastFrame } = render(_jsx(Box, { flexDirection: "column", children: items.slice(0, 10).map((item, i) => (_jsx(Text, { children: item }, i))) }));
64
+ const duration = performance.now() - start;
65
+ expect(lastFrame()).toContain('Item 0');
66
+ // Even with 10 items, should be fast
67
+ expect(duration).toBeLessThan(200);
68
+ });
69
+ it('Memoized components do not re-render unnecessarily', () => {
70
+ const renderCount = { count: 0 };
71
+ const MemoizedComponent = React.memo(function MemoizedComponent({ text }) {
72
+ renderCount.count++;
73
+ return _jsx(Text, { children: text });
74
+ });
75
+ const { rerender, lastFrame } = render(_jsx(MemoizedComponent, { text: "Hello" }));
76
+ expect(renderCount.count).toBe(1);
77
+ // Re-render with same props
78
+ rerender(_jsx(MemoizedComponent, { text: "Hello" }));
79
+ // Should not increase render count significantly
80
+ expect(renderCount.count).toBeLessThanOrEqual(2);
81
+ });
82
+ });
83
+ describe('Memory Efficiency', () => {
84
+ it('Components render and can be unmounted', () => {
85
+ // In ink-testing-library, unmount doesn't trigger useEffect cleanup
86
+ // the same way as react-dom. We verify the component lifecycle works.
87
+ const CleanupComponent = () => {
88
+ return _jsx(Text, { children: "Cleanup test" });
89
+ };
90
+ const { unmount, lastFrame } = render(_jsx(CleanupComponent, {}));
91
+ expect(lastFrame()).toContain('Cleanup test');
92
+ // Verify unmount completes without error
93
+ expect(() => unmount()).not.toThrow();
94
+ });
95
+ it('Event handlers are properly attached', () => {
96
+ // For terminal apps, we use useInput hook
97
+ // This test ensures the pattern is followed
98
+ const { lastFrame } = render(_jsxs(Box, { children: [_jsx(Text, { children: "Press any key" }), _jsx(Text, { dimColor: true, children: "(keyboard input enabled)" })] }));
99
+ expect(lastFrame()).toContain('Press any key');
100
+ });
101
+ });
102
+ describe('No Render-Blocking', () => {
103
+ it('Progressive content display', () => {
104
+ // Content should render top-to-bottom
105
+ const { lastFrame } = render(_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Line 1" }), _jsx(Text, { children: "Line 2" }), _jsx(Text, { children: "Line 3" })] }));
106
+ const output = lastFrame() || '';
107
+ const line1Idx = output.indexOf('Line 1');
108
+ const line2Idx = output.indexOf('Line 2');
109
+ const line3Idx = output.indexOf('Line 3');
110
+ expect(line1Idx).toBeLessThan(line2Idx);
111
+ expect(line2Idx).toBeLessThan(line3Idx);
112
+ });
113
+ });
114
+ });
@@ -0,0 +1 @@
1
+ export {};