@saulwade/swl-ses 1.5.2 → 1.6.1

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.
Files changed (64) hide show
  1. package/CLAUDE.md +32 -61
  2. package/README.md +20 -3
  3. package/agentes/datos-swl.md +1 -1
  4. package/agentes/frontend-angular-swl.md +7 -7
  5. package/agentes/frontend-css-swl.md +4 -4
  6. package/agentes/frontend-react-swl.md +7 -7
  7. package/agentes/frontend-swl.md +9 -9
  8. package/agentes/frontend-tailwind-swl.md +4 -4
  9. package/agentes/rendimiento-swl.md +2 -2
  10. package/bin/swl-ses.js +49 -7
  11. package/comandos/swl/brainstorm.md +1 -0
  12. package/comandos/swl/compactar.md +1 -1
  13. package/comandos/swl/discutir-fase.md +15 -1
  14. package/comandos/swl/mapear-codebase.md +1 -1
  15. package/comandos/swl/nemesis.md +29 -0
  16. package/comandos/swl/planear-fase.md +2 -2
  17. package/comandos/swl/verificar.md +4 -4
  18. package/habilidades/aprendizaje-continuo/SKILL.md +7 -1
  19. package/habilidades/diseno-herramientas-agente/SKILL.md +1 -0
  20. package/habilidades/doc-sync/SKILL.md +441 -1
  21. package/habilidades/doubt-driven-review/SKILL.md +177 -171
  22. package/habilidades/feynman-auditor-swl/SKILL.md +129 -123
  23. package/habilidades/infra-github-actions/SKILL.md +172 -166
  24. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  25. package/habilidades/nemesis-evaluacion-json/SKILL.md +5 -0
  26. package/habilidades/nemesis-redistribuir/SKILL.md +5 -0
  27. package/habilidades/node-experto/SKILL.md +197 -3
  28. package/habilidades/prevencion-racionalizacion/SKILL.md +1 -0
  29. package/habilidades/privacy-memoria/SKILL.md +1 -0
  30. package/habilidades/sre-patrones/SKILL.md +1 -1
  31. package/habilidades/state-inconsistency-auditor-swl/SKILL.md +172 -166
  32. package/habilidades/tdd-workflow/SKILL.md +178 -3
  33. package/habilidades/verificacion-evidencia/SKILL.md +1 -0
  34. package/habilidades/web-fetcher-routing/SKILL.md +81 -75
  35. package/habilidades/workflow-claude-code/SKILL.md +2 -2
  36. package/hooks/extraccion-aprendizajes.js +11 -0
  37. package/manifiestos/modulos.json +2 -1
  38. package/manifiestos/skills-lock.json +1142 -1114
  39. package/package.json +7 -4
  40. package/plugin.json +4 -2
  41. package/reglas/auditorias-documentales-estructurales.md +205 -0
  42. package/schemas/agent-frontmatter.schema.json +1 -1
  43. package/scripts/desinstalar.js +105 -24
  44. package/scripts/generar-inventario.js +450 -420
  45. package/scripts/instalador.js +55 -4
  46. package/scripts/lib/parsear-opciones.js +3 -0
  47. package/scripts/lib/ui.js +148 -22
  48. package/scripts/tui/componentes/selector-multi.js +189 -0
  49. package/scripts/tui/componentes/selector-unico.js +158 -0
  50. package/scripts/tui/ejecutores.js +375 -0
  51. package/scripts/tui/index.js +162 -0
  52. package/scripts/tui/lib/colores.js +129 -0
  53. package/scripts/tui/lib/render.js +264 -0
  54. package/scripts/tui/lib/teclas.js +113 -0
  55. package/scripts/tui/pantallas/inspect.js +173 -0
  56. package/scripts/tui/pantallas/install-wizard.js +334 -0
  57. package/scripts/tui/pantallas/menu-principal.js +52 -0
  58. package/scripts/tui/pantallas/progreso.js +274 -0
  59. package/scripts/tui/pantallas/resumen.js +132 -0
  60. package/scripts/tui/pantallas/uninstall-wizard.js +208 -0
  61. package/scripts/tui/pantallas/update-wizard.js +232 -0
  62. package/scripts/tui/pantallas/welcome.js +187 -0
  63. package/scripts/verificar-docs-vs-codigo.js +654 -0
  64. package/scripts/verificar-evolucion.js +19 -3
@@ -42,6 +42,24 @@ const { actualizarGitignore, entradasParaRuntime, limpiarTracked, leerManifest,
42
42
  const RAIZ_PKG = path.resolve(__dirname, '..');
43
43
  const VERSION = require('../package.json').version;
44
44
 
45
+ /**
46
+ * Contrato adicional: `opciones.onProgress(evento)`
47
+ *
48
+ * Si se pasa una función `opciones.onProgress`, el instalador deja de imprimir
49
+ * cada archivo copiado/backup/symlink a stdout y en su lugar emite eventos
50
+ * estructurados que el caller (típicamente la TUI) puede consumir para mostrar
51
+ * barra de progreso + contadores por categoría.
52
+ *
53
+ * Eventos emitidos:
54
+ * { tipo: 'archivo-copiado', componente: 'agentes'|'habilidades'|... , archivo: 'nombre.md' }
55
+ * { tipo: 'backup', componente: <tipo>, archivo, rutaBackup }
56
+ * { tipo: 'symlink', componente: <tipo>, archivo }
57
+ * { tipo: 'colision', componente: <tipo>, archivo }
58
+ * { tipo: 'log', linea: 'mensaje libre' } — para mensajes informativos
59
+ *
60
+ * Backward compat: si onProgress NO se pasa, los console.log siguen iguales.
61
+ * El comportamiento sin TUI no cambia.
62
+ */
45
63
  async function install(opciones) {
46
64
  // Leer manifest .swl-ses si existe — sus valores son defaults que el CLI puede sobreescribir
47
65
  const manifest = leerManifest(process.cwd());
@@ -990,7 +1008,15 @@ function instalarArchivo(archivo, rutas, runtime, opciones = {}) {
990
1008
 
991
1009
  // Verificar colisión
992
1010
  if (fs.existsSync(destino) && !opciones.force) {
993
- console.log(` - Colisión: ${nombreArchivo} (usa --force para sobreescribir)`);
1011
+ if (typeof opciones.onProgress === 'function') {
1012
+ opciones.onProgress({
1013
+ tipo: 'colision',
1014
+ componente: archivo.tipo,
1015
+ archivo: nombreArchivo,
1016
+ });
1017
+ } else {
1018
+ console.log(` - Colisión: ${nombreArchivo} (usa --force para sobreescribir)`);
1019
+ }
994
1020
  return { instalado: false, colision: true };
995
1021
  }
996
1022
 
@@ -998,7 +1024,16 @@ function instalarArchivo(archivo, rutas, runtime, opciones = {}) {
998
1024
  if (fs.existsSync(destino) && opciones.force && opciones.versionAnterior) {
999
1025
  const backup = crearBackup(process.cwd(), destino, opciones.versionAnterior);
1000
1026
  if (backup.respaldado) {
1001
- console.log(` ↩ Backup: ${nombreArchivo} ${path.relative(process.cwd(), backup.rutaBackup)}`);
1027
+ if (typeof opciones.onProgress === 'function') {
1028
+ opciones.onProgress({
1029
+ tipo: 'backup',
1030
+ componente: archivo.tipo,
1031
+ archivo: nombreArchivo,
1032
+ rutaBackup: backup.rutaBackup,
1033
+ });
1034
+ } else {
1035
+ console.log(` ↩ Backup: ${nombreArchivo} → ${path.relative(process.cwd(), backup.rutaBackup)}`);
1036
+ }
1002
1037
  if (opciones.estado) {
1003
1038
  registrarBackup(opciones.estado, {
1004
1039
  rutaOriginal: destino,
@@ -1027,7 +1062,15 @@ function instalarArchivo(archivo, rutas, runtime, opciones = {}) {
1027
1062
  }
1028
1063
  const origenAbsoluto = path.resolve(archivo.origen);
1029
1064
  fs.symlinkSync(origenAbsoluto, destino, stat.isDirectory() ? 'junction' : 'file');
1030
- console.log(` ~ ${path.relative(rutas.base, destino)} (symlink)`);
1065
+ if (typeof opciones.onProgress === 'function') {
1066
+ opciones.onProgress({
1067
+ tipo: 'symlink',
1068
+ componente: archivo.tipo,
1069
+ archivo: nombreArchivo,
1070
+ });
1071
+ } else {
1072
+ console.log(` ~ ${path.relative(rutas.base, destino)} (symlink)`);
1073
+ }
1031
1074
  return { instalado: true, destino };
1032
1075
  } catch (symlinkErr) {
1033
1076
  // Fallback a copy si symlink falla (permisos, filesystem)
@@ -1045,7 +1088,15 @@ function instalarArchivo(archivo, rutas, runtime, opciones = {}) {
1045
1088
  } else {
1046
1089
  fs.copyFileSync(archivo.origen, destino);
1047
1090
  }
1048
- console.log(` + ${path.relative(rutas.base, destino)}`);
1091
+ if (typeof opciones.onProgress === 'function') {
1092
+ opciones.onProgress({
1093
+ tipo: 'archivo-copiado',
1094
+ componente: archivo.tipo,
1095
+ archivo: nombreArchivo,
1096
+ });
1097
+ } else {
1098
+ console.log(` + ${path.relative(rutas.base, destino)}`);
1099
+ }
1049
1100
  return { instalado: true, destino };
1050
1101
  } else {
1051
1102
  return { instalado: false, razon: 'origen no existe' };
@@ -45,6 +45,9 @@ const BOOLEANAS = [
45
45
  // ADR-0019 — Codex/Cursor + MCP autoconfig opt-in
46
46
  'with-mcp', 'no-mcp',
47
47
  'all-runtimes',
48
+ // TUI opt-out (Fase 4 instalador visual). Default: si stdin es TTY y no
49
+ // hay flags, lanza el TUI. Con --no-tui cae al install-asistido clásico.
50
+ 'no-tui', 'tui',
48
51
  ];
49
52
 
50
53
  /**
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 };