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,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 {};