@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/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
- const intervalo = setInterval(() => {
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
- }, 80);
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
- clearInterval(intervalo);
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
- clearInterval(intervalo);
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
- clearInterval(intervalo);
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
- // Limpieza en caso de que el proceso muera
136
- const exitHandler = () => { handle.detener(); };
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
- resolve(valorDefault);
245
+ valorFinal = valorDefault;
164
246
  } else {
165
- resolve(r === 's' || r === 'si' || r === 'sí' || r === 'y' || r === 'yes');
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
- resolve(opciones[indiceDefault].valor);
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
- // Permitir también escribir el valor directo si coincide
212
- const match = opciones.find(o => o.valor === r);
213
- resolve(match ? match.valor : opciones[indiceDefault].valor);
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 };