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/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
+ }