promptgraph-mcp 2.8.1 → 2.8.3
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 +205 -205
- package/ann.js +33 -33
- package/api.js +202 -202
- package/bundle-counts.js +111 -111
- package/chunker.js +28 -28
- package/cli.js +115 -115
- package/commands/bundle.js +150 -150
- package/commands/doctor.js +15 -15
- package/commands/import.js +7 -7
- package/commands/init.js +37 -37
- package/commands/marketplace.js +146 -146
- package/commands/reindex.js +10 -10
- package/commands/search.js +55 -55
- package/commands/setup.js +19 -19
- package/commands/status.js +110 -110
- package/commands/train.js +18 -18
- package/commands/update.js +49 -49
- package/commands/validate.js +63 -63
- package/config.js +72 -72
- package/db.js +157 -157
- package/doctor.js +48 -48
- package/embedder.js +54 -54
- package/github-import.js +750 -745
- package/indexer.js +310 -310
- package/package.json +61 -61
- package/parser.js +69 -69
- package/pg-hook.js +70 -70
- package/platform.js +120 -120
- package/search.js +216 -216
- package/src/filter/classifier.js +88 -88
- package/src/filter/hard-filter.js +62 -62
- package/src/filter/train.js +66 -66
- package/src/reranker/reranker.js +92 -92
- package/src/store/flat-store.js +61 -61
- package/src/store/hnsw-store.js +187 -187
- package/src/store/index.js +19 -19
- package/src/store/vector-store.js +9 -9
- package/src/utils/rate-limiter.js +33 -33
- package/tui.js +418 -418
- package/validate-repo-action.js +139 -139
- package/watcher.js +84 -84
package/tui.js
CHANGED
|
@@ -1,418 +1,418 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Interactive marketplace TUI — nano-style keyboard navigation
|
|
3
|
-
* Arrow keys, search, install, categories — all in one screen
|
|
4
|
-
*/
|
|
5
|
-
import readline from 'readline';
|
|
6
|
-
import chalk from 'chalk';
|
|
7
|
-
import process from 'process';
|
|
8
|
-
|
|
9
|
-
const ESC = '\x1b';
|
|
10
|
-
const HIDE = '\x1b[?25l';
|
|
11
|
-
const SHOW = '\x1b[?25h';
|
|
12
|
-
const HOME = '\x1b[H';
|
|
13
|
-
const CLEAR = '\x1b[2J\x1b[H';
|
|
14
|
-
const CLEAR_EOL = '\x1b[K';
|
|
15
|
-
|
|
16
|
-
const purple = chalk.hex('#7C3AED');
|
|
17
|
-
const dim = chalk.dim;
|
|
18
|
-
const bold = chalk.bold;
|
|
19
|
-
const cyan = chalk.cyan;
|
|
20
|
-
const yellow = chalk.yellow;
|
|
21
|
-
const green = chalk.green;
|
|
22
|
-
const red = chalk.red;
|
|
23
|
-
const blue = chalk.blue;
|
|
24
|
-
const white = chalk.white;
|
|
25
|
-
const magenta = chalk.magenta;
|
|
26
|
-
|
|
27
|
-
const CAT_ICON = { Engineering:'🛠', 'AI Tools':'🤖', Coding:'💻', Creative:'🎨', Security:'🔒', Community:'🌐' };
|
|
28
|
-
const SPINNER_FRAMES = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']; // braille spinner
|
|
29
|
-
|
|
30
|
-
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
function termSize() {
|
|
33
|
-
return { cols: process.stdout.columns || 100, rows: process.stdout.rows || 30 };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function write(s) { process.stdout.write(s); }
|
|
37
|
-
function moveTo(row, col) { write(`\x1b[${row};${col}H`); }
|
|
38
|
-
function clearLine() { write('\x1b[2K\r'); }
|
|
39
|
-
|
|
40
|
-
function truncate(s, n) {
|
|
41
|
-
s = String(s || '');
|
|
42
|
-
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ── build item list ───────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
function buildItems(skills, bundles) {
|
|
48
|
-
const items = [];
|
|
49
|
-
// skills
|
|
50
|
-
for (const s of skills) {
|
|
51
|
-
items.push({ type: 'skill', id: s.id, name: s.name || s.id, description: s.description || '', category: s.category || 'Community', tags: s.tags || [], stars: s.stars || 0, code: s.code });
|
|
52
|
-
}
|
|
53
|
-
// bundles
|
|
54
|
-
for (const b of bundles) {
|
|
55
|
-
items.push({ type: 'bundle', id: b.id, name: b.name || b.id, description: b.description || '', category: b.category || 'Community', tags: b.tags || [], stars: b.stars || 0, skillCount: b.skillCount, repo_url: b.repo_url, skills: b.skills });
|
|
56
|
-
}
|
|
57
|
-
return items;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function filterItems(items, query, tab) {
|
|
61
|
-
let filtered = items;
|
|
62
|
-
if (tab === 'skills') filtered = items.filter(i => i.type === 'skill');
|
|
63
|
-
if (tab === 'bundles') filtered = items.filter(i => i.type === 'bundle');
|
|
64
|
-
if (query) {
|
|
65
|
-
const q = query.toLowerCase();
|
|
66
|
-
filtered = filtered.filter(i =>
|
|
67
|
-
i.id.includes(q) || i.name.toLowerCase().includes(q) ||
|
|
68
|
-
i.description.toLowerCase().includes(q) ||
|
|
69
|
-
i.category.toLowerCase().includes(q) ||
|
|
70
|
-
(i.tags || []).some(t => t.includes(q))
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
return filtered;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── render ────────────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
function render(state, installedSet = new Set()) {
|
|
79
|
-
const { cols, rows } = termSize();
|
|
80
|
-
const HEADER_ROWS = 4;
|
|
81
|
-
const FOOTER_ROWS = 3;
|
|
82
|
-
const LIST_ROWS = rows - HEADER_ROWS - FOOTER_ROWS;
|
|
83
|
-
const NAME_W = Math.max(20, Math.floor(cols * 0.28));
|
|
84
|
-
const DESC_W = cols - NAME_W - 28;
|
|
85
|
-
|
|
86
|
-
const { items, cursor, scroll, query, searching, tab, status } = state;
|
|
87
|
-
const skills = items.filter(i => i.type === 'skill').length;
|
|
88
|
-
const bundles = items.filter(i => i.type === 'bundle').length;
|
|
89
|
-
|
|
90
|
-
write('\x1b[H'); // go home — screen already cleared on init, just reposition
|
|
91
|
-
|
|
92
|
-
// ── header ─────────────────────────────────────────────────────────────────
|
|
93
|
-
// Row 1: title bar
|
|
94
|
-
const titleText = ' ◆ PromptGraph Marketplace';
|
|
95
|
-
const tabParts = ['all', 'skills', 'bundles'].map(t =>
|
|
96
|
-
t === tab
|
|
97
|
-
? `\x1b[48;2;124;58;237m\x1b[97m ${t.toUpperCase()} \x1b[0m`
|
|
98
|
-
: dim(` ${t} `)
|
|
99
|
-
).join('');
|
|
100
|
-
const titleLine = purple.bold(titleText) + ' ' + tabParts;
|
|
101
|
-
write(titleLine + CLEAR_EOL + '\n');
|
|
102
|
-
|
|
103
|
-
// Row 2: counts
|
|
104
|
-
const countLine = dim(' ') +
|
|
105
|
-
(tab !== 'bundles' ? chalk.white(`${skills} skills`) + dim(' ') : '') +
|
|
106
|
-
(tab !== 'skills' ? chalk.blue(`${bundles} bundles`) : '') +
|
|
107
|
-
(query ? dim(' · filter: ') + cyan(query) : '');
|
|
108
|
-
write(countLine + CLEAR_EOL + '\n');
|
|
109
|
-
|
|
110
|
-
// Row 3: search bar
|
|
111
|
-
const searchLabel = searching ? green(' / ') : dim(' / ');
|
|
112
|
-
const cursor_blink = Math.floor(Date.now() / 500) % 2 ? '▌' : ' ';
|
|
113
|
-
const searchVal = searching
|
|
114
|
-
? white(query || '') + cursor_blink
|
|
115
|
-
: dim(query ? query : 'type / to search, Tab to switch view');
|
|
116
|
-
write(searchLabel + searchVal + CLEAR_EOL + '\n');
|
|
117
|
-
|
|
118
|
-
// Row 4: separator (with optional status inline or spinner)
|
|
119
|
-
if (status) {
|
|
120
|
-
let line;
|
|
121
|
-
if (status.ok === true) line = green(' ✓ ' + status.msg);
|
|
122
|
-
else if (status.ok === false) line = red(' ✗ ' + status.msg);
|
|
123
|
-
else line = cyan(' ⟳ ' + (status.msg || ''));
|
|
124
|
-
write(dim('─'.repeat(4)) + line + CLEAR_EOL + '\n');
|
|
125
|
-
} else {
|
|
126
|
-
write(dim('─'.repeat(cols)) + CLEAR_EOL + '\n');
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ── list ───────────────────────────────────────────────────────────────────
|
|
130
|
-
let lastCat = null;
|
|
131
|
-
let rendered = 0;
|
|
132
|
-
|
|
133
|
-
for (let i = scroll; i < items.length && rendered < LIST_ROWS; i++) {
|
|
134
|
-
const item = items[i];
|
|
135
|
-
const selected = i === cursor;
|
|
136
|
-
const bg = selected ? '\x1b[48;2;55;35;110m' : '';
|
|
137
|
-
const reset = '\x1b[0m';
|
|
138
|
-
|
|
139
|
-
// category header (only when ungrouped / mixed)
|
|
140
|
-
if (item.category !== lastCat) {
|
|
141
|
-
if (rendered >= LIST_ROWS) break;
|
|
142
|
-
const icon = CAT_ICON[item.category] || '📦';
|
|
143
|
-
write((selected ? bg : '') + ' ' + purple.bold(icon + ' ' + item.category) + reset + CLEAR_EOL + '\n');
|
|
144
|
-
lastCat = item.category;
|
|
145
|
-
rendered++;
|
|
146
|
-
if (rendered >= LIST_ROWS) break;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// item row
|
|
150
|
-
const isInstalled = installedSet.has(item.id) || (item.code && installedSet.has(item.code));
|
|
151
|
-
const arrow = selected ? cyan('▶') : ' ';
|
|
152
|
-
const type = item.type === 'bundle' ? blue('⊞') : dim('·');
|
|
153
|
-
const badge = isInstalled ? green('✓') : ' ';
|
|
154
|
-
const nameStr = truncate(item.name, NAME_W);
|
|
155
|
-
const namePad = nameStr.padEnd(NAME_W);
|
|
156
|
-
const nameCol = selected ? white.bold(namePad) : white(namePad);
|
|
157
|
-
const extra = item.type === 'bundle'
|
|
158
|
-
? item.skillCount
|
|
159
|
-
? blue((item.skillCount + ' sk').padEnd(8))
|
|
160
|
-
: item.repo_url
|
|
161
|
-
? chalk.hex('#3B82F6')('↗ GitHub')
|
|
162
|
-
: dim(((item.skills?.length || 0) + ' sk').padEnd(8))
|
|
163
|
-
: chalk.hex('#A78BFA')((item.code || '').padEnd(10));
|
|
164
|
-
const desc = dim(truncate(item.description, Math.max(10, DESC_W)));
|
|
165
|
-
|
|
166
|
-
write(bg + ` ${arrow} ${type} ${badge} ${nameCol} ${extra} ${desc}` + reset + CLEAR_EOL + '\n');
|
|
167
|
-
rendered++;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// fill empty rows
|
|
171
|
-
while (rendered < LIST_ROWS) {
|
|
172
|
-
write(CLEAR_EOL + '\n');
|
|
173
|
-
rendered++;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ── footer ─────────────────────────────────────────────────────────────────
|
|
177
|
-
write(dim('─'.repeat(cols)) + CLEAR_EOL + '\n');
|
|
178
|
-
const sel = items[cursor];
|
|
179
|
-
if (sel && !searching && !state.confirming) {
|
|
180
|
-
const isInst = installedSet.has(sel.id) || (sel.code && installedSet.has(sel.code));
|
|
181
|
-
const installCmd = sel.type === 'bundle' ? `bundle install ${sel.id}` : `install ${sel.code || sel.id}`;
|
|
182
|
-
const instLabel = isInst
|
|
183
|
-
? green(' ✓ installed') + dim(' ') + dim('d') + chalk.red(' remove') + dim(' ')
|
|
184
|
-
: dim(' Enter') + chalk.white(' install') + dim(' ');
|
|
185
|
-
write(instLabel + dim('Tab') + ' switch ' + dim('/') + ' search ' + dim('q') + ' quit' + CLEAR_EOL + '\n');
|
|
186
|
-
const ghUrl = sel.repo_url ? chalk.hex('#3B82F6')(` ↗ github.com/${sel.repo_url}`) : '';
|
|
187
|
-
write(dim(` → pg ${installCmd}`) + ghUrl + CLEAR_EOL + '\n');
|
|
188
|
-
} else if (state.confirming) {
|
|
189
|
-
write(chalk.red.bold(' Remove ') + chalk.white(state.confirming.name) + chalk.red('? ') +
|
|
190
|
-
chalk.white.bold('[y]') + chalk.gray('es ') + chalk.white.bold('[n]') + chalk.gray('o') + CLEAR_EOL + '\n');
|
|
191
|
-
write(CLEAR_EOL + '\n');
|
|
192
|
-
} else if (searching) {
|
|
193
|
-
write(dim(' Type to filter ') + cyan('Enter') + dim(' confirm ') + cyan('Esc') + dim(' cancel') + CLEAR_EOL + '\n');
|
|
194
|
-
write(CLEAR_EOL + '\n');
|
|
195
|
-
} else {
|
|
196
|
-
write(dim(' ↑↓ navigate Enter install d remove Tab switch / search q quit') + CLEAR_EOL + '\n');
|
|
197
|
-
write(CLEAR_EOL + '\n');
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ── clamp scroll ─────────────────────────────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
// Count how many screen rows items[start..end] occupy (including category headers)
|
|
204
|
-
function countVisibleRows(items, start, end) {
|
|
205
|
-
let rows = 0;
|
|
206
|
-
let lastCat = start > 0 ? items[start - 1]?.category : null;
|
|
207
|
-
for (let i = start; i < end && i < items.length; i++) {
|
|
208
|
-
if (items[i].category !== lastCat) { rows++; lastCat = items[i].category; }
|
|
209
|
-
rows++;
|
|
210
|
-
}
|
|
211
|
-
return rows;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function clampScroll(state) {
|
|
215
|
-
const { rows } = termSize();
|
|
216
|
-
const HEADER_ROWS = 4, FOOTER_ROWS = 3;
|
|
217
|
-
const LIST_ROWS = rows - HEADER_ROWS - FOOTER_ROWS;
|
|
218
|
-
const { cursor, items } = state;
|
|
219
|
-
|
|
220
|
-
if (state.scroll < 0) state.scroll = 0;
|
|
221
|
-
if (state.scroll > cursor) state.scroll = cursor;
|
|
222
|
-
|
|
223
|
-
// Check if cursor is visible from current scroll
|
|
224
|
-
const visibleRows = countVisibleRows(items, state.scroll, cursor + 1);
|
|
225
|
-
if (visibleRows > LIST_ROWS - 1) {
|
|
226
|
-
// Cursor is below visible area — scroll forward until it fits
|
|
227
|
-
while (state.scroll < cursor) {
|
|
228
|
-
state.scroll++;
|
|
229
|
-
const v = countVisibleRows(items, state.scroll, cursor + 1);
|
|
230
|
-
if (v <= LIST_ROWS - 1) break;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (state.scroll >= items.length) state.scroll = Math.max(0, items.length - 1);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ── main ─────────────────────────────────────────────────────────────────────
|
|
238
|
-
|
|
239
|
-
export async function runTUI(allSkills, allBundles, installFn, installedSet = new Set(), removeFn = async () => {}) {
|
|
240
|
-
const allItems = buildItems(allSkills, allBundles);
|
|
241
|
-
|
|
242
|
-
const state = {
|
|
243
|
-
tab: 'all',
|
|
244
|
-
query: '',
|
|
245
|
-
searching: false,
|
|
246
|
-
confirming: null, // { id, name, type, repoUrl }
|
|
247
|
-
cursor: 0,
|
|
248
|
-
scroll: 0,
|
|
249
|
-
items: allItems,
|
|
250
|
-
status: null,
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
function refresh(q, t) {
|
|
254
|
-
state.items = filterItems(allItems, q ?? state.query, t ?? state.tab);
|
|
255
|
-
if (state.cursor >= state.items.length) state.cursor = Math.max(0, state.items.length - 1);
|
|
256
|
-
clampScroll(state);
|
|
257
|
-
render(state, installedSet);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Setup terminal
|
|
261
|
-
write(HIDE + CLEAR + '\x1b[H\x1b[J');
|
|
262
|
-
readline.emitKeypressEvents(process.stdin);
|
|
263
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
264
|
-
|
|
265
|
-
function cleanup() {
|
|
266
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
267
|
-
write(SHOW + CLEAR);
|
|
268
|
-
process.stdin.pause();
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
process.on('SIGINT', cleanup);
|
|
272
|
-
process.on('exit', cleanup);
|
|
273
|
-
|
|
274
|
-
// Initial render
|
|
275
|
-
refresh();
|
|
276
|
-
|
|
277
|
-
// Status blink timer
|
|
278
|
-
let statusTimer = null;
|
|
279
|
-
function setStatus(ok, msg) {
|
|
280
|
-
state.status = { ok, msg };
|
|
281
|
-
clearTimeout(statusTimer);
|
|
282
|
-
render(state, installedSet);
|
|
283
|
-
statusTimer = setTimeout(() => { state.status = null; render(state, installedSet); }, 3000);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Keypress handler
|
|
287
|
-
process.stdin.on('keypress', async (ch, key) => {
|
|
288
|
-
if (!key) return;
|
|
289
|
-
|
|
290
|
-
if (state.searching) {
|
|
291
|
-
if (key.name === 'escape') {
|
|
292
|
-
state.searching = false;
|
|
293
|
-
state.query = '';
|
|
294
|
-
refresh();
|
|
295
|
-
} else if (key.name === 'return') {
|
|
296
|
-
state.searching = false;
|
|
297
|
-
refresh();
|
|
298
|
-
} else if (key.name === 'backspace') {
|
|
299
|
-
state.query = state.query.slice(0, -1);
|
|
300
|
-
refresh();
|
|
301
|
-
} else if (ch && !key.ctrl && !key.meta && ch.length === 1) {
|
|
302
|
-
state.query += ch;
|
|
303
|
-
refresh();
|
|
304
|
-
}
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Confirm-delete mode
|
|
309
|
-
if (state.confirming) {
|
|
310
|
-
if (ch === 'y' || ch === 'Y') {
|
|
311
|
-
const item = state.confirming;
|
|
312
|
-
state.confirming = null;
|
|
313
|
-
setStatus(null, `Removing ${item.name}…`);
|
|
314
|
-
try {
|
|
315
|
-
await removeFn(item);
|
|
316
|
-
installedSet.delete(item.id);
|
|
317
|
-
if (item.code) installedSet.delete(item.code);
|
|
318
|
-
setStatus(true, `Removed ${item.name}`);
|
|
319
|
-
} catch (e) {
|
|
320
|
-
setStatus(false, e.message.slice(0, 60));
|
|
321
|
-
}
|
|
322
|
-
} else {
|
|
323
|
-
state.confirming = null;
|
|
324
|
-
render(state, installedSet);
|
|
325
|
-
}
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Normal mode
|
|
330
|
-
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
|
331
|
-
cleanup();
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (key.name === 'slash' || ch === '/') {
|
|
336
|
-
state.searching = true;
|
|
337
|
-
render(state, installedSet);
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if (key.name === 'tab') {
|
|
342
|
-
const tabs = ['all', 'skills', 'bundles'];
|
|
343
|
-
state.tab = tabs[(tabs.indexOf(state.tab) + 1) % tabs.length];
|
|
344
|
-
state.cursor = 0;
|
|
345
|
-
state.scroll = 0;
|
|
346
|
-
refresh(state.query, state.tab);
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (key.name === 'up') {
|
|
351
|
-
if (state.cursor > 0) state.cursor--;
|
|
352
|
-
clampScroll(state);
|
|
353
|
-
render(state, installedSet);
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (key.name === 'down') {
|
|
358
|
-
if (state.cursor < state.items.length - 1) state.cursor++;
|
|
359
|
-
clampScroll(state);
|
|
360
|
-
render(state, installedSet);
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (key.name === 'pageup') {
|
|
365
|
-
state.cursor = Math.max(0, state.cursor - 10);
|
|
366
|
-
clampScroll(state);
|
|
367
|
-
render(state, installedSet);
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
if (key.name === 'pagedown') {
|
|
372
|
-
state.cursor = Math.min(state.items.length - 1, state.cursor + 10);
|
|
373
|
-
clampScroll(state);
|
|
374
|
-
render(state, installedSet);
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (key.name === 'home') { state.cursor = 0; state.scroll = 0; render(state, installedSet); return; }
|
|
379
|
-
if (key.name === 'end') { state.cursor = state.items.length - 1; clampScroll(state); render(state, installedSet); return; }
|
|
380
|
-
|
|
381
|
-
if (ch === 'd' || ch === 'D') {
|
|
382
|
-
const sel = state.items[state.cursor];
|
|
383
|
-
if (!sel) return;
|
|
384
|
-
const isInst = installedSet.has(sel.id) || (sel.code && installedSet.has(sel.code));
|
|
385
|
-
if (!isInst) { setStatus(false, 'Not installed'); return; }
|
|
386
|
-
state.confirming = { id: sel.id, name: sel.name, type: sel.type, code: sel.code, repo_url: sel.repo_url };
|
|
387
|
-
render(state, installedSet);
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (key.name === 'return' || key.name === 'i') {
|
|
392
|
-
const sel = state.items[state.cursor];
|
|
393
|
-
if (!sel) return;
|
|
394
|
-
// Fire install in background — TUI stays responsive for queuing more
|
|
395
|
-
installFn(sel, (ok, msg) => {
|
|
396
|
-
setStatus(ok, msg);
|
|
397
|
-
}).catch(e => {
|
|
398
|
-
setStatus(false, e.message?.slice(0, 60) || 'Install failed');
|
|
399
|
-
});
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (key.name === 'escape') {
|
|
404
|
-
state.query = '';
|
|
405
|
-
refresh();
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
// Resize handler
|
|
411
|
-
process.stdout.on('resize', () => { clampScroll(state); render(state, installedSet); });
|
|
412
|
-
|
|
413
|
-
// Keep alive
|
|
414
|
-
return new Promise(resolve => {
|
|
415
|
-
process.stdin.once('close', resolve);
|
|
416
|
-
process.on('SIGINT', resolve);
|
|
417
|
-
});
|
|
418
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Interactive marketplace TUI — nano-style keyboard navigation
|
|
3
|
+
* Arrow keys, search, install, categories — all in one screen
|
|
4
|
+
*/
|
|
5
|
+
import readline from 'readline';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import process from 'process';
|
|
8
|
+
|
|
9
|
+
const ESC = '\x1b';
|
|
10
|
+
const HIDE = '\x1b[?25l';
|
|
11
|
+
const SHOW = '\x1b[?25h';
|
|
12
|
+
const HOME = '\x1b[H';
|
|
13
|
+
const CLEAR = '\x1b[2J\x1b[H';
|
|
14
|
+
const CLEAR_EOL = '\x1b[K';
|
|
15
|
+
|
|
16
|
+
const purple = chalk.hex('#7C3AED');
|
|
17
|
+
const dim = chalk.dim;
|
|
18
|
+
const bold = chalk.bold;
|
|
19
|
+
const cyan = chalk.cyan;
|
|
20
|
+
const yellow = chalk.yellow;
|
|
21
|
+
const green = chalk.green;
|
|
22
|
+
const red = chalk.red;
|
|
23
|
+
const blue = chalk.blue;
|
|
24
|
+
const white = chalk.white;
|
|
25
|
+
const magenta = chalk.magenta;
|
|
26
|
+
|
|
27
|
+
const CAT_ICON = { Engineering:'🛠', 'AI Tools':'🤖', Coding:'💻', Creative:'🎨', Security:'🔒', Community:'🌐' };
|
|
28
|
+
const SPINNER_FRAMES = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']; // braille spinner
|
|
29
|
+
|
|
30
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function termSize() {
|
|
33
|
+
return { cols: process.stdout.columns || 100, rows: process.stdout.rows || 30 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function write(s) { process.stdout.write(s); }
|
|
37
|
+
function moveTo(row, col) { write(`\x1b[${row};${col}H`); }
|
|
38
|
+
function clearLine() { write('\x1b[2K\r'); }
|
|
39
|
+
|
|
40
|
+
function truncate(s, n) {
|
|
41
|
+
s = String(s || '');
|
|
42
|
+
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── build item list ───────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function buildItems(skills, bundles) {
|
|
48
|
+
const items = [];
|
|
49
|
+
// skills
|
|
50
|
+
for (const s of skills) {
|
|
51
|
+
items.push({ type: 'skill', id: s.id, name: s.name || s.id, description: s.description || '', category: s.category || 'Community', tags: s.tags || [], stars: s.stars || 0, code: s.code });
|
|
52
|
+
}
|
|
53
|
+
// bundles
|
|
54
|
+
for (const b of bundles) {
|
|
55
|
+
items.push({ type: 'bundle', id: b.id, name: b.name || b.id, description: b.description || '', category: b.category || 'Community', tags: b.tags || [], stars: b.stars || 0, skillCount: b.skillCount, repo_url: b.repo_url, skills: b.skills });
|
|
56
|
+
}
|
|
57
|
+
return items;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function filterItems(items, query, tab) {
|
|
61
|
+
let filtered = items;
|
|
62
|
+
if (tab === 'skills') filtered = items.filter(i => i.type === 'skill');
|
|
63
|
+
if (tab === 'bundles') filtered = items.filter(i => i.type === 'bundle');
|
|
64
|
+
if (query) {
|
|
65
|
+
const q = query.toLowerCase();
|
|
66
|
+
filtered = filtered.filter(i =>
|
|
67
|
+
i.id.includes(q) || i.name.toLowerCase().includes(q) ||
|
|
68
|
+
i.description.toLowerCase().includes(q) ||
|
|
69
|
+
i.category.toLowerCase().includes(q) ||
|
|
70
|
+
(i.tags || []).some(t => t.includes(q))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return filtered;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── render ────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function render(state, installedSet = new Set()) {
|
|
79
|
+
const { cols, rows } = termSize();
|
|
80
|
+
const HEADER_ROWS = 4;
|
|
81
|
+
const FOOTER_ROWS = 3;
|
|
82
|
+
const LIST_ROWS = rows - HEADER_ROWS - FOOTER_ROWS;
|
|
83
|
+
const NAME_W = Math.max(20, Math.floor(cols * 0.28));
|
|
84
|
+
const DESC_W = cols - NAME_W - 28;
|
|
85
|
+
|
|
86
|
+
const { items, cursor, scroll, query, searching, tab, status } = state;
|
|
87
|
+
const skills = items.filter(i => i.type === 'skill').length;
|
|
88
|
+
const bundles = items.filter(i => i.type === 'bundle').length;
|
|
89
|
+
|
|
90
|
+
write('\x1b[H'); // go home — screen already cleared on init, just reposition
|
|
91
|
+
|
|
92
|
+
// ── header ─────────────────────────────────────────────────────────────────
|
|
93
|
+
// Row 1: title bar
|
|
94
|
+
const titleText = ' ◆ PromptGraph Marketplace';
|
|
95
|
+
const tabParts = ['all', 'skills', 'bundles'].map(t =>
|
|
96
|
+
t === tab
|
|
97
|
+
? `\x1b[48;2;124;58;237m\x1b[97m ${t.toUpperCase()} \x1b[0m`
|
|
98
|
+
: dim(` ${t} `)
|
|
99
|
+
).join('');
|
|
100
|
+
const titleLine = purple.bold(titleText) + ' ' + tabParts;
|
|
101
|
+
write(titleLine + CLEAR_EOL + '\n');
|
|
102
|
+
|
|
103
|
+
// Row 2: counts
|
|
104
|
+
const countLine = dim(' ') +
|
|
105
|
+
(tab !== 'bundles' ? chalk.white(`${skills} skills`) + dim(' ') : '') +
|
|
106
|
+
(tab !== 'skills' ? chalk.blue(`${bundles} bundles`) : '') +
|
|
107
|
+
(query ? dim(' · filter: ') + cyan(query) : '');
|
|
108
|
+
write(countLine + CLEAR_EOL + '\n');
|
|
109
|
+
|
|
110
|
+
// Row 3: search bar
|
|
111
|
+
const searchLabel = searching ? green(' / ') : dim(' / ');
|
|
112
|
+
const cursor_blink = Math.floor(Date.now() / 500) % 2 ? '▌' : ' ';
|
|
113
|
+
const searchVal = searching
|
|
114
|
+
? white(query || '') + cursor_blink
|
|
115
|
+
: dim(query ? query : 'type / to search, Tab to switch view');
|
|
116
|
+
write(searchLabel + searchVal + CLEAR_EOL + '\n');
|
|
117
|
+
|
|
118
|
+
// Row 4: separator (with optional status inline or spinner)
|
|
119
|
+
if (status) {
|
|
120
|
+
let line;
|
|
121
|
+
if (status.ok === true) line = green(' ✓ ' + status.msg);
|
|
122
|
+
else if (status.ok === false) line = red(' ✗ ' + status.msg);
|
|
123
|
+
else line = cyan(' ⟳ ' + (status.msg || ''));
|
|
124
|
+
write(dim('─'.repeat(4)) + line + CLEAR_EOL + '\n');
|
|
125
|
+
} else {
|
|
126
|
+
write(dim('─'.repeat(cols)) + CLEAR_EOL + '\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── list ───────────────────────────────────────────────────────────────────
|
|
130
|
+
let lastCat = null;
|
|
131
|
+
let rendered = 0;
|
|
132
|
+
|
|
133
|
+
for (let i = scroll; i < items.length && rendered < LIST_ROWS; i++) {
|
|
134
|
+
const item = items[i];
|
|
135
|
+
const selected = i === cursor;
|
|
136
|
+
const bg = selected ? '\x1b[48;2;55;35;110m' : '';
|
|
137
|
+
const reset = '\x1b[0m';
|
|
138
|
+
|
|
139
|
+
// category header (only when ungrouped / mixed)
|
|
140
|
+
if (item.category !== lastCat) {
|
|
141
|
+
if (rendered >= LIST_ROWS) break;
|
|
142
|
+
const icon = CAT_ICON[item.category] || '📦';
|
|
143
|
+
write((selected ? bg : '') + ' ' + purple.bold(icon + ' ' + item.category) + reset + CLEAR_EOL + '\n');
|
|
144
|
+
lastCat = item.category;
|
|
145
|
+
rendered++;
|
|
146
|
+
if (rendered >= LIST_ROWS) break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// item row
|
|
150
|
+
const isInstalled = installedSet.has(item.id) || (item.code && installedSet.has(item.code));
|
|
151
|
+
const arrow = selected ? cyan('▶') : ' ';
|
|
152
|
+
const type = item.type === 'bundle' ? blue('⊞') : dim('·');
|
|
153
|
+
const badge = isInstalled ? green('✓') : ' ';
|
|
154
|
+
const nameStr = truncate(item.name, NAME_W);
|
|
155
|
+
const namePad = nameStr.padEnd(NAME_W);
|
|
156
|
+
const nameCol = selected ? white.bold(namePad) : white(namePad);
|
|
157
|
+
const extra = item.type === 'bundle'
|
|
158
|
+
? item.skillCount
|
|
159
|
+
? blue((item.skillCount + ' sk').padEnd(8))
|
|
160
|
+
: item.repo_url
|
|
161
|
+
? chalk.hex('#3B82F6')('↗ GitHub')
|
|
162
|
+
: dim(((item.skills?.length || 0) + ' sk').padEnd(8))
|
|
163
|
+
: chalk.hex('#A78BFA')((item.code || '').padEnd(10));
|
|
164
|
+
const desc = dim(truncate(item.description, Math.max(10, DESC_W)));
|
|
165
|
+
|
|
166
|
+
write(bg + ` ${arrow} ${type} ${badge} ${nameCol} ${extra} ${desc}` + reset + CLEAR_EOL + '\n');
|
|
167
|
+
rendered++;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// fill empty rows
|
|
171
|
+
while (rendered < LIST_ROWS) {
|
|
172
|
+
write(CLEAR_EOL + '\n');
|
|
173
|
+
rendered++;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── footer ─────────────────────────────────────────────────────────────────
|
|
177
|
+
write(dim('─'.repeat(cols)) + CLEAR_EOL + '\n');
|
|
178
|
+
const sel = items[cursor];
|
|
179
|
+
if (sel && !searching && !state.confirming) {
|
|
180
|
+
const isInst = installedSet.has(sel.id) || (sel.code && installedSet.has(sel.code));
|
|
181
|
+
const installCmd = sel.type === 'bundle' ? `bundle install ${sel.id}` : `install ${sel.code || sel.id}`;
|
|
182
|
+
const instLabel = isInst
|
|
183
|
+
? green(' ✓ installed') + dim(' ') + dim('d') + chalk.red(' remove') + dim(' ')
|
|
184
|
+
: dim(' Enter') + chalk.white(' install') + dim(' ');
|
|
185
|
+
write(instLabel + dim('Tab') + ' switch ' + dim('/') + ' search ' + dim('q') + ' quit' + CLEAR_EOL + '\n');
|
|
186
|
+
const ghUrl = sel.repo_url ? chalk.hex('#3B82F6')(` ↗ github.com/${sel.repo_url}`) : '';
|
|
187
|
+
write(dim(` → pg ${installCmd}`) + ghUrl + CLEAR_EOL + '\n');
|
|
188
|
+
} else if (state.confirming) {
|
|
189
|
+
write(chalk.red.bold(' Remove ') + chalk.white(state.confirming.name) + chalk.red('? ') +
|
|
190
|
+
chalk.white.bold('[y]') + chalk.gray('es ') + chalk.white.bold('[n]') + chalk.gray('o') + CLEAR_EOL + '\n');
|
|
191
|
+
write(CLEAR_EOL + '\n');
|
|
192
|
+
} else if (searching) {
|
|
193
|
+
write(dim(' Type to filter ') + cyan('Enter') + dim(' confirm ') + cyan('Esc') + dim(' cancel') + CLEAR_EOL + '\n');
|
|
194
|
+
write(CLEAR_EOL + '\n');
|
|
195
|
+
} else {
|
|
196
|
+
write(dim(' ↑↓ navigate Enter install d remove Tab switch / search q quit') + CLEAR_EOL + '\n');
|
|
197
|
+
write(CLEAR_EOL + '\n');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── clamp scroll ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
// Count how many screen rows items[start..end] occupy (including category headers)
|
|
204
|
+
function countVisibleRows(items, start, end) {
|
|
205
|
+
let rows = 0;
|
|
206
|
+
let lastCat = start > 0 ? items[start - 1]?.category : null;
|
|
207
|
+
for (let i = start; i < end && i < items.length; i++) {
|
|
208
|
+
if (items[i].category !== lastCat) { rows++; lastCat = items[i].category; }
|
|
209
|
+
rows++;
|
|
210
|
+
}
|
|
211
|
+
return rows;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function clampScroll(state) {
|
|
215
|
+
const { rows } = termSize();
|
|
216
|
+
const HEADER_ROWS = 4, FOOTER_ROWS = 3;
|
|
217
|
+
const LIST_ROWS = rows - HEADER_ROWS - FOOTER_ROWS;
|
|
218
|
+
const { cursor, items } = state;
|
|
219
|
+
|
|
220
|
+
if (state.scroll < 0) state.scroll = 0;
|
|
221
|
+
if (state.scroll > cursor) state.scroll = cursor;
|
|
222
|
+
|
|
223
|
+
// Check if cursor is visible from current scroll
|
|
224
|
+
const visibleRows = countVisibleRows(items, state.scroll, cursor + 1);
|
|
225
|
+
if (visibleRows > LIST_ROWS - 1) {
|
|
226
|
+
// Cursor is below visible area — scroll forward until it fits
|
|
227
|
+
while (state.scroll < cursor) {
|
|
228
|
+
state.scroll++;
|
|
229
|
+
const v = countVisibleRows(items, state.scroll, cursor + 1);
|
|
230
|
+
if (v <= LIST_ROWS - 1) break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (state.scroll >= items.length) state.scroll = Math.max(0, items.length - 1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── main ─────────────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
export async function runTUI(allSkills, allBundles, installFn, installedSet = new Set(), removeFn = async () => {}) {
|
|
240
|
+
const allItems = buildItems(allSkills, allBundles);
|
|
241
|
+
|
|
242
|
+
const state = {
|
|
243
|
+
tab: 'all',
|
|
244
|
+
query: '',
|
|
245
|
+
searching: false,
|
|
246
|
+
confirming: null, // { id, name, type, repoUrl }
|
|
247
|
+
cursor: 0,
|
|
248
|
+
scroll: 0,
|
|
249
|
+
items: allItems,
|
|
250
|
+
status: null,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
function refresh(q, t) {
|
|
254
|
+
state.items = filterItems(allItems, q ?? state.query, t ?? state.tab);
|
|
255
|
+
if (state.cursor >= state.items.length) state.cursor = Math.max(0, state.items.length - 1);
|
|
256
|
+
clampScroll(state);
|
|
257
|
+
render(state, installedSet);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Setup terminal
|
|
261
|
+
write(HIDE + CLEAR + '\x1b[H\x1b[J');
|
|
262
|
+
readline.emitKeypressEvents(process.stdin);
|
|
263
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
264
|
+
|
|
265
|
+
function cleanup() {
|
|
266
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
267
|
+
write(SHOW + CLEAR);
|
|
268
|
+
process.stdin.pause();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
process.on('SIGINT', cleanup);
|
|
272
|
+
process.on('exit', cleanup);
|
|
273
|
+
|
|
274
|
+
// Initial render
|
|
275
|
+
refresh();
|
|
276
|
+
|
|
277
|
+
// Status blink timer
|
|
278
|
+
let statusTimer = null;
|
|
279
|
+
function setStatus(ok, msg) {
|
|
280
|
+
state.status = { ok, msg };
|
|
281
|
+
clearTimeout(statusTimer);
|
|
282
|
+
render(state, installedSet);
|
|
283
|
+
statusTimer = setTimeout(() => { state.status = null; render(state, installedSet); }, 3000);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Keypress handler
|
|
287
|
+
process.stdin.on('keypress', async (ch, key) => {
|
|
288
|
+
if (!key) return;
|
|
289
|
+
|
|
290
|
+
if (state.searching) {
|
|
291
|
+
if (key.name === 'escape') {
|
|
292
|
+
state.searching = false;
|
|
293
|
+
state.query = '';
|
|
294
|
+
refresh();
|
|
295
|
+
} else if (key.name === 'return') {
|
|
296
|
+
state.searching = false;
|
|
297
|
+
refresh();
|
|
298
|
+
} else if (key.name === 'backspace') {
|
|
299
|
+
state.query = state.query.slice(0, -1);
|
|
300
|
+
refresh();
|
|
301
|
+
} else if (ch && !key.ctrl && !key.meta && ch.length === 1) {
|
|
302
|
+
state.query += ch;
|
|
303
|
+
refresh();
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Confirm-delete mode
|
|
309
|
+
if (state.confirming) {
|
|
310
|
+
if (ch === 'y' || ch === 'Y') {
|
|
311
|
+
const item = state.confirming;
|
|
312
|
+
state.confirming = null;
|
|
313
|
+
setStatus(null, `Removing ${item.name}…`);
|
|
314
|
+
try {
|
|
315
|
+
await removeFn(item);
|
|
316
|
+
installedSet.delete(item.id);
|
|
317
|
+
if (item.code) installedSet.delete(item.code);
|
|
318
|
+
setStatus(true, `Removed ${item.name}`);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
setStatus(false, e.message.slice(0, 60));
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
state.confirming = null;
|
|
324
|
+
render(state, installedSet);
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Normal mode
|
|
330
|
+
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
|
331
|
+
cleanup();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (key.name === 'slash' || ch === '/') {
|
|
336
|
+
state.searching = true;
|
|
337
|
+
render(state, installedSet);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (key.name === 'tab') {
|
|
342
|
+
const tabs = ['all', 'skills', 'bundles'];
|
|
343
|
+
state.tab = tabs[(tabs.indexOf(state.tab) + 1) % tabs.length];
|
|
344
|
+
state.cursor = 0;
|
|
345
|
+
state.scroll = 0;
|
|
346
|
+
refresh(state.query, state.tab);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (key.name === 'up') {
|
|
351
|
+
if (state.cursor > 0) state.cursor--;
|
|
352
|
+
clampScroll(state);
|
|
353
|
+
render(state, installedSet);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (key.name === 'down') {
|
|
358
|
+
if (state.cursor < state.items.length - 1) state.cursor++;
|
|
359
|
+
clampScroll(state);
|
|
360
|
+
render(state, installedSet);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (key.name === 'pageup') {
|
|
365
|
+
state.cursor = Math.max(0, state.cursor - 10);
|
|
366
|
+
clampScroll(state);
|
|
367
|
+
render(state, installedSet);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (key.name === 'pagedown') {
|
|
372
|
+
state.cursor = Math.min(state.items.length - 1, state.cursor + 10);
|
|
373
|
+
clampScroll(state);
|
|
374
|
+
render(state, installedSet);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (key.name === 'home') { state.cursor = 0; state.scroll = 0; render(state, installedSet); return; }
|
|
379
|
+
if (key.name === 'end') { state.cursor = state.items.length - 1; clampScroll(state); render(state, installedSet); return; }
|
|
380
|
+
|
|
381
|
+
if (ch === 'd' || ch === 'D') {
|
|
382
|
+
const sel = state.items[state.cursor];
|
|
383
|
+
if (!sel) return;
|
|
384
|
+
const isInst = installedSet.has(sel.id) || (sel.code && installedSet.has(sel.code));
|
|
385
|
+
if (!isInst) { setStatus(false, 'Not installed'); return; }
|
|
386
|
+
state.confirming = { id: sel.id, name: sel.name, type: sel.type, code: sel.code, repo_url: sel.repo_url };
|
|
387
|
+
render(state, installedSet);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (key.name === 'return' || key.name === 'i') {
|
|
392
|
+
const sel = state.items[state.cursor];
|
|
393
|
+
if (!sel) return;
|
|
394
|
+
// Fire install in background — TUI stays responsive for queuing more
|
|
395
|
+
installFn(sel, (ok, msg) => {
|
|
396
|
+
setStatus(ok, msg);
|
|
397
|
+
}).catch(e => {
|
|
398
|
+
setStatus(false, e.message?.slice(0, 60) || 'Install failed');
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (key.name === 'escape') {
|
|
404
|
+
state.query = '';
|
|
405
|
+
refresh();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Resize handler
|
|
411
|
+
process.stdout.on('resize', () => { clampScroll(state); render(state, installedSet); });
|
|
412
|
+
|
|
413
|
+
// Keep alive
|
|
414
|
+
return new Promise(resolve => {
|
|
415
|
+
process.stdin.once('close', resolve);
|
|
416
|
+
process.on('SIGINT', resolve);
|
|
417
|
+
});
|
|
418
|
+
}
|