chocolatito-code 1.3.0 → 1.3.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.
@@ -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,193 @@
1
+ import chalk from "chalk";
2
+ import { describeMode, getMode, TRIANGULOS } from "./modes.js";
3
+ import { motorActual } from "./motorActual.js";
4
+ /**
5
+ * EL MARCO DE LA BARRA, EN UN SOLO SITIO
6
+ *
7
+ * Vivia dentro de prompt.ts. Ahora lo necesitan dos: el prompt cuando espera una
8
+ * orden, y el spinner mientras el agente trabaja.
9
+ *
10
+ * POR QUE COMPARTIDO Y NO COPIADO
11
+ *
12
+ * Habia DOS barras distintas turnandose. Al esperar salia el marco entero
13
+ * -barra, entrada, barra, linea de ayuda-; al trabajar, una version recortada
14
+ * con otro texto. Se parecian lo justo para que el cambio de una a otra se viera
15
+ * como un salto, y el usuario lo dijo asi: "aun no es fija... debe ser fija y
16
+ * por debajo deben pasar los otros mensajes".
17
+ *
18
+ * Con el constructor compartido no pueden divergir: si alguien toca el marco, lo
19
+ * toca en los dos estados a la vez. Copiarlo habria durado hasta el primer
20
+ * retoque.
21
+ *
22
+ * Va aparte tambien por peso: prompt.ts arrastra readline, el historial en disco
23
+ * y el escaneo del proyecto, y el spinner no tiene por que cargar nada de eso.
24
+ */
25
+ function stripAnsi(str) {
26
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
27
+ }
28
+ const PLACEHOLDER = "Escribe una instrucción, '@' para archivos o '/' para comandos...";
29
+ /**
30
+ * Filas FISICAS que ocupa una linea del marco.
31
+ *
32
+ * Aqui estaba el fallo de verdad: el repintado contaba elementos del array del
33
+ * marco mientras el terminal cuenta filas de pantalla. Cualquier linea que no
34
+ * cupiera en el ancho se partia en dos filas, el cursor subia una fila de menos
35
+ * y el marco entero se iba bajando, dejando una barra huerfana por pulsacion.
36
+ * La entrada ademas puede llevar saltos de linea dentro (continuacion con "\").
37
+ */
38
+ function physicalRows(line, cols) {
39
+ return stripAnsi(line)
40
+ .split("\n")
41
+ .reduce((n, seg) => n + Math.max(1, Math.ceil(seg.length / cols)), 0);
42
+ }
43
+ /** Recorta a `width` columnas visibles. Texto sin pintar: aqui no hay que cerrar colores. */
44
+ function truncatePlain(text, width) {
45
+ if (width <= 0)
46
+ return "";
47
+ if (text.length <= width)
48
+ return text;
49
+ return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
50
+ }
51
+ /**
52
+ * Arma el marco del prompt y dice exactamente donde queda el cursor.
53
+ *
54
+ * Va aparte de render() por dos razones: se puede probar sin terminal, y la
55
+ * aritmetica de filas se hace UNA vez sobre las mismas lineas que se pintan, en
56
+ * lugar de estimarse por otro lado y descuadrarse.
57
+ */
58
+ export function buildPromptFrame(opts) {
59
+ const cols = Math.max(20, opts.cols || 80);
60
+ const viewportRows = Math.max(6, opts.rows || 24);
61
+ const promptSymbol = opts.promptSymbol ?? "❯";
62
+ const input = opts.input ?? "";
63
+ const cursorIndex = Math.max(0, Math.min(opts.cursorIndex ?? input.length, input.length));
64
+ const items = opts.items ?? [];
65
+ const selectedIndex = items.length > 0 ? (((opts.selectedIndex ?? 0) % items.length) + items.length) % items.length : 0;
66
+ const isPlan = promptSymbol.includes("plan");
67
+ const symbolStr = isPlan
68
+ ? `${chalk.cyan.bold("plan ❯")} `
69
+ : `${chalk.bold.hex("#D97757")("❯")} `;
70
+ const symbolWidth = isPlan ? 7 : 2;
71
+ const bar = chalk.gray("─".repeat(cols));
72
+ // 1. Linea de entrada. Lo que el usuario escribe NO se recorta nunca: se deja
73
+ // que el terminal la parta y despues se cuentan las filas que ocupa.
74
+ let formattedInput;
75
+ if (input.length === 0) {
76
+ // La pista si se recorta: en una ventana estrecha ocupaba una segunda fila
77
+ // para no decir nada que el usuario no sepa ya.
78
+ formattedInput = chalk.gray(truncatePlain(PLACEHOLDER, cols - symbolWidth));
79
+ }
80
+ else if (input.startsWith("/")) {
81
+ const parts = input.split(" ");
82
+ const cmdPart = chalk.bold.hex("#A855F7")(parts[0]);
83
+ const restPart = parts.slice(1).join(" ");
84
+ formattedInput = restPart ? `${cmdPart} ${chalk.white(restPart)}` : cmdPart;
85
+ }
86
+ else {
87
+ formattedInput = input.replace(/(@[^\s]+)/g, (m) => chalk.bold.hex("#D97757")(m));
88
+ }
89
+ const inputLine = symbolStr + formattedInput;
90
+ const inputRows = physicalRows(inputLine, cols);
91
+ // 2. Desplegable. Un marco mas alto que la ventana obliga al terminal a
92
+ // desplazar el contenido, y a partir de ahi el repintado ya no sabe donde
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));
95
+ const shown = Math.min(items.length, room);
96
+ const dropdownOffset = shown > 0 && items.length > shown
97
+ ? Math.min(Math.max(0, selectedIndex - Math.floor(shown / 2)), items.length - shown)
98
+ : 0;
99
+ const dropdownLines = [];
100
+ for (let i = dropdownOffset; i < dropdownOffset + shown; i++) {
101
+ const item = items[i];
102
+ const isSelected = i === selectedIndex;
103
+ const colorHex = item.type === "file" ? "#D97757" : "#A855F7";
104
+ const pointer = isSelected ? chalk.bold.hex(colorHex)(" ❯ ") : chalk.gray(" ");
105
+ // El puntero se lleva 3 columnas fijas. De lo que queda manda la etiqueta:
106
+ // la descripcion es lo primero que se cae, porque una ruta a medias no se
107
+ // puede ni leer ni elegir.
108
+ const free = cols - 3;
109
+ const label = truncatePlain(item.label, free);
110
+ const roomForDesc = free - label.length - 2;
111
+ const secondary = item.secondary && roomForDesc >= 8 ? truncatePlain(item.secondary, roomForDesc) : "";
112
+ const paintedLabel = isSelected ? chalk.bold.white(label) : chalk.hex(colorHex)(label);
113
+ const paintedDesc = secondary
114
+ ? isSelected
115
+ ? chalk.whiteBright(` ${secondary}`)
116
+ : chalk.gray(` ${secondary}`)
117
+ : "";
118
+ dropdownLines.push(`${pointer}${paintedLabel}${paintedDesc}`);
119
+ }
120
+ // 3. Pie de estado. El relleno se calculaba con Math.max(1, ...): cuando no
121
+ // cabia, en vez de recortarse se quedaba con un espacio y la linea salia MAS
122
+ // ancha que el terminal. En una consola de 80 columnas el pie media 87 y se
123
+ // partia en dos filas desde el primer dibujado.
124
+ const modeLook = describeMode(getMode());
125
+ // 3. La linea de estado, con POSICIONES FIJAS.
126
+ //
127
+ // Antes iban los atajos a la izquierda y el modo y el motor apelotonados a la
128
+ // derecha, y encima la linea se reordenaba sola en cuatro variantes segun el
129
+ // ancho: al estrecharse la ventana, el modo saltaba de sitio. El usuario lo
130
+ // pidio asi: "el boton del modo debe ir a la izquierda... y el modelo a la
131
+ // derecha, pero deben ser fijos siempre y nunca cambiar".
132
+ //
133
+ // Ahora el modo esta SIEMPRE pegado a la izquierda y el motor SIEMPRE pegado a
134
+ // la derecha. Lo unico que aparece y desaparece es la pista del medio, que es
135
+ // lo prescindible: quien ya se sabe los atajos no la necesita, y quien no, la
136
+ // tiene en /help.
137
+ const modePlain = `${TRIANGULOS} modo ${modeLook.label}`;
138
+ // Estaba escrito a mano y por eso no cambiaba nunca. Vacio si nadie lo ha
139
+ // fijado: mejor un hueco que un nombre que no es el que se esta usando.
140
+ const enginePlain = motorActual();
141
+ const hintPlain = "shift+tab cambia · esc cancela · ? /help";
142
+ const modeTag = chalk.hex(modeLook.color)(modePlain);
143
+ const engine = chalk.gray(enginePlain);
144
+ // Dos columnas de respiro. Los triangulos son un glifo de anchura ambigua y
145
+ // hay terminales que lo pintan doble: si la linea llegara justa al ancho, el
146
+ // terminal la partiria en dos filas y el repintado del marco se descuadraria
147
+ // entero. Ese fallo ya costo una barra huerfana por pulsacion.
148
+ const util = Math.max(0, cols - 2);
149
+ let footerLine;
150
+ const huecoConPista = util - modePlain.length - 1 - hintPlain.length - enginePlain.length;
151
+ const hueco = util - modePlain.length - enginePlain.length;
152
+ if (!enginePlain) {
153
+ // Todavia nadie ha dicho que motor hay. Se enseña el modo y ya, pero SIN
154
+ // saltarse el recorte: "⏵⏵ modo acepta ediciones" son 24 columnas y en una
155
+ // consola de 20 se partiria en dos filas, que es lo que descuadra el marco.
156
+ const conPista = util - modePlain.length - 1 - hintPlain.length;
157
+ if (modePlain.length > util) {
158
+ footerLine = chalk.hex(modeLook.color)(truncatePlain(modePlain, util));
159
+ }
160
+ else {
161
+ footerLine = conPista >= 0 ? `${modeTag} ${chalk.gray(hintPlain)}` : modeTag;
162
+ }
163
+ }
164
+ else if (huecoConPista >= 1) {
165
+ footerLine = `${modeTag} ${chalk.gray(hintPlain)}${" ".repeat(huecoConPista)}${engine}`;
166
+ }
167
+ else if (hueco >= 1) {
168
+ footerLine = `${modeTag}${" ".repeat(hueco)}${engine}`;
169
+ }
170
+ else if (modePlain.length <= util) {
171
+ // Si hay que sacrificar uno, se va el motor: el modo es lo que decide si te
172
+ // van a preguntar antes de tocar tus archivos.
173
+ footerLine = modeTag;
174
+ }
175
+ else {
176
+ footerLine = chalk.hex(modeLook.color)(truncatePlain(modePlain, util));
177
+ }
178
+ const lines = [bar, inputLine, ...dropdownLines, bar, footerLine];
179
+ // 4. Donde queda el cursor, en filas fisicas. Antes se hacia cursorCol =
180
+ // total % cols: aplicaba el modulo pero nunca sumaba la fila, asi que en
181
+ // cuanto la entrada se partia el acento se iba a escribir a otra linea.
182
+ const typed = input.length === 0 ? "" : input.slice(0, cursorIndex);
183
+ const segments = typed.split("\n");
184
+ let cursorRow = physicalRows(bar, cols);
185
+ for (let i = 0; i < segments.length - 1; i++) {
186
+ cursorRow += Math.max(1, Math.ceil(((i === 0 ? symbolWidth : 0) + segments[i].length) / cols));
187
+ }
188
+ const lastSegWidth = (segments.length === 1 ? symbolWidth : 0) + segments[segments.length - 1].length;
189
+ cursorRow += Math.floor(lastSegWidth / cols);
190
+ const cursorCol = lastSegWidth % cols;
191
+ const totalRows = lines.reduce((n, l) => n + physicalRows(l, cols), 0);
192
+ return { lines, totalRows, cursorRow, cursorCol, dropdownOffset };
193
+ }
@@ -22,7 +22,15 @@ export interface ModeLook {
22
22
  color: string;
23
23
  }
24
24
  export declare function describeMode(mode?: InteractiveMode): ModeLook;
25
- /** Etiqueta de una linea para el pie del prompt y para el spinner. */
25
+ /**
26
+ * El simbolo del modo.
27
+ *
28
+ * Dos triangulos, no la flecha de tabulacion. El "⇥" de antes es el simbolo del
29
+ * tabulador: dice COMO se cambia de modo, no QUE modo hay puesto. Y como ademas
30
+ * el atajo es shift+tab, se leia como si el modo fuera un atajo mas.
31
+ */
32
+ export declare const TRIANGULOS = "\u23F5\u23F5";
33
+ /** Etiqueta de una linea del modo activo. */
26
34
  export declare function modeBadge(mode?: InteractiveMode): string;
27
35
  /** Aviso completo, para cuando el usuario acaba de cambiar de modo. */
28
36
  export declare function modeAnnouncement(mode?: InteractiveMode): string;
package/dist/ui/modes.js CHANGED
@@ -43,15 +43,23 @@ const LOOKS = {
43
43
  export function describeMode(mode = current) {
44
44
  return LOOKS[mode];
45
45
  }
46
- /** Etiqueta de una linea para el pie del prompt y para el spinner. */
46
+ /**
47
+ * El simbolo del modo.
48
+ *
49
+ * Dos triangulos, no la flecha de tabulacion. El "⇥" de antes es el simbolo del
50
+ * tabulador: dice COMO se cambia de modo, no QUE modo hay puesto. Y como ademas
51
+ * el atajo es shift+tab, se leia como si el modo fuera un atajo mas.
52
+ */
53
+ export const TRIANGULOS = "⏵⏵";
54
+ /** Etiqueta de una linea del modo activo. */
47
55
  export function modeBadge(mode = current) {
48
56
  if (mode === "default")
49
57
  return "";
50
58
  const look = LOOKS[mode];
51
- return chalk.hex(look.color)(`⇥ modo ${look.label}`);
59
+ return chalk.hex(look.color)(`${TRIANGULOS} modo ${look.label}`);
52
60
  }
53
61
  /** Aviso completo, para cuando el usuario acaba de cambiar de modo. */
54
62
  export function modeAnnouncement(mode = current) {
55
63
  const look = LOOKS[mode];
56
- return `${chalk.bold.hex(look.color)(`⇥ modo ${look.label}`)} ${chalk.gray(`· ${look.hint}`)} ${chalk.gray("· shift+tab para cambiar")}`;
64
+ return `${chalk.bold.hex(look.color)(`${TRIANGULOS} modo ${look.label}`)} ${chalk.gray(`· ${look.hint}`)} ${chalk.gray("· shift+tab para cambiar")}`;
57
65
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * QUE MOTOR ESTA PUESTO
3
+ *
4
+ * La linea de estado del marco lo llevaba escrito a mano:
5
+ *
6
+ * const enginePlain = "Espectro 1.0 (Ágil)";
7
+ *
8
+ * Asi que decia siempre lo mismo pasara lo que pasara. Cambiabas de motor con
9
+ * /effort, el agente empezaba a usar otro de verdad, y la barra seguia
10
+ * anunciando el primero. Lo vio el usuario: "al poner el modelo, la parte de
11
+ * abajo que es fija no se actualiza con el modelo que se pone".
12
+ *
13
+ * Un indicador que miente es peor que no tener indicador: te hace tomar
14
+ * decisiones -esperar mas, gastar mas- creyendo que estas en otro sitio.
15
+ *
16
+ * Vive aparte y no en el marco por lo mismo que el modo (ver modes.ts): lo
17
+ * escribe quien cambia de motor y lo lee quien pinta, y ninguno de los dos tiene
18
+ * por que conocer al otro.
19
+ */
20
+ /** Lo pone index.ts al arrancar y cada vez que se cambia de motor. */
21
+ export declare function fijarMotor(nuevo: string): void;
22
+ /**
23
+ * El nombre del motor activo.
24
+ *
25
+ * Vacio hasta que alguien lo fije. Quien pinta decide que hacer con eso: mejor
26
+ * no enseñar nada que enseñar un nombre inventado.
27
+ */
28
+ export declare function motorActual(): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * QUE MOTOR ESTA PUESTO
3
+ *
4
+ * La linea de estado del marco lo llevaba escrito a mano:
5
+ *
6
+ * const enginePlain = "Espectro 1.0 (Ágil)";
7
+ *
8
+ * Asi que decia siempre lo mismo pasara lo que pasara. Cambiabas de motor con
9
+ * /effort, el agente empezaba a usar otro de verdad, y la barra seguia
10
+ * anunciando el primero. Lo vio el usuario: "al poner el modelo, la parte de
11
+ * abajo que es fija no se actualiza con el modelo que se pone".
12
+ *
13
+ * Un indicador que miente es peor que no tener indicador: te hace tomar
14
+ * decisiones -esperar mas, gastar mas- creyendo que estas en otro sitio.
15
+ *
16
+ * Vive aparte y no en el marco por lo mismo que el modo (ver modes.ts): lo
17
+ * escribe quien cambia de motor y lo lee quien pinta, y ninguno de los dos tiene
18
+ * por que conocer al otro.
19
+ */
20
+ let nombre = "";
21
+ /** Lo pone index.ts al arrancar y cada vez que se cambia de motor. */
22
+ export function fijarMotor(nuevo) {
23
+ if (nuevo && nuevo.trim())
24
+ nombre = nuevo.trim();
25
+ }
26
+ /**
27
+ * El nombre del motor activo.
28
+ *
29
+ * Vacio hasta que alguien lo fije. Quien pinta decide que hacer con eso: mejor
30
+ * no enseñar nada que enseñar un nombre inventado.
31
+ */
32
+ export function motorActual() {
33
+ return nombre;
34
+ }
@@ -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>;
package/dist/ui/prompt.js CHANGED
@@ -4,12 +4,21 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
6
6
  import chalk from "chalk";
7
- import { cycleMode, describeMode, getMode } from "./modes.js";
7
+ import { cycleMode } from "./modes.js";
8
8
  import { releaseKeyboard, tomarTecladoParaPrompt, devolverTecladoDelPrompt } from "./interrupt.js";
9
9
  import { repararTeclado, reafirmarModoCrudo, MS_ENTRE_COMPROBACIONES } from "./keyboardGuard.js";
10
10
  import { clearTyped } from "./typeAhead.js";
11
11
  import { dibujar } from "./salida.js";
12
- import { escribirEncimaDelPie, reiniciarPie } from "./pieFijo.js";
12
+ // La lista vive en su propio modulo porque tambien la necesita el prompt de
13
+ // sistema, y este archivo arrastra readline, el historial y el escaneo del
14
+ // proyecto. Se re-exporta para no romper a quien ya la importaba de aqui.
15
+ import { SLASH_COMMANDS } from "./comandos.js";
16
+ // El marco lo comparten el prompt y el spinner: son la MISMA barra, no dos
17
+ // que se parecen. Ver la nota de marco.ts.
18
+ import { buildPromptFrame } from "./marco.js";
19
+ export { buildPromptFrame } from "./marco.js";
20
+ export { SLASH_COMMANDS } from "./comandos.js";
21
+ import { escribirEncimaDelPie, soltarPie } from "./pieFijo.js";
13
22
  import { esGrande, guardarPegado, expandirPegados, marcaQueTerminaEn, limpiarPegados } from "./pegados.js";
14
23
  /**
15
24
  * Escribir algo mientras el prompt esta dibujado.
@@ -45,26 +54,6 @@ export function imprimirSobreElPrompt(texto) {
45
54
  // viven ahi. Un aviso que llegue tarde tiene que pasar por encima igual.
46
55
  escribirEncimaDelPie(texto.endsWith("\n") ? texto : `${texto}\n`);
47
56
  }
48
- export const SLASH_COMMANDS = [
49
- { name: "/mcp", description: "Ver servidores MCP conectados y sus herramientas" },
50
- { name: "/undo", description: "Deshacer las últimas modificaciones de archivos de la sesión" },
51
- { name: "/export", description: "Exportar bitácora de la sesión a archivo Markdown", args: "[nombre.md]" },
52
- { name: "/plan", description: "Alternar Modo Plan (solo lectura e investigación previa)" },
53
- { name: "/resume", description: "Reanudar una sesión anterior guardada", args: "[id]" },
54
- { name: "/skills", description: "Ver skills disponibles y cómo usarlos" },
55
- { name: "/memory", description: "Consultar la memoria persistente transversal (~/.chocolatito/memory/)" },
56
- { name: "/effort", description: "Regular potencia del motor (1.0, 1.5, 2.0, 2.5)", args: "[nivel]" },
57
- { name: "/goal", description: "Ejecutar meta compleja autónoma con auto-corrección", args: "<meta>" },
58
- { name: "/compact", description: "Comprimir contexto y optimizar memoria de la sesión" },
59
- { name: "/diff", description: "Ver diferencias de código (Git diff) del proyecto" },
60
- { name: "/commit", description: "Crear un commit en Git con mensaje semántico", args: "<mensaje>" },
61
- { name: "/cd", description: "Cambiar el directorio de trabajo activo", args: "<ruta>" },
62
- { name: "/cost", description: "Ver consumo detallado de tokens e inversión en USD" },
63
- { name: "/clear", description: "Limpiar historial y reiniciar contexto" },
64
- { name: "/init", description: "Crear archivo de directivas CHOCOLATITO.md" },
65
- { name: "/help", description: "Mostrar guía completa de comandos y ayuda" },
66
- { name: "/exit", description: "Cerrar sesión de Chocolatito Code" },
67
- ];
68
57
  let isKeypressInitialized = false;
69
58
  /**
70
59
  * PEGADO DE VARIAS LINEAS
@@ -151,148 +140,18 @@ function scanProjectFiles(dir, maxResults = 40) {
151
140
  walk(dir);
152
141
  return results;
153
142
  }
154
- const PLACEHOLDER = "Escribe una instrucción, '@' para archivos o '/' para comandos...";
155
- /**
156
- * Filas FISICAS que ocupa una linea del marco.
157
- *
158
- * Aqui estaba el fallo de verdad: el repintado contaba elementos del array del
159
- * marco mientras el terminal cuenta filas de pantalla. Cualquier linea que no
160
- * cupiera en el ancho se partia en dos filas, el cursor subia una fila de menos
161
- * y el marco entero se iba bajando, dejando una barra huerfana por pulsacion.
162
- * La entrada ademas puede llevar saltos de linea dentro (continuacion con "\").
163
- */
164
- function physicalRows(line, cols) {
165
- return stripAnsi(line)
166
- .split("\n")
167
- .reduce((n, seg) => n + Math.max(1, Math.ceil(seg.length / cols)), 0);
168
- }
169
- /** Recorta a `width` columnas visibles. Texto sin pintar: aqui no hay que cerrar colores. */
170
- function truncatePlain(text, width) {
171
- if (width <= 0)
172
- return "";
173
- if (text.length <= width)
174
- return text;
175
- return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
176
- }
177
- /**
178
- * Arma el marco del prompt y dice exactamente donde queda el cursor.
179
- *
180
- * Va aparte de render() por dos razones: se puede probar sin terminal, y la
181
- * aritmetica de filas se hace UNA vez sobre las mismas lineas que se pintan, en
182
- * lugar de estimarse por otro lado y descuadrarse.
183
- */
184
- export function buildPromptFrame(opts) {
185
- const cols = Math.max(20, opts.cols || 80);
186
- const viewportRows = Math.max(6, opts.rows || 24);
187
- const promptSymbol = opts.promptSymbol ?? "❯";
188
- const input = opts.input ?? "";
189
- const cursorIndex = Math.max(0, Math.min(opts.cursorIndex ?? input.length, input.length));
190
- const items = opts.items ?? [];
191
- const selectedIndex = items.length > 0 ? (((opts.selectedIndex ?? 0) % items.length) + items.length) % items.length : 0;
192
- const isPlan = promptSymbol.includes("plan");
193
- const symbolStr = isPlan
194
- ? `${chalk.cyan.bold("plan ❯")} `
195
- : `${chalk.bold.hex("#D97757")("❯")} `;
196
- const symbolWidth = isPlan ? 7 : 2;
197
- const bar = chalk.gray("─".repeat(cols));
198
- // 1. Linea de entrada. Lo que el usuario escribe NO se recorta nunca: se deja
199
- // que el terminal la parta y despues se cuentan las filas que ocupa.
200
- let formattedInput;
201
- if (input.length === 0) {
202
- // La pista si se recorta: en una ventana estrecha ocupaba una segunda fila
203
- // para no decir nada que el usuario no sepa ya.
204
- formattedInput = chalk.gray(truncatePlain(PLACEHOLDER, cols - symbolWidth));
205
- }
206
- else if (input.startsWith("/")) {
207
- const parts = input.split(" ");
208
- const cmdPart = chalk.bold.hex("#A855F7")(parts[0]);
209
- const restPart = parts.slice(1).join(" ");
210
- formattedInput = restPart ? `${cmdPart} ${chalk.white(restPart)}` : cmdPart;
211
- }
212
- else {
213
- formattedInput = input.replace(/(@[^\s]+)/g, (m) => chalk.bold.hex("#D97757")(m));
214
- }
215
- const inputLine = symbolStr + formattedInput;
216
- const inputRows = physicalRows(inputLine, cols);
217
- // 2. Desplegable. Un marco mas alto que la ventana obliga al terminal a
218
- // desplazar el contenido, y a partir de ahi el repintado ya no sabe donde
219
- // esta su primera fila: se muestra solo una ventana alrededor del elegido.
220
- const room = Math.max(1, viewportRows - 1 - (1 + inputRows + 1 + 1));
221
- const shown = Math.min(items.length, room);
222
- const dropdownOffset = shown > 0 && items.length > shown
223
- ? Math.min(Math.max(0, selectedIndex - Math.floor(shown / 2)), items.length - shown)
224
- : 0;
225
- const dropdownLines = [];
226
- for (let i = dropdownOffset; i < dropdownOffset + shown; i++) {
227
- const item = items[i];
228
- const isSelected = i === selectedIndex;
229
- const colorHex = item.type === "file" ? "#D97757" : "#A855F7";
230
- const pointer = isSelected ? chalk.bold.hex(colorHex)(" ❯ ") : chalk.gray(" ");
231
- // El puntero se lleva 3 columnas fijas. De lo que queda manda la etiqueta:
232
- // la descripcion es lo primero que se cae, porque una ruta a medias no se
233
- // puede ni leer ni elegir.
234
- const free = cols - 3;
235
- const label = truncatePlain(item.label, free);
236
- const roomForDesc = free - label.length - 2;
237
- const secondary = item.secondary && roomForDesc >= 8 ? truncatePlain(item.secondary, roomForDesc) : "";
238
- const paintedLabel = isSelected ? chalk.bold.white(label) : chalk.hex(colorHex)(label);
239
- const paintedDesc = secondary
240
- ? isSelected
241
- ? chalk.whiteBright(` ${secondary}`)
242
- : chalk.gray(` ${secondary}`)
243
- : "";
244
- dropdownLines.push(`${pointer}${paintedLabel}${paintedDesc}`);
245
- }
246
- // 3. Pie de estado. El relleno se calculaba con Math.max(1, ...): cuando no
247
- // cabia, en vez de recortarse se quedaba con un espacio y la linea salia MAS
248
- // ancha que el terminal. En una consola de 80 columnas el pie media 87 y se
249
- // partia en dos filas desde el primer dibujado.
250
- const modeLook = describeMode(getMode());
251
- const hintPlain = "? /help para atajos · shift+tab modo · esc cancelar";
252
- const modePlain = `⇥ modo ${modeLook.label}`;
253
- const enginePlain = "· Espectro 1.0 (Ágil)";
254
- const hint = chalk.gray(hintPlain);
255
- const modeTag = chalk.hex(modeLook.color)(modePlain);
256
- const engine = chalk.gray(enginePlain);
257
- let footerLine;
258
- if (hintPlain.length + 1 + modePlain.length + 1 + enginePlain.length <= cols) {
259
- const padLen = cols - hintPlain.length - modePlain.length - 1 - enginePlain.length;
260
- footerLine = `${hint}${" ".repeat(padLen)}${modeTag} ${engine}`;
261
- }
262
- else if (modePlain.length + 1 + enginePlain.length <= cols) {
263
- // Los atajos son opcionales; el modo activo no, que es lo que decide si te
264
- // van a preguntar antes de tocar tus archivos.
265
- footerLine = `${" ".repeat(cols - modePlain.length - 1 - enginePlain.length)}${modeTag} ${engine}`;
266
- }
267
- else if (modePlain.length <= cols) {
268
- footerLine = `${" ".repeat(cols - modePlain.length)}${modeTag}`;
269
- }
270
- else {
271
- footerLine = chalk.hex(modeLook.color)(truncatePlain(modePlain, cols));
272
- }
273
- const lines = [bar, inputLine, ...dropdownLines, bar, footerLine];
274
- // 4. Donde queda el cursor, en filas fisicas. Antes se hacia cursorCol =
275
- // total % cols: aplicaba el modulo pero nunca sumaba la fila, asi que en
276
- // cuanto la entrada se partia el acento se iba a escribir a otra linea.
277
- const typed = input.length === 0 ? "" : input.slice(0, cursorIndex);
278
- const segments = typed.split("\n");
279
- let cursorRow = physicalRows(bar, cols);
280
- for (let i = 0; i < segments.length - 1; i++) {
281
- cursorRow += Math.max(1, Math.ceil(((i === 0 ? symbolWidth : 0) + segments[i].length) / cols));
282
- }
283
- const lastSegWidth = (segments.length === 1 ? symbolWidth : 0) + segments[segments.length - 1].length;
284
- cursorRow += Math.floor(lastSegWidth / cols);
285
- const cursorCol = lastSegWidth % cols;
286
- const totalRows = lines.reduce((n, l) => n + physicalRows(l, cols), 0);
287
- return { lines, totalRows, cursorRow, cursorCol, dropdownOffset };
288
- }
289
143
  export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.cwd()) {
290
144
  return new Promise((resolve) => {
291
- // A partir de aqui el terminal es del prompt, que lleva su propia cuenta de
292
- // filas. El pie fijo tiene que olvidar la suya SIN borrar nada: lo que dejo
293
- // en pantalla es respuesta del agente, no adorno. Y devuelve el cursor, que
294
- // lo escondia mientras se repintaba.
295
- reiniciarPie();
145
+ // El relevo de la barra.
146
+ //
147
+ // Mientras el agente trabajaba, el pie tenia pintado ESTE MISMO marco. Se
148
+ // borra -dejando el cursor justo donde empezaba- y acto seguido se dibuja
149
+ // aqui, en el mismo sitio y con el mismo contenido. Desde fuera la barra no
150
+ // se ha movido: no hay parpadeo ni salto, que era la queja.
151
+ //
152
+ // `soltarPie` y no `reiniciarPie`: hay que BORRAR lo que el pie tenia
153
+ // pintado. Olvidarlo sin borrar dejaria dos marcos, uno encima del otro.
154
+ soltarPie();
296
155
  let inputBuffer = "";
297
156
  let cursorIndex = 0;
298
157
  let selectedIndex = 0;
@@ -603,10 +462,28 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
603
462
  render();
604
463
  return;
605
464
  }
606
- if (items.length > 0 && (inputBuffer === "/" || getAtMention().active)) {
607
- if (applySelectedDropdownItem()) {
465
+ // EL DESPLEGABLE MANDA SOBRE LO TECLEADO
466
+ //
467
+ // Antes esto solo entraba con el buffer EXACTAMENTE en "/". Si habias
468
+ // escrito "/e" y el desplegable te estaba señalando "/effort", el Enter
469
+ // enviaba "/e" tal cual: un comando que no existe, mientras en pantalla
470
+ // ponia otra cosa. Lo conto el usuario asi: "me recomienda effort pero
471
+ // al dar enter se manda como /e".
472
+ const mencionActiva = getAtMention().active;
473
+ const escribiendoComando = inputBuffer.startsWith("/") && !inputBuffer.includes(" ");
474
+ const soloLaBarra = inputBuffer === "/";
475
+ if (items.length > 0 && (escribiendoComando || mencionActiva)) {
476
+ const elegido = items[selectedIndex];
477
+ const yaEstaEntero = Boolean(elegido) && inputBuffer === elegido.insertText;
478
+ if (!yaEstaEntero && applySelectedDropdownItem()) {
608
479
  render();
609
- return;
480
+ // Se completa Y SE ENVIA, que es lo que uno espera al ver el comando
481
+ // señalado y pulsar Enter. Dos excepciones que solo completan:
482
+ // - una mencion de archivo casi siempre lleva mas texto detras;
483
+ // - con solo "/" escrito no has elegido nada todavia, y enviar ahi
484
+ // ejecutaria el primero de la lista sin querer.
485
+ if (mencionActiva || soloLaBarra)
486
+ return;
610
487
  }
611
488
  }
612
489
  scheduleSubmit();
@@ -5,6 +5,19 @@ export declare function renderToolError(name: string, errorSummary: string): voi
5
5
  export declare function renderDiff(filePath: string, diffText: string): void;
6
6
  export declare function renderReasoning(reasoningText: string): void;
7
7
  export declare function renderAssistantResponse(markdownText: string): void;
8
+ /**
9
+ * La linea de cada turno.
10
+ *
11
+ * `⚡ 3.0s · 64 tokens salida (10.8k en caché) · Espectro 1.0 (Ágil) · 99%
12
+ * contexto libre · 28% de la cuota semanal`
13
+ *
14
+ * Con cuenta gestionada NO lleva importe. Se quito primero de /cost y esta se
15
+ * quedo atras; el usuario lo vio enseguida -"aun sigue saliendo el precio que se
16
+ * gasta"- y con razon: /cost se mira cuando uno se acuerda, esta linea sale en
17
+ * TODOS los turnos. De los dos sitios, era el que mas importaba.
18
+ *
19
+ * Con clave propia si sale: ahi el dinero es del usuario y es su factura.
20
+ */
8
21
  export declare function renderFooter(costStr: string, outTokens: number, cachedTokens: number, modelName: string, durationMs?: number, contextPercent?: number, quotaStr?: string): void;
9
22
  export declare function renderError(message: string): void;
10
23
  export declare function formatToolName(name: string): string;