@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,504 @@
1
+ import { Box, Text, useInput, useStdout } from 'ink'
2
+ import { useAtomValue } from 'jotai'
3
+ import { useMemo, useState } from 'react'
4
+
5
+ import { CategoryHeader, Header, SearchInput, SkillCard, SkillDetailPanel } from '../components'
6
+ import { FooterBar } from '../components/FooterBar'
7
+ import { KeyboardShortcutsOverlay, type ShortcutEntry } from '../components/KeyboardShortcutsOverlay'
8
+ import { useFilter, useSkills } from '../hooks'
9
+ import { groupSkillsByCategory } from '../services/categories'
10
+ import { canShowDetailPanel } from '../services/terminal-dimensions'
11
+ import { colors, symbols } from '../theme'
12
+ import type { SkillInfo } from '../types'
13
+
14
+ import { deprecatedSkillsAtom } from '../atoms/deprecatedSkills'
15
+ import { installedSkillsAtom } from '../atoms/installedSkills'
16
+ import { selectedAgentsAtom } from '../atoms/wizard'
17
+
18
+ interface SkillBrowserProps {
19
+ onInstall?: (selectedSkills: SkillInfo[]) => void
20
+ onExit?: () => void
21
+ overrideSkills?: SkillInfo[]
22
+ readOnly?: boolean
23
+ }
24
+
25
+ type VisualItem =
26
+ | { type: 'header'; category: string; categoryId?: string; count: number; installedCount: number }
27
+ | { type: 'skill'; skill: SkillInfo }
28
+
29
+ const MIN_VISIBLE = 5
30
+ const CHROME_LINES = 24
31
+ const PANEL_WIDTH_RATIO = 0.35
32
+
33
+ const getShortcuts = (readOnly: boolean): ShortcutEntry[] => {
34
+ const common = [
35
+ { key: '/', description: 'Filter skills' },
36
+ { key: '←/→', description: 'Collapse / expand' },
37
+ { key: 'tab/→', description: 'Skill details' },
38
+ { key: 'f', description: 'Expand / compact panel' },
39
+ ]
40
+
41
+ const exitKey = { key: 'esc', description: readOnly ? 'Exit / close panel' : 'Back / close panel' }
42
+
43
+ if (readOnly) return [...common, exitKey]
44
+
45
+ return [
46
+ ...common,
47
+ { key: 'space', description: 'Toggle / expand' },
48
+ { key: 'enter', description: 'Install selected' },
49
+ { key: 'ctrl+a', description: 'Select all' },
50
+ exitKey,
51
+ ]
52
+ }
53
+
54
+ export const SkillBrowser = ({ onInstall, onExit, overrideSkills, readOnly = false }: SkillBrowserProps) => {
55
+ const { skills: fetchedSkills, loading: fetching, error } = useSkills()
56
+ const { stdout } = useStdout()
57
+
58
+ const selectedAgents = useAtomValue(selectedAgentsAtom)
59
+ const installedSkills = useAtomValue(installedSkillsAtom)
60
+ const deprecatedMap = useAtomValue(deprecatedSkillsAtom)
61
+
62
+ const skills = overrideSkills || fetchedSkills
63
+ const loading = overrideSkills ? false : fetching
64
+
65
+ const { query, setQuery, filtered } = useFilter(skills, {
66
+ keys: ['name', 'description', 'category'],
67
+ })
68
+
69
+ const [selectedSet, setSelectedSet] = useState<Set<string>>(new Set())
70
+ const [focusArea, setFocusArea] = useState<'search' | 'list'>('list')
71
+ const [listIndex, setListIndex] = useState(0)
72
+ const [offset, setOffset] = useState(0)
73
+ const [showSearch, setShowSearch] = useState(false)
74
+ const [showShortcuts, setShowShortcuts] = useState(false)
75
+ const [detailSkill, setDetailSkill] = useState<SkillInfo | null>(null)
76
+ const [drawerExpanded, setDrawerExpanded] = useState(false)
77
+ const [expandedCategory, setExpandedCategory] = useState<string | null>(null)
78
+
79
+ const canShowPanel = canShowDetailPanel()
80
+ const terminalRows = stdout?.rows ?? 40
81
+ const terminalCols = stdout?.columns ?? 120
82
+ const VISIBLE_ITEMS = Math.max(MIN_VISIBLE, terminalRows - CHROME_LINES)
83
+ const panelWidth = Math.max(30, Math.round(terminalCols * PANEL_WIDTH_RATIO))
84
+ const contentAreaHeight = Math.max(10, terminalRows - 17)
85
+ const isSearchExpanded = query.trim().length > 0
86
+
87
+ const groupedMap = useMemo(() => groupSkillsByCategory(filtered), [filtered])
88
+
89
+ useMemo(() => {
90
+ if (query) setShowSearch(true)
91
+ }, [query])
92
+
93
+ const isCategoryExpanded = (categoryName: string) => isSearchExpanded || expandedCategory === categoryName
94
+
95
+ const toggleCategory = (categoryName: string) => {
96
+ setExpandedCategory(isCategoryExpanded(categoryName) ? null : categoryName)
97
+ }
98
+
99
+ const visualList = useMemo(() => {
100
+ const list: VisualItem[] = []
101
+
102
+ for (const [category, categorySkills] of groupedMap.entries()) {
103
+ const installedCount = categorySkills.filter((s) => {
104
+ const agents = installedSkills[s.name] || []
105
+ if (selectedAgents.length > 0) return agents.some((a) => selectedAgents.includes(a))
106
+ return agents.length > 0
107
+ }).length
108
+
109
+ list.push({
110
+ type: 'header',
111
+ category: category.name,
112
+ categoryId: category.id,
113
+ count: categorySkills.length,
114
+ installedCount,
115
+ })
116
+
117
+ if (isCategoryExpanded(category.name)) categorySkills.forEach((skill) => list.push({ type: 'skill', skill }))
118
+ }
119
+
120
+ return list
121
+ }, [groupedMap, expandedCategory, isSearchExpanded, installedSkills, selectedAgents])
122
+
123
+ const handleToggleShortcuts = () => setShowShortcuts((prev) => !prev)
124
+
125
+ const handleEscape = () => {
126
+ if (showSearch) {
127
+ setShowSearch(false)
128
+ setQuery('')
129
+ setFocusArea('list')
130
+ return
131
+ }
132
+ onExit?.()
133
+ }
134
+
135
+ const handleSelectAll = () => {
136
+ const allSkillNames = filtered.map((s) => s.name)
137
+ setSelectedSet(selectedSet.size === allSkillNames.length ? new Set() : new Set(allSkillNames))
138
+ }
139
+
140
+ const handleSearchNavigation = (key: { downArrow?: boolean; return?: boolean }) => {
141
+ if ((key.downArrow || key.return) && visualList.length > 0) {
142
+ setFocusArea('list')
143
+ setListIndex(0)
144
+ }
145
+ }
146
+
147
+ const handleUpArrow = () => {
148
+ if (listIndex === 0 && showSearch) {
149
+ setFocusArea('search')
150
+ return
151
+ }
152
+
153
+ const newIndex = Math.max(0, listIndex - 1)
154
+ setListIndex(newIndex)
155
+ if (newIndex < offset) setOffset(newIndex)
156
+ }
157
+
158
+ const handleDownArrow = () => {
159
+ const newIndex = Math.min(visualList.length - 1, listIndex + 1)
160
+ setListIndex(newIndex)
161
+ if (newIndex >= offset + VISIBLE_ITEMS) setOffset(newIndex - VISIBLE_ITEMS + 1)
162
+ }
163
+
164
+ const handleSpaceKey = () => {
165
+ const item = visualList[listIndex]
166
+
167
+ if (item.type === 'header') {
168
+ toggleCategory(item.category)
169
+ return
170
+ }
171
+
172
+ if (item.type === 'skill' && !readOnly) {
173
+ const isInstalled =
174
+ selectedAgents.length > 0
175
+ ? (installedSkills[item.skill.name]?.some((a) => selectedAgents.includes(a)) ?? false)
176
+ : (installedSkills[item.skill.name]?.length ?? 0) > 0
177
+
178
+ if (isInstalled && !overrideSkills) return
179
+
180
+ const newSet = new Set(selectedSet)
181
+ if (newSet.has(item.skill.name)) {
182
+ newSet.delete(item.skill.name)
183
+ } else {
184
+ newSet.add(item.skill.name)
185
+ }
186
+ setSelectedSet(newSet)
187
+ }
188
+ }
189
+
190
+ const handleEnterKey = () => {
191
+ const item = visualList[listIndex]
192
+
193
+ if (item.type === 'header') {
194
+ toggleCategory(item.category)
195
+ return
196
+ }
197
+
198
+ if (readOnly) return
199
+
200
+ const selectedSkills = skills.filter((s) => selectedSet.has(s.name))
201
+ if (selectedSkills.length > 0) onInstall?.(selectedSkills)
202
+ }
203
+
204
+ const handleTabOrRightArrow = (isTab: boolean) => {
205
+ const item = visualList[listIndex]
206
+
207
+ if (item.type === 'skill' && canShowPanel) {
208
+ setDetailSkill(item.skill)
209
+ setDrawerExpanded(false)
210
+ return
211
+ }
212
+
213
+ if (!isTab && item.type === 'header' && !isCategoryExpanded(item.category)) setExpandedCategory(item.category)
214
+ }
215
+
216
+ const handleLeftArrow = () => {
217
+ const item = visualList[listIndex]
218
+ if (item.type === 'header' && isCategoryExpanded(item.category)) setExpandedCategory(null)
219
+ }
220
+
221
+ const isRegularCharacter = (
222
+ input: string,
223
+ key: {
224
+ ctrl?: boolean
225
+ meta?: boolean
226
+ upArrow?: boolean
227
+ downArrow?: boolean
228
+ leftArrow?: boolean
229
+ rightArrow?: boolean
230
+ },
231
+ ) =>
232
+ input.length === 1 && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow
233
+
234
+ useInput(
235
+ (input, key) => {
236
+ if (input === '?') return handleToggleShortcuts()
237
+ if (showShortcuts) return setShowShortcuts(false)
238
+ if (key.escape) return handleEscape()
239
+
240
+ if (!showSearch && input === '/') {
241
+ setShowSearch(true)
242
+ setFocusArea('search')
243
+ return
244
+ }
245
+
246
+ if (input === 'a' && key.ctrl && !readOnly) return handleSelectAll()
247
+ if (focusArea === 'search') return handleSearchNavigation(key)
248
+
249
+ if (focusArea === 'list') {
250
+ if (key.upArrow) return handleUpArrow()
251
+ if (key.downArrow) return handleDownArrow()
252
+ if (input === ' ') return handleSpaceKey()
253
+ if (key.return) return handleEnterKey()
254
+ if (key.tab) return handleTabOrRightArrow(true)
255
+ if (key.rightArrow) return handleTabOrRightArrow(false)
256
+ if (key.leftArrow) return handleLeftArrow()
257
+
258
+ if (isRegularCharacter(input, key)) {
259
+ setShowSearch(true)
260
+ setFocusArea('search')
261
+ setQuery(input)
262
+ }
263
+ }
264
+ },
265
+ { isActive: !detailSkill },
266
+ )
267
+
268
+ const visibleWindow = visualList.slice(offset, offset + VISIBLE_ITEMS)
269
+ const hasItemsAbove = offset > 0
270
+ const hasItemsBelow = offset + VISIBLE_ITEMS < visualList.length
271
+ const scrollPercent =
272
+ visualList.length <= VISIBLE_ITEMS ? 100 : Math.round(((offset + VISIBLE_ITEMS) / visualList.length) * 100)
273
+ const showSkillList = !detailSkill || !drawerExpanded
274
+
275
+ if (loading) {
276
+ return (
277
+ <Box flexDirection="column" paddingX={1}>
278
+ <Header />
279
+ <Box flexDirection="column" alignItems="center" justifyContent="center" paddingY={4}>
280
+ <Text color={colors.accent}>Loading skills...</Text>
281
+ </Box>
282
+ </Box>
283
+ )
284
+ }
285
+
286
+ if (error || (!loading && skills.length === 0)) {
287
+ return (
288
+ <Box flexDirection="column" paddingX={1} minHeight={20}>
289
+ <Header />
290
+ <Box flexDirection="column" alignItems="center" justifyContent="center" flexGrow={1}>
291
+ <Box
292
+ flexDirection="column"
293
+ borderStyle="round"
294
+ borderColor={colors.error}
295
+ paddingX={3}
296
+ paddingY={2}
297
+ alignItems="center"
298
+ >
299
+ <Text color={colors.error} bold>
300
+ {symbols.cross} No Skills Available
301
+ </Text>
302
+ <Box marginTop={1}>
303
+ <Text color={colors.textDim}>Check your internet connection and try again</Text>
304
+ </Box>
305
+ {error && (
306
+ <Box marginTop={1}>
307
+ <Text color={colors.textMuted} dimColor>
308
+ {error}
309
+ </Text>
310
+ </Box>
311
+ )}
312
+ </Box>
313
+ <Box marginTop={2}>
314
+ <Text color={colors.textDim}>
315
+ Press{' '}
316
+ <Text color={colors.accent} bold>
317
+ Esc
318
+ </Text>{' '}
319
+ to exit
320
+ </Text>
321
+ </Box>
322
+ </Box>
323
+ </Box>
324
+ )
325
+ }
326
+
327
+ const renderSkillListItem = (item: VisualItem, index: number) => {
328
+ const realIndex = offset + index
329
+ const isFocused = focusArea === 'list' && realIndex === listIndex
330
+
331
+ if (item.type === 'header') {
332
+ return (
333
+ <Box key={`cat-${item.category}`} marginTop={index === 0 ? 0 : 1}>
334
+ <CategoryHeader
335
+ name={item.category}
336
+ categoryId={item.categoryId}
337
+ totalCount={item.count}
338
+ installedCount={item.installedCount}
339
+ isExpanded={isCategoryExpanded(item.category)}
340
+ isFocused={isFocused}
341
+ />
342
+ </Box>
343
+ )
344
+ }
345
+
346
+ const isSelected = selectedSet.has(item.skill.name)
347
+ const isInstalled =
348
+ selectedAgents.length > 0
349
+ ? (installedSkills[item.skill.name]?.some((a) => selectedAgents.includes(a)) ?? false)
350
+ : (installedSkills[item.skill.name]?.length ?? 0) > 0
351
+ const isDeprecated = deprecatedMap instanceof Map && deprecatedMap.has(item.skill.name)
352
+ const status = isDeprecated ? 'deprecated' : isInstalled ? 'installed' : null
353
+
354
+ return (
355
+ <SkillCard
356
+ key={item.skill.name}
357
+ name={item.skill.name}
358
+ description={item.skill.description}
359
+ status={status}
360
+ selected={isSelected}
361
+ focused={isFocused}
362
+ readOnly={readOnly}
363
+ />
364
+ )
365
+ }
366
+
367
+ const renderScrollIndicator = (direction: 'up' | 'down') => (
368
+ <Box justifyContent="center" marginBottom={direction === 'up' ? 1 : 0} marginTop={direction === 'down' ? 1 : 0}>
369
+ <Text color={colors.textDim}>
370
+ {direction === 'up' ? symbols.arrowUp : symbols.arrowDown}{' '}
371
+ {direction === 'up' ? symbols.arrowUp : symbols.arrowDown}{' '}
372
+ {direction === 'up' ? symbols.arrowUp : symbols.arrowDown}
373
+ </Text>
374
+ </Box>
375
+ )
376
+
377
+ const renderFooterHints = () => {
378
+ if (detailSkill) {
379
+ return [
380
+ { key: '↑/↓', label: 'scroll' },
381
+ { key: 'f', label: drawerExpanded ? 'compact' : 'expand' },
382
+ { key: 'Esc', label: 'close', color: colors.warning },
383
+ ]
384
+ }
385
+
386
+ if (readOnly) {
387
+ return [
388
+ { key: '/', label: 'filter' },
389
+ { key: 'tab', label: 'detail' },
390
+ { key: 'esc', label: 'exit', color: colors.warning },
391
+ { key: '?', label: 'help' },
392
+ ]
393
+ }
394
+
395
+ return [
396
+ { key: 'space', label: 'toggle' },
397
+ { key: 'enter', label: 'install', color: colors.success },
398
+ { key: '/', label: 'filter' },
399
+ { key: 'tab', label: 'detail' },
400
+ { key: 'esc', label: 'exit', color: colors.warning },
401
+ { key: '?', label: 'help' },
402
+ ]
403
+ }
404
+
405
+ const renderFooterStatus = () => {
406
+ if (detailSkill) return undefined
407
+
408
+ return (
409
+ <>
410
+ {!readOnly && selectedSet.size > 0 && (
411
+ <Text>
412
+ <Text color={colors.success} bold>
413
+ {symbols.checkboxActive} {selectedSet.size}
414
+ </Text>
415
+ <Text color={colors.textDim}> selected</Text>
416
+ </Text>
417
+ )}
418
+ {visualList.length > VISIBLE_ITEMS && (
419
+ <Text color={colors.textDim}>
420
+ {!readOnly && selectedSet.size > 0 ? ` ${symbols.dot} ` : ''}
421
+ {scrollPercent}%
422
+ </Text>
423
+ )}
424
+ </>
425
+ )
426
+ }
427
+
428
+ return (
429
+ <Box flexDirection="column" paddingX={1} minHeight={20}>
430
+ <Header />
431
+
432
+ {showShortcuts ? (
433
+ <Box flexDirection="column" flexGrow={1} alignItems="center" justifyContent="center">
434
+ <KeyboardShortcutsOverlay
435
+ visible={showShortcuts}
436
+ onDismiss={() => setShowShortcuts(false)}
437
+ shortcuts={getShortcuts(readOnly)}
438
+ />
439
+ </Box>
440
+ ) : (
441
+ <Box
442
+ flexDirection="row"
443
+ height={detailSkill ? contentAreaHeight : undefined}
444
+ flexGrow={detailSkill ? 0 : 1}
445
+ overflow="hidden"
446
+ >
447
+ {showSkillList && (
448
+ <Box key="skill-list" flexDirection="column" flexGrow={1} flexShrink={1}>
449
+ {showSearch && (
450
+ <Box marginBottom={1}>
451
+ <SearchInput
452
+ query={query}
453
+ onChange={(q) => {
454
+ setQuery(q)
455
+ setListIndex(0)
456
+ setOffset(0)
457
+ }}
458
+ total={skills.length}
459
+ filtered={filtered.length}
460
+ isLoading={loading}
461
+ focus={focusArea === 'search'}
462
+ />
463
+ </Box>
464
+ )}
465
+
466
+ <Box flexDirection="column" flexGrow={1}>
467
+ {hasItemsAbove && renderScrollIndicator('up')}
468
+ {visibleWindow.map(renderSkillListItem)}
469
+ {hasItemsBelow && renderScrollIndicator('down')}
470
+ {visualList.length === 0 && (
471
+ <Box paddingY={1}>
472
+ <Text color={colors.textMuted}>No skills match "{query}"</Text>
473
+ </Box>
474
+ )}
475
+ </Box>
476
+ </Box>
477
+ )}
478
+
479
+ {detailSkill && (
480
+ <Box
481
+ key="detail-panel"
482
+ flexDirection="column"
483
+ width={drawerExpanded ? undefined : panelWidth}
484
+ flexGrow={drawerExpanded ? 1 : 0}
485
+ flexShrink={0}
486
+ >
487
+ <SkillDetailPanel
488
+ skill={detailSkill}
489
+ expanded={drawerExpanded}
490
+ onClose={() => {
491
+ setDetailSkill(null)
492
+ setDrawerExpanded(false)
493
+ }}
494
+ onToggleExpand={() => setDrawerExpanded((prev) => !prev)}
495
+ />
496
+ </Box>
497
+ )}
498
+ </Box>
499
+ )}
500
+
501
+ <FooterBar hints={renderFooterHints()} status={renderFooterStatus()} />
502
+ </Box>
503
+ )
504
+ }