chocolatito-code 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,37 @@ export declare const SILKSCREEN_TITLE_ROWS: string[];
4
4
  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
+ /**
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.
10
+ */
11
+ export declare const FILAS_MINIMAS_PARA_ANCLAR = 16;
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.
15
+ *
16
+ * Se exporta para poder comprobar la cuenta: dentro de un terminal esto no se
17
+ * revisa a ojo, y pasarse de una fila hace desaparecer la primera del banner.
18
+ */
19
+ export declare function filasParaAnclar(contenido: string, filasDeVentana: number): number;
20
+ /**
21
+ * ANCLAR EL PROMPT AL FONDO DE LA VENTANA
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.
28
+ *
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.
33
+ *
34
+ * NO se usa la region de scroll ni la pantalla alternativa: las dos se llevan
35
+ * por delante el historial del terminal, y aqui media sesion consiste en subir
36
+ * a releer lo que salio.
37
+ */
7
38
  export declare function showBanner(cwd: string, model: EspectroModel, state?: MascotState): void;
8
39
  export declare function animateStartupBanner(cwd: string, model: EspectroModel): Promise<void>;
9
40
  export declare function showEffortMenu(currentId: string): void;
package/dist/ui/banner.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import chalk from "chalk";
2
+ import process from "node:process";
2
3
  import { ESPECTRO_MODELS } from "../config/constants.js";
3
4
  import { CONTEXT_WINDOW } from "../config/constants.js";
5
+ import { dibujar } from "./salida.js";
4
6
  // Base grid for mascota-pixeleada: 16 cols x 16 rows
5
7
  const BASE_GRID_16X16 = [
6
8
  [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0],
@@ -112,9 +114,52 @@ export function buildBannerContent(cwd, model, isBlinking = false) {
112
114
  }
113
115
  return ["", ...combinedRows, ""].join("\n");
114
116
  }
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.
120
+ */
121
+ export const FILAS_MINIMAS_PARA_ANCLAR = 16;
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.
125
+ *
126
+ * Se exporta para poder comprobar la cuenta: dentro de un terminal esto no se
127
+ * revisa a ojo, y pasarse de una fila hace desaparecer la primera del banner.
128
+ */
129
+ export function filasParaAnclar(contenido, filasDeVentana) {
130
+ if (filasDeVentana < FILAS_MINIMAS_PARA_ANCLAR)
131
+ return 0;
132
+ // El `console.log` anade su propio salto: por eso la altura del bloque es una
133
+ // linea mas de las que trae el texto.
134
+ const alto = contenido.split("\n").length;
135
+ return Math.max(0, filasDeVentana - alto - 1);
136
+ }
137
+ /**
138
+ * ANCLAR EL PROMPT AL FONDO DE LA VENTANA
139
+ *
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.
145
+ *
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.
150
+ *
151
+ * NO se usa la region de scroll ni la pantalla alternativa: las dos se llevan
152
+ * por delante el historial del terminal, y aqui media sesion consiste en subir
153
+ * a releer lo que salio.
154
+ */
115
155
  export function showBanner(cwd, model, state = "idle") {
116
156
  const content = buildBannerContent(cwd, model, state === "blink");
117
157
  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));
118
163
  }
119
164
  export async function animateStartupBanner(cwd, model) {
120
165
  // Impresión limpia instantánea sin animaciones lentas en el terminal
@@ -0,0 +1,33 @@
1
+ /**
2
+ * LOS COMANDOS DE BARRA, EN UN SOLO SITIO
3
+ *
4
+ * Vivian dentro de prompt.ts, que es quien los autocompleta. Ahora tambien los
5
+ * necesita el prompt de sistema, y prompt.ts arrastra readline, el historial en
6
+ * disco y el escaneo del proyecto: no es algo que deba cargar quien solo quiere
7
+ * la lista.
8
+ *
9
+ * POR QUE LE HACEN FALTA AL MODELO
10
+ *
11
+ * Sesion real. El usuario pregunta como ver su consumo y el agente contesta
12
+ * hablando de "la barra lateral", "la app de escritorio" y "empezar un hilo
13
+ * nuevo". Ninguna de esas cosas existe aqui: esto es una terminal. El modelo
14
+ * sabia que se llamaba Chocolatito Code -se lo dice el prompt- pero no sabia
15
+ * NADA de su propio producto, asi que contestaba con lo que sabe de los chats
16
+ * web. Y la respuesta correcta era una palabra: /cost.
17
+ *
18
+ * Con la lista en un modulo aparte, la que ve el usuario al escribir "/" y la
19
+ * que se le cuenta al modelo son la misma. Duplicarla habria durado hasta el
20
+ * primer comando nuevo.
21
+ *
22
+ * OJO: esta lista SE MODIFICA EN CALIENTE. index.ts anade /actualizar y
23
+ * /licencia al arrancar, segun como este instalado el programa. Por eso el
24
+ * prompt de sistema la lee en cada turno y no una vez al importar.
25
+ */
26
+ export interface SlashCommandItem {
27
+ name: string;
28
+ description: string;
29
+ args?: string;
30
+ }
31
+ export declare const SLASH_COMMANDS: SlashCommandItem[];
32
+ /** La lista tal y como se le cuenta al modelo. Una linea por comando. */
33
+ export declare function listaParaElModelo(): string;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * LOS COMANDOS DE BARRA, EN UN SOLO SITIO
3
+ *
4
+ * Vivian dentro de prompt.ts, que es quien los autocompleta. Ahora tambien los
5
+ * necesita el prompt de sistema, y prompt.ts arrastra readline, el historial en
6
+ * disco y el escaneo del proyecto: no es algo que deba cargar quien solo quiere
7
+ * la lista.
8
+ *
9
+ * POR QUE LE HACEN FALTA AL MODELO
10
+ *
11
+ * Sesion real. El usuario pregunta como ver su consumo y el agente contesta
12
+ * hablando de "la barra lateral", "la app de escritorio" y "empezar un hilo
13
+ * nuevo". Ninguna de esas cosas existe aqui: esto es una terminal. El modelo
14
+ * sabia que se llamaba Chocolatito Code -se lo dice el prompt- pero no sabia
15
+ * NADA de su propio producto, asi que contestaba con lo que sabe de los chats
16
+ * web. Y la respuesta correcta era una palabra: /cost.
17
+ *
18
+ * Con la lista en un modulo aparte, la que ve el usuario al escribir "/" y la
19
+ * que se le cuenta al modelo son la misma. Duplicarla habria durado hasta el
20
+ * primer comando nuevo.
21
+ *
22
+ * OJO: esta lista SE MODIFICA EN CALIENTE. index.ts anade /actualizar y
23
+ * /licencia al arrancar, segun como este instalado el programa. Por eso el
24
+ * prompt de sistema la lee en cada turno y no una vez al importar.
25
+ */
26
+ export const SLASH_COMMANDS = [
27
+ { name: "/mcp", description: "Ver servidores MCP conectados y sus herramientas" },
28
+ { name: "/undo", description: "Deshacer las últimas modificaciones de archivos de la sesión" },
29
+ { name: "/export", description: "Exportar bitácora de la sesión a archivo Markdown", args: "[nombre.md]" },
30
+ { name: "/plan", description: "Alternar Modo Plan (solo lectura e investigación previa)" },
31
+ { name: "/resume", description: "Reanudar una sesión anterior guardada", args: "[id]" },
32
+ { name: "/skills", description: "Ver skills disponibles y cómo usarlos" },
33
+ { name: "/memory", description: "Consultar la memoria persistente transversal (~/.chocolatito/memory/)" },
34
+ { name: "/effort", description: "Regular potencia del motor (1.0, 1.5, 2.0, 2.5)", args: "[nivel]" },
35
+ { name: "/goal", description: "Ejecutar meta compleja autónoma con auto-corrección", args: "<meta>" },
36
+ { name: "/compact", description: "Comprimir contexto y optimizar memoria de la sesión" },
37
+ { name: "/diff", description: "Ver diferencias de código (Git diff) del proyecto" },
38
+ { name: "/commit", description: "Crear un commit en Git con mensaje semántico", args: "<mensaje>" },
39
+ { name: "/cd", description: "Cambiar el directorio de trabajo activo", args: "<ruta>" },
40
+ { name: "/uso", description: "Ver cuánto llevas consumido de tu cuenta" },
41
+ { name: "/clear", description: "Limpiar historial y reiniciar contexto" },
42
+ { name: "/init", description: "Crear archivo de directivas CHOCOLATITO.md" },
43
+ { name: "/help", description: "Mostrar guía completa de comandos y ayuda" },
44
+ { name: "/exit", description: "Cerrar sesión de Chocolatito Code" },
45
+ ];
46
+ /** La lista tal y como se le cuenta al modelo. Una linea por comando. */
47
+ export function listaParaElModelo() {
48
+ return SLASH_COMMANDS.map((c) => `- ${c.name}${c.args ? ` ${c.args}` : ""}: ${c.description}`).join("\n");
49
+ }
@@ -0,0 +1,17 @@
1
+ import { QuotaStatus } from "../config/quota.js";
2
+ export interface DatosDeSesion {
3
+ motor: string;
4
+ tokensEntrada: number;
5
+ tokensSalida: number;
6
+ tokensEnCache: number;
7
+ /** Coste de la sesion, ya formateado. Solo se usa con clave propia. */
8
+ costeFormateado: string;
9
+ }
10
+ /**
11
+ * Arma el informe. Puro y exportado para poder comprobarlo: lo que importa aqui
12
+ * es lo que NO sale, y eso a ojo en un terminal no se revisa.
13
+ *
14
+ * `vaciaEn` es cuando la ventana corta vuelve a cero (quota.ventanaVaciaEn).
15
+ * `null` cuando no hay nada dentro de ella.
16
+ */
17
+ export declare function construirInformeDeUso(estado: QuotaStatus, sesion: DatosDeSesion, vaciaEn: Date | null, ahora?: number, gestionado?: boolean): string;
@@ -0,0 +1,81 @@
1
+ import chalk from "chalk";
2
+ import { faltaPara, isManagedMode } from "../config/quota.js";
3
+ /**
4
+ * EL INFORME DE /cost
5
+ *
6
+ * QUE ENSENABA ANTES
7
+ *
8
+ * • Tokens entrada: 11,553 ($0.00508 USD)
9
+ * • Tokens salida: 332 ($0.00044 USD)
10
+ * • Inversion total: $0.0055 USD
11
+ *
12
+ * Tarifas de API en la cara de alguien que paga una suscripcion plana. Dos
13
+ * problemas, y el segundo es el importante.
14
+ *
15
+ * **No le sirve.** Su factura es fija; que un turno costara medio centimo no le
16
+ * dice nada que pueda usar. Lo que necesita saber es cuanto le queda antes de
17
+ * que el programa le pare, y eso son las ventanas de uso.
18
+ *
19
+ * **Y ensena el margen.** El coste de API por cuenta es informacion del negocio
20
+ * de quien vende esto, no del cliente. Puesto en pantalla, cualquiera saca la
21
+ * cuenta de lo que se gana con el.
22
+ *
23
+ * CON CLAVE PROPIA ES AL REVES
24
+ *
25
+ * Ahi el dinero es del usuario, se lo cobra su proveedor y no hay ventana que
26
+ * ensenar porque no se le limita nada (ver la nota de quota.ts). Ocultarle el
27
+ * gasto seria esconderle SU factura. Asi que ese caso mantiene los dolares.
28
+ */
29
+ const ANCHO_BARRA = 20;
30
+ /** Barra de progreso en texto. Se ve el nivel de un vistazo, sin leer el numero. */
31
+ function barra(porcentaje) {
32
+ const llenas = Math.max(0, Math.min(ANCHO_BARRA, Math.round((porcentaje / 100) * ANCHO_BARRA)));
33
+ // Tres colores y no un degradado: lo unico que hay que distinguir es "vas
34
+ // bien", "ojo" y "estas a punto de quedarte sin".
35
+ const color = porcentaje >= 90 ? "#EF4444" : porcentaje >= 70 ? "#FFA500" : "#22C55E";
36
+ return chalk.hex(color)("█".repeat(llenas)) + chalk.gray("░".repeat(ANCHO_BARRA - llenas));
37
+ }
38
+ function porcentaje(gastado, tope) {
39
+ if (!(tope > 0))
40
+ return 0;
41
+ return Math.min(100, Math.round((gastado / tope) * 100));
42
+ }
43
+ /**
44
+ * Arma el informe. Puro y exportado para poder comprobarlo: lo que importa aqui
45
+ * es lo que NO sale, y eso a ojo en un terminal no se revisa.
46
+ *
47
+ * `vaciaEn` es cuando la ventana corta vuelve a cero (quota.ventanaVaciaEn).
48
+ * `null` cuando no hay nada dentro de ella.
49
+ */
50
+ export function construirInformeDeUso(estado, sesion, vaciaEn, ahora = Date.now(), gestionado = isManagedMode()) {
51
+ const num = (n) => n.toLocaleString("es-ES");
52
+ const lineas = [];
53
+ lineas.push(chalk.bold.hex("#A855F7")("\n📊 CONSUMO DE TU CUENTA"));
54
+ lineas.push("");
55
+ if (gestionado) {
56
+ const pctVentana = porcentaje(estado.spentWindowUsd, estado.config.windowBudgetUsd);
57
+ const pctSemana = porcentaje(estado.spentWeekUsd, estado.config.weeklyBudgetUsd);
58
+ // La de 5 h va primera: es la que corta antes y la que sorprende.
59
+ const cuando = vaciaEn
60
+ ? chalk.gray(` vuelve a cero en ${faltaPara(vaciaEn, ahora)}`)
61
+ : chalk.gray(" sin uso reciente");
62
+ lineas.push(` ${chalk.gray(`Ventana de ${estado.config.windowHours} h`.padEnd(16))}${barra(pctVentana)} ` +
63
+ `${chalk.bold.white(`${pctVentana}%`.padStart(4))}${cuando}`);
64
+ lineas.push(` ${chalk.gray("Últimos 7 días".padEnd(16))}${barra(pctSemana)} ` +
65
+ `${chalk.bold.white(`${pctSemana}%`.padStart(4))}`);
66
+ }
67
+ else {
68
+ // Clave propia: no hay cuenta que gestionar, hay una factura que es suya.
69
+ lineas.push(` ${chalk.gray("Gasto de API".padEnd(16))}${chalk.bold.green(sesion.costeFormateado)}`);
70
+ lineas.push(` ${chalk.gray("".padEnd(16))}${chalk.gray("con tu propia clave: esto te lo cobra tu proveedor")}`);
71
+ }
72
+ lineas.push("");
73
+ lineas.push(` ${chalk.gray("Esta sesión".padEnd(16))}${chalk.white(num(sesion.tokensEntrada))} de entrada · ` +
74
+ `${chalk.white(num(sesion.tokensSalida))} de salida`);
75
+ if (sesion.tokensEnCache > 0) {
76
+ lineas.push(` ${chalk.gray("".padEnd(16))}${chalk.gray(`${num(sesion.tokensEnCache)} reaprovechados de la caché`)}`);
77
+ }
78
+ lineas.push(` ${chalk.gray("Motor".padEnd(16))}${chalk.white(sesion.motor)}`);
79
+ lineas.push("");
80
+ return lineas.join("\n");
81
+ }
@@ -0,0 +1,35 @@
1
+ export interface DropdownItem {
2
+ type: "command" | "file";
3
+ label: string;
4
+ secondary?: string;
5
+ insertText: string;
6
+ }
7
+ export interface PromptFrameOptions {
8
+ cols: number;
9
+ /** Alto de la ventana; sirve para no dibujar un marco que no quepa. */
10
+ rows?: number;
11
+ promptSymbol?: string;
12
+ input?: string;
13
+ cursorIndex?: number;
14
+ items?: DropdownItem[];
15
+ selectedIndex?: number;
16
+ }
17
+ export interface PromptFrame {
18
+ /** Lineas LOGICAS. Cualquiera puede ocupar mas de una fila fisica. */
19
+ lines: string[];
20
+ /** Filas de pantalla que ocupara el marco entero con este ancho. */
21
+ totalRows: number;
22
+ /** Fila fisica del cursor, con 0 en la primera fila del marco. */
23
+ cursorRow: number;
24
+ cursorCol: number;
25
+ /** Primer elemento del desplegable que se muestra (ventana deslizante). */
26
+ dropdownOffset: number;
27
+ }
28
+ /**
29
+ * Arma el marco del prompt y dice exactamente donde queda el cursor.
30
+ *
31
+ * Va aparte de render() por dos razones: se puede probar sin terminal, y la
32
+ * aritmetica de filas se hace UNA vez sobre las mismas lineas que se pintan, en
33
+ * lugar de estimarse por otro lado y descuadrarse.
34
+ */
35
+ export declare function buildPromptFrame(opts: PromptFrameOptions): PromptFrame;
@@ -0,0 +1,161 @@
1
+ import chalk from "chalk";
2
+ import { describeMode, getMode } from "./modes.js";
3
+ /**
4
+ * EL MARCO DE LA BARRA, EN UN SOLO SITIO
5
+ *
6
+ * Vivia dentro de prompt.ts. Ahora lo necesitan dos: el prompt cuando espera una
7
+ * orden, y el spinner mientras el agente trabaja.
8
+ *
9
+ * POR QUE COMPARTIDO Y NO COPIADO
10
+ *
11
+ * Habia DOS barras distintas turnandose. Al esperar salia el marco entero
12
+ * -barra, entrada, barra, linea de ayuda-; al trabajar, una version recortada
13
+ * con otro texto. Se parecian lo justo para que el cambio de una a otra se viera
14
+ * como un salto, y el usuario lo dijo asi: "aun no es fija... debe ser fija y
15
+ * por debajo deben pasar los otros mensajes".
16
+ *
17
+ * Con el constructor compartido no pueden divergir: si alguien toca el marco, lo
18
+ * toca en los dos estados a la vez. Copiarlo habria durado hasta el primer
19
+ * retoque.
20
+ *
21
+ * Va aparte tambien por peso: prompt.ts arrastra readline, el historial en disco
22
+ * y el escaneo del proyecto, y el spinner no tiene por que cargar nada de eso.
23
+ */
24
+ function stripAnsi(str) {
25
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
26
+ }
27
+ const PLACEHOLDER = "Escribe una instrucción, '@' para archivos o '/' para comandos...";
28
+ /**
29
+ * Filas FISICAS que ocupa una linea del marco.
30
+ *
31
+ * Aqui estaba el fallo de verdad: el repintado contaba elementos del array del
32
+ * marco mientras el terminal cuenta filas de pantalla. Cualquier linea que no
33
+ * cupiera en el ancho se partia en dos filas, el cursor subia una fila de menos
34
+ * y el marco entero se iba bajando, dejando una barra huerfana por pulsacion.
35
+ * La entrada ademas puede llevar saltos de linea dentro (continuacion con "\").
36
+ */
37
+ function physicalRows(line, cols) {
38
+ return stripAnsi(line)
39
+ .split("\n")
40
+ .reduce((n, seg) => n + Math.max(1, Math.ceil(seg.length / cols)), 0);
41
+ }
42
+ /** Recorta a `width` columnas visibles. Texto sin pintar: aqui no hay que cerrar colores. */
43
+ function truncatePlain(text, width) {
44
+ if (width <= 0)
45
+ return "";
46
+ if (text.length <= width)
47
+ return text;
48
+ return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
49
+ }
50
+ /**
51
+ * Arma el marco del prompt y dice exactamente donde queda el cursor.
52
+ *
53
+ * Va aparte de render() por dos razones: se puede probar sin terminal, y la
54
+ * aritmetica de filas se hace UNA vez sobre las mismas lineas que se pintan, en
55
+ * lugar de estimarse por otro lado y descuadrarse.
56
+ */
57
+ export function buildPromptFrame(opts) {
58
+ const cols = Math.max(20, opts.cols || 80);
59
+ const viewportRows = Math.max(6, opts.rows || 24);
60
+ const promptSymbol = opts.promptSymbol ?? "❯";
61
+ const input = opts.input ?? "";
62
+ const cursorIndex = Math.max(0, Math.min(opts.cursorIndex ?? input.length, input.length));
63
+ const items = opts.items ?? [];
64
+ const selectedIndex = items.length > 0 ? (((opts.selectedIndex ?? 0) % items.length) + items.length) % items.length : 0;
65
+ const isPlan = promptSymbol.includes("plan");
66
+ const symbolStr = isPlan
67
+ ? `${chalk.cyan.bold("plan ❯")} `
68
+ : `${chalk.bold.hex("#D97757")("❯")} `;
69
+ const symbolWidth = isPlan ? 7 : 2;
70
+ const bar = chalk.gray("─".repeat(cols));
71
+ // 1. Linea de entrada. Lo que el usuario escribe NO se recorta nunca: se deja
72
+ // que el terminal la parta y despues se cuentan las filas que ocupa.
73
+ let formattedInput;
74
+ if (input.length === 0) {
75
+ // La pista si se recorta: en una ventana estrecha ocupaba una segunda fila
76
+ // para no decir nada que el usuario no sepa ya.
77
+ formattedInput = chalk.gray(truncatePlain(PLACEHOLDER, cols - symbolWidth));
78
+ }
79
+ else if (input.startsWith("/")) {
80
+ const parts = input.split(" ");
81
+ const cmdPart = chalk.bold.hex("#A855F7")(parts[0]);
82
+ const restPart = parts.slice(1).join(" ");
83
+ formattedInput = restPart ? `${cmdPart} ${chalk.white(restPart)}` : cmdPart;
84
+ }
85
+ else {
86
+ formattedInput = input.replace(/(@[^\s]+)/g, (m) => chalk.bold.hex("#D97757")(m));
87
+ }
88
+ const inputLine = symbolStr + formattedInput;
89
+ const inputRows = physicalRows(inputLine, cols);
90
+ // 2. Desplegable. Un marco mas alto que la ventana obliga al terminal a
91
+ // desplazar el contenido, y a partir de ahi el repintado ya no sabe donde
92
+ // esta su primera fila: se muestra solo una ventana alrededor del elegido.
93
+ const room = Math.max(1, viewportRows - 1 - (1 + inputRows + 1 + 1));
94
+ const shown = Math.min(items.length, room);
95
+ const dropdownOffset = shown > 0 && items.length > shown
96
+ ? Math.min(Math.max(0, selectedIndex - Math.floor(shown / 2)), items.length - shown)
97
+ : 0;
98
+ const dropdownLines = [];
99
+ for (let i = dropdownOffset; i < dropdownOffset + shown; i++) {
100
+ const item = items[i];
101
+ const isSelected = i === selectedIndex;
102
+ const colorHex = item.type === "file" ? "#D97757" : "#A855F7";
103
+ const pointer = isSelected ? chalk.bold.hex(colorHex)(" ❯ ") : chalk.gray(" ");
104
+ // El puntero se lleva 3 columnas fijas. De lo que queda manda la etiqueta:
105
+ // la descripcion es lo primero que se cae, porque una ruta a medias no se
106
+ // puede ni leer ni elegir.
107
+ const free = cols - 3;
108
+ const label = truncatePlain(item.label, free);
109
+ const roomForDesc = free - label.length - 2;
110
+ const secondary = item.secondary && roomForDesc >= 8 ? truncatePlain(item.secondary, roomForDesc) : "";
111
+ const paintedLabel = isSelected ? chalk.bold.white(label) : chalk.hex(colorHex)(label);
112
+ const paintedDesc = secondary
113
+ ? isSelected
114
+ ? chalk.whiteBright(` ${secondary}`)
115
+ : chalk.gray(` ${secondary}`)
116
+ : "";
117
+ dropdownLines.push(`${pointer}${paintedLabel}${paintedDesc}`);
118
+ }
119
+ // 3. Pie de estado. El relleno se calculaba con Math.max(1, ...): cuando no
120
+ // cabia, en vez de recortarse se quedaba con un espacio y la linea salia MAS
121
+ // ancha que el terminal. En una consola de 80 columnas el pie media 87 y se
122
+ // partia en dos filas desde el primer dibujado.
123
+ const modeLook = describeMode(getMode());
124
+ const hintPlain = "? /help para atajos · shift+tab modo · esc cancelar";
125
+ const modePlain = `⇥ modo ${modeLook.label}`;
126
+ const enginePlain = "· Espectro 1.0 (Ágil)";
127
+ const hint = chalk.gray(hintPlain);
128
+ const modeTag = chalk.hex(modeLook.color)(modePlain);
129
+ const engine = chalk.gray(enginePlain);
130
+ let footerLine;
131
+ if (hintPlain.length + 1 + modePlain.length + 1 + enginePlain.length <= cols) {
132
+ const padLen = cols - hintPlain.length - modePlain.length - 1 - enginePlain.length;
133
+ footerLine = `${hint}${" ".repeat(padLen)}${modeTag} ${engine}`;
134
+ }
135
+ else if (modePlain.length + 1 + enginePlain.length <= cols) {
136
+ // Los atajos son opcionales; el modo activo no, que es lo que decide si te
137
+ // van a preguntar antes de tocar tus archivos.
138
+ footerLine = `${" ".repeat(cols - modePlain.length - 1 - enginePlain.length)}${modeTag} ${engine}`;
139
+ }
140
+ else if (modePlain.length <= cols) {
141
+ footerLine = `${" ".repeat(cols - modePlain.length)}${modeTag}`;
142
+ }
143
+ else {
144
+ footerLine = chalk.hex(modeLook.color)(truncatePlain(modePlain, cols));
145
+ }
146
+ const lines = [bar, inputLine, ...dropdownLines, bar, footerLine];
147
+ // 4. Donde queda el cursor, en filas fisicas. Antes se hacia cursorCol =
148
+ // total % cols: aplicaba el modulo pero nunca sumaba la fila, asi que en
149
+ // cuanto la entrada se partia el acento se iba a escribir a otra linea.
150
+ const typed = input.length === 0 ? "" : input.slice(0, cursorIndex);
151
+ const segments = typed.split("\n");
152
+ let cursorRow = physicalRows(bar, cols);
153
+ for (let i = 0; i < segments.length - 1; i++) {
154
+ cursorRow += Math.max(1, Math.ceil(((i === 0 ? symbolWidth : 0) + segments[i].length) / cols));
155
+ }
156
+ const lastSegWidth = (segments.length === 1 ? symbolWidth : 0) + segments[segments.length - 1].length;
157
+ cursorRow += Math.floor(lastSegWidth / cols);
158
+ const cursorCol = lastSegWidth % cols;
159
+ const totalRows = lines.reduce((n, l) => n + physicalRows(l, cols), 0);
160
+ return { lines, totalRows, cursorRow, cursorCol, dropdownOffset };
161
+ }
@@ -1,47 +1,10 @@
1
+ export { buildPromptFrame } from "./marco.js";
2
+ export type { DropdownItem, PromptFrame, PromptFrameOptions } from "./marco.js";
3
+ export { SLASH_COMMANDS } from "./comandos.js";
4
+ export type { SlashCommandItem } from "./comandos.js";
1
5
  /**
2
6
  * Imprime sin romper el prompt. Uselo en vez de `console.log` para todo lo que
3
7
  * pueda llegar TARDE: conexiones en segundo plano, avisos, temporizadores.
4
8
  */
5
9
  export declare function imprimirSobreElPrompt(texto: string): void;
6
- export interface SlashCommandItem {
7
- name: string;
8
- description: string;
9
- args?: string;
10
- }
11
- export declare const SLASH_COMMANDS: SlashCommandItem[];
12
- export interface DropdownItem {
13
- type: "command" | "file";
14
- label: string;
15
- secondary?: string;
16
- insertText: string;
17
- }
18
- export interface PromptFrameOptions {
19
- cols: number;
20
- /** Alto de la ventana; sirve para no dibujar un marco que no quepa. */
21
- rows?: number;
22
- promptSymbol?: string;
23
- input?: string;
24
- cursorIndex?: number;
25
- items?: DropdownItem[];
26
- selectedIndex?: number;
27
- }
28
- export interface PromptFrame {
29
- /** Lineas LOGICAS. Cualquiera puede ocupar mas de una fila fisica. */
30
- lines: string[];
31
- /** Filas de pantalla que ocupara el marco entero con este ancho. */
32
- totalRows: number;
33
- /** Fila fisica del cursor, con 0 en la primera fila del marco. */
34
- cursorRow: number;
35
- cursorCol: number;
36
- /** Primer elemento del desplegable que se muestra (ventana deslizante). */
37
- dropdownOffset: number;
38
- }
39
- /**
40
- * Arma el marco del prompt y dice exactamente donde queda el cursor.
41
- *
42
- * Va aparte de render() por dos razones: se puede probar sin terminal, y la
43
- * aritmetica de filas se hace UNA vez sobre las mismas lineas que se pintan, en
44
- * lugar de estimarse por otro lado y descuadrarse.
45
- */
46
- export declare function buildPromptFrame(opts: PromptFrameOptions): PromptFrame;
47
10
  export declare function askInteractivePrompt(promptSymbol?: string, cwd?: string): Promise<string>;