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/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;