skill-search 0.0.1
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/README.md +381 -0
- package/bin/skill.js +348 -0
- package/bin/tui.js +33 -0
- package/package.json +53 -0
- package/scripts/package-lock.json +6 -0
- package/scripts/setup-env.bat +58 -0
- package/scripts/test-scan.js +42 -0
- package/src/actions.js +216 -0
- package/src/api.js +306 -0
- package/src/cache.js +107 -0
- package/src/config.js +220 -0
- package/src/fallback-index.json +6 -0
- package/src/interactive.js +23 -0
- package/src/localCrawler.js +204 -0
- package/src/matcher.js +170 -0
- package/src/store.js +156 -0
- package/src/syncer.js +226 -0
- package/src/theme.js +191 -0
- package/src/tui/ActionModal.js +209 -0
- package/src/tui/AddDelView.js +212 -0
- package/src/tui/App.js +739 -0
- package/src/tui/AsciiHeader.js +35 -0
- package/src/tui/CommandPalette.js +64 -0
- package/src/tui/ConfigView.js +168 -0
- package/src/tui/DetailView.js +139 -0
- package/src/tui/DualPane.js +114 -0
- package/src/tui/PrimaryView.js +163 -0
- package/src/tui/SearchBox.js +26 -0
- package/src/tui/SearchView.js +121 -0
- package/src/tui/SkillList.js +102 -0
- package/src/tui/SyncView.js +143 -0
- package/src/tui/ThemeView.js +116 -0
- package/src/utils.js +83 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/tui/AsciiHeader.js - FIGlet-style ASCII Art Header
|
|
2
|
+
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { Box, Text } = require('ink');
|
|
5
|
+
|
|
6
|
+
// ASCII Art for "SKILL CLI" using a compact block style
|
|
7
|
+
const ASCII_ART = [
|
|
8
|
+
'███████╗██╗ ██╗██╗██╗ ██╗ ██████╗██╗ ██╗',
|
|
9
|
+
'██╔════╝██║ ██╔╝██║██║ ██║ ██╔════╝██║ ██║',
|
|
10
|
+
'███████╗█████╔╝ ██║██║ ██║ ██║ ██║ ██║',
|
|
11
|
+
'╚════██║██╔═██╗ ██║██║ ██║ ██║ ██║ ██║',
|
|
12
|
+
'███████║██║ ██╗██║███████╗███████╗ ╚██████╗███████╗██║',
|
|
13
|
+
'╚══════╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝ ╚═════╝╚══════╝╚═╝'
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// Gradient colors for the ASCII art (top to bottom)
|
|
17
|
+
const GRADIENT_COLORS = ['cyan', 'cyanBright', 'cyan', 'blue', 'blueBright', 'blue'];
|
|
18
|
+
|
|
19
|
+
function AsciiHeader() {
|
|
20
|
+
return React.createElement(Box, {
|
|
21
|
+
flexDirection: 'column',
|
|
22
|
+
alignItems: 'center',
|
|
23
|
+
paddingY: 1
|
|
24
|
+
},
|
|
25
|
+
ASCII_ART.map((line, index) =>
|
|
26
|
+
React.createElement(Text, {
|
|
27
|
+
key: index,
|
|
28
|
+
color: GRADIENT_COLORS[index] || 'cyan',
|
|
29
|
+
bold: true
|
|
30
|
+
}, line)
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = AsciiHeader;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/tui/CommandPalette.js - Slash Command Menu
|
|
2
|
+
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { useState } = React;
|
|
5
|
+
const { Box, Text, useInput } = require('ink');
|
|
6
|
+
|
|
7
|
+
const COMMANDS = [
|
|
8
|
+
{ key: 'sync', icon: '🔄', label: 'Sync', desc: 'Sync skills from remote' },
|
|
9
|
+
{ key: 'sync-docs', icon: '📥', label: 'Sync Docs', desc: 'Download all SKILL.md files' },
|
|
10
|
+
{ key: 'config', icon: '⚙️', label: 'Config', desc: 'Configure API settings' },
|
|
11
|
+
{ key: 'clear', icon: '🗑️', label: 'Clear Cache', desc: 'Clear local cache' },
|
|
12
|
+
{ key: 'help', icon: '❓', label: 'Help', desc: 'Show help information' },
|
|
13
|
+
{ key: 'quit', icon: '👋', label: 'Quit', desc: 'Exit the application' }
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function CommandPalette({ isOpen, filter, selectedIndex, onSelect, onClose }) {
|
|
17
|
+
if (!isOpen) return null;
|
|
18
|
+
|
|
19
|
+
// Filter commands based on input after "/"
|
|
20
|
+
const filteredCommands = filter
|
|
21
|
+
? COMMANDS.filter(cmd =>
|
|
22
|
+
cmd.key.toLowerCase().includes(filter.toLowerCase()) ||
|
|
23
|
+
cmd.label.toLowerCase().includes(filter.toLowerCase())
|
|
24
|
+
)
|
|
25
|
+
: COMMANDS;
|
|
26
|
+
|
|
27
|
+
return React.createElement(Box, {
|
|
28
|
+
flexDirection: 'column',
|
|
29
|
+
borderStyle: 'round',
|
|
30
|
+
borderColor: 'cyan',
|
|
31
|
+
paddingX: 1,
|
|
32
|
+
paddingY: 0,
|
|
33
|
+
marginX: 2
|
|
34
|
+
},
|
|
35
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
36
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, '/ Commands'),
|
|
37
|
+
React.createElement(Text, { color: 'gray' }, ' (Up/Down select, Enter run, Esc cancel)')
|
|
38
|
+
),
|
|
39
|
+
|
|
40
|
+
filteredCommands.length === 0
|
|
41
|
+
? React.createElement(Text, { color: 'yellow' }, 'No matching commands')
|
|
42
|
+
: filteredCommands.map((cmd, idx) => {
|
|
43
|
+
const isSelected = idx === selectedIndex;
|
|
44
|
+
return React.createElement(Box, { key: cmd.key },
|
|
45
|
+
React.createElement(Text, {
|
|
46
|
+
color: isSelected ? 'cyan' : 'white',
|
|
47
|
+
bold: isSelected,
|
|
48
|
+
inverse: isSelected
|
|
49
|
+
}, isSelected ? ' ▶ ' : ' '),
|
|
50
|
+
React.createElement(Text, null, `${cmd.icon} `),
|
|
51
|
+
React.createElement(Text, {
|
|
52
|
+
color: isSelected ? 'cyan' : 'white',
|
|
53
|
+
bold: isSelected
|
|
54
|
+
}, cmd.label.padEnd(15)),
|
|
55
|
+
React.createElement(Text, { color: 'gray' }, cmd.desc)
|
|
56
|
+
);
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Export commands for use in App.js
|
|
62
|
+
CommandPalette.COMMANDS = COMMANDS;
|
|
63
|
+
|
|
64
|
+
module.exports = CommandPalette;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/tui/ConfigView.js - Configuration View Component
|
|
2
|
+
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { useState, useEffect } = React;
|
|
5
|
+
const { Box, Text, useInput } = require('ink');
|
|
6
|
+
const TextInput = require('ink-text-input').default;
|
|
7
|
+
const config = require('../config');
|
|
8
|
+
|
|
9
|
+
function ConfigView({ onBack }) {
|
|
10
|
+
const [currentConfig, setCurrentConfig] = useState(null);
|
|
11
|
+
const [editingApiKey, setEditingApiKey] = useState(false);
|
|
12
|
+
const [apiKeyValue, setApiKeyValue] = useState('');
|
|
13
|
+
const [message, setMessage] = useState(null);
|
|
14
|
+
const [selectedOption, setSelectedOption] = useState(0);
|
|
15
|
+
|
|
16
|
+
const options = [
|
|
17
|
+
{ key: 'view', label: 'View Current Config' },
|
|
18
|
+
{ key: 'apikey', label: 'Set API Key' },
|
|
19
|
+
{ key: 'paths', label: 'View Data Paths' }
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
loadConfig();
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const loadConfig = () => {
|
|
27
|
+
const cfg = config.getUserConfig();
|
|
28
|
+
setCurrentConfig(cfg);
|
|
29
|
+
setApiKeyValue(cfg.api?.apiKey || '');
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
useInput((input, key) => {
|
|
33
|
+
if (editingApiKey) {
|
|
34
|
+
if (key.escape) {
|
|
35
|
+
setEditingApiKey(false);
|
|
36
|
+
loadConfig();
|
|
37
|
+
} else if (key.return) {
|
|
38
|
+
saveApiKey();
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (key.escape) {
|
|
44
|
+
onBack();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (key.upArrow) {
|
|
49
|
+
setSelectedOption(Math.max(0, selectedOption - 1));
|
|
50
|
+
} else if (key.downArrow) {
|
|
51
|
+
setSelectedOption(Math.min(options.length - 1, selectedOption + 1));
|
|
52
|
+
} else if (key.return) {
|
|
53
|
+
executeOption(options[selectedOption].key);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const executeOption = (optionKey) => {
|
|
58
|
+
setMessage(null);
|
|
59
|
+
|
|
60
|
+
if (optionKey === 'view') {
|
|
61
|
+
loadConfig();
|
|
62
|
+
setMessage('Config refreshed');
|
|
63
|
+
} else if (optionKey === 'apikey') {
|
|
64
|
+
setEditingApiKey(true);
|
|
65
|
+
} else if (optionKey === 'paths') {
|
|
66
|
+
const paths = config.getPaths();
|
|
67
|
+
setMessage(`Data: ${paths.dataDir}`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const saveApiKey = () => {
|
|
72
|
+
try {
|
|
73
|
+
config.setApiKey(apiKeyValue);
|
|
74
|
+
setEditingApiKey(false);
|
|
75
|
+
setMessage('✅ API Key saved successfully!');
|
|
76
|
+
loadConfig();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setMessage(`❌ Error: ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const maskApiKey = (key) => {
|
|
83
|
+
if (!key) return '(not set)';
|
|
84
|
+
if (key.length <= 8) return '****';
|
|
85
|
+
return key.slice(0, 4) + '****' + key.slice(-4);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return React.createElement(Box, { flexDirection: 'column' },
|
|
89
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
90
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, '⚙️ Configuration')
|
|
91
|
+
),
|
|
92
|
+
|
|
93
|
+
// Current config display
|
|
94
|
+
currentConfig && React.createElement(Box, {
|
|
95
|
+
flexDirection: 'column',
|
|
96
|
+
marginBottom: 1,
|
|
97
|
+
marginBottom: 1,
|
|
98
|
+
// Removed borderStyle
|
|
99
|
+
// paddingX: 1
|
|
100
|
+
},
|
|
101
|
+
// Use dim color for label
|
|
102
|
+
React.createElement(Text, { color: 'gray', dimColor: true }, ' Current Settings:'),
|
|
103
|
+
React.createElement(Text, null,
|
|
104
|
+
` API URL: ${currentConfig.api?.baseUrl || 'N/A'}`
|
|
105
|
+
),
|
|
106
|
+
React.createElement(Text, null,
|
|
107
|
+
` API Key: ${maskApiKey(currentConfig.api?.apiKey)}`
|
|
108
|
+
),
|
|
109
|
+
React.createElement(Text, null,
|
|
110
|
+
` Cache TTL: ${(currentConfig.cache?.ttl || 0) / 3600000}h`
|
|
111
|
+
)
|
|
112
|
+
),
|
|
113
|
+
|
|
114
|
+
// API Key editing mode
|
|
115
|
+
editingApiKey && React.createElement(Box, { flexDirection: 'column', marginBottom: 1 },
|
|
116
|
+
React.createElement(Text, { color: 'yellow' }, 'Enter new API Key:'),
|
|
117
|
+
React.createElement(Box, null,
|
|
118
|
+
React.createElement(Text, { color: 'gray' }, '> '),
|
|
119
|
+
React.createElement(TextInput, {
|
|
120
|
+
value: apiKeyValue,
|
|
121
|
+
onChange: setApiKeyValue,
|
|
122
|
+
placeholder: 'sk-xxxxxxxx...',
|
|
123
|
+
focus: true
|
|
124
|
+
})
|
|
125
|
+
),
|
|
126
|
+
React.createElement(Text, { color: 'gray', dimColor: true },
|
|
127
|
+
'Enter to save │ Esc to cancel'
|
|
128
|
+
)
|
|
129
|
+
),
|
|
130
|
+
|
|
131
|
+
// Options (when not editing)
|
|
132
|
+
!editingApiKey && React.createElement(Box, { flexDirection: 'column', marginBottom: 1 },
|
|
133
|
+
options.map((opt, idx) => {
|
|
134
|
+
const isSelected = idx === selectedOption;
|
|
135
|
+
return React.createElement(Box, { key: opt.key },
|
|
136
|
+
React.createElement(Text, {
|
|
137
|
+
color: isSelected ? 'cyan' : 'white',
|
|
138
|
+
inverse: isSelected
|
|
139
|
+
},
|
|
140
|
+
isSelected ? '▶ ' : ' '
|
|
141
|
+
),
|
|
142
|
+
React.createElement(Text, {
|
|
143
|
+
color: isSelected ? 'cyan' : 'white',
|
|
144
|
+
bold: isSelected
|
|
145
|
+
},
|
|
146
|
+
opt.label
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
})
|
|
150
|
+
),
|
|
151
|
+
|
|
152
|
+
// Message
|
|
153
|
+
message && React.createElement(Box, { marginTop: 1 },
|
|
154
|
+
React.createElement(Text, {
|
|
155
|
+
color: message.startsWith('✅') ? 'green' :
|
|
156
|
+
message.startsWith('❌') ? 'red' : 'gray'
|
|
157
|
+
}, message)
|
|
158
|
+
),
|
|
159
|
+
|
|
160
|
+
!editingApiKey && React.createElement(Box, { marginTop: 1 },
|
|
161
|
+
React.createElement(Text, { color: 'gray', dimColor: true },
|
|
162
|
+
'Up/Down Navigate │ Enter Select'
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = ConfigView;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// src/tui/DetailView.js - Skill Detail View Component
|
|
2
|
+
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { useState, useEffect } = React;
|
|
5
|
+
const { Box, Text, useInput } = require('ink');
|
|
6
|
+
const store = require('../store');
|
|
7
|
+
const api = require('../api');
|
|
8
|
+
|
|
9
|
+
function DetailView({ skill, onBack }) {
|
|
10
|
+
const [content, setContent] = useState(null);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
14
|
+
const visibleLines = 20;
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (skill) {
|
|
18
|
+
loadContent();
|
|
19
|
+
}
|
|
20
|
+
}, [skill]);
|
|
21
|
+
|
|
22
|
+
const loadContent = async () => {
|
|
23
|
+
setLoading(true);
|
|
24
|
+
setError(null);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
// Try local first
|
|
28
|
+
let doc = store.getDoc(skill.id);
|
|
29
|
+
|
|
30
|
+
if (!doc && skill.githubUrl) {
|
|
31
|
+
// Fetch from remote
|
|
32
|
+
doc = await api.fetchSkillContent(skill.githubUrl);
|
|
33
|
+
store.setDoc(skill.id, doc);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setContent(doc || 'No content available.');
|
|
37
|
+
} catch (err) {
|
|
38
|
+
setError(err.message);
|
|
39
|
+
setContent(null);
|
|
40
|
+
}
|
|
41
|
+
setLoading(false);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
useInput((input, key) => {
|
|
45
|
+
if (key.escape || input === 'b') {
|
|
46
|
+
onBack();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lines = (content || '').split('\n');
|
|
51
|
+
const maxScroll = Math.max(0, lines.length - visibleLines);
|
|
52
|
+
|
|
53
|
+
if (key.upArrow) {
|
|
54
|
+
setScrollOffset(Math.max(0, scrollOffset - 1));
|
|
55
|
+
} else if (key.downArrow) {
|
|
56
|
+
setScrollOffset(Math.min(maxScroll, scrollOffset + 1));
|
|
57
|
+
} else if (key.pageUp) {
|
|
58
|
+
setScrollOffset(Math.max(0, scrollOffset - visibleLines));
|
|
59
|
+
} else if (key.pageDown) {
|
|
60
|
+
setScrollOffset(Math.min(maxScroll, scrollOffset + visibleLines));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!skill) {
|
|
65
|
+
return React.createElement(Text, { color: 'red' }, '❌ No skill selected');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (loading) {
|
|
69
|
+
return React.createElement(Box, { flexDirection: 'column' },
|
|
70
|
+
React.createElement(Text, { color: 'yellow' },
|
|
71
|
+
`⏳ Loading ${skill.id}...`
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (error) {
|
|
77
|
+
return React.createElement(Box, { flexDirection: 'column' },
|
|
78
|
+
React.createElement(Text, { color: 'red' }, `❌ Error: ${error}`),
|
|
79
|
+
React.createElement(Text, { color: 'gray' }, 'Press Esc or "b" to go back')
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const lines = content.split('\n');
|
|
84
|
+
const visibleContent = lines.slice(scrollOffset, scrollOffset + visibleLines);
|
|
85
|
+
|
|
86
|
+
return React.createElement(Box, { flexDirection: 'column' },
|
|
87
|
+
// Header
|
|
88
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
89
|
+
React.createElement(Text, { bold: true, color: 'cyan' },
|
|
90
|
+
`📄 ${skill.name || skill.id}`
|
|
91
|
+
),
|
|
92
|
+
React.createElement(Text, { color: 'gray' },
|
|
93
|
+
` (${scrollOffset + 1}-${Math.min(scrollOffset + visibleLines, lines.length)}/${lines.length})`
|
|
94
|
+
)
|
|
95
|
+
),
|
|
96
|
+
|
|
97
|
+
// Content
|
|
98
|
+
React.createElement(Box, { flexDirection: 'column' },
|
|
99
|
+
visibleContent.map((line, idx) => {
|
|
100
|
+
// Simple markdown highlighting
|
|
101
|
+
let color = 'white';
|
|
102
|
+
let bold = false;
|
|
103
|
+
|
|
104
|
+
if (line.startsWith('# ')) {
|
|
105
|
+
color = 'magenta';
|
|
106
|
+
bold = true;
|
|
107
|
+
} else if (line.startsWith('## ')) {
|
|
108
|
+
color = 'green';
|
|
109
|
+
bold = true;
|
|
110
|
+
} else if (line.startsWith('### ')) {
|
|
111
|
+
color = 'cyan';
|
|
112
|
+
bold = true;
|
|
113
|
+
} else if (line.startsWith('```')) {
|
|
114
|
+
color = 'yellow';
|
|
115
|
+
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
|
116
|
+
color = 'white';
|
|
117
|
+
} else if (line.startsWith('>')) {
|
|
118
|
+
color = 'gray';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return React.createElement(Text, {
|
|
122
|
+
key: idx,
|
|
123
|
+
color,
|
|
124
|
+
bold,
|
|
125
|
+
wrap: 'truncate-end'
|
|
126
|
+
}, line || ' ');
|
|
127
|
+
})
|
|
128
|
+
),
|
|
129
|
+
|
|
130
|
+
// Footer
|
|
131
|
+
React.createElement(Box, { marginTop: 1 },
|
|
132
|
+
React.createElement(Text, { color: 'gray', dimColor: true },
|
|
133
|
+
'Up/Down Scroll │ PgUp/PgDn │ Esc/b Back'
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = DetailView;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// src/tui/DualPane.js - Dual Column Results Display
|
|
2
|
+
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { useState } = React;
|
|
5
|
+
const { Box, Text, useInput } = require('ink');
|
|
6
|
+
const store = require('../store');
|
|
7
|
+
|
|
8
|
+
function SkillItem({ skill, isSelected, isLocal }) {
|
|
9
|
+
const hasCache = isLocal && store.getDoc(skill.id) !== null;
|
|
10
|
+
|
|
11
|
+
return React.createElement(Box, null,
|
|
12
|
+
React.createElement(Text, {
|
|
13
|
+
color: isSelected ? 'cyan' : 'white',
|
|
14
|
+
bold: isSelected,
|
|
15
|
+
inverse: isSelected
|
|
16
|
+
}, isSelected ? ' ▶ ' : ' '),
|
|
17
|
+
React.createElement(Text, {
|
|
18
|
+
color: isSelected ? 'cyan' : (isLocal ? 'green' : 'yellow')
|
|
19
|
+
}, (skill.name || skill.id).slice(0, 25).padEnd(25)),
|
|
20
|
+
hasCache && React.createElement(Text, { color: 'green' }, ' ✓')
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function DualPane({
|
|
25
|
+
localSkills,
|
|
26
|
+
remoteSkills,
|
|
27
|
+
activePane, // 'local' or 'remote'
|
|
28
|
+
localIndex,
|
|
29
|
+
remoteIndex,
|
|
30
|
+
onSelect,
|
|
31
|
+
onPaneChange,
|
|
32
|
+
isActive
|
|
33
|
+
}) {
|
|
34
|
+
const visibleCount = 8;
|
|
35
|
+
|
|
36
|
+
// Calculate scroll offsets
|
|
37
|
+
const localOffset = Math.max(0, localIndex - visibleCount + 1);
|
|
38
|
+
const remoteOffset = Math.max(0, remoteIndex - visibleCount + 1);
|
|
39
|
+
|
|
40
|
+
const visibleLocal = localSkills.slice(localOffset, localOffset + visibleCount);
|
|
41
|
+
const visibleRemote = remoteSkills.slice(remoteOffset, remoteOffset + visibleCount);
|
|
42
|
+
|
|
43
|
+
return React.createElement(Box, { flexDirection: 'row', flexGrow: 1 },
|
|
44
|
+
// Left Pane - Local Skills
|
|
45
|
+
React.createElement(Box, {
|
|
46
|
+
flexDirection: 'column',
|
|
47
|
+
width: '50%',
|
|
48
|
+
borderStyle: 'single',
|
|
49
|
+
borderColor: activePane === 'local' ? 'cyan' : 'gray',
|
|
50
|
+
paddingX: 1
|
|
51
|
+
},
|
|
52
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
53
|
+
React.createElement(Text, {
|
|
54
|
+
bold: true,
|
|
55
|
+
color: activePane === 'local' ? 'cyan' : 'white'
|
|
56
|
+
}, '📚 LOCAL SKILLS'),
|
|
57
|
+
React.createElement(Text, { color: 'gray' }, ` (${localSkills.length})`)
|
|
58
|
+
),
|
|
59
|
+
|
|
60
|
+
localSkills.length === 0
|
|
61
|
+
? React.createElement(Text, { color: 'gray', dimColor: true }, 'No local skills found')
|
|
62
|
+
: visibleLocal.map((skill, idx) =>
|
|
63
|
+
React.createElement(SkillItem, {
|
|
64
|
+
key: skill.id,
|
|
65
|
+
skill: skill,
|
|
66
|
+
isSelected: activePane === 'local' && (localOffset + idx) === localIndex,
|
|
67
|
+
isLocal: true
|
|
68
|
+
})
|
|
69
|
+
),
|
|
70
|
+
|
|
71
|
+
localSkills.length > visibleCount && React.createElement(Box, { marginTop: 1 },
|
|
72
|
+
React.createElement(Text, { color: 'gray', dimColor: true },
|
|
73
|
+
`${localIndex + 1}/${localSkills.length}`
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
),
|
|
77
|
+
|
|
78
|
+
// Right Pane - Remote Skills
|
|
79
|
+
React.createElement(Box, {
|
|
80
|
+
flexDirection: 'column',
|
|
81
|
+
width: '50%',
|
|
82
|
+
borderStyle: 'single',
|
|
83
|
+
borderColor: activePane === 'remote' ? 'cyan' : 'gray',
|
|
84
|
+
paddingX: 1
|
|
85
|
+
},
|
|
86
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
87
|
+
React.createElement(Text, {
|
|
88
|
+
bold: true,
|
|
89
|
+
color: activePane === 'remote' ? 'cyan' : 'white'
|
|
90
|
+
}, '🌐 REMOTE SKILLS'),
|
|
91
|
+
React.createElement(Text, { color: 'gray' }, ` (${remoteSkills.length})`)
|
|
92
|
+
),
|
|
93
|
+
|
|
94
|
+
remoteSkills.length === 0
|
|
95
|
+
? React.createElement(Text, { color: 'gray', dimColor: true }, 'Search to find remote skills')
|
|
96
|
+
: visibleRemote.map((skill, idx) =>
|
|
97
|
+
React.createElement(SkillItem, {
|
|
98
|
+
key: skill.id,
|
|
99
|
+
skill: skill,
|
|
100
|
+
isSelected: activePane === 'remote' && (remoteOffset + idx) === remoteIndex,
|
|
101
|
+
isLocal: false
|
|
102
|
+
})
|
|
103
|
+
),
|
|
104
|
+
|
|
105
|
+
remoteSkills.length > visibleCount && React.createElement(Box, { marginTop: 1 },
|
|
106
|
+
React.createElement(Text, { color: 'gray', dimColor: true },
|
|
107
|
+
`${remoteIndex + 1}/${remoteSkills.length}`
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = DualPane;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// src/tui/PrimaryView.js - Primary Directory Selection View Component
|
|
2
|
+
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { useState, useEffect } = React;
|
|
5
|
+
const { Box, Text, useInput } = require('ink');
|
|
6
|
+
const config = require('../config');
|
|
7
|
+
const theme = require('../theme');
|
|
8
|
+
const { scanForSkillDirectories } = require('../localCrawler');
|
|
9
|
+
|
|
10
|
+
function PrimaryView({ onBack, onDirChange }) {
|
|
11
|
+
const [currentDir, setCurrentDir] = useState(config.getPrimaryDirName());
|
|
12
|
+
const [selectedOption, setSelectedOption] = useState(0);
|
|
13
|
+
const [message, setMessage] = useState(null);
|
|
14
|
+
const [availableDirs, setAvailableDirs] = useState(config.getAvailablePrimaryDirs());
|
|
15
|
+
|
|
16
|
+
const t = theme.getTheme(); // Get current theme colors
|
|
17
|
+
|
|
18
|
+
// Load extra directories found on disk
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let mounted = true;
|
|
21
|
+
|
|
22
|
+
const loadDirs = async () => {
|
|
23
|
+
try {
|
|
24
|
+
const foundDirs = await scanForSkillDirectories();
|
|
25
|
+
if (!mounted) return;
|
|
26
|
+
|
|
27
|
+
setAvailableDirs(prev => {
|
|
28
|
+
const next = [...prev];
|
|
29
|
+
const existingKeys = new Set(prev.map(d => d.key));
|
|
30
|
+
let changed = false;
|
|
31
|
+
|
|
32
|
+
foundDirs.forEach(dirName => {
|
|
33
|
+
// config.js assumes primary dirs start with '.', so we only support those for now
|
|
34
|
+
if (!dirName.startsWith('.')) return;
|
|
35
|
+
|
|
36
|
+
const key = dirName.substring(1);
|
|
37
|
+
if (!existingKeys.has(key)) {
|
|
38
|
+
next.push({
|
|
39
|
+
key: key,
|
|
40
|
+
name: dirName,
|
|
41
|
+
desc: 'Found local directory'
|
|
42
|
+
});
|
|
43
|
+
changed = true;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return changed ? next : prev;
|
|
48
|
+
});
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Ignore errors
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
loadDirs();
|
|
55
|
+
|
|
56
|
+
return () => { mounted = false; };
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// Set initial selection to current directory
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const idx = availableDirs.findIndex(d => d.key === currentDir);
|
|
62
|
+
|
|
63
|
+
if (idx >= 0) {
|
|
64
|
+
setSelectedOption(idx);
|
|
65
|
+
}
|
|
66
|
+
}, [currentDir]);
|
|
67
|
+
|
|
68
|
+
useInput((input, key) => {
|
|
69
|
+
if (key.escape) {
|
|
70
|
+
onBack();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (key.upArrow) {
|
|
75
|
+
setSelectedOption(Math.max(0, selectedOption - 1));
|
|
76
|
+
} else if (key.downArrow) {
|
|
77
|
+
setSelectedOption(Math.min(availableDirs.length - 1, selectedOption + 1));
|
|
78
|
+
} else if (key.return) {
|
|
79
|
+
const selected = availableDirs[selectedOption];
|
|
80
|
+
if (selected.key !== currentDir) {
|
|
81
|
+
config.setPrimaryDir(selected.key);
|
|
82
|
+
setCurrentDir(selected.key);
|
|
83
|
+
setMessage(`✅ Primary directory changed to ${selected.name}`);
|
|
84
|
+
if (onDirChange) {
|
|
85
|
+
onDirChange(selected.key);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
setMessage(`Already using ${selected.name} as primary directory.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return React.createElement(Box, { flexDirection: 'column' },
|
|
94
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
95
|
+
React.createElement(Text, { bold: true, color: t.primary }, '📁 Primary Directory Settings')
|
|
96
|
+
),
|
|
97
|
+
|
|
98
|
+
// Current directory display
|
|
99
|
+
React.createElement(Box, {
|
|
100
|
+
flexDirection: 'column',
|
|
101
|
+
marginBottom: 1
|
|
102
|
+
},
|
|
103
|
+
React.createElement(Text, { color: t.textDim },
|
|
104
|
+
` Current: ~/${availableDirs.find(d => d.key === currentDir)?.name || '.' + currentDir}`
|
|
105
|
+
)
|
|
106
|
+
),
|
|
107
|
+
|
|
108
|
+
// Info box
|
|
109
|
+
React.createElement(Box, {
|
|
110
|
+
flexDirection: 'column',
|
|
111
|
+
marginBottom: 1,
|
|
112
|
+
paddingX: 1
|
|
113
|
+
},
|
|
114
|
+
React.createElement(Text, { color: t.textDim },
|
|
115
|
+
'The primary directory is where synced skills are stored and'
|
|
116
|
+
),
|
|
117
|
+
React.createElement(Text, { color: t.textDim },
|
|
118
|
+
'displayed first in the local list.'
|
|
119
|
+
)
|
|
120
|
+
),
|
|
121
|
+
|
|
122
|
+
// Directory options
|
|
123
|
+
React.createElement(Box, { flexDirection: 'column', marginBottom: 1 },
|
|
124
|
+
React.createElement(Text, { color: t.textDim, marginBottom: 1 }, 'Select primary directory:'),
|
|
125
|
+
availableDirs.map((dirOption, idx) => {
|
|
126
|
+
const isSelected = idx === selectedOption;
|
|
127
|
+
const isCurrent = dirOption.key === currentDir;
|
|
128
|
+
|
|
129
|
+
return React.createElement(Box, { key: dirOption.key },
|
|
130
|
+
React.createElement(Text, {
|
|
131
|
+
color: isSelected ? t.selected : t.text,
|
|
132
|
+
inverse: isSelected
|
|
133
|
+
},
|
|
134
|
+
isSelected ? '▶ ' : ' '
|
|
135
|
+
),
|
|
136
|
+
React.createElement(Text, {
|
|
137
|
+
color: isSelected ? t.selected : t.text,
|
|
138
|
+
bold: isSelected
|
|
139
|
+
},
|
|
140
|
+
dirOption.name.padEnd(16)
|
|
141
|
+
),
|
|
142
|
+
React.createElement(Text, { color: t.textDim }, dirOption.desc),
|
|
143
|
+
isCurrent && React.createElement(Text, { color: t.success }, ' (current)')
|
|
144
|
+
);
|
|
145
|
+
})
|
|
146
|
+
),
|
|
147
|
+
|
|
148
|
+
// Message
|
|
149
|
+
message && React.createElement(Box, { marginTop: 1 },
|
|
150
|
+
React.createElement(Text, {
|
|
151
|
+
color: message.startsWith('✅') ? t.success : t.textDim
|
|
152
|
+
}, message)
|
|
153
|
+
),
|
|
154
|
+
|
|
155
|
+
React.createElement(Box, { marginTop: 1 },
|
|
156
|
+
React.createElement(Text, { color: t.textDim, dimColor: true },
|
|
157
|
+
'Up/Down Navigate │ Enter Select │ Esc Back'
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = PrimaryView;
|