@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.
@@ -0,0 +1,264 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Primitivas de render para la TUI custom de swl-ses.
5
+ * Zero-deps. Solo escape codes ANSI estándar + readline para keypress.
6
+ *
7
+ * Modelo:
8
+ * - `iniciarModoTui()` entra al alternate screen buffer, oculta cursor.
9
+ * - `salirModoTui()` restaura el buffer principal y muestra el cursor.
10
+ * - Cada pantalla llama `limpiarPantalla()` y dibuja desde (1,1).
11
+ * - Las coordenadas son 1-based (compatibilidad con escape codes ANSI).
12
+ *
13
+ * Diseño defensivo:
14
+ * - Si stdout no es TTY (CI, pipe), todas las funciones de cursor son no-op
15
+ * y los dibujos se reducen a console.log lineal.
16
+ * - Capturar SIGINT y exit para garantizar salirModoTui() siempre.
17
+ */
18
+
19
+ const { colores, borde, iconos } = require('./colores');
20
+
21
+ const ES_TTY = !!process.stdout.isTTY;
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Control del terminal
25
+ // ---------------------------------------------------------------------------
26
+
27
+ let modoTuiActivo = false;
28
+ let limpiezaRegistrada = false;
29
+
30
+ function iniciarModoTui() {
31
+ if (modoTuiActivo || !ES_TTY) return;
32
+ modoTuiActivo = true;
33
+
34
+ // Alternate screen buffer: preserva el contenido del terminal pre-TUI
35
+ process.stdout.write('\x1b[?1049h');
36
+ // Ocultar cursor
37
+ process.stdout.write('\x1b[?25l');
38
+ // Limpiar
39
+ process.stdout.write('\x1b[2J\x1b[H');
40
+
41
+ // Registrar limpieza al salir (idempotente)
42
+ if (!limpiezaRegistrada) {
43
+ limpiezaRegistrada = true;
44
+ const limpieza = () => salirModoTui();
45
+ process.once('exit', limpieza);
46
+ process.once('SIGINT', () => { salirModoTui(); process.exit(130); });
47
+ process.once('SIGTERM', () => { salirModoTui(); process.exit(143); });
48
+ }
49
+ }
50
+
51
+ function salirModoTui() {
52
+ if (!modoTuiActivo || !ES_TTY) return;
53
+ modoTuiActivo = false;
54
+
55
+ // Mostrar cursor
56
+ process.stdout.write('\x1b[?25h');
57
+ // Salir del alternate screen buffer (restaura contenido pre-TUI)
58
+ process.stdout.write('\x1b[?1049l');
59
+ }
60
+
61
+ function estaActivo() {
62
+ return modoTuiActivo;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Movimiento de cursor
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function moverCursor(fila, col) {
70
+ if (!ES_TTY) return;
71
+ process.stdout.write(`\x1b[${fila};${col}H`);
72
+ }
73
+
74
+ function limpiarPantalla() {
75
+ if (!ES_TTY) return;
76
+ process.stdout.write('\x1b[2J\x1b[H');
77
+ }
78
+
79
+ function limpiarLinea() {
80
+ if (!ES_TTY) return;
81
+ process.stdout.write('\x1b[2K\r');
82
+ }
83
+
84
+ function obtenerDimensiones() {
85
+ return {
86
+ cols: process.stdout.columns || 80,
87
+ rows: process.stdout.rows || 24,
88
+ };
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Escritura posicionada
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Escribe texto en una posición específica de la pantalla. El texto
97
+ * puede contener escape codes ANSI (colores) — no se cuentan en la longitud.
98
+ */
99
+ function escribirEn(fila, col, texto) {
100
+ moverCursor(fila, col);
101
+ process.stdout.write(texto);
102
+ }
103
+
104
+ /**
105
+ * Cuenta la longitud visual de un string ignorando códigos ANSI.
106
+ * Útil para centrar/alinear texto con color sin distorsionar el cálculo.
107
+ */
108
+ function anchoVisual(texto) {
109
+ return String(texto).replace(/\x1b\[[0-9;]*m/g, '').length;
110
+ }
111
+
112
+ /**
113
+ * Recorta un string a `maxAncho` caracteres visuales preservando códigos ANSI.
114
+ * Si excede, agrega '…' al final.
115
+ */
116
+ function recortar(texto, maxAncho) {
117
+ const s = String(texto);
118
+ if (anchoVisual(s) <= maxAncho) return s;
119
+
120
+ // Conservar códigos ANSI mientras se cuentan solo caracteres visibles
121
+ let resultado = '';
122
+ let ancho = 0;
123
+ let i = 0;
124
+ while (i < s.length && ancho < maxAncho - 1) {
125
+ if (s[i] === '\x1b' && s[i + 1] === '[') {
126
+ // copiar el escape code completo sin contar
127
+ const finCode = s.indexOf('m', i);
128
+ if (finCode === -1) break;
129
+ resultado += s.slice(i, finCode + 1);
130
+ i = finCode + 1;
131
+ } else {
132
+ resultado += s[i];
133
+ ancho++;
134
+ i++;
135
+ }
136
+ }
137
+ return resultado + '…\x1b[0m';
138
+ }
139
+
140
+ /**
141
+ * Rellena un string a `ancho` caracteres visuales con espacios a la derecha.
142
+ */
143
+ function rellenarDer(texto, ancho) {
144
+ const diff = ancho - anchoVisual(texto);
145
+ return diff > 0 ? String(texto) + ' '.repeat(diff) : recortar(texto, ancho);
146
+ }
147
+
148
+ /**
149
+ * Centra un string en `ancho` caracteres visuales.
150
+ */
151
+ function centrar(texto, ancho) {
152
+ const sobra = ancho - anchoVisual(texto);
153
+ if (sobra <= 0) return recortar(texto, ancho);
154
+ const izq = Math.floor(sobra / 2);
155
+ const der = sobra - izq;
156
+ return ' '.repeat(izq) + texto + ' '.repeat(der);
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Cajas y paneles
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Dibuja una caja con borde rounded en (fila, col) con ancho y alto dados.
165
+ * Opciones:
166
+ * - titulo: string opcional centrado en la línea superior
167
+ * - color: función de color a aplicar al borde
168
+ * - doble: usar borde doble (═║) en lugar de rounded (─│)
169
+ * - resaltado: invertir colores del borde (para indicar foco)
170
+ */
171
+ function dibujarCaja(fila, col, ancho, alto, opts = {}) {
172
+ if (!ES_TTY) return;
173
+ if (ancho < 2 || alto < 2) return;
174
+
175
+ const c = opts.color || colores.gris;
176
+ const b = opts.doble ? {
177
+ esqSI: borde.dbEsqSupIzq, esqSD: borde.dbEsqSupDer,
178
+ esqII: borde.dbEsqInfIzq, esqID: borde.dbEsqInfDer,
179
+ h: borde.dbHorizontal, v: borde.dbVertical,
180
+ } : {
181
+ esqSI: borde.esquinaSupIzq, esqSD: borde.esquinaSupDer,
182
+ esqII: borde.esquinaInfIzq, esqID: borde.esquinaInfDer,
183
+ h: borde.horizontal, v: borde.vertical,
184
+ };
185
+
186
+ // Línea superior con título opcional
187
+ let lineaSup;
188
+ if (opts.titulo) {
189
+ const tituloRecortado = recortar(opts.titulo, ancho - 4);
190
+ const anchoTitulo = anchoVisual(tituloRecortado);
191
+ const izq = b.h.repeat(2);
192
+ const der = b.h.repeat(Math.max(0, ancho - 4 - anchoTitulo));
193
+ lineaSup = c(b.esqSI + izq) + ' ' + tituloRecortado + ' ' + c(der + b.esqSD);
194
+ } else {
195
+ lineaSup = c(b.esqSI + b.h.repeat(ancho - 2) + b.esqSD);
196
+ }
197
+ escribirEn(fila, col, lineaSup);
198
+
199
+ // Laterales
200
+ for (let f = fila + 1; f < fila + alto - 1; f++) {
201
+ escribirEn(f, col, c(b.v));
202
+ escribirEn(f, col + ancho - 1, c(b.v));
203
+ }
204
+
205
+ // Línea inferior
206
+ escribirEn(fila + alto - 1, col, c(b.esqII + b.h.repeat(ancho - 2) + b.esqID));
207
+ }
208
+
209
+ /**
210
+ * Dibuja una barra horizontal de progreso en (fila, col) con ancho dado.
211
+ * Porcentaje entre 0 y 1. Estilo: [████░░░░░░] 42%
212
+ */
213
+ function dibujarBarra(fila, col, ancho, porcentaje, opts = {}) {
214
+ const pct = Math.max(0, Math.min(1, porcentaje));
215
+ const lleno = opts.charLleno || '█';
216
+ const vacio = opts.charVacio || '░';
217
+ const colorLleno = opts.colorLleno || colores.verde;
218
+ const colorVacio = opts.colorVacio || colores.dim;
219
+
220
+ const anchoBarra = ancho - 7; // reservar espacio para " 100%"
221
+ const llenoN = Math.round(pct * anchoBarra);
222
+ const vacioN = anchoBarra - llenoN;
223
+
224
+ const barra = colorLleno(lleno.repeat(llenoN)) + colorVacio(vacio.repeat(vacioN));
225
+ const pctStr = String(Math.round(pct * 100)).padStart(3) + '%';
226
+
227
+ escribirEn(fila, col, barra + ' ' + colores.dim(pctStr));
228
+ }
229
+
230
+ /**
231
+ * Dibuja una línea de pie (footer hint) con atajos de teclado.
232
+ * Ejemplo: "↑↓ navegar · Enter elegir · Esc salir"
233
+ */
234
+ function dibujarPiePagina(atajos) {
235
+ const { rows, cols } = obtenerDimensiones();
236
+ const sep = colores.dim(' · ');
237
+ const linea = atajos
238
+ .map(([tecla, accion]) => `${colores.cyan(tecla)} ${colores.dim(accion)}`)
239
+ .join(sep);
240
+ escribirEn(rows, 1, ' ' + recortar(linea, cols - 2));
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Exports
245
+ // ---------------------------------------------------------------------------
246
+
247
+ module.exports = {
248
+ ES_TTY,
249
+ iniciarModoTui,
250
+ salirModoTui,
251
+ estaActivo,
252
+ moverCursor,
253
+ limpiarPantalla,
254
+ limpiarLinea,
255
+ obtenerDimensiones,
256
+ escribirEn,
257
+ anchoVisual,
258
+ recortar,
259
+ rellenarDer,
260
+ centrar,
261
+ dibujarCaja,
262
+ dibujarBarra,
263
+ dibujarPiePagina,
264
+ };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Handler unificado de keypress para la TUI de swl-ses.
5
+ * Zero-deps. Usa readline.emitKeypressEvents para parsear secuencias ANSI.
6
+ *
7
+ * Modelo de uso:
8
+ *
9
+ * const teclado = crearTeclado();
10
+ * teclado.on('up', () => cursor--);
11
+ * teclado.on('down', () => cursor++);
12
+ * teclado.on('return', () => resolver());
13
+ * teclado.on('escape', () => cancelar());
14
+ * teclado.activar();
15
+ * // ... eventualmente
16
+ * teclado.desactivar();
17
+ *
18
+ * Maneja:
19
+ * - Flechas (up/down/left/right)
20
+ * - vim-keys (h/j/k/l) opcional
21
+ * - Espacio, return, escape, tab
22
+ * - Ctrl+C (re-emite como 'escape' por default; se puede sobrescribir)
23
+ * - Teclas alfanuméricas como caracteres (con `keypress` evento)
24
+ */
25
+
26
+ const readline = require('readline');
27
+
28
+ function crearTeclado(opciones = {}) {
29
+ const aliasVim = opciones.vim !== false; // por default soporta h/j/k/l
30
+ const handlers = new Map();
31
+ let activo = false;
32
+ let modoAnterior = null;
33
+
34
+ function _resolverNombre(key, str) {
35
+ if (!key) return null;
36
+ if (key.ctrl && key.name === 'c') return 'escape'; // Ctrl+C → escape
37
+ if (key.name) return key.name;
38
+ return null;
39
+ }
40
+
41
+ function onKeypress(str, key) {
42
+ if (!activo) return;
43
+
44
+ const nombre = _resolverNombre(key, str);
45
+ if (!nombre) return;
46
+
47
+ // Alias vim
48
+ let normalizado = nombre;
49
+ if (aliasVim) {
50
+ if (nombre === 'k') normalizado = 'up';
51
+ else if (nombre === 'j') normalizado = 'down';
52
+ else if (nombre === 'h') normalizado = 'left';
53
+ else if (nombre === 'l') normalizado = 'right';
54
+ }
55
+
56
+ // Disparar handlers exactos
57
+ if (handlers.has(normalizado)) {
58
+ handlers.get(normalizado).forEach(fn => fn(key, str));
59
+ }
60
+
61
+ // Handler genérico para cualquier tecla
62
+ if (handlers.has('*')) {
63
+ handlers.get('*').forEach(fn => fn(nombre, str, key));
64
+ }
65
+ }
66
+
67
+ function activar() {
68
+ if (activo) return;
69
+ if (!process.stdin.isTTY) {
70
+ // No-TTY: no podemos leer teclas. El llamador debe manejar este caso.
71
+ return;
72
+ }
73
+ activo = true;
74
+ readline.emitKeypressEvents(process.stdin);
75
+ modoAnterior = process.stdin.isRaw;
76
+ process.stdin.setRawMode(true);
77
+ process.stdin.resume();
78
+ process.stdin.on('keypress', onKeypress);
79
+ }
80
+
81
+ function desactivar() {
82
+ if (!activo) return;
83
+ activo = false;
84
+ if (process.stdin.isTTY) {
85
+ process.stdin.removeListener('keypress', onKeypress);
86
+ process.stdin.setRawMode(modoAnterior || false);
87
+ process.stdin.pause();
88
+ }
89
+ }
90
+
91
+ function on(tecla, handler) {
92
+ if (!handlers.has(tecla)) handlers.set(tecla, []);
93
+ handlers.get(tecla).push(handler);
94
+ return api;
95
+ }
96
+
97
+ function off(tecla, handler) {
98
+ if (!handlers.has(tecla)) return api;
99
+ if (!handler) {
100
+ handlers.delete(tecla);
101
+ return api;
102
+ }
103
+ const arr = handlers.get(tecla).filter(h => h !== handler);
104
+ if (arr.length === 0) handlers.delete(tecla);
105
+ else handlers.set(tecla, arr);
106
+ return api;
107
+ }
108
+
109
+ const api = { activar, desactivar, on, off, estaActivo: () => activo };
110
+ return api;
111
+ }
112
+
113
+ module.exports = { crearTeclado };
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pantalla Inspect — navegador read-only de componentes instalados.
5
+ *
6
+ * Flujo:
7
+ * 1. Lista de instalaciones detectadas (runtime + scope + perfil + version)
8
+ * 2. Al seleccionar una, muestra detalles:
9
+ * - Componentes por categoría (con conteos)
10
+ * - Última actualización
11
+ * - Lista de hooks registrados
12
+ * - Lista de skills disponibles
13
+ *
14
+ * No modifica nada. Solo lee de los archivos de estado.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const { selectorUnico } = require('../componentes/selector-unico');
21
+ const render = require('../lib/render');
22
+ const teclas = require('../lib/teclas');
23
+ const { colores, semantico, iconos } = require('../lib/colores');
24
+
25
+ function _cargarDatosInstalaciones() {
26
+ let detectarRuntimes, cargarEstado;
27
+ try {
28
+ ({ detectarRuntimes } = require('../../lib/detectar-runtime'));
29
+ ({ cargarEstado } = require('../../lib/estado'));
30
+ } catch (_) {
31
+ return [];
32
+ }
33
+
34
+ const runtimes = detectarRuntimes();
35
+ const filas = [];
36
+ for (const runtime of runtimes) {
37
+ const candidatos = [
38
+ { ruta: runtime.global, scope: 'global' },
39
+ { ruta: path.resolve(runtime.local), scope: 'proyecto' },
40
+ ];
41
+ for (const { ruta, scope } of candidatos) {
42
+ if (!fs.existsSync(ruta)) continue;
43
+ const estado = cargarEstado(ruta);
44
+ if (!estado) continue;
45
+ filas.push({
46
+ id: `${runtime.id}:${scope}`,
47
+ runtimeId: runtime.id,
48
+ runtimeNombre: runtime.nombre,
49
+ scope,
50
+ ruta,
51
+ version: estado.versionSistema || 'desconocida',
52
+ perfil: estado.perfil || '-',
53
+ instalado: estado.fechaInstalacion || estado.timestamp || '-',
54
+ componentes: estado.componentesInstalados || [],
55
+ archivos: estado.archivosInstalados || [],
56
+ });
57
+ }
58
+ }
59
+ return filas;
60
+ }
61
+
62
+ function _agruparPorCategoria(archivos) {
63
+ const grupos = {};
64
+ for (const a of archivos) {
65
+ const cat = a.tipo || a.categoria || 'otros';
66
+ grupos[cat] = (grupos[cat] || 0) + 1;
67
+ }
68
+ return grupos;
69
+ }
70
+
71
+ function _pantallaDetalle(fila) {
72
+ return new Promise((resolve) => {
73
+ if (!render.ES_TTY || !process.stdin.isTTY) {
74
+ resolve();
75
+ return;
76
+ }
77
+
78
+ function _renderizar() {
79
+ render.limpiarPantalla();
80
+ const { cols, rows } = render.obtenerDimensiones();
81
+
82
+ render.escribirEn(2, 3, semantico.titulo(`${fila.runtimeNombre} — ${fila.scope}`));
83
+ render.escribirEn(3, 3, colores.dim(fila.ruta));
84
+
85
+ const datos = [
86
+ ['Versión', colores.cyan('v' + fila.version)],
87
+ ['Perfil', semantico.enfasis(fila.perfil)],
88
+ ['Instalado', fila.instalado],
89
+ ['Total componentes', String(fila.componentes.length)],
90
+ ['Total archivos', String(fila.archivos.length)],
91
+ ];
92
+
93
+ datos.forEach((d, i) => {
94
+ render.escribirEn(5 + i, 5,
95
+ render.rellenarDer(colores.dim(d[0] + ':'), 22) + d[1]);
96
+ });
97
+
98
+ // Conteos por categoría
99
+ const grupos = _agruparPorCategoria(fila.archivos);
100
+ const categorias = Object.entries(grupos).sort(([a], [b]) => a.localeCompare(b));
101
+
102
+ const filaCat = 5 + datos.length + 2;
103
+ render.escribirEn(filaCat, 3, semantico.enfasis('Archivos por categoría:'));
104
+ categorias.forEach(([cat, n], i) => {
105
+ const fila = filaCat + 1 + i;
106
+ if (fila >= rows - 3) return;
107
+ render.escribirEn(fila, 5,
108
+ render.rellenarDer(`${iconos.punto} ${cat}`, 22) +
109
+ colores.cyan(String(n).padStart(4)));
110
+ });
111
+
112
+ render.dibujarPiePagina([
113
+ ['Enter|Esc', 'volver al listado'],
114
+ ]);
115
+ }
116
+
117
+ render.iniciarModoTui();
118
+ _renderizar();
119
+
120
+ const onResize = () => _renderizar();
121
+ process.stdout.on('resize', onResize);
122
+
123
+ const teclado = teclas.crearTeclado();
124
+ function _finalizar() {
125
+ teclado.desactivar();
126
+ process.stdout.removeListener('resize', onResize);
127
+ render.salirModoTui();
128
+ resolve();
129
+ }
130
+ teclado.on('return', _finalizar);
131
+ teclado.on('escape', _finalizar);
132
+ teclado.activar();
133
+ });
134
+ }
135
+
136
+ async function ejecutarInspect() {
137
+ const instalaciones = _cargarDatosInstalaciones();
138
+
139
+ if (instalaciones.length === 0) {
140
+ return {
141
+ exito: false,
142
+ error: 'No se detectaron instalaciones SWL en este equipo.',
143
+ sugerencia: 'Ejecuta swl-ses install para crear una.',
144
+ };
145
+ }
146
+
147
+ // Loop: seleccionar una instalación → ver detalle → volver al listado.
148
+ // Esc en el listado sale del Inspect.
149
+ while (true) {
150
+ const items = instalaciones.map(i => ({
151
+ valor: i.id,
152
+ etiqueta: `${i.runtimeNombre} (${i.scope}) — v${i.version} · perfil ${i.perfil}`,
153
+ descripcion: `${i.componentes.length} componentes · ${i.archivos.length} archivos · ${i.ruta}`,
154
+ }));
155
+
156
+ const elegida = await selectorUnico({
157
+ titulo: 'Inspect — Elige una instalación para ver detalle:',
158
+ items,
159
+ });
160
+ if (!elegida) break;
161
+
162
+ const fila = instalaciones.find(i => i.id === elegida);
163
+ if (fila) await _pantallaDetalle(fila);
164
+ }
165
+
166
+ return { exito: true };
167
+ }
168
+
169
+ module.exports = {
170
+ ejecutarInspect,
171
+ _cargarDatosInstalaciones,
172
+ _agruparPorCategoria,
173
+ };