chocolatito-code 1.3.3 → 1.4.2

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.
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import chalk from "chalk";
6
6
  import { getGlobalConfigPath } from "./env.js";
7
+ import { CONFIG_FILE_NAME } from "./constants.js";
7
8
  /**
8
9
  * LICENCIAS
9
10
  *
@@ -365,9 +366,10 @@ export async function ensureLicense() {
365
366
  if (res.kind === "ok")
366
367
  return true;
367
368
  if (res.kind === "rejected") {
368
- // Respuesta clara de Lemon Squeezy: se corta ya.
369
+ // Respuesta clara de Lemon Squeezy: esta clave ya no vale. Pero eso NO es
370
+ // motivo para cerrarle la puerta al que acaba de comprar otra.
369
371
  console.log(chalk.red(`\n✖ ${res.reason}\n`));
370
- return false;
372
+ return pedirClaveDeRecambio();
371
373
  }
372
374
  // No se pudo preguntar. Aqui es donde importa no castigar al cliente por una
373
375
  // caida que no es suya.
@@ -382,6 +384,56 @@ export async function ensureLicense() {
382
384
  chalk.gray("\n Conéctate a internet una vez para reanudarla.\n"));
383
385
  return false;
384
386
  }
387
+ /**
388
+ * LA PUERTA QUE SE CERRABA POR DENTRO
389
+ *
390
+ * Cuando la clave guardada dejaba de valer, esto imprimía el motivo y devolvía
391
+ * `false`. El programa se cerraba y no volvía a preguntar NUNCA: al arrancar de
392
+ * nuevo encontraba la misma clave guardada, la volvía a rechazar y se volvía a
393
+ * cerrar. La única salida era borrar a mano ~/.chocolatitorc, un archivo oculto
394
+ * que ningún cliente sabe que existe.
395
+ *
396
+ * Le pasó al dueño en su propio portátil, que es la peor forma de enterarse: lo
397
+ * tenía instalado con una licencia de MODO PRUEBA de Lemon Squeezy, compró la
398
+ * suscripción de verdad, y no hubo manera de meter la clave nueva. Desde fuera
399
+ * se ve como "compré y no funciona", que es exactamente el momento en que un
400
+ * cliente pide la devolución.
401
+ *
402
+ * Y no hace falta que sea una prueba: pasa igual con una suscripción que caducó
403
+ * y se renovó, con un equipo liberado desde el panel, o con un reembolso
404
+ * seguido de una compra nueva.
405
+ *
406
+ * Ahora se ofrece meter otra clave ahí mismo. Sin terminal delante —un script,
407
+ * una tubería— al menos se dice DÓNDE está el archivo, que es lo único
408
+ * accionable en ese caso.
409
+ */
410
+ async function pedirClaveDeRecambio() {
411
+ const donde = path.join(os.homedir(), CONFIG_FILE_NAME);
412
+ if (!process.stdin.isTTY) {
413
+ console.log(chalk.gray(` La clave guardada está en ${donde}.`) +
414
+ chalk.gray("\n Bórrala y ejecuta 'chocolatito' en una terminal para introducir la nueva.\n"));
415
+ return false;
416
+ }
417
+ console.log(chalk.gray(" Si has comprado una suscripción nueva, pega aquí su clave y se cambia.") +
418
+ chalk.gray(`\n (Enter en blanco para salir. La anterior está guardada en ${donde}.)\n`));
419
+ const clave = await pedirClave();
420
+ if (!clave) {
421
+ console.log(chalk.gray("\n No se introdujo ninguna clave.\n"));
422
+ return false;
423
+ }
424
+ const nueva = await activate(clave);
425
+ if (nueva.kind === "ok") {
426
+ console.log(chalk.green(`\n✔ Licencia activada${nueva.license.customerEmail ? ` para ${nueva.license.customerEmail}` : ""}.\n`));
427
+ return true;
428
+ }
429
+ // Un solo intento. Encadenar preguntas hasta acertar convierte un fallo de
430
+ // conexión en un bucle del que no se sale con Ctrl+C limpio.
431
+ const motivo = nueva.kind === "rejected" || nueva.kind === "unpersisted"
432
+ ? nueva.reason
433
+ : `No se pudo activar (${nueva.reason}).`;
434
+ console.log(chalk.red(`\n✖ ${motivo}\n`));
435
+ return false;
436
+ }
385
437
  /** Texto del comando /licencia. */
386
438
  export function licenseStatus() {
387
439
  const l = getStoredLicense();
@@ -5,13 +5,13 @@ export declare function colorizeSilkscreenLine(line: string): string;
5
5
  export declare function renderMascotLines(isBlinking?: boolean): string[];
6
6
  export declare function buildBannerContent(cwd: string, model: EspectroModel, isBlinking?: boolean): string;
7
7
  /**
8
- * Ventana por debajo de esto y no se ancla nada: el hueco se comeria el banner.
9
- * Con 16 filas quedan tres o cuatro libres, que ya es una separacion util.
8
+ * Ventana por debajo de esto y no se ancla nada: no sobra sitio y el hueco solo
9
+ * serviria para empujar el banner fuera de la pantalla.
10
10
  */
11
11
  export declare const FILAS_MINIMAS_PARA_ANCLAR = 16;
12
12
  /**
13
- * Filas en blanco que hay que soltar tras `contenido` para que lo siguiente que
14
- * se pinte caiga al fondo de la ventana. Cero si no cabe o no hace falta.
13
+ * Filas en blanco que hay que soltar ANTES de `contenido` para que el prompt
14
+ * acabe pegado al fondo de la ventana. Cero si no cabe o no hace falta.
15
15
  *
16
16
  * Se exporta para poder comprobar la cuenta: dentro de un terminal esto no se
17
17
  * revisa a ojo, y pasarse de una fila hace desaparecer la primera del banner.
@@ -20,16 +20,18 @@ export declare function filasParaAnclar(contenido: string, filasDeVentana: numbe
20
20
  /**
21
21
  * ANCLAR EL PROMPT AL FONDO DE LA VENTANA
22
22
  *
23
- * El usuario lo enseno con una captura y una frase: "mira hay espacio y no es
24
- * fijo como te decia, eso debe estar fijo en la parte de abajo". Con la sesion
25
- * recien empezada, el contenido no llega al final de la pantalla, asi que el
26
- * marco del prompt se queda a media altura con hueco vacio DEBAJO. Se lee como
27
- * si el programa estuviera a medio dibujar.
23
+ * El usuario lo enseño con una captura: "mira hay espacio y no es fijo, eso debe
24
+ * estar fijo en la parte de abajo". Con la sesion recien empezada el contenido
25
+ * no llega al final de la pantalla, asi que el marco del prompt se quedaba a
26
+ * media altura con hueco vacio DEBAJO.
28
27
  *
29
- * Se arregla soltando las filas en blanco que faltan justo detras del banner. A
30
- * partir de ahi el prompt nace abajo, y ya no se mueve de ahi en toda la sesion:
31
- * el contenido solo crece hacia abajo, y cuando llega al final el terminal
32
- * desplaza, asi que el marco vuelve a quedar en las ultimas filas.
28
+ * POR QUE EL HUECO VA ARRIBA Y NO ABAJO
29
+ *
30
+ * La primera version soltaba las filas en blanco DETRAS del banner. Anclaba,
31
+ * si, pero dejaba un claro enorme entre el banner y el prompt que se leia como
32
+ * un dibujo a medio hacer. Puestas DELANTE, el banner y el prompt quedan
33
+ * pegados en la parte baja y el hueco queda arriba, donde se lee como lo que es:
34
+ * una sesion que acaba de empezar.
33
35
  *
34
36
  * NO se usa la region de scroll ni la pantalla alternativa: las dos se llevan
35
37
  * por delante el historial del terminal, y aqui media sesion consiste en subir
package/dist/ui/banner.js CHANGED
@@ -115,13 +115,28 @@ export function buildBannerContent(cwd, model, isBlinking = false) {
115
115
  return ["", ...combinedRows, ""].join("\n");
116
116
  }
117
117
  /**
118
- * Ventana por debajo de esto y no se ancla nada: el hueco se comeria el banner.
119
- * Con 16 filas quedan tres o cuatro libres, que ya es una separacion util.
118
+ * Ventana por debajo de esto y no se ancla nada: no sobra sitio y el hueco solo
119
+ * serviria para empujar el banner fuera de la pantalla.
120
120
  */
121
121
  export const FILAS_MINIMAS_PARA_ANCLAR = 16;
122
122
  /**
123
- * Filas en blanco que hay que soltar tras `contenido` para que lo siguiente que
124
- * se pinte caiga al fondo de la ventana. Cero si no cabe o no hace falta.
123
+ * Filas que se imprimen DESPUES del banner y hay que dejarles sitio.
124
+ *
125
+ * Son el aviso del atajo (su texto ya trae un salto dentro, mas el que anade
126
+ * console.log: 2 filas) y el marco del prompt, que son cinco: una de aire y
127
+ * despues barra, entrada, barra y estado.
128
+ *
129
+ * AQUI ESTABA EL FALLO DEL ZORRO CORTADO
130
+ *
131
+ * La primera version rellenaba hasta la ultima fila y se olvidaba de estas seis.
132
+ * Al imprimirse despues, el terminal desplazaba seis filas y se llevaba por
133
+ * arriba la cabeza del zorro. El usuario lo vio en cuanto abrio: "hay mucho
134
+ * espacio y se baja y se corta el zorrito al iniciar".
135
+ */
136
+ const FILAS_DESPUES_DEL_BANNER = 7;
137
+ /**
138
+ * Filas en blanco que hay que soltar ANTES de `contenido` para que el prompt
139
+ * acabe pegado al fondo de la ventana. Cero si no cabe o no hace falta.
125
140
  *
126
141
  * Se exporta para poder comprobar la cuenta: dentro de un terminal esto no se
127
142
  * revisa a ojo, y pasarse de una fila hace desaparecer la primera del banner.
@@ -131,22 +146,24 @@ export function filasParaAnclar(contenido, filasDeVentana) {
131
146
  return 0;
132
147
  // El `console.log` anade su propio salto: por eso la altura del bloque es una
133
148
  // linea mas de las que trae el texto.
134
- const alto = contenido.split("\n").length;
135
- return Math.max(0, filasDeVentana - alto - 1);
149
+ const alto = contenido.split("\n").length + 1;
150
+ return Math.max(0, filasDeVentana - alto - FILAS_DESPUES_DEL_BANNER);
136
151
  }
137
152
  /**
138
153
  * ANCLAR EL PROMPT AL FONDO DE LA VENTANA
139
154
  *
140
- * El usuario lo enseno con una captura y una frase: "mira hay espacio y no es
141
- * fijo como te decia, eso debe estar fijo en la parte de abajo". Con la sesion
142
- * recien empezada, el contenido no llega al final de la pantalla, asi que el
143
- * marco del prompt se queda a media altura con hueco vacio DEBAJO. Se lee como
144
- * si el programa estuviera a medio dibujar.
155
+ * El usuario lo enseño con una captura: "mira hay espacio y no es fijo, eso debe
156
+ * estar fijo en la parte de abajo". Con la sesion recien empezada el contenido
157
+ * no llega al final de la pantalla, asi que el marco del prompt se quedaba a
158
+ * media altura con hueco vacio DEBAJO.
159
+ *
160
+ * POR QUE EL HUECO VA ARRIBA Y NO ABAJO
145
161
  *
146
- * Se arregla soltando las filas en blanco que faltan justo detras del banner. A
147
- * partir de ahi el prompt nace abajo, y ya no se mueve de ahi en toda la sesion:
148
- * el contenido solo crece hacia abajo, y cuando llega al final el terminal
149
- * desplaza, asi que el marco vuelve a quedar en las ultimas filas.
162
+ * La primera version soltaba las filas en blanco DETRAS del banner. Anclaba,
163
+ * si, pero dejaba un claro enorme entre el banner y el prompt que se leia como
164
+ * un dibujo a medio hacer. Puestas DELANTE, el banner y el prompt quedan
165
+ * pegados en la parte baja y el hueco queda arriba, donde se lee como lo que es:
166
+ * una sesion que acaba de empezar.
150
167
  *
151
168
  * NO se usa la region de scroll ni la pantalla alternativa: las dos se llevan
152
169
  * por delante el historial del terminal, y aqui media sesion consiste en subir
@@ -154,12 +171,12 @@ export function filasParaAnclar(contenido, filasDeVentana) {
154
171
  */
155
172
  export function showBanner(cwd, model, state = "idle") {
156
173
  const content = buildBannerContent(cwd, model, state === "blink");
174
+ if (process.stdout.isTTY) {
175
+ const relleno = filasParaAnclar(content, process.stdout.rows || 0);
176
+ if (relleno > 0)
177
+ dibujar("\n".repeat(relleno));
178
+ }
157
179
  console.log(content);
158
- if (!process.stdout.isTTY)
159
- return;
160
- const relleno = filasParaAnclar(content, process.stdout.rows || 0);
161
- if (relleno > 0)
162
- dibujar("\n".repeat(relleno));
163
180
  }
164
181
  export async function animateStartupBanner(cwd, model) {
165
182
  // Impresión limpia instantánea sin animaciones lentas en el terminal
@@ -0,0 +1,41 @@
1
+ import type { DropdownItem } from "./marco.js";
2
+ /**
3
+ * LA LÓGICA DEL PROMPT, SIN TERMINAL DELANTE
4
+ *
5
+ * Todo esto vivía dentro del closure de `askInteractivePrompt`, mezclado con el
6
+ * dibujo, el modo crudo y el oyente de teclas. Mientras hubo una sola forma de
7
+ * pintar el prompt eso daba igual; con dos —la de siempre y la de Ink— habría
8
+ * significado escribir dos veces el autocompletado, el historial y la detección
9
+ * de menciones, y que se separaran al primer arreglo.
10
+ *
11
+ * Aquí no hay nada que sepa de terminales: entra texto y una posición de
12
+ * cursor, sale qué ofrecer. Por eso se puede comprobar sin abrir nada.
13
+ */
14
+ /** Una arroba a medio escribir justo antes del cursor. */
15
+ export declare function mencionEnCurso(texto: string, cursor: number): {
16
+ activa: boolean;
17
+ consulta: string;
18
+ desde: number;
19
+ };
20
+ /**
21
+ * Archivos del proyecto para el autocompletado de "@".
22
+ *
23
+ * Tope de resultados y de profundidad a propósito: esto corre en cada
24
+ * pulsación, y recorrer un monorepo entero por cada letra se nota.
25
+ */
26
+ export declare function archivosDelProyecto(dir: string, maximo?: number): string[];
27
+ /** Lo que se ofrece debajo de la entrada, o nada si no toca ofrecer. */
28
+ export declare function opcionesDelDesplegable(texto: string, cursor: number, archivos: string[]): DropdownItem[];
29
+ /**
30
+ * Aplica la opción elegida. Devuelve el texto y el cursor nuevos, o `null` si
31
+ * no había nada que aplicar.
32
+ *
33
+ * Devuelve valores en vez de mutar: así el componente de Ink puede meterlo en
34
+ * su estado sin que nada cambie a sus espaldas entre dos dibujados.
35
+ */
36
+ export declare function aplicarOpcion(texto: string, cursor: number, opcion: DropdownItem | undefined): {
37
+ texto: string;
38
+ cursor: number;
39
+ } | null;
40
+ export declare function leerHistorial(): string[];
41
+ export declare function apuntarEnHistorial(orden: string): void;
@@ -0,0 +1,143 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { SLASH_COMMANDS } from "./comandos.js";
5
+ /**
6
+ * LA LÓGICA DEL PROMPT, SIN TERMINAL DELANTE
7
+ *
8
+ * Todo esto vivía dentro del closure de `askInteractivePrompt`, mezclado con el
9
+ * dibujo, el modo crudo y el oyente de teclas. Mientras hubo una sola forma de
10
+ * pintar el prompt eso daba igual; con dos —la de siempre y la de Ink— habría
11
+ * significado escribir dos veces el autocompletado, el historial y la detección
12
+ * de menciones, y que se separaran al primer arreglo.
13
+ *
14
+ * Aquí no hay nada que sepa de terminales: entra texto y una posición de
15
+ * cursor, sale qué ofrecer. Por eso se puede comprobar sin abrir nada.
16
+ */
17
+ /** Una arroba a medio escribir justo antes del cursor. */
18
+ export function mencionEnCurso(texto, cursor) {
19
+ const antes = texto.slice(0, cursor);
20
+ const m = antes.match(/@([^\s]*)$/);
21
+ if (m && m.index !== undefined) {
22
+ return { activa: true, consulta: m[1].toLowerCase(), desde: m.index };
23
+ }
24
+ return { activa: false, consulta: "", desde: -1 };
25
+ }
26
+ /**
27
+ * Archivos del proyecto para el autocompletado de "@".
28
+ *
29
+ * Tope de resultados y de profundidad a propósito: esto corre en cada
30
+ * pulsación, y recorrer un monorepo entero por cada letra se nota.
31
+ */
32
+ export function archivosDelProyecto(dir, maximo = 40) {
33
+ const encontrados = [];
34
+ const ignorar = new Set([
35
+ "node_modules",
36
+ ".git",
37
+ "dist",
38
+ "build",
39
+ ".next",
40
+ "out",
41
+ "venv",
42
+ "__pycache__",
43
+ ".turbo",
44
+ ]);
45
+ function recorrer(actual, hondo = 0) {
46
+ if (hondo > 6 || encontrados.length >= maximo)
47
+ return;
48
+ try {
49
+ for (const ent of fs.readdirSync(actual, { withFileTypes: true })) {
50
+ if (ent.name.startsWith(".") && ent.name !== ".env")
51
+ continue;
52
+ if (ignorar.has(ent.name))
53
+ continue;
54
+ const completo = path.join(actual, ent.name);
55
+ if (ent.isDirectory()) {
56
+ recorrer(completo, hondo + 1);
57
+ }
58
+ else if (ent.isFile()) {
59
+ encontrados.push(path.relative(dir, completo).replace(/\\/g, "/"));
60
+ if (encontrados.length >= maximo)
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ catch { }
66
+ }
67
+ recorrer(dir);
68
+ return encontrados;
69
+ }
70
+ /** Lo que se ofrece debajo de la entrada, o nada si no toca ofrecer. */
71
+ export function opcionesDelDesplegable(texto, cursor, archivos) {
72
+ if (texto.startsWith("/")) {
73
+ const consulta = texto.toLowerCase();
74
+ return SLASH_COMMANDS.filter((c) => c.name.toLowerCase().startsWith(consulta) || consulta === "/").map((c) => ({
75
+ type: "command",
76
+ label: `${c.name}${c.args ? " " + c.args : ""}`,
77
+ secondary: c.description,
78
+ insertText: c.name,
79
+ }));
80
+ }
81
+ const arroba = mencionEnCurso(texto, cursor);
82
+ if (arroba.activa) {
83
+ return archivos
84
+ .filter((f) => f.toLowerCase().includes(arroba.consulta))
85
+ .slice(0, 8)
86
+ .map((f) => ({
87
+ type: "file",
88
+ label: f,
89
+ secondary: "Archivo del proyecto",
90
+ insertText: `@${f} `,
91
+ }));
92
+ }
93
+ return [];
94
+ }
95
+ /**
96
+ * Aplica la opción elegida. Devuelve el texto y el cursor nuevos, o `null` si
97
+ * no había nada que aplicar.
98
+ *
99
+ * Devuelve valores en vez de mutar: así el componente de Ink puede meterlo en
100
+ * su estado sin que nada cambie a sus espaldas entre dos dibujados.
101
+ */
102
+ export function aplicarOpcion(texto, cursor, opcion) {
103
+ if (!opcion)
104
+ return null;
105
+ if (opcion.type === "command") {
106
+ return { texto: opcion.insertText, cursor: opcion.insertText.length };
107
+ }
108
+ const arroba = mencionEnCurso(texto, cursor);
109
+ if (!arroba.activa)
110
+ return null;
111
+ const nuevo = texto.slice(0, arroba.desde) + opcion.insertText + texto.slice(cursor);
112
+ return { texto: nuevo, cursor: arroba.desde + opcion.insertText.length };
113
+ }
114
+ // ------------------------------------------------------------------ historial
115
+ function archivoDeHistorial() {
116
+ const dir = path.join(os.homedir(), ".chocolatito");
117
+ if (!fs.existsSync(dir))
118
+ fs.mkdirSync(dir, { recursive: true });
119
+ return path.join(dir, "history");
120
+ }
121
+ export function leerHistorial() {
122
+ try {
123
+ const f = archivoDeHistorial();
124
+ if (!fs.existsSync(f))
125
+ return [];
126
+ return fs
127
+ .readFileSync(f, "utf-8")
128
+ .split("\n")
129
+ .filter((l) => l.trim().length > 0);
130
+ }
131
+ catch {
132
+ // Un historial ilegible no puede impedir escribir: se sigue sin él.
133
+ return [];
134
+ }
135
+ }
136
+ export function apuntarEnHistorial(orden) {
137
+ if (!orden.trim())
138
+ return;
139
+ try {
140
+ fs.appendFileSync(archivoDeHistorial(), `${orden.trim()}\n`, "utf-8");
141
+ }
142
+ catch { }
143
+ }
@@ -0,0 +1,42 @@
1
+ import React from "react";
2
+ /**
3
+ * EL PROMPT, DIBUJADO POR INK
4
+ *
5
+ * POR QUE SE CAMBIÓ
6
+ *
7
+ * La versión de siempre lo hacía todo a mano: pedía el modo crudo, escuchaba
8
+ * `keypress` de readline, contaba filas físicas, subía el cursor con `\x1b[nA` y
9
+ * repintaba. Funcionaba, pero cada arreglo destapaba otro agujero —el marco que
10
+ * bajaba una fila por pulsación, el cursor detrás del acento, el teclado que se
11
+ * quedaba en modo cocido después de un hijo—, y todos eran del MISMO tipo:
12
+ * llevar a mano una cuenta que el terminal también lleva.
13
+ *
14
+ * Ink se queda con esa parte. El modo crudo lo pide y lo devuelve él, con cuenta
15
+ * de referencias, y al desmontar lo restaura pase lo que pase. El repintado lo
16
+ * hace por diferencias, así que no hay filas que contar.
17
+ *
18
+ * LO QUE NO CAMBIA, Y ES DELIBERADO
19
+ *
20
+ * El aspecto sale del MISMO `buildPromptFrame` de antes. No se ha reescrito el
21
+ * marco en cajas de Ink: se pinta la lista de líneas que ya devolvía. Así el
22
+ * cambio no puede alterar ni una columna de lo que se ve, y las pruebas del
23
+ * marco —anchos, recortes, el modo a la izquierda, el motor a la derecha— siguen
24
+ * valiendo como red mientras se cambia lo de debajo.
25
+ *
26
+ * EL CURSOR
27
+ *
28
+ * Ink no mueve el cursor del terminal: lo esconde. Por eso el cursor se pinta
29
+ * como un carácter más dentro del texto, igual que ya se hacía en la barra
30
+ * mientras el agente trabaja.
31
+ */
32
+ export interface PromptProps {
33
+ /** Se llama UNA vez, con la orden ya expandida. */
34
+ alEnviar: (orden: string) => void;
35
+ /** Ctrl+C: cerrar la sesión. */
36
+ alCancelar: () => void;
37
+ cwd: string;
38
+ promptSymbol: string;
39
+ /** Mensajes que llegaron tarde y hay que dejar por encima del marco. */
40
+ avisos: string[];
41
+ }
42
+ export declare function Prompt({ alEnviar, alCancelar, cwd, promptSymbol, avisos, }: PromptProps): React.ReactElement;
@@ -0,0 +1,181 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useMemo, useRef } from "react";
3
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
4
+ import { buildPromptFrame } from "../marco.js";
5
+ import { cycleMode } from "../modes.js";
6
+ import { aplicarOpcion, archivosDelProyecto, leerHistorial, mencionEnCurso, opcionesDelDesplegable, } from "../entrada.js";
7
+ import { esGrande, guardarPegado, marcaQueTerminaEn } from "../pegados.js";
8
+ /** Marca del cursor. Ink esconde el de verdad, así que este se dibuja. */
9
+ const CARET = "█";
10
+ export function Prompt({ alEnviar, alCancelar, cwd, promptSymbol, avisos, }) {
11
+ const { stdout } = useStdout();
12
+ const { exit } = useApp();
13
+ const [texto, setTexto] = useState("");
14
+ const [cursor, setCursor] = useState(0);
15
+ const [elegido, setElegido] = useState(0);
16
+ const historial = useRef(leerHistorial());
17
+ const [posHistorial, setPosHistorial] = useState(historial.current.length);
18
+ // Se escanea UNA vez por prompt, no en cada tecla: recorrer el proyecto entero
19
+ // por cada letra se nota en cuanto el proyecto pasa de mediano.
20
+ const archivos = useMemo(() => {
21
+ try {
22
+ return archivosDelProyecto(cwd);
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ }, [cwd]);
28
+ const opciones = opcionesDelDesplegable(texto, cursor, archivos);
29
+ const indice = opciones.length > 0 ? ((elegido % opciones.length) + opciones.length) % opciones.length : 0;
30
+ function enviar() {
31
+ const orden = texto.trim();
32
+ if (!orden)
33
+ return;
34
+ alEnviar(orden);
35
+ exit();
36
+ }
37
+ useInput((entrada, tecla) => {
38
+ // ------------------------------------------------------------- salir
39
+ if (tecla.ctrl && entrada === "c") {
40
+ alCancelar();
41
+ exit();
42
+ return;
43
+ }
44
+ // ------------------------------------------- cambiar el modo de permisos
45
+ if (tecla.tab && tecla.shift) {
46
+ cycleMode();
47
+ return;
48
+ }
49
+ // ------------------------------------------------------------- enviar
50
+ if (tecla.return) {
51
+ // La continuación con barra invertida mete un salto de verdad.
52
+ if (texto.endsWith("\\")) {
53
+ setTexto(texto.slice(0, -1) + "\n");
54
+ setCursor(texto.length);
55
+ return;
56
+ }
57
+ // El desplegable manda sobre lo tecleado: si en pantalla está señalado
58
+ // "/effort" y has escrito "/e", se envía lo que se ve. Ver la nota larga
59
+ // en la versión de siempre; esto es el mismo criterio.
60
+ const arroba = mencionEnCurso(texto, cursor).activa;
61
+ const escribiendoComando = texto.startsWith("/") && !texto.includes(" ");
62
+ const soloLaBarra = texto === "/";
63
+ if (opciones.length > 0 && (escribiendoComando || arroba)) {
64
+ const opcion = opciones[indice];
65
+ const yaEntero = Boolean(opcion) && texto === opcion.insertText;
66
+ if (!yaEntero) {
67
+ const aplicado = aplicarOpcion(texto, cursor, opcion);
68
+ if (aplicado) {
69
+ setTexto(aplicado.texto);
70
+ setCursor(aplicado.cursor);
71
+ setElegido(0);
72
+ // Una mención lleva más texto detrás; con solo "/" no has elegido
73
+ // nada todavía. En los dos casos se completa y se espera.
74
+ if (arroba || soloLaBarra)
75
+ return;
76
+ alEnviar(aplicado.texto.trim());
77
+ exit();
78
+ return;
79
+ }
80
+ }
81
+ }
82
+ enviar();
83
+ return;
84
+ }
85
+ // ------------------------------------------------------- autocompletar
86
+ if (tecla.tab && opciones.length > 0) {
87
+ const aplicado = aplicarOpcion(texto, cursor, opciones[indice]);
88
+ if (aplicado) {
89
+ setTexto(aplicado.texto);
90
+ setCursor(aplicado.cursor);
91
+ setElegido(0);
92
+ }
93
+ return;
94
+ }
95
+ // ------------------------------------------------ moverse por la lista
96
+ if (tecla.upArrow) {
97
+ if (opciones.length > 0) {
98
+ setElegido(indice - 1);
99
+ return;
100
+ }
101
+ if (historial.current.length > 0 && posHistorial > 0) {
102
+ const nueva = posHistorial - 1;
103
+ setPosHistorial(nueva);
104
+ const linea = historial.current[nueva] || "";
105
+ setTexto(linea);
106
+ setCursor(linea.length);
107
+ }
108
+ return;
109
+ }
110
+ if (tecla.downArrow) {
111
+ if (opciones.length > 0) {
112
+ setElegido(indice + 1);
113
+ return;
114
+ }
115
+ if (posHistorial < historial.current.length - 1) {
116
+ const nueva = posHistorial + 1;
117
+ setPosHistorial(nueva);
118
+ const linea = historial.current[nueva] || "";
119
+ setTexto(linea);
120
+ setCursor(linea.length);
121
+ }
122
+ else if (posHistorial === historial.current.length - 1) {
123
+ // Bajar desde la última deja la línea en blanco, que es de donde se
124
+ // había salido al empezar a navegar.
125
+ setPosHistorial(historial.current.length);
126
+ setTexto("");
127
+ setCursor(0);
128
+ }
129
+ return;
130
+ }
131
+ // ------------------------------------------------- moverse por el texto
132
+ if (tecla.leftArrow) {
133
+ setCursor(Math.max(0, cursor - 1));
134
+ return;
135
+ }
136
+ if (tecla.rightArrow) {
137
+ setCursor(Math.min(texto.length, cursor + 1));
138
+ return;
139
+ }
140
+ // --------------------------------------------------------------- borrar
141
+ if (tecla.backspace || tecla.delete) {
142
+ if (cursor === 0)
143
+ return;
144
+ // Una marca de pegado se borra ENTERA. Dejar "[pegado #1 · 347 line" a
145
+ // medias no significa nada y al enviar se expandiría a la nada.
146
+ const marca = marcaQueTerminaEn(texto, cursor);
147
+ const cuantos = marca ? cursor - marca.inicio : 1;
148
+ setTexto(texto.slice(0, cursor - cuantos) + texto.slice(cursor));
149
+ setCursor(cursor - cuantos);
150
+ setElegido(0);
151
+ return;
152
+ }
153
+ // ---------------------------------------------------------------- texto
154
+ //
155
+ // Ink entrega el pegado como UN solo trozo, así que aquí no hace falta nada
156
+ // del baile de antes con el modo de pegado entre corchetes ni con esperar
157
+ // al siguiente tick para saber si el Enter era un salto del pegado: si
158
+ // llegan varias líneas juntas, es un pegado y punto.
159
+ if (entrada && !tecla.ctrl && !tecla.meta) {
160
+ const limpio = entrada.replace(/\r\n?/g, "\n");
161
+ const trozo = esGrande(limpio) ? guardarPegado(limpio) : limpio;
162
+ setTexto(texto.slice(0, cursor) + trozo + texto.slice(cursor));
163
+ setCursor(cursor + trozo.length);
164
+ setElegido(0);
165
+ }
166
+ });
167
+ const cols = Math.max(20, stdout?.columns || 80);
168
+ const rows = stdout?.rows || 24;
169
+ // El cursor se dibuja: Ink esconde el del terminal.
170
+ const conCaret = texto.slice(0, cursor) + CARET + texto.slice(cursor);
171
+ const marco = buildPromptFrame({
172
+ cols,
173
+ rows,
174
+ promptSymbol,
175
+ input: conCaret,
176
+ cursorIndex: cursor,
177
+ items: opciones,
178
+ selectedIndex: indice,
179
+ });
180
+ return (_jsxs(Box, { flexDirection: "column", children: [avisos.map((a, i) => (_jsx(Text, { children: a }, `aviso-${i}`))), marco.lines.map((linea, i) => (_jsx(Text, { children: linea }, i)))] }));
181
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Pide una orden al usuario. Misma firma que la versión de siempre.
3
+ *
4
+ * Devuelve el texto ya expandido: las marcas de pegado vuelven a ser el texto
5
+ * entero antes de que nadie lo vea, porque lo que el modelo tiene que recibir es
6
+ * lo que el usuario pegó, no "[pegado #1 · 347 lineas]".
7
+ */
8
+ export declare function preguntarConInk(promptSymbol?: string, cwd?: string): Promise<string>;
9
+ export declare function hayPromptDeInk(): boolean;
10
+ export declare function avisarSobreElPromptDeInk(texto: string): void;
@@ -0,0 +1,113 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import process from "node:process";
4
+ import { render } from "ink";
5
+ import { Prompt } from "./Prompt.js";
6
+ import { expandirPegados, limpiarPegados } from "../pegados.js";
7
+ import { apuntarEnHistorial } from "../entrada.js";
8
+ import { soltarPie } from "../pieFijo.js";
9
+ import { clearTyped } from "../typeAhead.js";
10
+ /**
11
+ * EL PUENTE ENTRE INK Y EL BUCLE DE SIEMPRE
12
+ *
13
+ * El programa es un `while` que espera una orden: `const orden = await
14
+ * askInteractivePrompt()`. Ink, en cambio, es un árbol que se monta y vive.
15
+ *
16
+ * Aquí se casan las dos formas: se monta Ink, se espera a que el componente
17
+ * diga que hay orden, se desmonta y se resuelve la promesa. Para el bucle no ha
18
+ * cambiado nada —sigue habiendo una función que devuelve un texto— y por eso
19
+ * este cambio no toca index.ts ni una línea.
20
+ *
21
+ * POR QUE SE MONTA Y SE DESMONTA EN CADA VUELTA
22
+ *
23
+ * Podría quedarse montado toda la sesión, pero entonces Ink tendría que ser
24
+ * dueño TAMBIÉN de todo lo que el agente imprime mientras trabaja: las
25
+ * herramientas, el markdown, el pie. Eso es el resto de la mudanza, no ésta.
26
+ * Montando por vuelta, Ink manda solo mientras se escribe —que es donde estaban
27
+ * los fallos— y suelta el terminal en cuanto empieza el turno.
28
+ *
29
+ * El desmontaje devuelve el modo del teclado con su propia cuenta de
30
+ * referencias. Es justamente lo que aquí se llevaba a mano y fallaba.
31
+ */
32
+ /** Lo que se teclea mientras el agente trabaja; aquí ya no vale. */
33
+ function limpiarRestos() {
34
+ clearTyped();
35
+ limpiarPegados();
36
+ }
37
+ /**
38
+ * Envuelve al prompt para que los mensajes que llegan tarde sean estado de
39
+ * React y no escrituras sueltas a stdout.
40
+ *
41
+ * Sin esto, un servidor MCP que termina de conectarse a destiempo escribiría
42
+ * justo encima del marco: Ink no se entera de lo que no pasa por su árbol, y en
43
+ * el siguiente dibujado la cuenta de filas ya no cuadra.
44
+ */
45
+ function Anfitrion({ cwd, promptSymbol, resolver, cancelar, registrarAviso, }) {
46
+ const [avisos, setAvisos] = useState([]);
47
+ useEffect(() => {
48
+ registrarAviso((texto) => {
49
+ setAvisos((previos) => [...previos, texto.replace(/\n$/, "")]);
50
+ });
51
+ }, [registrarAviso]);
52
+ return (_jsx(Prompt, { alEnviar: resolver, alCancelar: cancelar, cwd: cwd, promptSymbol: promptSymbol, avisos: avisos }));
53
+ }
54
+ /**
55
+ * Pide una orden al usuario. Misma firma que la versión de siempre.
56
+ *
57
+ * Devuelve el texto ya expandido: las marcas de pegado vuelven a ser el texto
58
+ * entero antes de que nadie lo vea, porque lo que el modelo tiene que recibir es
59
+ * lo que el usuario pegó, no "[pegado #1 · 347 lineas]".
60
+ */
61
+ export async function preguntarConInk(promptSymbol = "❯", cwd = process.cwd()) {
62
+ // El pie tenía pintado el marco mientras el agente trabajaba. Se borra antes
63
+ // de que Ink empiece a contar: dos dueños del mismo trozo de pantalla se
64
+ // pisan, y el que pierde deja filas huérfanas.
65
+ soltarPie();
66
+ limpiarRestos();
67
+ return new Promise((resolve) => {
68
+ let entregado = false;
69
+ let empujarAviso = null;
70
+ // Los avisos tardíos entran por aquí mientras el prompt esté montado.
71
+ escribirTardio = (texto) => {
72
+ if (empujarAviso)
73
+ empujarAviso(texto);
74
+ };
75
+ const terminar = (orden) => {
76
+ if (entregado)
77
+ return;
78
+ entregado = true;
79
+ resolve(orden);
80
+ };
81
+ const instancia = render(_jsx(Anfitrion, { cwd: cwd, promptSymbol: promptSymbol, resolver: (orden) => {
82
+ const entero = expandirPegados(orden);
83
+ apuntarEnHistorial(entero);
84
+ terminar(entero);
85
+ }, cancelar: () => {
86
+ terminar("/exit");
87
+ }, registrarAviso: (empujar) => {
88
+ empujarAviso = empujar;
89
+ } }));
90
+ instancia.waitUntilExit().then(() => {
91
+ escribirTardio = null;
92
+ limpiarPegados();
93
+ // Si el árbol muere sin haber entregado nada -una excepción, un Ctrl+C
94
+ // del sistema-, la promesa no puede quedarse colgada: el bucle se
95
+ // detendría para siempre sin decir por qué.
96
+ terminar("");
97
+ });
98
+ });
99
+ }
100
+ /**
101
+ * Por dónde entran los mensajes que llegan mientras el prompt está abierto.
102
+ *
103
+ * `null` cuando no hay prompt montado: entonces quien avise que escriba por
104
+ * donde escriba siempre, que no hay nada que romper.
105
+ */
106
+ let escribirTardio = null;
107
+ export function hayPromptDeInk() {
108
+ return escribirTardio !== null;
109
+ }
110
+ export function avisarSobreElPromptDeInk(texto) {
111
+ if (escribirTardio)
112
+ escribirTardio(texto);
113
+ }
package/dist/ui/marco.js CHANGED
@@ -91,7 +91,10 @@ export function buildPromptFrame(opts) {
91
91
  // 2. Desplegable. Un marco mas alto que la ventana obliga al terminal a
92
92
  // desplazar el contenido, y a partir de ahi el repintado ya no sabe donde
93
93
  // esta su primera fila: se muestra solo una ventana alrededor del elegido.
94
- const room = Math.max(1, viewportRows - 1 - (1 + inputRows + 1 + 1));
94
+ // El 1 de delante es la fila de aire; despues barra, entrada, barra y pie.
95
+ // Sin contarla, el desplegable pide una fila mas de las que caben y el marco
96
+ // se sale por arriba de la ventana.
97
+ const room = Math.max(1, viewportRows - 1 - (1 + 1 + inputRows + 1 + 1));
95
98
  const shown = Math.min(items.length, room);
96
99
  const dropdownOffset = shown > 0 && items.length > shown
97
100
  ? Math.min(Math.max(0, selectedIndex - Math.floor(shown / 2)), items.length - shown)
@@ -184,13 +187,24 @@ export function buildPromptFrame(opts) {
184
187
  else {
185
188
  footerLine = chalk.hex(modeLook.color)(truncatePlain(modePlain, util));
186
189
  }
187
- const lines = [bar, inputLine, ...dropdownLines, bar, footerLine];
190
+ // UNA FILA DE AIRE ENCIMA DE LA CAJA
191
+ //
192
+ // Al anclar el prompt al fondo, el marco quedo pegado a lo de arriba: la
193
+ // ultima linea de la respuesta y la barra superior de la caja, seguidas. Se
194
+ // lee apelotonado y cuesta ver donde acaba una cosa y empieza la otra. El
195
+ // usuario lo pidio asi: "ponle un poquito de espacio pero no tanto".
196
+ //
197
+ // Una fila, no dos: dos ya son un hueco, y en una ventana corta cada fila que
198
+ // se gasta en aire es una que no se ve de conversacion.
199
+ const lines = ["", bar, inputLine, ...dropdownLines, bar, footerLine];
188
200
  // 4. Donde queda el cursor, en filas fisicas. Antes se hacia cursorCol =
189
201
  // total % cols: aplicaba el modulo pero nunca sumaba la fila, asi que en
190
202
  // cuanto la entrada se partia el acento se iba a escribir a otra linea.
191
203
  const typed = input.length === 0 ? "" : input.slice(0, cursorIndex);
192
204
  const segments = typed.split("\n");
193
- let cursorRow = physicalRows(bar, cols);
205
+ // La fila de aire de arriba cuenta: sin sumarla, el cursor se dibuja una fila
206
+ // por encima de donde se escribe.
207
+ let cursorRow = 1 + physicalRows(bar, cols);
194
208
  for (let i = 0; i < segments.length - 1; i++) {
195
209
  cursorRow += Math.max(1, Math.ceil(((i === 0 ? symbolWidth : 0) + segments[i].length) / cols));
196
210
  }
package/dist/ui/prompt.js CHANGED
@@ -19,6 +19,7 @@ import { buildPromptFrame } from "./marco.js";
19
19
  export { buildPromptFrame } from "./marco.js";
20
20
  export { SLASH_COMMANDS } from "./comandos.js";
21
21
  import { escribirEncimaDelPie, soltarPie } from "./pieFijo.js";
22
+ import { avisarSobreElPromptDeInk, hayPromptDeInk, preguntarConInk } from "./ink/montarPrompt.js";
22
23
  import { esGrande, guardarPegado, expandirPegados, marcaQueTerminaEn, limpiarPegados } from "./pegados.js";
23
24
  /**
24
25
  * Escribir algo mientras el prompt esta dibujado.
@@ -46,6 +47,12 @@ let escribirEncimaDelPrompt = null;
46
47
  * pueda llegar TARDE: conexiones en segundo plano, avisos, temporizadores.
47
48
  */
48
49
  export function imprimirSobreElPrompt(texto) {
50
+ // Con Ink montado, escribir a stdout por fuera de su arbol le descuadra la
51
+ // cuenta de filas en el siguiente dibujado. Se le entrega como estado.
52
+ if (hayPromptDeInk()) {
53
+ avisarSobreElPromptDeInk(texto);
54
+ return;
55
+ }
49
56
  if (escribirEncimaDelPrompt) {
50
57
  escribirEncimaDelPrompt(texto);
51
58
  return;
@@ -140,7 +147,24 @@ function scanProjectFiles(dir, maxResults = 40) {
140
147
  walk(dir);
141
148
  return results;
142
149
  }
150
+ /**
151
+ * ¿Se usa el prompt de Ink? Sí, salvo que se pida lo contrario.
152
+ *
153
+ * La salida de emergencia existe porque esto cambia la parte más frágil del
154
+ * programa —el teclado— justo cuando hay clientes pagando. Si Ink se porta mal
155
+ * en una consola concreta, `CHOCOLATITO_PROMPT=clasico` devuelve el de siempre
156
+ * sin reinstalar ni bajar de versión.
157
+ */
158
+ function usarInk() {
159
+ return process.env.CHOCOLATITO_PROMPT !== "clasico";
160
+ }
143
161
  export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.cwd()) {
162
+ if (usarInk())
163
+ return preguntarConInk(promptSymbol, cwd);
164
+ return promptClasico(promptSymbol, cwd);
165
+ }
166
+ /** La implementación de siempre: readline en crudo y las filas contadas a mano. */
167
+ async function promptClasico(promptSymbol = "❯", cwd = process.cwd()) {
144
168
  return new Promise((resolve) => {
145
169
  // El relevo de la barra.
146
170
  //
@@ -200,9 +224,38 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
200
224
  // demasiados los sitios que apagan el modo crudo con razon, y basta que uno
201
225
  // llegue tarde para que el usuario teclee y su texto salga en la linea de
202
226
  // ayuda en vez de en el recuadro.
227
+ /**
228
+ * La primera vuelta REAFIRMA aunque Node crea que todo esta bien.
229
+ *
230
+ * EL AGUJERO QUE TAPA
231
+ *
232
+ * `repararTeclado` solo actua cuando SABE que se perdio el modo crudo: mira
233
+ * `process.stdin.isRaw`. Pero hay una forma de perderlo que Node no ve. Un
234
+ * proceso hijo que hereda la consola -git al refrescar el contexto, un hook,
235
+ * un comando- puede devolverla en cocido al terminar, y `isRaw` se queda
236
+ * diciendo `true`. Node cree que manda y no manda.
237
+ *
238
+ * Si eso pasa DESPUES de abrir el prompt, la reafirmacion de arriba ya paso
239
+ * y el vigilante no lo ve: el usuario teclea, no sale nada, y solo se
240
+ * desatasca al pulsar Enter, que es cuando la consola en cocido suelta la
241
+ * linea. Palabras del usuario, dos veces: "al cambiar de potencia se queda
242
+ * pegado y no deja escribir, tengo que presionar enter".
243
+ *
244
+ * Reafirmar una vez mas, poco despues de abrir, cubre esa ventana. No se
245
+ * hace en cada vuelta a proposito: pasar por false y volver a true ocho
246
+ * veces por segundo es pedirle problemas a la consola por un caso raro.
247
+ */
248
+ let primeraVigilancia = true;
203
249
  const vigilanteTeclado = setInterval(() => {
204
250
  if (isFinished)
205
251
  return;
252
+ if (primeraVigilancia) {
253
+ primeraVigilancia = false;
254
+ reafirmarModoCrudo(process.stdin);
255
+ if (process.env.CHOCOLATITO_TECLADO === "debug") {
256
+ process.stderr.write(`\n[teclado] reafirmado al abrir (isRaw decia ${process.stdin.isRaw})\n`);
257
+ }
258
+ }
206
259
  const reparado = repararTeclado(process.stdin);
207
260
  if (reparado.length === 0)
208
261
  return;
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "minimum_chrome_version": "102",
4
4
  "name": "Chocolatito Code",
5
- "version": "1.3.3",
5
+ "version": "1.4.2",
6
6
  "description": "Deja que Chocolatito Code trabaje dentro de tu Chrome, en segundo plano y con tus sesiones ya iniciadas.",
7
7
  "permissions": [
8
8
  "tabs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chocolatito-code",
3
- "version": "1.3.3",
3
+ "version": "1.4.2",
4
4
  "description": "Agente autónomo de programación para la terminal, con control real del ordenador y del navegador. Desarrollado por Chocolatito.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,17 +51,21 @@
51
51
  "dotenv": "^16.4.7",
52
52
  "execa": "^9.5.2",
53
53
  "fast-glob": "^3.3.3",
54
+ "ink": "^7.1.1",
54
55
  "marked": "^15.0.12",
55
56
  "marked-terminal": "^7.3.0",
56
57
  "openai": "^4.85.4",
57
58
  "ora": "^8.2.0",
58
59
  "puppeteer-core": "^25.9.0",
60
+ "react": "^19.2.8",
59
61
  "ws": "^8.21.3"
60
62
  },
61
63
  "devDependencies": {
62
64
  "@types/diff": "^7.0.0",
63
65
  "@types/node": "^22.13.5",
66
+ "@types/react": "^19.2.18",
64
67
  "@types/ws": "^8.18.1",
68
+ "ink-testing-library": "^4.0.0",
65
69
  "typescript": "^5.7.3"
66
70
  }
67
71
  }