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.
- package/dist/agent/loop.js +49 -13
- package/dist/config/quota.d.ts +11 -0
- package/dist/config/quota.js +18 -0
- package/dist/index.js +35 -26
- package/dist/prompts/systemPrompt.js +186 -150
- package/dist/ui/banner.d.ts +31 -0
- package/dist/ui/banner.js +45 -0
- package/dist/ui/comandos.d.ts +33 -0
- package/dist/ui/comandos.js +49 -0
- package/dist/ui/informeDeUso.d.ts +17 -0
- package/dist/ui/informeDeUso.js +81 -0
- package/dist/ui/marco.d.ts +35 -0
- package/dist/ui/marco.js +161 -0
- package/dist/ui/prompt.d.ts +4 -41
- package/dist/ui/prompt.js +21 -162
- package/dist/ui/renderer.d.ts +13 -0
- package/dist/ui/renderer.js +41 -12
- package/dist/ui/spinner.d.ts +33 -5
- package/dist/ui/spinner.js +65 -28
- package/extension/manifest.json +60 -60
- package/package.json +67 -67
package/dist/ui/banner.d.ts
CHANGED
|
@@ -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;
|
package/dist/ui/marco.js
ADDED
|
@@ -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
|
+
}
|
package/dist/ui/prompt.d.ts
CHANGED
|
@@ -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>;
|