chocolatito-code 1.3.4 → 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();
package/dist/ui/banner.js CHANGED
@@ -123,7 +123,8 @@ export const FILAS_MINIMAS_PARA_ANCLAR = 16;
123
123
  * Filas que se imprimen DESPUES del banner y hay que dejarles sitio.
124
124
  *
125
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 (barra, entrada, barra, estado: 4).
126
+ * console.log: 2 filas) y el marco del prompt, que son cinco: una de aire y
127
+ * despues barra, entrada, barra y estado.
127
128
  *
128
129
  * AQUI ESTABA EL FALLO DEL ZORRO CORTADO
129
130
  *
@@ -132,7 +133,7 @@ export const FILAS_MINIMAS_PARA_ANCLAR = 16;
132
133
  * arriba la cabeza del zorro. El usuario lo vio en cuanto abrio: "hay mucho
133
134
  * espacio y se baja y se corta el zorrito al iniciar".
134
135
  */
135
- const FILAS_DESPUES_DEL_BANNER = 6;
136
+ const FILAS_DESPUES_DEL_BANNER = 7;
136
137
  /**
137
138
  * Filas en blanco que hay que soltar ANTES de `contenido` para que el prompt
138
139
  * acabe pegado al fondo de la ventana. Cero si no cabe o no hace falta.
@@ -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.4",
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.4",
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
  }