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.
- package/dashboard/dist/App.js +229 -35
- package/dashboard/dist/components/AgentRegistryPane.d.ts +35 -0
- package/dashboard/dist/components/AgentRegistryPane.js +89 -0
- package/dashboard/dist/components/AgentRegistryPane.test.d.ts +1 -0
- package/dashboard/dist/components/AgentRegistryPane.test.js +200 -0
- package/dashboard/dist/components/RouterPane.d.ts +5 -0
- package/dashboard/dist/components/RouterPane.js +65 -0
- package/dashboard/dist/components/RouterPane.test.d.ts +1 -0
- package/dashboard/dist/components/RouterPane.test.js +176 -0
- package/dashboard/dist/components/accessibility.test.d.ts +1 -0
- package/dashboard/dist/components/accessibility.test.js +116 -0
- package/dashboard/dist/components/layout/MobileNav.d.ts +16 -0
- package/dashboard/dist/components/layout/MobileNav.js +31 -0
- package/dashboard/dist/components/layout/MobileNav.test.d.ts +1 -0
- package/dashboard/dist/components/layout/MobileNav.test.js +111 -0
- package/dashboard/dist/components/performance.test.d.ts +1 -0
- package/dashboard/dist/components/performance.test.js +114 -0
- package/dashboard/dist/components/responsive.test.d.ts +1 -0
- package/dashboard/dist/components/responsive.test.js +114 -0
- package/dashboard/dist/components/ui/Dropdown.d.ts +22 -0
- package/dashboard/dist/components/ui/Dropdown.js +109 -0
- package/dashboard/dist/components/ui/Dropdown.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Dropdown.test.js +105 -0
- package/dashboard/dist/components/ui/Modal.d.ts +13 -0
- package/dashboard/dist/components/ui/Modal.js +25 -0
- package/dashboard/dist/components/ui/Modal.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Modal.test.js +91 -0
- package/dashboard/dist/components/ui/Skeleton.d.ts +32 -0
- package/dashboard/dist/components/ui/Skeleton.js +48 -0
- package/dashboard/dist/components/ui/Skeleton.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Skeleton.test.js +125 -0
- package/dashboard/dist/components/ui/Toast.d.ts +32 -0
- package/dashboard/dist/components/ui/Toast.js +21 -0
- package/dashboard/dist/components/ui/Toast.test.d.ts +1 -0
- package/dashboard/dist/components/ui/Toast.test.js +118 -0
- package/dashboard/dist/hooks/useTheme.d.ts +37 -0
- package/dashboard/dist/hooks/useTheme.js +96 -0
- package/dashboard/dist/hooks/useTheme.test.d.ts +1 -0
- package/dashboard/dist/hooks/useTheme.test.js +94 -0
- package/dashboard/dist/hooks/useWebSocket.d.ts +17 -0
- package/dashboard/dist/hooks/useWebSocket.js +100 -0
- package/dashboard/dist/hooks/useWebSocket.test.d.ts +1 -0
- package/dashboard/dist/hooks/useWebSocket.test.js +115 -0
- package/dashboard/dist/stores/projectStore.d.ts +44 -0
- package/dashboard/dist/stores/projectStore.js +76 -0
- package/dashboard/dist/stores/projectStore.test.d.ts +1 -0
- package/dashboard/dist/stores/projectStore.test.js +114 -0
- package/dashboard/dist/stores/uiStore.d.ts +29 -0
- package/dashboard/dist/stores/uiStore.js +72 -0
- package/dashboard/dist/stores/uiStore.test.d.ts +1 -0
- package/dashboard/dist/stores/uiStore.test.js +93 -0
- package/dashboard/package.json +3 -3
- package/docker-compose.dev.yml +6 -1
- package/package.json +5 -2
- package/server/dashboard/index.html +1336 -779
- package/server/index.js +178 -0
- package/server/lib/agent-cleanup.js +177 -0
- package/server/lib/agent-cleanup.test.js +359 -0
- package/server/lib/agent-hooks.js +126 -0
- package/server/lib/agent-hooks.test.js +303 -0
- package/server/lib/agent-metadata.js +179 -0
- package/server/lib/agent-metadata.test.js +383 -0
- package/server/lib/agent-persistence.js +191 -0
- package/server/lib/agent-persistence.test.js +475 -0
- package/server/lib/agent-registry-command.js +340 -0
- package/server/lib/agent-registry-command.test.js +334 -0
- package/server/lib/agent-registry.js +155 -0
- package/server/lib/agent-registry.test.js +239 -0
- package/server/lib/agent-state.js +236 -0
- package/server/lib/agent-state.test.js +375 -0
- package/server/lib/api-provider.js +186 -0
- package/server/lib/api-provider.test.js +336 -0
- package/server/lib/cli-detector.js +166 -0
- package/server/lib/cli-detector.test.js +269 -0
- package/server/lib/cli-provider.js +212 -0
- package/server/lib/cli-provider.test.js +349 -0
- package/server/lib/debug.test.js +62 -0
- package/server/lib/devserver-router-api.js +249 -0
- package/server/lib/devserver-router-api.test.js +426 -0
- package/server/lib/model-router.js +245 -0
- package/server/lib/model-router.test.js +313 -0
- package/server/lib/output-schemas.js +269 -0
- package/server/lib/output-schemas.test.js +307 -0
- package/server/lib/provider-interface.js +153 -0
- package/server/lib/provider-interface.test.js +394 -0
- package/server/lib/provider-queue.js +158 -0
- package/server/lib/provider-queue.test.js +315 -0
- package/server/lib/router-config.js +221 -0
- package/server/lib/router-config.test.js +237 -0
- package/server/lib/router-setup-command.js +419 -0
- package/server/lib/router-setup-command.test.js +375 -0
|
@@ -0,0 +1,114 @@
|
|
|
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 layout components
|
|
6
|
+
import { Shell } from './layout/Shell.js';
|
|
7
|
+
import { Sidebar } from './layout/Sidebar.js';
|
|
8
|
+
import { MobileNav } from './layout/MobileNav.js';
|
|
9
|
+
const sampleNavItems = [
|
|
10
|
+
{ key: 'projects', label: 'Projects', icon: '📁' },
|
|
11
|
+
{ key: 'tasks', label: 'Tasks', icon: '📋' },
|
|
12
|
+
{ key: 'logs', label: 'Logs', icon: '📜' },
|
|
13
|
+
];
|
|
14
|
+
describe('Responsive Layout', () => {
|
|
15
|
+
describe('Shell Layout', () => {
|
|
16
|
+
it('renders with sidebar', () => {
|
|
17
|
+
const { lastFrame } = render(_jsx(Shell, { header: _jsx(Text, { children: "Header" }), footer: _jsx(Text, { children: "Footer" }), sidebar: _jsx(Text, { children: "Sidebar" }), showSidebar: true, children: _jsx(Text, { children: "Content" }) }));
|
|
18
|
+
expect(lastFrame()).toContain('Sidebar');
|
|
19
|
+
expect(lastFrame()).toContain('Content');
|
|
20
|
+
});
|
|
21
|
+
it('renders without sidebar when hidden', () => {
|
|
22
|
+
const { lastFrame } = render(_jsx(Shell, { header: _jsx(Text, { children: "Header" }), footer: _jsx(Text, { children: "Footer" }), sidebar: _jsx(Text, { children: "Sidebar" }), showSidebar: false, children: _jsx(Text, { children: "Content" }) }));
|
|
23
|
+
expect(lastFrame()).toContain('Content');
|
|
24
|
+
// Content should still be present when sidebar hidden
|
|
25
|
+
});
|
|
26
|
+
it('renders header and footer', () => {
|
|
27
|
+
const { lastFrame } = render(_jsx(Shell, { header: _jsx(Text, { children: "Header Content" }), footer: _jsx(Text, { children: "Footer Content" }), children: _jsx(Text, { children: "Main" }) }));
|
|
28
|
+
expect(lastFrame()).toContain('Header Content');
|
|
29
|
+
expect(lastFrame()).toContain('Footer Content');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('Sidebar Component', () => {
|
|
33
|
+
it('renders navigation items', () => {
|
|
34
|
+
const { lastFrame } = render(_jsxs(Sidebar, { title: "TLC", children: [_jsx(Text, { children: "Item 1" }), _jsx(Text, { children: "Item 2" })] }));
|
|
35
|
+
expect(lastFrame()).toContain('TLC');
|
|
36
|
+
expect(lastFrame()).toContain('Item 1');
|
|
37
|
+
expect(lastFrame()).toContain('Item 2');
|
|
38
|
+
});
|
|
39
|
+
it('shows title', () => {
|
|
40
|
+
const { lastFrame } = render(_jsx(Sidebar, { title: "Dashboard", children: _jsx(Text, { children: "Content" }) }));
|
|
41
|
+
expect(lastFrame()).toContain('Dashboard');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('MobileNav for Phone Layout', () => {
|
|
45
|
+
it('renders all navigation items', () => {
|
|
46
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleNavItems, activeKey: "projects", onNavigate: () => { } }));
|
|
47
|
+
expect(lastFrame()).toContain('Projects');
|
|
48
|
+
expect(lastFrame()).toContain('Tasks');
|
|
49
|
+
expect(lastFrame()).toContain('Logs');
|
|
50
|
+
});
|
|
51
|
+
it('shows active indicator', () => {
|
|
52
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleNavItems, activeKey: "tasks", onNavigate: () => { } }));
|
|
53
|
+
// Active item should be highlighted
|
|
54
|
+
expect(lastFrame()).toContain('Tasks');
|
|
55
|
+
});
|
|
56
|
+
it('supports compact mode for small screens', () => {
|
|
57
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleNavItems, activeKey: "projects", onNavigate: () => { }, compact: true }));
|
|
58
|
+
// Icons should still show in compact mode
|
|
59
|
+
expect(lastFrame()).toContain('📁');
|
|
60
|
+
});
|
|
61
|
+
it('limits visible items for very small screens', () => {
|
|
62
|
+
const manyItems = Array.from({ length: 10 }, (_, i) => ({
|
|
63
|
+
key: `item${i}`,
|
|
64
|
+
label: `Item ${i}`,
|
|
65
|
+
icon: '●',
|
|
66
|
+
}));
|
|
67
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: manyItems, activeKey: "item0", onNavigate: () => { }, maxItems: 4 }));
|
|
68
|
+
// Should show overflow indicator
|
|
69
|
+
expect(lastFrame()).toMatch(/more|⋯/i);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('Touch Targets', () => {
|
|
73
|
+
it('Button has sufficient padding for touch', () => {
|
|
74
|
+
// In terminal UI, we ensure buttons have visible brackets
|
|
75
|
+
const { lastFrame } = render(_jsx(Box, { children: _jsx(Text, { children: "[ OK ]" }) }));
|
|
76
|
+
expect(lastFrame()).toContain('[');
|
|
77
|
+
expect(lastFrame()).toContain(']');
|
|
78
|
+
});
|
|
79
|
+
it('Navigation items are spaced apart', () => {
|
|
80
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: sampleNavItems, activeKey: "projects", onNavigate: () => { } }));
|
|
81
|
+
// Items should all be visible (spaced)
|
|
82
|
+
expect(lastFrame()).toContain('Projects');
|
|
83
|
+
expect(lastFrame()).toContain('Tasks');
|
|
84
|
+
expect(lastFrame()).toContain('Logs');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('Content Fitting', () => {
|
|
88
|
+
it('Content renders without truncation', () => {
|
|
89
|
+
const { lastFrame } = render(_jsx(Box, { width: 80, children: _jsx(Text, { children: "This is a reasonably long text that should fit within the viewport" }) }));
|
|
90
|
+
expect(lastFrame()).toContain('This is a reasonably long text');
|
|
91
|
+
});
|
|
92
|
+
it('Long labels truncate gracefully', () => {
|
|
93
|
+
const { lastFrame } = render(_jsx(MobileNav, { items: [
|
|
94
|
+
{ key: 'long', label: 'Very Long Navigation Item Label', icon: '📁' },
|
|
95
|
+
], activeKey: "long", onNavigate: () => { } }));
|
|
96
|
+
// Should render something (truncated or full)
|
|
97
|
+
expect(lastFrame()).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('Readable Font Sizes', () => {
|
|
101
|
+
it('Primary text renders clearly', () => {
|
|
102
|
+
const { lastFrame } = render(_jsx(Text, { children: "Primary readable text" }));
|
|
103
|
+
expect(lastFrame()).toContain('Primary readable text');
|
|
104
|
+
});
|
|
105
|
+
it('Secondary text renders with dimColor', () => {
|
|
106
|
+
const { lastFrame } = render(_jsx(Text, { dimColor: true, children: "Secondary text" }));
|
|
107
|
+
expect(lastFrame()).toContain('Secondary text');
|
|
108
|
+
});
|
|
109
|
+
it('Bold text renders for emphasis', () => {
|
|
110
|
+
const { lastFrame } = render(_jsx(Text, { bold: true, children: "Important text" }));
|
|
111
|
+
expect(lastFrame()).toContain('Important text');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface DropdownOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
group?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DropdownProps {
|
|
8
|
+
options: DropdownOption[];
|
|
9
|
+
onSelect: (value: string | string[]) => void;
|
|
10
|
+
value?: string | string[];
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
isOpen?: boolean;
|
|
13
|
+
onOpenChange?: (open: boolean) => void;
|
|
14
|
+
multiple?: boolean;
|
|
15
|
+
filterable?: boolean;
|
|
16
|
+
filterQuery?: string;
|
|
17
|
+
onFilterChange?: (query: string) => void;
|
|
18
|
+
maxHeight?: number;
|
|
19
|
+
isTTY?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function Dropdown({ options, onSelect, value, placeholder, isOpen, onOpenChange, multiple, filterable, filterQuery, onFilterChange, maxHeight, isTTY, }: DropdownProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
export default Dropdown;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { useState, useMemo } from 'react';
|
|
4
|
+
export function Dropdown({ options, onSelect, value, placeholder = 'Select...', isOpen = false, onOpenChange, multiple = false, filterable = false, filterQuery = '', onFilterChange, maxHeight = 10, isTTY = true, }) {
|
|
5
|
+
const [highlightIndex, setHighlightIndex] = useState(0);
|
|
6
|
+
// Filter options based on query
|
|
7
|
+
const filteredOptions = useMemo(() => {
|
|
8
|
+
if (!filterQuery)
|
|
9
|
+
return options;
|
|
10
|
+
const query = filterQuery.toLowerCase();
|
|
11
|
+
return options.filter(opt => opt.label.toLowerCase().includes(query) ||
|
|
12
|
+
opt.value.toLowerCase().includes(query));
|
|
13
|
+
}, [options, filterQuery]);
|
|
14
|
+
// Group options
|
|
15
|
+
const groupedOptions = useMemo(() => {
|
|
16
|
+
const groups = {};
|
|
17
|
+
filteredOptions.forEach(opt => {
|
|
18
|
+
const group = opt.group || '__default__';
|
|
19
|
+
if (!groups[group])
|
|
20
|
+
groups[group] = [];
|
|
21
|
+
groups[group].push(opt);
|
|
22
|
+
});
|
|
23
|
+
return groups;
|
|
24
|
+
}, [filteredOptions]);
|
|
25
|
+
// Get display value
|
|
26
|
+
const displayValue = useMemo(() => {
|
|
27
|
+
if (!value)
|
|
28
|
+
return placeholder;
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
if (value.length === 0)
|
|
31
|
+
return placeholder;
|
|
32
|
+
const labels = value.map(v => {
|
|
33
|
+
const opt = options.find(o => o.value === v);
|
|
34
|
+
return opt?.label || v;
|
|
35
|
+
});
|
|
36
|
+
return labels.join(', ');
|
|
37
|
+
}
|
|
38
|
+
const opt = options.find(o => o.value === value);
|
|
39
|
+
return opt?.label || placeholder;
|
|
40
|
+
}, [value, options, placeholder]);
|
|
41
|
+
// Check if option is selected
|
|
42
|
+
const isSelected = (optValue) => {
|
|
43
|
+
if (Array.isArray(value))
|
|
44
|
+
return value.includes(optValue);
|
|
45
|
+
return value === optValue;
|
|
46
|
+
};
|
|
47
|
+
// Handle keyboard input
|
|
48
|
+
useInput((input, key) => {
|
|
49
|
+
if (!isOpen || !isTTY)
|
|
50
|
+
return;
|
|
51
|
+
if (key.escape) {
|
|
52
|
+
onOpenChange?.(false);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (key.downArrow || input === 'j') {
|
|
56
|
+
setHighlightIndex(prev => {
|
|
57
|
+
let next = prev + 1;
|
|
58
|
+
while (next < filteredOptions.length && filteredOptions[next]?.disabled) {
|
|
59
|
+
next++;
|
|
60
|
+
}
|
|
61
|
+
return next < filteredOptions.length ? next : prev;
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (key.upArrow || input === 'k') {
|
|
66
|
+
setHighlightIndex(prev => {
|
|
67
|
+
let next = prev - 1;
|
|
68
|
+
while (next >= 0 && filteredOptions[next]?.disabled) {
|
|
69
|
+
next--;
|
|
70
|
+
}
|
|
71
|
+
return next >= 0 ? next : prev;
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (key.return) {
|
|
76
|
+
const selected = filteredOptions[highlightIndex];
|
|
77
|
+
if (selected && !selected.disabled) {
|
|
78
|
+
if (multiple) {
|
|
79
|
+
const currentValues = Array.isArray(value) ? value : [];
|
|
80
|
+
const newValues = isSelected(selected.value)
|
|
81
|
+
? currentValues.filter(v => v !== selected.value)
|
|
82
|
+
: [...currentValues, selected.value];
|
|
83
|
+
onSelect(newValues);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
onSelect(selected.value);
|
|
87
|
+
onOpenChange?.(false);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Type-ahead filtering
|
|
93
|
+
if (filterable && input && input.length === 1 && /[a-zA-Z0-9]/.test(input)) {
|
|
94
|
+
onFilterChange?.(filterQuery + input);
|
|
95
|
+
}
|
|
96
|
+
}, { isActive: isTTY && isOpen });
|
|
97
|
+
if (!isOpen) {
|
|
98
|
+
// Closed state - show trigger
|
|
99
|
+
return (_jsxs(Box, { children: [_jsx(Text, { children: displayValue }), _jsx(Text, { color: "gray", children: " \u25BE" })] }));
|
|
100
|
+
}
|
|
101
|
+
// Open state - show dropdown list
|
|
102
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: displayValue }), _jsx(Text, { color: "cyan", children: " \u25B4" })] }), filterable && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "> " }), _jsx(Text, { children: filterQuery }), _jsx(Text, { color: "cyan", children: "\u258F" })] })), _jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: filteredOptions.length === 0 ? (_jsx(Text, { color: "gray", children: "No matching options" })) : (Object.entries(groupedOptions).map(([group, opts]) => (_jsxs(Box, { flexDirection: "column", children: [group !== '__default__' && (_jsx(Text, { bold: true, color: "cyan", children: group })), opts.slice(0, maxHeight).map((opt, idx) => {
|
|
103
|
+
const globalIdx = filteredOptions.indexOf(opt);
|
|
104
|
+
const isHighlighted = globalIdx === highlightIndex;
|
|
105
|
+
const isChecked = isSelected(opt.value);
|
|
106
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? 'cyan' : undefined, children: isHighlighted ? '▶ ' : ' ' }), multiple && (_jsx(Text, { color: isChecked ? 'green' : 'gray', children: isChecked ? '☑ ' : '☐ ' })), _jsx(Text, { color: opt.disabled ? 'gray' : undefined, dimColor: opt.disabled, children: opt.label })] }, opt.value));
|
|
107
|
+
})] }, group)))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "\u2191\u2193 navigate | Enter select | Esc close" }) })] }));
|
|
108
|
+
}
|
|
109
|
+
export default Dropdown;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
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 { Dropdown } from './Dropdown.js';
|
|
5
|
+
const sampleOptions = [
|
|
6
|
+
{ value: 'option1', label: 'Option 1' },
|
|
7
|
+
{ value: 'option2', label: 'Option 2' },
|
|
8
|
+
{ value: 'option3', label: 'Option 3', disabled: true },
|
|
9
|
+
{ value: 'option4', label: 'Option 4' },
|
|
10
|
+
];
|
|
11
|
+
describe('Dropdown', () => {
|
|
12
|
+
describe('Rendering', () => {
|
|
13
|
+
it('renders trigger button with placeholder', () => {
|
|
14
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, placeholder: "Select..." }));
|
|
15
|
+
expect(lastFrame()).toContain('Select...');
|
|
16
|
+
});
|
|
17
|
+
it('renders trigger with selected value', () => {
|
|
18
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, value: "option1" }));
|
|
19
|
+
expect(lastFrame()).toContain('Option 1');
|
|
20
|
+
});
|
|
21
|
+
it('shows dropdown indicator', () => {
|
|
22
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { } }));
|
|
23
|
+
expect(lastFrame()).toMatch(/▼|▾|↓|v/);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('Open State', () => {
|
|
27
|
+
it('shows options when open', () => {
|
|
28
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: true }));
|
|
29
|
+
expect(lastFrame()).toContain('Option 1');
|
|
30
|
+
expect(lastFrame()).toContain('Option 2');
|
|
31
|
+
expect(lastFrame()).toContain('Option 4');
|
|
32
|
+
});
|
|
33
|
+
it('hides options when closed', () => {
|
|
34
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: false }));
|
|
35
|
+
// Should show trigger but not full options list
|
|
36
|
+
const output = lastFrame() || '';
|
|
37
|
+
// Options list should not be visible when closed
|
|
38
|
+
expect(output).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe('Selection', () => {
|
|
42
|
+
it('highlights first option by default when open', () => {
|
|
43
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: true }));
|
|
44
|
+
expect(lastFrame()).toMatch(/▶|→|>|\*/);
|
|
45
|
+
});
|
|
46
|
+
it('calls onSelect when option selected', () => {
|
|
47
|
+
const onSelect = vi.fn();
|
|
48
|
+
render(_jsx(Dropdown, { options: sampleOptions, onSelect: onSelect, isOpen: true }));
|
|
49
|
+
expect(onSelect).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
it('skips disabled options', () => {
|
|
52
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: true }));
|
|
53
|
+
// Option 3 should be shown but dimmed/disabled
|
|
54
|
+
expect(lastFrame()).toContain('Option 3');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('Multi-Select', () => {
|
|
58
|
+
it('allows multiple selections', () => {
|
|
59
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: true, multiple: true, value: ['option1', 'option2'] }));
|
|
60
|
+
expect(lastFrame()).toMatch(/✓|☑|✔/);
|
|
61
|
+
});
|
|
62
|
+
it('shows checkbox indicators in multi mode', () => {
|
|
63
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: true, multiple: true }));
|
|
64
|
+
expect(lastFrame()).toMatch(/□|☐|☑|✓|\[ \]|\[x\]/i);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('Filtering', () => {
|
|
68
|
+
it('shows filter input when filterable', () => {
|
|
69
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: true, filterable: true }));
|
|
70
|
+
expect(lastFrame()).toMatch(/search|filter|type|>|▏/i);
|
|
71
|
+
});
|
|
72
|
+
it('filters options based on query', () => {
|
|
73
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: true, filterable: true, filterQuery: "1" }));
|
|
74
|
+
expect(lastFrame()).toContain('Option 1');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('Keyboard Hints', () => {
|
|
78
|
+
it('shows navigation hints when open', () => {
|
|
79
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: true }));
|
|
80
|
+
expect(lastFrame()).toMatch(/↑|↓|j|k|enter|esc/i);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('Empty State', () => {
|
|
84
|
+
it('shows empty message when no options', () => {
|
|
85
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: [], onSelect: () => { }, isOpen: true }));
|
|
86
|
+
expect(lastFrame()).toMatch(/no.*option|empty/i);
|
|
87
|
+
});
|
|
88
|
+
it('shows no match message when filter returns empty', () => {
|
|
89
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: sampleOptions, onSelect: () => { }, isOpen: true, filterable: true, filterQuery: "xyz" }));
|
|
90
|
+
expect(lastFrame()).toMatch(/no.*match|not.*found/i);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('Grouping', () => {
|
|
94
|
+
it('renders grouped options with headers', () => {
|
|
95
|
+
const groupedOptions = [
|
|
96
|
+
{ value: 'a', label: 'A', group: 'Letters' },
|
|
97
|
+
{ value: 'b', label: 'B', group: 'Letters' },
|
|
98
|
+
{ value: '1', label: '1', group: 'Numbers' },
|
|
99
|
+
];
|
|
100
|
+
const { lastFrame } = render(_jsx(Dropdown, { options: groupedOptions, onSelect: () => { }, isOpen: true }));
|
|
101
|
+
expect(lastFrame()).toContain('Letters');
|
|
102
|
+
expect(lastFrame()).toContain('Numbers');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface ModalProps {
|
|
3
|
+
isOpen: boolean;
|
|
4
|
+
onClose: () => void;
|
|
5
|
+
title?: string;
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
footer?: React.ReactNode;
|
|
8
|
+
closeable?: boolean;
|
|
9
|
+
size?: 'small' | 'medium' | 'large' | 'fullscreen';
|
|
10
|
+
isTTY?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function Modal({ isOpen, onClose, title, children, footer, closeable, size, isTTY, }: ModalProps): import("react/jsx-runtime").JSX.Element | null;
|
|
13
|
+
export default Modal;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
const sizeMap = {
|
|
4
|
+
small: { width: 40, height: 10 },
|
|
5
|
+
medium: { width: 60, height: 15 },
|
|
6
|
+
large: { width: 80, height: 20 },
|
|
7
|
+
fullscreen: { width: '100%', height: '100%' },
|
|
8
|
+
};
|
|
9
|
+
export function Modal({ isOpen, onClose, title, children, footer, closeable = true, size = 'medium', isTTY = true, }) {
|
|
10
|
+
// Handle keyboard input
|
|
11
|
+
useInput((input, key) => {
|
|
12
|
+
if (!isOpen || !closeable)
|
|
13
|
+
return;
|
|
14
|
+
if (key.escape) {
|
|
15
|
+
onClose();
|
|
16
|
+
}
|
|
17
|
+
}, { isActive: isTTY && isOpen });
|
|
18
|
+
if (!isOpen) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const dimensions = sizeMap[size];
|
|
22
|
+
const isFullscreen = size === 'fullscreen';
|
|
23
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", width: isFullscreen ? undefined : dimensions.width, minHeight: isFullscreen ? undefined : dimensions.height, padding: 1, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [_jsx(Box, { children: title && _jsx(Text, { bold: true, color: "cyan", children: title }) }), closeable && (_jsx(Text, { color: "gray", children: "[esc] \u00D7" }))] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "gray", children: '─'.repeat(isFullscreen ? 50 : dimensions.width - 4) }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: children }), footer && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: '─'.repeat(isFullscreen ? 50 : dimensions.width - 4) }) }), _jsx(Box, { marginTop: 1, children: footer })] }))] }));
|
|
24
|
+
}
|
|
25
|
+
export default Modal;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { render } from 'ink-testing-library';
|
|
5
|
+
import { Modal } from './Modal.js';
|
|
6
|
+
describe('Modal', () => {
|
|
7
|
+
describe('Rendering', () => {
|
|
8
|
+
it('renders children when open', () => {
|
|
9
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, children: _jsx(Text, { children: "Modal Content" }) }));
|
|
10
|
+
expect(lastFrame()).toContain('Modal Content');
|
|
11
|
+
});
|
|
12
|
+
it('does not render when closed', () => {
|
|
13
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: false, onClose: () => { }, children: _jsx(Text, { children: "Modal Content" }) }));
|
|
14
|
+
expect(lastFrame()).not.toContain('Modal Content');
|
|
15
|
+
});
|
|
16
|
+
it('renders title when provided', () => {
|
|
17
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, title: "My Modal", children: _jsx(Text, { children: "Content" }) }));
|
|
18
|
+
expect(lastFrame()).toContain('My Modal');
|
|
19
|
+
});
|
|
20
|
+
it('renders with border by default', () => {
|
|
21
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, children: _jsx(Text, { children: "Content" }) }));
|
|
22
|
+
const output = lastFrame() || '';
|
|
23
|
+
// Should have some border characters
|
|
24
|
+
expect(output).toMatch(/[─│╭╮╰╯]/);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('Close Behavior', () => {
|
|
28
|
+
it('calls onClose when closeable and overlay clicked', () => {
|
|
29
|
+
const onClose = vi.fn();
|
|
30
|
+
render(_jsx(Modal, { isOpen: true, onClose: onClose, closeable: true, children: _jsx(Text, { children: "Content" }) }));
|
|
31
|
+
expect(onClose).toBeDefined();
|
|
32
|
+
});
|
|
33
|
+
it('does not close when closeable=false', () => {
|
|
34
|
+
const onClose = vi.fn();
|
|
35
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: onClose, closeable: false, children: _jsx(Text, { children: "Content" }) }));
|
|
36
|
+
expect(lastFrame()).toContain('Content');
|
|
37
|
+
});
|
|
38
|
+
it('shows close button when closeable', () => {
|
|
39
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, closeable: true, children: _jsx(Text, { children: "Content" }) }));
|
|
40
|
+
expect(lastFrame()).toMatch(/×|X|✕|esc/i);
|
|
41
|
+
});
|
|
42
|
+
it('hides close button when not closeable', () => {
|
|
43
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, closeable: false, children: _jsx(Text, { children: "Content" }) }));
|
|
44
|
+
expect(lastFrame()).toContain('Content');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('Sizes', () => {
|
|
48
|
+
it('renders small size', () => {
|
|
49
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, size: "small", children: _jsx(Text, { children: "Small Modal" }) }));
|
|
50
|
+
expect(lastFrame()).toContain('Small Modal');
|
|
51
|
+
});
|
|
52
|
+
it('renders medium size (default)', () => {
|
|
53
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, children: _jsx(Text, { children: "Medium Modal" }) }));
|
|
54
|
+
expect(lastFrame()).toContain('Medium Modal');
|
|
55
|
+
});
|
|
56
|
+
it('renders large size', () => {
|
|
57
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, size: "large", children: _jsx(Text, { children: "Large Modal" }) }));
|
|
58
|
+
expect(lastFrame()).toContain('Large Modal');
|
|
59
|
+
});
|
|
60
|
+
it('renders fullscreen size', () => {
|
|
61
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, size: "fullscreen", children: _jsx(Text, { children: "Fullscreen Modal" }) }));
|
|
62
|
+
expect(lastFrame()).toContain('Fullscreen Modal');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('Title Display', () => {
|
|
66
|
+
it('has title visible', () => {
|
|
67
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, title: "Dialog", children: _jsx(Text, { children: "Content" }) }));
|
|
68
|
+
expect(lastFrame()).toContain('Dialog');
|
|
69
|
+
});
|
|
70
|
+
it('shows title for accessibility', () => {
|
|
71
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, title: "Accessible Title", children: _jsx(Text, { children: "Content" }) }));
|
|
72
|
+
expect(lastFrame()).toContain('Accessible Title');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('Footer', () => {
|
|
76
|
+
it('renders footer when provided', () => {
|
|
77
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, footer: _jsx(Text, { children: "Footer Content" }), children: _jsx(Text, { children: "Body" }) }));
|
|
78
|
+
expect(lastFrame()).toContain('Footer Content');
|
|
79
|
+
});
|
|
80
|
+
it('renders without footer when not provided', () => {
|
|
81
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, children: _jsx(Text, { children: "Body Only" }) }));
|
|
82
|
+
expect(lastFrame()).toContain('Body Only');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('Keyboard Hints', () => {
|
|
86
|
+
it('shows escape hint when closeable', () => {
|
|
87
|
+
const { lastFrame } = render(_jsx(Modal, { isOpen: true, onClose: () => { }, closeable: true, children: _jsx(Text, { children: "Content" }) }));
|
|
88
|
+
expect(lastFrame()).toMatch(/esc/i);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type SkeletonVariant = 'text' | 'avatar' | 'card' | 'button' | 'table-row';
|
|
2
|
+
export interface SkeletonProps {
|
|
3
|
+
variant?: SkeletonVariant;
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
lines?: number;
|
|
7
|
+
size?: number;
|
|
8
|
+
columns?: number;
|
|
9
|
+
rounded?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function Skeleton(props: SkeletonProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export declare namespace Skeleton {
|
|
13
|
+
var Text: ({ lines, width }: {
|
|
14
|
+
lines?: number;
|
|
15
|
+
width?: number;
|
|
16
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
var Avatar: ({ size }: {
|
|
18
|
+
size?: number;
|
|
19
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
20
|
+
var Card: ({ width, height }: {
|
|
21
|
+
width?: number;
|
|
22
|
+
height?: number;
|
|
23
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
24
|
+
var Button: ({ width }: {
|
|
25
|
+
width?: number;
|
|
26
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
27
|
+
var TableRow: ({ columns, width }: {
|
|
28
|
+
columns?: number;
|
|
29
|
+
width?: number;
|
|
30
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
31
|
+
}
|
|
32
|
+
export default Skeleton;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
// Shimmer/pulse characters for animation feel
|
|
4
|
+
const SHIMMER_CHAR = '░';
|
|
5
|
+
const SHIMMER_CHAR_ALT = '▒';
|
|
6
|
+
function SkeletonLine({ width = 20 }) {
|
|
7
|
+
return _jsx(Text, { color: "gray", children: SHIMMER_CHAR.repeat(width) });
|
|
8
|
+
}
|
|
9
|
+
function SkeletonBase({ variant = 'text', width = 20, height = 1, lines = 1, size = 3, columns = 3, rounded = false, }) {
|
|
10
|
+
switch (variant) {
|
|
11
|
+
case 'text':
|
|
12
|
+
return (_jsx(Box, { flexDirection: "column", children: Array.from({ length: lines }).map((_, i) => (_jsx(SkeletonLine, { width: i === lines - 1 ? Math.floor(width * 0.7) : width }, i))) }));
|
|
13
|
+
case 'avatar':
|
|
14
|
+
// Render a circle-ish shape using characters
|
|
15
|
+
return (_jsx(Box, { flexDirection: "column", children: Array.from({ length: size }).map((_, row) => (_jsx(Box, { children: row === 0 || row === size - 1 ? (_jsxs(Text, { color: "gray", children: [' '.repeat(1), SHIMMER_CHAR.repeat(size - 2)] })) : (_jsx(Text, { color: "gray", children: SHIMMER_CHAR.repeat(size) })) }, row))) }));
|
|
16
|
+
case 'card':
|
|
17
|
+
const borderTop = rounded ? '╭' + '─'.repeat(width - 2) + '╮' : '┌' + '─'.repeat(width - 2) + '┐';
|
|
18
|
+
const borderBottom = rounded ? '╰' + '─'.repeat(width - 2) + '╯' : '└' + '─'.repeat(width - 2) + '┘';
|
|
19
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", children: borderTop }), Array.from({ length: height - 2 }).map((_, i) => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "\u2502" }), _jsx(Text, { color: "gray", children: SHIMMER_CHAR.repeat(width - 2) }), _jsx(Text, { color: "gray", children: "\u2502" })] }, i))), _jsx(Text, { color: "gray", children: borderBottom })] }));
|
|
20
|
+
case 'button':
|
|
21
|
+
return (_jsx(Box, { children: _jsxs(Text, { color: "gray", children: ["[", SHIMMER_CHAR.repeat(width - 2), "]"] }) }));
|
|
22
|
+
case 'table-row':
|
|
23
|
+
return (_jsx(Box, { children: Array.from({ length: columns }).map((_, i) => (_jsx(Box, { marginRight: 2, children: _jsx(Text, { color: "gray", children: SHIMMER_CHAR.repeat(Math.floor(width / columns)) }) }, i))) }));
|
|
24
|
+
default:
|
|
25
|
+
return _jsx(SkeletonLine, { width: width });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Main Skeleton component with sub-components
|
|
29
|
+
export function Skeleton(props) {
|
|
30
|
+
return _jsx(SkeletonBase, { ...props });
|
|
31
|
+
}
|
|
32
|
+
// Convenience sub-components
|
|
33
|
+
Skeleton.Text = function SkeletonText({ lines = 1, width = 20 }) {
|
|
34
|
+
return _jsx(SkeletonBase, { variant: "text", lines: lines, width: width });
|
|
35
|
+
};
|
|
36
|
+
Skeleton.Avatar = function SkeletonAvatar({ size = 3 }) {
|
|
37
|
+
return _jsx(SkeletonBase, { variant: "avatar", size: size });
|
|
38
|
+
};
|
|
39
|
+
Skeleton.Card = function SkeletonCard({ width = 30, height = 5 }) {
|
|
40
|
+
return _jsx(SkeletonBase, { variant: "card", width: width, height: height });
|
|
41
|
+
};
|
|
42
|
+
Skeleton.Button = function SkeletonButton({ width = 10 }) {
|
|
43
|
+
return _jsx(SkeletonBase, { variant: "button", width: width });
|
|
44
|
+
};
|
|
45
|
+
Skeleton.TableRow = function SkeletonTableRow({ columns = 3, width = 30 }) {
|
|
46
|
+
return _jsx(SkeletonBase, { variant: "table-row", columns: columns, width: width });
|
|
47
|
+
};
|
|
48
|
+
export default Skeleton;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|