chocolatito-code 1.6.6 → 1.6.7

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.
@@ -2,6 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import process from "node:process";
3
3
  import { render } from "ink";
4
4
  import { App } from "./App.js";
5
+ import { darPorPintado, vaciarTranscripcion } from "./transcripcion.js";
6
+ import { devolverTecladoDelPrompt, tomarTecladoParaPrompt } from "../interrupt.js";
5
7
  /**
6
8
  * MONTA EL ÁRBOL Y LO DEJA VIVO TODA LA SESIÓN
7
9
  *
@@ -12,23 +14,113 @@ import { App } from "./App.js";
12
14
  * parece a su causa. Con el montaje aparte, `index.ts` llama a los dos y no hay
13
15
  * ciclo.
14
16
  *
17
+ * LAS OPCIONES, UNA POR UNA
18
+ *
15
19
  * `exitOnCtrlC: false` porque Ctrl+C ya lo trata el prompt: dejar que Ink cierre
16
20
  * el proceso por su cuenta se salta el guardado de la sesión.
21
+ *
22
+ * `patchConsole: false` y esto necesita explicación, porque el de serie de Ink
23
+ * es bueno y aun así no se usa. Quedan cerca de cien `console.log` y
24
+ * `console.error` repartidos por el programa —los comandos de barra, los avisos
25
+ * de los hooks, la licencia— y escribir directo con un marco de Ink puesto lo
26
+ * descuadra. El parche de Ink resuelve eso: borra su marco, escribe y repinta.
27
+ *
28
+ * Pero los manda por un camino distinto que el texto del agente, y con otro
29
+ * tiempo: el del agente espera al dibujado de React, el del parche sale en el
30
+ * acto. Dos colas para la misma conversación acaban ordenándola mal. Aquí los dos
31
+ * van por la transcripción, que es una sola cola. Ver ui/pantalla.ts.
32
+ *
33
+ * `alternateScreen: false` es lo de serie y se pone a la vista a propósito: es
34
+ * justo lo que se quitó, y dejarlo escrito ahorra que alguien —yo, dentro de
35
+ * tres semanas— vuelva a probarlo.
17
36
  */
18
37
  let arbol = null;
19
38
  export function montarArbol() {
20
39
  if (arbol)
21
40
  return;
22
- arbol = render(_jsx(App, {}), { exitOnCtrlC: false });
41
+ // EL TECLADO ES DE INK MIENTRAS EL ÁRBOL VIVA
42
+ //
43
+ // El vigilante de Esc guarda cómo estaba el modo del teclado al empezar el
44
+ // turno y lo restaura al pararse. Cuando se para tarde —y se para tarde a
45
+ // menudo: es un `keypress` que se desengancha en el siguiente tick— restaura
46
+ // "no crudo" con el árbol delante. El terminal vuelve a hacer eco por su
47
+ // cuenta, las teclas dejan de llegar a Ink, y Enter suelta la línea de golpe.
48
+ //
49
+ // Visto desde fuera: "con darle enter se va la traba". Es el teclado que se
50
+ // queda pillado después de un /effort.
51
+ //
52
+ // Esta marca ya existía para el prompt clásico, que la ponía al abrirse y la
53
+ // quitaba al cerrarse. Con el árbol único el prompt no se cierra nunca, así
54
+ // que se pone aquí y no se quita hasta desmontar.
55
+ tomarTecladoParaPrompt();
56
+ arbol = render(_jsx(App, {}), {
57
+ exitOnCtrlC: false,
58
+ patchConsole: false,
59
+ alternateScreen: false,
60
+ });
23
61
  }
62
+ /**
63
+ * Suelta el árbol.
64
+ *
65
+ * `darPorPintado()` ANTES de desmontar, y el orden importa: si el árbol se
66
+ * vuelve a montar —un diálogo que necesita el teclado para él solo—, el
67
+ * `<Static>` nuevo empieza su cuenta de cero y reimprimiría la conversación
68
+ * entera. En la pantalla normal esas líneas siguen ahí, en el terminal; la marca
69
+ * dice por dónde iba para que el montaje nuevo solo escriba lo que falta.
70
+ *
71
+ * `clear()` borra el marco de abajo antes de irse. Sin eso queda una copia
72
+ * muerta de la barra en mitad del historial, y en la próxima sesión de scroll el
73
+ * usuario se encuentra dos.
74
+ */
24
75
  export function desmontarArbol() {
25
76
  if (!arbol)
26
77
  return;
78
+ darPorPintado();
79
+ try {
80
+ arbol.clear();
81
+ }
82
+ catch { }
27
83
  try {
28
84
  arbol.unmount();
29
85
  }
30
86
  catch { }
31
87
  arbol = null;
88
+ devolverTecladoDelPrompt();
89
+ }
90
+ export function hayArbol() {
91
+ return arbol !== null;
92
+ }
93
+ /**
94
+ * Borra la pantalla de verdad. Es lo que hacen /clear, /cd y Ctrl+L.
95
+ *
96
+ * POR QUE NO VALE UN `console.clear()`
97
+ *
98
+ * `console.clear()` escribe la secuencia de borrado directa al terminal, y no
99
+ * pasa por la interceptación de consola: ahí solo van `log`, `error`, `warn` e
100
+ * `info`. Con el árbol montado eso borra filas que Ink cree suyas, y a partir de
101
+ * ahí Ink borra y repinta sobre un sitio que ya no existe. Lo que se ve es
102
+ * basura, o nada.
103
+ *
104
+ * Aquí se hace en el orden que no rompe nada: se borra el marco con la cuenta
105
+ * todavía buena, se suelta el árbol, se limpia la pantalla —y el historial del
106
+ * terminal con `3J`, que es lo que el usuario espera de un /clear— y se vuelve a
107
+ * montar de cero.
108
+ *
109
+ * Y se vacía la transcripción: si no, el <Static> nuevo tendría por delante toda
110
+ * la conversación que se acaba de borrar.
111
+ */
112
+ export function limpiarLaPantalla() {
113
+ const habia = arbol !== null;
114
+ if (habia)
115
+ desmontarArbol();
116
+ vaciarTranscripcion();
117
+ try {
118
+ // 2J la pantalla, 3J el historial de desplazamiento, H el cursor arriba.
119
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
120
+ }
121
+ catch { }
122
+ if (habia)
123
+ montarArbol();
32
124
  }
33
125
  // Si el proceso muere sin pasar por la salida ordenada, el árbol tiene que
34
126
  // soltarse igual: un árbol vivo en un proceso que se cierra deja el terminal a
@@ -0,0 +1,63 @@
1
+ /**
2
+ * PRESTARLE EL TERMINAL A OTRO UN MOMENTO
3
+ *
4
+ * EL PROBLEMA
5
+ *
6
+ * Con el árbol de Ink montado, el terminal tiene un dueño: Ink pide el modo
7
+ * crudo, lleva la cuenta de las filas que ha pintado y repinta por diferencias.
8
+ *
9
+ * Pero hay tres sitios del programa que necesitan el teclado para ellos solos, y
10
+ * los tres se abren EN MITAD DE UN TURNO, con el árbol vivo:
11
+ *
12
+ * - el diálogo de permisos (ui/permissionPrompt.ts), que sube el cursor con
13
+ * `\x1b[nA` y repinta su caja a mano;
14
+ * - el aviso de bucle (ui/loopPrompt.ts), que abre `readline`;
15
+ * - el menú de /effort (ui/selector.ts), que repinta su lista.
16
+ *
17
+ * Los tres escriben directo a `process.stdout` y abren `readline` sobre el mismo
18
+ * `process.stdin` que Ink está leyendo. Dos dueños a la vez: el marco de Ink se
19
+ * descuadra desde la primera herramienta que pida permiso, y a partir de ahí
20
+ * repinta sobre filas que ya no son suyas.
21
+ *
22
+ * LA SOLUCIÓN, QUE YA VENÍA EN INK
23
+ *
24
+ * `suspendTerminal` existe para esto —está pensado para abrir `$EDITOR` o `less`
25
+ * desde una aplicación de Ink— y hace exactamente lo que hace falta: vacía lo
26
+ * pendiente, borra su marco, devuelve el cursor, suelta el modo crudo, y al
27
+ * volver reclama la entrada y fuerza un redibujado completo. Si la tarea revienta
28
+ * lo restaura igual.
29
+ *
30
+ * POR QUE HAY UN ARCHIVO SOLO PARA ESTO
31
+ *
32
+ * `suspendTerminal` solo se coge con `useApp()`, o sea desde dentro de un
33
+ * componente. Quien lo necesita —el diálogo de permisos, llamado desde el bucle
34
+ * del agente— está a diez capas de React. Aquí App lo deja apuntado al montarse
35
+ * y el resto del programa lo usa sin saber que Ink existe.
36
+ *
37
+ * SIN ÁRBOL NO PASA NADA
38
+ *
39
+ * En el modo clásico no hay nadie registrado y la tarea se ejecuta tal cual. Por
40
+ * eso se puede envolver a los tres sin condicionales en el sitio de la llamada.
41
+ */
42
+ type Prestamo = (tarea: () => void | Promise<void>) => Promise<void>;
43
+ export declare function registrarPrestamo(fn: Prestamo | null): void;
44
+ export declare function hayPrestamo(): boolean;
45
+ /**
46
+ * ¿Está el terminal ahora mismo en manos de otro?
47
+ *
48
+ * Lo pregunta el vigilante del teclado del árbol: mientras un diálogo tiene el
49
+ * terminal, el modo crudo es SUYO y reafirmarlo por detrás le quitaría las
50
+ * teclas justo cuando está esperando una respuesta.
51
+ */
52
+ export declare function terminalPrestado(): boolean;
53
+ /**
54
+ * Corre `tarea` con el terminal en manos de quien lo pide.
55
+ *
56
+ * El guardián de anidamiento no es hipotético: un subagente puede pedir permiso
57
+ * mientras el principal ya tiene un diálogo abierto. Suspender dos veces lanza
58
+ * una excepción dentro de Ink, y el árbol se quedaría suspendido para siempre —
59
+ * pantalla muerta. Si ya está prestado, la tarea corre y ya está: el terminal ya
60
+ * es de quien lo pidió primero.
61
+ */
62
+ export declare function conElTerminalPrestado<T>(tarea: () => Promise<T>): Promise<T>;
63
+ export {};
@@ -0,0 +1,92 @@
1
+ /**
2
+ * PRESTARLE EL TERMINAL A OTRO UN MOMENTO
3
+ *
4
+ * EL PROBLEMA
5
+ *
6
+ * Con el árbol de Ink montado, el terminal tiene un dueño: Ink pide el modo
7
+ * crudo, lleva la cuenta de las filas que ha pintado y repinta por diferencias.
8
+ *
9
+ * Pero hay tres sitios del programa que necesitan el teclado para ellos solos, y
10
+ * los tres se abren EN MITAD DE UN TURNO, con el árbol vivo:
11
+ *
12
+ * - el diálogo de permisos (ui/permissionPrompt.ts), que sube el cursor con
13
+ * `\x1b[nA` y repinta su caja a mano;
14
+ * - el aviso de bucle (ui/loopPrompt.ts), que abre `readline`;
15
+ * - el menú de /effort (ui/selector.ts), que repinta su lista.
16
+ *
17
+ * Los tres escriben directo a `process.stdout` y abren `readline` sobre el mismo
18
+ * `process.stdin` que Ink está leyendo. Dos dueños a la vez: el marco de Ink se
19
+ * descuadra desde la primera herramienta que pida permiso, y a partir de ahí
20
+ * repinta sobre filas que ya no son suyas.
21
+ *
22
+ * LA SOLUCIÓN, QUE YA VENÍA EN INK
23
+ *
24
+ * `suspendTerminal` existe para esto —está pensado para abrir `$EDITOR` o `less`
25
+ * desde una aplicación de Ink— y hace exactamente lo que hace falta: vacía lo
26
+ * pendiente, borra su marco, devuelve el cursor, suelta el modo crudo, y al
27
+ * volver reclama la entrada y fuerza un redibujado completo. Si la tarea revienta
28
+ * lo restaura igual.
29
+ *
30
+ * POR QUE HAY UN ARCHIVO SOLO PARA ESTO
31
+ *
32
+ * `suspendTerminal` solo se coge con `useApp()`, o sea desde dentro de un
33
+ * componente. Quien lo necesita —el diálogo de permisos, llamado desde el bucle
34
+ * del agente— está a diez capas de React. Aquí App lo deja apuntado al montarse
35
+ * y el resto del programa lo usa sin saber que Ink existe.
36
+ *
37
+ * SIN ÁRBOL NO PASA NADA
38
+ *
39
+ * En el modo clásico no hay nadie registrado y la tarea se ejecuta tal cual. Por
40
+ * eso se puede envolver a los tres sin condicionales en el sitio de la llamada.
41
+ */
42
+ let prestar = null;
43
+ /** Ink revienta si se suspende dos veces. Ver ink.js:873-875. */
44
+ let yaPrestado = false;
45
+ export function registrarPrestamo(fn) {
46
+ prestar = fn;
47
+ }
48
+ export function hayPrestamo() {
49
+ return prestar !== null;
50
+ }
51
+ /**
52
+ * ¿Está el terminal ahora mismo en manos de otro?
53
+ *
54
+ * Lo pregunta el vigilante del teclado del árbol: mientras un diálogo tiene el
55
+ * terminal, el modo crudo es SUYO y reafirmarlo por detrás le quitaría las
56
+ * teclas justo cuando está esperando una respuesta.
57
+ */
58
+ export function terminalPrestado() {
59
+ return yaPrestado;
60
+ }
61
+ /**
62
+ * Corre `tarea` con el terminal en manos de quien lo pide.
63
+ *
64
+ * El guardián de anidamiento no es hipotético: un subagente puede pedir permiso
65
+ * mientras el principal ya tiene un diálogo abierto. Suspender dos veces lanza
66
+ * una excepción dentro de Ink, y el árbol se quedaría suspendido para siempre —
67
+ * pantalla muerta. Si ya está prestado, la tarea corre y ya está: el terminal ya
68
+ * es de quien lo pidió primero.
69
+ */
70
+ export async function conElTerminalPrestado(tarea) {
71
+ if (!prestar || yaPrestado)
72
+ return tarea();
73
+ let resultado;
74
+ let fallo = null;
75
+ yaPrestado = true;
76
+ try {
77
+ await prestar(async () => {
78
+ try {
79
+ resultado = await tarea();
80
+ }
81
+ catch (e) {
82
+ fallo = e;
83
+ }
84
+ });
85
+ }
86
+ finally {
87
+ yaPrestado = false;
88
+ }
89
+ if (fallo)
90
+ throw fallo;
91
+ return resultado;
92
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * EL TECLADO SIGUE SIENDO NUESTRO, SE COMPRUEBE CUANDO SE COMPRUEBE
3
+ *
4
+ * EL FALLO, CON SUS PALABRAS
5
+ *
6
+ * "al poner un / y poner algo luego se queda trabado y ya no se puede escribir a
7
+ * menos que pongas enter". Y antes: "al cambiar de potencia se queda pegado y no
8
+ * deja escribir, tengo que presionar enter". Y antes: "escribí 2 prompts y al
9
+ * siguiente ya no me deja escribir nada, teclee lo que teclee".
10
+ *
11
+ * Es siempre el mismo, y el "se arregla con Enter" es la firma que lo delata: en
12
+ * modo cocido la consola guarda lo que tecleas y no lo suelta hasta el Enter. O
13
+ * sea que el teclado no está muerto — está en manos del terminal, y quien hace
14
+ * el eco es PowerShell, no la caja.
15
+ *
16
+ * POR QUE VUELVE UNA Y OTRA VEZ
17
+ *
18
+ * Porque hay muchos sitios que apagan el modo crudo, y todos con razón: un
19
+ * `InterruptWatcher` que se para tarde y restaura el estado anterior, un
20
+ * `readline` que al cerrarse hace `pause()` y `setRawMode(false)`, un proceso
21
+ * hijo que hereda la consola y la devuelve en cocido. Basta con que UNA de las
22
+ * veinte salidas se escape.
23
+ *
24
+ * Y HAY UNA FORMA QUE NODE NI SIQUIERA VE
25
+ *
26
+ * Cuando el que la cambia es un proceso hijo, `process.stdin.isRaw` se queda
27
+ * diciendo `true`. Por debajo, libuv guarda el modo que cree tener y empieza con
28
+ * `if (tty->mode == mode) return 0;`: a partir de ahí todos los `setRawMode(true)`
29
+ * son un no-op. Node cree que manda y no manda. Por eso hay una reafirmación
30
+ * —pasar por `false` y volver— que sí rompe esa caché.
31
+ *
32
+ * POR QUE ESTE ARCHIVO ES NUEVO
33
+ *
34
+ * El vigilante existía y funcionaba, pero vivía DENTRO del prompt clásico
35
+ * (ui/prompt.ts), que se abría y se cerraba en cada vuelta. Al mudarse a Ink y
36
+ * después al árbol único, el prompt dejó de abrirse y cerrarse... y el vigilante
37
+ * se quedó atrás. El fallo volvió, y volvió idéntico.
38
+ *
39
+ * Aquí vive mientras viva el árbol, que es toda la sesión.
40
+ */
41
+ /**
42
+ * @param esperando ¿está la caja abierta esperando una orden?
43
+ *
44
+ * Se usa para reafirmar en el momento exacto en que se abre. Ese instante es el
45
+ * que sigue a un comando de barra, a un menú o a un proceso hijo: justo cuando
46
+ * la consola puede acabar de volver en cocido sin que Node se entere.
47
+ */
48
+ export declare function useTecladoSiempreNuestro(esperando: boolean): void;
@@ -0,0 +1,96 @@
1
+ import { useEffect } from "react";
2
+ import process from "node:process";
3
+ import { MS_ENTRE_COMPROBACIONES, reafirmarModoCrudo, repararTeclado, } from "../keyboardGuard.js";
4
+ import { terminalPrestado } from "./prestamo.js";
5
+ /**
6
+ * EL TECLADO SIGUE SIENDO NUESTRO, SE COMPRUEBE CUANDO SE COMPRUEBE
7
+ *
8
+ * EL FALLO, CON SUS PALABRAS
9
+ *
10
+ * "al poner un / y poner algo luego se queda trabado y ya no se puede escribir a
11
+ * menos que pongas enter". Y antes: "al cambiar de potencia se queda pegado y no
12
+ * deja escribir, tengo que presionar enter". Y antes: "escribí 2 prompts y al
13
+ * siguiente ya no me deja escribir nada, teclee lo que teclee".
14
+ *
15
+ * Es siempre el mismo, y el "se arregla con Enter" es la firma que lo delata: en
16
+ * modo cocido la consola guarda lo que tecleas y no lo suelta hasta el Enter. O
17
+ * sea que el teclado no está muerto — está en manos del terminal, y quien hace
18
+ * el eco es PowerShell, no la caja.
19
+ *
20
+ * POR QUE VUELVE UNA Y OTRA VEZ
21
+ *
22
+ * Porque hay muchos sitios que apagan el modo crudo, y todos con razón: un
23
+ * `InterruptWatcher` que se para tarde y restaura el estado anterior, un
24
+ * `readline` que al cerrarse hace `pause()` y `setRawMode(false)`, un proceso
25
+ * hijo que hereda la consola y la devuelve en cocido. Basta con que UNA de las
26
+ * veinte salidas se escape.
27
+ *
28
+ * Y HAY UNA FORMA QUE NODE NI SIQUIERA VE
29
+ *
30
+ * Cuando el que la cambia es un proceso hijo, `process.stdin.isRaw` se queda
31
+ * diciendo `true`. Por debajo, libuv guarda el modo que cree tener y empieza con
32
+ * `if (tty->mode == mode) return 0;`: a partir de ahí todos los `setRawMode(true)`
33
+ * son un no-op. Node cree que manda y no manda. Por eso hay una reafirmación
34
+ * —pasar por `false` y volver— que sí rompe esa caché.
35
+ *
36
+ * POR QUE ESTE ARCHIVO ES NUEVO
37
+ *
38
+ * El vigilante existía y funcionaba, pero vivía DENTRO del prompt clásico
39
+ * (ui/prompt.ts), que se abría y se cerraba en cada vuelta. Al mudarse a Ink y
40
+ * después al árbol único, el prompt dejó de abrirse y cerrarse... y el vigilante
41
+ * se quedó atrás. El fallo volvió, y volvió idéntico.
42
+ *
43
+ * Aquí vive mientras viva el árbol, que es toda la sesión.
44
+ */
45
+ /**
46
+ * @param esperando ¿está la caja abierta esperando una orden?
47
+ *
48
+ * Se usa para reafirmar en el momento exacto en que se abre. Ese instante es el
49
+ * que sigue a un comando de barra, a un menú o a un proceso hijo: justo cuando
50
+ * la consola puede acabar de volver en cocido sin que Node se entere.
51
+ */
52
+ export function useTecladoSiempreNuestro(esperando) {
53
+ // Al abrir la caja se reafirma SIEMPRE, sin preguntar. `repararTeclado` solo
54
+ // actúa cuando SABE que se perdió el modo crudo, y este es justo el caso en el
55
+ // que no lo sabe.
56
+ useEffect(() => {
57
+ if (!esperando)
58
+ return;
59
+ if (terminalPrestado())
60
+ return;
61
+ reafirmarModoCrudo(process.stdin);
62
+ avisar(`reafirmado al abrir la caja (isRaw decia ${process.stdin.isRaw})`);
63
+ }, [esperando]);
64
+ // Y cuatro veces por segundo, mientras el árbol viva, se comprueba que sigue
65
+ // siendo nuestro. No se reafirma en cada vuelta a propósito: pasar por `false`
66
+ // y volver cuatro veces por segundo es pedirle problemas a la consola por un
67
+ // caso raro. Aquí solo se repara lo que se ve roto.
68
+ useEffect(() => {
69
+ const vigilante = setInterval(() => {
70
+ // Mientras un diálogo tiene el terminal, el modo crudo es SUYO: tocarlo
71
+ // por detrás le quitaría las teclas justo cuando espera una respuesta.
72
+ if (terminalPrestado())
73
+ return;
74
+ const reparado = repararTeclado(process.stdin);
75
+ if (reparado.length > 0)
76
+ avisar(`recuperado: ${reparado.join(" y ")}`);
77
+ }, MS_ENTRE_COMPROBACIONES);
78
+ // Un temporizador de vigilancia no es motivo para que el proceso siga vivo.
79
+ vigilante.unref?.();
80
+ return () => clearInterval(vigilante);
81
+ }, []);
82
+ }
83
+ /**
84
+ * Se calla salvo que se pida verlo.
85
+ *
86
+ * Si esto llega a decir algo es que hay un fallo de verdad en quien soltó el
87
+ * teclado, y saber cuál de las veinte salidas fue vale más que el mensaje.
88
+ */
89
+ function avisar(texto) {
90
+ if (process.env.CHOCOLATITO_TECLADO !== "debug")
91
+ return;
92
+ try {
93
+ process.stderr.write(`\n[teclado] ${texto}\n`);
94
+ }
95
+ catch { }
96
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * LA CONVERSACIÓN, EN CAMINO AL HISTORIAL DEL TERMINAL
3
+ *
4
+ * QUÉ SUSTITUYE, Y POR QUÉ
5
+ *
6
+ * Antes esto era `historial.ts`: un buffer en memoria con las últimas 5000
7
+ * líneas, que hacía falta porque la pantalla alternativa deja al terminal sin
8
+ * historial propio. Aquí no hay pantalla alternativa. Lo que se escribe entra en
9
+ * el historial DEL TERMINAL —el de verdad, el de la rueda del ratón— a través de
10
+ * `<Static>` de Ink, y esto es solo la cinta transportadora que lo lleva hasta
11
+ * allí.
12
+ *
13
+ * POR QUÉ HAY QUE PARTIR EN LÍNEAS
14
+ *
15
+ * `<Static>` emite un elemento y lo da por escrito para siempre: una vez fuera,
16
+ * ni Ink ni nadie puede volver a tocarlo, porque ya es texto del terminal. Así
17
+ * que solo puede entrar lo que está terminado. El modelo, en cambio, escribe a
18
+ * chorros que cortan a mitad de palabra.
19
+ *
20
+ * Aquí se separan las dos cosas: lo que ya tiene su salto de línea pasa a
21
+ * `<Static>` y se vuelve permanente; el trozo que cuelga se queda en `resto` y
22
+ * lo pinta la zona de abajo, que sí se repinta. Cuando llega el salto, el resto
23
+ * entero cruza al otro lado. Visto desde fuera es una línea que se va
24
+ * escribiendo, sin saltos ni parpadeos.
25
+ *
26
+ * LAS DOS REGLAS DE <STATIC>, QUE NO SON NEGOCIABLES
27
+ *
28
+ * Están medidas en `node_modules/ink/build/components/Static.js:11-20`, que es
29
+ * todo el mecanismo: guarda un índice, renderiza `items.slice(indice)` y en el
30
+ * commit siguiente adelanta el índice a `items.length`. No compara nada.
31
+ *
32
+ * 1. La lista SOLO crece. Un `splice`, un `filter` o un vaciado hacen retroceder
33
+ * el índice, y todo lo que se añada por encima se REIMPRIME: la conversación
34
+ * sale duplicada en el historial del terminal y ya no hay forma de borrarla.
35
+ * 2. Cada elemento necesita `key` propia y estable. React reutiliza nodos por
36
+ * posición, y la lista que ve cambia de longitud en cada commit; sin `key`
37
+ * acabas emitiendo el texto de otro. Por eso cada línea lleva su `id`.
38
+ *
39
+ * Y POR QUÉ EXISTE `base`
40
+ *
41
+ * Cuando el árbol se desmonta y se vuelve a montar —un diálogo de permisos que
42
+ * necesita el teclado para él solo— el `<Static>` nuevo empieza de cero y
43
+ * volvería a escribir la conversación entera. Pero en la pantalla normal esas
44
+ * líneas siguen ahí, en el terminal, que no se ha borrado. `base` marca por
45
+ * dónde iba, y el montaje nuevo solo recibe lo que aún no se ha pintado.
46
+ */
47
+ export interface LineaPermanente {
48
+ /** Estable y única. Es la `key` que <Static> necesita. */
49
+ id: number;
50
+ texto: string;
51
+ }
52
+ /**
53
+ * Entra texto del agente. Acepta trozos a media línea, que es lo normal.
54
+ *
55
+ * El `\r` suelto se descarta con su línea: en un flujo de markdown no es un
56
+ * retorno de carro de verdad, viene de un `\r\n` partido por la mitad entre dos
57
+ * trozos, y dejarlo pasar mete una línea vacía por cada salto.
58
+ */
59
+ export declare function anotar(texto: string): void;
60
+ /**
61
+ * Las líneas que <Static> todavía no ha escrito.
62
+ *
63
+ * DEVUELVE UN ARRAY NUEVO CADA VEZ QUE HAY ALGO NUEVO, Y NO ES UN CAPRICHO
64
+ *
65
+ * Este fue el fallo que dejó la conversación entera sin escribirse, y costó
66
+ * encontrarlo porque no parecía un fallo: la primera versión devolvía el array
67
+ * interno tal cual, al que `anotar` le hace `push`.
68
+ *
69
+ * <Static> memoriza lo que va a emitir con `useMemo(() => items.slice(index),
70
+ * [items, index])` (Static.js:12-14). Con la MISMA referencia de array y el
71
+ * mismo índice, React da por buena la memoria y devuelve lo de antes: el `[]`
72
+ * del primer dibujado. El array crecía, la pantalla no. Nada de emitir de más
73
+ * ni de menos: sencillamente no se emitía NUNCA.
74
+ *
75
+ * Así que la referencia tiene que cambiar cuando cambia el contenido. Se
76
+ * reconstruye solo cuando ha crecido de verdad —una vez por línea terminada, no
77
+ * una por dibujado—, que a treinta fotogramas por segundo es la diferencia entre
78
+ * reconstruirlo treinta veces por segundo o ninguna mientras nadie escribe.
79
+ */
80
+ export declare function pendientes(): LineaPermanente[];
81
+ /** El trozo que cuelga, el que pinta la zona de abajo. */
82
+ export declare function colgando(): string;
83
+ /**
84
+ * "Lo de hasta aquí ya está en el terminal."
85
+ *
86
+ * Se llama SOLO justo antes de desmontar el árbol. Llamarlo con el árbol vivo
87
+ * encogería la lista que ve <Static>, que es la regla 1 de arriba.
88
+ */
89
+ export declare function darPorPintado(): void;
90
+ export declare function alCambiar(cb: () => void): () => void;
91
+ /** Para las pruebas. En producción la conversación no se tira nunca. */
92
+ export declare function vaciarTranscripcion(): void;
93
+ /** Cuántas líneas terminadas van. Para las pruebas y para medir. */
94
+ export declare function cuantasLineas(): number;
95
+ /**
96
+ * Recorta el trozo que cuelga para que quepa en las filas que le tocan.
97
+ *
98
+ * NO ES COSMÉTICO, ES LA RED DE SEGURIDAD DEL RENDIMIENTO
99
+ *
100
+ * En Windows, si el bloque de abajo llega a medir lo que la ventana, Ink deja de
101
+ * repintar por diferencias y pasa a `clearTerminal` + reimprimir la conversación
102
+ * ENTERA en cada fotograma (ink.js:100-102 y 769). Con el spinner a 30 por
103
+ * segundo eso es parpadeo, historial perdido y coste creciente con la sesión.
104
+ *
105
+ * El caso que lo dispara: un párrafo largo sin un solo salto de línea. Mientras
106
+ * no lo termine, ese párrafo entero es "lo que cuelga".
107
+ *
108
+ * Cuando hay que recortar se quitan también los colores: cortar una cadena por
109
+ * el medio parte los códigos de escape y lo que sale es basura de colores
110
+ * enganchada al resto de la sesión. Es un trozo que en cuanto llegue su salto de
111
+ * línea se reemplaza por la línea entera y bien pintada, así que el recorte no
112
+ * dura ni un parpadeo.
113
+ */
114
+ export declare function recortarCola(texto: string, cols: number, filas: number): string[];