chocolatito-code 1.2.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/huecos.d.ts +58 -0
- package/dist/agent/huecos.js +81 -0
- package/dist/agent/loop.js +144 -24
- package/dist/agent/salidaDelAgente.js +10 -24
- package/dist/agent/toolGate.js +6 -1
- package/dist/config/engine.d.ts +21 -1
- package/dist/config/engine.js +22 -1
- 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/tools/browserExtension.js +118 -3
- package/dist/tools/computerUse.d.ts +1 -1
- package/dist/tools/computerUse.js +183 -2
- package/dist/tools/toolDefsComputer.js +6 -2
- package/dist/tools/visionBridge.js +60 -3
- package/dist/tools/win/hostScript.js +154 -0
- 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/interrupt.d.ts +10 -0
- package/dist/ui/interrupt.js +50 -2
- package/dist/ui/marco.d.ts +35 -0
- package/dist/ui/marco.js +161 -0
- package/dist/ui/pieFijo.d.ts +56 -0
- package/dist/ui/pieFijo.js +309 -0
- package/dist/ui/prompt.d.ts +4 -41
- package/dist/ui/prompt.js +24 -157
- package/dist/ui/reasoningStream.js +22 -12
- package/dist/ui/renderer.d.ts +13 -0
- package/dist/ui/renderer.js +41 -12
- package/dist/ui/spinner.d.ts +43 -8
- package/dist/ui/spinner.js +118 -43
- package/extension/background.js +9 -3
- package/extension/iconos/128.png +0 -0
- package/extension/iconos/16.png +0 -0
- package/extension/iconos/32.png +0 -0
- package/extension/iconos/48.png +0 -0
- package/extension/iconos/ORIGEN.md +48 -0
- package/extension/manifest.json +60 -60
- package/package.json +67 -67
|
@@ -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
|
+
}
|
package/dist/ui/interrupt.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ export declare function tomarTecladoParaPrompt(): void;
|
|
|
3
3
|
/** Lo llama el prompt al cerrarse, para que el siguiente turno pueda restaurar. */
|
|
4
4
|
export declare function devolverTecladoDelPrompt(): void;
|
|
5
5
|
export declare function releaseKeyboard(): void;
|
|
6
|
+
/**
|
|
7
|
+
* Devuelve el teclado a quien lo tenia antes del dialogo.
|
|
8
|
+
*
|
|
9
|
+
* NO se llama a start(): eso crearia un AbortController nuevo, y la peticion en
|
|
10
|
+
* curso se quedaria escuchando al viejo. Esc dejaria de cancelar justo lo que el
|
|
11
|
+
* usuario quiere cancelar.
|
|
12
|
+
*/
|
|
13
|
+
export declare function retomarTeclado(): void;
|
|
6
14
|
export declare class InterruptWatcher {
|
|
7
15
|
private controller;
|
|
8
16
|
private wasRaw;
|
|
@@ -13,6 +21,8 @@ export declare class InterruptWatcher {
|
|
|
13
21
|
get interrupted(): boolean;
|
|
14
22
|
get signal(): AbortSignal | undefined;
|
|
15
23
|
start(): AbortSignal | undefined;
|
|
24
|
+
/** Vuelve a escuchar despues de un dialogo, sin tocar la senal de aborto. */
|
|
25
|
+
reanudar(): void;
|
|
16
26
|
private abort;
|
|
17
27
|
stop(): void;
|
|
18
28
|
}
|
package/dist/ui/interrupt.js
CHANGED
|
@@ -3,6 +3,7 @@ import process from "node:process";
|
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { cycleMode, modeAnnouncement } from "./modes.js";
|
|
5
5
|
import { pushTyped, backspaceTyped, commitTyped } from "./typeAhead.js";
|
|
6
|
+
import { reafirmarModoCrudo } from "./keyboardGuard.js";
|
|
6
7
|
import { dibujar } from "./salida.js";
|
|
7
8
|
/**
|
|
8
9
|
* Interrupcion con Esc mientras el agente trabaja.
|
|
@@ -69,11 +70,39 @@ export function tomarTecladoParaPrompt() {
|
|
|
69
70
|
export function devolverTecladoDelPrompt() {
|
|
70
71
|
promptConElTeclado = false;
|
|
71
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Vigilantes que se soltaron para ensenar un dialogo, y que hay que devolver.
|
|
75
|
+
*
|
|
76
|
+
* El dialogo de permisos suelta el teclado antes de dibujarse -si no, el
|
|
77
|
+
* vigilante se come las teclas y la caja no responde-, pero hasta ahora nadie lo
|
|
78
|
+
* devolvia despues. El resto del turno se quedaba SIN vigilante: en modo cocido
|
|
79
|
+
* el terminal hacia el eco por su cuenta, Esc dejaba de cancelar, y lo que el
|
|
80
|
+
* usuario escribiera no se encolaba.
|
|
81
|
+
*
|
|
82
|
+
* Se veia asi, y el usuario dio con la pista sin saberlo: "a veces se queda
|
|
83
|
+
* trabado y no se puede escribir, pero con darle Enter vuelve a la normalidad".
|
|
84
|
+
* Claro: en modo cocido el terminal guarda la linea y Enter la suelta de golpe.
|
|
85
|
+
*/
|
|
86
|
+
let soltados = [];
|
|
72
87
|
export function releaseKeyboard() {
|
|
73
|
-
|
|
88
|
+
soltados = Array.from(activos);
|
|
89
|
+
for (const w of soltados) {
|
|
74
90
|
w.stop();
|
|
75
91
|
}
|
|
76
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Devuelve el teclado a quien lo tenia antes del dialogo.
|
|
95
|
+
*
|
|
96
|
+
* NO se llama a start(): eso crearia un AbortController nuevo, y la peticion en
|
|
97
|
+
* curso se quedaria escuchando al viejo. Esc dejaria de cancelar justo lo que el
|
|
98
|
+
* usuario quiere cancelar.
|
|
99
|
+
*/
|
|
100
|
+
export function retomarTeclado() {
|
|
101
|
+
const devolver = soltados;
|
|
102
|
+
soltados = [];
|
|
103
|
+
for (const w of devolver)
|
|
104
|
+
w.reanudar();
|
|
105
|
+
}
|
|
77
106
|
export class InterruptWatcher {
|
|
78
107
|
controller = null;
|
|
79
108
|
wasRaw = false;
|
|
@@ -164,6 +193,23 @@ export class InterruptWatcher {
|
|
|
164
193
|
activos.add(this);
|
|
165
194
|
return this.controller.signal;
|
|
166
195
|
}
|
|
196
|
+
/** Vuelve a escuchar despues de un dialogo, sin tocar la senal de aborto. */
|
|
197
|
+
reanudar() {
|
|
198
|
+
if (this.listening || !this.onKey)
|
|
199
|
+
return;
|
|
200
|
+
if (!process.stdin.isTTY || !process.stdin.setRawMode)
|
|
201
|
+
return;
|
|
202
|
+
readline.emitKeypressEvents(process.stdin);
|
|
203
|
+
if (activos.size === 0)
|
|
204
|
+
modoCrudoOriginal = process.stdin.isRaw || false;
|
|
205
|
+
// Reafirmar y no solo poner: el readline del dialogo deja la consola en modo
|
|
206
|
+
// cocido, y un setRawMode(true) a secas puede ser un no-op.
|
|
207
|
+
reafirmarModoCrudo(process.stdin);
|
|
208
|
+
process.stdin.resume();
|
|
209
|
+
process.stdin.on("keypress", this.onKey);
|
|
210
|
+
this.listening = true;
|
|
211
|
+
activos.add(this);
|
|
212
|
+
}
|
|
167
213
|
abort() {
|
|
168
214
|
if (this.aborted)
|
|
169
215
|
return;
|
|
@@ -176,7 +222,9 @@ export class InterruptWatcher {
|
|
|
176
222
|
stop() {
|
|
177
223
|
if (this.onKey) {
|
|
178
224
|
process.stdin.removeListener("keypress", this.onKey);
|
|
179
|
-
|
|
225
|
+
// El manejador NO se tira: reanudar() lo vuelve a enganchar tal cual, con
|
|
226
|
+
// su AbortController intacto. Rehacerlo desde start() romperia el Esc de
|
|
227
|
+
// la peticion que ya esta en vuelo.
|
|
180
228
|
}
|
|
181
229
|
const escuchaba = this.listening;
|
|
182
230
|
this.listening = false;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Las secciones, de arriba abajo. El orden es fijo a proposito: si cada quien
|
|
3
|
+
* pintara donde le tocara, dos productores independientes -el razonamiento y el
|
|
4
|
+
* spinner llegan por caminos distintos- se turnarian el sitio y el pie bailaria.
|
|
5
|
+
*/
|
|
6
|
+
declare const ORDEN: readonly ["razonamiento", "spinner", "entrada"];
|
|
7
|
+
export type SeccionDelPie = (typeof ORDEN)[number];
|
|
8
|
+
/**
|
|
9
|
+
* Enciende o apaga el pie a mano. Con `null` vuelve a mandar el terminal.
|
|
10
|
+
*
|
|
11
|
+
* Las pruebas lo encienden porque en `node --test` no hay TTY y sin esto el pie
|
|
12
|
+
* seria un no-op silencioso: pasarian todas sin comprobar nada.
|
|
13
|
+
*/
|
|
14
|
+
export declare function habilitarPie(valor: boolean | null): void;
|
|
15
|
+
/** ¿Hay algo pintado abajo que haya que respetar? */
|
|
16
|
+
export declare function hayPie(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Pone (o reemplaza) una seccion del pie y repinta.
|
|
19
|
+
*
|
|
20
|
+
* Con la lista vacia la seccion desaparece, que es lo mismo que quitarla. Si el
|
|
21
|
+
* contenido es identico al que ya habia, no se repinta: el spinner llama ocho
|
|
22
|
+
* veces por segundo y la barra de escribir casi nunca cambia entre dos.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fijarSeccion(nombre: SeccionDelPie, lineas: string[]): void;
|
|
25
|
+
/**
|
|
26
|
+
* Varias secciones de una vez, con UN solo repintado.
|
|
27
|
+
*
|
|
28
|
+
* El spinner cambia su linea y la barra de escribir en el mismo tick. Puestas
|
|
29
|
+
* por separado se pintaba el bloque dos veces cada 120 ms, y la segunda pasada
|
|
30
|
+
* borraba y reescribia lo que la primera acababa de dejar: el doble de bytes
|
|
31
|
+
* para el mismo resultado, y un parpadeo que se nota en un terminal lento.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fijarSecciones(entradas: Array<[SeccionDelPie, string[]]>): void;
|
|
34
|
+
export declare function quitarSeccion(nombre: SeccionDelPie): void;
|
|
35
|
+
/**
|
|
36
|
+
* Escribe texto permanente sin que el pie se lo lleve por delante.
|
|
37
|
+
*
|
|
38
|
+
* Sin pie es un write normal y no cuesta nada; por eso puede sustituir a
|
|
39
|
+
* `console.log` en el camino del agente sin pensarlo dos veces.
|
|
40
|
+
*/
|
|
41
|
+
export declare function escribirEncimaDelPie(texto: string): void;
|
|
42
|
+
/**
|
|
43
|
+
* Quita el pie entero y deja el terminal como si nunca hubiera estado.
|
|
44
|
+
*
|
|
45
|
+
* Lo que quede a medias de linea se conserva en pantalla: es texto del agente,
|
|
46
|
+
* no adorno, y borrarlo seria perder salida.
|
|
47
|
+
*/
|
|
48
|
+
export declare function soltarPie(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Olvida el estado sin tocar la pantalla.
|
|
51
|
+
*
|
|
52
|
+
* Para las pruebas y para la salida del proceso: ahi no queda terminal al que
|
|
53
|
+
* repintar, pero el cursor escondido si sobrevive y hay que devolverlo.
|
|
54
|
+
*/
|
|
55
|
+
export declare function reiniciarPie(): void;
|
|
56
|
+
export {};
|