@gustavobrunodev/ai-tools 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/jest.config.ts +26 -0
  3. package/package.json +1 -1
  4. package/project.json +74 -0
  5. package/src/app.tsx +56 -0
  6. package/src/atoms/deprecatedSkills.ts +11 -0
  7. package/src/atoms/environmentCheck.ts +51 -0
  8. package/src/atoms/installedSkills.ts +36 -0
  9. package/src/atoms/wizard.ts +6 -0
  10. package/src/cli/audit.ts +21 -0
  11. package/src/cli/cache.ts +28 -0
  12. package/src/cli/install.ts +93 -0
  13. package/src/cli/list.ts +41 -0
  14. package/src/cli/remove.ts +70 -0
  15. package/src/cli/update.ts +107 -0
  16. package/src/components/AnimatedTransition.tsx +42 -0
  17. package/src/components/AuditLogViewer.tsx +85 -0
  18. package/src/components/CategoryHeader.tsx +39 -0
  19. package/src/components/ConfirmPrompt.tsx +34 -0
  20. package/src/components/FooterBar.tsx +36 -0
  21. package/src/components/Header.tsx +97 -0
  22. package/src/components/InstallResults.tsx +110 -0
  23. package/src/components/KeyboardShortcutsOverlay.tsx +112 -0
  24. package/src/components/MultiSelectPrompt.tsx +219 -0
  25. package/src/components/SearchInput.tsx +36 -0
  26. package/src/components/SelectPrompt.tsx +108 -0
  27. package/src/components/SkillCard.tsx +74 -0
  28. package/src/components/SkillDetailPanel.tsx +233 -0
  29. package/src/components/StatusBadge.tsx +45 -0
  30. package/src/components/__tests__/AnimatedTransition.pbt.test.tsx +51 -0
  31. package/src/components/__tests__/CategoryHeader.pbt.test.tsx +107 -0
  32. package/src/components/__tests__/CategoryHeader.test.tsx +105 -0
  33. package/src/components/__tests__/KeyboardShortcutsOverlay.pbt.test.tsx +155 -0
  34. package/src/components/__tests__/KeyboardShortcutsOverlay.test.tsx +136 -0
  35. package/src/components/__tests__/SkillDetailPanel.test.tsx +273 -0
  36. package/src/components/index.ts +12 -0
  37. package/src/hooks/__tests__/useConfig.test.ts +242 -0
  38. package/src/hooks/index.ts +9 -0
  39. package/src/hooks/useAgents.ts +28 -0
  40. package/src/hooks/useConfig.ts +114 -0
  41. package/src/hooks/useFilter.ts +31 -0
  42. package/src/hooks/useInstaller.ts +39 -0
  43. package/src/hooks/useKeyboardNav.ts +39 -0
  44. package/src/hooks/useKonamiCode.ts +48 -0
  45. package/src/hooks/useRemover.ts +59 -0
  46. package/src/hooks/useSkillContent.ts +67 -0
  47. package/src/hooks/useSkills.ts +38 -0
  48. package/src/hooks/useWizardStep.ts +19 -0
  49. package/src/index.ts +129 -0
  50. package/src/services/__tests__/audit-log.spec.ts +220 -0
  51. package/src/services/__tests__/badge-format.test.ts +102 -0
  52. package/src/services/__tests__/category-colors.test.ts +253 -0
  53. package/src/services/__tests__/config.test.ts +184 -0
  54. package/src/services/__tests__/installer.security.spec.ts +151 -0
  55. package/src/services/__tests__/lockfile.security.spec.ts +132 -0
  56. package/src/services/__tests__/markdown-parser.spec.ts +185 -0
  57. package/src/services/__tests__/terminal-dimensions.pbt.test.ts +246 -0
  58. package/src/services/__tests__/terminal-dimensions.test.ts +109 -0
  59. package/src/services/__tests__/update-cache.pbt.test.ts +214 -0
  60. package/src/services/__tests__/update-cache.test.ts +215 -0
  61. package/src/services/agents.ts +42 -0
  62. package/src/services/audio-player.ts +55 -0
  63. package/src/services/audit-log.ts +69 -0
  64. package/src/services/badge-format.ts +4 -0
  65. package/src/services/categories.ts +176 -0
  66. package/src/services/category-colors.ts +19 -0
  67. package/src/services/config.ts +84 -0
  68. package/src/services/github-contributors.ts +56 -0
  69. package/src/services/global-path.ts +20 -0
  70. package/src/services/index.ts +21 -0
  71. package/src/services/installer.ts +371 -0
  72. package/src/services/lockfile.ts +177 -0
  73. package/src/services/markdown-parser.ts +108 -0
  74. package/src/services/package-info.ts +19 -0
  75. package/src/services/project-root.ts +18 -0
  76. package/src/services/registry.ts +382 -0
  77. package/src/services/skills-provider.ts +169 -0
  78. package/src/services/terminal-dimensions.ts +18 -0
  79. package/src/services/update-cache.ts +65 -0
  80. package/src/services/update-check.ts +26 -0
  81. package/src/theme/colors.ts +24 -0
  82. package/src/theme/index.ts +2 -0
  83. package/src/theme/symbols.ts +22 -0
  84. package/src/types.ts +38 -0
  85. package/src/utils/constants.ts +49 -0
  86. package/src/utils/paths.ts +52 -0
  87. package/src/views/ActionSelector.tsx +45 -0
  88. package/src/views/AgentSelector.tsx +105 -0
  89. package/src/views/CreditsView.tsx +332 -0
  90. package/src/views/InstallConfig.tsx +162 -0
  91. package/src/views/InstallWizard.tsx +181 -0
  92. package/src/views/ListView.tsx +41 -0
  93. package/src/views/RemoveWizard.tsx +237 -0
  94. package/src/views/SkillBrowser.tsx +504 -0
  95. package/src/views/UpdateView.tsx +272 -0
  96. package/src/views/arcade/ArcadeMenu.tsx +89 -0
  97. package/src/views/arcade/VibeInvaders.tsx +339 -0
  98. package/src/views/arcade/index.ts +2 -0
  99. package/src/views/index.ts +11 -0
  100. package/tsconfig.json +19 -0
  101. package/tsconfig.spec.json +25 -0
  102. package/LICENSE +0 -26
  103. package/README.md +0 -257
  104. package/index.js +0 -12
  105. package/index.js.map +0 -7
  106. /package/{assets → src/assets}/chiptune.mp3 +0 -0
@@ -0,0 +1,45 @@
1
+ import { Box, Text } from 'ink'
2
+
3
+ import { colors } from '../theme/colors'
4
+ import { symbols } from '../theme/symbols'
5
+
6
+ export type StatusType = 'installed' | 'update' | 'new' | 'deprecated'
7
+
8
+ export interface StatusBadgeProps {
9
+ status: StatusType
10
+ }
11
+
12
+ const badgeConfig = {
13
+ installed: { icon: symbols.check, label: 'installed', color: colors.success, bg: '#052e16' },
14
+ update: { icon: symbols.arrowUp, label: 'update', color: colors.warning, bg: '#422006' },
15
+ new: { icon: symbols.sparkle, label: 'new', color: colors.accent, bg: '#083344' },
16
+ deprecated: { icon: symbols.warning, label: 'deprecated', color: colors.warning, bg: '#422006' },
17
+ } as const
18
+
19
+ export function StatusBadge({ status }: StatusBadgeProps) {
20
+ const config = badgeConfig[status]
21
+ if (!config) return null
22
+
23
+ return (
24
+ <Box>
25
+ <Text backgroundColor={config.bg} color={config.color}>
26
+ {' '}
27
+ {config.icon} {config.label}{' '}
28
+ </Text>
29
+ </Box>
30
+ )
31
+ }
32
+
33
+ export interface AgentBadgeProps {
34
+ agents: string[]
35
+ }
36
+
37
+ export function AgentBadge({ agents }: AgentBadgeProps) {
38
+ if (!agents || agents.length === 0) return null
39
+
40
+ return (
41
+ <Text color={colors.textDim}>
42
+ <Text color={colors.success}>{symbols.check}</Text> {agents.join(', ')}
43
+ </Text>
44
+ )
45
+ }
@@ -0,0 +1,51 @@
1
+ /** @jest-environment jsdom */
2
+ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
3
+ import { act, render } from '@testing-library/react'
4
+ import * as fc from 'fast-check'
5
+
6
+ import { AnimatedTransition } from '../AnimatedTransition'
7
+
8
+ jest.mock('ink', () => ({
9
+ Transform: jest.fn(({ transform }) => {
10
+ const output = transform ? transform('child content') : 'child content'
11
+ return <div data-testid="ink-transform">{output}</div>
12
+ }),
13
+ }))
14
+
15
+ describe('AnimatedTransition - Property-Based Tests', () => {
16
+ beforeEach(() => {
17
+ jest.useFakeTimers()
18
+ })
19
+
20
+ afterEach(() => {
21
+ jest.clearAllTimers()
22
+ jest.useRealTimers()
23
+ })
24
+
25
+ it('should eventually render empty or full content based on final visibility for ANY duration', () => {
26
+ fc.assert(
27
+ fc.property(fc.boolean(), fc.integer({ min: -100, max: 10000 }), (isVisible, duration) => {
28
+ const { unmount, container } = render(
29
+ <AnimatedTransition visible={isVisible} duration={duration}>
30
+ <span>child</span>
31
+ </AnimatedTransition>,
32
+ )
33
+
34
+ const waitTime = Math.max(duration, 0) + 100
35
+
36
+ act(() => {
37
+ jest.advanceTimersByTime(waitTime)
38
+ })
39
+
40
+ if (isVisible) {
41
+ expect(container.textContent).toContain('child')
42
+ } else {
43
+ expect(container.innerHTML).toBe('')
44
+ }
45
+
46
+ unmount()
47
+ }),
48
+ { numRuns: 100 },
49
+ )
50
+ })
51
+ })
@@ -0,0 +1,107 @@
1
+ /** @jest-environment jsdom */
2
+ import { render } from '@testing-library/react'
3
+ import * as fc from 'fast-check'
4
+
5
+ import { CategoryHeader } from '../CategoryHeader'
6
+
7
+ jest.mock('../../services/badge-format', () => ({
8
+ formatCategoryBadge: (installed: number, total: number) => (installed > 0 ? `(${installed}/${total})` : `(${total})`),
9
+ }))
10
+
11
+ jest.mock('../../services/category-colors', () => ({
12
+ getColorForCategory: () => '#64748b',
13
+ }))
14
+
15
+ jest.mock('../../theme', () => ({
16
+ colors: {
17
+ accent: '#06b6d4',
18
+ primaryLight: '#60a5fa',
19
+ text: '#f8fafc',
20
+ textMuted: '#64748b',
21
+ textDim: '#94a3b8',
22
+ success: '#22c55e',
23
+ },
24
+ symbols: {
25
+ bullet: '\u25B8',
26
+ dot: '\u00B7',
27
+ },
28
+ }))
29
+
30
+ jest.mock('ink', () => ({
31
+ Box: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
32
+ const { flexDirection, width, marginTop, marginBottom, ...validProps } = props
33
+ void flexDirection
34
+ void width
35
+ void marginTop
36
+ void marginBottom
37
+ return (
38
+ <div data-testid="box" {...validProps}>
39
+ {children}
40
+ </div>
41
+ )
42
+ },
43
+ Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
44
+ const { bold, underline, ...validProps } = props
45
+ void bold
46
+ void underline
47
+ return (
48
+ <span data-testid="text" {...validProps}>
49
+ {children}
50
+ </span>
51
+ )
52
+ },
53
+ }))
54
+
55
+ describe('CategoryHeader property tests', () => {
56
+ it('should display correct badge format for any valid skill counts', () => {
57
+ fc.assert(
58
+ fc.property(fc.nat({ max: 100 }), fc.nat({ max: 100 }), (installed, total) => {
59
+ const validInstalled = Math.min(installed, total)
60
+ const { container, unmount } = render(
61
+ <CategoryHeader name="Test" totalCount={total} installedCount={validInstalled} />,
62
+ )
63
+ const text = container.textContent ?? ''
64
+
65
+ if (validInstalled > 0) {
66
+ expect(text).toContain(`(${validInstalled}/${total})`)
67
+ } else {
68
+ expect(text).toContain(`(${total})`)
69
+ }
70
+ unmount()
71
+ }),
72
+ { numRuns: 200 },
73
+ )
74
+ })
75
+
76
+ it('should update badge immediately when installed count changes', () => {
77
+ fc.assert(
78
+ fc.property(fc.nat({ max: 50 }), fc.nat({ max: 50 }), (countA, countB) => {
79
+ const total = 50
80
+ const installedA = Math.min(countA, total)
81
+ const installedB = Math.min(countB, total)
82
+
83
+ const { container, rerender, unmount } = render(
84
+ <CategoryHeader name="Test" totalCount={total} installedCount={installedA} />,
85
+ )
86
+
87
+ const textBefore = container.textContent ?? ''
88
+ if (installedA > 0) {
89
+ expect(textBefore).toContain(`(${installedA}/${total})`)
90
+ } else {
91
+ expect(textBefore).toContain(`(${total})`)
92
+ }
93
+
94
+ rerender(<CategoryHeader name="Test" totalCount={total} installedCount={installedB} />)
95
+
96
+ const textAfter = container.textContent ?? ''
97
+ if (installedB > 0) {
98
+ expect(textAfter).toContain(`(${installedB}/${total})`)
99
+ } else {
100
+ expect(textAfter).toContain(`(${total})`)
101
+ }
102
+ unmount()
103
+ }),
104
+ { numRuns: 200 },
105
+ )
106
+ })
107
+ })
@@ -0,0 +1,105 @@
1
+ /** @jest-environment jsdom */
2
+ import { render, screen } from '@testing-library/react'
3
+
4
+ import { CategoryHeader } from '../CategoryHeader'
5
+
6
+ jest.mock('../../services/badge-format', () => ({
7
+ formatCategoryBadge: (installed: number, total: number) => (installed > 0 ? `(${installed}/${total})` : `(${total})`),
8
+ }))
9
+
10
+ jest.mock('../../services/category-colors', () => ({
11
+ getColorForCategory: (id: string) => (id === 'web' ? '#3b82f6' : '#64748b'),
12
+ }))
13
+
14
+ jest.mock('../../theme', () => ({
15
+ colors: {
16
+ accent: '#06b6d4',
17
+ primaryLight: '#60a5fa',
18
+ text: '#f8fafc',
19
+ textMuted: '#64748b',
20
+ textDim: '#94a3b8',
21
+ success: '#22c55e',
22
+ },
23
+ symbols: {
24
+ bullet: '\u25B8',
25
+ dot: '\u00B7',
26
+ },
27
+ }))
28
+
29
+ jest.mock('ink', () => ({
30
+ Box: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
31
+ const { flexDirection, width, marginTop, marginBottom, ...validProps } = props
32
+ void flexDirection
33
+ void width
34
+ void marginTop
35
+ void marginBottom
36
+ return (
37
+ <div data-testid="box" {...validProps}>
38
+ {children}
39
+ </div>
40
+ )
41
+ },
42
+ Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
43
+ const { bold, underline, ...validProps } = props
44
+ void bold
45
+ void underline
46
+ return (
47
+ <span data-testid="text" {...validProps}>
48
+ {children}
49
+ </span>
50
+ )
51
+ },
52
+ }))
53
+
54
+ describe('CategoryHeader', () => {
55
+ it('renders category name', () => {
56
+ render(<CategoryHeader name="Web" totalCount={5} />)
57
+ expect(screen.getByText('Web')).toBeTruthy()
58
+ })
59
+
60
+ it('displays badge with total only when no installed skills', () => {
61
+ const { container } = render(<CategoryHeader name="Web" totalCount={8} installedCount={0} />)
62
+ expect(container.textContent).toContain('(8)')
63
+ expect(container.textContent).not.toContain('/')
64
+ })
65
+
66
+ it('displays badge with installed/total when skills are installed', () => {
67
+ const { container } = render(<CategoryHeader name="Web" totalCount={8} installedCount={3} />)
68
+ expect(container.textContent).toContain('(3/8)')
69
+ })
70
+
71
+ it('shows expand hint when collapsed and focused', () => {
72
+ const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={false} isFocused={true} />)
73
+ expect(container.textContent).toContain('press space to expand')
74
+ })
75
+
76
+ it('hides expand hint when expanded', () => {
77
+ const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={true} isFocused={true} />)
78
+ expect(container.textContent).not.toContain('press space to expand')
79
+ })
80
+
81
+ it('hides expand hint when not focused', () => {
82
+ const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={false} isFocused={false} />)
83
+ expect(container.textContent).not.toContain('press space to expand')
84
+ })
85
+
86
+ it('shows collapsed chevron when not expanded', () => {
87
+ const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={false} />)
88
+ expect(container.textContent).toContain('\u25B8')
89
+ })
90
+
91
+ it('shows expanded chevron when expanded', () => {
92
+ const { container } = render(<CategoryHeader name="Web" totalCount={5} isExpanded={true} />)
93
+ expect(container.textContent).toContain('\u25BE')
94
+ })
95
+
96
+ it('uses category-specific color via categoryId', () => {
97
+ const { container } = render(<CategoryHeader name="Web" categoryId="web" totalCount={5} />)
98
+ expect(container.textContent).toContain('Web')
99
+ })
100
+
101
+ it('highlights badge in success color when installed count > 0', () => {
102
+ const { container } = render(<CategoryHeader name="Web" totalCount={10} installedCount={5} />)
103
+ expect(container.textContent).toContain('(5/10)')
104
+ })
105
+ })
@@ -0,0 +1,155 @@
1
+ /** @jest-environment jsdom */
2
+ import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
3
+ import { act, render } from '@testing-library/react'
4
+ import * as fc from 'fast-check'
5
+
6
+ import { KeyboardShortcutsOverlay } from '../KeyboardShortcutsOverlay'
7
+
8
+ jest.mock('../../theme', () => ({
9
+ colors: {
10
+ primary: '#3b82f6',
11
+ primaryLight: '#60a5fa',
12
+ accent: '#06b6d4',
13
+ text: '#f8fafc',
14
+ textDim: '#94a3b8',
15
+ textMuted: '#64748b',
16
+ border: '#334155',
17
+ bg: '#0f172a',
18
+ bgLight: '#1e293b',
19
+ success: '#22c55e',
20
+ error: '#ef4444',
21
+ },
22
+ symbols: {
23
+ sparkle: '✦',
24
+ diamond: '◆',
25
+ arrow: '›',
26
+ dot: '·',
27
+ bullet: '▸',
28
+ cross: '✗',
29
+ info: 'ℹ',
30
+ arrowDown: '↓',
31
+ arrowUp: '↑',
32
+ bar: '│',
33
+ },
34
+ }))
35
+
36
+ jest.mock('../AnimatedTransition', () => ({
37
+ AnimatedTransition: ({ children, visible }: { children: React.ReactNode; visible: boolean }) =>
38
+ visible ? <div data-testid="overlay">{children}</div> : null,
39
+ }))
40
+
41
+ const mockUseInput = jest.fn()
42
+
43
+ jest.mock('ink', () => {
44
+ return {
45
+ useInput: (handler: (input: string, key: Record<string, boolean>) => void, options: Record<string, unknown>) =>
46
+ mockUseInput(handler, options),
47
+ Box: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
48
+ const {
49
+ flexDirection,
50
+ justifyContent,
51
+ borderStyle,
52
+ borderColor,
53
+ paddingLeft,
54
+ paddingRight,
55
+ marginTop,
56
+ marginBottom,
57
+ width,
58
+ height,
59
+ gap,
60
+ ...validProps
61
+ } = props
62
+ void flexDirection
63
+ void justifyContent
64
+ void borderStyle
65
+ void borderColor
66
+ void paddingLeft
67
+ void paddingRight
68
+ void marginTop
69
+ void marginBottom
70
+ void width
71
+ void height
72
+ void gap
73
+ return (
74
+ <div data-testid="box" {...validProps}>
75
+ {children}
76
+ </div>
77
+ )
78
+ },
79
+ Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
80
+ const { bold, underline, ...validProps } = props
81
+ void bold
82
+ void underline
83
+ return (
84
+ <span data-testid="text" {...validProps}>
85
+ {children}
86
+ </span>
87
+ )
88
+ },
89
+ }
90
+ })
91
+
92
+ const defaultShortcuts = [
93
+ { key: 'space', description: 'Toggle selection' },
94
+ { key: 'enter', description: 'Confirm' },
95
+ ]
96
+
97
+ describe('KeyboardShortcutsOverlay - Property-Based Tests', () => {
98
+ beforeEach(() => {
99
+ jest.useFakeTimers()
100
+ mockUseInput.mockClear()
101
+ })
102
+
103
+ afterEach(() => {
104
+ jest.clearAllTimers()
105
+ jest.useRealTimers()
106
+ })
107
+
108
+ it('should dismiss on ANY random key press when visible', () => {
109
+ fc.assert(
110
+ fc.property(
111
+ // Generate random key inputs
112
+ fc.record({
113
+ input: fc.string(),
114
+ key: fc.record({
115
+ return: fc.boolean(),
116
+ escape: fc.boolean(),
117
+ ctrl: fc.boolean(),
118
+ meta: fc.boolean(),
119
+ shift: fc.boolean(),
120
+ tab: fc.boolean(),
121
+ backspace: fc.boolean(),
122
+ delete: fc.boolean(),
123
+ upArrow: fc.boolean(),
124
+ downArrow: fc.boolean(),
125
+ leftArrow: fc.boolean(),
126
+ rightArrow: fc.boolean(),
127
+ pageDown: fc.boolean(),
128
+ pageUp: fc.boolean(),
129
+ }),
130
+ }),
131
+ (keyEvent) => {
132
+ const onDismiss = jest.fn()
133
+ mockUseInput.mockClear()
134
+
135
+ const { unmount } = render(
136
+ <KeyboardShortcutsOverlay visible={true} onDismiss={onDismiss} shortcuts={defaultShortcuts} />,
137
+ )
138
+
139
+ if (mockUseInput.mock.calls.length > 0) {
140
+ const handler = mockUseInput.mock.calls[0][0] as (input: string, key: unknown) => void
141
+
142
+ act(() => {
143
+ handler(keyEvent.input, keyEvent.key)
144
+ })
145
+
146
+ expect(onDismiss).toHaveBeenCalled()
147
+ }
148
+
149
+ unmount()
150
+ },
151
+ ),
152
+ { numRuns: 100 },
153
+ )
154
+ })
155
+ })
@@ -0,0 +1,136 @@
1
+ /** @jest-environment jsdom */
2
+ import { act, render, screen } from '@testing-library/react'
3
+
4
+ import { KeyboardShortcutsOverlay } from '../KeyboardShortcutsOverlay'
5
+
6
+ jest.mock('../../theme', () => ({
7
+ colors: {
8
+ primary: '#3b82f6',
9
+ primaryLight: '#60a5fa',
10
+ accent: '#06b6d4',
11
+ text: '#f8fafc',
12
+ textDim: '#94a3b8',
13
+ textMuted: '#64748b',
14
+ border: '#334155',
15
+ bg: '#0f172a',
16
+ bgLight: '#1e293b',
17
+ success: '#22c55e',
18
+ error: '#ef4444',
19
+ },
20
+ symbols: {
21
+ sparkle: '✦',
22
+ diamond: '◆',
23
+ arrow: '›',
24
+ dot: '·',
25
+ bullet: '▸',
26
+ cross: '✗',
27
+ info: 'ℹ',
28
+ arrowDown: '↓',
29
+ arrowUp: '↑',
30
+ bar: '│',
31
+ },
32
+ }))
33
+
34
+ jest.mock('../AnimatedTransition', () => ({
35
+ AnimatedTransition: ({ children, visible }: { children: React.ReactNode; visible: boolean }) =>
36
+ visible ? <div data-testid="overlay">{children}</div> : null,
37
+ }))
38
+
39
+ const mockUseInput = jest.fn()
40
+
41
+ jest.mock('ink', () => {
42
+ return {
43
+ useInput: (handler: (input: string, key: Record<string, boolean>) => void, options: Record<string, unknown>) =>
44
+ mockUseInput(handler, options),
45
+ Box: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
46
+ const {
47
+ flexDirection,
48
+ justifyContent,
49
+ borderStyle,
50
+ borderColor,
51
+ paddingLeft,
52
+ paddingRight,
53
+ marginTop,
54
+ marginBottom,
55
+ width,
56
+ height,
57
+ gap,
58
+ ...validProps
59
+ } = props
60
+ void flexDirection
61
+ void justifyContent
62
+ void borderStyle
63
+ void borderColor
64
+ void paddingLeft
65
+ void paddingRight
66
+ void marginTop
67
+ void marginBottom
68
+ void width
69
+ void height
70
+ void gap
71
+
72
+ return (
73
+ <div data-testid="box" {...validProps}>
74
+ {children}
75
+ </div>
76
+ )
77
+ },
78
+ Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
79
+ const { bold, underline, ...validProps } = props
80
+ void bold
81
+ void underline
82
+ return (
83
+ <span data-testid="text" {...validProps}>
84
+ {children}
85
+ </span>
86
+ )
87
+ },
88
+ }
89
+ })
90
+
91
+ const defaultShortcuts = [
92
+ { key: 'space', description: 'Toggle selection' },
93
+ { key: 'enter', description: 'Confirm' },
94
+ ]
95
+
96
+ describe('KeyboardShortcutsOverlay', () => {
97
+ beforeEach(() => {
98
+ jest.useFakeTimers()
99
+ mockUseInput.mockClear()
100
+ })
101
+
102
+ afterEach(() => {
103
+ jest.clearAllTimers()
104
+ jest.useRealTimers()
105
+ })
106
+
107
+ it('renders correctly when visible', () => {
108
+ render(<KeyboardShortcutsOverlay visible={true} onDismiss={() => {}} shortcuts={defaultShortcuts} />)
109
+ expect(screen.getByTestId('overlay')).toBeTruthy()
110
+ })
111
+
112
+ it('auto-dismisses after 8 seconds', () => {
113
+ const onDismiss = jest.fn()
114
+ render(<KeyboardShortcutsOverlay visible={true} onDismiss={onDismiss} shortcuts={defaultShortcuts} />)
115
+
116
+ act(() => {
117
+ jest.advanceTimersByTime(8000)
118
+ })
119
+
120
+ expect(onDismiss).toHaveBeenCalled()
121
+ })
122
+
123
+ it('dismisses on any key press', () => {
124
+ const onDismiss = jest.fn()
125
+ render(<KeyboardShortcutsOverlay visible={true} onDismiss={onDismiss} shortcuts={defaultShortcuts} />)
126
+
127
+ expect(mockUseInput).toHaveBeenCalled()
128
+ const handler = mockUseInput.mock.calls[0][0] as (input: string, key: Record<string, boolean>) => void
129
+
130
+ act(() => {
131
+ handler('a', { return: false })
132
+ })
133
+
134
+ expect(onDismiss).toHaveBeenCalled()
135
+ })
136
+ })