chocolatito-code 1.0.0 → 1.2.0

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 (122) hide show
  1. package/README.md +155 -0
  2. package/dist/agent/compaction.d.ts +65 -0
  3. package/dist/agent/compaction.js +134 -0
  4. package/dist/agent/context.d.ts +3 -0
  5. package/dist/agent/context.js +35 -0
  6. package/dist/agent/loop.d.ts +91 -2
  7. package/dist/agent/loop.js +642 -91
  8. package/dist/agent/loopDetector.d.ts +57 -0
  9. package/dist/agent/loopDetector.js +0 -0
  10. package/dist/agent/mentions.d.ts +13 -0
  11. package/dist/agent/mentions.js +49 -0
  12. package/dist/agent/repoMap.d.ts +117 -0
  13. package/dist/agent/repoMap.js +560 -0
  14. package/dist/agent/result.d.ts +64 -0
  15. package/dist/agent/result.js +48 -0
  16. package/dist/agent/retry.d.ts +35 -0
  17. package/dist/agent/retry.js +123 -4
  18. package/dist/agent/salidaDelAgente.d.ts +43 -0
  19. package/dist/agent/salidaDelAgente.js +53 -0
  20. package/dist/agent/subagent.js +62 -15
  21. package/dist/agent/syntaxValidator.js +288 -52
  22. package/dist/agent/toolGate.d.ts +58 -0
  23. package/dist/agent/toolGate.js +111 -0
  24. package/dist/agent/tracker.js +4 -7
  25. package/dist/agent/undoManager.d.ts +44 -11
  26. package/dist/agent/undoManager.js +95 -24
  27. package/dist/agent/usageMeter.d.ts +56 -0
  28. package/dist/agent/usageMeter.js +51 -0
  29. package/dist/agent/verifier.d.ts +49 -0
  30. package/dist/agent/verifier.js +158 -0
  31. package/dist/config/constants.js +14 -0
  32. package/dist/config/engine.d.ts +19 -0
  33. package/dist/config/engine.js +35 -3
  34. package/dist/config/license.d.ts +5 -0
  35. package/dist/config/license.js +107 -9
  36. package/dist/config/limits.d.ts +28 -0
  37. package/dist/config/limits.js +51 -0
  38. package/dist/config/nodeVersion.d.ts +13 -0
  39. package/dist/config/nodeVersion.js +26 -0
  40. package/dist/config/permissions.d.ts +76 -1
  41. package/dist/config/permissions.js +379 -23
  42. package/dist/config/quota.js +24 -1
  43. package/dist/config/updater.d.ts +68 -0
  44. package/dist/config/updater.js +215 -0
  45. package/dist/hooks/manager.js +159 -14
  46. package/dist/index.js +240 -37
  47. package/dist/mcp/client.d.ts +50 -0
  48. package/dist/mcp/client.js +155 -19
  49. package/dist/mcp/ide.d.ts +76 -0
  50. package/dist/mcp/ide.js +160 -0
  51. package/dist/mcp/manager.js +9 -0
  52. package/dist/prompts/systemPrompt.js +12 -1
  53. package/dist/sessions/manager.d.ts +12 -0
  54. package/dist/sessions/manager.js +33 -5
  55. package/dist/sessions/resume.d.ts +36 -0
  56. package/dist/sessions/resume.js +43 -0
  57. package/dist/skills/manager.d.ts +1 -0
  58. package/dist/skills/manager.js +9 -0
  59. package/dist/tools/binary.d.ts +46 -0
  60. package/dist/tools/binary.js +100 -0
  61. package/dist/tools/browserCdp.js +13 -1
  62. package/dist/tools/browserExtension.d.ts +5 -0
  63. package/dist/tools/browserExtension.js +237 -22
  64. package/dist/tools/browserSession.d.ts +23 -0
  65. package/dist/tools/browserSession.js +28 -0
  66. package/dist/tools/computerUse.js +33 -1
  67. package/dist/tools/createImage.d.ts +11 -0
  68. package/dist/tools/createImage.js +13 -1
  69. package/dist/tools/definitions.js +17 -4
  70. package/dist/tools/editDiagnosis.d.ts +54 -0
  71. package/dist/tools/editDiagnosis.js +142 -0
  72. package/dist/tools/editFile.d.ts +1 -0
  73. package/dist/tools/editFile.js +118 -62
  74. package/dist/tools/findFiles.d.ts +21 -0
  75. package/dist/tools/findFiles.js +75 -6
  76. package/dist/tools/getSystemInfo.js +15 -7
  77. package/dist/tools/gitTools.d.ts +24 -0
  78. package/dist/tools/gitTools.js +87 -26
  79. package/dist/tools/grepSearch.js +111 -23
  80. package/dist/tools/moveCopyFile.d.ts +18 -1
  81. package/dist/tools/moveCopyFile.js +74 -4
  82. package/dist/tools/patchCascade.d.ts +57 -0
  83. package/dist/tools/patchCascade.js +338 -0
  84. package/dist/tools/runCommand.js +213 -25
  85. package/dist/tools/runner.js +58 -5
  86. package/dist/tools/toolDefsComputer.js +12 -2
  87. package/dist/tools/truncator.d.ts +27 -1
  88. package/dist/tools/truncator.js +117 -11
  89. package/dist/tools/viewFile.js +15 -11
  90. package/dist/tools/visionBridge.js +7 -0
  91. package/dist/tools/webSearch.d.ts +21 -0
  92. package/dist/tools/webSearch.js +125 -14
  93. package/dist/tools/writeFile.d.ts +16 -1
  94. package/dist/tools/writeFile.js +40 -2
  95. package/dist/ui/interrupt.d.ts +4 -1
  96. package/dist/ui/interrupt.js +59 -7
  97. package/dist/ui/keyboardGuard.d.ts +76 -0
  98. package/dist/ui/keyboardGuard.js +94 -0
  99. package/dist/ui/loopPrompt.d.ts +17 -0
  100. package/dist/ui/loopPrompt.js +59 -0
  101. package/dist/ui/pegados.d.ts +50 -0
  102. package/dist/ui/pegados.js +83 -0
  103. package/dist/ui/permissionPrompt.js +84 -2
  104. package/dist/ui/prompt.d.ts +40 -0
  105. package/dist/ui/prompt.js +436 -131
  106. package/dist/ui/reasoningStream.d.ts +8 -0
  107. package/dist/ui/reasoningStream.js +99 -7
  108. package/dist/ui/salida.d.ts +10 -0
  109. package/dist/ui/salida.js +50 -0
  110. package/extension/README.md +95 -74
  111. package/extension/background.js +772 -181
  112. package/extension/content.js +527 -46
  113. package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
  114. package/extension/iconos/128.png +0 -0
  115. package/extension/iconos/16.png +0 -0
  116. package/extension/iconos/32.png +0 -0
  117. package/extension/iconos/48.png +0 -0
  118. package/extension/iconos/logotipo.png +0 -0
  119. package/extension/manifest.json +28 -4
  120. package/extension/popup.html +16 -6
  121. package/extension/popup.js +28 -12
  122. package/package.json +3 -2
package/dist/ui/prompt.js CHANGED
@@ -5,8 +5,43 @@ import path from "node:path";
5
5
  import os from "node:os";
6
6
  import chalk from "chalk";
7
7
  import { cycleMode, describeMode, getMode } from "./modes.js";
8
- import { releaseKeyboard } from "./interrupt.js";
8
+ import { releaseKeyboard, tomarTecladoParaPrompt, devolverTecladoDelPrompt } from "./interrupt.js";
9
+ import { repararTeclado, reafirmarModoCrudo, MS_ENTRE_COMPROBACIONES } from "./keyboardGuard.js";
9
10
  import { clearTyped } from "./typeAhead.js";
11
+ import { dibujar } from "./salida.js";
12
+ import { esGrande, guardarPegado, expandirPegados, marcaQueTerminaEn, limpiarPegados } from "./pegados.js";
13
+ /**
14
+ * Escribir algo mientras el prompt esta dibujado.
15
+ *
16
+ * Sale de una sesion real del usuario. Los servidores MCP se conectan en
17
+ * segundo plano para no retrasar el arranque, y cuando terminan sueltan su
18
+ * resumen con `console.log`. Si para entonces el prompt ya estaba en pantalla,
19
+ * el resumen se escribe ENCIMA del marco y lo destroza:
20
+ *
21
+ * ❯ Escribe una instruccion...
22
+ * 🔌 Servidores MCP:────────────────────────────────────
23
+ * ✔ ide (http): 4 herramientasb modo · esc cancelar ⇥ modo normal
24
+ *
25
+ * Ese "4 herramientasb modo" es el resumen pisando la linea de ayuda. Le pasa a
26
+ * cualquier cosa que se imprima tarde: el aviso de actualizacion, un servidor
27
+ * MCP lento, un hook que tarda.
28
+ *
29
+ * La regla es sencilla: si hay un prompt abierto, primero se borra su marco, se
30
+ * escribe el mensaje donde estaba, y se vuelve a dibujar debajo. Si no lo hay,
31
+ * es un console.log normal y ya.
32
+ */
33
+ let escribirEncimaDelPrompt = null;
34
+ /**
35
+ * Imprime sin romper el prompt. Uselo en vez de `console.log` para todo lo que
36
+ * pueda llegar TARDE: conexiones en segundo plano, avisos, temporizadores.
37
+ */
38
+ export function imprimirSobreElPrompt(texto) {
39
+ if (escribirEncimaDelPrompt) {
40
+ escribirEncimaDelPrompt(texto);
41
+ return;
42
+ }
43
+ dibujar(texto.endsWith("\n") ? texto : `${texto}\n`);
44
+ }
10
45
  export const SLASH_COMMANDS = [
11
46
  { name: "/mcp", description: "Ver servidores MCP conectados y sus herramientas" },
12
47
  { name: "/undo", description: "Deshacer las últimas modificaciones de archivos de la sesión" },
@@ -28,6 +63,33 @@ export const SLASH_COMMANDS = [
28
63
  { name: "/exit", description: "Cerrar sesión de Chocolatito Code" },
29
64
  ];
30
65
  let isKeypressInitialized = false;
66
+ /**
67
+ * PEGADO DE VARIAS LINEAS
68
+ *
69
+ * Un pegado no llega tecla a tecla: llega como UN chunk de stdin que readline
70
+ * desmonta en pulsaciones y emite todas en el mismo tick sincrono. El primer
71
+ * "\r" del texto se emitia como key.name === "return", el manejador de Enter
72
+ * hacia cleanup() + resolve() y quitaba el oyente MIENTRAS el resto del chunk
73
+ * se seguia emitiendo: las lineas 2..n salian sin nadie escuchando y se perdian
74
+ * sin dejar rastro ni en el buffer ni en la cola. El usuario creia haber mandado
75
+ * la instruccion entera y el agente arrancaba con la primera linea.
76
+ *
77
+ * Dos defensas, porque ninguna cubre sola todos los terminales:
78
+ *
79
+ * 1. Modo de pegado entre corchetes. El terminal envuelve lo pegado en
80
+ * \x1b[200~ ... \x1b[201~, asi que se distingue de lo tecleado aunque el
81
+ * pegado venga partido en varios chunks (los grandes lo vienen). Dentro de
82
+ * las marcas, un salto de linea es texto y nunca envia.
83
+ * 2. El Enter no envia en el acto: se apunta y se decide en el setImmediate
84
+ * siguiente, cuando el chunk entero ya paso. Si detras venia mas texto, ese
85
+ * Enter era un salto del pegado. Esto salva a los terminales que no soportan
86
+ * el punto 1 (conhost antiguo, por ejemplo).
87
+ */
88
+ const PASTE_START = "\x1b[200~";
89
+ const PASTE_END = "\x1b[201~";
90
+ /** Activar/desactivar el modo en el terminal. Se apaga al salir: si se queda puesto, la shell de despues recibe las marcas como texto. */
91
+ const PASTE_START_REQUEST = "\x1b[?2004h";
92
+ const PASTE_STOP_REQUEST = "\x1b[?2004l";
31
93
  function stripAnsi(str) {
32
94
  return str.replace(/\x1b\[[0-9;]*m/g, "");
33
95
  }
@@ -86,14 +148,157 @@ function scanProjectFiles(dir, maxResults = 40) {
86
148
  walk(dir);
87
149
  return results;
88
150
  }
151
+ const PLACEHOLDER = "Escribe una instrucción, '@' para archivos o '/' para comandos...";
152
+ /**
153
+ * Filas FISICAS que ocupa una linea del marco.
154
+ *
155
+ * Aqui estaba el fallo de verdad: el repintado contaba elementos del array del
156
+ * marco mientras el terminal cuenta filas de pantalla. Cualquier linea que no
157
+ * cupiera en el ancho se partia en dos filas, el cursor subia una fila de menos
158
+ * y el marco entero se iba bajando, dejando una barra huerfana por pulsacion.
159
+ * La entrada ademas puede llevar saltos de linea dentro (continuacion con "\").
160
+ */
161
+ function physicalRows(line, cols) {
162
+ return stripAnsi(line)
163
+ .split("\n")
164
+ .reduce((n, seg) => n + Math.max(1, Math.ceil(seg.length / cols)), 0);
165
+ }
166
+ /** Recorta a `width` columnas visibles. Texto sin pintar: aqui no hay que cerrar colores. */
167
+ function truncatePlain(text, width) {
168
+ if (width <= 0)
169
+ return "";
170
+ if (text.length <= width)
171
+ return text;
172
+ return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
173
+ }
174
+ /**
175
+ * Arma el marco del prompt y dice exactamente donde queda el cursor.
176
+ *
177
+ * Va aparte de render() por dos razones: se puede probar sin terminal, y la
178
+ * aritmetica de filas se hace UNA vez sobre las mismas lineas que se pintan, en
179
+ * lugar de estimarse por otro lado y descuadrarse.
180
+ */
181
+ export function buildPromptFrame(opts) {
182
+ const cols = Math.max(20, opts.cols || 80);
183
+ const viewportRows = Math.max(6, opts.rows || 24);
184
+ const promptSymbol = opts.promptSymbol ?? "❯";
185
+ const input = opts.input ?? "";
186
+ const cursorIndex = Math.max(0, Math.min(opts.cursorIndex ?? input.length, input.length));
187
+ const items = opts.items ?? [];
188
+ const selectedIndex = items.length > 0 ? (((opts.selectedIndex ?? 0) % items.length) + items.length) % items.length : 0;
189
+ const isPlan = promptSymbol.includes("plan");
190
+ const symbolStr = isPlan
191
+ ? `${chalk.cyan.bold("plan ❯")} `
192
+ : `${chalk.bold.hex("#D97757")("❯")} `;
193
+ const symbolWidth = isPlan ? 7 : 2;
194
+ const bar = chalk.gray("─".repeat(cols));
195
+ // 1. Linea de entrada. Lo que el usuario escribe NO se recorta nunca: se deja
196
+ // que el terminal la parta y despues se cuentan las filas que ocupa.
197
+ let formattedInput;
198
+ if (input.length === 0) {
199
+ // La pista si se recorta: en una ventana estrecha ocupaba una segunda fila
200
+ // para no decir nada que el usuario no sepa ya.
201
+ formattedInput = chalk.gray(truncatePlain(PLACEHOLDER, cols - symbolWidth));
202
+ }
203
+ else if (input.startsWith("/")) {
204
+ const parts = input.split(" ");
205
+ const cmdPart = chalk.bold.hex("#A855F7")(parts[0]);
206
+ const restPart = parts.slice(1).join(" ");
207
+ formattedInput = restPart ? `${cmdPart} ${chalk.white(restPart)}` : cmdPart;
208
+ }
209
+ else {
210
+ formattedInput = input.replace(/(@[^\s]+)/g, (m) => chalk.bold.hex("#D97757")(m));
211
+ }
212
+ const inputLine = symbolStr + formattedInput;
213
+ const inputRows = physicalRows(inputLine, cols);
214
+ // 2. Desplegable. Un marco mas alto que la ventana obliga al terminal a
215
+ // desplazar el contenido, y a partir de ahi el repintado ya no sabe donde
216
+ // esta su primera fila: se muestra solo una ventana alrededor del elegido.
217
+ const room = Math.max(1, viewportRows - 1 - (1 + inputRows + 1 + 1));
218
+ const shown = Math.min(items.length, room);
219
+ const dropdownOffset = shown > 0 && items.length > shown
220
+ ? Math.min(Math.max(0, selectedIndex - Math.floor(shown / 2)), items.length - shown)
221
+ : 0;
222
+ const dropdownLines = [];
223
+ for (let i = dropdownOffset; i < dropdownOffset + shown; i++) {
224
+ const item = items[i];
225
+ const isSelected = i === selectedIndex;
226
+ const colorHex = item.type === "file" ? "#D97757" : "#A855F7";
227
+ const pointer = isSelected ? chalk.bold.hex(colorHex)(" ❯ ") : chalk.gray(" ");
228
+ // El puntero se lleva 3 columnas fijas. De lo que queda manda la etiqueta:
229
+ // la descripcion es lo primero que se cae, porque una ruta a medias no se
230
+ // puede ni leer ni elegir.
231
+ const free = cols - 3;
232
+ const label = truncatePlain(item.label, free);
233
+ const roomForDesc = free - label.length - 2;
234
+ const secondary = item.secondary && roomForDesc >= 8 ? truncatePlain(item.secondary, roomForDesc) : "";
235
+ const paintedLabel = isSelected ? chalk.bold.white(label) : chalk.hex(colorHex)(label);
236
+ const paintedDesc = secondary
237
+ ? isSelected
238
+ ? chalk.whiteBright(` ${secondary}`)
239
+ : chalk.gray(` ${secondary}`)
240
+ : "";
241
+ dropdownLines.push(`${pointer}${paintedLabel}${paintedDesc}`);
242
+ }
243
+ // 3. Pie de estado. El relleno se calculaba con Math.max(1, ...): cuando no
244
+ // cabia, en vez de recortarse se quedaba con un espacio y la linea salia MAS
245
+ // ancha que el terminal. En una consola de 80 columnas el pie media 87 y se
246
+ // partia en dos filas desde el primer dibujado.
247
+ const modeLook = describeMode(getMode());
248
+ const hintPlain = "? /help para atajos · shift+tab modo · esc cancelar";
249
+ const modePlain = `⇥ modo ${modeLook.label}`;
250
+ const enginePlain = "· Espectro 1.0 (Ágil)";
251
+ const hint = chalk.gray(hintPlain);
252
+ const modeTag = chalk.hex(modeLook.color)(modePlain);
253
+ const engine = chalk.gray(enginePlain);
254
+ let footerLine;
255
+ if (hintPlain.length + 1 + modePlain.length + 1 + enginePlain.length <= cols) {
256
+ const padLen = cols - hintPlain.length - modePlain.length - 1 - enginePlain.length;
257
+ footerLine = `${hint}${" ".repeat(padLen)}${modeTag} ${engine}`;
258
+ }
259
+ else if (modePlain.length + 1 + enginePlain.length <= cols) {
260
+ // Los atajos son opcionales; el modo activo no, que es lo que decide si te
261
+ // van a preguntar antes de tocar tus archivos.
262
+ footerLine = `${" ".repeat(cols - modePlain.length - 1 - enginePlain.length)}${modeTag} ${engine}`;
263
+ }
264
+ else if (modePlain.length <= cols) {
265
+ footerLine = `${" ".repeat(cols - modePlain.length)}${modeTag}`;
266
+ }
267
+ else {
268
+ footerLine = chalk.hex(modeLook.color)(truncatePlain(modePlain, cols));
269
+ }
270
+ const lines = [bar, inputLine, ...dropdownLines, bar, footerLine];
271
+ // 4. Donde queda el cursor, en filas fisicas. Antes se hacia cursorCol =
272
+ // total % cols: aplicaba el modulo pero nunca sumaba la fila, asi que en
273
+ // cuanto la entrada se partia el acento se iba a escribir a otra linea.
274
+ const typed = input.length === 0 ? "" : input.slice(0, cursorIndex);
275
+ const segments = typed.split("\n");
276
+ let cursorRow = physicalRows(bar, cols);
277
+ for (let i = 0; i < segments.length - 1; i++) {
278
+ cursorRow += Math.max(1, Math.ceil(((i === 0 ? symbolWidth : 0) + segments[i].length) / cols));
279
+ }
280
+ const lastSegWidth = (segments.length === 1 ? symbolWidth : 0) + segments[segments.length - 1].length;
281
+ cursorRow += Math.floor(lastSegWidth / cols);
282
+ const cursorCol = lastSegWidth % cols;
283
+ const totalRows = lines.reduce((n, l) => n + physicalRows(l, cols), 0);
284
+ return { lines, totalRows, cursorRow, cursorCol, dropdownOffset };
285
+ }
89
286
  export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.cwd()) {
90
287
  return new Promise((resolve) => {
91
288
  let inputBuffer = "";
92
289
  let cursorIndex = 0;
93
290
  let selectedIndex = 0;
94
291
  let isFinished = false;
95
- let lastRenderedLines = [];
96
- let lastCursorLineIndex = 1;
292
+ // Estado del pegado. Ver la nota PEGADO DE VARIAS LINEAS de arriba.
293
+ let isPasting = false;
294
+ /** Lo que va llegando del pegado, antes de decidir si cabe o va como marca. */
295
+ let loPegado = "";
296
+ let pendingSubmit = null;
297
+ let lastWasCR = false;
298
+ // Filas FISICAS, no elementos del array: es la unica cuenta con la que el
299
+ // terminal esta de acuerdo.
300
+ let hasRendered = false;
301
+ let lastCursorRow = 0;
97
302
  const history = loadHistory();
98
303
  let historyIdx = history.length;
99
304
  // Cache project files for @ autocompletion
@@ -115,10 +320,43 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
115
320
  // Lo que quedara a medio escribir del turno anterior no es de este prompt.
116
321
  clearTyped();
117
322
  const wasRaw = process.stdin.isRaw;
118
- if (process.stdin.setRawMode) {
119
- process.stdin.setRawMode(true);
120
- }
323
+ // Reafirmar y no solo poner: si algo cambio el modo de la consola por fuera
324
+ // de Node, un setRawMode(true) a secas es un no-op y el prompt se abre sin
325
+ // teclado sin enterarse. Ver reafirmarModoCrudo().
326
+ reafirmarModoCrudo(process.stdin);
121
327
  process.stdin.resume();
328
+ // A partir de aqui el teclado es de este prompt: un vigilante que se pare
329
+ // tarde ya no puede devolver el modo crudo a como estaba antes del turno,
330
+ // porque "antes del turno" ya no es ahora.
331
+ tomarTecladoParaPrompt();
332
+ // Y por si alguien se lo quita igualmente. Ver keyboardGuard.ts: son
333
+ // demasiados los sitios que apagan el modo crudo con razon, y basta que uno
334
+ // llegue tarde para que el usuario teclee y su texto salga en la linea de
335
+ // ayuda en vez de en el recuadro.
336
+ const vigilanteTeclado = setInterval(() => {
337
+ if (isFinished)
338
+ return;
339
+ const reparado = repararTeclado(process.stdin);
340
+ if (reparado.length === 0)
341
+ return;
342
+ if (process.env.CHOCOLATITO_TECLADO === "debug") {
343
+ process.stderr.write(`\n[teclado] recuperado: ${reparado.join(" y ")}\n`);
344
+ }
345
+ // El terminal ya ha hecho eco de lo que se tecleara mientras tanto, en un
346
+ // sitio que este dibujo no controla. Intentar borrar el marco anterior
347
+ // contando filas se comeria ese texto ajeno y dejaria el cursor perdido:
348
+ // se dibuja uno limpio debajo y se sigue.
349
+ hasRendered = false;
350
+ lastCursorRow = 0;
351
+ render();
352
+ }, MS_ENTRE_COMPROBACIONES);
353
+ // Un temporizador de vigilancia no es motivo para que el proceso siga vivo.
354
+ vigilanteTeclado.unref?.();
355
+ // Se pide el pegado entre corchetes solo si hay terminal delante; a un
356
+ // fichero o a una tuberia esto seria basura en la salida.
357
+ if (process.stdout.isTTY) {
358
+ dibujar(PASTE_START_REQUEST);
359
+ }
122
360
  function getAtMention() {
123
361
  const textBeforeCursor = inputBuffer.slice(0, cursorIndex);
124
362
  const match = textBeforeCursor.match(/@([^\s]*)$/);
@@ -163,115 +401,78 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
163
401
  if (isFinished)
164
402
  return;
165
403
  const cols = Math.max(20, process.stdout.columns || 80);
166
- // 1. Top separator line
167
- const topBar = chalk.gray("─".repeat(cols));
168
- // 2. Prompt symbol & input text
169
- const symbolStr = promptSymbol.includes("plan")
170
- ? `${chalk.cyan.bold("plan ❯")} `
171
- : `${chalk.bold.hex("#D97757")("❯")} `;
172
- const rawSymbolLen = promptSymbol.includes("plan") ? 7 : 2;
173
- const placeholderText = "Escribe una instrucción, '@' para archivos o '/' para comandos...";
174
- let formattedInput = "";
175
- if (inputBuffer.length === 0) {
176
- formattedInput = chalk.gray(placeholderText);
177
- }
178
- else if (inputBuffer.startsWith("/")) {
179
- const parts = inputBuffer.split(" ");
180
- const cmdPart = chalk.bold.hex("#A855F7")(parts[0]);
181
- const restPart = parts.slice(1).join(" ");
182
- formattedInput = restPart ? `${cmdPart} ${chalk.white(restPart)}` : cmdPart;
183
- }
184
- else {
185
- formattedInput = inputBuffer.replace(/(@[^\s]+)/g, (m) => chalk.bold.hex("#D97757")(m));
186
- }
187
- // 3. Dropdown items (if any)
188
404
  const items = getDropdownItems();
189
- const dropdownLines = [];
190
405
  if (items.length > 0) {
191
406
  if (selectedIndex >= items.length)
192
407
  selectedIndex = 0;
193
408
  if (selectedIndex < 0)
194
409
  selectedIndex = items.length - 1;
195
- items.forEach((item, idx) => {
196
- const isSelected = idx === selectedIndex;
197
- const isFile = item.type === "file";
198
- const colorHex = isFile ? "#D97757" : "#A855F7";
199
- const pointer = isSelected
200
- ? chalk.bold.hex(colorHex)(" ❯ ")
201
- : chalk.gray(" ");
202
- const label = isSelected
203
- ? chalk.bold.white(item.label)
204
- : chalk.hex(colorHex)(item.label);
205
- const desc = isSelected
206
- ? chalk.whiteBright(` ${item.secondary || ""}`)
207
- : chalk.gray(` ${item.secondary || ""}`);
208
- dropdownLines.push(`${pointer}${label}${desc}`);
209
- });
210
410
  }
211
- // 4. Bottom separator line
212
- const bottomBar = chalk.gray("─".repeat(cols));
213
- // 5. Footer status line
214
- const modeLook = describeMode(getMode());
215
- const leftFooter = chalk.gray("? /help para atajos · shift+tab modo · esc cancelar");
216
- const rightFooter = `${chalk.hex(modeLook.color)(`⇥ modo ${modeLook.label}`)} ${chalk.gray("· Espectro 1.0 (Ágil)")}`;
217
- const leftLen = stripAnsi(leftFooter).length;
218
- const rightLen = stripAnsi(rightFooter).length;
219
- const padLen = Math.max(1, cols - leftLen - rightLen);
220
- const footerLine = `${leftFooter}${" ".repeat(padLen)}${rightFooter}`;
221
- // Build current frame array
222
- const currentFrame = [topBar, symbolStr + formattedInput];
223
- if (dropdownLines.length > 0) {
224
- currentFrame.push(...dropdownLines);
411
+ const frame = buildPromptFrame({
412
+ cols,
413
+ rows: process.stdout.rows || 24,
414
+ promptSymbol,
415
+ input: inputBuffer,
416
+ cursorIndex,
417
+ items,
418
+ selectedIndex,
419
+ });
420
+ // Todo en una sola escritura: un marco pintado a trozos es un marco que
421
+ // parpadea.
422
+ const out = [];
423
+ if (hasRendered) {
424
+ // Volver a la primera fila del marco anterior y borrar de ahi hacia
425
+ // abajo. Antes se repintaba linea a linea con \x1b[2K contando lineas
426
+ // logicas, asi que lo que el terminal habia partido en dos filas se
427
+ // quedaba en pantalla para siempre.
428
+ if (lastCursorRow > 0)
429
+ out.push(`\x1b[${lastCursorRow}A`);
430
+ out.push("\r\x1b[0J");
225
431
  }
226
- currentFrame.push(bottomBar, footerLine);
227
- const inputLineIdx = 1;
228
- const totalCharsCursor = rawSymbolLen + (inputBuffer.length === 0 ? 0 : cursorIndex);
229
- const cursorCol = totalCharsCursor % cols;
230
- if (lastRenderedLines.length === 0) {
231
- // First render: write frame and position cursor on input line
232
- process.stdout.write(currentFrame.join("\n"));
233
- const moveUp = currentFrame.length - 1 - inputLineIdx;
234
- process.stdout.write(`\x1b[${moveUp}A\r\x1b[${cursorCol}C`);
235
- }
236
- else {
237
- // Subsequent render: re-draw lines in place with 0 scrolling drift
238
- if (lastCursorLineIndex > 0) {
239
- process.stdout.write(`\x1b[${lastCursorLineIndex}A\r`);
240
- }
241
- else {
242
- process.stdout.write("\r");
243
- }
244
- const maxLines = Math.max(currentFrame.length, lastRenderedLines.length);
245
- for (let i = 0; i < maxLines; i++) {
246
- const content = i < currentFrame.length ? currentFrame[i] : "";
247
- process.stdout.write(`\x1b[2K${content}`);
248
- if (i < maxLines - 1) {
249
- process.stdout.write("\x1b[1E");
250
- }
251
- }
252
- const moveUp = (maxLines - 1) - inputLineIdx;
253
- if (moveUp > 0) {
254
- process.stdout.write(`\x1b[${moveUp}A\r\x1b[${cursorCol}C`);
255
- }
256
- else {
257
- process.stdout.write(`\r\x1b[${cursorCol}C`);
258
- }
259
- }
260
- lastRenderedLines = currentFrame;
261
- lastCursorLineIndex = inputLineIdx;
432
+ out.push(frame.lines.join("\n"));
433
+ // El cursor se quedo en la ultima fila pintada; hay que subirlo a la de la
434
+ // entrada. Ojo con los ceros: \x1b[0A y \x1b[0C mueven UNA posicion.
435
+ const moveUp = frame.totalRows - 1 - frame.cursorRow;
436
+ if (moveUp > 0)
437
+ out.push(`\x1b[${moveUp}A`);
438
+ out.push("\r");
439
+ if (frame.cursorCol > 0)
440
+ out.push(`\x1b[${frame.cursorCol}C`);
441
+ dibujar(out.join(""));
442
+ hasRendered = true;
443
+ lastCursorRow = frame.cursorRow;
262
444
  }
263
445
  function cleanup() {
264
446
  isFinished = true;
447
+ limpiarPegados();
448
+ escribirEncimaDelPrompt = null;
449
+ clearInterval(vigilanteTeclado);
450
+ devolverTecladoDelPrompt();
451
+ if (pendingSubmit) {
452
+ clearImmediate(pendingSubmit);
453
+ pendingSubmit = null;
454
+ }
265
455
  process.stdin.removeListener("keypress", onKeypress);
266
456
  if (process.stdin.setRawMode) {
267
457
  process.stdin.setRawMode(wasRaw || false);
268
458
  }
459
+ if (process.stdout.isTTY) {
460
+ dibujar(PASTE_STOP_REQUEST);
461
+ }
462
+ }
463
+ /** Mete texto donde esta el cursor. Un solo sitio para no repetir el corte del buffer. */
464
+ function insertText(text) {
465
+ inputBuffer = inputBuffer.slice(0, cursorIndex) + text + inputBuffer.slice(cursorIndex);
466
+ cursorIndex += text.length;
467
+ selectedIndex = 0;
269
468
  }
270
469
  function applySelectedDropdownItem() {
271
470
  const items = getDropdownItems();
272
471
  if (items.length === 0)
273
472
  return false;
274
473
  const item = items[selectedIndex];
474
+ if (!item)
475
+ return false;
275
476
  if (item.type === "command") {
276
477
  inputBuffer = item.insertText;
277
478
  cursorIndex = inputBuffer.length;
@@ -292,38 +493,101 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
292
493
  const symbolStr = promptSymbol.includes("plan")
293
494
  ? `${chalk.cyan.bold("plan ❯")} `
294
495
  : `${chalk.bold.hex("#D97757")("❯")} `;
295
- // Move cursor to top of frame
296
- if (lastCursorLineIndex > 0) {
297
- process.stdout.write(`\x1b[${lastCursorLineIndex}A\r`);
298
- }
299
- else {
300
- process.stdout.write("\r");
301
- }
302
- // Draw clean submitted prompt box in terminal history
303
- process.stdout.write(`\x1b[2K${chalk.gray("─".repeat(cols))}\x1b[1E`);
304
- process.stdout.write(`\x1b[2K${symbolStr}${chalk.bold.white(submittedText)}\x1b[1E`);
305
- process.stdout.write(`\x1b[2K${chalk.gray("─".repeat(cols))}\x1b[1E`);
306
- // Clear any leftover rows (dropdown, old footer)
307
- const extraLines = Math.max(0, lastRenderedLines.length - 3);
308
- for (let i = 0; i < extraLines; i++) {
309
- process.stdout.write(`\x1b[2K`);
310
- if (i < extraLines - 1)
311
- process.stdout.write("\x1b[1E");
312
- }
313
- // Move cursor directly below the bottom bar
314
- if (extraLines > 0) {
315
- process.stdout.write(`\x1b[${extraLines}A\r\x1b[1E\n`);
316
- }
317
- else {
318
- process.stdout.write("\n");
496
+ const bar = chalk.gray("─".repeat(cols));
497
+ const out = [];
498
+ // Subir a la primera fila del marco y borrar de ahi hacia abajo. Antes se
499
+ // contaban a mano las filas sobrantes (desplegable, pie) y esa cuenta era
500
+ // en lineas logicas: lo que el terminal habia partido se quedaba debajo
501
+ // del prompt ya enviado.
502
+ if (hasRendered) {
503
+ if (lastCursorRow > 0)
504
+ out.push(`\x1b[${lastCursorRow}A`);
505
+ out.push("\r\x1b[0J");
319
506
  }
507
+ out.push(`${bar}\n${symbolStr}${chalk.bold.white(submittedText)}\n${bar}\n\n`);
508
+ dibujar(out.join(""));
509
+ hasRendered = false;
510
+ lastCursorRow = 0;
511
+ }
512
+ function submitNow() {
513
+ if (isFinished)
514
+ return;
515
+ pendingSubmit = null;
516
+ // El historial guarda la marca, no las 347 lineas: si no, el fichero de
517
+ // historial crece sin control y recorrerlo con las flechas es inutil.
518
+ appendHistory(inputBuffer);
519
+ // Pero el modelo recibe el texto entero, que es lo que el usuario pego.
520
+ const completo = expandirPegados(inputBuffer);
521
+ cleanup();
522
+ finalizePromptOnSubmit(inputBuffer);
523
+ resolve(completo);
524
+ }
525
+ /**
526
+ * Apunta el envio para el final del tick. Todo el chunk de stdin se emite
527
+ * antes de que corra el setImmediate, asi que para entonces ya se sabe si
528
+ * detras del Enter venia mas texto pegado.
529
+ */
530
+ function scheduleSubmit() {
531
+ if (pendingSubmit)
532
+ return;
533
+ pendingSubmit = setImmediate(submitNow);
534
+ }
535
+ /** El Enter en espera no era una orden de enviar: era un salto del pegado. */
536
+ function absorbPendingEnter() {
537
+ if (!pendingSubmit)
538
+ return;
539
+ clearImmediate(pendingSubmit);
540
+ pendingSubmit = null;
541
+ insertText("\n");
320
542
  }
321
543
  function onKeypress(str, key) {
322
544
  if (isFinished)
323
545
  return;
546
+ // Marcas del pegado entre corchetes. Node >= 22 las nombra; en versiones
547
+ // anteriores solo llega la secuencia cruda, asi que se miran las dos.
548
+ const sequence = (key && key.sequence) || str || "";
549
+ if ((key && key.name === "paste-start") || sequence === PASTE_START) {
550
+ isPasting = true;
551
+ // Se recoge aparte: si resulta ser grande, en el recuadro va una marca
552
+ // corta en vez de las 347 lineas. Ver pegados.ts.
553
+ loPegado = "";
554
+ return;
555
+ }
556
+ if ((key && key.name === "paste-end") || sequence === PASTE_END) {
557
+ isPasting = false;
558
+ if (loPegado) {
559
+ insertText(esGrande(loPegado) ? guardarPegado(loPegado) : loPegado);
560
+ loPegado = "";
561
+ }
562
+ render();
563
+ return;
564
+ }
565
+ const isEnter = Boolean(key && (key.name === "return" || key.name === "enter"));
566
+ // CRLF llega como DOS pulsaciones ("\r" y luego "\n") y es un unico salto.
567
+ // Sin esto, el portapapeles de Windows duplicaba todas las lineas en blanco.
568
+ const isPairedLF = isEnter && key.name === "enter" && lastWasCR;
569
+ lastWasCR = isEnter && key.name === "return";
570
+ if (isPairedLF)
571
+ return;
572
+ // Cualquier tecla que no sea el Enter y llegue en su mismo tick delata un
573
+ // pegado: aquel Enter pasa a ser salto de linea y se sigue con esta tecla.
574
+ if (!isEnter)
575
+ absorbPendingEnter();
324
576
  const items = getDropdownItems();
325
577
  // Enter key
326
- if (key && (key.name === "return" || key.name === "enter")) {
578
+ if (isEnter) {
579
+ // Dentro de un pegado, el salto va al buffer aparte, no al recuadro.
580
+ if (isPasting) {
581
+ loPegado += "\n";
582
+ return;
583
+ }
584
+ // Segundo Enter del mismo tick: linea en blanco dentro del pegado.
585
+ if (pendingSubmit) {
586
+ absorbPendingEnter();
587
+ scheduleSubmit();
588
+ render();
589
+ return;
590
+ }
327
591
  // Multi-line continuation if ends with \
328
592
  if (inputBuffer.endsWith("\\")) {
329
593
  inputBuffer = inputBuffer.slice(0, -1) + "\n";
@@ -337,10 +601,7 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
337
601
  return;
338
602
  }
339
603
  }
340
- appendHistory(inputBuffer);
341
- cleanup();
342
- finalizePromptOnSubmit(inputBuffer);
343
- resolve(inputBuffer);
604
+ scheduleSubmit();
344
605
  return;
345
606
  }
346
607
  // Shift+Tab: cambia el modo de permisos instantáneamente
@@ -417,8 +678,12 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
417
678
  // Backspace
418
679
  if (key && (key.name === "backspace" || (key.ctrl && key.name === "h"))) {
419
680
  if (cursorIndex > 0) {
420
- inputBuffer = inputBuffer.slice(0, cursorIndex - 1) + inputBuffer.slice(cursorIndex);
421
- cursorIndex--;
681
+ // Lo que ocupa una linea se borra como una linea: sin esto habria que
682
+ // pulsar borrar treinta veces para quitar una marca de pegado.
683
+ const marca = marcaQueTerminaEn(inputBuffer, cursorIndex);
684
+ const desde = marca ? marca.inicio : cursorIndex - 1;
685
+ inputBuffer = inputBuffer.slice(0, desde) + inputBuffer.slice(cursorIndex);
686
+ cursorIndex = desde;
422
687
  selectedIndex = 0;
423
688
  render();
424
689
  }
@@ -435,14 +700,18 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
435
700
  inputBuffer = "";
436
701
  cursorIndex = 0;
437
702
  selectedIndex = 0;
703
+ // Salida de emergencia: si un terminal raro mandara la marca de apertura
704
+ // del pegado y nunca la de cierre, sin esto el Enter no volveria a
705
+ // enviar en toda la sesion.
706
+ isPasting = false;
438
707
  render();
439
708
  return;
440
709
  }
441
710
  // Ctrl + L (clear screen)
442
711
  if (key && key.ctrl && key.name === "l") {
443
712
  console.clear();
444
- lastRenderedLines = [];
445
- lastCursorLineIndex = 1;
713
+ hasRendered = false;
714
+ lastCursorRow = 0;
446
715
  render();
447
716
  return;
448
717
  }
@@ -451,15 +720,51 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
451
720
  !isShiftTab &&
452
721
  str !== "\x1b[Z" &&
453
722
  (!key || (!key.ctrl && !key.meta) || (key.ctrl && key.meta) || (!key.name && str.length > 0))) {
723
+ // Una secuencia de escape no es texto. Hoy readline manda str undefined
724
+ // cuando reconoce la secuencia, asi que este filtro casi nunca salta;
725
+ // esta para que una marca de pegado o una tecla de funcion que llegue
726
+ // cruda no acabe escrita como letras sueltas ("\x1b[200~") en el buffer.
727
+ if (str.charCodeAt(0) === 0x1b)
728
+ return;
454
729
  const cleanStr = str.replace(/[\r\n]/g, "");
455
- if (cleanStr.length > 0) {
456
- inputBuffer = inputBuffer.slice(0, cursorIndex) + cleanStr + inputBuffer.slice(cursorIndex);
457
- cursorIndex += cleanStr.length;
458
- selectedIndex = 0;
730
+ if (cleanStr.length === 0)
731
+ return;
732
+ if (isPasting) {
733
+ // Va al buffer del pegado, no al recuadro: al cerrarse se decide si
734
+ // cabe entero o si merece una marca corta.
735
+ loPegado += cleanStr;
736
+ return;
737
+ }
738
+ // Sin marcas de pegado -conhost antiguo, algun terminal raro- el pegado
739
+ // llega como un chunk enorme en una sola pulsacion. Tecleando nunca
740
+ // llega mas de un caracter, asi que esto solo puede ser un pegado.
741
+ if (esGrande(cleanStr)) {
742
+ insertText(guardarPegado(cleanStr));
459
743
  render();
744
+ return;
460
745
  }
746
+ insertText(cleanStr);
747
+ render();
461
748
  }
462
749
  }
750
+ // A partir de aqui, lo que llegue tarde se dibuja SIN romper el marco.
751
+ escribirEncimaDelPrompt = (texto) => {
752
+ if (isFinished)
753
+ return;
754
+ let salida = "";
755
+ // Se borra el marco igual que hace render() al repintar.
756
+ if (hasRendered) {
757
+ if (lastCursorRow > 0)
758
+ salida += `\x1b[${lastCursorRow}A`;
759
+ salida += "\r\x1b[0J";
760
+ }
761
+ salida += texto.endsWith("\n") ? texto : `${texto}\n`;
762
+ dibujar(salida);
763
+ // Y el prompt se vuelve a dibujar limpio debajo del mensaje.
764
+ hasRendered = false;
765
+ lastCursorRow = 0;
766
+ render();
767
+ };
463
768
  process.stdin.on("keypress", onKeypress);
464
769
  render();
465
770
  });