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
package/src/tui/App.js
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
// src/tui/App.js - Main TUI Application (Force Full Redraw)
|
|
2
|
+
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { useState, useEffect, useRef } = React;
|
|
5
|
+
const { Box, Text, useApp, useInput, useStdout } = require('ink');
|
|
6
|
+
const TextInput = require('ink-text-input').default;
|
|
7
|
+
|
|
8
|
+
const { searchSkills, listSkills } = require('../matcher');
|
|
9
|
+
const api = require('../api');
|
|
10
|
+
const config = require('../config');
|
|
11
|
+
const theme = require('../theme');
|
|
12
|
+
const SyncView = require('./SyncView');
|
|
13
|
+
const ConfigView = require('./ConfigView');
|
|
14
|
+
const ThemeView = require('./ThemeView');
|
|
15
|
+
const PrimaryView = require('./PrimaryView');
|
|
16
|
+
const AddDelView = require('./AddDelView');
|
|
17
|
+
const ActionModal = require('./ActionModal');
|
|
18
|
+
const { performAction } = require('../actions');
|
|
19
|
+
const { exec } = require('child_process');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
|
|
22
|
+
// Page size for pagination
|
|
23
|
+
const PAGE_SIZE = 20;
|
|
24
|
+
|
|
25
|
+
// Pixel Art Logo for "SKILL SEARCH" - 8-bit retro style
|
|
26
|
+
// Single line "SKILL SEARCH" with pixel art aesthetic
|
|
27
|
+
const LOGO = [
|
|
28
|
+
' \u2588\u2588\u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588 \u2588',
|
|
29
|
+
' \u2588 \u2588\u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588',
|
|
30
|
+
' \u2588\u2588\u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588 \u2588 \u2588\u2588\u2588',
|
|
31
|
+
' \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588',
|
|
32
|
+
' \u2588\u2588\u2588 \u2588 \u2588 \u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588\u2588\u2588 \u2588 \u2588 \u2588 \u2588 \u2588\u2588\u2588 \u2588 \u2588'
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const COMMANDS = [
|
|
36
|
+
{ key: 'list', label: '/list', desc: 'Show all local & remote cached skills' },
|
|
37
|
+
{ key: 'refresh', label: '/refresh', desc: 'Force refresh local/remote cache' },
|
|
38
|
+
{ key: 'sync', label: '/sync', desc: 'Sync skills from different places' },
|
|
39
|
+
{ key: 'config', label: '/config', desc: 'Configure Remote API settings' },
|
|
40
|
+
{ key: 'theme', label: '/theme', desc: 'Switch between dark/light themes' },
|
|
41
|
+
{ key: 'adddel', label: '/add&del', desc: 'Manage custom skill paths' },
|
|
42
|
+
{ key: 'primary', label: '/primary', desc: 'Set primary skills directory' },
|
|
43
|
+
{ key: 'quit', label: '/quit', desc: 'Exit' }
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function App() {
|
|
47
|
+
const { exit } = useApp();
|
|
48
|
+
const { stdout } = useStdout();
|
|
49
|
+
const renderCount = useRef(0);
|
|
50
|
+
|
|
51
|
+
const [mode, setMode] = useState('idle'); // idle, command, search
|
|
52
|
+
const [query, setQuery] = useState('');
|
|
53
|
+
const [cmdIdx, setCmdIdx] = useState(0);
|
|
54
|
+
const [localResults, setLocalResults] = useState([]);
|
|
55
|
+
const [remoteResults, setRemoteResults] = useState([]);
|
|
56
|
+
const [selIdx, setSelIdx] = useState(0);
|
|
57
|
+
const [selLevel, setSelLevel] = useState(2); // 1 = level 1 (provider/repo), 2 = level 2 (skill)
|
|
58
|
+
const [pane, setPane] = useState('local');
|
|
59
|
+
const [activeView, setActiveView] = useState(null);
|
|
60
|
+
const [localPage, setLocalPage] = useState(0);
|
|
61
|
+
const [remotePage, setRemotePage] = useState(0);
|
|
62
|
+
const [focusView, setFocusView] = useState('search'); // 'search', 'results'
|
|
63
|
+
const [currentTheme, setCurrentTheme] = useState(() => theme.getTheme()); // Theme state
|
|
64
|
+
const [statusMsg, setStatusMsg] = useState(''); // Status feedback message
|
|
65
|
+
const [termWidth, setTermWidth] = useState(stdout?.columns || 120); // Terminal width for responsiveness
|
|
66
|
+
|
|
67
|
+
// Get theme colors helper
|
|
68
|
+
const t = currentTheme;
|
|
69
|
+
|
|
70
|
+
// Clear screen when mode changes to prevent ghosting/afterimage effects
|
|
71
|
+
const prevModeRef = useRef(mode);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (prevModeRef.current !== mode) {
|
|
74
|
+
// Clear screen: move cursor to top-left and clear entire screen
|
|
75
|
+
if (stdout && stdout.write) {
|
|
76
|
+
stdout.write('\x1b[2J\x1b[H');
|
|
77
|
+
}
|
|
78
|
+
prevModeRef.current = mode;
|
|
79
|
+
}
|
|
80
|
+
}, [mode, stdout]);
|
|
81
|
+
|
|
82
|
+
// Open URL or folder using system command
|
|
83
|
+
const openPath = (path) => {
|
|
84
|
+
if (!path) return;
|
|
85
|
+
const platform = os.platform();
|
|
86
|
+
let cmd;
|
|
87
|
+
if (platform === 'win32') {
|
|
88
|
+
cmd = `start "" "${path}"`;
|
|
89
|
+
} else if (platform === 'darwin') {
|
|
90
|
+
cmd = `open "${path}"`;
|
|
91
|
+
} else {
|
|
92
|
+
cmd = `xdg-open "${path}"`;
|
|
93
|
+
}
|
|
94
|
+
exec(cmd, (err) => { if (err) console.error(err); });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Handle input query change
|
|
98
|
+
const handleChange = (val) => {
|
|
99
|
+
if (activeView || actionModal) return; // Block input when modal or view is active
|
|
100
|
+
|
|
101
|
+
// CRITICAL: Filter out ALL mouse event escape sequences and control characters
|
|
102
|
+
// These can appear from terminal mouse tracking even if we don't enable it
|
|
103
|
+
// Patterns: ESC[<...M, ESC[<...m, ESC[M..., and any ESC sequences
|
|
104
|
+
if (val.includes('\x1b') || val.includes('[<') || /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(val)) {
|
|
105
|
+
// Remove all escape sequences
|
|
106
|
+
let cleanVal = val
|
|
107
|
+
.replace(/\x1b\[[^a-zA-Z]*[a-zA-Z]/g, '') // ANSI CSI sequences
|
|
108
|
+
.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, '') // SGR mouse events
|
|
109
|
+
.replace(/\x1b\[M.{3}/g, '') // Legacy mouse events
|
|
110
|
+
.replace(/\[\<\d+;\d+;\d+[Mm]/g, '') // Partial SGR (missing ESC)
|
|
111
|
+
.replace(/\x1b/g, '') // Any remaining ESC
|
|
112
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // Control chars except \t\n\r
|
|
113
|
+
|
|
114
|
+
if (cleanVal === query || cleanVal === '') return;
|
|
115
|
+
val = cleanVal;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Filter out pagination and action keys (Shift+O, [, ]) to prevent typing them
|
|
119
|
+
// AND importantly, prevent switching focus back to search if we are in results
|
|
120
|
+
if (mode === 'search' && (val.endsWith('[') || val.endsWith(']') || val.endsWith('O'))) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setFocusView('search'); // Typing standard characters focuses search
|
|
125
|
+
setStatusMsg(''); // Clear status on input
|
|
126
|
+
|
|
127
|
+
// Filter out pagination and action keys when in search mode
|
|
128
|
+
if (mode === 'search' && (val.endsWith('[') || val.endsWith(']') || val.endsWith('O'))) {
|
|
129
|
+
// Don't update query with these characters, they're for actions
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (val === '/' && query === '') {
|
|
134
|
+
setMode('command');
|
|
135
|
+
setQuery('/');
|
|
136
|
+
setCmdIdx(0);
|
|
137
|
+
} else if (mode === 'command' && val.startsWith('/')) {
|
|
138
|
+
setQuery(val);
|
|
139
|
+
} else if (mode === 'command' && !val.startsWith('/')) {
|
|
140
|
+
setMode('idle');
|
|
141
|
+
setQuery(val);
|
|
142
|
+
} else {
|
|
143
|
+
setQuery(val);
|
|
144
|
+
if (val.length >= 2) {
|
|
145
|
+
setMode('search');
|
|
146
|
+
doLocalSearch(val); // Only local search real-time
|
|
147
|
+
// Do NOT clear remote results here, keep them until Enter is pressed
|
|
148
|
+
} else {
|
|
149
|
+
setMode('idle');
|
|
150
|
+
setLocalResults([]);
|
|
151
|
+
// Do NOT clear remote results even if query is short or empty
|
|
152
|
+
// This keeps the last search results visible until a new search is triggered
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Local search - real-time as user types
|
|
158
|
+
const doLocalSearch = async (q) => {
|
|
159
|
+
try {
|
|
160
|
+
const local = await searchSkills(q);
|
|
161
|
+
const formatted = formatResults(local); // Use shared formatter
|
|
162
|
+
setLocalResults(formatted);
|
|
163
|
+
} catch (e) { }
|
|
164
|
+
};
|
|
165
|
+
// Remote search - only on Enter
|
|
166
|
+
const doRemoteSearch = async (q) => {
|
|
167
|
+
try {
|
|
168
|
+
const limit = config.getUserConfig().sync.defaultLimit || 20;
|
|
169
|
+
const res = await api.searchSkills(q, { limit: 100 }); // Fetch more results to support pagination
|
|
170
|
+
setRemoteResults(res.skills || []);
|
|
171
|
+
} catch (e) { }
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const [actionModal, setActionModal] = useState(null); // { item, pane, level }
|
|
175
|
+
|
|
176
|
+
const handleAction = async (action, item, param) => {
|
|
177
|
+
if (action === 'open') {
|
|
178
|
+
// Re-use existing open logic defined in useInput or refactor it into a shared function
|
|
179
|
+
// For consistency, let's just use the logic we had.
|
|
180
|
+
// But the modal expects us to handle it.
|
|
181
|
+
if (pane === 'local') {
|
|
182
|
+
if (selLevel === 1 && item.path) {
|
|
183
|
+
const providerPath = item.path.split(/[\\/][^\\/]*skill[^\\/]*[\\/]/i)[0];
|
|
184
|
+
openPath(providerPath);
|
|
185
|
+
} else if (selLevel === 2 && item.path) {
|
|
186
|
+
openPath(item.path);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
const url = item.githubUrl || item.url || '';
|
|
190
|
+
if (selLevel === 1) {
|
|
191
|
+
const match = url.match(/github\.com\/([^\/]+\/[^\/]+)/);
|
|
192
|
+
if (match) openPath(`https://github.com/${match[1]}`);
|
|
193
|
+
} else if (selLevel === 2) {
|
|
194
|
+
openPath(url);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { success: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// For other actions (Sync, Move, Delete)
|
|
201
|
+
// We need 'targetPath' for move. 'param' from modal is the input value.
|
|
202
|
+
|
|
203
|
+
const extra = {};
|
|
204
|
+
if (action === 'move') extra.targetPath = param;
|
|
205
|
+
if (action === 'sync' && pane === 'remote' && selLevel === 1) {
|
|
206
|
+
extra.allRemoteSkills = remoteResults;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const result = await performAction(action, item, pane, selLevel, extra);
|
|
210
|
+
|
|
211
|
+
if (result.success) {
|
|
212
|
+
// Refresh if needed - but delay it slightly to avoid interfering with modal state
|
|
213
|
+
if (action === 'delete' || action === 'move' || (action === 'sync' && pane === 'remote')) {
|
|
214
|
+
// Use setTimeout to ensure the modal result view renders first
|
|
215
|
+
setTimeout(() => {
|
|
216
|
+
listSkills(true).then(skills => {
|
|
217
|
+
const results = formatResults(skills);
|
|
218
|
+
setLocalResults(results);
|
|
219
|
+
}).catch(() => { });
|
|
220
|
+
}, 100);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
useInput((input, key) => {
|
|
228
|
+
if (activeView || actionModal) return; // Allow sub-views to handle input
|
|
229
|
+
|
|
230
|
+
if (key.escape) {
|
|
231
|
+
if (mode !== 'idle') {
|
|
232
|
+
setMode('idle');
|
|
233
|
+
setQuery('');
|
|
234
|
+
setLocalResults([]);
|
|
235
|
+
setRemoteResults([]);
|
|
236
|
+
setFocusView('search'); // Reset focus to search box on idle
|
|
237
|
+
setStatusMsg('');
|
|
238
|
+
} else {
|
|
239
|
+
exit();
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (mode === 'command') {
|
|
245
|
+
const filter = query.slice(1);
|
|
246
|
+
const cmds = COMMANDS.filter(c => c.key.includes(filter));
|
|
247
|
+
|
|
248
|
+
if (key.downArrow) setCmdIdx(Math.min(cmdIdx + 1, cmds.length - 1));
|
|
249
|
+
if (key.upArrow) setCmdIdx(Math.max(0, cmdIdx - 1));
|
|
250
|
+
|
|
251
|
+
if (key.return && cmds[cmdIdx]) {
|
|
252
|
+
const cmd = cmds[cmdIdx];
|
|
253
|
+
if (cmd.key === 'quit') exit();
|
|
254
|
+
if (cmd.key === 'sync') setActiveView('sync');
|
|
255
|
+
if (cmd.key === 'config') setActiveView('config');
|
|
256
|
+
if (cmd.key === 'theme') setActiveView('theme');
|
|
257
|
+
if (cmd.key === 'primary') setActiveView('primary');
|
|
258
|
+
if (cmd.key === 'adddel') setActiveView('adddel');
|
|
259
|
+
if (cmd.key === 'refresh') {
|
|
260
|
+
listSkills(true).catch(() => { });
|
|
261
|
+
setLocalResults([]);
|
|
262
|
+
setRemoteResults([]);
|
|
263
|
+
setMode('idle');
|
|
264
|
+
setQuery('');
|
|
265
|
+
setStatusMsg('Refreshed skill cache');
|
|
266
|
+
}
|
|
267
|
+
if (cmd.key === 'list') {
|
|
268
|
+
// Show all local and cached skills using listSkills
|
|
269
|
+
setMode('search');
|
|
270
|
+
listSkills().then(skills => {
|
|
271
|
+
const results = formatResults(skills);
|
|
272
|
+
setLocalResults(results);
|
|
273
|
+
setLocalPage(0);
|
|
274
|
+
}).catch(() => { });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (cmd.key !== 'list' && cmd.key !== 'add') {
|
|
278
|
+
setMode('idle');
|
|
279
|
+
setQuery('');
|
|
280
|
+
} else if (cmd.key === 'list') {
|
|
281
|
+
setQuery('');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (mode === 'search') {
|
|
287
|
+
if (key.tab || key.downArrow || key.upArrow || key.leftArrow || key.rightArrow) {
|
|
288
|
+
setFocusView('results');
|
|
289
|
+
}
|
|
290
|
+
if (key.tab) setPane(p => p === 'local' ? 'remote' : 'local');
|
|
291
|
+
|
|
292
|
+
// Smart navigation for Level 1: jump between provider groups
|
|
293
|
+
if (key.downArrow) {
|
|
294
|
+
if (selLevel === 1 && pane === 'local' && localResults.length > 0) {
|
|
295
|
+
// Find next provider group
|
|
296
|
+
const currentProvider = localResults[selIdx]?.provider;
|
|
297
|
+
let nextIdx = selIdx + 1;
|
|
298
|
+
while (nextIdx < localResults.length && localResults[nextIdx].provider === currentProvider) {
|
|
299
|
+
nextIdx++;
|
|
300
|
+
}
|
|
301
|
+
if (nextIdx < localResults.length) {
|
|
302
|
+
setSelIdx(nextIdx);
|
|
303
|
+
const newPage = Math.floor(nextIdx / PAGE_SIZE);
|
|
304
|
+
if (newPage !== localPage) setLocalPage(newPage);
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
setSelIdx(i => i + 1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (key.upArrow) {
|
|
311
|
+
if (selLevel === 1 && pane === 'local' && localResults.length > 0 && selIdx > 0) {
|
|
312
|
+
// Find first item of current provider group
|
|
313
|
+
const currentProvider = localResults[selIdx]?.provider;
|
|
314
|
+
let firstOfCurrentGroup = selIdx;
|
|
315
|
+
while (firstOfCurrentGroup > 0 && localResults[firstOfCurrentGroup - 1].provider === currentProvider) {
|
|
316
|
+
firstOfCurrentGroup--;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (firstOfCurrentGroup === selIdx && selIdx > 0) {
|
|
320
|
+
// Already at first of group, go to first of previous group
|
|
321
|
+
const prevProvider = localResults[selIdx - 1]?.provider;
|
|
322
|
+
let prevGroupStart = selIdx - 1;
|
|
323
|
+
while (prevGroupStart > 0 && localResults[prevGroupStart - 1].provider === prevProvider) {
|
|
324
|
+
prevGroupStart--;
|
|
325
|
+
}
|
|
326
|
+
setSelIdx(prevGroupStart);
|
|
327
|
+
const newPage = Math.floor(prevGroupStart / PAGE_SIZE);
|
|
328
|
+
if (newPage !== localPage) setLocalPage(newPage);
|
|
329
|
+
} else {
|
|
330
|
+
// Jump to first of current group
|
|
331
|
+
setSelIdx(firstOfCurrentGroup);
|
|
332
|
+
const newPage = Math.floor(firstOfCurrentGroup / PAGE_SIZE);
|
|
333
|
+
if (newPage !== localPage) setLocalPage(newPage);
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
setSelIdx(i => Math.max(0, i - 1));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (key.leftArrow) setSelLevel(1); // Select level 1 (provider/repo)
|
|
340
|
+
if (key.rightArrow) setSelLevel(2); // Select level 2 (skill)
|
|
341
|
+
if (key.return) {
|
|
342
|
+
// Trigger remote search OR open action modal depending on focus
|
|
343
|
+
if (focusView === 'search') {
|
|
344
|
+
if (query.length >= 2) {
|
|
345
|
+
doRemoteSearch(query);
|
|
346
|
+
setFocusView('results');
|
|
347
|
+
}
|
|
348
|
+
} else if (focusView === 'results') {
|
|
349
|
+
// Get selected item
|
|
350
|
+
const results = pane === 'local' ? localResults : remoteResults;
|
|
351
|
+
const page = pane === 'local' ? localPage : remotePage;
|
|
352
|
+
const idx = page * PAGE_SIZE + (selIdx % PAGE_SIZE);
|
|
353
|
+
const item = results[idx];
|
|
354
|
+
|
|
355
|
+
if (item) {
|
|
356
|
+
setActionModal({ item, pane, level: selLevel });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Shift+O to open selected item
|
|
361
|
+
if (input === 'O') {
|
|
362
|
+
const results = pane === 'local' ? localResults : remoteResults;
|
|
363
|
+
const page = pane === 'local' ? localPage : remotePage;
|
|
364
|
+
const idx = page * PAGE_SIZE + (selIdx % PAGE_SIZE);
|
|
365
|
+
const item = results[idx];
|
|
366
|
+
if (item) {
|
|
367
|
+
if (pane === 'local') {
|
|
368
|
+
// Local: open folder
|
|
369
|
+
if (selLevel === 1 && item.path) {
|
|
370
|
+
// Open provider folder (parent of skills)
|
|
371
|
+
const providerPath = item.path.split(/[\\/][^\\/]*skill[^\\/]*[\\/]/i)[0];
|
|
372
|
+
openPath(providerPath);
|
|
373
|
+
} else if (selLevel === 2 && item.path) {
|
|
374
|
+
// Open skill folder
|
|
375
|
+
openPath(item.path);
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
// Remote: open GitHub link
|
|
379
|
+
const url = item.githubUrl || item.url || '';
|
|
380
|
+
if (selLevel === 1) {
|
|
381
|
+
// Open repo main page
|
|
382
|
+
const match = url.match(/github\.com\/([^\/]+\/[^\/]+)/);
|
|
383
|
+
if (match) openPath(`https://github.com/${match[1]}`);
|
|
384
|
+
} else if (selLevel === 2) {
|
|
385
|
+
// Open skill page
|
|
386
|
+
openPath(url);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Pagination controls: [ and ] for prev/next
|
|
392
|
+
if (input === '[') {
|
|
393
|
+
if (pane === 'local') setLocalPage(p => Math.max(0, p - 1));
|
|
394
|
+
else setRemotePage(p => Math.max(0, p - 1));
|
|
395
|
+
}
|
|
396
|
+
if (input === ']') {
|
|
397
|
+
if (pane === 'local') {
|
|
398
|
+
const maxPage = Math.ceil(localResults.length / PAGE_SIZE) - 1;
|
|
399
|
+
setLocalPage(p => Math.min(maxPage, p + 1));
|
|
400
|
+
} else {
|
|
401
|
+
const maxPage = Math.ceil(remoteResults.length / PAGE_SIZE) - 1;
|
|
402
|
+
setRemotePage(p => Math.min(maxPage, p + 1));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const filteredCmds = COMMANDS.filter(c => c.key.includes(query.slice(1)));
|
|
409
|
+
|
|
410
|
+
// activeView is now handled inside the main Box return
|
|
411
|
+
// if (activeView === 'sync') ... removed
|
|
412
|
+
// if (activeView === 'config') ... removed
|
|
413
|
+
|
|
414
|
+
return React.createElement(Box, {
|
|
415
|
+
flexDirection: 'column',
|
|
416
|
+
paddingX: 2,
|
|
417
|
+
paddingY: 1,
|
|
418
|
+
// Ensure content starts at the top
|
|
419
|
+
height: '100%',
|
|
420
|
+
justifyContent: 'flex-start'
|
|
421
|
+
},
|
|
422
|
+
// Top Row: Logo + Search Box
|
|
423
|
+
React.createElement(Box, { flexDirection: 'row', width: '100%', marginBottom: 1, alignItems: 'center' },
|
|
424
|
+
// Left: Logo
|
|
425
|
+
React.createElement(Box, { flexDirection: 'column', width: '50%', paddingRight: 1, alignItems: 'center', justifyContent: 'center' },
|
|
426
|
+
LOGO.map((line, i) =>
|
|
427
|
+
React.createElement(Text, { key: i, color: t.logo, bold: true, dimColor: !!actionModal }, line)
|
|
428
|
+
),
|
|
429
|
+
React.createElement(Text, { color: t.logo, bold: true, dimColor: !!actionModal, marginTop: 0 }, '--- SKILL SEARCH ---')
|
|
430
|
+
|
|
431
|
+
),
|
|
432
|
+
// Right: Search Box
|
|
433
|
+
React.createElement(Box, { flexDirection: 'column', width: '50%', paddingLeft: 1 },
|
|
434
|
+
React.createElement(Box, {
|
|
435
|
+
borderStyle: 'single',
|
|
436
|
+
borderColor: actionModal ? t.textDim : (focusView === 'search' ? t.border : t.borderInactive), // Dynamic border visibility
|
|
437
|
+
paddingX: 1,
|
|
438
|
+
paddingY: 1,
|
|
439
|
+
width: '100%',
|
|
440
|
+
flexDirection: 'column',
|
|
441
|
+
height: 7
|
|
442
|
+
},
|
|
443
|
+
React.createElement(TextInput, {
|
|
444
|
+
value: query,
|
|
445
|
+
onChange: handleChange,
|
|
446
|
+
placeholder: 'Search...',
|
|
447
|
+
focus: !activeView && !actionModal,
|
|
448
|
+
showCursor: true
|
|
449
|
+
}),
|
|
450
|
+
|
|
451
|
+
React.createElement(Box, { flexGrow: 1 }),
|
|
452
|
+
|
|
453
|
+
React.createElement(Box, {
|
|
454
|
+
flexDirection: 'row',
|
|
455
|
+
justifyContent: 'space-between'
|
|
456
|
+
},
|
|
457
|
+
React.createElement(Text, { color: statusMsg ? t.accent : t.textDim, dimColor: focusView !== 'search' || !!actionModal }, statusMsg || 'Search Local/Remote Skills'),
|
|
458
|
+
React.createElement(Text, { color: t.textDim, dimColor: focusView !== 'search' || !!actionModal }, '[Esc] Quit')
|
|
459
|
+
)
|
|
460
|
+
),
|
|
461
|
+
// Command menu
|
|
462
|
+
mode === 'command' && React.createElement(Box, {
|
|
463
|
+
flexDirection: 'column',
|
|
464
|
+
marginTop: 0,
|
|
465
|
+
marginLeft: 1
|
|
466
|
+
},
|
|
467
|
+
filteredCmds.map((c, i) => {
|
|
468
|
+
const isSelected = i === cmdIdx;
|
|
469
|
+
return React.createElement(Text, {
|
|
470
|
+
key: c.key,
|
|
471
|
+
color: isSelected ? 'black' : t.primary,
|
|
472
|
+
backgroundColor: isSelected ? t.bgHighlight : undefined
|
|
473
|
+
}, ` ${c.label.padEnd(12)} ${c.desc}`);
|
|
474
|
+
})
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
),
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
// Content Area: activeView (Sync/Config) OR Search Results
|
|
482
|
+
activeView === 'sync' && React.createElement(Box, { marginTop: 1, paddingX: 1, width: '100%' },
|
|
483
|
+
React.createElement(SyncView, { onBack: () => setActiveView(null) })
|
|
484
|
+
),
|
|
485
|
+
|
|
486
|
+
activeView === 'config' && React.createElement(Box, { marginTop: 1, paddingX: 1, width: '100%' },
|
|
487
|
+
React.createElement(ConfigView, { onBack: () => setActiveView(null) })
|
|
488
|
+
),
|
|
489
|
+
|
|
490
|
+
activeView === 'theme' && React.createElement(Box, { marginTop: 1, paddingX: 1, width: '100%' },
|
|
491
|
+
React.createElement(ThemeView, {
|
|
492
|
+
onBack: () => setActiveView(null),
|
|
493
|
+
onThemeChange: (newTheme) => {
|
|
494
|
+
setCurrentTheme(theme.getTheme());
|
|
495
|
+
// Force screen redraw
|
|
496
|
+
if (stdout && stdout.write) {
|
|
497
|
+
stdout.write('\x1b[2J\x1b[H');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
),
|
|
502
|
+
|
|
503
|
+
activeView === 'primary' && React.createElement(Box, { marginTop: 1, paddingX: 1, width: '100%' },
|
|
504
|
+
React.createElement(PrimaryView, {
|
|
505
|
+
onBack: () => setActiveView(null),
|
|
506
|
+
onDirChange: (newDir) => {
|
|
507
|
+
// Auto-refresh local skills list after changing primary directory
|
|
508
|
+
listSkills(true).then(skills => {
|
|
509
|
+
const results = formatResults(skills);
|
|
510
|
+
setLocalResults(results);
|
|
511
|
+
setLocalPage(0);
|
|
512
|
+
}).catch(() => { });
|
|
513
|
+
|
|
514
|
+
// Force screen redraw
|
|
515
|
+
if (stdout && stdout.write) {
|
|
516
|
+
stdout.write('\x1b[2J\x1b[H');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
})
|
|
520
|
+
),
|
|
521
|
+
|
|
522
|
+
activeView === 'adddel' && React.createElement(Box, { marginTop: 1, paddingX: 1, width: '100%' },
|
|
523
|
+
React.createElement(AddDelView, {
|
|
524
|
+
onBack: () => setActiveView(null),
|
|
525
|
+
onRefresh: () => {
|
|
526
|
+
listSkills(true).then(skills => {
|
|
527
|
+
const results = formatResults(skills);
|
|
528
|
+
setLocalResults(results);
|
|
529
|
+
setLocalPage(0);
|
|
530
|
+
}).catch(() => { });
|
|
531
|
+
|
|
532
|
+
// Force refresh
|
|
533
|
+
if (stdout && stdout.write) {
|
|
534
|
+
stdout.write('\x1b[2J\x1b[H');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
),
|
|
539
|
+
|
|
540
|
+
// Search results (only if no active view)
|
|
541
|
+
!activeView && mode === 'search' && React.createElement(Box, { flexDirection: 'row', marginTop: 1 },
|
|
542
|
+
// Local Column
|
|
543
|
+
React.createElement(Box, {
|
|
544
|
+
flexDirection: 'column',
|
|
545
|
+
width: '50%',
|
|
546
|
+
// Removed borderStyle and borderColor to eliminate vertical lines
|
|
547
|
+
},
|
|
548
|
+
React.createElement(Box, { justifyContent: 'center', borderStyle: undefined }, // Remove explicit border styling if any inner boxes had it
|
|
549
|
+
React.createElement(Text, { bold: true, color: t.localHighlight, dimColor: (focusView !== 'results' || pane !== 'local') || !!actionModal }, 'LOCAL')
|
|
550
|
+
),
|
|
551
|
+
React.createElement(Text, {}, ''),
|
|
552
|
+
localResults.length === 0
|
|
553
|
+
? React.createElement(Box, { justifyContent: 'center' }, React.createElement(Text, { color: t.textDim }, '(no results)'))
|
|
554
|
+
: localResults.slice(localPage * PAGE_SIZE, (localPage + 1) * PAGE_SIZE).map((s, i, arr) => {
|
|
555
|
+
const isRowSelected = pane === 'local' && i === selIdx % PAGE_SIZE;
|
|
556
|
+
const isLevel1Selected = isRowSelected && selLevel === 1;
|
|
557
|
+
const isLevel2Selected = isRowSelected && selLevel === 2;
|
|
558
|
+
const dateStr = s.updatedAt ? new Date(s.updatedAt).toISOString().split('T')[0] : '';
|
|
559
|
+
const name = s.name || s.id;
|
|
560
|
+
const maxLen = 80;
|
|
561
|
+
const dispName = name.length > maxLen ? name.slice(0, 38) + '...' + name.slice(-38) : name;
|
|
562
|
+
|
|
563
|
+
// Grouping Logic: Only show provider if different from previous
|
|
564
|
+
// OR if this row is selected at Level 1 (so the indicator is visible)
|
|
565
|
+
const prev = i > 0 ? arr[i - 1] : null;
|
|
566
|
+
const showProvider = i === 0 || (prev && prev.provider !== s.provider) || isLevel1Selected;
|
|
567
|
+
|
|
568
|
+
return React.createElement(Box, { key: s.id + i, flexDirection: 'column' },
|
|
569
|
+
showProvider && s.provider && React.createElement(Text, {
|
|
570
|
+
color: isLevel1Selected ? t.localHighlight : t.primary, // Theme colors
|
|
571
|
+
bold: isLevel1Selected,
|
|
572
|
+
dimColor: (!isLevel1Selected && (focusView !== 'results' || pane !== 'local')) || !!actionModal
|
|
573
|
+
}, (isLevel1Selected ? '> ' : ' ') + s.provider), // Add prefix indicator
|
|
574
|
+
React.createElement(Box, { justifyContent: 'space-between' },
|
|
575
|
+
React.createElement(Text, {
|
|
576
|
+
color: isLevel2Selected ? t.text : t.text, // Theme text color
|
|
577
|
+
bold: isLevel2Selected,
|
|
578
|
+
dimColor: (!isLevel2Selected && (focusView !== 'results' || pane !== 'local')) || !!actionModal
|
|
579
|
+
}, (isLevel2Selected ? ' > ' : ' ') + dispName), // Add prefix indicator for level 2
|
|
580
|
+
React.createElement(Text, { color: t.accent, dimColor: (focusView !== 'results' || pane !== 'local') || !!actionModal }, dateStr)
|
|
581
|
+
)
|
|
582
|
+
);
|
|
583
|
+
}),
|
|
584
|
+
localResults.length > PAGE_SIZE && React.createElement(Box, { justifyContent: 'flex-end', marginTop: 1, paddingRight: 1 },
|
|
585
|
+
React.createElement(Text, { color: t.textDim, dimColor: focusView !== 'results' || pane !== 'local' },
|
|
586
|
+
`[Prev] [${localPage + 1}/${Math.ceil(localResults.length / PAGE_SIZE)}] [Next]`
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
),
|
|
590
|
+
// Remote Column
|
|
591
|
+
React.createElement(Box, {
|
|
592
|
+
flexDirection: 'column',
|
|
593
|
+
width: '50%',
|
|
594
|
+
paddingLeft: 1,
|
|
595
|
+
// Removed borderStyle and borderColor to eliminate vertical lines
|
|
596
|
+
},
|
|
597
|
+
React.createElement(Box, { justifyContent: 'center' },
|
|
598
|
+
React.createElement(Text, { bold: true, color: t.remoteHighlight, dimColor: (focusView !== 'results' || pane !== 'remote') || !!actionModal }, 'REMOTE')
|
|
599
|
+
),
|
|
600
|
+
React.createElement(Text, {}, ''),
|
|
601
|
+
remoteResults.length === 0
|
|
602
|
+
? React.createElement(Box, { justifyContent: 'center' }, React.createElement(Text, { color: t.textDim }, '(/config before search)'))
|
|
603
|
+
: remoteResults.slice(remotePage * PAGE_SIZE, (remotePage + 1) * PAGE_SIZE).map((s, i) => {
|
|
604
|
+
const isRowSelected = pane === 'remote' && i === selIdx % PAGE_SIZE;
|
|
605
|
+
const isLevel1Selected = isRowSelected && selLevel === 1;
|
|
606
|
+
const isLevel2Selected = isRowSelected && selLevel === 2;
|
|
607
|
+
|
|
608
|
+
let level1 = 'GitHub';
|
|
609
|
+
let level2 = s.name || s.id;
|
|
610
|
+
const url = s.githubUrl || s.url || '';
|
|
611
|
+
const ownerRepoMatch = url.match(/github\.com\/([^\/]+\/[^\/]+)/);
|
|
612
|
+
const skillMatch = url.match(/\/skills\/([^\/]+)/);
|
|
613
|
+
if (ownerRepoMatch) level1 = ownerRepoMatch[1];
|
|
614
|
+
if (skillMatch) level2 = skillMatch[1];
|
|
615
|
+
|
|
616
|
+
let dateStr = '';
|
|
617
|
+
const timestamp = s.updatedAt || s.updated_at;
|
|
618
|
+
if (timestamp) {
|
|
619
|
+
const ms = timestamp > 9999999999 ? timestamp : timestamp * 1000;
|
|
620
|
+
const d = new Date(ms);
|
|
621
|
+
if (!isNaN(d.getTime())) dateStr = d.toISOString().split('T')[0];
|
|
622
|
+
}
|
|
623
|
+
const starsFormatted = s.stars !== undefined ? s.stars.toLocaleString() + ' \u2605' : '';
|
|
624
|
+
const maxLen = 80;
|
|
625
|
+
const dispName = level2.length > maxLen ? level2.slice(0, 38) + '...' + level2.slice(-38) : level2;
|
|
626
|
+
|
|
627
|
+
let metaStr = '';
|
|
628
|
+
if (starsFormatted && dateStr) {
|
|
629
|
+
metaStr = `/ ${starsFormatted} / ${dateStr}`;
|
|
630
|
+
} else if (starsFormatted) {
|
|
631
|
+
metaStr = `/ ${starsFormatted}`;
|
|
632
|
+
} else if (dateStr) {
|
|
633
|
+
metaStr = `/ ${dateStr}`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return React.createElement(Box, { key: s.id + i, flexDirection: 'column' },
|
|
637
|
+
React.createElement(Text, {
|
|
638
|
+
color: isLevel1Selected ? t.remoteHighlight : t.primary, // Theme colors
|
|
639
|
+
bold: isLevel1Selected,
|
|
640
|
+
dimColor: (!isLevel1Selected && (focusView !== 'results' || pane !== 'remote')) || !!actionModal
|
|
641
|
+
}, (isLevel1Selected ? '> ' : ' ') + level1), // Add prefix indicator
|
|
642
|
+
React.createElement(Box, { justifyContent: 'space-between' },
|
|
643
|
+
React.createElement(Text, {
|
|
644
|
+
color: isLevel2Selected ? t.text : t.text, // Theme text color
|
|
645
|
+
bold: isLevel2Selected,
|
|
646
|
+
dimColor: (!isLevel2Selected && (focusView !== 'results' || pane !== 'remote')) || !!actionModal
|
|
647
|
+
}, (isLevel2Selected ? ' > ' : ' ') + dispName), // Add prefix indicator for level 2
|
|
648
|
+
React.createElement(Text, { color: t.accent, dimColor: (focusView !== 'results' || pane !== 'remote') || !!actionModal }, metaStr)
|
|
649
|
+
)
|
|
650
|
+
);
|
|
651
|
+
}),
|
|
652
|
+
remoteResults.length > PAGE_SIZE && React.createElement(Box, { justifyContent: 'flex-end', marginTop: 1 },
|
|
653
|
+
React.createElement(Text, { color: t.textDim },
|
|
654
|
+
`[Prev] [${remotePage + 1}/${Math.ceil(remoteResults.length / PAGE_SIZE)}] [Next]`
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
),
|
|
659
|
+
|
|
660
|
+
!activeView && mode === 'search' && React.createElement(Box, { marginTop: 1 },
|
|
661
|
+
React.createElement(Text, { color: t.primary, dimColor: !!actionModal }, 'Tab/Arrows: select / L/R: level / Enter: action / Shift+O: open / ][: page / Esc: back')
|
|
662
|
+
),
|
|
663
|
+
|
|
664
|
+
actionModal && React.createElement(Box, {
|
|
665
|
+
key: 'action-modal',
|
|
666
|
+
position: 'absolute',
|
|
667
|
+
width: '100%',
|
|
668
|
+
height: '100%',
|
|
669
|
+
alignItems: 'center',
|
|
670
|
+
justifyContent: 'center',
|
|
671
|
+
zIndex: 99
|
|
672
|
+
},
|
|
673
|
+
React.createElement(ActionModal, {
|
|
674
|
+
item: actionModal.item,
|
|
675
|
+
pane: actionModal.pane,
|
|
676
|
+
level: actionModal.level,
|
|
677
|
+
onAction: handleAction,
|
|
678
|
+
onClose: () => setActionModal(null)
|
|
679
|
+
})
|
|
680
|
+
)
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Helper to format results for display
|
|
685
|
+
function formatResults(skills) {
|
|
686
|
+
let results = skills || [];
|
|
687
|
+
const primaryDirName = '.' + config.getPrimaryDirName();
|
|
688
|
+
|
|
689
|
+
results = results.map(s => {
|
|
690
|
+
const isPrimary = s.path && s.path.includes(primaryDirName);
|
|
691
|
+
|
|
692
|
+
// Custom Path Logic: Check source or other indicators
|
|
693
|
+
if (s.source === 'custom_path' && s.path) {
|
|
694
|
+
const parts = s.path.split(/[\\/]/).filter(Boolean);
|
|
695
|
+
if (parts.length >= 3) {
|
|
696
|
+
// e.g. .../antigravity/skills/skill-name
|
|
697
|
+
// skill-name is the skill folder
|
|
698
|
+
// parent is 'skills'
|
|
699
|
+
// grandparent is 'antigravity'
|
|
700
|
+
// We want provider to be 'antigravity/skills'
|
|
701
|
+
const parent = parts[parts.length - 2];
|
|
702
|
+
const grantParent = parts[parts.length - 3];
|
|
703
|
+
return {
|
|
704
|
+
...s,
|
|
705
|
+
provider: `${grantParent}/${parent}`,
|
|
706
|
+
name: s.path.split(/[\\/]/).pop(),
|
|
707
|
+
updatedAt: s.updatedAt || s.syncedAt
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
...s,
|
|
714
|
+
provider: isPrimary ? primaryDirName : s.provider,
|
|
715
|
+
name: s.path ? s.path.split(/[\\/]/).pop() : (s.name || s.id),
|
|
716
|
+
updatedAt: s.updatedAt || s.syncedAt
|
|
717
|
+
};
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
results.sort((a, b) => {
|
|
721
|
+
const aIsPrimary = a.path && a.path.includes(primaryDirName);
|
|
722
|
+
const bIsPrimary = b.path && b.path.includes(primaryDirName);
|
|
723
|
+
if (aIsPrimary && !bIsPrimary) return -1;
|
|
724
|
+
if (!aIsPrimary && bIsPrimary) return 1;
|
|
725
|
+
|
|
726
|
+
const pA = (a.provider || '').toLowerCase();
|
|
727
|
+
const pB = (b.provider || '').toLowerCase();
|
|
728
|
+
if (pA < pB) return -1;
|
|
729
|
+
if (pA > pB) return 1;
|
|
730
|
+
|
|
731
|
+
const nA = (a.name || '').toLowerCase();
|
|
732
|
+
const nB = (b.name || '').toLowerCase();
|
|
733
|
+
return nA < nB ? -1 : 1;
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
return results;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
module.exports = App;
|