snip-manager 0.1.0

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.
@@ -0,0 +1,1137 @@
1
+ const blessed = require('blessed');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const storage = require('../storage');
7
+ const Fuse = require('fuse.js');
8
+ const config = require('../config');
9
+ const exec = require('../exec');
10
+ const safety = require('../safety');
11
+ const clipboard = require('../clipboard');
12
+
13
+ const LIST_KEYS = ['name', 'tags', 'content'];
14
+ const FUSE_OPTIONS = { keys: LIST_KEYS, threshold: 0.4, ignoreLocation: true };
15
+ const FAST_MOVE_STEP = 5;
16
+ const SPLIT_MIN_WIDTH = 80;
17
+
18
+ // ── Unicode glyphs ──────────────────────────────────────────────────
19
+ const G = {
20
+ dot: '●',
21
+ hollow: '○',
22
+ arrow: '›',
23
+ usage: '↻',
24
+ sort: '↕',
25
+ check: '✓',
26
+ cross: '✗',
27
+ warn: '⚠',
28
+ bar: '│',
29
+ dash: '─',
30
+ corner: '╭',
31
+ search: '⌕',
32
+ };
33
+
34
+ // ── Catppuccin Mocha inspired palette ───────────────────────────────
35
+ // Designed for readability on both dark and light terminal backgrounds.
36
+ const THEME = {
37
+ border: { fg: '#585B70' },
38
+ borderFocus: { fg: '#89B4FA' },
39
+ selected: { bg: '#89B4FA', fg: '#1E1E2E', bold: true },
40
+ item: { fg: '#CDD6F4' },
41
+ muted: { fg: '#6C7086' },
42
+ accent: { fg: '#94E2D5' },
43
+ success: { fg: '#A6E3A1' },
44
+ warning: { fg: '#F9E2AF' },
45
+ error: { fg: '#F38BA8' },
46
+ headerBg: '#313244',
47
+ headerFg: '#CDD6F4',
48
+ footerBg: '#1E1E2E',
49
+ footerFg: '#6C7086',
50
+ labelBg: '#45475A',
51
+ labelFg: '#CDD6F4',
52
+ preview: { fg: '#BAC2DE' },
53
+ badge: { fg: '#F5C2E7' },
54
+ };
55
+
56
+ function getFilteredSnippets(all, tagFilter, searchQuery) {
57
+ let list = tagFilter
58
+ ? all.filter(s => (s.tags || []).includes(tagFilter))
59
+ : all.slice();
60
+ if (searchQuery.trim()) {
61
+ const fuse = new Fuse(list.map(s => ({
62
+ ...s,
63
+ content: (storage.readSnippetContent(s) || '').slice(0, 1000)
64
+ })), FUSE_OPTIONS);
65
+ const results = fuse.search(searchQuery, { limit: 500 });
66
+ list = results.map(r => r.item);
67
+ }
68
+ return list;
69
+ }
70
+
71
+ function getUniqueTags(snippets) {
72
+ const set = new Set();
73
+ snippets.forEach(s => (s.tags || []).forEach(t => set.add(t)));
74
+ return Array.from(set).sort();
75
+ }
76
+
77
+ // Helper: spawn an external command (editor, runner) while blessed is active.
78
+ // Blessed holds the alt-screen buffer + raw stdin — must release both first.
79
+ function spawnExternal(screen, cmd, args) {
80
+ try {
81
+ screen.program.normalBuffer();
82
+ screen.program.showCursor();
83
+ } catch (e) { /* older blessed versions */ }
84
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
85
+ process.stdin.pause();
86
+
87
+ const result = spawnSync(cmd, args, { stdio: 'inherit' });
88
+
89
+ process.stdin.resume();
90
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
91
+ try {
92
+ screen.program.alternateBuffer();
93
+ screen.program.hideCursor();
94
+ } catch (e) { /* older blessed versions */ }
95
+ if (screen.alloc) screen.alloc();
96
+ screen.render();
97
+ return result;
98
+ }
99
+
100
+ function truncate(text, max) {
101
+ if (!text || max <= 0) return '';
102
+ if (text.length <= max) return text;
103
+ if (max <= 3) return '.'.repeat(max);
104
+ return text.slice(0, max - 3) + '...';
105
+ }
106
+
107
+ function clampIndex(index, length) {
108
+ if (!length) return 0;
109
+ return Math.max(0, Math.min(index, length - 1));
110
+ }
111
+
112
+ function getModalSize(screen, minWidth, maxWidth, minHeight, maxHeight) {
113
+ const usableWidth = Math.max(16, screen.width - 2);
114
+ const usableHeight = Math.max(8, screen.height - 2);
115
+ const floorWidth = Math.min(minWidth, usableWidth);
116
+ const floorHeight = Math.min(minHeight, usableHeight);
117
+ const width = Math.max(floorWidth, Math.min(maxWidth, usableWidth));
118
+ const height = Math.max(floorHeight, Math.min(maxHeight, usableHeight));
119
+ return { width, height };
120
+ }
121
+
122
+ function formatListItems(filtered, listWidth) {
123
+ if (!filtered.length) return [];
124
+ const w = listWidth || 80;
125
+ return filtered.map(s => {
126
+ const nameMax = Math.min(28, Math.floor(w * 0.3));
127
+ const name = truncate(String(s.name || 'untitled'), nameMax).padEnd(nameMax + 2, ' ');
128
+ const language = truncate((s.language || '').trim(), 8);
129
+ const langToken = language ? `{${THEME.muted.fg}-fg}[${language}]{/}` : '';
130
+ const langPad = language ? 10 - language.length : 0;
131
+ const pad1 = ' '.repeat(Math.max(1, langPad));
132
+ const tagsMax = Math.max(12, Math.floor(w * 0.3));
133
+ const tags = truncate((s.tags || []).join(', '), tagsMax);
134
+ const tagsStr = tags ? `{${THEME.accent.fg}-fg}${tags}{/}` : `{${THEME.muted.fg}-fg}${G.dash}{/}`;
135
+ const usage = s.usageCount ? `{${THEME.badge.fg}-fg}${G.usage}${s.usageCount}{/}` : '';
136
+ return ` ${G.hollow} ${name}${langToken}${pad1}${tagsStr} ${usage}`;
137
+ });
138
+ }
139
+
140
+ function runSnippet(snippet, screen, cb) {
141
+ const content = storage.readSnippetContent(snippet);
142
+ const cfg = config.loadConfig();
143
+ const runner = exec.resolveRunner(snippet.language, cfg.defaultShell);
144
+ const isDangerous = safety.isDangerous(content);
145
+ const { width, height } = getModalSize(screen, 78, 118, 15, 36);
146
+
147
+ const modal = blessed.box({
148
+ parent: screen,
149
+ top: 'center',
150
+ left: 'center',
151
+ width,
152
+ height,
153
+ padding: { top: 1, right: 1, bottom: 1, left: 1 },
154
+ border: { type: 'line', fg: THEME.warning.fg },
155
+ style: { border: { fg: THEME.warning.fg }, bg: THEME.footerBg, fg: THEME.item.fg },
156
+ keys: true,
157
+ scrollable: true,
158
+ tags: true,
159
+ label: { text: ` ${G.dot} Run (${runner.command}) `, side: 'left', fg: THEME.labelFg, bg: THEME.labelBg }
160
+ });
161
+
162
+ const preview = blessed.box({
163
+ parent: modal,
164
+ top: 0,
165
+ left: 0,
166
+ width: '100%-2',
167
+ height: '100%-4',
168
+ content: content + (isDangerous ? `\n\n${G.warn} Potentially dangerous. Press y to run anyway.` : ''),
169
+ tags: false,
170
+ scrollable: true,
171
+ alwaysScroll: true,
172
+ mouse: false,
173
+ keys: true,
174
+ vi: true,
175
+ scrollbar: { style: { bg: THEME.accent.fg } }
176
+ });
177
+
178
+ blessed.text({
179
+ parent: modal,
180
+ bottom: 0,
181
+ left: 0,
182
+ width: '100%',
183
+ content: isDangerous
184
+ ? ` ${G.warn} j/k:scroll y:run anyway n/Esc:cancel `
185
+ : ` ${G.check} j/k:scroll y:run n/Esc:cancel `,
186
+ style: { fg: THEME.warning.fg, bg: THEME.footerBg }
187
+ });
188
+
189
+ function abort() {
190
+ modal.destroy();
191
+ screen.render();
192
+ cb(null);
193
+ }
194
+ function run() {
195
+ modal.destroy();
196
+ screen.render();
197
+ // Leave blessed screen for snippet execution
198
+ try {
199
+ screen.program.normalBuffer();
200
+ screen.program.showCursor();
201
+ } catch (e) { /* fallback */ }
202
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
203
+ process.stdin.pause();
204
+
205
+ const status = exec.runSnippetContent(content, {
206
+ dryRun: false,
207
+ shell: cfg.defaultShell,
208
+ language: snippet.language
209
+ });
210
+
211
+ // Return to blessed screen
212
+ process.stdin.resume();
213
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
214
+ try {
215
+ screen.program.alternateBuffer();
216
+ screen.program.hideCursor();
217
+ } catch (e) { /* fallback */ }
218
+ if (screen.alloc) screen.alloc();
219
+ screen.render();
220
+
221
+ if (status === 0) storage.touchUsage(snippet);
222
+ cb(status);
223
+ }
224
+
225
+ modal.key(['escape', 'q', 'n'], abort);
226
+ preview.key(['escape', 'q', 'n'], abort);
227
+ modal.key('y', run);
228
+ preview.key('y', run);
229
+ preview.focus();
230
+ screen.render();
231
+ }
232
+
233
+ function showSnippet(snippet, screen, cb) {
234
+ const content = storage.readSnippetContent(snippet);
235
+ const { width, height } = getModalSize(screen, 78, 120, 14, 34);
236
+ const box = blessed.box({
237
+ parent: screen,
238
+ top: 'center',
239
+ left: 'center',
240
+ width,
241
+ height,
242
+ padding: { top: 1, right: 1, bottom: 1, left: 1 },
243
+ border: { type: 'line', fg: THEME.accent.fg },
244
+ style: { border: { fg: THEME.accent.fg }, bg: THEME.footerBg, fg: THEME.item.fg },
245
+ keys: true,
246
+ scrollable: true,
247
+ tags: true,
248
+ label: { text: ` ${G.dot} ${snippet.name} `, side: 'left', fg: THEME.labelFg, bg: THEME.accent.fg }
249
+ });
250
+ const text = blessed.box({
251
+ parent: box,
252
+ top: 0,
253
+ left: 0,
254
+ width: '100%-2',
255
+ height: '100%-3',
256
+ content,
257
+ tags: false,
258
+ scrollable: true,
259
+ alwaysScroll: true,
260
+ mouse: false,
261
+ keys: true,
262
+ vi: true,
263
+ scrollbar: { style: { bg: THEME.success.fg } }
264
+ });
265
+ const footerDefault = ` j/k:scroll c:copy p:pager q/Esc:close `;
266
+ const footer = blessed.text({
267
+ parent: box,
268
+ bottom: 0,
269
+ left: 0,
270
+ width: '100%',
271
+ content: footerDefault,
272
+ style: { fg: THEME.muted.fg, bg: 'black' }
273
+ });
274
+ let footerTimer = null;
275
+ function showFooterMessage(message, style, timeoutMs) {
276
+ if (footerTimer) clearTimeout(footerTimer);
277
+ footer.style.fg = style.fg;
278
+ footer.setContent(message);
279
+ screen.render();
280
+ if (timeoutMs > 0) {
281
+ footerTimer = setTimeout(() => {
282
+ footer.style.fg = THEME.muted.fg;
283
+ footer.setContent(footerDefault);
284
+ screen.render();
285
+ }, timeoutMs);
286
+ }
287
+ }
288
+ function copySnippetContent() {
289
+ const result = clipboard.copyText(content);
290
+ if (result.ok) {
291
+ showFooterMessage(` Copied snippet to clipboard (${result.command}) `, THEME.success, 1800);
292
+ return;
293
+ }
294
+ showFooterMessage(' Clipboard unavailable. Use terminal select + copy. ', THEME.warning, 2600);
295
+ }
296
+ function openInPager() {
297
+ const pagerRaw = process.env.PAGER || 'less -R';
298
+ const pager = pagerRaw.split(' ').filter(Boolean);
299
+ const tmpFile = path.join(os.tmpdir(), `snip-view-${snippet.id}.txt`);
300
+ const pagerEnv = { ...process.env, LESSOPEN: '', LESSCLOSE: '' };
301
+ try {
302
+ fs.writeFileSync(tmpFile, content, 'utf8');
303
+ if (screen.leave) screen.leave();
304
+ const res = spawnSync(pager[0], pager.slice(1).concat([tmpFile]), {
305
+ stdio: 'inherit',
306
+ env: pagerEnv
307
+ });
308
+ if (screen.enter) screen.enter();
309
+ if (res.error && res.error.code === 'ENOENT') {
310
+ showFooterMessage(` Pager not found: ${pager[0]} `, THEME.warning, 2600);
311
+ } else {
312
+ showFooterMessage(' Returned from pager. ', THEME.accent, 1200);
313
+ }
314
+ } finally {
315
+ try { fs.unlinkSync(tmpFile); } catch (e) { }
316
+ text.focus();
317
+ screen.render();
318
+ }
319
+ }
320
+ function close() {
321
+ if (footerTimer) clearTimeout(footerTimer);
322
+ box.destroy();
323
+ screen.render();
324
+ cb();
325
+ }
326
+ box.key(['c', 'y'], copySnippetContent);
327
+ text.key(['c', 'y'], copySnippetContent);
328
+ box.key(['p'], openInPager);
329
+ text.key(['p'], openInPager);
330
+ box.key(['escape', 'q'], () => {
331
+ close();
332
+ });
333
+ text.key(['escape', 'q'], () => {
334
+ close();
335
+ });
336
+ text.focus();
337
+ screen.render();
338
+ }
339
+
340
+ function showHelpOverlay(screen, helpBar, cb) {
341
+ const lines = [
342
+ ` ${G.dot} SNIP ${G.dash} KEYBOARD SHORTCUTS`,
343
+ '',
344
+ ` {${THEME.accent.fg}-fg}Navigation{/}`,
345
+ ` j / ${G.arrow} Move down`,
346
+ ` k / ${G.arrow} Move up`,
347
+ ' Ctrl+d Jump down (+5)',
348
+ ' Ctrl+u Jump up (-5)',
349
+ ' g Go to first',
350
+ ' G Go to last',
351
+ '',
352
+ ` {${THEME.accent.fg}-fg}Actions{/}`,
353
+ ` Enter Show snippet content`,
354
+ ` c Copy selected snippet`,
355
+ ` r Run snippet (preview + confirm)`,
356
+ ` e Edit snippet in $EDITOR`,
357
+ ` a Add new snippet`,
358
+ ` d Delete snippet (with confirm)`,
359
+ ` / Fuzzy search (type to filter)`,
360
+ ` t Filter by tag`,
361
+ ` s Cycle sort (name ${G.arrow} usage ${G.arrow} recent)`,
362
+ '',
363
+ ` {${THEME.accent.fg}-fg}View Mode{/}`,
364
+ ` c / y Copy snippet content`,
365
+ ` p Open snippet in pager`,
366
+ '',
367
+ ` {${THEME.accent.fg}-fg}General{/}`,
368
+ ` ? This help`,
369
+ ` q / Ctrl+C Quit`,
370
+ ` Esc Cancel / clear filter`,
371
+ '',
372
+ ` {${THEME.muted.fg}-fg}[Press any key to close]{/}`
373
+ ];
374
+ const { width, height } = getModalSize(screen, 58, 66, 28, 34);
375
+ const box = blessed.box({
376
+ parent: screen,
377
+ top: 'center',
378
+ left: 'center',
379
+ width,
380
+ height,
381
+ padding: { top: 1, right: 2, bottom: 1, left: 2 },
382
+ border: { type: 'line', fg: THEME.accent.fg },
383
+ style: { border: { fg: THEME.accent.fg }, bg: THEME.footerBg, fg: THEME.item.fg },
384
+ keys: true,
385
+ tags: true,
386
+ content: lines.join('\n'),
387
+ label: { text: ` ${G.dot} Help `, side: 'left', fg: THEME.labelFg, bg: THEME.labelBg }
388
+ });
389
+ let isClosed = false;
390
+ function close() {
391
+ if (isClosed) return;
392
+ isClosed = true;
393
+ box.destroy();
394
+ screen.render();
395
+ cb();
396
+ }
397
+ box.key(['escape', 'q', 'enter', 'space'], close);
398
+ let ignoreFirstKeypress = true;
399
+ setTimeout(() => {
400
+ ignoreFirstKeypress = false;
401
+ }, 0);
402
+ box.on('keypress', () => {
403
+ if (ignoreFirstKeypress) return;
404
+ close();
405
+ });
406
+ box.focus();
407
+ screen.render();
408
+ }
409
+
410
+ const SORT_MODES = ['name', 'usage', 'recent'];
411
+ const SORT_LABELS = { name: 'A-Z', usage: 'Most used', recent: 'Recent' };
412
+
413
+ function buildHelpBar(mode) {
414
+ if (mode === 'tag') return ` ${G.sort} select tag Enter:apply Esc:cancel`;
415
+ if (mode === 'search') return ` ${G.search} Type to filter... Enter:apply Esc:cancel`;
416
+ return ` j/k:move ${G.sort}s:sort /:search t:tag Enter:open c:copy r:run a:add e:edit d:del ?:help q:quit`;
417
+ }
418
+
419
+ function showRunFeedback(screen, helpBar, status, cb) {
420
+ const msg = status === 0 ? ` ${G.check} Done (exit 0) ` : ` ${G.cross} Failed (exit ${status}) `;
421
+ const style = status === 0 ? THEME.success : THEME.error;
422
+ helpBar.setContent(`{${style.fg}-fg}${msg}{/} ${buildHelpBar()}`);
423
+ helpBar.style.fg = style.fg;
424
+ screen.render();
425
+ setTimeout(() => {
426
+ helpBar.setContent(buildHelpBar());
427
+ helpBar.style.fg = THEME.footerFg;
428
+ screen.render();
429
+ if (cb) cb();
430
+ }, 2200);
431
+ }
432
+
433
+ function start() {
434
+ let all = storage.listSnippets();
435
+ let tagFilter = null;
436
+ let searchQuery = '';
437
+ let sortMode = 0; // index into SORT_MODES
438
+ let filtered = getFilteredSnippets(all, tagFilter, searchQuery);
439
+ let selectedIndex = 0;
440
+ let tagPickerActive = false;
441
+ let tagList = [];
442
+ let tagIndex = 0;
443
+ let helpTimer = null;
444
+ let deleteConfirmActive = false;
445
+
446
+ // Sort helper
447
+ const SORTERS = {
448
+ name: (a, b) => String(a.name || '').localeCompare(String(b.name || '')),
449
+ usage: (a, b) => {
450
+ const diff = (b.usageCount || 0) - (a.usageCount || 0);
451
+ return diff !== 0 ? diff : String(a.name || '').localeCompare(String(b.name || ''));
452
+ },
453
+ recent: (a, b) => {
454
+ const aTs = Date.parse(a.lastUsedAt || a.updatedAt || a.createdAt || 0) || 0;
455
+ const bTs = Date.parse(b.lastUsedAt || b.updatedAt || b.createdAt || 0) || 0;
456
+ return bTs !== aTs ? bTs - aTs : String(a.name || '').localeCompare(String(b.name || ''));
457
+ }
458
+ };
459
+
460
+ const screen = blessed.screen({
461
+ smartCSR: true,
462
+ title: 'snip',
463
+ fullUnicode: true,
464
+ cursor: { artificial: 'line', shape: 'line', blink: true }
465
+ });
466
+
467
+ const isSplit = () => screen.width >= SPLIT_MIN_WIDTH;
468
+ const calcListWidth = () => isSplit() ? Math.floor(screen.width * 0.42) : screen.width;
469
+ const calcPreviewWidth = () => isSplit() ? screen.width - calcListWidth() : 0;
470
+
471
+ // ── Header ──────────────────────────────────────────────────────────
472
+ const headerLeft = blessed.box({
473
+ parent: screen,
474
+ top: 0,
475
+ left: 0,
476
+ width: '60%',
477
+ height: 1,
478
+ content: ` ${G.dot} snip`,
479
+ style: { fg: THEME.headerFg, bg: THEME.headerBg, bold: true },
480
+ tags: true
481
+ });
482
+ const headerRight = blessed.box({
483
+ parent: screen,
484
+ top: 0,
485
+ right: 0,
486
+ width: '40%',
487
+ height: 1,
488
+ content: '',
489
+ align: 'right',
490
+ style: { fg: THEME.headerFg, bg: THEME.headerBg },
491
+ tags: true
492
+ });
493
+
494
+ // ── Search ──────────────────────────────────────────────────────────
495
+ const searchBox = blessed.textbox({
496
+ parent: screen,
497
+ top: 1,
498
+ left: 0,
499
+ width: calcListWidth(),
500
+ height: 3,
501
+ border: { type: 'line', fg: THEME.accent.fg },
502
+ padding: { left: 1, right: 1 },
503
+ style: { fg: THEME.item.fg, border: THEME.borderFocus, bg: THEME.footerBg },
504
+ keys: true,
505
+ inputOnFocus: true,
506
+ tags: true,
507
+ label: { text: ` ${G.search} Search `, side: 'left', fg: THEME.labelFg, bg: THEME.labelBg }
508
+ });
509
+ searchBox.hide();
510
+
511
+ // ── List ────────────────────────────────────────────────────────────
512
+ const listBox = blessed.list({
513
+ parent: screen,
514
+ top: 1,
515
+ left: 0,
516
+ width: calcListWidth(),
517
+ height: '100%-2',
518
+ keys: true,
519
+ vi: false,
520
+ mouse: true,
521
+ padding: { left: 0, right: 0 },
522
+ style: {
523
+ selected: THEME.selected,
524
+ item: THEME.item,
525
+ border: THEME.border
526
+ },
527
+ border: { type: 'line', fg: THEME.border.fg },
528
+ tags: true,
529
+ items: [' Loading...']
530
+ });
531
+
532
+ // ── Preview pane ────────────────────────────────────────────────────
533
+ const previewBox = blessed.box({
534
+ parent: screen,
535
+ top: 1,
536
+ right: 0,
537
+ width: calcPreviewWidth(),
538
+ height: '100%-2',
539
+ padding: { top: 0, right: 1, bottom: 0, left: 1 },
540
+ border: { type: 'line', fg: THEME.border.fg },
541
+ style: { fg: THEME.preview.fg, border: THEME.border },
542
+ tags: true,
543
+ scrollable: true,
544
+ alwaysScroll: true,
545
+ mouse: false,
546
+ keys: false,
547
+ vi: false,
548
+ scrollbar: { style: { bg: THEME.accent.fg } },
549
+ label: { text: ' Preview ', side: 'left', fg: THEME.labelFg, bg: THEME.labelBg },
550
+ content: ''
551
+ });
552
+
553
+ // ── Footer ──────────────────────────────────────────────────────────
554
+ const helpBar = blessed.box({
555
+ parent: screen,
556
+ bottom: 0,
557
+ left: 0,
558
+ width: '100%',
559
+ height: 1,
560
+ style: { fg: THEME.footerFg, bg: THEME.footerBg },
561
+ content: buildHelpBar(),
562
+ tags: true
563
+ });
564
+
565
+ // ── State helpers ───────────────────────────────────────────────────
566
+ function clearHelpTimer() {
567
+ if (helpTimer) {
568
+ clearTimeout(helpTimer);
569
+ helpTimer = null;
570
+ }
571
+ }
572
+
573
+ function flashHelp(message, style = THEME.accent, timeoutMs = 1800) {
574
+ clearHelpTimer();
575
+ helpBar.style.fg = style.fg;
576
+ helpBar.setContent(message);
577
+ screen.render();
578
+ if (timeoutMs > 0) {
579
+ helpTimer = setTimeout(() => {
580
+ setHelpBarDefault();
581
+ screen.render();
582
+ }, timeoutMs);
583
+ }
584
+ }
585
+
586
+ function setHelpBarDefault() {
587
+ clearHelpTimer();
588
+ helpBar.setContent(buildHelpBar());
589
+ helpBar.style.fg = THEME.footerFg;
590
+ }
591
+
592
+ function updatePreview() {
593
+ if (!isSplit()) {
594
+ previewBox.hide();
595
+ return;
596
+ }
597
+ previewBox.show();
598
+ const snippet = filtered[selectedIndex];
599
+ if (!snippet) {
600
+ previewBox.setLabel({ text: ' Preview ', side: 'left', fg: THEME.labelFg, bg: THEME.labelBg });
601
+ previewBox.setContent(`{${THEME.muted.fg}-fg}No snippet selected{/}`);
602
+ return;
603
+ }
604
+ const content = storage.readSnippetContent(snippet);
605
+ const lang = snippet.language ? `[${snippet.language}]` : '';
606
+ previewBox.setLabel({ text: ` ${snippet.name} ${lang} `, side: 'left', fg: THEME.labelFg, bg: THEME.labelBg });
607
+ previewBox.setContent(content || `{${THEME.muted.fg}-fg}(empty){/}`);
608
+ previewBox.setScrollPerc(0);
609
+ }
610
+
611
+ function updateHeader() {
612
+ const parts = [];
613
+ if (tagFilter) parts.push(`{${THEME.accent.fg}-fg}${tagFilter}{/}`);
614
+ if (searchQuery.trim()) parts.push(`{${THEME.warning.fg}-fg}"${searchQuery}"{/}`);
615
+ const filterStr = parts.length ? ` ${G.arrow} ` + parts.join(` ${G.arrow} `) : '';
616
+ headerLeft.setContent(` {bold}${G.dot} snip{/bold}${filterStr}`);
617
+ const sortLabel = SORT_LABELS[SORT_MODES[sortMode]] || 'A-Z';
618
+ headerRight.setContent(`{${THEME.muted.fg}-fg}${G.sort} ${sortLabel}{/} {${THEME.accent.fg}-fg}${filtered.length}/${all.length}{/} snippets `);
619
+ screen.render();
620
+ }
621
+
622
+ function refreshList() {
623
+ filtered = getFilteredSnippets(all, tagFilter, searchQuery);
624
+ // Apply sort
625
+ const key = SORT_MODES[sortMode];
626
+ if (SORTERS[key]) filtered.sort(SORTERS[key]);
627
+ selectedIndex = clampIndex(selectedIndex, filtered.length);
628
+ const lw = calcListWidth();
629
+ const items = filtered.length
630
+ ? formatListItems(filtered, lw)
631
+ : [` {${THEME.muted.fg}-fg}No matches. Change search or tag filter.{/}`];
632
+ listBox.setItems(items);
633
+ if (filtered.length) {
634
+ listBox.select(selectedIndex);
635
+ } else {
636
+ listBox.select(0);
637
+ }
638
+ updateHeader();
639
+ updatePreview();
640
+ screen.render();
641
+ }
642
+
643
+ function reloadAll() {
644
+ all = storage.listSnippets();
645
+ refreshList();
646
+ }
647
+
648
+ // ── Search ──────────────────────────────────────────────────────────
649
+ function focusSearch() {
650
+ searchBox.show();
651
+ if (searchBox.setValue) searchBox.setValue(searchQuery);
652
+ else searchBox.setContent(searchQuery);
653
+ searchBox.focus();
654
+ listBox.hide();
655
+ previewBox.hide();
656
+ headerLeft.setContent(` {bold}${G.dot} snip{/bold} ${G.arrow} {${THEME.warning.fg}-fg}search{/}`);
657
+ headerRight.setContent(` Enter:apply Esc:cancel `);
658
+ helpBar.setContent(buildHelpBar('search'));
659
+ screen.render();
660
+ }
661
+
662
+ // ── Tag filter ──────────────────────────────────────────────────────
663
+ function toggleTagFilter() {
664
+ if (tagPickerActive) {
665
+ tagPickerActive = false;
666
+ listBox.focus();
667
+ refreshList();
668
+ setHelpBarDefault();
669
+ screen.render();
670
+ return;
671
+ }
672
+ const tags = getUniqueTags(all);
673
+ if (tags.length === 0) {
674
+ flashHelp(` ${G.warn} No tags found. Add tags with: snip add foo --tags a,b `, THEME.warning, 2200);
675
+ listBox.focus();
676
+ return;
677
+ }
678
+ tagPickerActive = true;
679
+ tagList = tags;
680
+ tagIndex = tagFilter ? tagList.indexOf(tagFilter) : 0;
681
+ if (tagIndex < 0) tagIndex = 0;
682
+ helpBar.style.fg = THEME.accent.fg;
683
+ helpBar.setContent(` ${G.sort} j/k:select Enter:apply Esc:cancel ${G.arrow} {bold}${tagList[tagIndex]}{/bold}`);
684
+ screen.render();
685
+ }
686
+
687
+ // ── Navigation ──────────────────────────────────────────────────────
688
+ function selectRelative(delta) {
689
+ if (tagPickerActive) {
690
+ tagIndex = clampIndex(tagIndex + delta, tagList.length);
691
+ helpBar.setContent(` ${G.sort} j/k:select Enter:apply Esc:cancel ${G.arrow} {bold}${tagList[tagIndex]}{/bold}`);
692
+ screen.render();
693
+ return;
694
+ }
695
+ if (!filtered.length) return;
696
+ selectedIndex = clampIndex(selectedIndex + delta, filtered.length);
697
+ listBox.select(selectedIndex);
698
+ updateHeader();
699
+ updatePreview();
700
+ screen.render();
701
+ }
702
+
703
+ function selectAbsolute(index) {
704
+ if (tagPickerActive) {
705
+ tagIndex = clampIndex(index, tagList.length);
706
+ helpBar.setContent(` ${G.sort} j/k:select Enter:apply Esc:cancel ${G.arrow} {bold}${tagList[tagIndex]}{/bold}`);
707
+ screen.render();
708
+ return;
709
+ }
710
+ if (!filtered.length) return;
711
+ selectedIndex = clampIndex(index, filtered.length);
712
+ listBox.select(selectedIndex);
713
+ updateHeader();
714
+ updatePreview();
715
+ screen.render();
716
+ }
717
+
718
+ // ── Clipboard ───────────────────────────────────────────────────────
719
+ function copySelectedSnippet() {
720
+ if (tagPickerActive || deleteConfirmActive) return;
721
+ const snippet = filtered[selectedIndex];
722
+ if (!snippet) return;
723
+ const result = clipboard.copyText(storage.readSnippetContent(snippet));
724
+ if (result.ok) {
725
+ flashHelp(` ${G.check} Copied "${snippet.name}" to clipboard `, THEME.success, 1800);
726
+ return;
727
+ }
728
+ flashHelp(` ${G.cross} Clipboard unavailable. Press Enter to view and manually copy. `, THEME.warning, 2600);
729
+ }
730
+
731
+ // ── Sort cycling ────────────────────────────────────────────────────
732
+ function cycleSort() {
733
+ if (tagPickerActive || deleteConfirmActive) return;
734
+ sortMode = (sortMode + 1) % SORT_MODES.length;
735
+ const label = SORT_LABELS[SORT_MODES[sortMode]];
736
+ flashHelp(` ${G.sort} Sort: ${label} `, THEME.accent, 1200);
737
+ refreshList();
738
+ }
739
+
740
+ // ── Delete snippet ──────────────────────────────────────────────────
741
+ function deleteSelected() {
742
+ if (tagPickerActive || deleteConfirmActive) return;
743
+ const snippet = filtered[selectedIndex];
744
+ if (!snippet) return;
745
+ deleteConfirmActive = true;
746
+ helpBar.style.fg = THEME.error.fg;
747
+ helpBar.setContent(` ${G.warn} Delete "${snippet.name}"? y:confirm n/Esc:cancel`);
748
+ screen.render();
749
+ }
750
+
751
+ function confirmDelete() {
752
+ if (!deleteConfirmActive) return;
753
+ const snippet = filtered[selectedIndex];
754
+ if (snippet) {
755
+ storage.deleteSnippetById(snippet.id);
756
+ flashHelp(` ${G.check} Deleted "${snippet.name}" `, THEME.success, 1800);
757
+ reloadAll();
758
+ }
759
+ deleteConfirmActive = false;
760
+ }
761
+
762
+ function cancelDelete() {
763
+ if (!deleteConfirmActive) return;
764
+ deleteConfirmActive = false;
765
+ setHelpBarDefault();
766
+ screen.render();
767
+ }
768
+
769
+ // ── Inline editor modal ──────────────────────────────────────────────
770
+ function showEditorModal(title, initialContent, onSave) {
771
+ const { width, height } = getModalSize(screen, 70, 120, 18, 36);
772
+ const modal = blessed.box({
773
+ parent: screen,
774
+ top: 'center',
775
+ left: 'center',
776
+ width,
777
+ height,
778
+ border: { type: 'line', fg: THEME.accent.fg },
779
+ style: { border: { fg: THEME.accent.fg }, bg: THEME.footerBg, fg: THEME.item.fg },
780
+ tags: true,
781
+ label: { text: ` ${G.dot} ${title} `, side: 'left', fg: THEME.labelFg, bg: THEME.accent.fg }
782
+ });
783
+
784
+ const editor = blessed.textarea({
785
+ parent: modal,
786
+ top: 0,
787
+ left: 0,
788
+ width: '100%-2',
789
+ height: '100%-3',
790
+ keys: true,
791
+ mouse: true,
792
+ inputOnFocus: true,
793
+ scrollable: true,
794
+ alwaysScroll: true,
795
+ style: { fg: THEME.item.fg, bg: THEME.footerBg },
796
+ scrollbar: { style: { bg: THEME.accent.fg } },
797
+ value: initialContent
798
+ });
799
+
800
+ const footer = blessed.text({
801
+ parent: modal,
802
+ bottom: 0,
803
+ left: 0,
804
+ width: '100%',
805
+ content: ` Ctrl+S:save Esc:cancel `,
806
+ style: { fg: THEME.footerFg, bg: THEME.headerBg }
807
+ });
808
+
809
+ let isClosed = false;
810
+ function close(saved) {
811
+ if (isClosed) return;
812
+ isClosed = true;
813
+ const content = (editor.getValue ? editor.getValue() : editor.value || '');
814
+ modal.destroy();
815
+ listBox.focus();
816
+ screen.render();
817
+ if (saved) onSave(content);
818
+ }
819
+
820
+ editor.key(['C-s'], () => close(true));
821
+ editor.key(['escape'], () => close(false));
822
+
823
+ editor.focus();
824
+ screen.render();
825
+ }
826
+
827
+ // ── Metadata prompt (tags + language) — sequential single-input ─────
828
+ function showMetaPrompt(defaultTags, defaultLang, onDone) {
829
+ // Step 1: Tags
830
+ promptSingleField('Tags (comma-separated)', defaultTags, (tags) => {
831
+ if (tags === null) { onDone(null, null); return; }
832
+ // Step 2: Language
833
+ promptSingleField('Language (sh, bash, python, node)', defaultLang || 'sh', (lang) => {
834
+ if (lang === null) {
835
+ // Esc on language — still save tags
836
+ const tagArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
837
+ onDone(tagArr, defaultLang || 'sh');
838
+ } else {
839
+ const tagArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
840
+ onDone(tagArr, lang || 'sh');
841
+ }
842
+ });
843
+ });
844
+ }
845
+
846
+ function promptSingleField(label, defaultValue, cb) {
847
+ const input = blessed.textbox({
848
+ parent: screen,
849
+ top: 'center',
850
+ left: 'center',
851
+ width: 56,
852
+ height: 3,
853
+ border: { type: 'line', fg: THEME.accent.fg },
854
+ padding: { left: 1, right: 1 },
855
+ style: { fg: THEME.item.fg, border: { fg: THEME.accent.fg }, bg: THEME.footerBg },
856
+ keys: true,
857
+ inputOnFocus: true,
858
+ value: defaultValue || '',
859
+ label: { text: ` ${G.dot} ${label} `, side: 'left', fg: THEME.labelFg, bg: THEME.accent.fg }
860
+ });
861
+
862
+ input.on('submit', (val) => {
863
+ input.destroy();
864
+ listBox.focus();
865
+ screen.render();
866
+ cb((val || '').trim());
867
+ });
868
+
869
+ input.on('cancel', () => {
870
+ input.destroy();
871
+ listBox.focus();
872
+ screen.render();
873
+ cb(null);
874
+ });
875
+
876
+ input.focus();
877
+ screen.render();
878
+ }
879
+
880
+ // ── Edit snippet ────────────────────────────────────────────────────
881
+ function editSelected() {
882
+ if (tagPickerActive || deleteConfirmActive) return;
883
+ const snippet = filtered[selectedIndex];
884
+ if (!snippet) return;
885
+ const content = storage.readSnippetContent(snippet);
886
+ showEditorModal(`Edit: ${snippet.name}`, content, (newContent) => {
887
+ const contentChanged = newContent.trim() && newContent !== content;
888
+ if (contentChanged) {
889
+ storage.updateSnippetContent(snippet.id, newContent);
890
+ }
891
+ // Always offer metadata editing
892
+ const currentTags = (snippet.tags || []).join(', ');
893
+ const currentLang = snippet.language || 'sh';
894
+ showMetaPrompt(currentTags, currentLang, (tags, lang) => {
895
+ if (tags !== null) {
896
+ storage.updateSnippetMeta(snippet.id, { tags, language: lang });
897
+ }
898
+ if (contentChanged || tags !== null) {
899
+ flashHelp(` ${G.check} Updated "${snippet.name}" `, THEME.success, 1800);
900
+ reloadAll();
901
+ } else {
902
+ flashHelp(` No changes. `, THEME.muted, 1200);
903
+ }
904
+ });
905
+ });
906
+ }
907
+
908
+ // ── Add snippet from TUI ────────────────────────────────────────────
909
+ function addSnippet() {
910
+ if (tagPickerActive || deleteConfirmActive) return;
911
+ // Step 1: prompt for snippet name
912
+ const nameInput = blessed.textbox({
913
+ parent: screen,
914
+ top: 'center',
915
+ left: 'center',
916
+ width: 50,
917
+ height: 3,
918
+ border: { type: 'line', fg: THEME.accent.fg },
919
+ padding: { left: 1, right: 1 },
920
+ style: { fg: THEME.item.fg, border: { fg: THEME.accent.fg }, bg: THEME.footerBg },
921
+ keys: true,
922
+ inputOnFocus: true,
923
+ label: { text: ` ${G.dot} Snippet name `, side: 'left', fg: THEME.labelFg, bg: THEME.accent.fg }
924
+ });
925
+
926
+ nameInput.on('submit', (name) => {
927
+ nameInput.destroy();
928
+ name = (name || '').trim();
929
+ if (!name) {
930
+ flashHelp(` Cancelled — no name given. `, THEME.muted, 1200);
931
+ listBox.focus();
932
+ screen.render();
933
+ return;
934
+ }
935
+ name = name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
936
+ // Step 2: editor (empty — no placeholder)
937
+ showEditorModal(`New: ${name}`, '', (content) => {
938
+ if (!content.trim()) {
939
+ flashHelp(` Cancelled — empty content. `, THEME.muted, 1200);
940
+ return;
941
+ }
942
+ // Step 3: tags + language
943
+ showMetaPrompt('', 'sh', (tags, lang) => {
944
+ const finalTags = tags || [];
945
+ const finalLang = lang || 'sh';
946
+ storage.addSnippet({ name, content, language: finalLang, tags: finalTags });
947
+ flashHelp(` ${G.check} Added "${name}" `, THEME.success, 2600);
948
+ reloadAll();
949
+ });
950
+ });
951
+ });
952
+
953
+ nameInput.on('cancel', () => {
954
+ nameInput.destroy();
955
+ listBox.focus();
956
+ screen.render();
957
+ });
958
+
959
+ nameInput.focus();
960
+ screen.render();
961
+ }
962
+
963
+ // ── Event bindings ──────────────────────────────────────────────────
964
+ searchBox.on('submit', () => {
965
+ const raw = (searchBox.getValue ? searchBox.getValue() : searchBox.content || '').trim();
966
+ searchQuery = raw.startsWith('/') ? raw.slice(1).trim() : raw;
967
+ searchBox.hide();
968
+ if (searchBox.clearValue) searchBox.clearValue();
969
+ listBox.show();
970
+ listBox.focus();
971
+ refreshList();
972
+ setHelpBarDefault();
973
+ screen.render();
974
+ });
975
+
976
+ searchBox.on('cancel', () => {
977
+ searchBox.hide();
978
+ if (searchBox.clearValue) searchBox.clearValue();
979
+ listBox.show();
980
+ listBox.focus();
981
+ updateHeader();
982
+ setHelpBarDefault();
983
+ updatePreview();
984
+ screen.render();
985
+ });
986
+
987
+ listBox.on('select', (el, i) => {
988
+ selectedIndex = i;
989
+ updatePreview();
990
+ });
991
+ listBox.on('focus', () => {
992
+ listBox.style.border = THEME.borderFocus;
993
+ screen.render();
994
+ });
995
+ listBox.on('blur', () => {
996
+ listBox.style.border = THEME.border;
997
+ screen.render();
998
+ });
999
+
1000
+ // Delete confirm/cancel intercept
1001
+ listBox.key('y', () => { if (deleteConfirmActive) confirmDelete(); });
1002
+ listBox.key(['n'], () => { if (deleteConfirmActive) { cancelDelete(); return; } });
1003
+
1004
+ listBox.key('r', () => {
1005
+ if (deleteConfirmActive) return;
1006
+ const snippet = filtered[selectedIndex];
1007
+ if (!snippet) return;
1008
+ runSnippet(snippet, screen, (status) => {
1009
+ listBox.focus();
1010
+ if (status !== null && status !== undefined) showRunFeedback(screen, helpBar, status);
1011
+ else screen.render();
1012
+ });
1013
+ });
1014
+
1015
+ listBox.key('c', () => copySelectedSnippet());
1016
+ listBox.key('t', () => { if (!deleteConfirmActive) toggleTagFilter(); });
1017
+ listBox.key('/', () => { if (!deleteConfirmActive) focusSearch(); });
1018
+ listBox.key('s', () => cycleSort());
1019
+ listBox.key('d', () => deleteSelected());
1020
+ listBox.key('e', () => editSelected());
1021
+ listBox.key('a', () => addSnippet());
1022
+
1023
+ listBox.key('?', () => {
1024
+ if (deleteConfirmActive) return;
1025
+ showHelpOverlay(screen, helpBar, () => {
1026
+ listBox.focus();
1027
+ setHelpBarDefault();
1028
+ screen.render();
1029
+ });
1030
+ });
1031
+
1032
+ listBox.key(['j', 'down'], () => {
1033
+ if (deleteConfirmActive) return;
1034
+ selectRelative(1);
1035
+ });
1036
+
1037
+ listBox.key(['k', 'up'], () => {
1038
+ if (deleteConfirmActive) return;
1039
+ selectRelative(-1);
1040
+ });
1041
+
1042
+ listBox.key(['C-d', 'pagedown', 'J'], () => {
1043
+ if (deleteConfirmActive) return;
1044
+ selectRelative(FAST_MOVE_STEP);
1045
+ });
1046
+
1047
+ listBox.key(['C-u', 'pageup', 'K'], () => {
1048
+ if (deleteConfirmActive) return;
1049
+ selectRelative(-FAST_MOVE_STEP);
1050
+ });
1051
+
1052
+ listBox.key('g', () => {
1053
+ if (deleteConfirmActive) return;
1054
+ selectAbsolute(0);
1055
+ });
1056
+
1057
+ listBox.key('G', () => {
1058
+ if (deleteConfirmActive) return;
1059
+ const index = tagPickerActive ? Math.max(0, tagList.length - 1) : Math.max(0, filtered.length - 1);
1060
+ selectAbsolute(index);
1061
+ });
1062
+
1063
+ listBox.key('enter', () => {
1064
+ if (deleteConfirmActive) return;
1065
+ if (tagPickerActive) {
1066
+ tagFilter = tagList[tagIndex] || null;
1067
+ tagPickerActive = false;
1068
+ refreshList();
1069
+ setHelpBarDefault();
1070
+ } else {
1071
+ const snippet = filtered[selectedIndex];
1072
+ if (snippet) showSnippet(snippet, screen, () => { listBox.focus(); updatePreview(); screen.render(); });
1073
+ }
1074
+ screen.render();
1075
+ });
1076
+
1077
+ function quit() {
1078
+ clearHelpTimer();
1079
+ screen.destroy();
1080
+ process.exit(0);
1081
+ }
1082
+
1083
+ listBox.key(['q'], () => { if (deleteConfirmActive) { cancelDelete(); return; } quit(); });
1084
+ screen.key(['C-c'], () => quit());
1085
+
1086
+ listBox.key(['escape'], () => {
1087
+ if (deleteConfirmActive) { cancelDelete(); return; }
1088
+ if (tagPickerActive) {
1089
+ tagPickerActive = false;
1090
+ setHelpBarDefault();
1091
+ }
1092
+ if (searchQuery) {
1093
+ searchQuery = '';
1094
+ refreshList();
1095
+ }
1096
+ screen.render();
1097
+ });
1098
+
1099
+ // Handle resize to toggle split-pane
1100
+ function resizeLayout() {
1101
+ const lw = calcListWidth();
1102
+ const pw = calcPreviewWidth();
1103
+ searchBox.width = lw;
1104
+ listBox.width = lw;
1105
+ previewBox.width = pw;
1106
+ }
1107
+ screen.on('resize', () => {
1108
+ resizeLayout();
1109
+ refreshList();
1110
+ });
1111
+
1112
+ listBox.focus();
1113
+ setHelpBarDefault();
1114
+ refreshList();
1115
+ screen.render();
1116
+ }
1117
+
1118
+ function ui() {
1119
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1120
+ console.error('snip ui requires an interactive terminal (TTY).');
1121
+ process.exitCode = 1;
1122
+ return;
1123
+ }
1124
+ // Avoid terminfo quirks (e.g. Ghostty's Setulc) by using a well-supported TERM
1125
+ const termOverride = process.env.TERM;
1126
+ if (!termOverride || /ghostty|wezterm|kitty/i.test(termOverride)) {
1127
+ process.env.TERM = 'xterm-256color';
1128
+ }
1129
+ try {
1130
+ start();
1131
+ } catch (e) {
1132
+ console.error('TUI failed:', e.message);
1133
+ process.exitCode = 1;
1134
+ }
1135
+ }
1136
+
1137
+ module.exports = ui;