task-summary-extractor 9.5.0 → 9.6.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.
package/README.md CHANGED
@@ -596,6 +596,8 @@ task-summary-extractor/
596
596
 
597
597
  | Version | Highlights |
598
598
  |---------|-----------|
599
+ | **v9.6.0** | **Interactive CLI UX** — arrow-key navigation for all selectors (folder, model, run mode, formats, confidence, doc exclusion), zero-dependency prompt engine (`interactive.js`), `selectOne()` with ↑↓+Enter, `selectMany()` with Space toggle + A all/none, non-TTY fallback to number input |
600
+ | **v9.5.0** | **Video processing flags** — `--no-compress`, `--speed`, `--segment-time` CLI flags, hardcoded 1200s for raw mode, deprecated `--skip-compression` |
599
601
  | **v9.4.0** | **Context window safety** — pre-flight token checks, auto-truncation for oversized docs/VTTs, RESOURCE_EXHAUSTED recovery with automatic doc shedding, chunked compilation for large segment sets, P0/P1 hard cap (2× budget) prevents context overflow, improved deep-summary prompt quality |
600
602
  | **v9.3.1** | **Audit & polish** — VIDEO_SPEED 1.5→1.6, `--exclude-docs` flag for non-interactive deep-summary exclusion, friendlier Gemini error messages, dead code removal, DRY RUN_PRESETS |
601
603
  | **v9.3.0** | **Deep summary** — `--deep-summary` pre-summarizes context documents (60-80% token savings), interactive doc picker, `--exclude-docs` for CLI automation, batch processing |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-summary-extractor",
3
- "version": "9.5.0",
3
+ "version": "9.6.0",
4
4
  "description": "AI-powered meeting analysis & document generation CLI — video + document processing, deep dive docs, dynamic mode, interactive CLI with model selection, confidence scoring, learning loop, git progress tracking",
5
5
  "main": "process_and_upload.js",
6
6
  "bin": {
package/src/utils/cli.js CHANGED
@@ -18,6 +18,7 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const { c } = require('./colors');
21
+ const { selectOne, selectMany } = require('./interactive');
21
22
 
22
23
  /**
23
24
  * Parse command-line arguments into flags and positional args.
@@ -166,7 +167,6 @@ function discoverFolders(projectRoot) {
166
167
  * @returns {Promise<string|null>} - Folder name or null
167
168
  */
168
169
  async function selectFolder(projectRoot) {
169
- const readline = require('readline');
170
170
  const folders = discoverFolders(projectRoot);
171
171
 
172
172
  if (folders.length === 0) {
@@ -177,37 +177,24 @@ async function selectFolder(projectRoot) {
177
177
  return null;
178
178
  }
179
179
 
180
- console.log('');
181
- console.log(c.heading(' 📂 Available Folders'));
182
- console.log(c.dim(' ' + '─'.repeat(50)));
183
- folders.forEach((f, i) => {
180
+ const items = folders.map(f => {
184
181
  const icon = f.hasVideo ? '🎥' : f.hasAudio ? '🎵' : '📄';
185
- const num = c.cyan(`[${i + 1}]`);
186
- const name = c.bold(f.name);
187
- const desc = c.dim(f.description);
188
182
  const mode = (!f.hasVideo && !f.hasAudio) ? c.yellow(' (docs only)') : '';
189
- console.log(` ${num} ${icon} ${name} ${desc}${mode}`);
183
+ return {
184
+ label: `${icon} ${c.bold(f.name)}${mode}`,
185
+ hint: f.description,
186
+ value: f.name,
187
+ };
190
188
  });
191
- console.log('');
192
-
193
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
194
- return new Promise(resolve => {
195
- rl.question(' Select folder (number, or type a path): ', answer => {
196
- rl.close();
197
- const trimmed = (answer || '').trim();
198
- if (!trimmed) { resolve(null); return; }
199
-
200
- // Number selection
201
- const num = parseInt(trimmed, 10);
202
- if (!isNaN(num) && num >= 1 && num <= folders.length) {
203
- resolve(folders[num - 1].name);
204
- return;
205
- }
206
189
 
207
- // Direct path input
208
- resolve(trimmed);
209
- });
190
+ const result = await selectOne({
191
+ title: c.bold('📂 Select Folder'),
192
+ items,
193
+ default: 0,
194
+ footer: '↑↓ navigate · Enter select',
210
195
  });
196
+
197
+ return result.value;
211
198
  }
212
199
 
213
200
  // ======================== INTERACTIVE MODEL SELECTOR ========================
@@ -232,24 +219,26 @@ function fmtContext(tokens) {
232
219
  * @returns {Promise<string>} Selected model ID
233
220
  */
234
221
  async function selectModel(GEMINI_MODELS, currentModel) {
235
- const readline = require('readline');
236
222
  const modelIds = Object.keys(GEMINI_MODELS);
237
223
 
238
- // Group by tier for organized display
239
- const tiers = {
240
- premium: { label: 'Premium (highest quality)', icon: '🏆', models: [] },
241
- balanced: { label: 'Balanced (recommended)', icon: '⚡', models: [] },
242
- economy: { label: 'Economy (lowest cost)', icon: '💰', models: [] },
243
- };
224
+ // Build items with tier grouping info in hints
225
+ const items = [];
226
+ let defaultIdx = 0;
244
227
 
245
- let idx = 0;
246
- const indexMap = {}; // index → modelId
247
228
  for (const id of modelIds) {
248
229
  const m = GEMINI_MODELS[id];
249
- const tier = tiers[m.tier] || tiers.economy;
250
- idx++;
251
- indexMap[idx] = id;
252
- tier.models.push({ idx, id, ...m });
230
+ const thinkTag = m.thinking ? c.magenta(' [thinking]') : '';
231
+ const ctxStr = fmtContext(m.contextWindow);
232
+ const tierIcon = m.tier === 'premium' ? '🏆' : m.tier === 'balanced' ? '⚡' : '💰';
233
+ const costLabel = m.costEstimate || '';
234
+
235
+ if (id === currentModel) defaultIdx = items.length;
236
+
237
+ items.push({
238
+ label: `${tierIcon} ${c.bold(m.name)}${thinkTag}`,
239
+ hint: `${ctxStr} ctx · ${costLabel} · ${m.description}`,
240
+ value: id,
241
+ });
253
242
  }
254
243
 
255
244
  console.log('');
@@ -257,79 +246,14 @@ async function selectModel(GEMINI_MODELS, currentModel) {
257
246
  console.log(c.heading(' │ 🤖 Gemini Model Selection │'));
258
247
  console.log(c.heading(' └──────────────────────────────────────────────────────────────────────────────┘'));
259
248
 
260
- for (const [, tier] of Object.entries(tiers)) {
261
- if (tier.models.length === 0) continue;
262
- console.log('');
263
- console.log(` ${tier.icon} ${c.bold(tier.label)}`);
264
- console.log(c.dim(' ' + '─'.repeat(76)));
265
-
266
- for (const m of tier.models) {
267
- const isDefault = m.id === currentModel;
268
- const marker = isDefault ? c.green(' ← default') : '';
269
- const thinkTag = m.thinking ? c.magenta(' [thinking]') : '';
270
-
271
- // Line 1: number, name, description
272
- console.log(` ${c.cyan(`[${m.idx}]`)} ${c.bold(m.name)}${thinkTag}${marker}`);
273
- console.log(` ${c.dim(m.description)}`);
274
-
275
- // Line 2: specs
276
- const ctxStr = fmtContext(m.contextWindow);
277
- const outStr = fmtContext(m.maxOutput);
278
- const inPrice = `$${m.pricing.inputPerM.toFixed(m.pricing.inputPerM < 0.1 ? 4 : 2)}/1M in`;
279
- const outPrice = `$${m.pricing.outputPerM.toFixed(m.pricing.outputPerM < 1 ? 2 : 2)}/1M out`;
280
- const thinkPrice = m.thinking ? ` · $${m.pricing.thinkingPerM.toFixed(2)}/1M think` : '';
281
- console.log(` ${c.dim('Context:')} ${ctxStr} · ${c.dim('Max output:')} ${outStr} · ${c.highlight(m.costEstimate)}`);
282
- console.log(` ${c.dim('Pricing:')} ${inPrice} · ${outPrice}${thinkPrice}`);
283
- }
284
- }
285
-
286
- console.log('');
287
-
288
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
289
- return new Promise(resolve => {
290
- rl.question(` Select model [1-${idx}] (Enter = keep default): `, answer => {
291
- rl.close();
292
- const trimmed = (answer || '').trim();
293
-
294
- // Enter = keep default
295
- if (!trimmed) {
296
- console.log(c.success(`Using ${GEMINI_MODELS[currentModel].name}`));
297
- resolve(currentModel);
298
- return;
299
- }
300
-
301
- // Number selection
302
- const num = parseInt(trimmed, 10);
303
- if (!isNaN(num) && indexMap[num]) {
304
- const chosen = indexMap[num];
305
- console.log(c.success(`Selected ${GEMINI_MODELS[chosen].name}`));
306
- resolve(chosen);
307
- return;
308
- }
309
-
310
- // Direct model ID input
311
- if (GEMINI_MODELS[trimmed]) {
312
- console.log(c.success(`Selected ${GEMINI_MODELS[trimmed].name}`));
313
- resolve(trimmed);
314
- return;
315
- }
316
-
317
- // Fuzzy match: partial name
318
- const lower = trimmed.toLowerCase();
319
- const match = modelIds.find(id =>
320
- id.toLowerCase().includes(lower) ||
321
- GEMINI_MODELS[id].name.toLowerCase().includes(lower)
322
- );
323
- if (match) {
324
- console.log(c.success(`Matched ${GEMINI_MODELS[match].name}`));
325
- resolve(match);
326
- return;
327
- }
328
-
329
- console.log(c.warn(`Unknown selection "${trimmed}" — using default (${currentModel})`));
330
- resolve(currentModel);
331
- });
249
+ const result = await selectOne({
250
+ title: null, // banner already printed
251
+ items,
252
+ default: defaultIdx,
253
+ footer: '↑↓ navigate · Enter select',
332
254
  });
255
+
256
+ return result.value;
333
257
  }
334
258
 
335
259
  /**
@@ -552,59 +476,32 @@ module.exports.RUN_PRESETS = RUN_PRESETS;
552
476
  * @returns {Promise<string>} Preset key: 'fast' | 'balanced' | 'detailed' | 'custom'
553
477
  */
554
478
  async function selectRunMode() {
555
- const readline = require('readline');
556
479
  const presetKeys = Object.keys(RUN_PRESETS);
557
480
 
558
481
  console.log('');
559
482
  console.log(c.heading(' ┌──────────────────────────────────────────────────────────────────────────────┐'));
560
483
  console.log(c.heading(' │ 🚀 Run Mode │'));
561
484
  console.log(c.heading(' └──────────────────────────────────────────────────────────────────────────────┘'));
562
- console.log('');
563
485
 
564
- presetKeys.forEach((key, i) => {
486
+ const defaultIdx = presetKeys.indexOf('balanced');
487
+
488
+ const items = presetKeys.map(key => {
565
489
  const p = RUN_PRESETS[key];
566
- const num = c.cyan(`[${i + 1}]`);
567
- const label = c.bold(`${p.icon} ${p.label}`);
568
- const desc = c.dim(p.description);
569
- const marker = key === 'balanced' ? c.green(' ← default') : '';
570
- console.log(` ${num} ${label}${marker}`);
571
- console.log(` ${desc}`);
490
+ return {
491
+ label: `${p.icon} ${c.bold(p.label)}`,
492
+ hint: p.description,
493
+ value: key,
494
+ };
572
495
  });
573
- console.log('');
574
496
 
575
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
576
- return new Promise(resolve => {
577
- rl.question(' Select run mode [1-4] (Enter = balanced): ', answer => {
578
- rl.close();
579
- const trimmed = (answer || '').trim();
580
-
581
- if (!trimmed) {
582
- console.log(c.success('Using balanced mode'));
583
- resolve('balanced');
584
- return;
585
- }
586
-
587
- const num = parseInt(trimmed, 10);
588
- if (num >= 1 && num <= presetKeys.length) {
589
- const key = presetKeys[num - 1];
590
- console.log(c.success(`Using ${RUN_PRESETS[key].label} mode`));
591
- resolve(key);
592
- return;
593
- }
594
-
595
- // Try matching by name
596
- const lower = trimmed.toLowerCase();
597
- const match = presetKeys.find(k => k === lower || RUN_PRESETS[k].label.toLowerCase() === lower);
598
- if (match) {
599
- console.log(c.success(`Using ${RUN_PRESETS[match].label} mode`));
600
- resolve(match);
601
- return;
602
- }
603
-
604
- console.log(c.warn(`Unknown "${trimmed}" — using balanced mode`));
605
- resolve('balanced');
606
- });
497
+ const result = await selectOne({
498
+ title: null, // banner already printed
499
+ items,
500
+ default: defaultIdx >= 0 ? defaultIdx : 0,
501
+ footer: '↑↓ navigate · Enter select',
607
502
  });
503
+
504
+ return result.value;
608
505
  }
609
506
 
610
507
  // ======================== FORMAT PICKER ========================
@@ -624,56 +521,27 @@ const ALL_FORMATS = [
624
521
  * @returns {Promise<Set<string>>}
625
522
  */
626
523
  async function selectFormats() {
627
- const readline = require('readline');
628
-
629
- console.log('');
630
- console.log(` ${c.bold('📦 Output Formats')} ${c.dim('(select one or more)')}`);
631
- console.log(c.dim(' ' + '─'.repeat(50)));
632
-
633
- ALL_FORMATS.forEach((f, i) => {
634
- const num = c.cyan(`[${i + 1}]`);
635
- console.log(` ${num} ${f.icon} ${c.bold(f.label.padEnd(12))} ${c.dim(f.desc)}`);
524
+ const items = ALL_FORMATS.map(f => ({
525
+ label: `${f.icon} ${c.bold(f.label)}`,
526
+ hint: f.desc,
527
+ value: f.key,
528
+ }));
529
+
530
+ // Default: all selected
531
+ const defaultSelected = new Set(items.map((_, i) => i));
532
+
533
+ const result = await selectMany({
534
+ title: c.bold('📦 Output Formats'),
535
+ items,
536
+ defaultSelected,
636
537
  });
637
- console.log(` ${c.cyan('[A]')} ${c.bold('All formats')}`);
638
- console.log('');
639
-
640
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
641
- return new Promise(resolve => {
642
- rl.question(' Formats (e.g. 1,2,3 or A for all) [Enter = all]: ', answer => {
643
- rl.close();
644
- const trimmed = (answer || '').trim().toLowerCase();
645
-
646
- if (!trimmed || trimmed === 'a' || trimmed === 'all') {
647
- const all = new Set(ALL_FORMATS.map(f => f.key));
648
- console.log(c.success('All formats selected'));
649
- resolve(all);
650
- return;
651
- }
652
538
 
653
- const parts = trimmed.split(/[\s,]+/).filter(Boolean);
654
- const chosen = new Set();
655
- for (const p of parts) {
656
- const num = parseInt(p, 10);
657
- if (num >= 1 && num <= ALL_FORMATS.length) {
658
- chosen.add(ALL_FORMATS[num - 1].key);
659
- } else {
660
- // Try matching format key by name
661
- const match = ALL_FORMATS.find(f => f.key === p || f.label.toLowerCase() === p);
662
- if (match) chosen.add(match.key);
663
- }
664
- }
665
-
666
- if (chosen.size === 0) {
667
- console.log(c.warn('No valid formats selected — using all'));
668
- resolve(new Set(ALL_FORMATS.map(f => f.key)));
669
- return;
670
- }
539
+ // If none selected, default to all
540
+ if (result.values.length === 0) {
541
+ return new Set(ALL_FORMATS.map(f => f.key));
542
+ }
671
543
 
672
- const labels = [...chosen].map(k => ALL_FORMATS.find(f => f.key === k)?.label || k);
673
- console.log(c.success(`Formats: ${labels.join(', ')}`));
674
- resolve(chosen);
675
- });
676
- });
544
+ return new Set(result.values);
677
545
  }
678
546
 
679
547
  // ======================== CONFIDENCE PICKER ========================
@@ -685,59 +553,27 @@ async function selectFormats() {
685
553
  * @returns {Promise<string|null>}
686
554
  */
687
555
  async function selectConfidence() {
688
- const readline = require('readline');
689
-
690
556
  const levels = [
691
- { key: null, icon: '🌐', label: 'All', desc: 'Keep everything — no filtering' },
692
- { key: 'low', icon: '🟡', label: 'Low+', desc: 'Keep low, medium & high confidence' },
557
+ { key: null, icon: '🌐', label: 'All', desc: 'Keep everything — no filtering' },
558
+ { key: 'low', icon: '🟡', label: 'Low+', desc: 'Keep low, medium & high confidence' },
693
559
  { key: 'medium', icon: '🟠', label: 'Medium+', desc: 'Keep medium & high confidence' },
694
- { key: 'high', icon: '🔴', label: 'High', desc: 'Only high-confidence items' },
560
+ { key: 'high', icon: '🔴', label: 'High', desc: 'Only high-confidence items' },
695
561
  ];
696
562
 
697
- console.log('');
698
- console.log(` ${c.bold('🎯 Confidence Filter')}`);
699
- console.log(c.dim(' ' + '─'.repeat(50)));
700
-
701
- levels.forEach((l, i) => {
702
- const num = c.cyan(`[${i + 1}]`);
703
- const marker = i === 0 ? c.green(' ← default') : '';
704
- console.log(` ${num} ${l.icon} ${c.bold(l.label.padEnd(10))} ${c.dim(l.desc)}${marker}`);
563
+ const items = levels.map(l => ({
564
+ label: `${l.icon} ${c.bold(l.label)}`,
565
+ hint: l.desc,
566
+ value: l.key,
567
+ }));
568
+
569
+ const result = await selectOne({
570
+ title: c.bold('🎯 Confidence Filter'),
571
+ items,
572
+ default: 0,
573
+ footer: '↑↓ navigate · Enter select',
705
574
  });
706
- console.log('');
707
-
708
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
709
- return new Promise(resolve => {
710
- rl.question(' Confidence filter [1-4] (Enter = keep all): ', answer => {
711
- rl.close();
712
- const trimmed = (answer || '').trim();
713
-
714
- if (!trimmed) {
715
- console.log(c.success('Keeping all confidence levels'));
716
- resolve(null);
717
- return;
718
- }
719
575
 
720
- const num = parseInt(trimmed, 10);
721
- if (num >= 1 && num <= levels.length) {
722
- const chosen = levels[num - 1];
723
- console.log(c.success(`Confidence filter: ${chosen.label}`));
724
- resolve(chosen.key);
725
- return;
726
- }
727
-
728
- // Try matching by name
729
- const lower = trimmed.toLowerCase();
730
- const match = levels.find(l => l.key === lower || l.label.toLowerCase() === lower);
731
- if (match) {
732
- console.log(c.success(`Confidence filter: ${match.label}`));
733
- resolve(match.key);
734
- return;
735
- }
736
-
737
- console.log(c.warn(`Unknown "${trimmed}" — keeping all`));
738
- resolve(null);
739
- });
740
- });
576
+ return result.value;
741
577
  }
742
578
 
743
579
  // ======================== DEEP SUMMARY DOC EXCLUSION PICKER ========================
@@ -750,8 +586,6 @@ async function selectConfidence() {
750
586
  * @returns {Promise<string[]>} Array of excluded fileName strings
751
587
  */
752
588
  async function selectDocsToExclude(contextDocs) {
753
- const readline = require('readline');
754
-
755
589
  // Only show inlineText docs with actual content
756
590
  const eligible = contextDocs
757
591
  .filter(d => d.type === 'inlineText' && d.content && d.content.length > 0)
@@ -764,58 +598,28 @@ async function selectDocsToExclude(contextDocs) {
764
598
  if (eligible.length === 0) return [];
765
599
 
766
600
  console.log('');
767
- console.log(` ${c.bold('📋 Deep Summary Choose What to Keep in Full')}`);
768
- console.log(c.dim(' ' + '─'.repeat(60)));
769
- console.log('');
770
- console.log(` ${c.dim('To save processing time, we can create short summaries of your')}`);
771
- console.log(` ${c.dim('reference documents. The AI will still read them — just faster.')}`);
772
- console.log('');
773
- console.log(` ${c.bold('If a document is especially important to you, select it below')}`);
774
- console.log(` ${c.bold('to keep it in full.')} The rest will be smartly condensed.`);
601
+ console.log(` ${c.dim('Deep Summary will create short summaries of your reference documents.')}`);
602
+ console.log(` ${c.bold('Select documents to keep in FULL')} ${c.dim('(the rest will be condensed).')}`);
775
603
  console.log('');
776
604
 
777
- eligible.forEach((d, i) => {
778
- const num = c.cyan(`[${i + 1}]`);
605
+ const items = eligible.map(d => {
779
606
  const size = d.tokensEst >= 1000
780
- ? c.dim(`~${(d.tokensEst / 1000).toFixed(0)}K words`)
781
- : c.dim(`~${d.tokensEst} words`);
782
- console.log(` ${num} ${c.bold(d.fileName)} ${size}`);
607
+ ? `~${(d.tokensEst / 1000).toFixed(0)}K words`
608
+ : `~${d.tokensEst} words`;
609
+ return {
610
+ label: c.bold(d.fileName),
611
+ hint: size,
612
+ value: d.fileName,
613
+ };
783
614
  });
784
- console.log('');
785
- console.log(c.dim(' Tip: Enter = condense all · Type numbers to keep full (e.g. 1,3)'));
786
- console.log('');
787
615
 
788
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
789
- return new Promise(resolve => {
790
- rl.question(' Keep full (e.g. 1,3 or Enter = condense all): ', answer => {
791
- rl.close();
792
- const trimmed = (answer || '').trim();
793
-
794
- if (!trimmed) {
795
- console.log(c.success('Got it — all documents will be condensed for faster processing'));
796
- resolve([]);
797
- return;
798
- }
799
-
800
- const parts = trimmed.split(/[\s,]+/).filter(Boolean);
801
- const excluded = [];
802
-
803
- for (const p of parts) {
804
- const num = parseInt(p, 10);
805
- if (num >= 1 && num <= eligible.length) {
806
- excluded.push(eligible[num - 1].fileName);
807
- }
808
- }
809
-
810
- if (excluded.length === 0) {
811
- console.log(c.warn('No valid selections — condensing all documents'));
812
- resolve([]);
813
- return;
814
- }
815
-
816
- console.log(c.success(`Keeping ${excluded.length} doc(s) in full — the rest will be condensed:`));
817
- excluded.forEach(f => console.log(` ${c.dim('•')} ${c.cyan(f)}`));
818
- resolve(excluded);
819
- });
616
+ // Default: none selected (all will be condensed)
617
+ const result = await selectMany({
618
+ title: c.bold('📋 Deep Summary Keep in Full'),
619
+ items,
620
+ defaultSelected: new Set(),
621
+ footer: '↑↓ navigate · Space toggle · A all/none · Enter confirm (Enter = condense all)',
820
622
  });
623
+
624
+ return result.values;
821
625
  }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Interactive prompt engine — zero dependencies, arrow-key navigation.
3
+ *
4
+ * Provides:
5
+ * selectOne() — single-select with ↑↓ arrows + Enter
6
+ * selectMany() — multi-select with ↑↓ arrows + Space (toggle) + Enter (confirm)
7
+ *
8
+ * Falls back to number input when stdin is not a TTY.
9
+ *
10
+ * Rendering strategy:
11
+ * After every draw(), the terminal cursor sits on the LAST rendered line
12
+ * (bottom of the block). Before each subsequent redraw we move UP by
13
+ * (totalRenderedLines − 1) to reach the first item line, then overwrite
14
+ * every line top-to-bottom. This keeps positioning deterministic and
15
+ * avoids the overlap / garble bugs of relative-to-highlight approaches.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { c, strip } = require('./colors');
21
+
22
+ // ── ANSI helpers ──────────────────────────────────────────────────────────────
23
+
24
+ const HIDE_CURSOR = '\x1b[?25l';
25
+ const SHOW_CURSOR = '\x1b[?25h';
26
+ const CLEAR_LINE = '\x1b[2K';
27
+
28
+ /** Move cursor up N lines */
29
+ const UP = (n) => n > 0 ? `\x1b[${n}A` : '';
30
+ /** Move cursor down N lines */
31
+ const DOWN = (n) => n > 0 ? `\x1b[${n}B` : '';
32
+ /** Carriage return — column 0 */
33
+ const CR = '\r';
34
+
35
+ // ── Render helpers ────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Build display strings for each item.
39
+ *
40
+ * @param {Array<{label: string, hint?: string}>} items
41
+ * @param {number} cursor Currently highlighted index
42
+ * @param {Set<number>} [selected] For multi-select: selected indices
43
+ * @param {boolean} [multi=false]
44
+ * @returns {string[]}
45
+ */
46
+ function renderList(items, cursor, selected, multi = false) {
47
+ return items.map((item, i) => {
48
+ const isCursor = i === cursor;
49
+ const prefix = isCursor ? c.cyan('❯') : ' ';
50
+
51
+ let checkbox = '';
52
+ if (multi) {
53
+ const isChecked = selected && selected.has(i);
54
+ checkbox = isChecked ? c.green(' ◉') : c.dim(' ○');
55
+ }
56
+
57
+ const label = isCursor ? c.bold(c.cyan(strip(item.label))) : item.label;
58
+ const hint = item.hint
59
+ ? (isCursor ? c.dim(` ${strip(item.hint)}`) : c.dim(` ${item.hint}`))
60
+ : '';
61
+
62
+ return ` ${prefix}${checkbox} ${label}${hint}`;
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Write an array of strings to stdout, one per line.
68
+ * Each line is preceded by CR + CLEAR_LINE so the entire row is wiped first.
69
+ */
70
+ function writeLines(lines) {
71
+ for (let i = 0; i < lines.length; i++) {
72
+ if (i > 0) process.stdout.write('\n');
73
+ process.stdout.write(`${CR}${CLEAR_LINE}${lines[i]}`);
74
+ }
75
+ }
76
+
77
+ // ── Key decoder ───────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Decode a raw keypress buffer into a named action.
81
+ * @param {Buffer} buf
82
+ * @returns {'up'|'down'|'space'|'enter'|'escape'|'a'|null}
83
+ */
84
+ function decodeKey(buf) {
85
+ if (buf[0] === 0x1b && buf[1] === 0x5b) {
86
+ if (buf[2] === 0x41) return 'up';
87
+ if (buf[2] === 0x42) return 'down';
88
+ return null;
89
+ }
90
+ if (buf[0] === 0x0d || buf[0] === 0x0a) return 'enter';
91
+ if (buf[0] === 0x20) return 'space';
92
+ if (buf[0] === 0x03) return 'escape';
93
+ if (buf[0] === 0x61 || buf[0] === 0x41) return 'a';
94
+ return null;
95
+ }
96
+
97
+ // ── Core: selectOne ───────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Interactive single-select with arrow-key navigation.
101
+ *
102
+ * @param {Object} opts
103
+ * @param {string} opts.title - Heading text (printed once)
104
+ * @param {Array<{label: string, hint?: string, value: any}>} opts.items
105
+ * @param {number} [opts.default=0] - Default highlighted index
106
+ * @param {string} [opts.footer] - Hint line below the list
107
+ * @returns {Promise<{index: number, value: any}>}
108
+ */
109
+ function selectOne({ title, items, default: defaultIdx = 0, footer }) {
110
+ if (!process.stdin.isTTY) {
111
+ return _fallbackSelectOne({ title, items, default: defaultIdx });
112
+ }
113
+
114
+ return new Promise((resolve) => {
115
+ let cursor = defaultIdx;
116
+ const total = items.length;
117
+ const hasFooter = !!footer;
118
+ const renderedLines = total + (hasFooter ? 1 : 0); // lines we overwrite
119
+ let firstDraw = true;
120
+
121
+ // ── Title (printed once, never overwritten) ────────
122
+ if (title) {
123
+ console.log('');
124
+ console.log(` ${title}`);
125
+ console.log(c.dim(' ' + '─'.repeat(60)));
126
+ }
127
+
128
+ process.stdout.write(HIDE_CURSOR);
129
+
130
+ // ── Draw / redraw ──────────────────────────────────
131
+ const draw = () => {
132
+ if (!firstDraw) {
133
+ // Terminal cursor is on the last rendered line — go back to first
134
+ process.stdout.write(UP(renderedLines - 1) + CR);
135
+ }
136
+ const lines = renderList(items, cursor);
137
+ writeLines(lines);
138
+ if (hasFooter) {
139
+ process.stdout.write('\n');
140
+ process.stdout.write(`${CR}${CLEAR_LINE}${c.dim(` ${footer}`)}`);
141
+ }
142
+ // Terminal cursor is now on the LAST rendered line
143
+ firstDraw = false;
144
+ };
145
+
146
+ draw(); // initial render
147
+
148
+ process.stdin.setRawMode(true);
149
+ process.stdin.resume();
150
+
151
+ const cleanup = () => {
152
+ process.stdin.setRawMode(false);
153
+ process.stdin.pause();
154
+ process.stdin.removeListener('data', onKey);
155
+ process.stdout.write(CR + SHOW_CURSOR + '\n');
156
+ };
157
+
158
+ const onKey = (buf) => {
159
+ const key = decodeKey(buf);
160
+ if (key === 'up') {
161
+ cursor = (cursor - 1 + total) % total;
162
+ draw();
163
+ } else if (key === 'down') {
164
+ cursor = (cursor + 1) % total;
165
+ draw();
166
+ } else if (key === 'enter') {
167
+ cleanup();
168
+ const chosen = items[cursor];
169
+ console.log(c.success(`${strip(chosen.label)}`));
170
+ resolve({ index: cursor, value: chosen.value });
171
+ } else if (key === 'escape') {
172
+ cleanup();
173
+ const chosen = items[defaultIdx];
174
+ console.log(c.success(`${strip(chosen.label)}`));
175
+ resolve({ index: defaultIdx, value: chosen.value });
176
+ }
177
+ };
178
+
179
+ process.stdin.on('data', onKey);
180
+ });
181
+ }
182
+
183
+ // ── Core: selectMany ──────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Interactive multi-select with arrow-key navigation + Space toggle.
187
+ *
188
+ * @param {Object} opts
189
+ * @param {string} opts.title
190
+ * @param {Array<{label: string, hint?: string, value: any}>} opts.items
191
+ * @param {Set<number>} [opts.defaultSelected] - Pre-selected indices
192
+ * @param {string} [opts.footer]
193
+ * @returns {Promise<{indices: number[], values: any[]}>}
194
+ */
195
+ function selectMany({ title, items, defaultSelected, footer }) {
196
+ if (!process.stdin.isTTY) {
197
+ return _fallbackSelectMany({ title, items, defaultSelected });
198
+ }
199
+
200
+ return new Promise((resolve) => {
201
+ let cursor = 0;
202
+ const selected = new Set(defaultSelected || []);
203
+ const total = items.length;
204
+
205
+ const footerText = footer || '↑↓ navigate · Space toggle · A all/none · Enter confirm';
206
+ const renderedLines = total + 1; // items + footer (always shown)
207
+ let firstDraw = true;
208
+
209
+ if (title) {
210
+ console.log('');
211
+ console.log(` ${title}`);
212
+ console.log(c.dim(' ' + '─'.repeat(60)));
213
+ }
214
+
215
+ process.stdout.write(HIDE_CURSOR);
216
+
217
+ const draw = () => {
218
+ if (!firstDraw) {
219
+ process.stdout.write(UP(renderedLines - 1) + CR);
220
+ }
221
+ const lines = renderList(items, cursor, selected, true);
222
+ writeLines(lines);
223
+ process.stdout.write('\n');
224
+ process.stdout.write(`${CR}${CLEAR_LINE}${c.dim(` ${footerText}`)}`);
225
+ firstDraw = false;
226
+ };
227
+
228
+ draw();
229
+
230
+ process.stdin.setRawMode(true);
231
+ process.stdin.resume();
232
+
233
+ const cleanup = () => {
234
+ process.stdin.setRawMode(false);
235
+ process.stdin.pause();
236
+ process.stdin.removeListener('data', onKey);
237
+ process.stdout.write(CR + SHOW_CURSOR + '\n');
238
+ };
239
+
240
+ const onKey = (buf) => {
241
+ const key = decodeKey(buf);
242
+ if (key === 'up') {
243
+ cursor = (cursor - 1 + total) % total;
244
+ draw();
245
+ } else if (key === 'down') {
246
+ cursor = (cursor + 1) % total;
247
+ draw();
248
+ } else if (key === 'space') {
249
+ if (selected.has(cursor)) selected.delete(cursor);
250
+ else selected.add(cursor);
251
+ draw();
252
+ } else if (key === 'a') {
253
+ if (selected.size === total) selected.clear();
254
+ else { for (let i = 0; i < total; i++) selected.add(i); }
255
+ draw();
256
+ } else if (key === 'enter') {
257
+ cleanup();
258
+ const indices = [...selected].sort((a, b) => a - b);
259
+ const values = indices.map(i => items[i].value);
260
+ const labels = indices.map(i => strip(items[i].label));
261
+ if (labels.length === 0) {
262
+ console.log(c.dim(' None selected'));
263
+ } else if (labels.length === total) {
264
+ console.log(c.success('All selected'));
265
+ } else {
266
+ console.log(c.success(labels.join(', ')));
267
+ }
268
+ resolve({ indices, values });
269
+ } else if (key === 'escape') {
270
+ cleanup();
271
+ const indices = [...(defaultSelected || [])].sort((a, b) => a - b);
272
+ const values = indices.map(i => items[i].value);
273
+ resolve({ indices, values });
274
+ }
275
+ };
276
+
277
+ process.stdin.on('data', onKey);
278
+ });
279
+ }
280
+
281
+ // ── Fallback implementations (non-TTY) ───────────────────────────────────────
282
+
283
+ async function _fallbackSelectOne({ title, items, default: defaultIdx }) {
284
+ const readline = require('readline');
285
+ if (title) {
286
+ console.log('');
287
+ console.log(` ${title}`);
288
+ console.log(c.dim(' ' + '─'.repeat(50)));
289
+ }
290
+
291
+ items.forEach((item, i) => {
292
+ const marker = i === defaultIdx ? c.green(' ← default') : '';
293
+ console.log(` ${c.cyan(`[${i + 1}]`)} ${item.label}${item.hint ? c.dim(` ${item.hint}`) : ''}${marker}`);
294
+ });
295
+ console.log('');
296
+
297
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
298
+ return new Promise(resolve => {
299
+ rl.question(` Select [1-${items.length}] (Enter = default): `, answer => {
300
+ rl.close();
301
+ const trimmed = (answer || '').trim();
302
+ if (!trimmed) {
303
+ resolve({ index: defaultIdx, value: items[defaultIdx].value });
304
+ return;
305
+ }
306
+ const num = parseInt(trimmed, 10);
307
+ if (num >= 1 && num <= items.length) {
308
+ resolve({ index: num - 1, value: items[num - 1].value });
309
+ return;
310
+ }
311
+ resolve({ index: defaultIdx, value: items[defaultIdx].value });
312
+ });
313
+ });
314
+ }
315
+
316
+ async function _fallbackSelectMany({ title, items, defaultSelected }) {
317
+ const readline = require('readline');
318
+ if (title) {
319
+ console.log('');
320
+ console.log(` ${title}`);
321
+ console.log(c.dim(' ' + '─'.repeat(50)));
322
+ }
323
+
324
+ items.forEach((item, i) => {
325
+ console.log(` ${c.cyan(`[${i + 1}]`)} ${item.label}${item.hint ? c.dim(` ${item.hint}`) : ''}`);
326
+ });
327
+ console.log(` ${c.cyan('[A]')} All`);
328
+ console.log('');
329
+
330
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
331
+ return new Promise(resolve => {
332
+ rl.question(' Select (e.g. 1,3 or A for all) [Enter = all]: ', answer => {
333
+ rl.close();
334
+ const trimmed = (answer || '').trim().toLowerCase();
335
+ if (!trimmed || trimmed === 'a' || trimmed === 'all') {
336
+ const all = items.map((_, i) => i);
337
+ resolve({ indices: all, values: items.map(it => it.value) });
338
+ return;
339
+ }
340
+ const parts = trimmed.split(/[\s,]+/).filter(Boolean);
341
+ const indices = [];
342
+ for (const p of parts) {
343
+ const num = parseInt(p, 10);
344
+ if (num >= 1 && num <= items.length) indices.push(num - 1);
345
+ }
346
+ if (indices.length === 0) {
347
+ const all = items.map((_, i) => i);
348
+ resolve({ indices: all, values: items.map(it => it.value) });
349
+ return;
350
+ }
351
+ resolve({ indices, values: indices.map(i => items[i].value) });
352
+ });
353
+ });
354
+ }
355
+
356
+ module.exports = { selectOne, selectMany };