chocolatito-code 1.0.0 → 1.1.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 (102) hide show
  1. package/README.md +89 -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 +536 -78
  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/subagent.js +62 -15
  19. package/dist/agent/syntaxValidator.js +288 -52
  20. package/dist/agent/toolGate.d.ts +58 -0
  21. package/dist/agent/toolGate.js +111 -0
  22. package/dist/agent/tracker.js +4 -7
  23. package/dist/agent/undoManager.d.ts +44 -11
  24. package/dist/agent/undoManager.js +95 -24
  25. package/dist/agent/usageMeter.d.ts +56 -0
  26. package/dist/agent/usageMeter.js +51 -0
  27. package/dist/agent/verifier.d.ts +49 -0
  28. package/dist/agent/verifier.js +158 -0
  29. package/dist/config/constants.js +14 -0
  30. package/dist/config/engine.d.ts +19 -0
  31. package/dist/config/engine.js +35 -3
  32. package/dist/config/license.d.ts +5 -0
  33. package/dist/config/license.js +107 -9
  34. package/dist/config/limits.d.ts +28 -0
  35. package/dist/config/limits.js +51 -0
  36. package/dist/config/permissions.d.ts +76 -1
  37. package/dist/config/permissions.js +379 -23
  38. package/dist/config/quota.js +24 -1
  39. package/dist/config/updater.d.ts +68 -0
  40. package/dist/config/updater.js +215 -0
  41. package/dist/hooks/manager.js +159 -14
  42. package/dist/index.js +222 -34
  43. package/dist/mcp/client.d.ts +22 -0
  44. package/dist/mcp/client.js +93 -11
  45. package/dist/prompts/systemPrompt.js +12 -1
  46. package/dist/sessions/manager.d.ts +12 -0
  47. package/dist/sessions/manager.js +33 -5
  48. package/dist/sessions/resume.d.ts +36 -0
  49. package/dist/sessions/resume.js +43 -0
  50. package/dist/skills/manager.d.ts +1 -0
  51. package/dist/skills/manager.js +9 -0
  52. package/dist/tools/binary.d.ts +46 -0
  53. package/dist/tools/binary.js +100 -0
  54. package/dist/tools/browserCdp.js +13 -1
  55. package/dist/tools/browserExtension.d.ts +5 -0
  56. package/dist/tools/browserExtension.js +202 -18
  57. package/dist/tools/browserSession.d.ts +23 -0
  58. package/dist/tools/browserSession.js +28 -0
  59. package/dist/tools/computerUse.js +33 -1
  60. package/dist/tools/createImage.d.ts +11 -0
  61. package/dist/tools/createImage.js +13 -1
  62. package/dist/tools/definitions.js +9 -3
  63. package/dist/tools/editDiagnosis.d.ts +54 -0
  64. package/dist/tools/editDiagnosis.js +142 -0
  65. package/dist/tools/editFile.d.ts +1 -0
  66. package/dist/tools/editFile.js +118 -62
  67. package/dist/tools/findFiles.d.ts +21 -0
  68. package/dist/tools/findFiles.js +75 -6
  69. package/dist/tools/getSystemInfo.js +15 -7
  70. package/dist/tools/gitTools.d.ts +24 -0
  71. package/dist/tools/gitTools.js +87 -26
  72. package/dist/tools/moveCopyFile.d.ts +18 -1
  73. package/dist/tools/moveCopyFile.js +74 -4
  74. package/dist/tools/patchCascade.d.ts +57 -0
  75. package/dist/tools/patchCascade.js +338 -0
  76. package/dist/tools/runCommand.js +213 -25
  77. package/dist/tools/runner.js +57 -4
  78. package/dist/tools/toolDefsComputer.js +12 -2
  79. package/dist/tools/truncator.d.ts +27 -1
  80. package/dist/tools/truncator.js +117 -11
  81. package/dist/tools/viewFile.js +15 -11
  82. package/dist/tools/visionBridge.js +7 -0
  83. package/dist/tools/webSearch.d.ts +21 -0
  84. package/dist/tools/webSearch.js +125 -14
  85. package/dist/tools/writeFile.js +18 -1
  86. package/dist/ui/interrupt.d.ts +4 -1
  87. package/dist/ui/interrupt.js +56 -5
  88. package/dist/ui/keyboardGuard.d.ts +55 -0
  89. package/dist/ui/keyboardGuard.js +65 -0
  90. package/dist/ui/loopPrompt.d.ts +17 -0
  91. package/dist/ui/loopPrompt.js +59 -0
  92. package/dist/ui/permissionPrompt.js +84 -2
  93. package/dist/ui/prompt.d.ts +35 -0
  94. package/dist/ui/prompt.js +345 -125
  95. package/dist/ui/reasoningStream.d.ts +8 -0
  96. package/dist/ui/reasoningStream.js +99 -7
  97. package/extension/README.md +95 -74
  98. package/extension/background.js +598 -178
  99. package/extension/content.js +92 -9
  100. package/extension/manifest.json +2 -1
  101. package/extension/popup.js +28 -12
  102. package/package.json +1 -1
package/dist/ui/prompt.js CHANGED
@@ -5,7 +5,8 @@ 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, MS_ENTRE_COMPROBACIONES } from "./keyboardGuard.js";
9
10
  import { clearTyped } from "./typeAhead.js";
10
11
  export const SLASH_COMMANDS = [
11
12
  { name: "/mcp", description: "Ver servidores MCP conectados y sus herramientas" },
@@ -28,6 +29,33 @@ export const SLASH_COMMANDS = [
28
29
  { name: "/exit", description: "Cerrar sesión de Chocolatito Code" },
29
30
  ];
30
31
  let isKeypressInitialized = false;
32
+ /**
33
+ * PEGADO DE VARIAS LINEAS
34
+ *
35
+ * Un pegado no llega tecla a tecla: llega como UN chunk de stdin que readline
36
+ * desmonta en pulsaciones y emite todas en el mismo tick sincrono. El primer
37
+ * "\r" del texto se emitia como key.name === "return", el manejador de Enter
38
+ * hacia cleanup() + resolve() y quitaba el oyente MIENTRAS el resto del chunk
39
+ * se seguia emitiendo: las lineas 2..n salian sin nadie escuchando y se perdian
40
+ * sin dejar rastro ni en el buffer ni en la cola. El usuario creia haber mandado
41
+ * la instruccion entera y el agente arrancaba con la primera linea.
42
+ *
43
+ * Dos defensas, porque ninguna cubre sola todos los terminales:
44
+ *
45
+ * 1. Modo de pegado entre corchetes. El terminal envuelve lo pegado en
46
+ * \x1b[200~ ... \x1b[201~, asi que se distingue de lo tecleado aunque el
47
+ * pegado venga partido en varios chunks (los grandes lo vienen). Dentro de
48
+ * las marcas, un salto de linea es texto y nunca envia.
49
+ * 2. El Enter no envia en el acto: se apunta y se decide en el setImmediate
50
+ * siguiente, cuando el chunk entero ya paso. Si detras venia mas texto, ese
51
+ * Enter era un salto del pegado. Esto salva a los terminales que no soportan
52
+ * el punto 1 (conhost antiguo, por ejemplo).
53
+ */
54
+ const PASTE_START = "\x1b[200~";
55
+ const PASTE_END = "\x1b[201~";
56
+ /** Activar/desactivar el modo en el terminal. Se apaga al salir: si se queda puesto, la shell de despues recibe las marcas como texto. */
57
+ const PASTE_START_REQUEST = "\x1b[?2004h";
58
+ const PASTE_STOP_REQUEST = "\x1b[?2004l";
31
59
  function stripAnsi(str) {
32
60
  return str.replace(/\x1b\[[0-9;]*m/g, "");
33
61
  }
@@ -86,14 +114,155 @@ function scanProjectFiles(dir, maxResults = 40) {
86
114
  walk(dir);
87
115
  return results;
88
116
  }
117
+ const PLACEHOLDER = "Escribe una instrucción, '@' para archivos o '/' para comandos...";
118
+ /**
119
+ * Filas FISICAS que ocupa una linea del marco.
120
+ *
121
+ * Aqui estaba el fallo de verdad: el repintado contaba elementos del array del
122
+ * marco mientras el terminal cuenta filas de pantalla. Cualquier linea que no
123
+ * cupiera en el ancho se partia en dos filas, el cursor subia una fila de menos
124
+ * y el marco entero se iba bajando, dejando una barra huerfana por pulsacion.
125
+ * La entrada ademas puede llevar saltos de linea dentro (continuacion con "\").
126
+ */
127
+ function physicalRows(line, cols) {
128
+ return stripAnsi(line)
129
+ .split("\n")
130
+ .reduce((n, seg) => n + Math.max(1, Math.ceil(seg.length / cols)), 0);
131
+ }
132
+ /** Recorta a `width` columnas visibles. Texto sin pintar: aqui no hay que cerrar colores. */
133
+ function truncatePlain(text, width) {
134
+ if (width <= 0)
135
+ return "";
136
+ if (text.length <= width)
137
+ return text;
138
+ return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
139
+ }
140
+ /**
141
+ * Arma el marco del prompt y dice exactamente donde queda el cursor.
142
+ *
143
+ * Va aparte de render() por dos razones: se puede probar sin terminal, y la
144
+ * aritmetica de filas se hace UNA vez sobre las mismas lineas que se pintan, en
145
+ * lugar de estimarse por otro lado y descuadrarse.
146
+ */
147
+ export function buildPromptFrame(opts) {
148
+ const cols = Math.max(20, opts.cols || 80);
149
+ const viewportRows = Math.max(6, opts.rows || 24);
150
+ const promptSymbol = opts.promptSymbol ?? "❯";
151
+ const input = opts.input ?? "";
152
+ const cursorIndex = Math.max(0, Math.min(opts.cursorIndex ?? input.length, input.length));
153
+ const items = opts.items ?? [];
154
+ const selectedIndex = items.length > 0 ? (((opts.selectedIndex ?? 0) % items.length) + items.length) % items.length : 0;
155
+ const isPlan = promptSymbol.includes("plan");
156
+ const symbolStr = isPlan
157
+ ? `${chalk.cyan.bold("plan ❯")} `
158
+ : `${chalk.bold.hex("#D97757")("❯")} `;
159
+ const symbolWidth = isPlan ? 7 : 2;
160
+ const bar = chalk.gray("─".repeat(cols));
161
+ // 1. Linea de entrada. Lo que el usuario escribe NO se recorta nunca: se deja
162
+ // que el terminal la parta y despues se cuentan las filas que ocupa.
163
+ let formattedInput;
164
+ if (input.length === 0) {
165
+ // La pista si se recorta: en una ventana estrecha ocupaba una segunda fila
166
+ // para no decir nada que el usuario no sepa ya.
167
+ formattedInput = chalk.gray(truncatePlain(PLACEHOLDER, cols - symbolWidth));
168
+ }
169
+ else if (input.startsWith("/")) {
170
+ const parts = input.split(" ");
171
+ const cmdPart = chalk.bold.hex("#A855F7")(parts[0]);
172
+ const restPart = parts.slice(1).join(" ");
173
+ formattedInput = restPart ? `${cmdPart} ${chalk.white(restPart)}` : cmdPart;
174
+ }
175
+ else {
176
+ formattedInput = input.replace(/(@[^\s]+)/g, (m) => chalk.bold.hex("#D97757")(m));
177
+ }
178
+ const inputLine = symbolStr + formattedInput;
179
+ const inputRows = physicalRows(inputLine, cols);
180
+ // 2. Desplegable. Un marco mas alto que la ventana obliga al terminal a
181
+ // desplazar el contenido, y a partir de ahi el repintado ya no sabe donde
182
+ // esta su primera fila: se muestra solo una ventana alrededor del elegido.
183
+ const room = Math.max(1, viewportRows - 1 - (1 + inputRows + 1 + 1));
184
+ const shown = Math.min(items.length, room);
185
+ const dropdownOffset = shown > 0 && items.length > shown
186
+ ? Math.min(Math.max(0, selectedIndex - Math.floor(shown / 2)), items.length - shown)
187
+ : 0;
188
+ const dropdownLines = [];
189
+ for (let i = dropdownOffset; i < dropdownOffset + shown; i++) {
190
+ const item = items[i];
191
+ const isSelected = i === selectedIndex;
192
+ const colorHex = item.type === "file" ? "#D97757" : "#A855F7";
193
+ const pointer = isSelected ? chalk.bold.hex(colorHex)(" ❯ ") : chalk.gray(" ");
194
+ // El puntero se lleva 3 columnas fijas. De lo que queda manda la etiqueta:
195
+ // la descripcion es lo primero que se cae, porque una ruta a medias no se
196
+ // puede ni leer ni elegir.
197
+ const free = cols - 3;
198
+ const label = truncatePlain(item.label, free);
199
+ const roomForDesc = free - label.length - 2;
200
+ const secondary = item.secondary && roomForDesc >= 8 ? truncatePlain(item.secondary, roomForDesc) : "";
201
+ const paintedLabel = isSelected ? chalk.bold.white(label) : chalk.hex(colorHex)(label);
202
+ const paintedDesc = secondary
203
+ ? isSelected
204
+ ? chalk.whiteBright(` ${secondary}`)
205
+ : chalk.gray(` ${secondary}`)
206
+ : "";
207
+ dropdownLines.push(`${pointer}${paintedLabel}${paintedDesc}`);
208
+ }
209
+ // 3. Pie de estado. El relleno se calculaba con Math.max(1, ...): cuando no
210
+ // cabia, en vez de recortarse se quedaba con un espacio y la linea salia MAS
211
+ // ancha que el terminal. En una consola de 80 columnas el pie media 87 y se
212
+ // partia en dos filas desde el primer dibujado.
213
+ const modeLook = describeMode(getMode());
214
+ const hintPlain = "? /help para atajos · shift+tab modo · esc cancelar";
215
+ const modePlain = `⇥ modo ${modeLook.label}`;
216
+ const enginePlain = "· Espectro 1.0 (Ágil)";
217
+ const hint = chalk.gray(hintPlain);
218
+ const modeTag = chalk.hex(modeLook.color)(modePlain);
219
+ const engine = chalk.gray(enginePlain);
220
+ let footerLine;
221
+ if (hintPlain.length + 1 + modePlain.length + 1 + enginePlain.length <= cols) {
222
+ const padLen = cols - hintPlain.length - modePlain.length - 1 - enginePlain.length;
223
+ footerLine = `${hint}${" ".repeat(padLen)}${modeTag} ${engine}`;
224
+ }
225
+ else if (modePlain.length + 1 + enginePlain.length <= cols) {
226
+ // Los atajos son opcionales; el modo activo no, que es lo que decide si te
227
+ // van a preguntar antes de tocar tus archivos.
228
+ footerLine = `${" ".repeat(cols - modePlain.length - 1 - enginePlain.length)}${modeTag} ${engine}`;
229
+ }
230
+ else if (modePlain.length <= cols) {
231
+ footerLine = `${" ".repeat(cols - modePlain.length)}${modeTag}`;
232
+ }
233
+ else {
234
+ footerLine = chalk.hex(modeLook.color)(truncatePlain(modePlain, cols));
235
+ }
236
+ const lines = [bar, inputLine, ...dropdownLines, bar, footerLine];
237
+ // 4. Donde queda el cursor, en filas fisicas. Antes se hacia cursorCol =
238
+ // total % cols: aplicaba el modulo pero nunca sumaba la fila, asi que en
239
+ // cuanto la entrada se partia el acento se iba a escribir a otra linea.
240
+ const typed = input.length === 0 ? "" : input.slice(0, cursorIndex);
241
+ const segments = typed.split("\n");
242
+ let cursorRow = physicalRows(bar, cols);
243
+ for (let i = 0; i < segments.length - 1; i++) {
244
+ cursorRow += Math.max(1, Math.ceil(((i === 0 ? symbolWidth : 0) + segments[i].length) / cols));
245
+ }
246
+ const lastSegWidth = (segments.length === 1 ? symbolWidth : 0) + segments[segments.length - 1].length;
247
+ cursorRow += Math.floor(lastSegWidth / cols);
248
+ const cursorCol = lastSegWidth % cols;
249
+ const totalRows = lines.reduce((n, l) => n + physicalRows(l, cols), 0);
250
+ return { lines, totalRows, cursorRow, cursorCol, dropdownOffset };
251
+ }
89
252
  export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.cwd()) {
90
253
  return new Promise((resolve) => {
91
254
  let inputBuffer = "";
92
255
  let cursorIndex = 0;
93
256
  let selectedIndex = 0;
94
257
  let isFinished = false;
95
- let lastRenderedLines = [];
96
- let lastCursorLineIndex = 1;
258
+ // Estado del pegado. Ver la nota PEGADO DE VARIAS LINEAS de arriba.
259
+ let isPasting = false;
260
+ let pendingSubmit = null;
261
+ let lastWasCR = false;
262
+ // Filas FISICAS, no elementos del array: es la unica cuenta con la que el
263
+ // terminal esta de acuerdo.
264
+ let hasRendered = false;
265
+ let lastCursorRow = 0;
97
266
  const history = loadHistory();
98
267
  let historyIdx = history.length;
99
268
  // Cache project files for @ autocompletion
@@ -119,6 +288,38 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
119
288
  process.stdin.setRawMode(true);
120
289
  }
121
290
  process.stdin.resume();
291
+ // A partir de aqui el teclado es de este prompt: un vigilante que se pare
292
+ // tarde ya no puede devolver el modo crudo a como estaba antes del turno,
293
+ // porque "antes del turno" ya no es ahora.
294
+ tomarTecladoParaPrompt();
295
+ // Y por si alguien se lo quita igualmente. Ver keyboardGuard.ts: son
296
+ // demasiados los sitios que apagan el modo crudo con razon, y basta que uno
297
+ // llegue tarde para que el usuario teclee y su texto salga en la linea de
298
+ // ayuda en vez de en el recuadro.
299
+ const vigilanteTeclado = setInterval(() => {
300
+ if (isFinished)
301
+ return;
302
+ const reparado = repararTeclado(process.stdin);
303
+ if (reparado.length === 0)
304
+ return;
305
+ if (process.env.CHOCOLATITO_TECLADO === "debug") {
306
+ process.stderr.write(`\n[teclado] recuperado: ${reparado.join(" y ")}\n`);
307
+ }
308
+ // El terminal ya ha hecho eco de lo que se tecleara mientras tanto, en un
309
+ // sitio que este dibujo no controla. Intentar borrar el marco anterior
310
+ // contando filas se comeria ese texto ajeno y dejaria el cursor perdido:
311
+ // se dibuja uno limpio debajo y se sigue.
312
+ hasRendered = false;
313
+ lastCursorRow = 0;
314
+ render();
315
+ }, MS_ENTRE_COMPROBACIONES);
316
+ // Un temporizador de vigilancia no es motivo para que el proceso siga vivo.
317
+ vigilanteTeclado.unref?.();
318
+ // Se pide el pegado entre corchetes solo si hay terminal delante; a un
319
+ // fichero o a una tuberia esto seria basura en la salida.
320
+ if (process.stdout.isTTY) {
321
+ process.stdout.write(PASTE_START_REQUEST);
322
+ }
122
323
  function getAtMention() {
123
324
  const textBeforeCursor = inputBuffer.slice(0, cursorIndex);
124
325
  const match = textBeforeCursor.match(/@([^\s]*)$/);
@@ -163,115 +364,76 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
163
364
  if (isFinished)
164
365
  return;
165
366
  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
367
  const items = getDropdownItems();
189
- const dropdownLines = [];
190
368
  if (items.length > 0) {
191
369
  if (selectedIndex >= items.length)
192
370
  selectedIndex = 0;
193
371
  if (selectedIndex < 0)
194
372
  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
- }
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);
225
373
  }
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
- }
374
+ const frame = buildPromptFrame({
375
+ cols,
376
+ rows: process.stdout.rows || 24,
377
+ promptSymbol,
378
+ input: inputBuffer,
379
+ cursorIndex,
380
+ items,
381
+ selectedIndex,
382
+ });
383
+ // Todo en una sola escritura: un marco pintado a trozos es un marco que
384
+ // parpadea.
385
+ const out = [];
386
+ if (hasRendered) {
387
+ // Volver a la primera fila del marco anterior y borrar de ahi hacia
388
+ // abajo. Antes se repintaba linea a linea con \x1b[2K contando lineas
389
+ // logicas, asi que lo que el terminal habia partido en dos filas se
390
+ // quedaba en pantalla para siempre.
391
+ if (lastCursorRow > 0)
392
+ out.push(`\x1b[${lastCursorRow}A`);
393
+ out.push("\r\x1b[0J");
259
394
  }
260
- lastRenderedLines = currentFrame;
261
- lastCursorLineIndex = inputLineIdx;
395
+ out.push(frame.lines.join("\n"));
396
+ // El cursor se quedo en la ultima fila pintada; hay que subirlo a la de la
397
+ // entrada. Ojo con los ceros: \x1b[0A y \x1b[0C mueven UNA posicion.
398
+ const moveUp = frame.totalRows - 1 - frame.cursorRow;
399
+ if (moveUp > 0)
400
+ out.push(`\x1b[${moveUp}A`);
401
+ out.push("\r");
402
+ if (frame.cursorCol > 0)
403
+ out.push(`\x1b[${frame.cursorCol}C`);
404
+ process.stdout.write(out.join(""));
405
+ hasRendered = true;
406
+ lastCursorRow = frame.cursorRow;
262
407
  }
263
408
  function cleanup() {
264
409
  isFinished = true;
410
+ clearInterval(vigilanteTeclado);
411
+ devolverTecladoDelPrompt();
412
+ if (pendingSubmit) {
413
+ clearImmediate(pendingSubmit);
414
+ pendingSubmit = null;
415
+ }
265
416
  process.stdin.removeListener("keypress", onKeypress);
266
417
  if (process.stdin.setRawMode) {
267
418
  process.stdin.setRawMode(wasRaw || false);
268
419
  }
420
+ if (process.stdout.isTTY) {
421
+ process.stdout.write(PASTE_STOP_REQUEST);
422
+ }
423
+ }
424
+ /** Mete texto donde esta el cursor. Un solo sitio para no repetir el corte del buffer. */
425
+ function insertText(text) {
426
+ inputBuffer = inputBuffer.slice(0, cursorIndex) + text + inputBuffer.slice(cursorIndex);
427
+ cursorIndex += text.length;
428
+ selectedIndex = 0;
269
429
  }
270
430
  function applySelectedDropdownItem() {
271
431
  const items = getDropdownItems();
272
432
  if (items.length === 0)
273
433
  return false;
274
434
  const item = items[selectedIndex];
435
+ if (!item)
436
+ return false;
275
437
  if (item.type === "command") {
276
438
  inputBuffer = item.insertText;
277
439
  cursorIndex = inputBuffer.length;
@@ -292,38 +454,91 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
292
454
  const symbolStr = promptSymbol.includes("plan")
293
455
  ? `${chalk.cyan.bold("plan ❯")} `
294
456
  : `${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");
457
+ const bar = chalk.gray("─".repeat(cols));
458
+ const out = [];
459
+ // Subir a la primera fila del marco y borrar de ahi hacia abajo. Antes se
460
+ // contaban a mano las filas sobrantes (desplegable, pie) y esa cuenta era
461
+ // en lineas logicas: lo que el terminal habia partido se quedaba debajo
462
+ // del prompt ya enviado.
463
+ if (hasRendered) {
464
+ if (lastCursorRow > 0)
465
+ out.push(`\x1b[${lastCursorRow}A`);
466
+ out.push("\r\x1b[0J");
319
467
  }
468
+ out.push(`${bar}\n${symbolStr}${chalk.bold.white(submittedText)}\n${bar}\n\n`);
469
+ process.stdout.write(out.join(""));
470
+ hasRendered = false;
471
+ lastCursorRow = 0;
472
+ }
473
+ function submitNow() {
474
+ if (isFinished)
475
+ return;
476
+ pendingSubmit = null;
477
+ appendHistory(inputBuffer);
478
+ cleanup();
479
+ finalizePromptOnSubmit(inputBuffer);
480
+ resolve(inputBuffer);
481
+ }
482
+ /**
483
+ * Apunta el envio para el final del tick. Todo el chunk de stdin se emite
484
+ * antes de que corra el setImmediate, asi que para entonces ya se sabe si
485
+ * detras del Enter venia mas texto pegado.
486
+ */
487
+ function scheduleSubmit() {
488
+ if (pendingSubmit)
489
+ return;
490
+ pendingSubmit = setImmediate(submitNow);
491
+ }
492
+ /** El Enter en espera no era una orden de enviar: era un salto del pegado. */
493
+ function absorbPendingEnter() {
494
+ if (!pendingSubmit)
495
+ return;
496
+ clearImmediate(pendingSubmit);
497
+ pendingSubmit = null;
498
+ insertText("\n");
320
499
  }
321
500
  function onKeypress(str, key) {
322
501
  if (isFinished)
323
502
  return;
503
+ // Marcas del pegado entre corchetes. Node >= 22 las nombra; en versiones
504
+ // anteriores solo llega la secuencia cruda, asi que se miran las dos.
505
+ const sequence = (key && key.sequence) || str || "";
506
+ if ((key && key.name === "paste-start") || sequence === PASTE_START) {
507
+ isPasting = true;
508
+ return;
509
+ }
510
+ if ((key && key.name === "paste-end") || sequence === PASTE_END) {
511
+ isPasting = false;
512
+ render();
513
+ return;
514
+ }
515
+ const isEnter = Boolean(key && (key.name === "return" || key.name === "enter"));
516
+ // CRLF llega como DOS pulsaciones ("\r" y luego "\n") y es un unico salto.
517
+ // Sin esto, el portapapeles de Windows duplicaba todas las lineas en blanco.
518
+ const isPairedLF = isEnter && key.name === "enter" && lastWasCR;
519
+ lastWasCR = isEnter && key.name === "return";
520
+ if (isPairedLF)
521
+ return;
522
+ // Cualquier tecla que no sea el Enter y llegue en su mismo tick delata un
523
+ // pegado: aquel Enter pasa a ser salto de linea y se sigue con esta tecla.
524
+ if (!isEnter)
525
+ absorbPendingEnter();
324
526
  const items = getDropdownItems();
325
527
  // Enter key
326
- if (key && (key.name === "return" || key.name === "enter")) {
528
+ if (isEnter) {
529
+ // Dentro de un pegado, un salto de linea es texto y no envia nada.
530
+ if (isPasting) {
531
+ insertText("\n");
532
+ render();
533
+ return;
534
+ }
535
+ // Segundo Enter del mismo tick: linea en blanco dentro del pegado.
536
+ if (pendingSubmit) {
537
+ absorbPendingEnter();
538
+ scheduleSubmit();
539
+ render();
540
+ return;
541
+ }
327
542
  // Multi-line continuation if ends with \
328
543
  if (inputBuffer.endsWith("\\")) {
329
544
  inputBuffer = inputBuffer.slice(0, -1) + "\n";
@@ -337,10 +552,7 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
337
552
  return;
338
553
  }
339
554
  }
340
- appendHistory(inputBuffer);
341
- cleanup();
342
- finalizePromptOnSubmit(inputBuffer);
343
- resolve(inputBuffer);
555
+ scheduleSubmit();
344
556
  return;
345
557
  }
346
558
  // Shift+Tab: cambia el modo de permisos instantáneamente
@@ -435,14 +647,18 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
435
647
  inputBuffer = "";
436
648
  cursorIndex = 0;
437
649
  selectedIndex = 0;
650
+ // Salida de emergencia: si un terminal raro mandara la marca de apertura
651
+ // del pegado y nunca la de cierre, sin esto el Enter no volveria a
652
+ // enviar en toda la sesion.
653
+ isPasting = false;
438
654
  render();
439
655
  return;
440
656
  }
441
657
  // Ctrl + L (clear screen)
442
658
  if (key && key.ctrl && key.name === "l") {
443
659
  console.clear();
444
- lastRenderedLines = [];
445
- lastCursorLineIndex = 1;
660
+ hasRendered = false;
661
+ lastCursorRow = 0;
446
662
  render();
447
663
  return;
448
664
  }
@@ -451,11 +667,15 @@ export async function askInteractivePrompt(promptSymbol = "❯", cwd = process.c
451
667
  !isShiftTab &&
452
668
  str !== "\x1b[Z" &&
453
669
  (!key || (!key.ctrl && !key.meta) || (key.ctrl && key.meta) || (!key.name && str.length > 0))) {
670
+ // Una secuencia de escape no es texto. Hoy readline manda str undefined
671
+ // cuando reconoce la secuencia, asi que este filtro casi nunca salta;
672
+ // esta para que una marca de pegado o una tecla de funcion que llegue
673
+ // cruda no acabe escrita como letras sueltas ("\x1b[200~") en el buffer.
674
+ if (str.charCodeAt(0) === 0x1b)
675
+ return;
454
676
  const cleanStr = str.replace(/[\r\n]/g, "");
455
677
  if (cleanStr.length > 0) {
456
- inputBuffer = inputBuffer.slice(0, cursorIndex) + cleanStr + inputBuffer.slice(cursorIndex);
457
- cursorIndex += cleanStr.length;
458
- selectedIndex = 0;
678
+ insertText(cleanStr);
459
679
  render();
460
680
  }
461
681
  }
@@ -1,6 +1,14 @@
1
1
  export declare class ReasoningStream {
2
2
  private hasStarted;
3
3
  private atLineStart;
4
+ private inicio;
5
+ private caracteres;
6
+ private ultimoRefresco;
7
+ private cola;
8
+ private lineaPintada;
4
9
  push(chunk: string): void;
10
+ /** El volcado literal, para quien lo pida. Una escritura por trozo, no por letra. */
11
+ private volcar;
12
+ private refrescar;
5
13
  flush(): void;
6
14
  }