@saulwade/swl-ses 1.5.2 → 1.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/CLAUDE.md +1 -1
- package/README.md +19 -2
- package/bin/swl-ses.js +49 -7
- package/hooks/extraccion-aprendizajes.js +11 -0
- package/manifiestos/skills-lock.json +46 -18
- package/package.json +4 -3
- package/plugin.json +3 -1
- package/scripts/desinstalar.js +105 -24
- package/scripts/instalador.js +55 -4
- package/scripts/lib/parsear-opciones.js +3 -0
- package/scripts/lib/ui.js +148 -22
- package/scripts/tui/componentes/selector-multi.js +189 -0
- package/scripts/tui/componentes/selector-unico.js +158 -0
- package/scripts/tui/ejecutores.js +375 -0
- package/scripts/tui/index.js +162 -0
- package/scripts/tui/lib/colores.js +129 -0
- package/scripts/tui/lib/render.js +264 -0
- package/scripts/tui/lib/teclas.js +113 -0
- package/scripts/tui/pantallas/inspect.js +173 -0
- package/scripts/tui/pantallas/install-wizard.js +334 -0
- package/scripts/tui/pantallas/menu-principal.js +52 -0
- package/scripts/tui/pantallas/progreso.js +274 -0
- package/scripts/tui/pantallas/resumen.js +132 -0
- package/scripts/tui/pantallas/uninstall-wizard.js +208 -0
- package/scripts/tui/pantallas/update-wizard.js +232 -0
- package/scripts/tui/pantallas/welcome.js +187 -0
- package/scripts/verificar-docs-vs-codigo.js +425 -0
package/scripts/lib/ui.js
CHANGED
|
@@ -82,6 +82,25 @@ function encabezado(titulo, version) {
|
|
|
82
82
|
// Spinner
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
84
|
|
|
85
|
+
// Registro global de spinners activos. Permite que las funciones interactivas
|
|
86
|
+
// (preguntarSiNo, preguntarOpcion, preguntarTexto) pausen cualquier spinner en
|
|
87
|
+
// curso antes de crear un readline, evitando que el setInterval del spinner
|
|
88
|
+
// sobreescriba el prompt con `\r`. Origen: bug observado en `swl-ses update`
|
|
89
|
+
// cuando el instalador hace preguntarSiNo dentro del spinner del actualizador.
|
|
90
|
+
const _spinnersActivos = new Set();
|
|
91
|
+
|
|
92
|
+
function _pausarSpinnersActivos() {
|
|
93
|
+
for (const sp of _spinnersActivos) {
|
|
94
|
+
sp._pausar();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _reanudarSpinnersActivos() {
|
|
99
|
+
for (const sp of _spinnersActivos) {
|
|
100
|
+
sp._reanudar();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
function spinner(mensaje) {
|
|
86
105
|
if (!SOPORTA_COLOR) {
|
|
87
106
|
// Sin TTY: solo mostrar mensaje estático
|
|
@@ -91,49 +110,96 @@ function spinner(mensaje) {
|
|
|
91
110
|
exito: (msg) => { console.log(` [OK] ${msg}`); },
|
|
92
111
|
fallo: (msg) => { console.log(` [ERROR] ${msg}`); },
|
|
93
112
|
detener: () => {},
|
|
113
|
+
_pausar: () => {},
|
|
114
|
+
_reanudar: () => {},
|
|
94
115
|
};
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
let frameIdx = 0;
|
|
98
119
|
let textoActual = mensaje;
|
|
99
120
|
let activo = true;
|
|
121
|
+
let pausado = false;
|
|
122
|
+
let intervalo = null;
|
|
100
123
|
|
|
101
|
-
|
|
102
|
-
if (!activo) return;
|
|
124
|
+
function _tick() {
|
|
125
|
+
if (!activo || pausado) return;
|
|
103
126
|
const frame = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
|
104
127
|
process.stdout.write(`\r ${color.cyan(frame)} ${textoActual} `);
|
|
105
128
|
frameIdx++;
|
|
106
|
-
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _arrancarIntervalo() {
|
|
132
|
+
if (intervalo) return;
|
|
133
|
+
intervalo = setInterval(_tick, 80);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _detenerIntervalo() {
|
|
137
|
+
if (intervalo) {
|
|
138
|
+
clearInterval(intervalo);
|
|
139
|
+
intervalo = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
107
142
|
|
|
108
143
|
function limpiarLinea() {
|
|
109
144
|
process.stdout.write('\r\x1b[2K');
|
|
110
145
|
}
|
|
111
146
|
|
|
147
|
+
_arrancarIntervalo();
|
|
148
|
+
|
|
149
|
+
// Declarado antes del handle para que las funciones de terminación puedan
|
|
150
|
+
// removerlo. Origen: F-1 nemesis iter-1 — sin remove, cada spinner deja un
|
|
151
|
+
// listener muerto en `process` y un loop con N runtimes acumula warnings
|
|
152
|
+
// de MaxListenersExceeded.
|
|
153
|
+
const exitHandler = () => { handle.detener(); };
|
|
154
|
+
|
|
112
155
|
const handle = {
|
|
113
156
|
actualizar(msg) {
|
|
114
157
|
textoActual = msg;
|
|
115
158
|
},
|
|
116
159
|
exito(msg) {
|
|
117
160
|
activo = false;
|
|
118
|
-
|
|
161
|
+
_detenerIntervalo();
|
|
162
|
+
_spinnersActivos.delete(handle);
|
|
163
|
+
process.removeListener('exit', exitHandler);
|
|
119
164
|
limpiarLinea();
|
|
120
165
|
console.log(` ${color.verde(icono.check)} ${msg}`);
|
|
121
166
|
},
|
|
122
167
|
fallo(msg) {
|
|
123
168
|
activo = false;
|
|
124
|
-
|
|
169
|
+
_detenerIntervalo();
|
|
170
|
+
_spinnersActivos.delete(handle);
|
|
171
|
+
process.removeListener('exit', exitHandler);
|
|
125
172
|
limpiarLinea();
|
|
126
173
|
console.log(` ${color.rojo(icono.cross)} ${msg}`);
|
|
127
174
|
},
|
|
128
175
|
detener() {
|
|
129
176
|
activo = false;
|
|
130
|
-
|
|
177
|
+
_detenerIntervalo();
|
|
178
|
+
_spinnersActivos.delete(handle);
|
|
179
|
+
process.removeListener('exit', exitHandler);
|
|
180
|
+
limpiarLinea();
|
|
181
|
+
},
|
|
182
|
+
_pausar() {
|
|
183
|
+
// Pausa el tick para que un readline pueda renderizar el prompt sin
|
|
184
|
+
// que el spinner lo sobreescriba con `\r`. Limpia la línea para que
|
|
185
|
+
// el prompt empiece en una columna libre.
|
|
186
|
+
if (pausado) return;
|
|
187
|
+
pausado = true;
|
|
188
|
+
_detenerIntervalo();
|
|
131
189
|
limpiarLinea();
|
|
132
190
|
},
|
|
191
|
+
_reanudar() {
|
|
192
|
+
// Reanuda el tick tras el cierre del readline. Solo si el spinner sigue
|
|
193
|
+
// lógicamente activo (no fue detenido durante la pausa).
|
|
194
|
+
if (!pausado || !activo) return;
|
|
195
|
+
pausado = false;
|
|
196
|
+
_arrancarIntervalo();
|
|
197
|
+
},
|
|
133
198
|
};
|
|
134
199
|
|
|
135
|
-
|
|
136
|
-
|
|
200
|
+
_spinnersActivos.add(handle);
|
|
201
|
+
|
|
202
|
+
// Limpieza si el proceso muere antes de exito/fallo/detener
|
|
137
203
|
process.once('exit', exitHandler);
|
|
138
204
|
|
|
139
205
|
return handle;
|
|
@@ -151,19 +217,36 @@ function preguntarSiNo(mensaje, valorDefault = true) {
|
|
|
151
217
|
}
|
|
152
218
|
|
|
153
219
|
const hint = valorDefault ? 'S/n' : 's/N';
|
|
220
|
+
_pausarSpinnersActivos();
|
|
154
221
|
const rl = readline.createInterface({
|
|
155
222
|
input: process.stdin,
|
|
156
223
|
output: process.stdout,
|
|
157
224
|
});
|
|
158
225
|
|
|
226
|
+
// El evento 'close' es la única ruta de finalización. Se garantiza que
|
|
227
|
+
// se dispare incluso si el callback de question no se ejecuta (Ctrl+C,
|
|
228
|
+
// EOF, error en stdin). Esto evita el bug donde los spinners quedaban
|
|
229
|
+
// permanentemente pausados tras un cierre prematuro del readline.
|
|
230
|
+
let valorFinal = valorDefault;
|
|
231
|
+
let resuelto = false;
|
|
232
|
+
|
|
233
|
+
function finalizar() {
|
|
234
|
+
if (resuelto) return;
|
|
235
|
+
resuelto = true;
|
|
236
|
+
_reanudarSpinnersActivos();
|
|
237
|
+
resolve(valorFinal);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
rl.on('close', finalizar);
|
|
241
|
+
|
|
159
242
|
rl.question(` ${mensaje} [${hint}] `, (respuesta) => {
|
|
160
|
-
rl.close();
|
|
161
243
|
const r = respuesta.trim().toLowerCase();
|
|
162
244
|
if (r === '') {
|
|
163
|
-
|
|
245
|
+
valorFinal = valorDefault;
|
|
164
246
|
} else {
|
|
165
|
-
|
|
247
|
+
valorFinal = r === 's' || r === 'si' || r === 'sí' || r === 'y' || r === 'yes';
|
|
166
248
|
}
|
|
249
|
+
rl.close(); // dispara 'close' → finalizar(); idempotente vía `resuelto`
|
|
167
250
|
});
|
|
168
251
|
});
|
|
169
252
|
}
|
|
@@ -185,6 +268,11 @@ function preguntarOpcion(titulo, opciones, opts = {}) {
|
|
|
185
268
|
return;
|
|
186
269
|
}
|
|
187
270
|
|
|
271
|
+
// Pausar antes de emitir el menú con console.log — si hay spinner activo,
|
|
272
|
+
// un tick a 80ms puede sobreescribir la primera línea del menú. Origen:
|
|
273
|
+
// F-2 nemesis iter-1.
|
|
274
|
+
_pausarSpinnersActivos();
|
|
275
|
+
|
|
188
276
|
console.log('');
|
|
189
277
|
console.log(` ${titulo}`);
|
|
190
278
|
console.log('');
|
|
@@ -197,21 +285,36 @@ function preguntarOpcion(titulo, opciones, opts = {}) {
|
|
|
197
285
|
|
|
198
286
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
199
287
|
const hint = `[1-${opciones.length}, Enter=${opciones[indiceDefault].valor}]`;
|
|
288
|
+
|
|
289
|
+
// Ver preguntarSiNo: 'close' garantiza reanudación incluso si el callback
|
|
290
|
+
// de question no se invoca (Ctrl+C, EOF, error).
|
|
291
|
+
let valorFinal = opciones[indiceDefault].valor;
|
|
292
|
+
let resuelto = false;
|
|
293
|
+
|
|
294
|
+
function finalizar() {
|
|
295
|
+
if (resuelto) return;
|
|
296
|
+
resuelto = true;
|
|
297
|
+
_reanudarSpinnersActivos();
|
|
298
|
+
resolve(valorFinal);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
rl.on('close', finalizar);
|
|
302
|
+
|
|
200
303
|
rl.question(` Tu elección ${hint}: `, (respuesta) => {
|
|
201
|
-
rl.close();
|
|
202
304
|
const r = respuesta.trim();
|
|
203
305
|
if (r === '') {
|
|
204
|
-
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
const n = parseInt(r, 10);
|
|
208
|
-
if (Number.isFinite(n) && n >= 1 && n <= opciones.length) {
|
|
209
|
-
resolve(opciones[n - 1].valor);
|
|
306
|
+
valorFinal = opciones[indiceDefault].valor;
|
|
210
307
|
} else {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
308
|
+
const n = parseInt(r, 10);
|
|
309
|
+
if (Number.isFinite(n) && n >= 1 && n <= opciones.length) {
|
|
310
|
+
valorFinal = opciones[n - 1].valor;
|
|
311
|
+
} else {
|
|
312
|
+
// Permitir también escribir el valor directo si coincide
|
|
313
|
+
const match = opciones.find(o => o.valor === r);
|
|
314
|
+
valorFinal = match ? match.valor : opciones[indiceDefault].valor;
|
|
315
|
+
}
|
|
214
316
|
}
|
|
317
|
+
rl.close();
|
|
215
318
|
});
|
|
216
319
|
});
|
|
217
320
|
}
|
|
@@ -230,10 +333,26 @@ function preguntarTexto(mensaje, valorDefault = '') {
|
|
|
230
333
|
return;
|
|
231
334
|
}
|
|
232
335
|
const hint = valorDefault ? ` [${valorDefault}]` : '';
|
|
336
|
+
_pausarSpinnersActivos();
|
|
233
337
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
338
|
+
|
|
339
|
+
// Ver preguntarSiNo: 'close' garantiza reanudación incluso si el callback
|
|
340
|
+
// de question no se invoca (Ctrl+C, EOF, error).
|
|
341
|
+
let valorFinal = valorDefault;
|
|
342
|
+
let resuelto = false;
|
|
343
|
+
|
|
344
|
+
function finalizar() {
|
|
345
|
+
if (resuelto) return;
|
|
346
|
+
resuelto = true;
|
|
347
|
+
_reanudarSpinnersActivos();
|
|
348
|
+
resolve(valorFinal);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
rl.on('close', finalizar);
|
|
352
|
+
|
|
234
353
|
rl.question(` ${mensaje}${hint}: `, (respuesta) => {
|
|
354
|
+
valorFinal = respuesta.trim() || valorDefault;
|
|
235
355
|
rl.close();
|
|
236
|
-
resolve(respuesta.trim() || valorDefault);
|
|
237
356
|
});
|
|
238
357
|
});
|
|
239
358
|
}
|
|
@@ -245,6 +364,13 @@ function preguntarTexto(mensaje, valorDefault = '') {
|
|
|
245
364
|
module.exports = {
|
|
246
365
|
SOPORTA_COLOR,
|
|
247
366
|
ES_TTY,
|
|
367
|
+
// Exports internos para tests del fix de race spinner/prompt. NO usar en
|
|
368
|
+
// código de producción — la API estable es spinner() + preguntar*.
|
|
369
|
+
__internalForTesting: {
|
|
370
|
+
spinnersActivos: _spinnersActivos,
|
|
371
|
+
pausarSpinnersActivos: _pausarSpinnersActivos,
|
|
372
|
+
reanudarSpinnersActivos: _reanudarSpinnersActivos,
|
|
373
|
+
},
|
|
248
374
|
color,
|
|
249
375
|
icono,
|
|
250
376
|
formatearPaso,
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Selector múltiple (multi-select / checkbox) para la TUI.
|
|
5
|
+
* Extensión del selectorCheckbox de scripts/lib/selector-interactivo.js con:
|
|
6
|
+
* - Descripciones por item (panel inferior bajo el cursor)
|
|
7
|
+
* - Atajos: ↑↓ navegar, espacio toggle, 'a' todos, 'n' ninguno
|
|
8
|
+
* - Hint configurable
|
|
9
|
+
* - Items deshabilitados respetados
|
|
10
|
+
*
|
|
11
|
+
* Diseño:
|
|
12
|
+
* - Cada item: { valor, etiqueta, descripcion?, deshabilitado?, preseleccionado? }
|
|
13
|
+
* - Resuelve con array de valores seleccionados (en orden de items).
|
|
14
|
+
* - Esc resuelve con `null` (señal de cancelación).
|
|
15
|
+
*
|
|
16
|
+
* No-TTY: resuelve con los items preseleccionados o con todos si
|
|
17
|
+
* `opciones.todosSeleccionadosSiNoTty` está en true.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const render = require('../lib/render');
|
|
21
|
+
const teclas = require('../lib/teclas');
|
|
22
|
+
const { colores, semantico, iconos } = require('../lib/colores');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} opciones
|
|
26
|
+
* @param {string} opciones.titulo
|
|
27
|
+
* @param {Array<{valor:string,etiqueta:string,descripcion?:string,deshabilitado?:boolean,preseleccionado?:boolean}>} opciones.items
|
|
28
|
+
* @param {boolean} [opciones.todosSeleccionadosSiNoTty=false]
|
|
29
|
+
* @returns {Promise<string[]|null>}
|
|
30
|
+
*/
|
|
31
|
+
function selectorMulti(opciones) {
|
|
32
|
+
const items = opciones.items || [];
|
|
33
|
+
const titulo = opciones.titulo || 'Selecciona las opciones:';
|
|
34
|
+
|
|
35
|
+
// Construir set inicial respetando preseleccionados y deshabilitados
|
|
36
|
+
const seleccionados = new Set();
|
|
37
|
+
for (const it of items) {
|
|
38
|
+
if (it.preseleccionado && !it.deshabilitado) seleccionados.add(it.valor);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let cursor = 0;
|
|
42
|
+
while (cursor < items.length && items[cursor]?.deshabilitado) cursor++;
|
|
43
|
+
if (cursor >= items.length) cursor = 0;
|
|
44
|
+
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
if (!render.ES_TTY || !process.stdin.isTTY) {
|
|
47
|
+
if (opciones.todosSeleccionadosSiNoTty) {
|
|
48
|
+
resolve(items.filter(i => !i.deshabilitado).map(i => i.valor));
|
|
49
|
+
} else {
|
|
50
|
+
resolve(Array.from(seleccionados));
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _renderizar() {
|
|
56
|
+
render.limpiarPantalla();
|
|
57
|
+
const { cols, rows } = render.obtenerDimensiones();
|
|
58
|
+
|
|
59
|
+
render.escribirEn(2, 3, semantico.titulo(titulo));
|
|
60
|
+
render.escribirEn(3, 3, colores.dim(
|
|
61
|
+
`${seleccionados.size}/${items.filter(i => !i.deshabilitado).length} seleccionados`
|
|
62
|
+
));
|
|
63
|
+
|
|
64
|
+
const filaInicio = 5;
|
|
65
|
+
const maxVisible = Math.min(items.length, rows - 8);
|
|
66
|
+
let scrollOffset = 0;
|
|
67
|
+
if (cursor < scrollOffset) scrollOffset = cursor;
|
|
68
|
+
if (cursor >= scrollOffset + maxVisible) scrollOffset = cursor - maxVisible + 1;
|
|
69
|
+
|
|
70
|
+
for (let i = scrollOffset; i < Math.min(items.length, scrollOffset + maxVisible); i++) {
|
|
71
|
+
const item = items[i];
|
|
72
|
+
const esCursor = i === cursor;
|
|
73
|
+
const marcado = seleccionados.has(item.valor);
|
|
74
|
+
const filaItem = filaInicio + (i - scrollOffset);
|
|
75
|
+
|
|
76
|
+
let prefijo;
|
|
77
|
+
if (item.deshabilitado) {
|
|
78
|
+
prefijo = colores.dim(' ');
|
|
79
|
+
} else if (esCursor) {
|
|
80
|
+
prefijo = semantico.cursor(iconos.cursor + ' ');
|
|
81
|
+
} else {
|
|
82
|
+
prefijo = ' ';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const checkbox = item.deshabilitado
|
|
86
|
+
? colores.dim(iconos.cajaVacia)
|
|
87
|
+
: (marcado ? semantico.exito(iconos.cajaLlena) : iconos.cajaVacia);
|
|
88
|
+
|
|
89
|
+
let etiqueta = item.etiqueta;
|
|
90
|
+
if (item.deshabilitado) etiqueta = colores.dim(etiqueta);
|
|
91
|
+
else if (esCursor) etiqueta = colores.negrita(etiqueta);
|
|
92
|
+
|
|
93
|
+
render.escribirEn(filaItem, 3, `${prefijo}${checkbox} ${etiqueta}`);
|
|
94
|
+
|
|
95
|
+
// Descripción del item bajo cursor
|
|
96
|
+
if (esCursor && item.descripcion && !item.deshabilitado) {
|
|
97
|
+
const filaDesc = filaInicio + maxVisible + 1;
|
|
98
|
+
const desc = render.recortar(colores.dim(item.descripcion), cols - 6);
|
|
99
|
+
render.escribirEn(filaDesc, 3, desc);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Indicador de scroll
|
|
104
|
+
if (scrollOffset + maxVisible < items.length) {
|
|
105
|
+
render.escribirEn(filaInicio + maxVisible, 3,
|
|
106
|
+
colores.dim(`${iconos.flechaAbajo} ${items.length - scrollOffset - maxVisible} más`));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
render.dibujarPiePagina([
|
|
110
|
+
['↑↓', 'navegar'],
|
|
111
|
+
['Space', 'toggle'],
|
|
112
|
+
['a', 'todos'],
|
|
113
|
+
['n', 'ninguno'],
|
|
114
|
+
['Enter', 'confirmar'],
|
|
115
|
+
['Esc', 'cancelar'],
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
render.iniciarModoTui();
|
|
120
|
+
_renderizar();
|
|
121
|
+
|
|
122
|
+
const onResize = () => _renderizar();
|
|
123
|
+
process.stdout.on('resize', onResize);
|
|
124
|
+
|
|
125
|
+
const teclado = teclas.crearTeclado();
|
|
126
|
+
|
|
127
|
+
function _moverArriba() {
|
|
128
|
+
let nuevo = cursor;
|
|
129
|
+
do {
|
|
130
|
+
nuevo = nuevo > 0 ? nuevo - 1 : items.length - 1;
|
|
131
|
+
} while (nuevo !== cursor && items[nuevo]?.deshabilitado);
|
|
132
|
+
cursor = nuevo;
|
|
133
|
+
_renderizar();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _moverAbajo() {
|
|
137
|
+
let nuevo = cursor;
|
|
138
|
+
do {
|
|
139
|
+
nuevo = nuevo < items.length - 1 ? nuevo + 1 : 0;
|
|
140
|
+
} while (nuevo !== cursor && items[nuevo]?.deshabilitado);
|
|
141
|
+
cursor = nuevo;
|
|
142
|
+
_renderizar();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _toggle() {
|
|
146
|
+
const item = items[cursor];
|
|
147
|
+
if (!item || item.deshabilitado) return;
|
|
148
|
+
if (seleccionados.has(item.valor)) seleccionados.delete(item.valor);
|
|
149
|
+
else seleccionados.add(item.valor);
|
|
150
|
+
_renderizar();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _todos() {
|
|
154
|
+
for (const it of items) {
|
|
155
|
+
if (!it.deshabilitado) seleccionados.add(it.valor);
|
|
156
|
+
}
|
|
157
|
+
_renderizar();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _ninguno() {
|
|
161
|
+
seleccionados.clear();
|
|
162
|
+
_renderizar();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _finalizar(valor) {
|
|
166
|
+
teclado.desactivar();
|
|
167
|
+
process.stdout.removeListener('resize', onResize);
|
|
168
|
+
render.salirModoTui();
|
|
169
|
+
resolve(valor);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
teclado.on('up', _moverArriba);
|
|
173
|
+
teclado.on('down', _moverAbajo);
|
|
174
|
+
teclado.on('space', _toggle);
|
|
175
|
+
teclado.on('a', _todos);
|
|
176
|
+
teclado.on('n', _ninguno);
|
|
177
|
+
teclado.on('return', () => {
|
|
178
|
+
// Preservar el orden de los items, no el de inserción al set
|
|
179
|
+
const resultado = items
|
|
180
|
+
.filter(it => seleccionados.has(it.valor))
|
|
181
|
+
.map(it => it.valor);
|
|
182
|
+
_finalizar(resultado);
|
|
183
|
+
});
|
|
184
|
+
teclado.on('escape', () => _finalizar(null));
|
|
185
|
+
teclado.activar();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { selectorMulti };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Selector único (single-select) para la TUI.
|
|
5
|
+
* Más rico que `preguntarOpcion` de scripts/lib/ui.js: navegación con flechas,
|
|
6
|
+
* descripciones largas con wrap, soporte para items con etiquetas (badges),
|
|
7
|
+
* pie de página con atajos, indicador de cursor visible.
|
|
8
|
+
*
|
|
9
|
+
* Diseño:
|
|
10
|
+
* - Cada item: { valor, etiqueta, descripcion?, badge?, deshabilitado? }
|
|
11
|
+
* - El cursor se mueve solo entre items habilitados.
|
|
12
|
+
* - Enter resuelve con `items[cursor].valor`.
|
|
13
|
+
* - Escape resuelve con `null` (señal de "cancelado").
|
|
14
|
+
*
|
|
15
|
+
* No-TTY: resuelve inmediatamente con el item por defecto sin esperar input.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const render = require('../lib/render');
|
|
19
|
+
const teclas = require('../lib/teclas');
|
|
20
|
+
const { colores, semantico, iconos } = require('../lib/colores');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} opciones
|
|
24
|
+
* @param {string} opciones.titulo - Título mostrado arriba de la lista.
|
|
25
|
+
* @param {Array<{valor:string,etiqueta:string,descripcion?:string,badge?:string,deshabilitado?:boolean}>} opciones.items
|
|
26
|
+
* @param {number} [opciones.indiceDefault=0]
|
|
27
|
+
* @param {string} [opciones.hintAtajos] - Hint adicional en el pie (opcional).
|
|
28
|
+
* @returns {Promise<string|null>} - Valor seleccionado o null si se canceló.
|
|
29
|
+
*/
|
|
30
|
+
function selectorUnico(opciones) {
|
|
31
|
+
const items = opciones.items || [];
|
|
32
|
+
const titulo = opciones.titulo || 'Selecciona una opción:';
|
|
33
|
+
let cursor = opciones.indiceDefault || 0;
|
|
34
|
+
|
|
35
|
+
// Asegurar que el cursor inicial caiga en un item habilitado
|
|
36
|
+
while (cursor < items.length && items[cursor]?.deshabilitado) cursor++;
|
|
37
|
+
if (cursor >= items.length) cursor = 0;
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
if (!render.ES_TTY || !process.stdin.isTTY) {
|
|
41
|
+
// No-TTY: devolver el item por defecto sin renderizar
|
|
42
|
+
const valor = items[cursor]?.valor ?? items[0]?.valor ?? null;
|
|
43
|
+
resolve(valor);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _renderizar() {
|
|
48
|
+
render.limpiarPantalla();
|
|
49
|
+
const { cols, rows } = render.obtenerDimensiones();
|
|
50
|
+
|
|
51
|
+
// Título
|
|
52
|
+
render.escribirEn(2, 3, semantico.titulo(titulo));
|
|
53
|
+
|
|
54
|
+
// Items
|
|
55
|
+
const filaInicio = 4;
|
|
56
|
+
const maxVisible = Math.min(items.length, rows - 6);
|
|
57
|
+
let scrollOffset = 0;
|
|
58
|
+
if (cursor < scrollOffset) scrollOffset = cursor;
|
|
59
|
+
if (cursor >= scrollOffset + maxVisible) scrollOffset = cursor - maxVisible + 1;
|
|
60
|
+
|
|
61
|
+
for (let i = scrollOffset; i < Math.min(items.length, scrollOffset + maxVisible); i++) {
|
|
62
|
+
const item = items[i];
|
|
63
|
+
const esCursor = i === cursor;
|
|
64
|
+
const filaItem = filaInicio + (i - scrollOffset);
|
|
65
|
+
|
|
66
|
+
let prefijo;
|
|
67
|
+
if (item.deshabilitado) {
|
|
68
|
+
prefijo = colores.dim(' ');
|
|
69
|
+
} else if (esCursor) {
|
|
70
|
+
prefijo = semantico.cursor(iconos.cursor + ' ');
|
|
71
|
+
} else {
|
|
72
|
+
prefijo = ' ';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Etiqueta principal
|
|
76
|
+
let etiqueta = item.etiqueta;
|
|
77
|
+
if (item.deshabilitado) {
|
|
78
|
+
etiqueta = colores.dim(etiqueta);
|
|
79
|
+
} else if (esCursor) {
|
|
80
|
+
etiqueta = colores.negrita(etiqueta);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Badge opcional
|
|
84
|
+
const badge = item.badge ? ' ' + semantico.badge(item.badge) : '';
|
|
85
|
+
|
|
86
|
+
render.escribirEn(filaItem, 3, prefijo + etiqueta + badge);
|
|
87
|
+
|
|
88
|
+
// Descripción solo del item bajo cursor (panel inferior)
|
|
89
|
+
if (esCursor && item.descripcion && !item.deshabilitado) {
|
|
90
|
+
const filaDesc = filaInicio + maxVisible + 1;
|
|
91
|
+
const maxAnchoDesc = cols - 6;
|
|
92
|
+
const desc = render.recortar(colores.dim(item.descripcion), maxAnchoDesc);
|
|
93
|
+
render.escribirEn(filaDesc, 3, desc);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Indicador de scroll si hay más items abajo
|
|
98
|
+
if (scrollOffset + maxVisible < items.length) {
|
|
99
|
+
render.escribirEn(filaInicio + maxVisible, 3,
|
|
100
|
+
colores.dim(`${iconos.flechaAbajo} ${items.length - scrollOffset - maxVisible} más`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pie con atajos
|
|
104
|
+
const atajos = [
|
|
105
|
+
['↑↓', 'navegar'],
|
|
106
|
+
['Enter', 'elegir'],
|
|
107
|
+
['Esc', 'cancelar'],
|
|
108
|
+
];
|
|
109
|
+
if (opciones.hintAtajos) atajos.push([opciones.hintAtajos, '']);
|
|
110
|
+
render.dibujarPiePagina(atajos);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
render.iniciarModoTui();
|
|
114
|
+
_renderizar();
|
|
115
|
+
|
|
116
|
+
const onResize = () => _renderizar();
|
|
117
|
+
process.stdout.on('resize', onResize);
|
|
118
|
+
|
|
119
|
+
const teclado = teclas.crearTeclado();
|
|
120
|
+
|
|
121
|
+
function _moverArriba() {
|
|
122
|
+
// Saltar items deshabilitados
|
|
123
|
+
let nuevo = cursor;
|
|
124
|
+
do {
|
|
125
|
+
nuevo = nuevo > 0 ? nuevo - 1 : items.length - 1;
|
|
126
|
+
} while (nuevo !== cursor && items[nuevo]?.deshabilitado);
|
|
127
|
+
cursor = nuevo;
|
|
128
|
+
_renderizar();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _moverAbajo() {
|
|
132
|
+
let nuevo = cursor;
|
|
133
|
+
do {
|
|
134
|
+
nuevo = nuevo < items.length - 1 ? nuevo + 1 : 0;
|
|
135
|
+
} while (nuevo !== cursor && items[nuevo]?.deshabilitado);
|
|
136
|
+
cursor = nuevo;
|
|
137
|
+
_renderizar();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _finalizar(valor) {
|
|
141
|
+
teclado.desactivar();
|
|
142
|
+
process.stdout.removeListener('resize', onResize);
|
|
143
|
+
render.salirModoTui();
|
|
144
|
+
resolve(valor);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
teclado.on('up', _moverArriba);
|
|
148
|
+
teclado.on('down', _moverAbajo);
|
|
149
|
+
teclado.on('return', () => {
|
|
150
|
+
const item = items[cursor];
|
|
151
|
+
if (item && !item.deshabilitado) _finalizar(item.valor);
|
|
152
|
+
});
|
|
153
|
+
teclado.on('escape', () => _finalizar(null));
|
|
154
|
+
teclado.activar();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { selectorUnico };
|