task-summary-extractor 9.4.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.
@@ -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 };