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.
Files changed (44) hide show
  1. package/dist/agent/huecos.d.ts +58 -0
  2. package/dist/agent/huecos.js +81 -0
  3. package/dist/agent/loop.js +144 -24
  4. package/dist/agent/salidaDelAgente.js +10 -24
  5. package/dist/agent/toolGate.js +6 -1
  6. package/dist/config/engine.d.ts +21 -1
  7. package/dist/config/engine.js +22 -1
  8. package/dist/config/quota.d.ts +11 -0
  9. package/dist/config/quota.js +18 -0
  10. package/dist/index.js +35 -26
  11. package/dist/prompts/systemPrompt.js +186 -150
  12. package/dist/tools/browserExtension.js +118 -3
  13. package/dist/tools/computerUse.d.ts +1 -1
  14. package/dist/tools/computerUse.js +183 -2
  15. package/dist/tools/toolDefsComputer.js +6 -2
  16. package/dist/tools/visionBridge.js +60 -3
  17. package/dist/tools/win/hostScript.js +154 -0
  18. package/dist/ui/banner.d.ts +31 -0
  19. package/dist/ui/banner.js +45 -0
  20. package/dist/ui/comandos.d.ts +33 -0
  21. package/dist/ui/comandos.js +49 -0
  22. package/dist/ui/informeDeUso.d.ts +17 -0
  23. package/dist/ui/informeDeUso.js +81 -0
  24. package/dist/ui/interrupt.d.ts +10 -0
  25. package/dist/ui/interrupt.js +50 -2
  26. package/dist/ui/marco.d.ts +35 -0
  27. package/dist/ui/marco.js +161 -0
  28. package/dist/ui/pieFijo.d.ts +56 -0
  29. package/dist/ui/pieFijo.js +309 -0
  30. package/dist/ui/prompt.d.ts +4 -41
  31. package/dist/ui/prompt.js +24 -157
  32. package/dist/ui/reasoningStream.js +22 -12
  33. package/dist/ui/renderer.d.ts +13 -0
  34. package/dist/ui/renderer.js +41 -12
  35. package/dist/ui/spinner.d.ts +43 -8
  36. package/dist/ui/spinner.js +118 -43
  37. package/extension/background.js +9 -3
  38. package/extension/iconos/128.png +0 -0
  39. package/extension/iconos/16.png +0 -0
  40. package/extension/iconos/32.png +0 -0
  41. package/extension/iconos/48.png +0 -0
  42. package/extension/iconos/ORIGEN.md +48 -0
  43. package/extension/manifest.json +60 -60
  44. package/package.json +67 -67
@@ -23,7 +23,7 @@
23
23
  * "background": se actua sobre una ventana concreta sin traerla al frente y
24
24
  * sin mover el raton del usuario.
25
25
  */
26
- export type ComputerAction = "screenshot" | "ui_snapshot" | "ui_click" | "ui_type" | "ui_focus" | "find_element" | "left_click" | "click" | "double_click" | "triple_click" | "right_click" | "middle_click" | "mouse_move" | "move" | "left_click_drag" | "scroll" | "type" | "key" | "hotkey" | "wait" | "sleep" | "cursor_position" | "list_windows" | "focus_window" | "get_active_window" | "open_app";
26
+ export type ComputerAction = "screenshot" | "ui_snapshot" | "ui_click" | "ui_type" | "ui_focus" | "find_element" | "left_click" | "click" | "double_click" | "triple_click" | "right_click" | "middle_click" | "mouse_move" | "move" | "left_click_drag" | "scroll" | "type" | "key" | "hotkey" | "wait" | "wait_change" | "sleep" | "cursor_position" | "list_windows" | "focus_window" | "get_active_window" | "open_app";
27
27
  export interface ComputerUseParams {
28
28
  action: ComputerAction;
29
29
  /** Ventana objetivo por titulo o proceso (ej. "chrome", "Flow"). */
@@ -68,6 +68,155 @@ function ambiguityNote(p, choice) {
68
68
  function fail(action, msg) {
69
69
  return `Error en computer_use (${action}): ${msg}`;
70
70
  }
71
+ /**
72
+ * El estado de la ventana DESPUES de actuar, en el mismo resultado.
73
+ *
74
+ * POR QUE
75
+ *
76
+ * El ciclo de trabajo del agente es mirar, actuar, mirar. Hasta ahora eso
77
+ * costaba TRES viajes al modelo:
78
+ *
79
+ * ui_snapshot -> llamada al modelo
80
+ * ui_click -> llamada al modelo (devolvia solo "Activado [3]")
81
+ * ui_snapshot -> llamada al modelo (para ver que habia pasado)
82
+ *
83
+ * Y cada viaje son entre tres y diez segundos. Medido en esta maquina, un
84
+ * ui_snapshot cuesta 43 ms: es GRATIS al lado de una llamada al modelo. Asi que
85
+ * el tercer viaje se hacia solo para pedir algo que el ordenador podia haber
86
+ * adjuntado desde el principio.
87
+ *
88
+ * Devolviendo el estado nuevo con la accion, el ciclo pasa de tres viajes a
89
+ * dos. En una tarea de diez pasos son diez viajes menos: medio minuto largo.
90
+ *
91
+ * CUANDO NO SE HACE
92
+ *
93
+ * Si la accion fallo no se adjunta nada: lo que hace falta entonces es el
94
+ * motivo del fallo, y una lista de controles solo lo entierra.
95
+ */
96
+ /**
97
+ * Mirar una ventana muchas veces sin pagarlo muchas veces.
98
+ *
99
+ * EL PROBLEMA
100
+ *
101
+ * Leer el arbol de accesibilidad cuesta 43 ms: es gratis. Lo que no es gratis
102
+ * es MANDARSELO al modelo. Un ui_snapshot de una ventana normal son cien lineas
103
+ * que entran en el historial y se vuelven a pagar en cada turno siguiente. Asi
104
+ * que mirar seguido -que es lo que hace falta para trabajar en tiempo real- sale
105
+ * caro por el lado del contexto, no por el de la maquina.
106
+ *
107
+ * LA IDEA
108
+ *
109
+ * Casi nunca cambia la ventana entera. Se pulsa un boton y se habilita otro; se
110
+ * escribe en un campo y cambia su valor. De cien controles cambian dos.
111
+ *
112
+ * Guardando la ultima foto de cada ventana se puede devolver solo la diferencia:
113
+ *
114
+ * CAMBIOS EN "Calculadora" (3 de 47 controles):
115
+ * ~ [12] Text "0" -> "42"
116
+ * + [31] Button "Igual" (nuevo)
117
+ * - Button "Limpiar" (ya no esta)
118
+ *
119
+ * Tres lineas en vez de cuarenta y siete. Eso es lo que hace viable mirar
120
+ * despues de cada accion sin inflar el contexto.
121
+ *
122
+ * CUANDO SE DEVUELVE LA FOTO ENTERA
123
+ *
124
+ * La primera vez, cuando no hay con que comparar. Y cuando cambia tanto que el
125
+ * resumen ya no es mas corto que la lista: si cambio media ventana, es que se
126
+ * abrio otra pantalla, y ahi lo util es verla entera.
127
+ */
128
+ /** La ultima foto de cada ventana, para poder contar solo lo que cambio. */
129
+ const ultimaFoto = new Map();
130
+ /** Identidad de un control que NO depende de su numero de ref, que se renumera. */
131
+ function claveDe(e) {
132
+ return `${e.role}::${e.name}`;
133
+ }
134
+ function compararCon(clave, els) {
135
+ const ahora = new Map();
136
+ for (const e of els)
137
+ ahora.set(claveDe(e), String(e.value ?? ""));
138
+ const antes = ultimaFoto.get(clave);
139
+ ultimaFoto.set(clave, ahora);
140
+ if (!antes)
141
+ return { hayFoto: false, texto: "", cuantos: 0 };
142
+ const lineas = [];
143
+ const porClave = new Map(els.map((e) => [claveDe(e), e]));
144
+ for (const [k, valor] of ahora) {
145
+ const e = porClave.get(k);
146
+ if (!antes.has(k)) {
147
+ lineas.push(` + [${e.ref}] ${e.role} "${e.name}" (nuevo)`);
148
+ }
149
+ else if (antes.get(k) !== valor) {
150
+ lineas.push(` ~ [${e.ref}] ${e.role} "${e.name}": "${antes.get(k)}" -> "${valor}"`);
151
+ }
152
+ }
153
+ for (const k of antes.keys()) {
154
+ if (!ahora.has(k)) {
155
+ const [role, name] = k.split("::");
156
+ lineas.push(` - ${role} "${name}" (ya no esta)`);
157
+ }
158
+ }
159
+ return { hayFoto: true, texto: lineas.join("\n"), cuantos: lineas.length };
160
+ }
161
+ /** Cuantos controles se adjuntan. Bastantes para decidir, pocos para no inundar. */
162
+ const CONTROLES_TRAS_ACTUAR = 40;
163
+ /**
164
+ * Espera a que la ventana se quede quieta, y no un tiempo fijo.
165
+ *
166
+ * Un clic puede abrir un menu, cargar una lista o no hacer nada visible. Dormir
167
+ * 700 ms "por si acaso" es lento cuando no pasa nada y corto cuando si pasa.
168
+ * Se mira dos veces seguidas: si el arbol de controles sale igual, la ventana
169
+ * ya termino.
170
+ */
171
+ async function esperarQuieto(args) {
172
+ let anterior = "";
173
+ for (let intento = 0; intento < 4; intento++) {
174
+ const res = await winHost.send("ui_snapshot", { ...args, max: CONTROLES_TRAS_ACTUAR, interactiveOnly: true }, 20_000);
175
+ if (!res.ok)
176
+ return null;
177
+ const huella = JSON.stringify((res.elements || []).map((e) => [e.role, e.name, e.value]));
178
+ if (huella === anterior)
179
+ return res;
180
+ anterior = huella;
181
+ await new Promise((r) => setTimeout(r, 120));
182
+ }
183
+ // Cuatro vueltas sin quedarse quieta: la ventana esta animando algo. Se
184
+ // devuelve lo ultimo visto, que es mas util que nada.
185
+ return null;
186
+ }
187
+ /** Lo que se pega al resultado de una accion. Cadena vacia si no hay nada que contar. */
188
+ async function estadoTrasActuar(args) {
189
+ let res;
190
+ try {
191
+ res = await esperarQuieto(args);
192
+ }
193
+ catch {
194
+ return "";
195
+ }
196
+ const els = res?.elements || [];
197
+ if (els.length === 0)
198
+ return "";
199
+ // Si hay foto anterior de esta ventana, con decir lo que cambio basta: tres
200
+ // lineas en vez de cuarenta, y el contexto no se infla en cada clic.
201
+ const cambios = compararCon(String(res.title || ""), els);
202
+ if (cambios.hayFoto && cambios.cuantos > 0 && cambios.cuantos <= els.length / 2) {
203
+ return `\n\nCAMBIOS EN "${res.title}" (${cambios.cuantos} de ${els.length} controles):\n${cambios.texto}`;
204
+ }
205
+ if (cambios.hayFoto && cambios.cuantos === 0) {
206
+ return `\n\nLa ventana "${res.title}" no cambio en nada visible.`;
207
+ }
208
+ // Primera vez, o cambio tanto que el resumen ya no seria mas corto: entonces
209
+ // lo util es la lista entera, porque eso significa que se abrio otra pantalla.
210
+ const filas = els
211
+ .map((e) => {
212
+ const val = e.value ? ` = "${e.value}"` : "";
213
+ const off = e.enabled ? "" : " [deshabilitado]";
214
+ return ` [${e.ref}] ${e.role} "${e.name}"${val}${off} @(${e.x}, ${e.y})`;
215
+ })
216
+ .join("\n");
217
+ return (`\n\nASI HA QUEDADO "${res.title}" (los numeros son NUEVOS, usa estos):\n${filas}` +
218
+ (res.truncated ? `\n … hay mas controles; pide ui_snapshot con 'max' mas alto si te falta alguno.` : ""));
219
+ }
71
220
  export async function computerUse(params, cwd = process.cwd(), apiKey) {
72
221
  const { action } = params;
73
222
  const mode = params.mode || "background";
@@ -151,6 +300,10 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
151
300
  `Si es un lienzo grafico, usa screenshot con analyze.` +
152
301
  ambiguityNote(params, choice));
153
302
  }
303
+ // Se apunta esta foto como referencia: si no, el resumen de cambios de la
304
+ // siguiente accion comparara contra una foto vieja y contara cambios que
305
+ // el modelo ya ha visto.
306
+ compararCon(String(res.title || ""), els);
154
307
  const rows = els
155
308
  .map((e) => {
156
309
  const val = e.value ? ` = "${e.value}"` : "";
@@ -163,13 +316,38 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
163
316
  `Actua con ui_click / ui_type usando el numero entre corchetes. No hace falta mover el raton.` +
164
317
  ambiguityNote(params, choice));
165
318
  }
319
+ // Esperar a que la ventana haga algo, en vez de dormir un rato y mirar.
320
+ //
321
+ // Antes solo existia "wait", que duerme un numero fijo de milisegundos:
322
+ // corto cuando la aplicacion tarda y desperdiciado cuando no tarda nada.
323
+ // Esto se suscribe a los avisos de la propia ventana y vuelve en cuanto
324
+ // pasa algo. Medido en vivo: 275 ms contra los 2 s que se habrian dormido.
325
+ case "wait_change": {
326
+ const elegida = await resolveWindow(params);
327
+ if (!elegida.ok)
328
+ return fail(action, elegida.error);
329
+ const ms = Math.min(Math.max(params.ms ?? 3000, 100), 30_000);
330
+ const res = await winHost.send("wait_change", { ...pinned(elegida), ...(elegida.hwnd ? {} : windowArgs(params)), timeoutMs: ms }, ms + 10_000);
331
+ if (!res.ok)
332
+ return fail(action, res.error || "fallo desconocido");
333
+ if (!res.changed) {
334
+ return (`La ventana "${elegida.title || params.window}" no cambio en ${ms} ms. ` +
335
+ `Si esperabas una reaccion, puede que la accion anterior no surtiera efecto.`);
336
+ }
337
+ // Y ya que se vuelve, se dice COMO quedo: si no, hace falta otro viaje.
338
+ const despues = await estadoTrasActuar(windowArgs(params));
339
+ return `Cambio: ${res.what}.${despues}`;
340
+ }
166
341
  case "ui_click": {
167
342
  if (!params.ref)
168
343
  return fail(action, 'indica el "ref" que devolvio ui_snapshot.');
169
344
  const res = await winHost.send("ui_click", { ref: params.ref }, 20_000);
170
345
  if (!res.ok)
171
346
  return fail(action, res.error || "fallo desconocido");
172
- return `Activado [${params.ref}] ${res.label || ""} (via ${res.via}, sin mover el raton).`;
347
+ // El estado nuevo va aqui y no en otro viaje al modelo: cuesta 43 ms en
348
+ // la maquina contra varios segundos de ida y vuelta. Ver estadoTrasActuar.
349
+ const despues = await estadoTrasActuar(windowArgs(params));
350
+ return `Activado [${params.ref}] ${res.label || ""} (via ${res.via}, sin mover el raton).${despues}`;
173
351
  }
174
352
  case "ui_type": {
175
353
  if (!params.ref)
@@ -221,8 +399,11 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
221
399
  interactiveOnly: false,
222
400
  });
223
401
  const landed = (check.elements || []).length > 0;
402
+ // Igual que en ui_click: el estado nuevo viaja con la accion en vez de
403
+ // costar otra ida y vuelta al modelo.
404
+ const tras = landed ? await estadoTrasActuar(windowArgs(params)) : "";
224
405
  return landed
225
- ? `Texto escrito en [${params.ref}] con clic y teclado reales, y verificado en el control: "${preview(params.text)}"`
406
+ ? `Texto escrito en [${params.ref}] con clic y teclado reales, y verificado en el control: "${preview(params.text)}"${tras}`
226
407
  : `Aviso: se escribio en [${params.ref}] pero el control no muestra el texto. ` +
227
408
  `Puede que el foco se perdiera. Haz ui_snapshot y reintenta antes de dar por hecho que funciono.`;
228
409
  }
@@ -82,7 +82,10 @@ export const COMPUTER_USE_TOOL = {
82
82
  "REGLA DE ORO: nunca hagas clic en una coordenada que no te haya dado antes una herramienta. " +
83
83
  "FLUJO CORRECTO: 'list_windows' para saber que hay abierto -> 'ui_snapshot' con la ventana objetivo para listar sus controles reales -> " +
84
84
  "'ui_click' / 'ui_type' con el numero de ref. Eso actua en segundo plano, sin mover el raton ni robar el foco al usuario. " +
85
- "Solo si el arbol de accesibilidad no ve el control (lienzos, juegos, imagenes) recurre a 'screenshot' con 'window' y luego a 'find_element'. " +
85
+ "CUANTO CUESTA CADA COSA, que decide cual usar: ui_snapshot tarda unos 40 ms y te devuelve texto que entiendes directo. "
86
+ + "screenshot cuesta una llamada entera a otro modelo -varios segundos- mas los tokens de la imagen. "
87
+ + "O sea que screenshot es unas cien veces mas caro. Usalo SOLO cuando el arbol de accesibilidad no vea el control: lienzos, juegos, imagenes, interfaces dibujadas a mano. "
88
+ + "Para todo lo demas, ui_snapshot y luego find_element. " +
86
89
  "Las coordenadas siempre son pixeles fisicos de pantalla.",
87
90
  parameters: {
88
91
  type: "object",
@@ -109,6 +112,7 @@ export const COMPUTER_USE_TOOL = {
109
112
  "type",
110
113
  "key",
111
114
  "wait",
115
+ "wait_change",
112
116
  "cursor_position",
113
117
  "focus_window",
114
118
  "get_active_window",
@@ -116,7 +120,7 @@ export const COMPUTER_USE_TOOL = {
116
120
  ],
117
121
  description: "list_windows: inventario de ventanas. ui_snapshot: lista los controles reales de una ventana con su ref. " +
118
122
  "ui_click/ui_type/ui_focus: actuan sobre un ref sin tocar el raton. find_element: busca un control por texto y, si hace falta, lo localiza con vision. " +
119
- "screenshot: captura una ventana concreta (aunque este tapada) y la describe. click/type/key: entrada real, solo como ultimo recurso.",
123
+ "wait_change: se suscribe a la ventana y vuelve EN CUANTO cambia algo, con el estado nuevo. Usalo en vez de wait siempre que esperes una reaccion: wait duerme un tiempo fijo (corto si la app tarda, desperdiciado si no). " + "screenshot: captura una ventana concreta (aunque este tapada) y la describe con otro modelo; lento y caro, ultimo recurso para lo que el arbol no ve. click/type/key: entrada real, solo como ultimo recurso.",
120
124
  },
121
125
  window: {
122
126
  type: "string",
@@ -5,15 +5,72 @@ import { apuntarUso } from "../agent/usageMeter.js";
5
5
  function client(apiKey) {
6
6
  return createEngineClient(apiKey, "vision");
7
7
  }
8
- function toDataUrl(imagePath) {
8
+ /**
9
+ * Cuanto puede pesar una imagen antes de que la peticion se muera por el camino.
10
+ *
11
+ * EL FALLO QUE CIERRA ESTO
12
+ *
13
+ * `toDataUrl` leia el archivo entero y lo metia en base64 sin mirar el tamano.
14
+ * Una captura de pantalla en PNG de un monitor normal son 1,3 MB, que en base64
15
+ * se convierten en 1,75 MB dentro del JSON de la peticion. Eso no llega: el
16
+ * Worker del proxy se queda sin margen a mitad de camino, la peticion muere sin
17
+ * respuesta, y ENCIMA se lleva por delante uno de los cuatro huecos de la
18
+ * licencia.
19
+ *
20
+ * Desde fuera se veia asi, y el usuario lo sufrio una tarde entera:
21
+ *
22
+ * chrome(screenshot) → chrome_tab.png (1314.8 KB)
23
+ * 💭 penso 4s
24
+ * ⚠ El motor no devolvio nada.
25
+ * ...
26
+ * ⏸ Tienes 4 peticiones en curso. Se libera en 169s.
27
+ *
28
+ * Cuatro capturas grandes y la licencia queda bloqueada tres minutos, sin que
29
+ * nada explique por que. Ahora se para ANTES de mandarla y se dice como
30
+ * arreglarlo, que cuesta lo mismo y no gasta un hueco.
31
+ *
32
+ * El limite no es caprichoso: 900 KB de base64 son ~675 KB de imagen, muy por
33
+ * encima de lo que ocupa una captura en JPEG (unos 120 KB) y muy por debajo de
34
+ * donde empiezan los problemas.
35
+ */
36
+ const MAX_BASE64 = 900_000;
37
+ /** El tipo real de la imagen, mirando sus primeros bytes y no su extension. */
38
+ function tipoDeImagen(buf) {
39
+ if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
40
+ return "image/jpeg";
41
+ if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50)
42
+ return "image/png";
43
+ if (buf.length > 12 && buf.toString("ascii", 8, 12) === "WEBP")
44
+ return "image/webp";
45
+ // Se deja pasar como PNG: equivocarse aqui es un error del servidor, no un
46
+ // cuelgue silencioso.
47
+ return "image/png";
48
+ }
49
+ function prepararImagen(imagePath) {
9
50
  const buf = fs.readFileSync(imagePath);
10
- return `data:image/png;base64,${buf.toString("base64")}`;
51
+ const b64 = buf.toString("base64");
52
+ if (b64.length > MAX_BASE64) {
53
+ const kb = Math.round(buf.length / 1024);
54
+ return {
55
+ ok: false,
56
+ error: `La imagen pesa ${kb} KB y no cabe en una peticion (el tope son ${Math.round(MAX_BASE64 / 1024)} KB de datos). ` +
57
+ `Mandarla igualmente no falla con un error: la peticion se muere por el camino, no vuelve nada, y se pierde ` +
58
+ `uno de los cuatro huecos de peticion durante minutos.\n\n` +
59
+ `Vuelve a capturar en JPEG, o recorta la zona que te interesa en vez de la pantalla entera.`,
60
+ };
61
+ }
62
+ return { ok: true, url: `data:${tipoDeImagen(buf)};base64,${b64}` };
11
63
  }
12
64
  async function ask(apiKey, prompt, imagePath, maxTokens) {
13
65
  if (!apiKey)
14
66
  return { ok: false, error: "No hay API key disponible para el modelo de vision." };
15
67
  if (!fs.existsSync(imagePath))
16
68
  return { ok: false, error: `No existe la imagen "${imagePath}".` };
69
+ // Se comprueba ANTES de gastar un hueco de peticion: una imagen demasiado
70
+ // grande no da error, mata la peticion en silencio. Ver prepararImagen().
71
+ const imagen = prepararImagen(imagePath);
72
+ if (!imagen.ok)
73
+ return { ok: false, error: imagen.error };
17
74
  try {
18
75
  const res = await client(apiKey).chat.completions.create({
19
76
  model: VISION_MODEL,
@@ -24,7 +81,7 @@ async function ask(apiKey, prompt, imagePath, maxTokens) {
24
81
  role: "user",
25
82
  content: [
26
83
  { type: "text", text: prompt },
27
- { type: "image_url", image_url: { url: toDataUrl(imagePath) } },
84
+ { type: "image_url", image_url: { url: imagen.url } },
28
85
  ],
29
86
  },
30
87
  ],
@@ -518,6 +518,155 @@ public static class Choco
518
518
  return null;
519
519
  }
520
520
 
521
+ // Lo que el elemento dice SER AHORA MISMO, con el mismo formato que se
522
+ // guardo al hacer la foto. Si un elemento murio, aqui salta y se trata como
523
+ // cambio, que es lo correcto.
524
+ static string EtiquetaViva(AutomationElement e)
525
+ {
526
+ try { return RoleOf(e) + " " + (e.Current.Name == null ? "" : e.Current.Name); }
527
+ catch { return "(elemento muerto)"; }
528
+ }
529
+
530
+ // Un ref solo vale si sigue siendo lo que era.
531
+ //
532
+ // POR QUE
533
+ //
534
+ // ui_snapshot guarda los elementos en _snap y su etiqueta en _snapLabel, y
535
+ // hasta ahora _snapLabel solo servia para INFORMAR: nunca se comparaba. Pero
536
+ // entre mirar y actuar pasa tiempo, y las interfaces cambian solas -una
537
+ // lista que se refresca, un menu que se cierra, un dialogo que aparece-. El
538
+ // elemento guardado en el ref 3 sigue ahi, pero ya no es el boton que el
539
+ // modelo vio: es otro. Y se pulsa ese otro, sin que nada avise.
540
+ //
541
+ // Es la misma familia del fallo que ya mordio dos veces en el navegador
542
+ // (hallazgos #19 y #20): actuar sobre lo que habia antes creyendo que es lo
543
+ // de ahora. Alli se arreglo; aqui seguia abierto.
544
+ //
545
+ // Comparar la etiqueta cuesta una lectura y convierte un clic en el sitio
546
+ // equivocado -que el usuario descubre cuando ya se ha borrado algo- en un
547
+ // mensaje que el modelo puede resolver volviendo a mirar.
548
+ static string RefCambiado(int id)
549
+ {
550
+ AutomationElement e = Ref(id);
551
+ if (e == null) return "ref inexistente; repite ui_snapshot";
552
+
553
+ string antes;
554
+ if (!_snapLabel.TryGetValue(id, out antes)) return null;
555
+
556
+ string ahora = EtiquetaViva(e);
557
+ if (ahora == antes) return null;
558
+
559
+ if (ahora == "(elemento muerto)")
560
+ {
561
+ return "el elemento del ref " + N(id) + " (" + antes + ") ya no existe: la ventana cambio. Repite ui_snapshot.";
562
+ }
563
+ return "el ref " + N(id) + " ya no es lo que viste: era \"" + antes + "\" y ahora es \"" + ahora +
564
+ "\". Actuar sobre el seria pulsar otra cosa. Repite ui_snapshot y usa el numero nuevo.";
565
+ }
566
+
567
+ // ------------------------------------------------------------- esperar cambios
568
+ //
569
+ // Esperar a que la ventana haga algo, en vez de preguntarle cada tanto.
570
+ //
571
+ // POR QUE
572
+ //
573
+ // Hasta ahora, para saber si una accion habia surtido efecto se leia el arbol
574
+ // varias veces seguidas hasta que dos salieran iguales. Funciona, pero tiene
575
+ // dos costes: se lee el arbol entero N veces, y la respuesta llega en el
576
+ // siguiente sondeo, no cuando pasa la cosa. Con una ventana que tarda 40 ms en
577
+ // reaccionar y un sondeo cada 120, se pierden 80 ms de nada; con una que tarda
578
+ // 2 s, se leyo el arbol veinte veces para nada.
579
+ //
580
+ // UI Automation puede avisar. Aqui se suscribe a los cambios de la ventana, se
581
+ // espera bloqueado, y se vuelve en cuanto ocurre el primero.
582
+ //
583
+ // LO QUE HAY QUE HACER BIEN O NO HACERLO
584
+ //
585
+ // Los manejadores de UIA viven dentro del proceso de la aplicacion observada.
586
+ // Si se registran y no se quitan, esa aplicacion se queda mas lenta PARA
587
+ // SIEMPRE, hasta que el usuario la cierre. Por eso todo va dentro de un
588
+ // try/finally y se quitan pase lo que pase, incluido si salta una excepcion o
589
+ // se agota el tiempo.
590
+ //
591
+ // El evento llega en un hilo interno de UIA, no en este: por eso lo que se
592
+ // comparte es un ManualResetEventSlim y una cadena, y nada mas.
593
+ static string _cambioQue = "";
594
+ static readonly object _cambioLock = new object();
595
+
596
+ static void ApuntarCambio(string texto)
597
+ {
598
+ lock (_cambioLock) { if (_cambioQue.Length == 0) _cambioQue = texto; }
599
+ }
600
+
601
+ public static string WaitChange(IntPtr hwnd, int timeoutMs)
602
+ {
603
+ if (hwnd == IntPtr.Zero) return "{\"ok\":false,\"error\":\"ventana no encontrada\"}";
604
+ if (timeoutMs < 100) timeoutMs = 100;
605
+ if (timeoutMs > 30000) timeoutMs = 30000;
606
+
607
+ AutomationElement raiz;
608
+ try { raiz = AutomationElement.FromHandle(hwnd); }
609
+ catch (Exception ex) { return "{\"ok\":false,\"error\":" + Q(ex.Message) + "}"; }
610
+ if (raiz == null) return "{\"ok\":false,\"error\":\"la ventana no expone arbol de accesibilidad\"}";
611
+
612
+ lock (_cambioLock) { _cambioQue = ""; }
613
+ ManualResetEventSlim listo = new ManualResetEventSlim(false);
614
+
615
+ AutomationPropertyChangedEventHandler onProp = delegate(object src, AutomationPropertyChangedEventArgs e)
616
+ {
617
+ string prop = e.Property == ValuePattern.ValueProperty ? "valor"
618
+ : e.Property == AutomationElement.NameProperty ? "nombre"
619
+ : e.Property == AutomationElement.IsEnabledProperty ? "habilitado"
620
+ : "propiedad";
621
+ string quien = "";
622
+ try { AutomationElement el = src as AutomationElement; if (el != null) quien = RoleOf(el) + " " + el.Current.Name; }
623
+ catch { quien = "un control"; }
624
+ ApuntarCambio("cambio el " + prop + " de " + quien);
625
+ listo.Set();
626
+ };
627
+
628
+ StructureChangedEventHandler onStruct = delegate(object src, StructureChangedEventArgs e)
629
+ {
630
+ ApuntarCambio("la ventana anadio o quito controles");
631
+ listo.Set();
632
+ };
633
+
634
+ bool propOk = false, structOk = false;
635
+ try
636
+ {
637
+ AutomationProperty[] props = new AutomationProperty[] {
638
+ ValuePattern.ValueProperty,
639
+ AutomationElement.NameProperty,
640
+ AutomationElement.IsEnabledProperty
641
+ };
642
+ try { Automation.AddAutomationPropertyChangedEventHandler(raiz, TreeScope.Subtree, onProp, props); propOk = true; } catch { }
643
+ try { Automation.AddStructureChangedEventHandler(raiz, TreeScope.Subtree, onStruct); structOk = true; } catch { }
644
+
645
+ if (!propOk && !structOk)
646
+ {
647
+ return "{\"ok\":false,\"error\":\"esta ventana no admite avisos de cambio; usa ui_snapshot para mirar\"}";
648
+ }
649
+
650
+ bool paso = listo.Wait(timeoutMs);
651
+ string que;
652
+ lock (_cambioLock) { que = _cambioQue; }
653
+
654
+ if (!paso)
655
+ {
656
+ return "{\"ok\":true,\"changed\":false,\"waited\":" + N(timeoutMs) + "}";
657
+ }
658
+ return "{\"ok\":true,\"changed\":true,\"what\":" + Q(que.Length > 0 ? que : "algo cambio") + "}";
659
+ }
660
+ finally
661
+ {
662
+ // Sin esto, la aplicacion observada se queda mas lenta hasta que el
663
+ // usuario la cierre. Se quitan siempre, aunque haya saltado todo.
664
+ if (propOk) { try { Automation.RemoveAutomationPropertyChangedEventHandler(raiz, onProp); } catch { } }
665
+ if (structOk) { try { Automation.RemoveStructureChangedEventHandler(raiz, onStruct); } catch { } }
666
+ listo.Dispose();
667
+ }
668
+ }
669
+
521
670
  public static string RefRect(int id)
522
671
  {
523
672
  AutomationElement e = Ref(id);
@@ -537,6 +686,8 @@ public static class Choco
537
686
  {
538
687
  AutomationElement e = Ref(id);
539
688
  if (e == null) return "{\"ok\":false,\"error\":\"ref inexistente; repite ui_snapshot\"}";
689
+ string cambio = RefCambiado(id);
690
+ if (cambio != null) return "{\"ok\":false,\"error\":" + Q(cambio) + "}";
540
691
  string label = _snapLabel.ContainsKey(id) ? _snapLabel[id] : ("ref " + N(id));
541
692
  try
542
693
  {
@@ -555,6 +706,8 @@ public static class Choco
555
706
  {
556
707
  AutomationElement e = Ref(id);
557
708
  if (e == null) return "{\"ok\":false,\"error\":\"ref inexistente; repite ui_snapshot\"}";
709
+ string cambio = RefCambiado(id);
710
+ if (cambio != null) return "{\"ok\":false,\"error\":" + Q(cambio) + "}";
558
711
  try
559
712
  {
560
713
  // ValuePattern.SetValue SUSTITUYE todo el contenido del control. En un
@@ -902,6 +1055,7 @@ while ($true) {
902
1055
  'ui_snapshot' {
903
1056
  $res = [Choco]::Snapshot((Resolve-Hwnd $cmd), [int](Get-Prop $cmd 'max' 250), [string](Get-Prop $cmd 'filter' ''), [bool](Get-Prop $cmd 'interactiveOnly' $true))
904
1057
  }
1058
+ 'wait_change' { $res = [Choco]::WaitChange((Resolve-Hwnd $cmd), [int](Get-Prop $cmd 'timeoutMs' 3000)) }
905
1059
  'ui_click' { $res = [Choco]::RefInvoke([int](Get-Prop $cmd 'ref' 0)) }
906
1060
  'ui_set_value' { $res = [Choco]::RefSetValue([int](Get-Prop $cmd 'ref' 0), [string](Get-Prop $cmd 'text' ''), [bool](Get-Prop $cmd 'replace' $false)) }
907
1061
  'ui_focus' { $res = [Choco]::RefFocus([int](Get-Prop $cmd 'ref' 0)) }
@@ -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;